refactor: remove the managed shared Provider capability

Each Agent CLI now runs only with its own native credentials. The central
path of configuring one provider inside Meldwork for every Agent added
instability without being realistic to maintain, and it depended on
machine-local configuration details.

- Delete the provider store/options modules, the local-agent-provider IPC
  handlers and preload API, per-CLI env injection (CODEBUDDY_*/ANTHROPIC_*/
  KIMI_* ...), the managed OpenClaw provider runtime, and shared-provider
  catalog gating; startup removes the legacy encrypted
  meldwork-provider.json.
- OpenClaw always resolves its native runtime now; native credential
  passthrough, the isolated gateway/guard runtime, and the
  runtime-auth-failure state machine stay unchanged.
- Remove the Provider settings UI, onboarding slide, providerMode display,
  ~100 i18n keys per language, provider CSS, and update the architecture,
  security, and discoverability docs to the native-credentials boundary.

Checks: npm --prefix desktop test (1512 pass), npm --prefix frontend test
(325 pass), frontend build and build:desktop, desktop pack.
This commit is contained in:
Ryder Sun committed 2026-10-14 16:59:21 +08:00
1 parent 22f811cde2
commit eac015dbfa
81 files changed
+265 -5165

No files matched your search

+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 凭据对应的模型服务商。工作区写入是可选的工作流控制,不是操作系统级沙箱。
## 文档
+3 -3
View File
@@ -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
+5 -8
View File
@@ -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`).
+1 -1
View File
@@ -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'],
-1
View File
@@ -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,
@@ -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,
@@ -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,
@@ -632,8 +632,6 @@ class CloudAgentBridgeStore {
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: 0,
custom: false,
connector: true,
@@ -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,
@@ -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: '',
@@ -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) {
+1 -3
View File
@@ -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
+17 -44
View File
@@ -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'),
})
-7
View File
@@ -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),
-1
View File
@@ -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'],
])
-184
View File
@@ -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,
}
-382
View File
@@ -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 }
-35
View File
@@ -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?.()
@@ -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)
@@ -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(),
@@ -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',
])
})
+21 -21
View File
@@ -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' },
@@ -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)
})
@@ -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',
@@ -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)
+57 -130
View File
@@ -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' })
})
@@ -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)
@@ -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)
@@ -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 {
+3 -8
View File
@@ -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)
@@ -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), {})
})
@@ -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://[email protected]/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' })
})
+6 -111
View File
@@ -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 }))
@@ -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(),
@@ -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,
@@ -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,
@@ -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 () => {
@@ -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 }))
+2 -2
View File
@@ -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 和本地数据边界。
## 契约与验证
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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。
+2 -2
View File
@@ -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 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

-33
View File
@@ -64,13 +64,11 @@
<HomeDashboard
v-else-if="activeView === 'home'"
:agent-description="agentDescription"
:configurable-provider-count="configurableProviderAgents.length"
:group-name="groupName"
:groups="snapshot.groups"
:installed-count="installedCount"
:is-group-running="isGroupRunning"
:onboarding-completed="onboardingCompleted"
:provider-configured-count="providerConfiguredCount"
:ready-agents="readyAgents"
:recent-group-meta="recentGroupMeta"
:refreshing="refreshing"
@@ -78,7 +76,6 @@
@open-agent-manager="openAgentManager"
@open-direct="openDirect"
@open-new-group="openNewGroup"
@open-provider="openProvider"
@refresh-agents="refreshAgents"
@select-group="selectGroup"
/>
@@ -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 = ''
@@ -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()
@@ -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')
+2 -84
View File
@@ -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()
@@ -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', () => {
@@ -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,
},
@@ -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,
)
@@ -50,7 +50,6 @@ describe('desktop bridge access', () => {
const bridge = {
localWorkspace: { get() {} },
agentInstaller: { catalog() {} },
localAgentProvider: { status() {} },
}
window.meldworkDesktop = bridge
@@ -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),
},
})
+1 -14
View File
@@ -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')))
@@ -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:{}')
})
})
+11 -13
View File
@@ -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) {
@@ -46,7 +46,6 @@
<span class="agent-capability-list">
<span v-if="agent.ready">{{ agentSkillLabel(agent.kind) }}</span>
<span>{{ agentImageLabel(agent) }}</span>
<span>{{ providerSummaryLabel(agent) }}</span>
</span>
</span>
<ChevronForwardOutline class="card-chevron" />
@@ -99,14 +98,6 @@
>
{{ t('installer.cancel') }}
</button>
<button
v-else-if="supportsExternalProvider(agent)"
type="button"
@click.stop="$emit('open-provider', agent.kind)"
>
<KeyOutline />
{{ t('systemSettings.providerForAgent') }}
</button>
</div>
</div>
</article>
@@ -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',
])
@@ -66,8 +66,6 @@
</template>
<span class="meta-separator">/</span>
<span>{{ agentImageLabel(activeDirectAgent) }}</span>
<span class="meta-separator">/</span>
<span>{{ providerModeShortLabel(activeDirectAgent.providerMode) }}</span>
</template>
</p>
</div>
@@ -160,7 +158,6 @@ const {
inlineTitleDraft,
inlineTitleEditing,
openGroupSettings,
providerModeShortLabel,
saveInlineTitle,
saving,
sending,
+1 -21
View File
@@ -126,14 +126,6 @@
<small>{{ t('setupGuide.detectBody') }}</small>
</span>
</li>
<li :class="{ complete: providerConfiguredCount > 0 || configurableProviderCount === 0 }">
<CheckmarkCircleOutline v-if="providerConfiguredCount > 0 || configurableProviderCount === 0" />
<KeyOutline v-else />
<span>
<strong>{{ t('setupGuide.providerTitle') }}</strong>
<small>{{ t('setupGuide.providerBody') }}</small>
</span>
</li>
<li :class="{ complete: readyCount > 0 }">
<CheckmarkCircleOutline v-if="readyCount > 0" />
<ChatbubblesOutline v-else />
@@ -148,10 +140,6 @@
<RefreshOutline :class="{ spinning: refreshing }" />
{{ refreshing ? t('home.refreshing') : t('home.refresh') }}
</button>
<button class="secondary-button" type="button" @click="$emit('open-provider')">
<KeyOutline />
{{ t('systemSettings.providers') }}
</button>
<button class="primary-button" type="button" @click="$emit('open-agent-manager')">
<SettingsOutline />
{{ t('home.openSettings') }}
@@ -168,7 +156,6 @@ import {
ChatbubblesOutline,
CheckmarkCircleOutline,
ChevronForwardOutline,
KeyOutline,
RefreshOutline,
SettingsOutline,
TerminalOutline,
@@ -179,13 +166,11 @@ import { t } from '../i18n.js'
const props = defineProps({
agentDescription: { type: Function, required: true },
configurableProviderCount: { type: Number, required: true },
groupName: { type: Function, required: true },
groups: { type: Array, required: true },
installedCount: { type: Number, required: true },
isGroupRunning: { type: Function, required: true },
onboardingCompleted: { type: Boolean, required: true },
providerConfiguredCount: { type: Number, required: true },
readyAgents: { type: Array, required: true },
recentGroupMeta: { type: Function, required: true },
refreshing: { type: Boolean, required: true },
@@ -196,7 +181,6 @@ defineEmits([
'open-agent-manager',
'open-direct',
'open-new-group',
'open-provider',
'refresh-agents',
'select-group',
])
@@ -224,9 +208,5 @@ const homeWorkspaceSummary = computed(() => (
conversations: props.groups.length,
})
))
const setupGuideMessage = computed(() => {
if (!props.installedCount) return t('setupGuide.detectBody')
if (props.configurableProviderCount && !props.providerConfiguredCount) return t('setupGuide.providerBody')
return t('setupGuide.detectBody')
})
const setupGuideMessage = computed(() => t('setupGuide.detectBody'))
</script>
@@ -1,279 +0,0 @@
<template>
<section class="settings-panel provider-settings-panel">
<header class="provider-page-header">
<div>
<h2>{{ t('provider.title') }}</h2>
<p>{{ t('provider.subtitle') }}</p>
</div>
<span class="provider-summary-count">
{{ t('provider.configuredCount', {
configured: provider.providerConfiguredCount,
total: provider.configurableProviderAgents.length,
}) }}
</span>
</header>
<nav class="provider-agent-list" :aria-label="t('systemSettings.providers')">
<button
v-for="agent in provider.configurableProviderAgents"
:key="agent.kind"
type="button"
:aria-pressed="provider.selectedProviderKind === agent.kind ? 'true' : 'false'"
:class="{ active: provider.selectedProviderKind === agent.kind }"
@click="provider.selectProviderAgent(agent.kind)"
>
<img :src="agent.logo" alt="" />
<span class="provider-agent-copy">
<strong>{{ agent.label }}</strong>
<small :class="provider.providerStatusTone(agent.kind)">{{ provider.providerStatusLabel(agent.kind) }}</small>
</span>
<component
:is="provider.providerStatusIcon(agent.kind)"
:class="{
ready: provider.providerReady(agent.kind),
spinning: provider.providerStatusIsChecking(agent.kind),
}"
/>
</button>
</nav>
<form
class="provider-editor form-stack"
:aria-busy="String(provider.providerStatusIsChecking(provider.selectedProviderKind))"
@submit.prevent="provider.saveProvider"
>
<header v-if="provider.selectedProviderAgent" class="provider-editor-header">
<img :src="provider.selectedProviderAgent.logo" alt="" />
<div class="provider-editor-title">
<h2>{{ t('provider.agentTitle', { agent: provider.selectedProviderAgent.label }) }}</h2>
<div class="provider-agent-state" :class="provider.selectedProviderAgentState.tone" role="status">
<component
:is="provider.selectedProviderAgentState.icon"
:class="{ spinning: provider.selectedProviderAgentState.id === 'checking' }"
/>
<span>
<strong>{{ provider.selectedProviderAgentState.label }}</strong>
<small>{{ provider.selectedProviderAgentState.detail }}</small>
</span>
</div>
</div>
</header>
<section class="provider-source-section" :aria-label="t('provider.source')">
<div class="provider-source-heading">
<span>{{ t('provider.source') }}</span>
</div>
<div class="provider-source-options">
<button
v-for="preset in provider.selectedProviderPresets"
:key="preset.id"
type="button"
:aria-pressed="provider.providerForm.preset === preset.id ? 'true' : 'false'"
:class="{ active: provider.providerForm.preset === preset.id }"
:disabled="saving"
:title="provider.providerPresetHint(preset.id)"
@click="provider.applyProviderPreset(preset.id)"
>
<strong>{{ provider.providerPresetLabel(preset.id) }}</strong>
<small :class="{
active: provider.providerPresetActive(preset.id),
configured: provider.providerPresetConfigured(preset.id),
}">
{{ provider.providerPresetStateLabel(preset.id) }}
</small>
</button>
</div>
</section>
<section v-if="provider.selectedProviderPreset" class="provider-source-detail">
<header class="provider-source-overview">
<TerminalOutline v-if="provider.providerNativeOfficialMode" />
<KeyOutline v-else />
<div>
<strong>{{ t('provider.sourceTitle', {
provider: provider.providerPresetLabel(provider.selectedProviderPreset.id),
}) }}</strong>
<p>{{ provider.providerPresetHint(provider.selectedProviderPreset.id) }}</p>
<small>{{ t('provider.agentSpecificHint') }}</small>
</div>
</header>
<div v-if="provider.providerStatus.error" class="provider-inline-warning" role="status">
<span>{{ t('provider.unavailable') }}</span>
<button
type="button"
:disabled="saving"
@click="provider.retryProviderStatus(provider.selectedProviderKind)"
>
<RefreshOutline />
{{ t('common.retry') }}
</button>
</div>
<p v-if="provider.providerStatus.encryptionAvailable === false" class="form-error">
{{ t('provider.encryptionUnavailable') }}
</p>
<template v-if="provider.providerNativeOfficialMode">
<div class="provider-native-card" :class="provider.selectedProviderAgentState.tone">
<TerminalOutline />
<div class="provider-native-card-content">
<strong>{{ t('provider.nativeTitle') }}</strong>
<p>{{ provider.providerNativeGuideBody }}</p>
<div v-if="provider.providerNativeActionVisible" class="provider-native-actions">
<button
class="secondary-button"
type="button"
:disabled="saving"
@click="$emit('open-agent-manager', provider.selectedProviderKind)"
>
<SettingsOutline />
{{ t('provider.manageAgent') }}
</button>
</div>
</div>
</div>
<div class="provider-doc-card">
<div>
<strong>{{ t('provider.configGuide') }}</strong>
<p>{{ t(provider.selectedProviderProfile.docsKey) }}</p>
</div>
<dl>
<div v-if="provider.selectedProviderProfile.configFile">
<dt>{{ t('provider.configFile') }}</dt>
<dd><code>{{ provider.selectedProviderProfile.configFile }}</code></dd>
</div>
<div v-if="provider.selectedProviderProfile.runtimeKeys.length">
<dt>{{ t('provider.runtimeKeys') }}</dt>
<dd>
<code v-for="key in provider.selectedProviderProfile.runtimeKeys" :key="key">{{ key }}</code>
</dd>
</div>
</dl>
</div>
</template>
<template v-else>
<div v-if="provider.selectedProviderProfileSaved" class="provider-profile-summary">
<span :class="{ active: provider.selectedProviderPresetActive }">
{{ provider.selectedProviderPresetActive ? t('provider.active') : t('provider.saved') }}
</span>
<span v-if="provider.selectedProviderProfileStatus?.provider">
{{ provider.selectedProviderProfileStatus.provider }}
</span>
<span v-if="provider.selectedProviderProfileStatus?.model">
{{ provider.selectedProviderProfileStatus.model }}
</span>
</div>
<p v-if="provider.providerIdentityLocked" class="provider-field-note">
{{ t('provider.identityLocked') }}
</p>
<div class="provider-external-fields">
<label class="provider-field-name">
<span>{{ t('provider.name') }}</span>
<input
v-model.trim="provider.providerForm.provider"
:placeholder="t('provider.namePlaceholder')"
autocomplete="off"
maxlength="80"
:readonly="provider.providerIdentityLocked"
:aria-readonly="provider.providerIdentityLocked ? 'true' : undefined"
:disabled="provider.providerFormControlsDisabled"
/>
</label>
<label class="provider-field-url">
<span>{{ t('provider.baseUrl') }}</span>
<input
v-model.trim="provider.providerForm.baseUrl"
:placeholder="t('provider.baseUrlPlaceholder')"
inputmode="url"
autocomplete="off"
maxlength="300"
:readonly="provider.providerIdentityLocked"
:aria-readonly="provider.providerIdentityLocked ? 'true' : undefined"
:disabled="provider.providerFormControlsDisabled"
/>
</label>
<label class="provider-field-model">
<span>{{ t('provider.model') }}</span>
<input
v-model.trim="provider.providerForm.model"
:placeholder="t('provider.modelPlaceholder')"
autocomplete="off"
maxlength="160"
:disabled="provider.providerFormControlsDisabled"
/>
</label>
<label class="provider-field-key">
<span>{{ t('provider.apiKey') }}</span>
<input
v-model="provider.providerForm.apiKey"
type="password"
:placeholder="t('provider.apiKeyPlaceholder')"
autocomplete="new-password"
maxlength="8192"
:disabled="provider.providerFormControlsDisabled"
/>
</label>
</div>
</template>
</section>
<p v-if="formError" class="form-error">{{ formError }}</p>
<footer class="provider-editor-footer">
<button
v-if="provider.selectedProviderProfileSaved"
class="danger-button"
type="button"
:disabled="saving"
@click="provider.removeProvider"
>
<TrashOutline />
{{ provider.providerRemoveArmed ? t('provider.removeConfirm') : t('provider.remove') }}
</button>
<span class="footer-spacer" />
<button
v-if="provider.selectedProviderPresetConfigured && !provider.selectedProviderPresetActive"
class="secondary-button provider-activate-button"
type="button"
:disabled="saving"
@click="provider.activateProviderPreset(provider.providerForm.preset)"
>
<CheckmarkCircleOutline />
{{ t('provider.useProfile') }}
</button>
<button
v-if="!provider.providerNativeOfficialMode"
class="primary-button"
type="submit"
:disabled="provider.providerFormControlsDisabled || provider.providerStatus.encryptionAvailable === false"
>
{{ provider.providerSaveActionLabel }}
</button>
</footer>
</form>
</section>
</template>
<script setup>
import { reactive } from 'vue'
import {
CheckmarkCircleOutline,
KeyOutline,
RefreshOutline,
SettingsOutline,
TerminalOutline,
TrashOutline,
} from '@vicons/ionicons5'
import { t } from '../i18n.js'
const props = defineProps({
controller: { type: Object, required: true },
formError: { type: String, required: true },
saving: { type: Boolean, required: true },
})
defineEmits(['open-agent-manager'])
const provider = reactive(props.controller)
</script>
+1 -30
View File
@@ -29,16 +29,6 @@
<CloudOutline />
{{ t('systemSettings.cloudServers') }}
</button>
<button
type="button"
role="tab"
:aria-selected="systemSettingsSection === 'providers'"
:class="{ active: systemSettingsSection === 'providers' }"
@click="selectSystemSettingsSection('providers')"
>
<KeyOutline />
{{ t('systemSettings.providers') }}
</button>
<button
type="button"
role="tab"
@@ -65,14 +55,11 @@
:installer-phase-label="installerPhaseLabel"
:installer-state="installerState"
:is-direct-creation-pending="isDirectCreationPending"
:provider-summary-label="providerSummaryLabel"
:ready-count="readyCount"
:refreshing="refreshing"
:supports-external-provider="supportsExternalProvider"
@cancel-install="cancelInstall"
@open-agent-detail="openAgentDetail"
@open-direct="openDirect"
@open-provider="openProvider"
@refresh-agents="refreshAgents"
@request-install="requestInstall"
/>
@@ -98,24 +85,15 @@
@primary-action="runKnowledgeBasePrimaryAction"
@refresh="loadKnowledgeBaseStatuses"
/>
<ProviderSettingsPanel
v-else-if="systemSettingsSection === 'providers'"
:controller="providerSettings"
:form-error="formError"
:saving="saving"
@open-agent-manager="openAgentManager"
/>
</div>
</section>
</template>
<script setup>
import { CloudOutline, KeyOutline, LibraryOutline, TerminalOutline } from '@vicons/ionicons5'
import { CloudOutline, LibraryOutline, TerminalOutline } from '@vicons/ionicons5'
import AgentSettingsPanel from './AgentSettingsPanel.vue'
import CloudAgentBridgePanel from './CloudAgentBridgePanel.vue'
import KnowledgeBaseSettingsPanel from './KnowledgeBaseSettingsPanel.vue'
import ProviderSettingsPanel from './ProviderSettingsPanel.vue'
const props = defineProps({
controller: { type: Object, required: true },
@@ -130,7 +108,6 @@ const {
cancelInstall,
cloudAgentBridges,
focusedAgentKind,
formError,
installConfirmKind,
installedCount,
installerBusy,
@@ -148,21 +125,15 @@ const {
loadKnowledgeBaseStatuses,
localKnowledgeBaseEntries,
openAgentDetail,
openAgentManager,
openDirect,
openProvider,
plannedKnowledgeBaseEntries,
providerSettings,
providerSummaryLabel,
readyCount,
readyKnowledgeBaseCount,
refreshAgents,
refreshing,
requestInstall,
runKnowledgeBasePrimaryAction,
saving,
selectSystemSettingsSection,
supportsExternalProvider,
systemSettingsSection,
t,
} = props.controller
@@ -260,17 +260,6 @@
<strong>{{ t('agentDetail.skills') }}</strong>
<small>{{ agentDetailSkillSummary }}</small>
</span>
<span>
<strong>{{ selectedAgentDetail.cloud ? t('agentDetail.location') : t('agentDetail.provider') }}</strong>
<small>
{{ selectedAgentDetail.cloud
? t('agentDetail.cloudLocation', {
server: selectedAgentDetail.serverLabel,
version: selectedAgentDetail.version,
})
: providerSummaryLabel(selectedAgentDetail) }}
</small>
</span>
<span>
<strong>{{ t('agentDetail.images') }}</strong>
<small>{{ agentImageLabel(selectedAgentDetail) }}</small>
@@ -321,15 +310,6 @@
{{ customAgentDeleteArmed ? t('customAgent.deleteConfirm') : t('customAgent.delete') }}
</button>
<button class="secondary-button" type="button" @click="closeModal">{{ t('common.close') }}</button>
<button
v-if="supportsExternalProvider(selectedAgentDetail)"
class="secondary-button"
type="button"
@click="openProvider(selectedAgentDetail.kind)"
>
<KeyOutline />
{{ t('systemSettings.providerForAgent') }}
</button>
<button
v-if="selectedAgentDetail.ready"
class="primary-button"
@@ -362,7 +342,6 @@ import {
CloudOutline,
DownloadOutline,
FolderOpenOutline,
KeyOutline,
TrashOutline,
WarningOutline,
} from '@vicons/ionicons5'
@@ -399,9 +378,7 @@ const {
isDirectCreationPending,
modal,
openDirect,
openProvider,
pickGroupDirectory,
providerSummaryLabel,
readyAgentGroups,
requestDeleteConfirmation,
requestInstall,
@@ -410,7 +387,6 @@ const {
selectedAgentDetail,
setAgentSidebarVisibility,
settingsForm,
supportsExternalProvider,
t,
} = props.controller
@@ -28,7 +28,6 @@ export function useAgentCatalog({
sourceKind: agent.sourceKind,
label: agent.label || agent.name || agent.kind,
logo: agent.logo,
providerMode: agent.providerMode || (agent.custom ? 'custom' : 'connector'),
imageLimit: Number(agent.imageAttachmentLimit || 0),
custom: agent.custom === true,
connector: agent.connector === true,
@@ -48,7 +47,6 @@ export function useAgentCatalog({
detected.sourceKind || installedProfile.sourceKind || profile.sourceKind || profile.kind,
theme.value,
),
providerMode: profile.providerMode,
imageLimit: profile.custom
? MAX_ATTACHMENTS
: Number(detected.imageAttachmentLimit ?? installedProfile.imageAttachmentLimit ?? profile.imageLimit) || 0,
@@ -118,10 +116,6 @@ export function useAgentCatalog({
return mergedCatalog.value.find(agent => agent.kind === activeGroup.value.directAgentKind) || null
})
function providerModeShortLabel(mode) {
return t(`agent.providerShort.${mode}`)
}
function agentDescription(kind) {
const profile = mergedCatalog.value.find(agent => agent.kind === kind)
if (profile?.connector && profile.description) return profile.description
@@ -158,9 +152,6 @@ export function useAgentCatalog({
})[agent.incompatibilityReason] || 'agent.incompatible'
return { label: t(reasonKey), tone: 'warning', icon: WarningOutline }
}
if (agent.availabilitySource === 'provider-credential-unavailable') {
return { label: t('agent.providerCredentialUnavailable'), tone: 'warning', icon: WarningOutline }
}
if (agent.credentialState === 'missing') return { label: t('agent.needsLogin'), tone: 'warning', icon: WarningOutline }
return { label: t('agent.unverified'), tone: 'neutral', icon: WarningOutline }
}
@@ -174,7 +165,6 @@ export function useAgentCatalog({
agentState,
installedCount,
mergedCatalog,
providerModeShortLabel,
readyAgentKinds,
readyAgentGroups,
readyAgentSignature,
@@ -25,7 +25,6 @@ export function useDesktopWorkspaceLifecycle({
handleRunFinished,
installer,
installerState,
provider,
refreshAgents,
showError,
snapshot,
@@ -54,7 +53,7 @@ export function useDesktopWorkspaceLifecycle({
async function boot() {
beforeBoot()
if (!workspace.value || !installer.value || !provider.value) {
if (!workspace.value || !installer.value) {
bridgeMissing.value = true
booting.value = false
return
@@ -24,11 +24,6 @@ export function useOnboarding({ refreshAgents }) {
title: t('onboarding.discoverTitle'),
body: t('onboarding.discoverBody'),
},
{
image: publicAsset('onboarding/provider-setup-v2.png'),
title: t('onboarding.providerTitle'),
body: t('onboarding.providerBody'),
},
{
image: publicAsset('onboarding/agent-collaboration.png'),
title: t('onboarding.collaborationTitle'),
@@ -1,421 +0,0 @@
import { computed, reactive, ref } from 'vue'
import { AGENTS } from '../catalog.js'
import { t, translateError } from '../i18n.js'
import { inferProviderPreset, providerProfile } from '../providerProfiles.js'
import {
EMPTY_PROVIDER_STATUS,
hasProviderStatus as hasProviderStatusIn,
nativeProviderReady,
providerActiveSourceFor as providerActiveSourceFrom,
providerAgentState as buildProviderAgentState,
providerProfilesFor as providerProfilesFrom,
providerStatusFor as providerStatusFrom,
providerSummaryLabel as buildProviderSummaryLabel,
supportsExternalProvider,
} from '../providerSettingsModel.js'
export function useProviderSettings({ agents, provider, saving, formError, refreshAgents }) {
const providerStatuses = ref({})
const providerStatusLoadingKinds = ref(new Set())
const selectedProviderKind = ref(AGENTS[0]?.kind || 'codex')
const providerRemoveArmed = ref(false)
const providerForm = reactive({ preset: 'official', provider: '', baseUrl: '', model: '', apiKey: '' })
const providerStatusRequestTokens = new Map()
let providerStatusRequestSequence = 0
const configurableProviderAgents = computed(() => agents.value.filter(agent => (
supportsExternalProvider(agent)
)))
function providerStatusFor(kind) {
return providerStatusFrom(providerStatuses.value, kind)
}
function providerProfilesFor(kind) {
return providerProfilesFrom(providerStatuses.value, kind)
}
function hasProviderStatus(kind) {
return hasProviderStatusIn(providerStatuses.value, kind)
}
function setProviderStatusPending(kind, pending) {
const targetKind = String(kind || '')
const next = new Set(providerStatusLoadingKinds.value)
if (pending) next.add(targetKind)
else next.delete(targetKind)
providerStatusLoadingKinds.value = next
}
function providerStatusIsChecking(kind) {
const targetKind = String(kind || '')
return providerStatusLoadingKinds.value.has(targetKind) || !hasProviderStatus(targetKind)
}
function providerActiveSourceFor(kind) {
return providerActiveSourceFrom(configurableProviderAgents.value, providerStatuses.value, kind)
}
function providerPresetLabel(id) {
return t(`provider.preset.${id}`)
}
function providerAgentState(kind) {
return buildProviderAgentState({
agents: configurableProviderAgents.value,
checking: providerStatusIsChecking(kind),
kind,
statuses: providerStatuses.value,
t,
})
}
function providerReady(kind) {
return ['active-override', 'native-ready'].includes(providerAgentState(kind).id)
}
function providerStatusLabel(kind) {
return providerAgentState(kind).label
}
function providerStatusTone(kind) {
return providerAgentState(kind).tone
}
function providerStatusIcon(kind) {
return providerAgentState(kind).icon
}
function providerSummaryLabel(agent) {
return buildProviderSummaryLabel({ agent, statuses: providerStatuses.value, t })
}
function providerPresetHint(id) {
return t(`provider.presetHint.${id}`)
}
function providerPresetSaved(presetId) {
return Boolean(providerProfilesFor(selectedProviderKind.value)[presetId]?.configured)
}
function providerPresetConfigured(presetId) {
if (providerPresetSaved(presetId)) return true
return presetId === 'official'
&& hasProviderStatus(selectedProviderKind.value)
&& !providerStatus.value.error
&& nativeProviderReady(selectedProviderAgent.value)
}
function providerPresetActive(presetId) {
return providerActiveSource.value === presetId
}
function providerPresetStateLabel(presetId) {
if (providerStatusIsChecking(selectedProviderKind.value) && !hasProviderStatus(selectedProviderKind.value)) {
return t('provider.checking')
}
if (providerStatus.value.error) return t('provider.unavailable')
if (providerPresetActive(presetId)) return t('provider.active')
if (providerPresetSaved(presetId)) return t('provider.saved')
if (presetId === 'official') {
if (!selectedProviderAgent.value?.installed) return t('provider.state.notInstalled')
if (selectedProviderAgent.value?.credentialState === 'missing') return t('provider.state.loginRequired')
if (nativeProviderReady(selectedProviderAgent.value)) return t('provider.nativeAvailable')
return t('provider.state.unverified')
}
return t('provider.notConfigured')
}
function providerPresetFor(kind, presetId) {
const profile = providerProfile(kind)
return profile.presets.find(preset => preset.id === presetId) || profile.presets[0] || null
}
function fillProviderFormFromPreset(kind, presetId) {
const preset = providerPresetFor(kind, presetId)
if (!preset) return
const saved = providerProfilesFor(kind)[preset.id]
const lockedIdentity = preset.id !== 'custom'
providerForm.preset = preset.id
providerForm.provider = lockedIdentity ? (preset.provider || '') : (saved?.provider || preset.provider || '')
providerForm.baseUrl = lockedIdentity ? (preset.baseUrl || '') : (saved?.baseUrl || preset.baseUrl || '')
providerForm.model = saved?.model || preset.model || ''
}
function applyProviderPreset(presetId) {
const changed = providerForm.preset !== presetId
formError.value = ''
providerRemoveArmed.value = false
fillProviderFormFromPreset(selectedProviderKind.value, presetId)
if (changed) providerForm.apiKey = ''
}
function syncProviderForm(kind) {
const status = providerStatusFor(kind)
const preset = status.activePreset || inferProviderPreset(kind, status)
fillProviderFormFromPreset(kind, preset)
if (status.configured && !providerProfilesFor(kind)[preset]) {
providerForm.provider = status.provider || providerForm.provider
providerForm.baseUrl = status.baseUrl || providerForm.baseUrl
providerForm.model = status.model || providerForm.model
}
providerForm.apiKey = ''
}
async function loadProviderStatus(kind, { probeEncryption = false, trackPending = true } = {}) {
if (!provider.value || !supportsExternalProvider({ kind })) {
return { status: EMPTY_PROVIDER_STATUS, applied: false }
}
const requestToken = ++providerStatusRequestSequence
providerStatusRequestTokens.set(kind, requestToken)
if (trackPending) setProviderStatusPending(kind, true)
try {
const status = await (probeEncryption ? provider.value.probe(kind) : provider.value.status(kind))
const applied = providerStatusRequestTokens.get(kind) === requestToken
if (applied) providerStatuses.value = { ...providerStatuses.value, [kind]: status }
return { status: applied ? status : providerStatusFor(kind), applied }
} catch {
const unavailable = { ...EMPTY_PROVIDER_STATUS, error: true }
const applied = providerStatusRequestTokens.get(kind) === requestToken
if (applied) providerStatuses.value = { ...providerStatuses.value, [kind]: unavailable }
return { status: applied ? unavailable : providerStatusFor(kind), applied }
} finally {
if (trackPending && providerStatusRequestTokens.get(kind) === requestToken) {
setProviderStatusPending(kind, false)
}
}
}
async function retryProviderStatus(kind) {
const targetKind = String(kind || '')
if (!supportsExternalProvider({ kind: targetKind }) || saving.value) {
return { status: EMPTY_PROVIDER_STATUS, applied: false }
}
const nextStatuses = { ...providerStatuses.value }
delete nextStatuses[targetKind]
providerStatuses.value = nextStatuses
const result = await loadProviderStatus(targetKind, { probeEncryption: true })
if (result.applied && selectedProviderKind.value === targetKind && !result.status.error) {
syncProviderForm(targetKind)
}
return result
}
async function loadProviderWorkspace(targetKind = '', { probeSelected = false } = {}) {
const selectedKind = supportsExternalProvider({ kind: targetKind }) ? targetKind : ''
if (selectedKind) {
selectedProviderKind.value = selectedKind
formError.value = ''
providerRemoveArmed.value = false
syncProviderForm(selectedKind)
}
const providerAgents = configurableProviderAgents.value
const results = await Promise.all(providerAgents.map(agent => loadProviderStatus(agent.kind, {
probeEncryption: probeSelected && agent.kind === selectedKind,
trackPending: Boolean(selectedKind && agent.kind === selectedKind),
})))
const selectedResult = results[providerAgents.findIndex(agent => agent.kind === selectedKind)]
if (selectedKind
&& selectedProviderKind.value === selectedKind
&& selectedResult?.applied
&& !selectedResult.status.error) {
syncProviderForm(selectedKind)
}
return results
}
async function selectProviderAgent(kind) {
if (!supportsExternalProvider({ kind }) || saving.value) return
selectedProviderKind.value = kind
formError.value = ''
providerRemoveArmed.value = false
syncProviderForm(kind)
const result = await loadProviderStatus(kind, { probeEncryption: true })
if (result.applied && selectedProviderKind.value === kind && !result.status.error) syncProviderForm(kind)
}
async function activateProviderPreset(presetId) {
if (saving.value || providerPresetActive(presetId) || !providerPresetConfigured(presetId)) return
if (typeof provider.value?.activate !== 'function') {
formError.value = t('provider.switchUnavailable')
return
}
saving.value = true
formError.value = ''
try {
const kind = selectedProviderKind.value
await provider.value.activate(kind, presetId)
const [result] = await Promise.all([
loadProviderStatus(kind, { probeEncryption: true }),
refreshAgents(),
])
if (result.applied) syncProviderForm(kind)
} catch (error) {
formError.value = translateError(error)
} finally {
saving.value = false
}
}
async function saveProvider() {
formError.value = ''
if (providerNativeOfficialMode.value) return
if (!providerForm.provider || !providerForm.baseUrl || !providerForm.model) {
formError.value = t('provider.requiredFields')
return
}
if (!providerForm.apiKey) {
formError.value = t('provider.keyRequired')
return
}
saving.value = true
try {
const kind = selectedProviderKind.value
await provider.value.save(kind, {
preset: providerForm.preset,
provider: providerForm.provider,
baseUrl: providerForm.baseUrl,
model: providerForm.model,
apiKey: providerForm.apiKey,
})
providerForm.apiKey = ''
const [result] = await Promise.all([
loadProviderStatus(kind, { probeEncryption: true }),
refreshAgents(),
])
if (result.applied) syncProviderForm(kind)
} catch (error) {
formError.value = translateError(error)
} finally {
saving.value = false
}
}
async function removeProvider() {
if (saving.value) return
if (!providerRemoveArmed.value) {
providerRemoveArmed.value = true
return
}
saving.value = true
try {
const kind = selectedProviderKind.value
const preset = providerForm.preset
await provider.value.delete(kind, preset)
const [result] = await Promise.all([
loadProviderStatus(kind, { probeEncryption: true }),
refreshAgents(),
])
providerRemoveArmed.value = false
if (result.applied) fillProviderFormFromPreset(kind, preset)
providerForm.apiKey = ''
} catch (error) {
formError.value = translateError(error)
} finally {
saving.value = false
}
}
const providerConfiguredCount = computed(() => configurableProviderAgents.value.filter(
agent => providerReady(agent.kind),
).length)
const selectedProviderAgent = computed(() => configurableProviderAgents.value.find(
agent => agent.kind === selectedProviderKind.value,
) || configurableProviderAgents.value[0] || null)
const providerStatus = computed(() => providerStatusFor(selectedProviderKind.value))
const selectedProviderProfile = computed(() => providerProfile(selectedProviderKind.value))
const selectedProviderPresets = computed(() => selectedProviderProfile.value.presets || [])
const selectedProviderPreset = computed(() => (
selectedProviderPresets.value.find(preset => preset.id === providerForm.preset)
|| selectedProviderPresets.value[0]
|| null
))
const selectedProviderAgentState = computed(() => providerAgentState(selectedProviderKind.value))
const providerActiveSource = computed(() => providerActiveSourceFor(selectedProviderKind.value))
const selectedProviderProfileStatus = computed(() => (
providerProfilesFor(selectedProviderKind.value)[providerForm.preset] || null
))
const selectedProviderProfileSaved = computed(() => Boolean(selectedProviderProfileStatus.value?.configured))
const selectedProviderPresetActive = computed(() => providerActiveSource.value === providerForm.preset)
const selectedProviderPresetConfigured = computed(() => providerPresetConfigured(providerForm.preset))
const providerNativeOfficialMode = computed(() => (
providerForm.preset === 'official'
&& !selectedProviderProfileSaved.value
&& (
nativeProviderReady(selectedProviderAgent.value)
|| !String(selectedProviderPreset.value?.baseUrl || '').trim()
)
))
const providerIdentityLocked = computed(() => providerForm.preset !== 'custom')
const providerFormControlsDisabled = computed(() => (
saving.value
|| providerStatus.value.error
|| (providerStatusIsChecking(selectedProviderKind.value) && !hasProviderStatus(selectedProviderKind.value))
))
const providerSaveActionLabel = computed(() => {
if (saving.value) return t('common.saving')
if (!selectedProviderProfileSaved.value) return t('provider.saveAndUse')
return selectedProviderPresetActive.value
? t('provider.updateCredentials')
: t('provider.updateAndUse')
})
const providerNativeGuideBody = computed(() => {
const state = selectedProviderAgentState.value
const agent = selectedProviderAgent.value
if (state.id === 'checking') return t('provider.nativeStatusCheckingBody')
if (state.id === 'unavailable') return t('provider.nativeStatusErrorBody')
if (nativeProviderReady(agent)) {
return selectedProviderPresetActive.value
? t('provider.nativeBody')
: t('provider.nativeAvailableBody')
}
return state.detail
})
const providerNativeActionVisible = computed(() => (
providerNativeOfficialMode.value
&& ['not-installed', 'login-required', 'unverified'].includes(selectedProviderAgentState.value.id)
))
return {
activateProviderPreset,
applyProviderPreset,
configurableProviderAgents,
loadProviderWorkspace,
providerConfiguredCount,
providerForm,
providerFormControlsDisabled,
providerIdentityLocked,
providerNativeActionVisible,
providerNativeGuideBody,
providerNativeOfficialMode,
providerPresetActive,
providerPresetConfigured,
providerPresetHint,
providerPresetLabel,
providerPresetStateLabel,
providerReady,
providerRemoveArmed,
providerSaveActionLabel,
providerStatus,
providerStatusIcon,
providerStatusIsChecking,
providerStatusLabel,
providerStatusTone,
providerSummaryLabel,
removeProvider,
retryProviderStatus,
saveProvider,
selectProviderAgent,
selectedProviderAgent,
selectedProviderAgentState,
selectedProviderKind,
selectedProviderPreset,
selectedProviderPresetActive,
selectedProviderPresetConfigured,
selectedProviderPresets,
selectedProviderProfile,
selectedProviderProfileSaved,
selectedProviderProfileStatus,
supportsExternalProvider,
}
}
@@ -1,5 +1,4 @@
function normalizedSection(section) {
if (section === 'providers') return 'providers'
if (section === 'cloud-agents') return 'cloud-agents'
if (section === 'knowledge-bases') return 'knowledge-bases'
return 'agents'
@@ -12,12 +11,8 @@ export function useSystemSettingsNavigation({
formError,
installConfirmKind,
loadKnowledgeBaseStatuses,
loadProviderWorkspace,
modal,
providerRemoveArmed,
saving,
selectedProviderKind,
supportsExternalProvider,
systemSettingsSection,
}) {
function openAgentManager(kind = '') {
@@ -32,11 +27,7 @@ export function useSystemSettingsNavigation({
focusedAgentKind.value = systemSettingsSection.value === 'agents' ? kind : ''
installConfirmKind.value = ''
formError.value = ''
providerRemoveArmed.value = false
if (systemSettingsSection.value === 'providers') {
const targetKind = supportsExternalProvider({ kind }) ? kind : selectedProviderKind.value
void loadProviderWorkspace(targetKind, { probeSelected: true })
} else if (systemSettingsSection.value === 'knowledge-bases') {
if (systemSettingsSection.value === 'knowledge-bases') {
void loadKnowledgeBaseStatuses()
}
}
@@ -45,25 +36,13 @@ export function useSystemSettingsNavigation({
if (section === systemSettingsSection.value) return
systemSettingsSection.value = normalizedSection(section)
formError.value = ''
providerRemoveArmed.value = false
if (systemSettingsSection.value === 'providers') {
void loadProviderWorkspace(selectedProviderKind.value, { probeSelected: true })
} else if (systemSettingsSection.value === 'knowledge-bases') {
if (systemSettingsSection.value === 'knowledge-bases') {
void loadKnowledgeBaseStatuses()
}
}
function openProvider(kind = '') {
if (saving.value) return
openSystemSettings(
'providers',
supportsExternalProvider({ kind }) ? kind : selectedProviderKind.value,
)
}
return {
openAgentManager,
openProvider,
openSystemSettings,
selectSystemSettingsSection,
}
-1
View File
@@ -122,7 +122,6 @@ export function createConversationControllers({
inlineTitleDraft: conversationActions.inlineTitleDraft,
inlineTitleEditing: conversationActions.inlineTitleEditing,
openGroupSettings: conversationActions.openGroupSettings,
providerModeShortLabel: agentCatalog.providerModeShortLabel,
saveInlineTitle: conversationActions.saveInlineTitle,
saving: app.saving,
sending: app.sending,
-10
View File
@@ -64,15 +64,6 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
INSTALL_AGENT_ALREADY_INSTALLED: 'error.installAlreadyInstalled',
INSTALL_AGENT_FAILED: 'error.installFailed',
INSTALL_AGENT_VERIFY_FAILED: 'error.installVerify',
PROVIDER_CREDENTIAL_REQUIRED: 'error.providerRequired',
PROVIDER_ENCRYPTION_FAILED: 'error.providerEncryption',
PROVIDER_ENCRYPTION_UNAVAILABLE: 'error.providerEncryption',
PROVIDER_INVALID_CREDENTIAL: 'error.providerEncryption',
PROVIDER_CREDENTIAL_UNAVAILABLE: 'error.providerCredentialUnavailable',
PROVIDER_INVALID_METADATA: 'error.providerMetadata',
PROVIDER_INSECURE_BASE_URL: 'error.providerMetadata',
PROVIDER_STORAGE_PATH_REQUIRED: 'error.generic',
OPENCLAW_PROVIDER_INVALID: 'error.providerMetadata',
LOCAL_GROUP_RUNNING: 'error.groupRunning',
LOCAL_MESSAGE_NOT_FOUND: 'error.messageNotFound',
LOCAL_MESSAGE_REQUIRED: 'error.messageRequired',
@@ -177,7 +168,6 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
const LEGACY_ERROR_PATTERNS = [
[/Desktop workspace is unavailable/i, 'error.workspaceUnavailable'],
[/PROVIDER_ENCRYPTION/i, 'error.providerEncryption'],
[/active run/i, 'error.groupRunning'],
]
+3 -123
View File
@@ -9,8 +9,6 @@ export default {
'agentDiscovery.status': 'Preparing your workspace',
'onboarding.discoverTitle': 'Bring your local Agents together',
'onboarding.discoverBody': 'Meldwork finds the supported Agent CLIs already available on this computer.',
'onboarding.providerTitle': 'Configure each Agent separately',
'onboarding.providerBody': 'Each local Agent can keep an independent Provider profile, including official, OpenRouter, or custom endpoints where the CLI supports them.',
'onboarding.collaborationTitle': 'Share one task across Agents',
'onboarding.collaborationBody': 'Keep different Agents in one workspace so they can contribute, challenge, and continue the work.',
'onboarding.toolsTitle': 'Add the context the work needs',
@@ -39,7 +37,6 @@ export default {
'nav.switchGroup': 'Switch group chat',
'nav.groupCount': '{count} local group chats',
'nav.agents': 'Local Agents',
'nav.provider': 'Provider',
'nav.settings': 'Settings',
'nav.home': 'Open workspace home',
'nav.noDirect': 'No direct chats yet',
@@ -70,8 +67,6 @@ export default {
'home.overview': 'Workspace overview',
'home.readyAgents': 'Ready Agents',
'home.installedAgents': '{count} installed locally',
'home.connectedProviders': 'Provider-ready Agents',
'home.providerTotal': '{count} Agent profiles',
'home.knowledgeSources': 'Knowledge sources',
'home.knowledgeTotal': '{count} local sources',
'home.conversations': 'Conversations',
@@ -97,8 +92,7 @@ export default {
'agent.ready': 'Ready',
'agent.installed': 'Installed',
'agent.notInstalled': 'Not installed',
'agent.needsLogin': 'Needs sign-in or Provider',
'agent.providerCredentialUnavailable': 'Provider credentials unavailable',
'agent.needsLogin': 'Needs sign-in',
'agent.unverified': 'Sign-in not verified',
'agent.incompatible': 'Incompatible CLI',
'agent.detectionTimedOut': 'Detection timed out; retry',
@@ -112,19 +106,6 @@ export default {
'agent.skillsUnavailable': 'Skill count unavailable',
'agent.images': 'Up to {count} images',
'agent.noImages': 'No image input',
'agent.providerShort.compatible': 'OpenAI compatible',
'agent.providerShort.experimental': 'Experimental external Provider',
'agent.providerShort.responses': 'Responses API',
'agent.providerShort.anthropic': 'Anthropic compatible',
'agent.providerShort.native': 'Native Provider',
'agent.providerShort.custom': 'CLI managed',
'agent.provider.connector': 'Remote CLI connection',
'agent.provider.compatible': 'Supports an individual OpenAI-compatible Provider',
'agent.provider.experimental': 'Individual external Provider support is experimental',
'agent.provider.responses': 'Uses a Responses API-compatible Provider',
'agent.provider.anthropic': 'Uses an Anthropic-compatible Provider',
'agent.provider.native': 'Uses its own local Provider settings',
'agent.provider.custom': 'Uses the selected local CLI configuration',
'agent.description.codex': 'Structured reasoning, dependable tool use, implementation, and verifiable task execution.',
'agent.description.hermes': 'Flexible open-source reasoning for research, planning, and long-running tasks.',
'agent.description.openclaw': 'Autonomous local workflows with persistent execution and managed task context.',
@@ -432,7 +413,7 @@ export default {
'composer.unlimitedRounds': 'No round limit',
'composer.unlimitedHint': 'The discussion continues until collaboration ends or you stop it. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply.',
'composer.unlimitedConfirmTitle': 'Continue without a round limit?',
'composer.unlimitedConfirm': 'The discussion can continue until collaboration ends or you stop it and may consume more Provider quota. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
'composer.unlimitedConfirm': 'The discussion can continue until collaboration ends or you stop it and may consume more resources. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
'composer.useBoundedRounds': 'Use bounded rounds',
'composer.addTarget': 'Include {agent}',
'composer.removeTarget': 'Exclude {agent}',
@@ -503,12 +484,10 @@ export default {
'settings.deleteConfirm': 'Confirm delete',
'settings.deleteHint': 'Deletes this Meldwork conversation and its saved messages. Native CLI sessions are not deleted.',
'systemSettings.title': 'Settings',
'systemSettings.subtitle': 'Manage local and cloud Agents, Provider connections, and knowledge bases.',
'systemSettings.subtitle': 'Manage local and cloud Agents and knowledge bases.',
'systemSettings.agents': 'Agents',
'systemSettings.cloudServers': 'Cloud servers',
'systemSettings.providers': 'Providers',
'systemSettings.knowledgeBases': 'Knowledge bases',
'systemSettings.providerForAgent': 'Provider',
'systemSettings.openAgentDetail': 'View {agent} details',
'systemSettings.openAgentDetailDefault': 'Agent details',
'systemSettings.officialAgents': 'Official Agents',
@@ -655,13 +634,10 @@ export default {
'setupGuide.title': 'Set up Meldwork',
'setupGuide.detectTitle': 'Detect local Agents',
'setupGuide.detectBody': 'Refresh the local catalog so Meldwork can verify installed Agent CLIs.',
'setupGuide.providerTitle': 'Connect Providers',
'setupGuide.providerBody': 'Review each Agent’s Provider profile, then use official, OpenRouter, custom, or native CLI configuration as appropriate.',
'setupGuide.chatTitle': 'Create a chat',
'setupGuide.chatBody': 'When Agents are ready, start a direct chat or create a group discussion.',
'setupGuide.readyBody': 'Agents are ready. Create a group or open a direct chat to start.',
'agentDetail.skills': 'Skills',
'agentDetail.provider': 'Provider',
'agentDetail.location': 'Location',
'agentDetail.cloudLocation': '{server} · Version {version}',
'agentDetail.images': 'Images',
@@ -692,98 +668,6 @@ export default {
'installer.phase.failed': 'Installation failed',
'installer.unsupported': 'Install manually on this system',
'installer.nodeRequired': 'Node.js and npm are required',
'provider.title': 'Agent providers',
'provider.subtitle': 'Configure a separate Provider for each Agent, using its official service, OpenRouter, or a custom endpoint.',
'provider.configuredCount': '{configured} usable of {total}',
'provider.activeProfile': '{provider} active',
'provider.active': 'Active',
'provider.saved': 'Saved',
'provider.useProfile': 'Use this configuration',
'provider.switchUnavailable': 'Provider switching is unavailable in this desktop bridge.',
'provider.capability.compatible': 'OpenAI-compatible endpoints',
'provider.capability.experimental': 'OpenAI-compatible, experimental',
'provider.capability.responses': 'Responses API only',
'provider.capability.anthropic': 'Anthropic-compatible endpoints',
'provider.capability.native': 'CLI-native configuration',
'provider.nativeTitle': 'Agent CLI configuration',
'provider.nativeBody': 'The verified local CLI configuration is active. No Provider key is stored in Meldwork.',
'provider.nativeAvailableBody': 'A verified CLI sign-in is available. Use this configuration to switch back from the saved override.',
'provider.nativeOverrideTitle': 'Meldwork override is still active',
'provider.nativeOverrideBody': 'Selecting Official keeps the saved override active. Complete the two-step removal in this warning before the Agent can return to its native CLI configuration.',
'provider.nativeStatusCheckingTitle': 'Checking saved overrides',
'provider.nativeStatusCheckingBody': 'Meldwork is checking whether this Agent has a saved Provider override.',
'provider.nativeStatusErrorTitle': 'Provider status could not be verified',
'provider.nativeStatusErrorBody': 'Meldwork could not verify whether a saved override is active. Retry the check before relying on native CLI configuration.',
'provider.agentTitle': 'Provider for {agent}',
'provider.agentSpecificHint': 'This connection is used only by the selected Agent. Other Agents keep their own Provider profile or native sign-in.',
'provider.source': 'Provider source',
'provider.sourceTitle': '{provider} configuration',
'provider.preset.official': 'Official',
'provider.preset.openrouter': 'OpenRouter',
'provider.preset.custom': 'Custom',
'provider.presetHint.official': 'Use the Agent’s official provider or native CLI profile.',
'provider.presetHint.openrouter': 'Use OpenRouter’s OpenAI-compatible gateway.',
'provider.presetHint.custom': 'Use a self-hosted endpoint or private gateway.',
'provider.name': 'Provider name',
'provider.namePlaceholder': 'OpenAI / OpenRouter / My gateway',
'provider.baseUrl': 'Base URL',
'provider.baseUrlPlaceholder': 'https://gateway.example/v1',
'provider.model': 'Model',
'provider.modelPlaceholder': 'model-name',
'provider.apiKey': 'API key',
'provider.apiKeyPlaceholder': 'Enter a new key to save',
'provider.identityLocked': 'Provider name and Base URL are fixed for this source. Choose Custom to edit them.',
'provider.configGuide': 'Local configuration guide',
'provider.configFile': 'CLI config',
'provider.runtimeKeys': 'Runtime keys',
'provider.docs.codex': 'Codex reads OpenAI-compatible settings. Meldwork injects saved Provider values into the Codex child process only.',
'provider.docs.hermes': 'Hermes accepts explicit provider and model arguments, plus OpenAI-compatible environment values.',
'provider.docs.openclaw': 'OpenClaw runs through Meldwork’s managed local runtime and receives an isolated OpenAI-compatible Provider profile.',
'provider.docs.workbuddy': 'WorkBuddy receives Provider values through CodeBuddy-specific model, base URL, and API key environment variables.',
'provider.docs.pi': 'Pi Agent primarily uses its native CLI configuration. Meldwork records Provider metadata here for explicit Agent profiles.',
'provider.docs.kimi': 'Kimi Code uses its local configuration for native sign-in; Meldwork can also inject Kimi model environment values for this Agent run.',
'provider.docs.mimo': 'MiMo Code primarily uses its native CLI configuration. Meldwork records Provider metadata here so the selected Agent profile remains explicit.',
'provider.docs.claude': 'Claude Code uses Anthropic-compatible configuration. Meldwork injects saved Anthropic-style values only for this Agent run.',
'provider.docs.gemini': 'Gemini CLI uses Google AI credentials. Meldwork maps the saved key into Gemini-compatible runtime variables.',
'provider.docs.opencode': 'OpenCode supports multiple provider backends. Meldwork keeps the selected OpenAI/OpenRouter/custom values scoped to OpenCode.',
'provider.docs.qwen': 'Qwen Code can run with its official DashScope-compatible endpoint, OpenRouter, or another OpenAI-compatible Provider.',
'provider.docs.opencodereview': 'OpenCodeReview receives an OpenAI-compatible chat completions URL, token, and model only for this Agent run.',
'provider.docs.custom': 'This Agent uses the saved Provider name, base URL, model, and encrypted key for local execution when supported.',
'provider.configured': 'Configured',
'provider.nativeReady': 'Native configuration active',
'provider.localCliAvailable': 'Local CLI available',
'provider.state.nativeAvailableBody': '{agent} can run with its local configuration. Sign-in will be verified on invocation.',
'provider.state.detectionTimedOutBody': '{agent} is installed, but its capability check timed out. Retry detection.',
'provider.nativeAvailable': 'Native available',
'provider.connected': 'Connected',
'provider.checking': 'Checking',
'provider.notChecked': 'Not checked yet',
'provider.unavailable': 'Status unavailable',
'provider.notConfigured': 'Not configured',
'provider.nativeConnected': 'Native connected',
'provider.state.notInstalled': 'Not installed',
'provider.state.notInstalledBody': 'Install {agent} before any Provider source can run.',
'provider.state.loginRequired': 'Sign-in required',
'provider.state.loginRequiredBody': 'Sign in to {agent}, or save a Provider configuration for this Agent.',
'provider.state.unverified': 'Sign-in not verified',
'provider.state.unverifiedBody': 'Meldwork has not verified a usable sign-in for {agent}.',
'provider.state.checkingBody': 'Checking saved Provider profiles for {agent}.',
'provider.state.unavailableBody': 'Saved Provider profiles for {agent} could not be read.',
'provider.state.nativeReadyBody': 'Meldwork can run {agent} with its verified local CLI credentials.',
'provider.state.overrideActive': '{provider} override active',
'provider.state.overrideActiveBody': 'Saved {provider} credentials are active for {agent}.',
'provider.encryptionUnavailable': 'System credential encryption is unavailable.',
'provider.manageAgent': 'Manage Agent',
'provider.save': 'Save and use',
'provider.saveAndUse': 'Save and use',
'provider.updateCredentials': 'Update credentials',
'provider.updateAndUse': 'Update and use',
'provider.remove': 'Remove Provider',
'provider.removeConfirm': 'Confirm removal',
'provider.removeOverride': 'Remove override and use native',
'provider.removeOverrideConfirm': 'Confirm return to native',
'provider.requiredFields': 'Complete all Provider fields.',
'provider.keyRequired': 'Enter an API key.',
'common.close': 'Close',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
@@ -840,10 +724,6 @@ export default {
'error.installAlreadyInstalled': 'This Agent is already installed. Refresh the Agent list.',
'error.installFailed': 'The Agent installation failed.',
'error.installVerify': 'The installer finished, but the CLI was not detected.',
'error.providerEncryption': 'The Provider key could not be encrypted.',
'error.providerCredentialUnavailable': 'The saved Provider key is unavailable. Unlock the system credential store and retry.',
'error.providerMetadata': 'Check the Provider name, URL, and model.',
'error.providerRequired': 'Configure this Agent\'s Provider before running it.',
'error.groupRunning': 'Stop the current Agent run first.',
'error.messageNotFound': 'This message no longer exists.',
'error.messageRequired': 'Enter a message.',
+3 -123
View File
@@ -9,8 +9,6 @@ export default {
'agentDiscovery.status': '正在准备工作台',
'onboarding.discoverTitle': '把本地 Agent 汇聚到一起',
'onboarding.discoverBody': 'Meldwork 会检测这台电脑上已经可用的受支持 Agent CLI。',
'onboarding.providerTitle': '为每个 Agent 单独配置',
'onboarding.providerBody': '每个本地 Agent 都可以保留独立 Provider 档案,并按 CLI 能力选择官方、OpenRouter 或自定义端点。',
'onboarding.collaborationTitle': '让多个 Agent 共享同一任务',
'onboarding.collaborationBody': '不同 Agent 在同一个工作台中补充、质疑并继续彼此的工作。',
'onboarding.toolsTitle': '补齐完成任务所需的上下文',
@@ -39,7 +37,6 @@ export default {
'nav.switchGroup': '快速切换群聊',
'nav.groupCount': '{count} 个本地群聊',
'nav.agents': '本地 Agent',
'nav.provider': 'Provider',
'nav.settings': '设置',
'nav.home': '打开工作台首页',
'nav.noDirect': '还没有单聊',
@@ -70,8 +67,6 @@ export default {
'home.overview': '工作台概览',
'home.readyAgents': '可用 Agent',
'home.installedAgents': '本机已安装 {count} 个',
'home.connectedProviders': 'Provider 已就绪 Agent',
'home.providerTotal': '共 {count} 个 Agent 档案',
'home.knowledgeSources': '知识源',
'home.knowledgeTotal': '共 {count} 个本地来源',
'home.conversations': '会话',
@@ -97,8 +92,7 @@ export default {
'agent.ready': '可用',
'agent.installed': '已安装',
'agent.notInstalled': '未安装',
'agent.needsLogin': '需要登录或配置 Provider',
'agent.providerCredentialUnavailable': 'Provider 凭证不可用',
'agent.needsLogin': '需要登录',
'agent.unverified': '尚未验证登录状态',
'agent.incompatible': 'CLI 不兼容',
'agent.detectionTimedOut': '检测超时,请重试',
@@ -112,19 +106,6 @@ export default {
'agent.skillsUnavailable': '无法读取技能数量',
'agent.images': '最多 {count} 张图片',
'agent.noImages': '不支持图片输入',
'agent.providerShort.compatible': 'OpenAI 兼容',
'agent.providerShort.experimental': '实验性外部 Provider',
'agent.providerShort.responses': 'Responses API',
'agent.providerShort.anthropic': 'Anthropic 兼容',
'agent.providerShort.native': '本机 Provider',
'agent.providerShort.custom': '由 CLI 管理',
'agent.provider.connector': '远端 CLI 连接',
'agent.provider.compatible': '可单独配置 OpenAI 兼容 Provider',
'agent.provider.experimental': '独立外部 Provider 为实验性支持',
'agent.provider.responses': '使用兼容 Responses API 的 Provider',
'agent.provider.anthropic': '使用 Anthropic 兼容 Provider',
'agent.provider.native': '沿用本机已有 Provider 配置',
'agent.provider.custom': '使用所选本地 CLI 的配置',
'agent.description.codex': '适合结构化推理、可靠工具调用、实现工作与可验证的任务执行。',
'agent.description.hermes': '适合开源推理、调研规划与持续时间较长的复杂任务。',
'agent.description.openclaw': '适合自主本地工作流、持久执行与任务上下文管理。',
@@ -432,7 +413,7 @@ export default {
'composer.unlimitedRounds': '不限轮数',
'composer.unlimitedHint': '讨论会持续到协作自然结束或你手动停止。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。',
'composer.unlimitedConfirmTitle': '继续使用不限制轮数吗?',
'composer.unlimitedConfirm': '讨论会持续到协作自然结束或你手动停止,并可能消耗更多 Provider 额度。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
'composer.unlimitedConfirm': '讨论会持续到协作自然结束或你手动停止,并可能消耗更多资源。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
'composer.useBoundedRounds': '改回固定轮数',
'composer.addTarget': '加入 {agent}',
'composer.removeTarget': '移除 {agent}',
@@ -503,12 +484,10 @@ export default {
'settings.deleteConfirm': '确认删除',
'settings.deleteHint': '仅删除 Meldwork 中的该对话及已保存消息,不会删除 Agent 原生 CLI 会话。',
'systemSettings.title': '设置',
'systemSettings.subtitle': '管理本机与云端 Agent、Provider 连接和知识库。',
'systemSettings.subtitle': '管理本机与云端 Agent 和知识库。',
'systemSettings.agents': 'Agent',
'systemSettings.cloudServers': '云服务器',
'systemSettings.providers': 'Provider',
'systemSettings.knowledgeBases': '知识库',
'systemSettings.providerForAgent': 'Provider',
'systemSettings.openAgentDetail': '查看 {agent} 详情',
'systemSettings.openAgentDetailDefault': 'Agent 详情',
'systemSettings.officialAgents': '官方 Agent',
@@ -655,13 +634,10 @@ export default {
'setupGuide.title': '设置 Meldwork',
'setupGuide.detectTitle': '检测本地 Agent',
'setupGuide.detectBody': '刷新本地列表,让 Meldwork 验证已安装的 Agent CLI。',
'setupGuide.providerTitle': '连接 Provider',
'setupGuide.providerBody': '逐个查看 Agent 的 Provider 档案,再按需使用官方、OpenRouter、自定义或 CLI 原生配置。',
'setupGuide.chatTitle': '创建会话',
'setupGuide.chatBody': '当 Agent 可用后,就可以创建群聊或单聊开始任务。',
'setupGuide.readyBody': 'Agent 已准备就绪,可以创建群聊或打开单聊开始任务。',
'agentDetail.skills': '技能',
'agentDetail.provider': 'Provider',
'agentDetail.location': '位置',
'agentDetail.cloudLocation': '{server} · 版本 {version}',
'agentDetail.images': '图片',
@@ -692,98 +668,6 @@ export default {
'installer.phase.failed': '安装失败',
'installer.unsupported': '当前系统需要手动安装',
'installer.nodeRequired': '需要先安装 Node.js 与 npm',
'provider.title': '独立 Provider 配置',
'provider.subtitle': '每个 Agent 分别保存自己的 Provider,可选择官方服务、OpenRouter 或自定义端点。',
'provider.configuredCount': '可用 {configured} / 共 {total}',
'provider.activeProfile': '当前使用 {provider}',
'provider.active': '当前使用',
'provider.saved': '已保存',
'provider.useProfile': '使用此配置',
'provider.switchUnavailable': '当前桌面桥接不支持切换 Provider。',
'provider.capability.compatible': '支持 OpenAI 兼容端点',
'provider.capability.experimental': '支持 OpenAI 兼容端点,实验性',
'provider.capability.responses': '仅支持 Responses API',
'provider.capability.anthropic': '支持 Anthropic 兼容端点',
'provider.capability.native': '使用 CLI 原生配置',
'provider.nativeTitle': 'Agent CLI 原生配置',
'provider.nativeBody': '当前正在使用已验证的本地 CLI 配置,Meldwork 不会额外保存 Provider Key。',
'provider.nativeAvailableBody': '已检测到可用的 CLI 登录;使用此配置即可从已保存的覆盖配置切回原生模式。',
'provider.nativeOverrideTitle': 'Meldwork 覆盖配置仍在生效',
'provider.nativeOverrideBody': '选择“官方”不会停用已保存的覆盖配置。请在此警告中完成两步移除,之后该 Agent 才会回到 CLI 原生配置。',
'provider.nativeStatusCheckingTitle': '正在检查覆盖配置',
'provider.nativeStatusCheckingBody': 'Meldwork 正在确认这个 Agent 是否存在已保存的 Provider 覆盖配置。',
'provider.nativeStatusErrorTitle': '无法验证 Provider 状态',
'provider.nativeStatusErrorBody': 'Meldwork 无法确认已保存的覆盖配置是否仍在生效。请先重试检查,再依赖 CLI 原生配置。',
'provider.agentTitle': '{agent} 配置',
'provider.agentSpecificHint': '该连接仅供当前 Agent 使用,其他 Agent 继续使用各自的 Provider 档案或原生登录。',
'provider.source': 'Provider 来源',
'provider.sourceTitle': '{provider} 配置',
'provider.preset.official': '官方',
'provider.preset.openrouter': 'OpenRouter',
'provider.preset.custom': '自定义',
'provider.presetHint.official': '使用该 Agent 的官方 Provider 或 CLI 原生配置。',
'provider.presetHint.openrouter': '使用 OpenRouter 的 OpenAI 兼容网关。',
'provider.presetHint.custom': '使用自建端点或私有网关。',
'provider.name': 'Provider 名称',
'provider.namePlaceholder': 'OpenAI / OpenRouter / 我的网关',
'provider.baseUrl': 'Base URL',
'provider.baseUrlPlaceholder': 'https://gateway.example/v1',
'provider.model': '模型',
'provider.modelPlaceholder': 'model-name',
'provider.apiKey': 'API Key',
'provider.apiKeyPlaceholder': '输入新 Key 后保存',
'provider.identityLocked': '当前来源的 Provider 名称和 Base URL 已锁定;如需修改,请选择“自定义”。',
'provider.configGuide': '本地配置说明',
'provider.configFile': 'CLI 配置',
'provider.runtimeKeys': '运行时配置键',
'provider.docs.codex': 'Codex 读取 OpenAI 兼容配置。Meldwork 只会把保存的 Provider 值注入到 Codex 子进程。',
'provider.docs.hermes': 'Hermes 支持显式 provider/model 参数,也支持 OpenAI 兼容环境变量。',
'provider.docs.openclaw': 'OpenClaw 会通过 Meldwork 托管的本地运行时接收隔离的 OpenAI 兼容 Provider 档案。',
'provider.docs.workbuddy': 'WorkBuddy 会通过 CodeBuddy 专用的模型、Base URL 和 API Key 环境变量接收 Provider。',
'provider.docs.pi': 'Pi Agent 主要使用 CLI 原生配置。Meldwork 会在这里记录 Provider 元数据,让该 Agent 档案保持明确。',
'provider.docs.kimi': 'Kimi Code 默认使用本地 CLI 配置;Meldwork 也可以在本 Agent 运行时注入 Kimi 模型环境变量。',
'provider.docs.mimo': 'MiMo Code 主要使用 CLI 原生配置。Meldwork 会在这里记录 Provider 元数据,让该 Agent 档案保持明确。',
'provider.docs.claude': 'Claude Code 使用 Anthropic 兼容配置。Meldwork 只会把保存的 Anthropic 风格变量注入到本 Agent 运行。',
'provider.docs.gemini': 'Gemini CLI 使用 Google AI 凭据。Meldwork 会把保存的 Key 映射到 Gemini 兼容运行时变量。',
'provider.docs.opencode': 'OpenCode 支持多种 provider 后端。Meldwork 会把 OpenAI、OpenRouter 或自定义配置限定在 OpenCode 中使用。',
'provider.docs.qwen': 'Qwen Code 可使用官方 DashScope 兼容端点、OpenRouter 或其他 OpenAI 兼容 Provider。',
'provider.docs.opencodereview': 'OpenCodeReview 会在本 Agent 运行时接收 OpenAI 兼容 chat completions URL、Token 和模型。',
'provider.docs.custom': '该 Agent 会在支持时使用保存的 Provider 名称、Base URL、模型和加密 Key 进行本地运行。',
'provider.configured': '已配置',
'provider.nativeReady': '原生配置生效中',
'provider.localCliAvailable': '本地 CLI 可用',
'provider.state.nativeAvailableBody': '{agent} 可使用本地配置运行,登录状态将在调用时验证。',
'provider.state.detectionTimedOutBody': '已找到 {agent},但能力检测超时,请重新检测。',
'provider.nativeAvailable': '原生配置可用',
'provider.connected': '已连接',
'provider.checking': '检查中',
'provider.notChecked': '尚未读取状态',
'provider.unavailable': '状态读取失败',
'provider.notConfigured': '未配置',
'provider.nativeConnected': '本机已连接',
'provider.state.notInstalled': '尚未安装',
'provider.state.notInstalledBody': '请先安装 {agent},之后才能使用任一 Provider 来源。',
'provider.state.loginRequired': '等待登录',
'provider.state.loginRequiredBody': '请登录 {agent},或为该 Agent 保存一份独立 Provider 配置。',
'provider.state.unverified': '尚未验证',
'provider.state.unverifiedBody': 'Meldwork 尚未验证 {agent} 是否存在可用登录。',
'provider.state.checkingBody': '正在读取 {agent} 已保存的 Provider 档案。',
'provider.state.unavailableBody': '无法读取 {agent} 已保存的 Provider 档案。',
'provider.state.nativeReadyBody': 'Meldwork 可使用 {agent} 已验证的本地 CLI 凭据运行。',
'provider.state.overrideActive': '{provider} 覆盖已启用',
'provider.state.overrideActiveBody': '{agent} 当前使用已保存的 {provider} 凭据。',
'provider.encryptionUnavailable': '系统凭据加密当前不可用。',
'provider.manageAgent': '管理 Agent',
'provider.save': '保存并使用',
'provider.saveAndUse': '保存并使用',
'provider.updateCredentials': '更新凭据',
'provider.updateAndUse': '更新并使用',
'provider.remove': '移除 Provider',
'provider.removeConfirm': '确认移除',
'provider.removeOverride': '移除覆盖并使用原生配置',
'provider.removeOverrideConfirm': '确认回到原生配置',
'provider.requiredFields': '请填写完整的 Provider 信息。',
'provider.keyRequired': '请输入 API Key。',
'common.close': '关闭',
'common.cancel': '取消',
'common.confirm': '确认',
@@ -840,10 +724,6 @@ export default {
'error.installAlreadyInstalled': '该 Agent 已安装,请刷新列表。',
'error.installFailed': 'Agent 安装失败。',
'error.installVerify': '安装程序已结束,但仍未检测到 CLI。',
'error.providerEncryption': 'Provider Key 加密失败。',
'error.providerCredentialUnavailable': '暂时无法读取已保存的 Provider Key,请解锁系统凭据存储后重试。',
'error.providerMetadata': '请检查 Provider 名称、地址和模型。',
'error.providerRequired': '请先配置该 Agent 的独立 Provider,再运行。',
'error.groupRunning': '请先停止当前 Agent 运行。',
'error.messageNotFound': '该消息已不存在。',
'error.messageRequired': '请输入消息。',
-102
View File
@@ -1,102 +0,0 @@
export const PROVIDER_PRESETS = Object.freeze(['official', 'openrouter', 'custom'])
const OPENROUTER = Object.freeze({
id: 'openrouter',
provider: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
model: '',
})
const CUSTOM = Object.freeze({
id: 'custom',
provider: 'Custom Provider',
baseUrl: '',
model: '',
})
const OFFICIAL = Object.freeze({
codex: { provider: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: '' },
hermes: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1', model: '' },
openclaw: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1', model: '' },
workbuddy: { provider: 'WorkBuddy Official', baseUrl: '', model: '' },
pi: { provider: 'Pi Native', baseUrl: '', model: '' },
kimi: { provider: 'Moonshot AI', baseUrl: 'https://api.moonshot.cn/v1', model: '' },
mimo: { provider: 'MiMo Native', baseUrl: '', model: '' },
claude: { provider: 'Anthropic', baseUrl: 'https://api.anthropic.com', model: '' },
gemini: { provider: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', model: '' },
opencode: { provider: 'OpenCode Provider', baseUrl: '', model: '' },
qwen: { provider: 'DashScope', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: '' },
opencodereview: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1', model: '' },
})
const RUNTIME_KEYS = Object.freeze({
codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
hermes: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_MODEL'],
openclaw: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
workbuddy: ['CODEBUDDY_API_KEY', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL'],
pi: ['PI_API_KEY', 'PI_BASE_URL', 'PI_MODEL'],
kimi: ['KIMI_MODEL_API_KEY', 'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME'],
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL'],
claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL'],
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL'],
opencode: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
qwen: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
opencodereview: ['OCR_LLM_URL', 'OCR_LLM_TOKEN', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC'],
})
const CONFIG_FILES = Object.freeze({
codex: '~/.codex/config.toml',
hermes: '~/.hermes/.env',
openclaw: '~/.openclaw/openclaw.json',
workbuddy: '~/.workbuddy/models.json',
pi: 'Pi Agent native config',
kimi: '~/.kimi-code/config.toml',
mimo: 'MiMo CLI native config',
claude: '~/.claude/settings.json',
gemini: '~/.gemini/settings.json',
opencode: '~/.local/share/opencode/auth.json',
qwen: '~/.qwen/oauth_creds.json',
opencodereview: '~/.opencodereview/config.json',
})
const PROFILES = Object.freeze(Object.fromEntries(Object.keys(OFFICIAL).map(kind => [kind, {
kind,
docsKey: `provider.docs.${kind}`,
configFile: CONFIG_FILES[kind] || '',
runtimeKeys: RUNTIME_KEYS[kind] || [],
presets: PROVIDER_PRESETS.map(id => ({
id,
...(id === 'official' ? OFFICIAL[kind] : id === 'openrouter' ? OPENROUTER : CUSTOM),
})),
}])))
const FALLBACK_PROFILE = Object.freeze({
kind: '',
docsKey: 'provider.docs.custom',
configFile: '',
runtimeKeys: ['API_KEY', 'BASE_URL', 'MODEL'],
presets: [
{ id: 'official', provider: 'Official Provider', baseUrl: '', model: '' },
OPENROUTER,
CUSTOM,
],
})
export function providerProfile(kind) {
return PROFILES[String(kind || '').trim()] || FALLBACK_PROFILE
}
export function inferProviderPreset(kind, status) {
const profile = providerProfile(kind)
const baseUrl = String(status?.baseUrl || '').trim().replace(/\/+$/, '')
const provider = String(status?.provider || '').trim().toLowerCase()
if (!baseUrl && !provider) return 'official'
if (/openrouter/i.test(provider) || /(^|\.)openrouter\.ai$/i.test(hostname(baseUrl))) return 'openrouter'
const official = profile.presets.find(preset => preset.id === 'official')
if (official && baseUrl && baseUrl === String(official.baseUrl || '').replace(/\/+$/, '')) return 'official'
return 'custom'
}
function hostname(value) {
try { return new URL(value).hostname } catch { return '' }
}
-179
View File
@@ -1,179 +0,0 @@
import {
CheckmarkCircleOutline,
DownloadOutline,
RefreshOutline,
WarningOutline,
} from '@vicons/ionicons5'
import { AGENTS } from './catalog.js'
import { inferProviderPreset } from './providerProfiles.js'
const EXTERNAL_PROVIDER_KINDS = new Set(AGENTS.map(agent => agent.kind))
const NATIVE_PROVIDER_READY_SOURCES = new Set([
'native-credential', 'native-auth-status', 'native-cli', 'native-shell', 'verified-run',
])
export const EMPTY_PROVIDER_STATUS = Object.freeze({
provider: '',
baseUrl: '',
model: '',
activePreset: 'official',
profiles: {},
configured: false,
encryptionAvailable: true,
error: false,
})
export function supportsExternalProvider(agent) {
return EXTERNAL_PROVIDER_KINDS.has(agent?.kind)
}
export function providerStatusFor(statuses, kind) {
return statuses?.[kind] || EMPTY_PROVIDER_STATUS
}
export function hasProviderStatus(statuses, kind) {
return Object.prototype.hasOwnProperty.call(statuses || {}, String(kind || ''))
}
export function providerProfilesFor(statuses, kind) {
const status = providerStatusFor(statuses, kind)
if (status.profiles && typeof status.profiles === 'object' && !Array.isArray(status.profiles)) {
return status.profiles
}
if (!status.configured) return {}
const preset = status.activePreset || inferProviderPreset(kind, status)
return {
[preset]: {
provider: status.provider,
baseUrl: status.baseUrl,
model: status.model,
configured: true,
},
}
}
export function nativeProviderReady(agent) {
return Boolean(agent?.ready && NATIVE_PROVIDER_READY_SOURCES.has(String(agent.availabilitySource || '')))
}
function nativeCliAvailable(agent) {
return Boolean(agent?.ready && agent.availabilitySource === 'local-cli' && agent.credentialState !== 'missing')
}
export function activeSavedProviderPreset(statuses, kind) {
const status = providerStatusFor(statuses, kind)
if (!hasProviderStatus(statuses, kind) || status.error || !status.configured) return ''
const preset = status.activePreset || inferProviderPreset(kind, status)
return providerProfilesFor(statuses, kind)[preset]?.configured ? preset : ''
}
export function providerActiveSourceFor(agents, statuses, kind) {
const agent = agents.find(item => item.kind === kind)
if (!agent?.installed || !hasProviderStatus(statuses, kind) || providerStatusFor(statuses, kind).error) return ''
const savedPreset = activeSavedProviderPreset(statuses, kind)
if (savedPreset) return savedPreset
return nativeProviderReady(agent) || nativeCliAvailable(agent) ? 'official' : ''
}
export function providerAgentState({ agents, checking, kind, statuses, t }) {
const agent = agents.find(item => item.kind === kind)
const agentName = agent?.label || String(kind || '')
if (!agent?.installed) {
return {
id: 'not-installed',
label: t('provider.state.notInstalled'),
detail: t('provider.state.notInstalledBody', { agent: agentName }),
tone: 'warning',
icon: DownloadOutline,
}
}
if (checking && !hasProviderStatus(statuses, kind)) {
return {
id: 'checking',
label: t('provider.checking'),
detail: t('provider.state.checkingBody', { agent: agentName }),
tone: 'checking',
icon: RefreshOutline,
}
}
if (agent.incompatibilityReason === 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT') {
return {
id: 'detection-timeout',
label: t('agent.detectionTimedOut'),
detail: t('provider.state.detectionTimedOutBody', { agent: agentName }),
tone: 'warning',
icon: RefreshOutline,
}
}
if (providerStatusFor(statuses, kind).error) {
return {
id: 'unavailable',
label: t('provider.unavailable'),
detail: t('provider.state.unavailableBody', { agent: agentName }),
tone: 'warning',
icon: WarningOutline,
}
}
const savedPreset = activeSavedProviderPreset(statuses, kind)
if (savedPreset) {
const source = t(`provider.preset.${savedPreset}`)
return {
id: 'active-override',
label: t('provider.state.overrideActive', { provider: source }),
detail: t('provider.state.overrideActiveBody', { agent: agentName, provider: source }),
tone: 'connected',
icon: CheckmarkCircleOutline,
}
}
if (nativeProviderReady(agent)) {
return {
id: 'native-ready',
label: t('provider.nativeReady'),
detail: t('provider.state.nativeReadyBody', { agent: agentName }),
tone: 'connected',
icon: CheckmarkCircleOutline,
}
}
if (nativeCliAvailable(agent)) {
return {
id: 'native-available',
label: t('provider.localCliAvailable'),
detail: t('provider.state.nativeAvailableBody', { agent: agentName }),
tone: 'neutral',
icon: CheckmarkCircleOutline,
}
}
if (agent.credentialState === 'missing') {
return {
id: 'login-required',
label: t('provider.state.loginRequired'),
detail: t('provider.state.loginRequiredBody', { agent: agentName }),
tone: 'warning',
icon: WarningOutline,
}
}
return {
id: 'unverified',
label: t('provider.state.unverified'),
detail: t('provider.state.unverifiedBody', { agent: agentName }),
tone: 'neutral',
icon: WarningOutline,
}
}
export function providerSummaryLabel({ agent, statuses, t }) {
if (!agent) return ''
if (agent.custom) return t('customAgent.cliManaged')
if (supportsExternalProvider(agent)) {
const status = providerStatusFor(statuses, agent.kind)
if (!hasProviderStatus(statuses, agent.kind)) return t('provider.checking')
if (status.error) return t('provider.unavailable')
if (agent.incompatibilityReason === 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT') return t('agent.detectionTimedOut')
if (status.configured) return t('provider.configured')
if (nativeProviderReady(agent)) return t('provider.nativeReady')
if (nativeCliAvailable(agent)) return t('provider.localCliAvailable')
return t('provider.notConfigured')
}
if (agent.ready) return t('provider.nativeConnected')
return t(`agent.provider.${agent.providerMode}`)
}
-65
View File
@@ -102,71 +102,6 @@
color: var(--muted);
}
.provider-status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 11px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.provider-status > span {
min-width: 0;
}
.provider-status svg {
width: 17px;
height: 17px;
}
.provider-status.configured {
border-color: var(--provider-success-border);
background: var(--provider-success-soft);
color: var(--provider-success);
}
.provider-status.checking {
background: var(--surface);
color: var(--muted);
}
.provider-status.error {
border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
background: var(--danger-soft);
color: var(--danger);
}
.provider-status-retry {
min-height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-left: auto;
padding: 0 8px;
border: 1px solid color-mix(in srgb, currentColor 28%, var(--border));
border-radius: var(--radius-sm);
background: var(--surface-raised);
color: inherit;
font-size: 10px;
font-weight: 650;
white-space: nowrap;
cursor: pointer;
}
.provider-status-retry:hover:not(:disabled) {
background: var(--surface-hover);
}
.provider-status-retry svg {
width: 13px;
height: 13px;
}
.footer-spacer {
flex: 1;
}
-57
View File
@@ -1,60 +1,3 @@
@container settings-content (max-width: 820px) {
.provider-settings-panel {
grid-template-areas:
"header"
"agents"
"editor";
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 14px;
overflow: auto;
}
.provider-page-header {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.provider-summary-count { padding-top: 0; }
.provider-agent-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
overflow: visible;
padding: 0 0 10px;
}
.provider-editor {
width: 100%;
overflow: visible;
padding-right: 0;
}
}
@container settings-content (max-width: 600px) {
.provider-external-fields {
grid-template-areas:
"name"
"url"
"model"
"key";
grid-template-columns: 1fr;
}
.provider-doc-card dl > div {
grid-template-columns: 1fr;
gap: 4px;
}
.provider-editor-footer { flex-wrap: wrap; }
}
@container settings-content (max-width: 390px) {
.provider-agent-list { grid-template-columns: 1fr; }
}
@media (max-width: 1040px) {
:root { --sidebar-width: 242px; }
.agent-grid { grid-template-columns: 1fr; }
-1
View File
@@ -273,7 +273,6 @@
color: var(--danger);
}
.agent-provider-mode,
.agent-version {
overflow: hidden;
color: var(--muted);
-606
View File
@@ -186,609 +186,3 @@
color: var(--muted);
font-size: 12px;
}
.provider-settings-panel {
--provider-success: #4f7564;
--provider-success-soft: #e7efeb;
--provider-success-border: #b8cec3;
display: grid;
grid-template-areas:
"header header"
"agents editor";
grid-template-columns: 228px minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
column-gap: 26px;
row-gap: 16px;
overflow: hidden;
}
:root[data-theme="dark"] .provider-settings-panel {
--provider-success: #8eb19f;
--provider-success-soft: #24332c;
--provider-success-border: #405f51;
}
.provider-page-header {
grid-area: header;
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.provider-page-header h2,
.provider-page-header p {
margin: 0;
}
.provider-page-header h2 {
font-size: 18px;
}
.provider-page-header p {
max-width: 700px;
margin-top: 5px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.provider-summary-count {
flex: 0 0 auto;
padding-top: 3px;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
font-weight: 650;
white-space: nowrap;
}
.provider-agent-list {
grid-area: agents;
min-height: 0;
overflow: auto;
padding-right: 14px;
}
.provider-agent-list button {
width: 100%;
min-height: 46px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 16px;
align-items: center;
gap: 9px;
padding: 6px 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-soft);
text-align: left;
cursor: pointer;
transition: opacity 0.16s ease, transform 0.16s ease;
}
.provider-agent-list button:hover,
.provider-agent-list button.active {
background: var(--surface-hover);
color: var(--text);
}
.provider-agent-list button.active {
background: var(--accent-soft);
}
.provider-agent-list button:active:not(:disabled) {
transform: scale(0.985);
}
.provider-agent-list img {
width: 26px;
height: 26px;
object-fit: contain;
}
.provider-agent-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.provider-agent-list strong,
.provider-agent-list small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-agent-list small {
color: var(--muted);
font-size: 10px;
}
.provider-agent-list small.connected {
color: var(--provider-success);
}
.provider-agent-list small.checking {
color: var(--faint);
}
.provider-agent-list small.warning {
color: var(--muted);
}
.provider-agent-list svg {
width: 16px;
height: 16px;
color: var(--muted);
}
.provider-agent-list svg.ready {
color: var(--provider-success);
}
.provider-editor {
grid-area: editor;
width: min(760px, 100%);
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.provider-editor-header {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: center;
gap: 12px;
margin-bottom: 2px;
}
.provider-editor-header img {
width: 44px;
height: 44px;
object-fit: contain;
}
.provider-editor-header h2,
.provider-editor-header p {
margin: 0;
}
.provider-editor-header h2 {
font-size: 18px;
}
.provider-editor-title {
min-width: 0;
}
.provider-profile-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
}
.provider-agent-state {
min-width: 0;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
align-items: start;
gap: 7px;
margin-top: 6px;
color: var(--muted);
}
.provider-agent-state > svg {
width: 15px;
height: 15px;
margin-top: 1px;
}
.provider-agent-state > span {
min-width: 0;
display: grid;
gap: 1px;
}
.provider-agent-state strong,
.provider-agent-state small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-agent-state strong {
color: var(--text-soft);
font-size: 11px;
line-height: 1.35;
}
.provider-agent-state small {
color: var(--muted);
font-size: 10px;
line-height: 1.35;
}
.provider-agent-state.connected > svg,
.provider-agent-state.connected strong {
color: var(--provider-success);
}
.provider-agent-state.warning > svg,
.provider-agent-state.warning strong {
color: var(--danger);
}
.provider-agent-state.checking > svg {
color: var(--faint);
}
.provider-profile-summary span,
.provider-profile-summary code {
max-width: 100%;
overflow: hidden;
padding: 3px 6px;
border: 0;
border-radius: 4px;
background: color-mix(in srgb, var(--surface-hover) 62%, transparent);
color: var(--muted);
font-size: 10px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-profile-summary span.active {
background: var(--accent-soft);
color: var(--accent);
}
.provider-editor-header p,
.provider-agent-hint {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
line-height: 1.45;
}
.provider-capability-label {
display: block;
margin-top: 5px;
color: var(--accent);
font-size: 10px;
font-weight: 650;
}
.provider-source-section {
display: grid;
gap: 7px;
}
.provider-source-heading {
display: flex;
align-items: baseline;
}
.provider-source-heading > span {
color: var(--text-soft);
font-size: 12px;
font-weight: 650;
}
.provider-source-options {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.provider-source-options button {
min-width: 0;
min-height: 36px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
padding: 6px 10px;
border: 0;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface-hover) 58%, transparent);
color: var(--text-soft);
text-align: left;
cursor: pointer;
transition: opacity 0.16s ease, transform 0.16s ease;
}
.provider-source-options button:hover,
.provider-source-options button:focus-visible {
background: var(--surface-hover);
color: var(--text);
}
.provider-source-options button.active {
background: var(--accent-soft);
color: var(--text);
}
.provider-source-options button:active:not(:disabled) {
transform: scale(0.985);
}
.provider-source-options strong {
font-size: 11px;
white-space: nowrap;
}
.provider-source-options small {
max-width: 100%;
overflow: hidden;
color: var(--muted);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-source-options small.active,
.provider-source-options small.configured {
color: var(--accent);
}
.provider-source-detail {
display: grid;
gap: 14px;
padding-top: 14px;
border-top: 1px solid color-mix(in srgb, var(--border) 64%, transparent);
}
.provider-source-overview {
min-width: 0;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: start;
gap: 9px;
}
.provider-source-overview > svg {
width: 17px;
height: 17px;
margin-top: 1px;
color: var(--accent);
}
.provider-source-overview strong,
.provider-source-overview p,
.provider-source-overview small {
margin: 0;
}
.provider-source-overview strong {
color: var(--text-soft);
font-size: 12px;
}
.provider-source-overview p,
.provider-source-overview small {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 10px;
line-height: 1.45;
}
.provider-source-overview small {
color: var(--faint);
}
.provider-inline-warning {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--danger);
font-size: 11px;
}
.provider-inline-warning button {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 6px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
}
.provider-inline-warning button:hover {
background: var(--danger-soft);
}
.provider-inline-warning svg {
width: 14px;
height: 14px;
}
.provider-native-card,
.provider-doc-card {
border: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.provider-native-card {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 12px;
background: color-mix(in srgb, var(--surface-hover) 54%, transparent);
}
.provider-native-card > svg {
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--accent);
}
.provider-native-card.connected {
background: var(--provider-success-soft);
}
.provider-native-card.connected > svg {
color: var(--provider-success);
}
.provider-native-card.warning {
background: var(--danger-soft);
}
.provider-native-card.warning > svg {
color: var(--danger);
}
.provider-native-card-content {
min-width: 0;
}
.provider-native-actions {
display: flex;
align-items: center;
gap: 7px;
margin-top: 10px;
}
.provider-native-actions .danger-button,
.provider-native-actions .provider-status-retry {
min-height: 30px;
margin-left: 0;
padding: 0 9px;
font-size: 10px;
}
.provider-native-card strong,
.provider-native-card p,
.provider-doc-card strong,
.provider-doc-card p,
.provider-doc-card dl,
.provider-doc-card dt,
.provider-doc-card dd {
margin: 0;
}
.provider-native-card strong,
.provider-doc-card strong {
font-size: 12px;
}
.provider-native-card p,
.provider-doc-card p {
margin-top: 4px;
color: var(--muted);
font-size: 10px;
line-height: 1.5;
}
.provider-external-fields {
display: grid;
grid-template-areas:
"name model"
"url url"
"key key";
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 10px;
}
.provider-external-fields > label {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.provider-field-note {
margin: -3px 0 0;
color: var(--muted);
font-size: 10px;
line-height: 1.45;
}
.provider-external-fields input[readonly] {
background: color-mix(in srgb, var(--surface-hover) 66%, transparent);
color: var(--muted);
cursor: default;
}
.provider-field-name { grid-area: name; }
.provider-field-url { grid-area: url; }
.provider-field-model { grid-area: model; }
.provider-field-key { grid-area: key; }
.provider-doc-card {
display: grid;
gap: 11px;
padding: 0 2px;
}
.provider-doc-card dl {
display: grid;
gap: 8px;
}
.provider-doc-card dl > div {
min-width: 0;
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.provider-doc-card dt {
color: var(--muted);
font-size: 10px;
font-weight: 650;
}
.provider-doc-card dd {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.provider-doc-card code {
max-width: 100%;
overflow: hidden;
padding: 2px 5px;
border-radius: 4px;
background: var(--surface-hover);
color: var(--text-soft);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-editor-footer {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
}
.provider-activate-button {
border: 0;
background: var(--accent-soft);
color: var(--accent);
}
.provider-activate-button:hover {
border: 0;
background: color-mix(in srgb, var(--accent-soft) 78%, var(--surface-hover));
color: var(--accent-hover);
}
-11
View File
@@ -8,7 +8,6 @@ export function createWorkspaceControllers({
conversationActions,
conversationNavigation,
knowledgeBase,
providerSettings,
timeline,
}) {
const workspaceSidebarController = {
@@ -74,7 +73,6 @@ export function createWorkspaceControllers({
cancelInstall: agentManagement.cancelInstall,
cloudAgentBridges: app.cloudAgentBridges,
focusedAgentKind: app.focusedAgentKind,
formError: app.formError,
installConfirmKind: app.installConfirmKind,
installedCount: agentCatalog.installedCount,
installerBusy: agentManagement.installerBusy,
@@ -92,21 +90,15 @@ export function createWorkspaceControllers({
loadKnowledgeBaseStatuses: knowledgeBase.loadKnowledgeBaseStatuses,
localKnowledgeBaseEntries: knowledgeBase.localKnowledgeBaseEntries,
openAgentDetail: agentManagement.openAgentDetail,
openAgentManager: app.openAgentManager,
openDirect: conversationActions.openDirect,
openProvider: app.openProvider,
plannedKnowledgeBaseEntries: knowledgeBase.plannedKnowledgeBaseEntries,
providerSettings,
providerSummaryLabel: providerSettings.providerSummaryLabel,
readyCount: agentCatalog.readyCount,
readyKnowledgeBaseCount: knowledgeBase.readyKnowledgeBaseCount,
refreshAgents: agentRefresh.refreshAgents,
refreshing: app.refreshing,
requestInstall: agentManagement.requestInstall,
runKnowledgeBasePrimaryAction: knowledgeBase.runKnowledgeBasePrimaryAction,
saving: app.saving,
selectSystemSettingsSection: app.selectSystemSettingsSection,
supportsExternalProvider: providerSettings.supportsExternalProvider,
systemSettingsSection: app.systemSettingsSection,
t: app.t,
}
@@ -137,9 +129,7 @@ export function createWorkspaceControllers({
isDirectCreationPending: conversationNavigation.isDirectCreationPending,
modal: app.modal,
openDirect: conversationActions.openDirect,
openProvider: app.openProvider,
pickGroupDirectory: conversationActions.pickGroupDirectory,
providerSummaryLabel: providerSettings.providerSummaryLabel,
readyAgentGroups: agentCatalog.readyAgentGroups,
requestDeleteConfirmation: conversationActions.requestDeleteConfirmation,
requestInstall: agentManagement.requestInstall,
@@ -148,7 +138,6 @@ export function createWorkspaceControllers({
selectedAgentDetail: agentSkills.selectedAgentDetail,
setAgentSidebarVisibility: agentManagement.setAgentSidebarVisibility,
settingsForm: conversationActions.settingsForm,
supportsExternalProvider: providerSettings.supportsExternalProvider,
t: app.t,
}
+2 -2
View File
@@ -596,7 +596,7 @@
<li>Not automatic participant selection from a larger roster.</li>
<li>Not enterprise SSO / RBAC / governance tooling.</li>
<li>Not an operating-system sandbox — writes happen through your shell.</li>
<li>Not fully offline — a selected Agent may send prompts to its configured Provider.</li>
<li>Not fully offline — a selected Agent may send prompts to its own CLI provider.</li>
</ul>
</div>
</div>
@@ -624,7 +624,7 @@
<span class="acc-icon" aria-hidden="true"></span>
</button>
<div class="acc-panel">
<p>No, not by itself. Meldwork runs locally in your Electron work cell. Selected Agents send prompts, attachments, or Skills to <em>their own</em> configured Provider — but Meldwork itself is a local review UI, not a cloud relay. Conversations stay on your machine.</p>
<p>No, not by itself. Meldwork runs locally in your Electron work cell. Selected Agents send prompts, attachments, or Skills to <em>their own</em> CLI provider — but Meldwork itself is a local review UI, not a cloud relay. Conversations stay on your machine.</p>
</div>
</li>
<li class="acc-item reveal" data-reveal style="--d:0.06s">