mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
Merge main into feature/1.0.5
Bring main's22f811c(auth classification from errors[], OpenClaw runtime scope per CLI version, full participant round and cross-review gate for natural discussions) andeac015d(removal of the managed shared Provider) into the V1.0.5 branch before the stability work. Conflict resolution: - local-workspace-agent-catalog.cjs: keep feature's guard that an 'unknown' runtime credential does not downgrade an authenticated Agent; drop the provider-credential-unavailable clause removed on main. The tiered auth policy is reconciled in a follow-up commit. - README.md / README.zh-CN.md: keep feature's V1.0.5 summary and apply main's wording that Agents use their own CLI's model provider. - docs/*: keep feature's untracking of local documentation drafts. - cli-adapters-discovery.test.cjs: both sides were equivalent. Checks: npm --prefix frontend test (325 pass). npm --prefix desktop test: 1503/1515 pass. The 12 failures are pre-existing on feature/1.0.5 atbb55fad(verified by running them on a detached worktree ofbb55fad) and are addressed by the follow-up fixes.
This commit is contained in:
commit
6b6be1403e
84 files changed
+544
-5219
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ Approved Agent Connectors and custom executable Agents use the desktop connector
|
||||
|
||||
Meldwork runs as a local Electron application. Conversations, group configuration, run records, and app-owned attachments stay in the local user data directory. The renderer receives only validated snapshots through a narrow preload API; executable paths, credentials, native session references, Skill paths, and unrestricted shell access remain in the main process.
|
||||
|
||||
Selected Agents may send prompts, attachments, or Skills to their configured model Provider. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that a configured Agent Provider is offline.
|
||||
Selected Agents may send prompts, attachments, or Skills to the model provider configured in their own CLI. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that an Agent's model provider is offline.
|
||||
|
||||
## Download V1.0.5
|
||||
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ Run Ledger 会保留阶段、参与者、尝试、回执、产物、恢复状态
|
||||
|
||||
Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记录和应用管理的附件保存在本地用户数据目录。渲染进程只通过受约束的 preload API 获取校验后的状态;可执行路径、凭据、原生会话引用、Skill 路径和任意 Shell 权限都留在主进程中。
|
||||
|
||||
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送给其配置的模型 Provider。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表已配置的 Agent Provider 一定离线。
|
||||
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
|
||||
|
||||
## 下载 V1.0.5
|
||||
|
||||
|
||||
+3
-3
@@ -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
@@ -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`).
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -219,6 +219,15 @@ function terminalAuthenticationDiagnostic(value) {
|
||||
: ''
|
||||
}
|
||||
|
||||
function authConfigurationFailure(detail) {
|
||||
return String(detail || '').split(/\r?\n/).some((line) => {
|
||||
const text = line.trim().replace(/^(?:error|fatal)\s*:\s*/i, '')
|
||||
return Boolean(terminalAuthenticationDiagnostic(text))
|
||||
|| /^(?:(?:invalid|expired|missing|incorrect)\s+(?:api[ _-]?key|(?:access|refresh|auth)[ _-]?token|credentials?)\b|(?:authentication|authorization)\s+(?:failed|required)\b|(?:please\s+)?(?:log|sign)\s+in\b|not\s+logged\s+in\b|select an auth type\b|provider rejected\b)/i.test(text)
|
||||
|| /^(?:认证|鉴权|登录)(?:失败|过期)|^(?:令牌|凭据|密钥)(?:无效|缺失|已过期)|^请(?:先|重新)登录/.test(text)
|
||||
})
|
||||
}
|
||||
|
||||
function failureCategory(code) {
|
||||
const value = String(code || '')
|
||||
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
|
||||
@@ -299,9 +308,16 @@ function requireTerminalAgentResult(result) {
|
||||
throw agentRuntimeError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
}
|
||||
if (normalized.outcome === 'failed') {
|
||||
const failure = normalized.failure?.code
|
||||
let failure = normalized.failure?.code
|
||||
? normalized.failure
|
||||
: agentRuntimeFailure('LOCAL_AGENT_PROCESS_FAILED')
|
||||
const diagnostic = String(normalized.diagnostic || normalized.text || '')
|
||||
// A structured failure whose diagnostic names an authentication problem is
|
||||
// an authentication failure even when the CLI reported a generic process
|
||||
// error, so the catalog can mark the Agent as needing a fresh login.
|
||||
if (failure.category !== 'authentication' && authConfigurationFailure(diagnostic)) {
|
||||
failure = agentRuntimeFailure('LOCAL_AGENT_AUTH_REQUIRED')
|
||||
}
|
||||
throw agentRuntimeError(
|
||||
failure.code,
|
||||
normalized.diagnostic || normalized.text,
|
||||
@@ -338,6 +354,7 @@ module.exports = {
|
||||
agentRuntimeCapabilities,
|
||||
agentRuntimeError,
|
||||
agentRuntimeFailure,
|
||||
authConfigurationFailure,
|
||||
isCodeReviewAgentKind,
|
||||
isReviewOnlyAgentKind,
|
||||
normalizeAgentResult,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -95,8 +95,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',
|
||||
@@ -218,24 +216,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,
|
||||
@@ -248,8 +228,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,
|
||||
|
||||
@@ -172,17 +172,38 @@ function stepFinishOutcome(reason) {
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
function structuredResultDiagnostic(event) {
|
||||
const parts = []
|
||||
if (typeof event?.error === 'string' && event.error.trim()) parts.push(event.error.trim())
|
||||
else if (event?.error?.message) parts.push(String(event.error.message))
|
||||
if (typeof event?.result === 'string' && event.result.trim()) parts.push(event.result.trim())
|
||||
if (Array.isArray(event?.errors)) {
|
||||
parts.push(...event.errors.map(value => String(value || '').trim()).filter(Boolean))
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function ingestStructuredEvent(state, event) {
|
||||
if (!event || typeof event !== 'object' || Array.isArray(event)) return
|
||||
state.seen = true
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)
|
||||
&& event.type === 'result' && typeof event.result === 'string') {
|
||||
&& event.type === 'result') {
|
||||
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
|
||||
state.failure = null
|
||||
state.diagnostic = ''
|
||||
state.text = ''
|
||||
appendStructuredText(state, event.result, '')
|
||||
state.outcome = event.is_error === true ? 'partial' : 'completed'
|
||||
if (typeof event.result === 'string') {
|
||||
state.failure = null
|
||||
state.diagnostic = ''
|
||||
state.text = ''
|
||||
appendStructuredText(state, event.result, '')
|
||||
state.outcome = event.is_error === true ? 'partial' : 'completed'
|
||||
return
|
||||
}
|
||||
// An error result without a usable result string still carries the CLI's
|
||||
// error list; keep it as the failure diagnostic so authentication and
|
||||
// session problems are classified instead of surfacing as a bare
|
||||
// LOCAL_AGENT_PROCESS_FAILED.
|
||||
if (event.is_error === true || String(event.subtype || '').startsWith('error')) {
|
||||
markStructuredFailure(state, 'LOCAL_AGENT_PROCESS_FAILED', structuredResultDiagnostic(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (structuredEventFailed(event)) {
|
||||
@@ -885,7 +906,8 @@ function classifyCliOutcome(kind, stdout) {
|
||||
function structuredCliError(stdout) {
|
||||
const failed = parseJsonOutputEvents(stdout)
|
||||
.findLast(event => event?.is_error || event?.subtype?.startsWith('error'))
|
||||
return String(failed?.error?.message || failed?.result || '').trim()
|
||||
if (!failed) return ''
|
||||
return structuredResultDiagnostic(failed)
|
||||
}
|
||||
|
||||
function hermesSessionRef(stderr) {
|
||||
|
||||
@@ -2,7 +2,10 @@ const path = require('node:path')
|
||||
const { searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
|
||||
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli-network-environment.cjs')
|
||||
const { redactChildSecrets } = require('./cli-runtime-events.cjs')
|
||||
const { agentRuntimeError, terminalAuthenticationDiagnostic } = require('../agent-runtime-contract.cjs')
|
||||
const {
|
||||
agentRuntimeError,
|
||||
authConfigurationFailure,
|
||||
} = require('../agent-runtime-contract.cjs')
|
||||
const { validateOpenClawRuntimeGuard } = require('./openclaw-runtime.cjs')
|
||||
|
||||
const TERMINATE_GRACE_MS = 500
|
||||
@@ -29,15 +32,6 @@ function agentExecutionError(code, diagnostic = '') {
|
||||
return agentRuntimeError(code, diagnostic)
|
||||
}
|
||||
|
||||
function authConfigurationFailure(detail) {
|
||||
return String(detail || '').split(/\r?\n/).some((line) => {
|
||||
const text = line.trim().replace(/^(?:error|fatal)\s*:\s*/i, '')
|
||||
return Boolean(terminalAuthenticationDiagnostic(text))
|
||||
|| /^(?:(?:invalid|expired|missing|incorrect)\s+(?:api[ _-]?key|(?:access|refresh|auth)[ _-]?token|credentials?)\b|(?:authentication|authorization)\s+(?:failed|required)\b|(?:please\s+)?(?:log|sign)\s+in\b|not\s+logged\s+in\b|select an auth type\b|provider rejected\b)/i.test(text)
|
||||
|| /^(?:认证|鉴权|登录)(?:失败|过期)|^(?:令牌|凭据|密钥)(?:无效|缺失|已过期)|^请(?:先|重新)登录/.test(text)
|
||||
})
|
||||
}
|
||||
|
||||
function invalidSessionFailure(detail) {
|
||||
const text = String(detail || '').trim()
|
||||
if (!text || authConfigurationFailure(text)
|
||||
|
||||
@@ -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,26 +171,7 @@ 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 }) {
|
||||
function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVersion }) {
|
||||
if (!path.isAbsolute(storageRoot) || !path.isAbsolute(workdir)) {
|
||||
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
|
||||
}
|
||||
@@ -204,8 +183,15 @@ function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
|
||||
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
|
||||
}
|
||||
const resolvedWorkdir = path.resolve(workdir)
|
||||
// The managed scope is keyed by session, workspace, and CLI version. OpenClaw
|
||||
// refuses to start on state written by an older CLI ("Legacy workspace setup
|
||||
// state requires migration"), so a version-scoped directory gives an upgraded
|
||||
// CLI a fresh isolated scope instead of a guaranteed startup failure.
|
||||
const versionSegment = String(runtimeVersion || '').trim()
|
||||
const scope = crypto.createHash('sha256')
|
||||
.update(`${sessionRef || 'configure'}\0${resolvedWorkdir}`)
|
||||
.update(versionSegment
|
||||
? `${sessionRef || 'configure'}\0${resolvedWorkdir}\0${versionSegment}`
|
||||
: `${sessionRef || 'configure'}\0${resolvedWorkdir}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24)
|
||||
const runtimeRoot = path.join(resolvedStorageRoot, 'openclaw-managed', scope)
|
||||
@@ -458,74 +444,8 @@ function normalizedNativeModel(input) {
|
||||
return model
|
||||
}
|
||||
|
||||
function managedOpenClawOptions({
|
||||
storageRoot, workdir, sessionRef = '', allowWrite = false, provider,
|
||||
}) {
|
||||
const normalized = normalizeProvider(provider)
|
||||
const configName = allowWrite
|
||||
? 'openclaw.provider.workspace-write.json'
|
||||
: 'openclaw.provider.read-only.json'
|
||||
const runtime = runtimePaths({
|
||||
storageRoot, workdir, sessionRef, configName,
|
||||
})
|
||||
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,
|
||||
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime, openclawVersion,
|
||||
}) {
|
||||
const modelRef = String(runtime?.model || '').trim()
|
||||
const provider = runtime?.provider
|
||||
@@ -557,7 +477,7 @@ function nativeOpenClawOptions({
|
||||
? 'openclaw.native.workspace-write.json'
|
||||
: 'openclaw.native.read-only.json'
|
||||
const runtimePathsResult = runtimePaths({
|
||||
storageRoot, workdir, sessionRef, configName,
|
||||
storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion,
|
||||
})
|
||||
const { resolvedWorkdir, home, state, configPath } = runtimePathsResult
|
||||
const modelConfig = { primary: modelRef }
|
||||
@@ -618,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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-44
@@ -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,33 +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'
|
||||
@@ -542,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,
|
||||
@@ -554,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 = {}) => {
|
||||
@@ -751,8 +726,6 @@ function registerIpc() {
|
||||
openExternalUrl,
|
||||
getOutcomeStore: () => workspace?.outcomeStore,
|
||||
getSkillTrustStore: () => skillTrustStore,
|
||||
providerStore,
|
||||
providerAgentKind,
|
||||
refreshLocalAgentState,
|
||||
registerTrustedHandle,
|
||||
skillCatalog,
|
||||
@@ -785,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'),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,8 +200,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
checkedAt: this.now(),
|
||||
}
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (agent && agent.availabilitySource !== 'provider-credential-unavailable'
|
||||
&& !(credentialState === 'unknown' && agent.authenticated === true)) {
|
||||
if (agent && !(credentialState === 'unknown' && agent.authenticated === true)) {
|
||||
agent.credentialState = credentialState
|
||||
agent.versionIdentified = agentVersionIdentified(agent)
|
||||
agent.compatible = agentCompatible(agent)
|
||||
|
||||
@@ -55,7 +55,21 @@ const v4NaturalDecisionMethods = {
|
||||
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
|
||||
return { status: 'partial' }
|
||||
}
|
||||
if (decision.status === 'completed') return { status: controller.failedKinds.length ? 'partial' : 'completed' }
|
||||
if (decision.status === 'completed') {
|
||||
// The owner may not close the task before selected peers had one discussion
|
||||
// round of their own; otherwise a single full round collapses into an
|
||||
// owner-only review and peers never get to challenge the result.
|
||||
if (activeKinds.some(kind => kind !== owner)
|
||||
&& !this.v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner)) {
|
||||
this.addMessage(
|
||||
group.id, 'system',
|
||||
'The delivery owner signaled completion before peer discussion; running one cross-review round first.',
|
||||
'', threadRootId, { key: 'system.autoCrossReview' },
|
||||
)
|
||||
return { nextKinds: [...activeKinds], ownerReview: false }
|
||||
}
|
||||
return { status: controller.failedKinds.length ? 'partial' : 'completed' }
|
||||
}
|
||||
if (decision.status === 'needs-human' && this.requestTaskDecision) {
|
||||
const response = await this.requestTaskDecision(group, controller, ownerMessage)
|
||||
if (response?.status === 'approved') return { nextKinds: [owner], ownerReview: true }
|
||||
@@ -81,6 +95,19 @@ const v4NaturalDecisionMethods = {
|
||||
return activeKinds.filter(kind => selected.has(kind))
|
||||
},
|
||||
|
||||
v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner) {
|
||||
return this.state().messages.some(message => (
|
||||
message.groupId === group.id
|
||||
&& message.threadRootId === threadRootId
|
||||
&& message.role === 'agent'
|
||||
&& message.agentKind !== owner
|
||||
&& message.trace?.runId === controller.runId
|
||||
&& message.trace?.phase === 'discussion'
|
||||
&& Number(message.trace?.round) < round
|
||||
&& this.v4NaturalMessageMatchesBinding(message, controller)
|
||||
))
|
||||
},
|
||||
|
||||
v4NaturalOwner(group, controller, threadRootId) {
|
||||
let owner = controller.targetKinds[0] || ''
|
||||
for (const message of this.state().messages) {
|
||||
|
||||
@@ -2856,8 +2856,10 @@ class LocalWorkspaceAutoRunner {
|
||||
}
|
||||
} else {
|
||||
round = 2
|
||||
nextKinds = [this.v4NaturalOwner(group, controller, threadRootId)]
|
||||
if (!activeKinds.includes(nextKinds[0])) return 'partial'
|
||||
// The first discussion round is a full participant round: every selected
|
||||
// Agent reacts to the proposal set before the delivery owner reviews and
|
||||
// judges completion, so a group run cannot collapse to owner-only turns.
|
||||
nextKinds = [...activeKinds]
|
||||
}
|
||||
|
||||
while (!controller.signal.aborted) {
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -521,6 +539,45 @@ process.exitCode = 1
|
||||
assert.equal(events.some(event => event.type === 'answer_delta'), true)
|
||||
})
|
||||
|
||||
for (const exitCode of [0, 1]) {
|
||||
test(`WorkBuddy authentication failures classify as auth-required with exit code ${exitCode}`, async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `meldwork-workbuddy-auth-${exitCode}-`))
|
||||
const cli = executable(directory, 'workbuddy-auth-required.cjs', `
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'system', subtype: 'init', session_id: 'workbuddy-auth-session',
|
||||
apiKeySource: 'www.workbuddy.cn',
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'assistant', session_id: 'workbuddy-auth-session',
|
||||
message: { role: 'assistant', content: [
|
||||
{ type: 'text', text: 'Authentication required. Please use /login command to sign in to your account' },
|
||||
] },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'result', subtype: 'error_during_execution', is_error: true,
|
||||
session_id: 'workbuddy-auth-session',
|
||||
errors: ['Authentication required. Please use /login command to sign in to your account'],
|
||||
}) + '\\n')
|
||||
process.exitCode = ${exitCode}
|
||||
`)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
await assert.rejects(
|
||||
runAgent(
|
||||
{ kind: 'workbuddy', executable: cli, name: 'WorkBuddy' },
|
||||
'hello',
|
||||
directory,
|
||||
{ },
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'LOCAL_AGENT_AUTH_REQUIRED')
|
||||
assert.match(error.diagnostic || '', /Authentication required/u)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test('Gemini profile state retains one real tool lifecycle id and summary', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-gemini-profile-state-'))
|
||||
const cli = executable(directory, 'gemini-profile-state.cjs', `
|
||||
@@ -1969,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(
|
||||
@@ -2053,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)
|
||||
|
||||
@@ -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,44 +51,35 @@ function runtimeScope(sessionRef, workdir) {
|
||||
.slice(0, 24)
|
||||
}
|
||||
|
||||
test('managed runtime isolates OpenClaw state and keeps the API key out of config', (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 }))
|
||||
|
||||
const result = managedOpenClawOptions({
|
||||
const base = {
|
||||
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)
|
||||
sessionRef: 'agent:main:desktop-meldwork-scope-openclaw',
|
||||
runtime: nativeRuntime(),
|
||||
}
|
||||
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(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)
|
||||
assert.equal(versionA.env.OPENCLAW_STATE_DIR, versionARepeat.env.OPENCLAW_STATE_DIR)
|
||||
assert.notEqual(versionA.env.OPENCLAW_STATE_DIR, versionB.env.OPENCLAW_STATE_DIR)
|
||||
assert.notEqual(versionA.env.OPENCLAW_STATE_DIR, unversioned.env.OPENCLAW_STATE_DIR)
|
||||
assert.equal(versionB.env.OPENCLAW_STATE_DIR.startsWith(fs.realpathSync(directory)), true)
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -126,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'))
|
||||
@@ -157,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 } },
|
||||
@@ -166,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'))
|
||||
@@ -183,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)
|
||||
@@ -199,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',
|
||||
@@ -265,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
|
||||
|
||||
@@ -343,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({
|
||||
@@ -401,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
|
||||
|
||||
@@ -436,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 }))
|
||||
@@ -461,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'))
|
||||
|
||||
@@ -477,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: {
|
||||
@@ -498,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)
|
||||
@@ -515,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)))
|
||||
})
|
||||
|
||||
@@ -571,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 = [
|
||||
@@ -592,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,
|
||||
@@ -634,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), [])
|
||||
}
|
||||
@@ -660,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)
|
||||
@@ -717,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
|
||||
@@ -735,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`
|
||||
@@ -753,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)
|
||||
@@ -774,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)
|
||||
@@ -802,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(
|
||||
@@ -831,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,
|
||||
@@ -857,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)
|
||||
@@ -871,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)
|
||||
|
||||
|
||||
@@ -124,7 +124,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 () => [
|
||||
{
|
||||
@@ -156,13 +156,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(
|
||||
@@ -195,9 +189,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 {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ for (const nextKinds of [undefined, []]) {
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Prose is not a dispatch', agentKinds: ['codex', 'hermes'], workdir: directory, allowWrite: false })
|
||||
const controller = await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 4 })
|
||||
assert.deepEqual(turns, ['codex'])
|
||||
assert.deepEqual(turns, ['codex', 'hermes', 'codex'])
|
||||
assert.equal(ledger.get(controller.runId).status, 'completed')
|
||||
const reopened = new RunLedger({ storagePath: ledger.storagePath })
|
||||
assert.deepEqual(reopened.get(controller.runId).agentRuns.at(-1).context.taskDecision.nextKinds, nextKinds)
|
||||
@@ -120,22 +120,25 @@ test('Natural routing retains a request beyond the stored message excerpt', asyn
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const turns = []
|
||||
const workspace = new LocalWorkspace({
|
||||
...options, naturalAgentResponses: true,
|
||||
runAgent: async (agent, prompt) => {
|
||||
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial contribution.' }
|
||||
turns.push(agent.kind)
|
||||
return { outcome: 'completed', text: turns.length === 1
|
||||
? decisionReply(`${'x'.repeat(21000)}\n@hermes Please verify the missing evidence.`, 'continue',
|
||||
{ reason: 'Request evidence verification.', nextKinds: ['hermes'] })
|
||||
: turns.length === 2 ? 'The evidence is unavailable.'
|
||||
: decisionReply('Required evidence is unavailable.', 'blocked') }
|
||||
},
|
||||
})
|
||||
const codexDiscussionTurns = new Map()
|
||||
options.naturalAgentResponses = true
|
||||
options.runAgent = async (agent, prompt) => {
|
||||
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial contribution.' }
|
||||
turns.push(agent.kind)
|
||||
const turn = (codexDiscussionTurns.get(agent.kind) || 0) + 1
|
||||
codexDiscussionTurns.set(agent.kind, turn)
|
||||
if (agent.kind === 'codex' && turn === 1) {
|
||||
return { outcome: 'completed', text: decisionReply(`${'x'.repeat(21000)}\n@hermes Please verify the missing evidence.`, 'continue',
|
||||
{ reason: 'Request evidence verification.', nextKinds: ['hermes'] }) }
|
||||
}
|
||||
if (agent.kind === 'hermes') return { outcome: 'completed', text: 'The evidence is unavailable.' }
|
||||
return { outcome: 'completed', text: decisionReply('Required evidence is unavailable.', 'blocked') }
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({ name: 'Long route', agentKinds: ['codex', 'hermes'], workdir: directory, allowWrite: false })
|
||||
await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 4 })
|
||||
assert.deepEqual(turns, ['codex', 'hermes', 'codex'])
|
||||
assert.deepEqual(turns, ['codex', 'hermes', 'hermes', 'codex'])
|
||||
})
|
||||
|
||||
test('Natural discussion does not complete when a peer reports an explicit blocker', async t => {
|
||||
@@ -226,12 +229,15 @@ for (const handoffTo of [undefined, 'hermes', 'ghost']) {
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
|
||||
const turns = []
|
||||
const discussionTurns = new Map()
|
||||
const workspace = new LocalWorkspace({
|
||||
...options, runLedger: ledger, naturalAgentResponses: true,
|
||||
runAgent: async (agent, prompt) => {
|
||||
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial answer.', sessionRef: `${agent.kind}-task-session` }
|
||||
turns.push(agent.kind)
|
||||
return { outcome: 'completed', sessionRef: `${agent.kind}-task-session`, text: turns.length === 1
|
||||
const turn = (discussionTurns.get(agent.kind) || 0) + 1
|
||||
discussionTurns.set(agent.kind, turn)
|
||||
return { outcome: 'completed', sessionRef: `${agent.kind}-task-session`, text: agent.kind === 'codex' && turn === 1
|
||||
? decisionReply('More work remains.', 'continue', handoffTo ? { handoffTo } : {})
|
||||
: decisionReply('The requested work is complete.') }
|
||||
},
|
||||
@@ -241,7 +247,10 @@ for (const handoffTo of [undefined, 'hermes', 'ghost']) {
|
||||
await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 5 })
|
||||
const record = ledger.list(group.id)[0]
|
||||
assert.equal(record.status, handoffTo === 'ghost' ? 'partial' : 'completed', record.reason)
|
||||
assert.deepEqual(turns, handoffTo === 'ghost' ? ['codex'] : ['codex', handoffTo || 'codex'])
|
||||
assert.deepEqual(turns, handoffTo === 'ghost'
|
||||
? ['codex', 'hermes']
|
||||
: handoffTo === 'hermes' ? ['codex', 'hermes', 'hermes']
|
||||
: ['codex', 'hermes', 'codex'])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -315,6 +324,68 @@ test('Natural sequential V4 runs every Agent once per round in configured CLI or
|
||||
)).length, 1)
|
||||
})
|
||||
|
||||
test('Natural V4 requires a prior peer discussion round before the owner completes', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const workspace = new LocalWorkspace({ ...options, naturalAgentResponses: true })
|
||||
await workspace.refreshAgents()
|
||||
const group = { id: 'group' }
|
||||
const controller = {
|
||||
runId: 'run', targetKinds: ['codex', 'hermes'], failedKinds: [],
|
||||
discussionStyle: 'agent-led',
|
||||
}
|
||||
const systemKeys = []
|
||||
workspace.autoRunner.state = () => ({ messages: [] })
|
||||
workspace.autoRunner.v4NaturalMessageMatchesBinding = () => true
|
||||
workspace.autoRunner.addMessage = (groupId, role, text, _target, threadRootId, metadata) => {
|
||||
systemKeys.push(metadata?.key)
|
||||
return { id: `system-${systemKeys.length}` }
|
||||
}
|
||||
const completedDecision = {
|
||||
summary: 'Done.', taskDecision: {
|
||||
status: 'completed', reason: 'Ready.', deliverables: ['The answer.'],
|
||||
},
|
||||
}
|
||||
|
||||
// A solo owner round with a completion judgment and no earlier peer
|
||||
// discussion round cannot close the task; it must dispatch one cross-review
|
||||
// round with every active participant first.
|
||||
workspace.autoRunner.state = () => ({
|
||||
messages: [{
|
||||
groupId: 'group', threadRootId: 'thread', role: 'agent', agentKind: 'codex',
|
||||
trace: { phase: 'discussion', runId: 'run', round: 2, context: completedDecision },
|
||||
}],
|
||||
})
|
||||
assert.deepEqual(
|
||||
await workspace.autoRunner.v4NaturalRoundOutcome(
|
||||
group, controller, 'thread', 2, ['codex', 'hermes'],
|
||||
),
|
||||
{ nextKinds: ['codex', 'hermes'], ownerReview: false },
|
||||
)
|
||||
assert.deepEqual(systemKeys, ['system.autoCrossReview'])
|
||||
|
||||
// Once a peer contributed to an earlier discussion round the same judgment
|
||||
// completes the run.
|
||||
workspace.autoRunner.state = () => ({
|
||||
messages: [
|
||||
{
|
||||
groupId: 'group', threadRootId: 'thread', role: 'agent', agentKind: 'hermes',
|
||||
trace: { phase: 'discussion', runId: 'run', round: 1, context: null },
|
||||
},
|
||||
{
|
||||
groupId: 'group', threadRootId: 'thread', role: 'agent', agentKind: 'codex',
|
||||
trace: { phase: 'discussion', runId: 'run', round: 2, context: completedDecision },
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.deepEqual(
|
||||
await workspace.autoRunner.v4NaturalRoundOutcome(
|
||||
group, controller, 'thread', 2, ['codex', 'hermes'],
|
||||
),
|
||||
{ status: 'completed' },
|
||||
)
|
||||
})
|
||||
|
||||
for (const discussionStyle of ['sequential', 'agent-led']) {
|
||||
test(`Natural ${discussionStyle} V4 reports a one-round budget stop without claiming delivery`, async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
@@ -406,12 +477,12 @@ test('Natural Agent-led V4 uses the full first-round transcript and inline menti
|
||||
|
||||
assert.deepEqual(calls.map(call => `${call.phase}:${call.kind}`), [
|
||||
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
|
||||
'discussion:codex',
|
||||
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:codex',
|
||||
])
|
||||
assert.equal(maxProposalRunning, 3)
|
||||
assert.equal(maxRoutedRunning, 2)
|
||||
assert.equal(maxRoutedRunning, 3)
|
||||
const coordinator = calls[3]
|
||||
for (const sentinel of tails.values()) assert.match(coordinator.prompt, new RegExp(sentinel))
|
||||
assert.ok(coordinator.prompt.length > 24000)
|
||||
@@ -428,7 +499,7 @@ test('Natural Agent-led V4 uses the full first-round transcript and inline menti
|
||||
))
|
||||
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
|
||||
'1:codex', '1:hermes', '1:workbuddy',
|
||||
'2:codex',
|
||||
'2:codex', '2:hermes', '2:workbuddy',
|
||||
'3:hermes', '3:workbuddy',
|
||||
'4:codex',
|
||||
])
|
||||
@@ -456,7 +527,7 @@ test('Natural routing uses explicit canonical recipients independently of displa
|
||||
}
|
||||
})
|
||||
|
||||
test('Natural Agent-led V4 preserves chosen peers without forced participation and supplies recent context', async (t) => {
|
||||
test('Natural Agent-led V4 keeps later rounds routed and supplies recent context', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.naturalAgentResponses = true
|
||||
@@ -479,18 +550,23 @@ test('Natural Agent-led V4 preserves chosen peers without forced participation a
|
||||
workdir: directory, allowWrite: false,
|
||||
})
|
||||
await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 5 })
|
||||
assert.equal(calls.some(call => call.kind === 'workbuddy' && call.phase === 'discussion'), false)
|
||||
const peer = calls.at(-1)
|
||||
assert.equal(peer.kind, 'hermes')
|
||||
const workbuddyDiscussionCalls = calls.filter(call => (
|
||||
call.kind === 'workbuddy' && call.phase === 'discussion'
|
||||
))
|
||||
// WorkBuddy joins the opening full round, then never runs again without an
|
||||
// explicit peer request.
|
||||
assert.equal(workbuddyDiscussionCalls.length, 1)
|
||||
assert.equal(workbuddyDiscussionCalls[0].prompt.includes('codex proposes an independent approach'), true)
|
||||
assert.equal(workbuddyDiscussionCalls[0].prompt.includes('hermes proposes an independent approach'), true)
|
||||
const firstPeerTurn = calls.find(call => call.kind === 'hermes' && call.phase === 'discussion')
|
||||
assert.match(firstPeerTurn.prompt, /Round 2 - @codex/)
|
||||
assert.match(firstPeerTurn.prompt, /workbuddy proposes an independent approach/)
|
||||
assert.doesNotMatch(peer.prompt, /Round 2 - @codex/)
|
||||
assert.match(peer.prompt, /Previously delivered turns are not repeated/)
|
||||
assert.match(peer.prompt, /Round 3 - @hermes/)
|
||||
assert.match(peer.prompt, /Round 4 - @codex/)
|
||||
assert.match(peer.prompt, /beside its concrete question/)
|
||||
assert.doesNotMatch(peer.prompt, /final non-empty line/)
|
||||
assert.match(firstPeerTurn.prompt, /Round 1 - @codex/u)
|
||||
assert.match(firstPeerTurn.prompt, /workbuddy proposes an independent approach/u)
|
||||
const laterPeerTurn = calls.filter(call => (
|
||||
call.kind === 'hermes' && call.phase === 'discussion'
|
||||
)).at(-1)
|
||||
assert.match(laterPeerTurn.prompt, /Previously delivered turns are not repeated/u)
|
||||
assert.match(laterPeerTurn.prompt, /beside its concrete question/u)
|
||||
assert.doesNotMatch(laterPeerTurn.prompt, /final non-empty line/u)
|
||||
})
|
||||
|
||||
test('Natural Agent-led V4 keeps a peer request when another concurrent peer accepts', async (t) => {
|
||||
@@ -500,15 +576,16 @@ test('Natural Agent-led V4 keeps a peer request when another concurrent peer acc
|
||||
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
|
||||
options.runLedger = ledger
|
||||
const calls = []
|
||||
let codexTurns = 0
|
||||
const discussionTurns = new Map()
|
||||
options.runAgent = async (agent, prompt, _workdir, runOptions) => {
|
||||
const phase = naturalPhase(prompt)
|
||||
calls.push(`${phase}:${agent.kind}`)
|
||||
if (phase === 'discussion' && agent.kind === 'codex') codexTurns += 1
|
||||
const turn = (discussionTurns.get(agent.kind) || 0) + (phase === 'discussion' ? 1 : 0)
|
||||
discussionTurns.set(agent.kind, turn)
|
||||
return {
|
||||
text: phase === 'proposal' ? `${agent.kind} proposal.`
|
||||
: agent.kind === 'codex' && codexTurns === 1 ? decisionReply('@hermes assess feasibility; @workbuddy validate risks.', 'continue', { nextKinds: ['hermes', 'workbuddy'] })
|
||||
: agent.kind === 'workbuddy' ? decisionReply('@codex address this remaining risk before proceeding.', 'continue', { nextKinds: ['codex'] })
|
||||
: agent.kind === 'codex' && turn === 1 ? decisionReply('@hermes assess feasibility; @workbuddy validate risks.', 'continue', { nextKinds: ['hermes', 'workbuddy'] })
|
||||
: agent.kind === 'workbuddy' && turn === 1 ? decisionReply('@codex address this remaining risk before proceeding.', 'continue', { nextKinds: ['codex'] })
|
||||
: decisionReply('I accept the current result without changes.'),
|
||||
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
|
||||
}
|
||||
@@ -524,7 +601,9 @@ test('Natural Agent-led V4 keeps a peer request when another concurrent peer acc
|
||||
})
|
||||
assert.deepEqual(calls, [
|
||||
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
|
||||
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy', 'discussion:codex',
|
||||
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:codex',
|
||||
])
|
||||
assert.equal(ledger.get(controller.runId).status, 'completed')
|
||||
})
|
||||
@@ -661,7 +740,7 @@ test('Natural Agent-led V4 reuses partially committed routed results after resta
|
||||
|
||||
assert.deepEqual(initialCalls.map(call => `${call.phase}:${call.kind}`), [
|
||||
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
|
||||
'discussion:codex',
|
||||
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:codex',
|
||||
])
|
||||
@@ -705,7 +784,7 @@ test('Natural Agent-led V4 reuses partially committed routed results after resta
|
||||
))
|
||||
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
|
||||
'1:codex', '1:hermes', '1:workbuddy',
|
||||
'2:codex',
|
||||
'2:codex', '2:hermes', '2:workbuddy',
|
||||
'3:hermes', '3:workbuddy',
|
||||
'4:codex',
|
||||
])
|
||||
@@ -1135,10 +1214,11 @@ test('Natural Agent-led V4 does not fabricate completion or force a peer review
|
||||
|
||||
assert.deepEqual(calls.map(call => `${call.phase}:${call.kind}`), [
|
||||
'proposal:codex', 'proposal:hermes',
|
||||
'discussion:codex', 'discussion:hermes',
|
||||
'discussion:codex',
|
||||
])
|
||||
assert.equal(maxProposalRunning, 2)
|
||||
assert.equal(maxRoutedRunning, 1)
|
||||
assert.equal(maxRoutedRunning, 2)
|
||||
assert.match(
|
||||
calls.find(call => call.phase === 'discussion').prompt,
|
||||
/Silence from peers is not evidence of completion/u,
|
||||
@@ -1147,7 +1227,7 @@ test('Natural Agent-led V4 does not fabricate completion or force a peer review
|
||||
message.role === 'agent' && message.threadRootId === controller.threadRootId
|
||||
))
|
||||
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
|
||||
'1:codex', '1:hermes', '2:codex',
|
||||
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:codex',
|
||||
])
|
||||
assert.equal(ledger.get(controller.runId).status, 'partial')
|
||||
})
|
||||
@@ -1195,14 +1275,15 @@ test('Natural Agent-led V4 routes a single mention serially and completes after
|
||||
assert.deepEqual(calls, [
|
||||
'proposal:codex', 'proposal:hermes',
|
||||
'discussion:codex', 'discussion:hermes',
|
||||
'discussion:hermes',
|
||||
'discussion:codex',
|
||||
])
|
||||
assert.equal(maxRoutedRunning, 1)
|
||||
assert.equal(maxRoutedRunning, 2)
|
||||
const messages = workspace.snapshot().messages.filter(message => (
|
||||
message.role === 'agent' && message.threadRootId === controller.threadRootId
|
||||
))
|
||||
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
|
||||
'1:codex', '1:hermes', '2:codex', '3:hermes', '4:codex',
|
||||
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:hermes', '4:codex',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1240,13 +1321,14 @@ test('Natural Agent-led V4 does not accept a self-only route without a task deci
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'proposal:codex', 'proposal:hermes',
|
||||
'discussion:codex', 'discussion:hermes',
|
||||
'discussion:codex',
|
||||
])
|
||||
const messages = workspace.snapshot().messages.filter(message => (
|
||||
message.role === 'agent' && message.threadRootId === controller.threadRootId
|
||||
))
|
||||
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
|
||||
'1:codex', '1:hermes', '2:codex',
|
||||
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:codex',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1287,7 +1369,7 @@ test('Natural Agent-led V4 does not treat concurrent self-routes as peer confirm
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
|
||||
'discussion:codex',
|
||||
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:hermes', 'discussion:workbuddy',
|
||||
'discussion:codex',
|
||||
])
|
||||
@@ -1296,7 +1378,8 @@ test('Natural Agent-led V4 does not treat concurrent self-routes as peer confirm
|
||||
))
|
||||
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
|
||||
'1:codex', '1:hermes', '1:workbuddy',
|
||||
'2:codex', '3:hermes', '3:workbuddy',
|
||||
'2:codex', '2:hermes', '2:workbuddy',
|
||||
'3:hermes', '3:workbuddy',
|
||||
'4:codex',
|
||||
])
|
||||
})
|
||||
@@ -1578,9 +1661,9 @@ test('Natural Agent-led V4 recovers the bound owner decision after a stale messa
|
||||
if (!crashCaptured
|
||||
&& input.agentKind === 'codex'
|
||||
&& input.metadata?.trace?.phase === 'discussion'
|
||||
&& Number(input.metadata?.trace?.round) === 2
|
||||
&& Number(input.metadata?.trace?.round) === 3
|
||||
&& durable?.orchestration?.phase === 'discussion'
|
||||
&& durable.orchestration.round === 2
|
||||
&& durable.orchestration.round === 3
|
||||
&& durable.orchestration.pendingKinds.join(',') === 'codex') {
|
||||
crashCaptured = true
|
||||
recoveryLedger.checkpoint(durable)
|
||||
@@ -1603,14 +1686,14 @@ test('Natural Agent-led V4 recovers the bound owner decision after a stale messa
|
||||
const crashRecord = await crashCheckpoint.promise
|
||||
await initialController.promise
|
||||
const originalCoordinatorRun = crashRecord.agentRuns.find(run => (
|
||||
run.round === 2 && run.kind === 'codex' && run.status === 'completed'
|
||||
run.round === 3 && run.kind === 'codex' && run.status === 'completed'
|
||||
))
|
||||
assert.ok(originalCoordinatorRun)
|
||||
const recoveryState = JSON.parse(fs.readFileSync(recoveryStoragePath, 'utf8'))
|
||||
const staleCoordinatorMessage = recoveryState.messages.find(message => (
|
||||
message.role === 'agent'
|
||||
&& message.threadRootId === crashRecord.threadRootId
|
||||
&& message.trace?.round === 2
|
||||
&& message.trace?.round === 3
|
||||
&& message.agentKind === 'codex'
|
||||
))
|
||||
assert.ok(staleCoordinatorMessage)
|
||||
@@ -1643,10 +1726,10 @@ test('Natural Agent-led V4 recovers the bound owner decision after a stale messa
|
||||
message.role === 'agent' && message.threadRootId === crashRecord.threadRootId
|
||||
))
|
||||
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
|
||||
'1:codex', '1:hermes', '2:codex',
|
||||
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:codex',
|
||||
])
|
||||
const coordinatorMessage = messages.find(message => (
|
||||
message.trace.round === 2 && message.agentKind === 'codex'
|
||||
message.trace.round === 3 && message.agentKind === 'codex'
|
||||
))
|
||||
assert.equal(coordinatorMessage.trace.agentRunId, originalCoordinatorRun.agentRunId)
|
||||
assertTurnParity(recoveredRecord, messages)
|
||||
|
||||
@@ -102,8 +102,19 @@ for (const writerKind of ['hermes', '']) {
|
||||
const result = ledger.list(group.id)[0]
|
||||
assert.equal(result.status, 'completed', JSON.stringify(workspace.snapshot().messages.map(message => message.content)))
|
||||
assert.equal(result.orchestration.discussionWriterKind || '', writerKind)
|
||||
assert.deepEqual(calls.filter(call => call.sandbox === 'workspace-write'), writerKind
|
||||
? [{ kind: writerKind, discussion: true, sandbox: 'workspace-write' }] : [])
|
||||
const writeCalls = calls.filter(call => call.sandbox === 'workspace-write')
|
||||
if (writerKind) {
|
||||
// The writer may join both the opening full round and a later owner
|
||||
// review round, but only the declared writer ever receives write access.
|
||||
assert.ok(writeCalls.length >= 1)
|
||||
assert.ok(writeCalls.every(call => (
|
||||
call.kind === writerKind && call.discussion === true
|
||||
)))
|
||||
assert.ok(calls.filter(call => call.sandbox !== 'workspace-write')
|
||||
.every(call => call.sandbox === 'read-only'))
|
||||
} else {
|
||||
assert.deepEqual(writeCalls, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -68,13 +68,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"
|
||||
@@ -82,7 +80,6 @@
|
||||
@open-agent-manager="openAgentManager"
|
||||
@open-direct="openDirect"
|
||||
@open-new-group="openNewGroup"
|
||||
@open-provider="openProvider"
|
||||
@refresh-agents="refreshAgents"
|
||||
@select-group="selectGroup"
|
||||
/>
|
||||
@@ -230,7 +227,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'
|
||||
@@ -304,7 +300,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)
|
||||
@@ -592,13 +587,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,
|
||||
@@ -616,14 +604,6 @@ const composerContext = useComposerContext({
|
||||
t,
|
||||
theme,
|
||||
})
|
||||
const {
|
||||
configurableProviderAgents,
|
||||
loadProviderWorkspace,
|
||||
providerConfiguredCount,
|
||||
providerRemoveArmed,
|
||||
selectedProviderKind,
|
||||
supportsExternalProvider,
|
||||
} = providerSettings
|
||||
systemSettingsNavigation = useSystemSettingsNavigation({
|
||||
activeView,
|
||||
closeModal,
|
||||
@@ -631,12 +611,8 @@ systemSettingsNavigation = useSystemSettingsNavigation({
|
||||
formError,
|
||||
installConfirmKind,
|
||||
loadKnowledgeBaseStatuses,
|
||||
loadProviderWorkspace,
|
||||
modal,
|
||||
providerRemoveArmed,
|
||||
saving,
|
||||
selectedProviderKind,
|
||||
supportsExternalProvider,
|
||||
systemSettingsSection,
|
||||
})
|
||||
const {
|
||||
@@ -883,7 +859,6 @@ const {
|
||||
isGroupRunning,
|
||||
modal,
|
||||
openAgentManager,
|
||||
openProvider,
|
||||
openSystemSettings,
|
||||
productMark,
|
||||
refreshing,
|
||||
@@ -906,7 +881,6 @@ const {
|
||||
conversationActions,
|
||||
conversationNavigation,
|
||||
knowledgeBase: knowledgeBaseSettings,
|
||||
providerSettings,
|
||||
timeline: conversationTimeline,
|
||||
})
|
||||
|
||||
@@ -951,7 +925,6 @@ const { booting, bridgeMissing } = useDesktopWorkspaceLifecycle({
|
||||
agentDiscoveryPending.value = true
|
||||
void refreshAgents().finally(() => { agentDiscoveryPending.value = false })
|
||||
}
|
||||
void loadProviderWorkspace()
|
||||
void loadKnowledgeBaseStatuses()
|
||||
},
|
||||
beforeBoot: () => {
|
||||
@@ -964,7 +937,6 @@ const { booting, bridgeMissing } = useDesktopWorkspaceLifecycle({
|
||||
handleRunFinished,
|
||||
installer,
|
||||
installerState,
|
||||
provider,
|
||||
refreshAgents,
|
||||
showError,
|
||||
snapshot,
|
||||
@@ -1039,10 +1011,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
|
||||
@@ -1051,7 +1019,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')
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -1270,85 +1267,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({
|
||||
@@ -2005,8 +1923,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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -126,7 +124,6 @@ const {
|
||||
inlineTitleDraft,
|
||||
inlineTitleEditing,
|
||||
openGroupSettings,
|
||||
providerModeShortLabel,
|
||||
saveInlineTitle,
|
||||
saving,
|
||||
sending,
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
|
||||
+4
-123
@@ -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',
|
||||
@@ -805,6 +689,7 @@ export default {
|
||||
'system.autoStopped': 'Automatic discussion stopped: {reason}',
|
||||
'system.autoDiscussionStalled': 'Automatic discussion stopped because repeated handoffs produced no new contribution.',
|
||||
'system.autoRoundLimit': 'Automatic discussion reached the {rounds}-round limit without reaching consensus.',
|
||||
'system.autoCrossReview': 'The delivery owner signaled completion before peer discussion; one cross-review round runs first.',
|
||||
'system.autoTaskDecisionMissing': 'Discussion stopped without a valid task completion decision.',
|
||||
'system.autoTaskBlocked': 'Task blocked: {reason}',
|
||||
'system.autoTaskNeedsHuman': 'Human decision needed: {reason}',
|
||||
@@ -839,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.',
|
||||
|
||||
+4
-123
@@ -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': '确认',
|
||||
@@ -805,6 +689,7 @@ export default {
|
||||
'system.autoStopped': '自动讨论已停止:{reason}',
|
||||
'system.autoDiscussionStalled': '自动讨论因重复转交且没有新增内容而停止。',
|
||||
'system.autoRoundLimit': '自动讨论已达到 {rounds} 轮上限,尚未达成共识。',
|
||||
'system.autoCrossReview': '交付负责人在同伴讨论前判断任务完成;将先进行一轮交叉审查。',
|
||||
'system.autoTaskDecisionMissing': '讨论已停止,尚无有效的任务完成判断。',
|
||||
'system.autoTaskBlocked': '任务受阻:{reason}',
|
||||
'system.autoTaskNeedsHuman': '需要人类决定:{reason}',
|
||||
@@ -839,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': '请输入消息。',
|
||||
|
||||
@@ -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 '' }
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -273,7 +273,6 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.agent-provider-mode,
|
||||
.agent-version {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export function createWorkspaceControllers({
|
||||
conversationActions,
|
||||
conversationNavigation,
|
||||
knowledgeBase,
|
||||
providerSettings,
|
||||
timeline,
|
||||
}) {
|
||||
const workspaceSidebarController = {
|
||||
@@ -71,7 +70,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,
|
||||
@@ -89,21 +87,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,
|
||||
}
|
||||
@@ -134,9 +126,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,
|
||||
@@ -145,7 +135,6 @@ export function createWorkspaceControllers({
|
||||
selectedAgentDetail: agentSkills.selectedAgentDetail,
|
||||
setAgentSidebarVisibility: agentManagement.setAgentSidebarVisibility,
|
||||
settingsForm: conversationActions.settingsForm,
|
||||
supportsExternalProvider: providerSettings.supportsExternalProvider,
|
||||
t: app.t,
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user