diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index e1d67cd107..9841be6533 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -4874,10 +4874,10 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "btw.work_loop.coding_agents": { "description": "第三方编码代理", "type": "list", - "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 会以该 CLI 原生配置层的形式生效,不改动用户全局配置。权限模式与沙箱是该 CLI 自己执行的策略,不是操作系统级隔离。委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local(sandbox 下委派等于绕过沙箱),并通过 tool.local_exec 与 tool.file_write 的授权。Claude Code 以非交互方式(-p)运行,没有终端可以回答权限询问:acceptEdits 只自动放行编辑,需要跑 shell 命令的任务会一直等到超时,这类任务要显式选择 bypassPermissions。", + "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、模型、输出上限、权限模式与可写目录;委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local(sandbox 下委派等于绕过沙箱),并通过 tool.local_exec 与 tool.file_write 的授权。Claude Code 以非交互方式(-p)运行,没有终端可以回答权限询问:acceptEdits 只自动放行编辑,需要跑 shell 命令的任务会一直等到超时,这类任务要显式选择 bypassPermissions。代理使用的 provider 由「第三方agent配置」页面统一管理,切换的是该 CLI 自己的全局配置。", "_special": "select_coding_agents", - # The editor is a list of cards holding a nested preset list; half - # of a row is not enough to lay one out. + # The editor is a list of cards, one per agent; half of a row is + # not enough to lay one out. "full_width": True, "condition": {"btw.work_loop.enabled": True}, }, diff --git a/dashboard/src/api/v1/codingCli.ts b/dashboard/src/api/v1/codingCli.ts index 60dd79acad..f93c74edc4 100644 --- a/dashboard/src/api/v1/codingCli.ts +++ b/dashboard/src/api/v1/codingCli.ts @@ -30,6 +30,21 @@ export interface CodingCliState { providers: CodingCliProvider[]; } +/** + * One provider as `btw.cli_providers` stores it. + * + * `api_key` is what the operator typed in the field and nothing else. A config + * response writes the marker `__ASTRBOT_REDACTED__` where a stored key would + * be, so the editor treats it as "a key is stored, the field is empty": the + * marker goes back only for the entry it came from, and only a switch ever + * writes a value into the CLI's own file. + */ +export interface StoredCliProvider extends CodingCliProvider { + /** The CLI this provider is scoped to; empty means either CLI may use it. */ + cli: string; + api_key: string; +} + export interface CodingCliStatePayload { clis: CodingCliState[]; } diff --git a/dashboard/src/components/shared/CodingAgentsEditor.vue b/dashboard/src/components/shared/CodingAgentsEditor.vue index 3dd8615b78..e43693a92d 100644 --- a/dashboard/src/components/shared/CodingAgentsEditor.vue +++ b/dashboard/src/components/shared/CodingAgentsEditor.vue @@ -6,6 +6,12 @@ enforces in `astrbot/core/agent/btw/coding_agents.py`; that module stays the source of truth, and a change there has to be mirrored here. + A preset list used to live inside each entry, naming the endpoint the run was + layered with. The Dashboard stopped offering it: a CLI's provider is switched + on the third-party agent page, which rewrites that CLI's own configuration, + and a delegated run reads the same file. Saving here drops the stored preset + fields, so a profile cannot keep an invisible setting alive. + Nothing is emitted until the operator edits something. The configuration page treats a change to the profile as unsaved work, so normalizing on load would raise that banner before anyone typed. @@ -301,210 +307,18 @@ " /> - -
-
- {{ tm('codingAgentsEditor.providers') }} -
-
- {{ - entry.type === 'custom' - ? tm('codingAgentsEditor.providersCustomHint') - : tm('codingAgentsEditor.providersHint') - }} -
- -
- -
- - - {{ tm('codingAgentsEditor.noProviders') }} - - - - - - - - - - - - - - - - - - - - - - - - -
- - {{ tm('codingAgentsEditor.removeProvider') }} - -
-
-
- -
- - {{ tm('codingAgentsEditor.addProvider') }} - -
-
+ + diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 7067b2954e..ab450d2b26 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -51,5 +51,5 @@ }, "pluginWebui": "Plugin Pages", "btw": "BTW Dual Loop", - "cliConfig": "CLI Global Config" + "thirdPartyAgents": "Third-party Agent Config" } diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index a94ac21dff..1ce6668c43 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -496,7 +496,7 @@ }, "coding_agents": { "description": "Third-party coding agents", - "hint": "Local CLI agents that can be handed write tasks (for example Claude Code or Codex). Each entry carries an id, type, command, permission mode, and provider presets. A provider's endpoint and model take effect as that CLI's own config layer, and its key is passed to the child process for the length of the run rather than written into that layer. The key itself is stored in this AstrBot profile, like any other provider credential. A delegation starts a local process and writes files, so the work loop's Computer Use runtime must be `local`: `sandbox` is not enough, because the process is started on the host rather than inside the sandbox. `tool.local_exec` and `tool.file_write` must both be authorized." + "hint": "Local CLI agents that can be handed write tasks (for example Claude Code or Codex). Each entry carries an id, type, command, model, output cap, permission mode, and the extra directory it may write to. A delegation starts a local process and writes files, so the work loop's Computer Use runtime must also be local (delegating under sandbox would be the way out of it), and `tool.local_exec` and `tool.file_write` must both be authorized. Claude Code runs non-interactively under `-p`, so there is no terminal to answer a permission prompt: `acceptEdits` approves edits alone, and a task that runs shell commands waits until it times out unless `bypassPermissions` is chosen deliberately. The provider an agent runs against is managed on the Third-party Agent Config page, which switches that CLI's own global configuration." } }, "work_session": { diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 11d2377571..1e2cb370ed 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -239,7 +239,7 @@ "commandHint": "The executable to run. Empty uses the command shown as the placeholder.", "missingCommandWarning": "A custom agent without a command is ignored when the configuration is read.", "model": "Model", - "modelHint": "Empty uses the active preset's model.", + "modelHint": "Empty uses the CLI's own default model.", "permissionMode": "Permission mode", "permissionModeHint": "Passed to Claude Code as --permission-mode. A delegated run is non-interactive, so it approves edits only: a task that runs shell commands waits for a confirmation nothing can give. Choose bypassPermissions deliberately when the task needs them.", "riskyPermissionWarning": "bypassPermissions lets the CLI run without asking. Only use it where the task folder can be discarded.", @@ -255,23 +255,7 @@ "envHint": "Added to the child process only. One NAME=value per entry.", "addEnv": "Add variable", "timeoutSeconds": "Timeout (seconds)", - "maxOutputChars": "Output limit (characters)", - "providers": "Provider presets", - "providersHint": "The active preset's endpoint and model take effect as this CLI's own config layer. Its key is passed to the child process for the length of the run rather than written into that layer; the key itself is stored in this AstrBot profile.", - "providersCustomHint": "A custom CLI does not read a generated config layer; give it credentials through Environment instead.", - "noProviders": "No preset. The CLI falls back to the account you already signed in with.", - "activeProvider": "Active preset", - "activeProviderHint": "The preset handed to this CLI.", - "addProvider": "Add preset", - "removeProvider": "Remove preset", - "providerId": "ID", - "providerName": "Name", - "providerBaseUrl": "Base URL", - "providerModel": "Model", - "providerApiKey": "API key", - "providerApiKeyHint": "Passed to the child process for the length of the run. It is stored in this AstrBot profile, and is not written into the CLI's own configuration.", - "providerWireApi": "Wire API", - "providerWireApiHint": "Codex only. \"responses\" is the default; \"chat\" selects the chat completions API." + "maxOutputChars": "Output limit (characters)" }, "btwPage": { "title": "BTW Dual Loop", @@ -289,11 +273,14 @@ "unsavedTitle": "Unsaved BTW settings", "unsavedMessage": "Leaving now discards them. The work loop's boundary is among what would be lost." }, - "cliConfigPage": { - "title": "CLI Provider Switching", - "subtitle": "Keep a list of providers for Claude Code and Codex, and switch a CLI to one of them. Switching writes that provider into the CLI's own configuration, so the CLI uses it everywhere -- including the sessions you start by hand.", - "untouchedHint": "A delegated coding task is not affected: it layers its own configuration over these files instead of reading them, so switching a provider here never changes how a task runs.", - "loadError": "Could not read the CLI configuration.", + "thirdPartyAgentsPage": { + "title": "Third-party Agent Config", + "subtitle": "Keep a list of providers for Claude Code and Codex, and switch a CLI to one of them.", + "providersTitle": "CLI global providers", + "providersSubtitle": "The configuration file each CLI reads on this host, and the providers it can be switched to.", + "delegatedHint": "A delegated task reads this same configuration: unless an agent names an endpoint of its own, a task runs against the provider your own sessions use, so switching here changes the provider a task uses too.", + "providersLoadError": "Could not read the CLI configuration.", + "loadError": "Could not read the provider list from the configuration profile.", "retry": "Retry", "claudeCode": "Claude Code", "codex": "Codex", @@ -318,7 +305,7 @@ "baseUrlHintClaude": "For example https://api.example.com -- written as ANTHROPIC_BASE_URL.", "baseUrlHintCodex": "For example https://api.example.com/v1 -- written as the Codex provider's base_url.", "apiKey": "API key", - "apiKeyHint": "Stored in the CLI's own configuration file on this host, readable only by you. Leave empty when editing to keep the stored key.", + "apiKeyHint": "Stored in this AstrBot profile, and written into the CLI's own configuration file when you switch this provider. Leave empty when editing to keep the stored key.", "model": "Model", "modelHint": "Optional. Written as the CLI's default model.", "note": "Note", @@ -326,9 +313,10 @@ "keyAbsent": "No key stored", "cancel": "Cancel", "confirm": "Save", - "save": "Save list", - "saved": "Provider list saved", - "saveError": "Could not save the provider list", + "save": "Save", + "saveError": "Could not save the third-party agent configuration", + "saveSuccess": "Third-party agent configuration saved", + "twoFactorRejected": "That code was not accepted. Enter the current one.", "idRequired": "A provider needs an ID.", "endpointOrKeyRequired": "A provider needs an endpoint or an API key.", "idTaken": "Another provider already uses this ID.", @@ -342,8 +330,8 @@ "switchError": "Could not switch the CLI's provider.", "restored": "Restored.", "restoreError": "Could not restore the CLI configuration.", - "unsavedTitle": "Unsaved provider list", - "unsavedMessage": "Leaving now discards the changes to the list.", - "actions": "Provider list actions" + "unsavedTitle": "Unsaved third-party agent configuration", + "unsavedMessage": "Leaving now discards the changes here, including an unsaved provider list.", + "actions": "Third-party agent configuration actions" } } diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json index 4a79b3c60f..a58ea81773 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json +++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json @@ -51,5 +51,5 @@ }, "pluginWebui": "插件页面", "btw": "BTW 双循环", - "cliConfig": "CLI 全局配置" + "thirdPartyAgents": "第三方agent配置" } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 7974368ae8..58aac49c3f 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -490,7 +490,7 @@ }, "coding_agents": { "description": "第三方编码代理", - "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 的端点与模型会以该 CLI 原生配置层的形式生效,密钥只在运行时传给子进程,不会写进该配置层。密钥本身与其他 provider 凭据一样保存在本配置档中。委派会启动本地进程并写入文件,因此工作循环的 Computer Use 运行时必须是 local:sandbox 不够,因为进程由本机直接拉起,并不在沙箱内。同时需要 tool.local_exec 与 tool.file_write 的授权。" + "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、模型、输出上限、权限模式与可写目录;委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local(sandbox 下委派等于绕过沙箱),并通过 tool.local_exec 与 tool.file_write 的授权。Claude Code 以非交互方式(-p)运行,没有终端可以回答权限询问:acceptEdits 只自动放行编辑,需要跑 shell 命令的任务会一直等到超时,这类任务要显式选择 bypassPermissions。代理使用的 provider 由「第三方agent配置」页面统一管理,切换的是该 CLI 自己的全局配置。" } }, "work_session": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index fc2fcb849a..7278ad1510 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -239,7 +239,7 @@ "commandHint": "要运行的可执行文件。留空时使用占位符中显示的默认命令。", "missingCommandWarning": "自定义代理缺少命令时,读取配置会被忽略。", "model": "模型", - "modelHint": "留空时使用当前预设的模型。", + "modelHint": "留空时使用该 CLI 自己的默认模型。", "permissionMode": "权限模式", "permissionModeHint": "以 --permission-mode 传给 Claude Code。委派是非交互运行,只自动放行编辑:需要跑 shell 命令的任务会一直等一个没人能给的确认。确实需要时请显式选择 bypassPermissions。", "riskyPermissionWarning": "bypassPermissions 会让该 CLI 不再询问直接执行。仅建议在任务目录可随时丢弃时使用。", @@ -255,23 +255,7 @@ "envHint": "仅注入子进程。每一条写成 名称=值。", "addEnv": "添加变量", "timeoutSeconds": "超时(秒)", - "maxOutputChars": "输出上限(字符)", - "providers": "Provider 预设", - "providersHint": "当前预设的端点与模型会以该 CLI 自己的配置层生效。密钥只在本次运行期间传给子进程,不会写进该配置层;密钥本身保存在本配置档中。", - "providersCustomHint": "自定义 CLI 不会读取生成的配置层,请在环境变量中给它凭据。", - "noProviders": "没有预设。该 CLI 会回退到你已经登录的账号。", - "activeProvider": "当前预设", - "activeProviderHint": "交给该 CLI 使用的预设。", - "addProvider": "添加预设", - "removeProvider": "删除预设", - "providerId": "ID", - "providerName": "名称", - "providerBaseUrl": "Base URL", - "providerModel": "模型", - "providerApiKey": "API Key", - "providerApiKeyHint": "仅在本次运行期间传给子进程。密钥保存在本配置档中,不会写进该 CLI 自己的配置。", - "providerWireApi": "Wire API", - "providerWireApiHint": "仅 Codex 使用。默认 \"responses\",\"chat\" 表示走 chat completions 接口。" + "maxOutputChars": "输出上限(字符)" }, "btwPage": { "title": "BTW 双循环", @@ -289,11 +273,14 @@ "unsavedTitle": "BTW 设置尚未保存", "unsavedMessage": "现在离开会丢弃这些改动,其中包含工作循环的边界设置。" }, - "cliConfigPage": { - "title": "CLI 供应商切换", - "subtitle": "为 Claude Code 与 Codex 各维护一份 provider 列表,把某个 CLI 切换到其中之一。切换会把该 provider 写进这个 CLI 自己的配置文件,让它在所有场合生效——包括你手动启动的会话。", - "untouchedHint": "委派任务不受影响:它使用独立的配置层,不读取这些文件,因此这里切换 provider 不会改变任务的运行方式。", - "loadError": "无法读取 CLI 配置。", + "thirdPartyAgentsPage": { + "title": "第三方agent配置", + "subtitle": "为 Claude Code 与 Codex 各维护一份 provider 列表,把某个 CLI 切换到其中之一。", + "providersTitle": "CLI 全局 provider", + "providersSubtitle": "每个 CLI 在本机自己读取的配置文件,以及可以切换到的 provider。", + "delegatedHint": "委派任务读的是同一份配置:代理没有单独指定端点时,任务与你手动启动的会话用同一个 provider,所以在这里切换会一并改变任务用到的 provider。", + "providersLoadError": "无法读取 CLI 配置。", + "loadError": "无法读取配置档里的 provider 列表。", "retry": "重试", "claudeCode": "Claude Code", "codex": "Codex", @@ -318,7 +305,7 @@ "baseUrlHintClaude": "例如 https://api.example.com —— 写入 ANTHROPIC_BASE_URL。", "baseUrlHintCodex": "例如 https://api.example.com/v1 —— 写入 Codex provider 的 base_url。", "apiKey": "API Key", - "apiKeyHint": "保存在本机该 CLI 自己的配置文件里,仅本机用户可读。编辑时留空表示保留已存的密钥。", + "apiKeyHint": "保存在本 AstrBot 配置档中,切换该 provider 时会写入该 CLI 自己的配置文件。编辑时留空表示保留已存的密钥。", "model": "模型", "modelHint": "可选。会写成该 CLI 的默认模型。", "note": "备注", @@ -326,9 +313,10 @@ "keyAbsent": "无密钥", "cancel": "取消", "confirm": "保存", - "save": "保存列表", - "saved": "provider 列表已保存", - "saveError": "provider 列表保存失败", + "save": "保存", + "saveError": "第三方 agent 配置保存失败", + "saveSuccess": "第三方 agent 配置已保存", + "twoFactorRejected": "验证码未被接受,请输入当前验证码。", "idRequired": "provider 需要一个 ID。", "endpointOrKeyRequired": "provider 至少需要端点或 API Key 之一。", "idTaken": "已有其他 provider 使用该 ID。", @@ -342,8 +330,8 @@ "switchError": "切换 CLI 的 provider 失败。", "restored": "已还原。", "restoreError": "还原 CLI 配置失败。", - "unsavedTitle": "provider 列表尚未保存", - "unsavedMessage": "现在离开会丢弃列表的改动。", - "actions": "provider 列表操作" + "unsavedTitle": "第三方 agent 配置尚未保存", + "unsavedMessage": "现在离开会丢弃这里的改动,包括尚未保存的 provider 列表。", + "actions": "第三方 agent 配置操作" } } diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts index 7801f601cf..817a4151f0 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts +++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts @@ -118,9 +118,9 @@ const sidebarItem: menu[] = [ to: '/btw', }, { - title: 'core.navigation.cliConfig', - icon: 'mdi-pencil-ruler', - to: '/cli-config', + title: 'core.navigation.thirdPartyAgents', + icon: 'mdi-console-line', + to: '/third-party-agents', }, { title: 'core.navigation.data', diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index a28ab0a3b6..612cf876d2 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -120,9 +120,9 @@ const MainRoutes = { component: () => import('@/views/BtwPage.vue'), }, { - name: 'CliConfig', - path: '/cli-config', - component: () => import('@/views/CliConfigPage.vue'), + name: 'ThirdPartyAgents', + path: '/third-party-agents', + component: () => import('@/views/ThirdPartyAgentsPage.vue'), }, { name: 'CronJobs', diff --git a/dashboard/src/views/CliConfigPage.vue b/dashboard/src/views/CliConfigPage.vue deleted file mode 100644 index 751bda75fc..0000000000 --- a/dashboard/src/views/CliConfigPage.vue +++ /dev/null @@ -1,907 +0,0 @@ - - - - - - diff --git a/dashboard/src/views/ThirdPartyAgentsPage.vue b/dashboard/src/views/ThirdPartyAgentsPage.vue new file mode 100644 index 0000000000..74598348ed --- /dev/null +++ b/dashboard/src/views/ThirdPartyAgentsPage.vue @@ -0,0 +1,348 @@ + + + + + + diff --git a/dashboard/tests/codingAgentsEditor.vitest.ts b/dashboard/tests/codingAgentsEditor.vitest.ts index 1fcee6914a..49562fee04 100644 --- a/dashboard/tests/codingAgentsEditor.vitest.ts +++ b/dashboard/tests/codingAgentsEditor.vitest.ts @@ -17,17 +17,6 @@ const claudeAgent = { env: {}, timeout_seconds: 1800, max_output_chars: 20000, - active_provider: 'official', - providers: [ - { - id: 'official', - name: 'official', - base_url: '', - api_key: '', - model: '', - wire_api: 'responses', - }, - ], }; function mountEditor(modelValue: unknown) { @@ -121,26 +110,12 @@ describe('CodingAgentsEditor', () => { false, ); - // The wire API is a Codex field: only `_codex_profile` writes it. - expect( - agents[0].find('.coding-agents-editor__provider-wire-api').exists(), - ).toBe(false); - expect( - agents[1].find('.coding-agents-editor__provider-wire-api').exists(), - ).toBe(true); - expect( - agents[2].find('.coding-agents-editor__provider-wire-api').exists(), - ).toBe(false); - for (const agent of agents) { expect(agent.find('.coding-agents-editor__command').exists()).toBe(true); expect(agent.find('.coding-agents-editor__extra-args').exists()).toBe( true, ); expect(agent.find('.coding-agents-editor__env').exists()).toBe(true); - expect(agent.find('.coding-agents-editor__providers').exists()).toBe( - true, - ); } wrapper.unmount(); }); @@ -183,8 +158,6 @@ describe('CodingAgentsEditor', () => { env: {}, timeout_seconds: 1800, max_output_chars: 20000, - active_provider: '', - providers: [], }); wrapper.unmount(); }); @@ -234,93 +207,6 @@ describe('CodingAgentsEditor', () => { wrapper.unmount(); }); - it('activates a new preset only when none was active', async () => { - const unset = { ...claudeAgent, active_provider: '', providers: [] }; - const wrapper = mountEditor([unset]); - - await wrapper.find('.coding-agents-editor__add-provider').trigger('click'); - - const added = lastEmitted(wrapper)[0]; - expect(added.providers).toEqual([ - { - id: 'provider', - name: 'provider', - base_url: '', - api_key: '', - model: '', - wire_api: 'responses', - }, - ]); - expect(added.active_provider).toBe('provider'); - wrapper.unmount(); - - const configured = mountEditor([{ ...claudeAgent }]); - await configured - .find('.coding-agents-editor__add-provider') - .trigger('click'); - expect(lastEmitted(configured)[0].active_provider).toBe('official'); - configured.unmount(); - }); - - it('repoints the active preset when the active one is removed', async () => { - const wrapper = mountEditor([ - { - ...claudeAgent, - active_provider: 'official', - providers: [...claudeAgent.providers, { id: 'gw', name: 'gw' }], - }, - ]); - - await wrapper - .find('.coding-agents-editor__remove-provider') - .trigger('click'); - - const entry = lastEmitted(wrapper)[0]; - expect((entry.providers as { id: string }[]).map((p) => p.id)).toEqual([ - 'gw', - ]); - expect(entry.active_provider).toBe('gw'); - wrapper.unmount(); - }); - - it('keeps a renamed preset active instead of falling back to the first', async () => { - const wrapper = mountEditor([ - { - ...claudeAgent, - // Active, and not first: renaming it must not repoint to `official`. - active_provider: 'gw', - providers: [ - { - id: 'gw', - name: 'gw', - base_url: '', - api_key: '', - model: '', - wire_api: 'responses', - }, - { - id: 'official', - name: 'official', - base_url: '', - api_key: '', - model: '', - wire_api: 'responses', - }, - ], - }, - ]); - - control(wrapper, '.coding-agents-editor__provider-id').vm.$emit( - 'update:modelValue', - 'gateway', - ); - await wrapper.vm.$nextTick(); - - const entry = lastEmitted(wrapper)[0]; - expect(entry.active_provider).toBe('gateway'); - wrapper.unmount(); - }); - it('round-trips extra arguments as a list', async () => { const wrapper = mountEditor([ { ...claudeAgent, extra_args: ['--verbose'] }, @@ -380,7 +266,10 @@ describe('CodingAgentsEditor', () => { env: ['oops'], timeout_seconds: 'x', max_output_chars: 5, - providers: [null, { id: '' }, { id: 'p' }], + // Fields this editor stopped owning: a save has to drop them, or a + // profile would keep an invisible preset alive. + active_provider: 'p', + providers: [{ id: 'p', base_url: 'https://gw.example' }], }, ]); @@ -405,17 +294,6 @@ describe('CodingAgentsEditor', () => { env: {}, timeout_seconds: 1800, max_output_chars: 20000, - active_provider: 'p', - providers: [ - { - id: 'p', - name: 'p', - base_url: '', - api_key: '', - model: '', - wire_api: 'responses', - }, - ], }, ]); wrapper.unmount(); diff --git a/dashboard/tests/cliConfigPage.vitest.ts b/dashboard/tests/codingCliProviders.vitest.ts similarity index 53% rename from dashboard/tests/cliConfigPage.vitest.ts rename to dashboard/tests/codingCliProviders.vitest.ts index af584cfa16..3fcc77176d 100644 --- a/dashboard/tests/cliConfigPage.vitest.ts +++ b/dashboard/tests/codingCliProviders.vitest.ts @@ -1,14 +1,15 @@ import { flushPromises } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import CliConfigPage from '@/views/CliConfigPage.vue'; +import CodingCliProviders from '@/components/shared/CodingCliProviders.vue'; +import DashboardStepUpDialog from '@/components/shared/DashboardStepUpDialog.vue'; +import type { StoredCliProvider } from '@/api/v1'; import { mountWithVuetify } from './utils/mountWithVuetify'; const testState = vi.hoisted(() => ({ stateMock: vi.fn(), switchMock: vi.fn(), removeMock: vi.fn(), - getProfileMock: vi.fn(), - updateProfileMock: vi.fn(), + stepUpMock: vi.fn(), })); vi.mock('@/api/v1', () => ({ @@ -17,15 +18,14 @@ vi.mock('@/api/v1', () => ({ switchProvider: testState.switchMock, remove: testState.removeMock, }, - configProfileApi: { - get: testState.getProfileMock, - update: testState.updateProfileMock, - }, })); vi.mock('@/api/v1/authorization', () => ({ STEP_UP_TTL_SECONDS: 300, - authorizationApi: { stepUp: vi.fn(), webChatStepUp: vi.fn() }, + authorizationApi: { + stepUp: testState.stepUpMock, + webChatStepUp: vi.fn(), + }, })); const STATE = { @@ -55,38 +55,43 @@ const STATE = { }; /** - * The operator's own list, which is what the cards render from. + * The operator's own list, as the page hands it over. * - * The key is the marker the profile reports, not the key: a config response - * never carries a stored secret, and a fixture that carries one tests a path - * that does not exist. + * `api_key` is empty and `has_api_key` is true for the entry whose key the + * profile holds: the page reads the marker a response carries as "a key is + * stored, the field is empty" and puts the marker back only for this entry. */ -const REDACTED = '__ASTRBOT_REDACTED__'; - -const CONFIG = { - btw: { - cli_providers: [ - { - id: 'gw', - name: 'Gateway', - base_url: 'https://gw.example', - api_key: REDACTED, - model: 'opus', - note: 'primary', - }, - { - id: 'local', - name: 'Local', - base_url: 'http://127.0.0.1:8080', - model: '', - note: '', - }, - ], +const PROVIDERS: StoredCliProvider[] = [ + { + id: 'gw', + name: 'Gateway', + base_url: 'https://gw.example', + api_key: '', + model: 'opus', + note: 'primary', + has_api_key: true, + current: false, + cli: '', }, -}; + { + id: 'local', + name: 'Local', + base_url: 'http://127.0.0.1:8080', + api_key: '', + model: '', + note: '', + has_api_key: false, + current: false, + cli: '', + }, +]; -function mountPage(confirm = true) { - return mountWithVuetify(CliConfigPage, { +function mountProviders( + providers: StoredCliProvider[] = PROVIDERS, + confirm = true, +) { + return mountWithVuetify(CodingCliProviders, { + props: { modelValue: providers }, global: { provide: { // The app supplies this through a plugin; a mount supplies it here so @@ -105,54 +110,53 @@ function mountPage(confirm = true) { }); } -/** The provider list as it was sent to the config profile endpoint. */ -function savedProviders() { - const body = testState.updateProfileMock.mock.calls[0][1] as { - btw: { cli_providers: Record[] }; - }; - return body.btw.cli_providers; +type Wrapper = ReturnType; + +/** The provider list as it was last emitted for the page to save. */ +function emittedProviders(wrapper: Wrapper): StoredCliProvider[] { + const emitted = wrapper.emitted('update:modelValue'); + expect(emitted).toBeTruthy(); + return emitted!.at(-1)![0] as StoredCliProvider[]; } /** The card for one provider inside one CLI's section. */ -function cardFor( - wrapper: ReturnType, - cli: string, - providerId: string, -) { - const sections = wrapper.findAll('.cli-config-page__cli'); +function cardFor(wrapper: Wrapper, cli: string, providerId: string) { + const sections = wrapper.findAll('.coding-cli-providers__cli'); const section = sections.find((node) => node.text().includes(cli === 'codex' ? 'Codex' : 'Claude Code'), ); if (!section) throw new Error(`no section for ${cli}`); const card = section - .findAll('.cli-config-page__provider') + .findAll('.coding-cli-providers__provider') .find((node) => node.text().includes(providerId)); if (!card) throw new Error(`no card for ${providerId} in ${cli}`); return card; } async function openFormAndFill( - wrapper: ReturnType, + wrapper: Wrapper, values: { id: string; base_url?: string; api_key?: string }, ) { - await wrapper.findAll('.cli-config-page__add')[0].trigger('click'); + await wrapper.findAll('.coding-cli-providers__add')[0].trigger('click'); await wrapper.vm.$nextTick(); - await wrapper.find('.cli-config-page__form-id input').setValue(values.id); + await wrapper + .find('.coding-cli-providers__form-id input') + .setValue(values.id); if (values.base_url !== undefined) { await wrapper - .find('.cli-config-page__form-base-url input') + .find('.coding-cli-providers__form-base-url input') .setValue(values.base_url); } if (values.api_key !== undefined) { await wrapper - .find('.cli-config-page__form-api-key input') + .find('.coding-cli-providers__form-api-key input') .setValue(values.api_key); } - await wrapper.find('.cli-config-page__form-confirm').trigger('click'); + await wrapper.find('.coding-cli-providers__form-confirm').trigger('click'); await wrapper.vm.$nextTick(); } -describe('CliConfigPage', () => { +describe('CodingCliProviders', () => { beforeEach(() => { testState.stateMock.mockResolvedValue({ data: { status: 'ok', data: STATE }, @@ -163,16 +167,10 @@ describe('CliConfigPage', () => { testState.removeMock.mockResolvedValue({ data: { status: 'ok', data: STATE.clis[0] }, }); - testState.getProfileMock.mockResolvedValue({ - data: { status: 'ok', data: { config: CONFIG, metadata: {} } }, - }); - testState.updateProfileMock.mockResolvedValue({ - data: { status: 'ok', message: 'saved' }, - }); }); it('lists both CLIs with their own config path', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); expect(wrapper.text()).toContain('Claude Code'); @@ -183,30 +181,31 @@ describe('CliConfigPage', () => { }); it('shows each provider card with its endpoint and state', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); const cards = wrapper - .findAll('.cli-config-page__cli')[0] - .findAll('.cli-config-page__provider'); + .findAll('.coding-cli-providers__cli')[0] + .findAll('.coding-cli-providers__provider'); expect(cards).toHaveLength(2); expect(cards[0].text()).toContain('Gateway'); expect(cards[0].text()).toContain('https://gw.example'); - expect(cards[0].find('.cli-config-page__current').exists()).toBe(true); + expect(cards[0].find('.coding-cli-providers__current').exists()).toBe(true); // The second card is not the one in use, so it has no marker. - expect(cards[1].find('.cli-config-page__current').exists()).toBe(false); + expect(cards[1].find('.coding-cli-providers__current').exists()).toBe( + false, + ); wrapper.unmount(); }); it('switches the CLI to the provider whose card was clicked', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); - const sections = wrapper.findAll('.cli-config-page__cli'); - const claude = sections[0]; - const cards = claude.findAll('.cli-config-page__provider'); + const claude = wrapper.findAll('.coding-cli-providers__cli')[0]; + const cards = claude.findAll('.coding-cli-providers__provider'); // The second card in the Claude section is the one not in use. - await cards[1].find('.cli-config-page__switch').trigger('click'); + await cards[1].find('.coding-cli-providers__switch').trigger('click'); await flushPromises(); expect(testState.switchMock).toHaveBeenCalledWith( @@ -217,12 +216,12 @@ describe('CliConfigPage', () => { }); it('does not offer a switch for the provider already in use', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); const buttons = wrapper - .findAll('.cli-config-page__cli')[0] - .findAll('.cli-config-page__switch'); + .findAll('.coding-cli-providers__cli')[0] + .findAll('.coding-cli-providers__switch'); expect(buttons[0].classes().some((name) => name.includes('disabled'))).toBe( true, ); @@ -232,58 +231,38 @@ describe('CliConfigPage', () => { wrapper.unmount(); }); - it('adds a provider through the form and saves the list', async () => { - const wrapper = mountPage(); - await flushPromises(); - - await openFormAndFill(wrapper, { - id: 'mine', - base_url: 'https://mine.example', - api_key: 'sk-2', - }); - await wrapper.find('.cli-config-page__save').trigger('click'); - await flushPromises(); - - const added = savedProviders().find((entry) => entry.id === 'mine'); - expect(added?.base_url).toBe('https://mine.example'); - expect(added?.api_key).toBe('sk-2'); - wrapper.unmount(); - }); - - it('leaves a stored key alone when the field is left empty', async () => { - const wrapper = mountPage(); + it('edits the list the page saves, leaving the stored key alone', async () => { + const wrapper = mountProviders(); await flushPromises(); await cardFor(wrapper, 'claude_code', 'gw') - .find('.cli-config-page__edit') + .find('.coding-cli-providers__edit') .trigger('click'); await wrapper.vm.$nextTick(); - const keyInput = wrapper.find('.cli-config-page__form-api-key input') + const keyInput = wrapper.find('.coding-cli-providers__form-api-key input') .element as HTMLInputElement; - // The profile reports a stored key as a marker, so the field starts empty - // and empty has to mean "unchanged" rather than "erase it". + // The form never shows the stored key, so the field starts empty, and + // empty has to mean "unchanged" rather than "erase it". expect(keyInput.value).toBe(''); - // Something has to change for there to be anything to save. await wrapper - .find('.cli-config-page__form-note input') + .find('.coding-cli-providers__form-note input') .setValue('updated note'); - await wrapper.find('.cli-config-page__form-confirm').trigger('click'); + await wrapper.find('.coding-cli-providers__form-confirm').trigger('click'); await wrapper.vm.$nextTick(); - await wrapper.find('.cli-config-page__save').trigger('click'); - await flushPromises(); - const entry = savedProviders().find((item) => item.id === 'gw'); - // The marker goes back exactly where it came from, which is what the - // profile resolves to the stored key. It is never copied to another entry: - // an id the profile has never seen has no key for the marker to name. - expect(entry?.api_key).toBe(REDACTED); + const entry = emittedProviders(wrapper).find((item) => item.id === 'gw'); + // The key is reported as still stored and its value is not in the list at + // all; the page is what turns that back into the marker the profile + // resolves, and only for the entry whose id the marker came from. + expect(entry?.api_key).toBe(''); + expect(entry?.has_api_key).toBe(true); expect(entry?.note).toBe('updated note'); wrapper.unmount(); }); it('adds a provider scoped to the CLI whose section it was added from', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); // `openFormAndFill` adds from the Claude Code section, the first one. @@ -295,39 +274,36 @@ describe('CliConfigPage', () => { // Scoped, not left open: an entry with no `cli` is shown under every CLI, // so leaving it unset would put a Claude provider in the Codex section too. - expect(() => cardFor(wrapper, 'claude_code', 'mine')).not.toThrow(); - expect(() => cardFor(wrapper, 'codex', 'mine')).toThrow(); - - await wrapper.find('.cli-config-page__save').trigger('click'); - await flushPromises(); - - const added = savedProviders().find((entry) => entry.id === 'mine'); + const added = emittedProviders(wrapper).find( + (entry) => entry.id === 'mine', + ); expect(added?.cli).toBe('claude_code'); wrapper.unmount(); }); it('does not carry a stored key onto a duplicate', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); await cardFor(wrapper, 'claude_code', 'gw') - .find('.cli-config-page__duplicate') + .find('.coding-cli-providers__duplicate') .trigger('click'); await wrapper.vm.$nextTick(); - await wrapper.find('.cli-config-page__save').trigger('click'); - await flushPromises(); - const copy = savedProviders().find((entry) => entry.id === 'gw-copy'); - // The copy is metadata only. Posting the source's marker under a new id - // would store the marker itself, and the switch would write that string - // into the CLI's own file as if it were a key. - expect(copy?.api_key).toBeUndefined(); + const copy = emittedProviders(wrapper).find( + (entry) => entry.id === 'gw-copy', + ); + // Metadata only. Copying `has_api_key` would make the page post the + // source's marker under a new id, which the profile cannot resolve -- and + // a switch would then write the marker into the CLI's own file as a key. + expect(copy?.has_api_key).toBe(false); + expect(copy?.api_key).toBe(''); expect(copy?.base_url).toBe('https://gw.example'); wrapper.unmount(); }); it('refuses an id another provider already uses, whichever CLI it is under', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); // `gw` is unscoped and so is listed under both CLIs, but the profile keys @@ -337,53 +313,82 @@ describe('CliConfigPage', () => { base_url: 'https://other.example', }); - expect(wrapper.find('.cli-config-page__form-error').exists()).toBe(true); + expect(wrapper.find('.coding-cli-providers__form-error').exists()).toBe( + true, + ); + wrapper.unmount(); + }); + + it('adds a provider through the form', async () => { + const wrapper = mountProviders(); + await flushPromises(); + + await openFormAndFill(wrapper, { + id: 'mine', + base_url: 'https://mine.example', + api_key: 'sk-2', + }); + + const added = emittedProviders(wrapper).find( + (entry) => entry.id === 'mine', + ); + expect(added?.base_url).toBe('https://mine.example'); + expect(added?.api_key).toBe('sk-2'); wrapper.unmount(); }); it('refuses a provider with no endpoint and no key', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); await openFormAndFill(wrapper, { id: 'bare' }); - expect(wrapper.find('.cli-config-page__form-error').exists()).toBe(true); - await wrapper.find('.cli-config-page__save').trigger('click'); - await flushPromises(); - expect(testState.updateProfileMock).not.toHaveBeenCalled(); + expect(wrapper.find('.coding-cli-providers__form-error').exists()).toBe( + true, + ); + expect(wrapper.emitted('update:modelValue')).toBeFalsy(); wrapper.unmount(); }); - it('mounts without Vue warnings when a class is injected from above', async () => { - // The page is mounted through an injected slot, which passes a class down. - // A fragment root would warn about extraneous attributes instead of - // rendering, and that warning is what the smoke suites fail on. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const wrapper = mountWithVuetify(CliConfigPage, { - attrs: { class: 'cli-config-page-test' }, - attachTo: document.body, + it('answers a step-up challenge instead of dead-ending on the load', async () => { + // A read is an ordinary permission, but a session that has not proved + // itself is answered with a challenge. Without answering it the page has + // nothing to retry with, so it would never fill in. + testState.stateMock + .mockRejectedValueOnce({ + response: { data: { data: { requires_step_up: true } } }, + }) + .mockResolvedValueOnce({ data: { status: 'ok', data: STATE } }); + testState.stepUpMock.mockResolvedValue({ + data: { status: 'ok', data: { token: 'token-1' } }, }); + + const wrapper = mountProviders(); await flushPromises(); - expect(wrapper.classes()).toContain('cli-config-page-test'); - const noisy = [...warn.mock.calls, ...error.mock.calls].filter((args) => - args.some((arg) => - String(arg).includes('Extraneous non-props attributes'), - ), - ); - expect(noisy).toHaveLength(0); + const dialog = wrapper.findComponent(DashboardStepUpDialog); + expect(dialog.props('modelValue')).toBe(true); + dialog.vm.$emit('confirm', { password: 'pw', code: '123456' }); + await flushPromises(); - warn.mockRestore(); - error.mockRestore(); + expect(testState.stepUpMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'platform.read', + resource_type: 'instance', + resource_id: 'default', + }), + ); + expect(testState.stateMock).toHaveBeenLastCalledWith({ + headers: { 'X-AstrBot-Step-Up': 'token-1' }, + }); + expect(wrapper.text()).toContain('Claude Code'); wrapper.unmount(); }); - it('reports a load failure instead of an empty page', async () => { + it('reports a load failure instead of an empty list', async () => { testState.stateMock.mockRejectedValue(new Error('nope')); - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); expect(wrapper.text()).toContain('Could not read the CLI configuration'); @@ -391,10 +396,10 @@ describe('CliConfigPage', () => { }); it('restores from the backup', async () => { - const wrapper = mountPage(); + const wrapper = mountProviders(); await flushPromises(); - await wrapper.find('.cli-config-page__restore').trigger('click'); + await wrapper.find('.coding-cli-providers__restore').trigger('click'); await flushPromises(); expect(testState.removeMock).toHaveBeenCalledWith('claude_code', { @@ -402,4 +407,27 @@ describe('CliConfigPage', () => { }); wrapper.unmount(); }); + + it('mounts without Vue warnings when a class is injected from above', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const wrapper = mountWithVuetify(CodingCliProviders, { + attrs: { class: 'coding-cli-providers-test' }, + props: { modelValue: PROVIDERS }, + }); + await flushPromises(); + + expect(wrapper.classes()).toContain('coding-cli-providers-test'); + const noisy = [...warn.mock.calls, ...error.mock.calls].filter((args) => + args.some((arg) => + String(arg).includes('Extraneous non-props attributes'), + ), + ); + expect(noisy).toHaveLength(0); + + warn.mockRestore(); + error.mockRestore(); + wrapper.unmount(); + }); }); diff --git a/dashboard/tests/thirdPartyAgentsPage.vitest.ts b/dashboard/tests/thirdPartyAgentsPage.vitest.ts new file mode 100644 index 0000000000..f4fe2c213b --- /dev/null +++ b/dashboard/tests/thirdPartyAgentsPage.vitest.ts @@ -0,0 +1,207 @@ +import { flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import DashboardTwoFactorDialog from '@/components/shared/DashboardTwoFactorDialog.vue'; +import ThirdPartyAgentsPage from '@/views/ThirdPartyAgentsPage.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +const testState = vi.hoisted(() => ({ + getProfileMock: vi.fn(), + updateProfileMock: vi.fn(), + cliStateMock: vi.fn(), + switchMock: vi.fn(), + removeMock: vi.fn(), +})); + +vi.mock('@/api/v1', () => ({ + codingCliApi: { + state: testState.cliStateMock, + switchProvider: testState.switchMock, + remove: testState.removeMock, + }, + configProfileApi: { + get: testState.getProfileMock, + update: testState.updateProfileMock, + }, +})); + +vi.mock('@/api/v1/authorization', () => ({ + STEP_UP_TTL_SECONDS: 300, + authorizationApi: { + stepUp: vi.fn(), + webChatStepUp: vi.fn(), + }, +})); + +/** + * A profile as the endpoint returns it. + * + * The key is the marker a response carries, not the key: the redaction happens + * before the page ever sees it, and a fixture that carries a real secret tests + * a path that does not exist. + * + * Built fresh for every test: the page edits the configuration it was handed, + * so a shared object would carry one test's edits into the next. + */ +function configFixture() { + return { + btw: { + cli_providers: [ + { + id: 'gw', + name: 'Gateway', + base_url: 'https://gw.example', + api_key: REDACTED_SECRET_PLACEHOLDER, + model: 'opus', + note: '', + }, + ], + }, + }; +} + +/** The marker a config response carries in place of a stored key. */ +const REDACTED_SECRET_PLACEHOLDER = '__ASTRBOT_REDACTED__'; + +const CLI_STATE = { + clis: [ + { + cli: 'claude_code', + path: 'C:/Users/x/.claude/settings.json', + exists: true, + managed: false, + backed_up: false, + base_url: '', + model: '', + has_credential: false, + }, + ], +}; + +function mountPage() { + return mountWithVuetify(ThirdPartyAgentsPage, { + global: { + provide: { + $confirm: vi.fn(async () => true), + }, + stubs: { + // Vuetify only mounts a dialog's content once it is open, so the stub + // renders it straight away and the form can be driven without waiting. + VDialog: { + props: ['modelValue'], + template: '
', + }, + }, + }, + }); +} + +type Wrapper = ReturnType; + +/** Add a provider through the provider editor and close its form. */ +async function addProvider(wrapper: Wrapper, id: string, baseUrl: string) { + await wrapper.findAll('.coding-cli-providers__add')[0].trigger('click'); + await wrapper.vm.$nextTick(); + await wrapper.find('.coding-cli-providers__form-id input').setValue(id); + await wrapper + .find('.coding-cli-providers__form-base-url input') + .setValue(baseUrl); + await wrapper.find('.coding-cli-providers__form-confirm').trigger('click'); + await wrapper.vm.$nextTick(); +} + +/** The config as it was sent to the profile endpoint. */ +function savedConfig() { + return testState.updateProfileMock.mock.calls[0][1] as { + btw: { cli_providers: Record[] }; + }; +} + +describe('ThirdPartyAgentsPage', () => { + beforeEach(() => { + testState.getProfileMock.mockResolvedValue({ + data: { status: 'ok', data: { config: configFixture() } }, + }); + testState.updateProfileMock.mockResolvedValue({ + data: { status: 'ok', message: 'saved' }, + }); + testState.cliStateMock.mockResolvedValue({ + data: { status: 'ok', data: CLI_STATE }, + }); + testState.switchMock.mockResolvedValue({ + data: { status: 'ok', data: CLI_STATE.clis[0] }, + }); + testState.removeMock.mockResolvedValue({ + data: { status: 'ok', data: CLI_STATE.clis[0] }, + }); + }); + + it('shows the stored provider list of the running profile', async () => { + const wrapper = mountPage(); + await flushPromises(); + + // The running profile, because that is the list a switch resolves against. + expect(testState.getProfileMock).toHaveBeenCalledWith('default'); + expect(testState.cliStateMock).toHaveBeenCalled(); + expect(wrapper.find('.coding-cli-providers__provider').exists()).toBe(true); + // The agent editor lives on the BTW page, not here. + expect(wrapper.find('.coding-agents-editor').exists()).toBe(false); + wrapper.unmount(); + }); + + it('saves the list the editor produced', async () => { + const wrapper = mountPage(); + await flushPromises(); + + await addProvider(wrapper, 'mine', 'https://mine.example'); + await wrapper.find('.third-party-agents-page__save').trigger('click'); + await flushPromises(); + + expect(testState.updateProfileMock).toHaveBeenCalledWith( + 'default', + expect.anything(), + expect.objectContaining({ headers: {} }), + ); + const ids = savedConfig().btw.cli_providers.map((entry) => entry.id); + expect(ids).toEqual(['gw', 'mine']); + // The marker goes back for the entry it came from, which is what the + // profile resolves to the stored key, and nowhere else. + expect(savedConfig().btw.cli_providers[0].api_key).toBe( + REDACTED_SECRET_PLACEHOLDER, + ); + // The provider that was just added has an id the profile has never seen, + // so there is no stored key for it: posting the marker would store it. + expect(savedConfig().btw.cli_providers[1].api_key).toBeUndefined(); + wrapper.unmount(); + }); + + it('asks for the second factor when the save is challenged', async () => { + testState.updateProfileMock.mockResolvedValue({ + status: 401, + data: { status: 'error', data: { totp_required: true } }, + }); + + const wrapper = mountPage(); + await flushPromises(); + + await addProvider(wrapper, 'mine', 'https://mine.example'); + await wrapper.find('.third-party-agents-page__save').trigger('click'); + await flushPromises(); + + expect( + wrapper.findComponent(DashboardTwoFactorDialog).props('modelValue'), + ).toBe(true); + wrapper.unmount(); + }); + + it('reports a load failure instead of rendering an empty page', async () => { + testState.getProfileMock.mockRejectedValue(new Error('nope')); + + const wrapper = mountPage(); + await flushPromises(); + + expect(wrapper.text()).toContain( + 'Could not read the provider list from the configuration profile', + ); + wrapper.unmount(); + }); +}); diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index b80b14aa39..8f58cfcb06 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -251,11 +251,13 @@ Detached work acknowledges receipt before execution and returns results through `btw.work_loop.read_only` defaults to `true`. With it on, the work loop's tool catalog keeps only reading and searching: `astrbot_file_read_tool`, `astrbot_grep_tool`, web search, memory, and knowledge-base tools stay available, while Shell, Python, file writing and editing, upload and download, browser, CUA, and MCP tools whose `readOnlyHint` is not true are removed. `delegate_coding_task` is then the only way to write. The decision is made by meaning rather than by name: a tool that declares no `required_actions` counts as a writer, plugin tools included, because nothing vouches for it. The setting only removes capabilities: file reading still requires `btw.work_loop.computer_use_runtime` to grant local or sandbox, and roles, path restrictions, sandboxing, WebChat step-up, and the per-capability loop assignments are unchanged. Turning `read_only` off restores the previous write capabilities. -`btw.work_loop.coding_agents` names the local CLI agents a task can be delegated to. Each entry carries `id`, `type` (`claude_code`, `codex`, or `custom`), `command`, `permission_mode` (the Claude Code permission mode, `acceptEdits` by default), `sandbox` (the Codex sandbox, `workspace-write` by default), `project_dir`, `extra_args`, `env`, `timeout_seconds`, and `providers`. `permission_mode` and `sandbox` are policies the CLI itself enforces, not operating-system isolation: the defaults keep an agent writing inside its task folder, a configured `project_dir` is added to what it may write, and `bypassPermissions` and `danger-full-access` must be configured explicitly. Claude Code runs non-interactively under `-p`, so there is no terminal to answer a permission prompt, and `acceptEdits` approves edits alone: a task that runs shell commands -- a test suite, a git command -- waits until `timeout_seconds` expires, and such a task has to choose `bypassPermissions` deliberately. A delegation starts a local process that writes to the filesystem, so `delegate_coding_task` is authorized as `tool.local_exec` and `tool.file_write`: this work loop's Computer Use runtime must be `local`. Allowing it under `sandbox` would be the way out of the sandbox rather than a use of it -- the process is started by AstrBot directly and is not inside the sandbox -- and `inherit` resolves to `none` before the request is built, so neither selects. The `none` default of `provider_settings.computer_use_runtime` turns off file reading and delegation together, and surface elevation such as WebChat step-up and the per-capability loop assignments still apply. +`btw.work_loop.coding_agents` names the local CLI agents a task can be delegated to, and is configured on the Dashboard's **More Features → BTW Dual Loop** page. Each entry carries `id`, `type` (`claude_code`, `codex`, or `custom`), `command`, `model`, `max_output_chars`, `permission_mode` (the Claude Code permission mode, `acceptEdits` by default), `sandbox` (the Codex sandbox, `workspace-write` by default), `project_dir`, `extra_args`, `env`, `timeout_seconds`, and `providers`. `permission_mode` and `sandbox` are policies the CLI itself enforces, not operating-system isolation: the defaults keep an agent writing inside its task folder, a configured `project_dir` is added to what it may write, and `bypassPermissions` and `danger-full-access` must be configured explicitly. Claude Code runs non-interactively under `-p`, so there is no terminal to answer a permission prompt, and `acceptEdits` approves edits alone: a task that runs shell commands -- a test suite, a git command -- waits until `timeout_seconds` expires, and such a task has to choose `bypassPermissions` deliberately. A delegation starts a local process that writes to the filesystem, so `delegate_coding_task` is authorized as `tool.local_exec` and `tool.file_write`: this work loop's Computer Use runtime must be `local`. Allowing it under `sandbox` would be the way out of the sandbox rather than a use of it -- the process is started by AstrBot directly and is not inside the sandbox -- and `inherit` resolves to `none` before the request is built, so neither selects. The `none` default of `provider_settings.computer_use_runtime` turns off file reading and delegation together, and surface elevation such as WebChat step-up and the per-capability loop assignments still apply. The work loop delegates a write task through `delegate_coding_task`: the task text is written to `/--/TASK.md`, and an empty `workspace_root` uses `btw/workspaces` inside the data directory. Every delegation creates a folder of its own, so two delegations in one session cannot overwrite each other. The agent runs in that folder, with its full output logged to `output.log` beside the task. When the run ends, the work loop reads back the status, exit code, artifact paths, and the agent's own final message. Artifacts are the difference the run made: the changed paths from `git status --porcelain` plus anything the run committed in a git work tree, and the files written after the run started in a plain folder. The report names them relative to the workspace root, so it never spells out where AstrBot keeps its data on the host, and caps them at 50 entries. Whatever a stopped run left in its process group or Job Object is ended with it, whether the run timed out, was cancelled, or finished on its own, so a delegation leaves no process still writing. -Each agent's `providers` is a list of provider presets, and `active_provider` selects the one in effect. A preset's `base_url` and `model` are written into that CLI's own config layer: Claude Code through `--settings` pointing at a settings file in the data directory, and Codex through `--profile astrbot-btw-` layering `$CODEX_HOME/astrbot-btw-.config.toml`. Each agent gets a layer of its own, so two agents running at once cannot overwrite each other's provider. The credential is never written to disk; it is passed in the child's environment for the length of the run, which Claude Code reads as `ANTHROPIC_AUTH_TOKEN` and which Codex names through the layer's `env_key`, a variable named for the agent. Switching providers therefore never rewrites the user's global CLI configuration, and only the endpoint and model persist. A preset with neither `base_url` nor `api_key` counts as an official login: no layer is written and the agent keeps the user's own session. +Each agent may also carry a `providers` list of presets and an `active_provider`. The Dashboard no longer edits them -- a CLI's provider is switched on the **More Features → Third-party Agent Config** page, which rewrites the CLI's own configuration -- but a preset written into the config file by hand still takes effect as described below. A preset's `base_url` and `model` are written into that CLI's own config layer: Claude Code through `--settings` pointing at a settings file in the data directory, and Codex through `--profile astrbot-btw-` layering `$CODEX_HOME/astrbot-btw-.config.toml`. Each agent gets a layer of its own, so two agents running at once cannot overwrite each other's provider. The credential is never written to disk; it is passed in the child's environment for the length of the run, which Claude Code reads as `ANTHROPIC_AUTH_TOKEN` and which Codex names through the layer's `env_key`, a variable named for the agent. Switching providers therefore never rewrites the user's global CLI configuration, and only the endpoint and model persist. A preset with neither `base_url` nor `api_key` counts as an official login: no layer is written and the agent keeps the user's own session. + +The same page keeps `btw.cli_providers`, which is a different list: one provider list per CLI, switched by hand. **Switch** writes the selected provider into that CLI's own global configuration on this host -- Claude Code's `env` block in `~/.claude/settings.json`, and for Codex a managed block at the top of `~/.codex/config.toml` with the credential in `~/.codex/auth.json`. The file is backed up once before the first write, and **Take back** restores that copy and drops the credentials AstrBot stored. When an agent carries no `providers` preset of its own, a delegated run reads this same file: switching a provider here changes the provider a task runs against, as well as your own sessions. Because a switch rewrites a file AstrBot does not own, it is authorized as `coding_cli.config.write` and asks for step-up. When a task finishes, `btw.work_loop.report_via_conversation` (default `true`) hands the result to the conversation loop, which composes one report: the agent's answer first, then the completion status and the artifact paths. Streamed chunks are unaffected; only the final result carries the report, and one run carries it exactly once. Turning the setting off delivers results from the work loop directly. diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index edda428e1c..ea8fef12f0 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -253,11 +253,13 @@ Alkaid [长期记忆](../use/long-term-memory) 当前没有对应的启停配置 `btw.work_loop.read_only` 默认为 `true`。开启后工作循环的工具目录只保留读取与搜索能力:`astrbot_file_read_tool`、`astrbot_grep_tool`、网页搜索、记忆与知识库工具照常可用;Shell、Python、文件写入与编辑、上传下载、浏览器、CUA,以及 `readOnlyHint` 不为真的 MCP 工具都会被移除,`delegate_coding_task` 是唯一的写入途径。判定按语义而非名单:没有声明 `required_actions` 的工具一律视为可写,插件工具也不例外,因为没有东西为它担保。它只收紧能力:文件读取仍要求 `btw.work_loop.computer_use_runtime` 已授予 local 或 sandbox,角色、路径限制、沙箱、WebChat step-up 与逐项循环分配规则都不变。关闭 `read_only` 恢复原有的写入能力。 -`btw.work_loop.coding_agents` 声明可委派的本地 CLI 代理。每个条目包含 `id`、`type`(`claude_code`、`codex` 或 `custom`)、`command`、`permission_mode`(Claude Code 权限模式,默认 `acceptEdits`)、`sandbox`(Codex 沙箱,默认 `workspace-write`)、`project_dir`、`extra_args`、`env`、`timeout_seconds` 和 `providers`。`permission_mode` 与 `sandbox` 是交给该 CLI 自己执行的策略,不是操作系统级的隔离:默认值让代理只在任务目录内写入,配置了 `project_dir` 时该目录也会显式加入可写范围;`bypassPermissions` 与 `danger-full-access` 必须显式配置。Claude Code 以 `-p` 非交互方式运行,没有终端可以回答权限询问,而 `acceptEdits` 只自动放行编辑:需要跑 shell 命令(测试、git 等)的任务会一直等到 `timeout_seconds` 超时,这类任务必须显式选择 `bypassPermissions`。委派会启动本地进程并向文件系统写入,因此 `delegate_coding_task` 按 `tool.local_exec` 与 `tool.file_write` 授权:这项工作循环的 Computer Use 运行时必须是 `local`。`sandbox` 下放行等于绕过沙箱——这个进程由本机直接拉起,并不在沙箱里——`inherit` 在请求构建前等同 `none`,`provider_settings.computer_use_runtime` 默认的 `none` 会同时关闭文件读取和委派;WebChat step-up 等表面提升与逐项循环分配规则照常适用。 +`btw.work_loop.coding_agents` 声明可委派的本地 CLI 代理,在 Dashboard 的 **更多功能 → BTW 双循环** 页面配置。每个条目包含 `id`、`type`(`claude_code`、`codex` 或 `custom`)、`command`、`model`、`max_output_chars`、`permission_mode`(Claude Code 权限模式,默认 `acceptEdits`)、`sandbox`(Codex 沙箱,默认 `workspace-write`)、`project_dir`、`extra_args`、`env`、`timeout_seconds` 和 `providers`。`permission_mode` 与 `sandbox` 是交给该 CLI 自己执行的策略,不是操作系统级的隔离:默认值让代理只在任务目录内写入,配置了 `project_dir` 时该目录也会显式加入可写范围;`bypassPermissions` 与 `danger-full-access` 必须显式配置。Claude Code 以 `-p` 非交互方式运行,没有终端可以回答权限询问,而 `acceptEdits` 只自动放行编辑:需要跑 shell 命令(测试、git 等)的任务会一直等到 `timeout_seconds` 超时,这类任务必须显式选择 `bypassPermissions`。委派会启动本地进程并向文件系统写入,因此 `delegate_coding_task` 按 `tool.local_exec` 与 `tool.file_write` 授权:这项工作循环的 Computer Use 运行时必须是 `local`。`sandbox` 下放行等于绕过沙箱——这个进程由本机直接拉起,并不在沙箱里——`inherit` 在请求构建前等同 `none`,`provider_settings.computer_use_runtime` 默认的 `none` 会同时关闭文件读取和委派;WebChat step-up 等表面提升与逐项循环分配规则照常适用。 工作循环通过 `delegate_coding_task` 交办一次写入任务:任务文本写入 `/<会话 ID>-<代理 ID>-<运行 ID>/TASK.md`,`workspace_root` 留空时使用数据目录下的 `btw/workspaces`。每次委派都会新建一个带运行 ID 的目录,因此同一会话的两次委派不会互相覆盖。代理在该目录中运行,完整输出记录到同目录的 `output.log`。任务结束后工作循环读回状态、退出码、产物路径与代理的最终消息;产物是运行前后的差集——git 工作树取 `git status --porcelain` 的变化与运行期间的提交,普通目录取运行开始后写入的文件——在报告里以工作区根目录为基准给出相对路径,不暴露本机数据目录的绝对位置,最多 50 项。代理退出后仍留在其进程组或 Job Object 中的子进程会被一并结束,超时、取消与正常结束都是如此,因此一次委派不会留下还在写盘的遗留进程。 -每个代理的 `providers` 是 provider 预设列表,`active_provider` 选择生效的一项。预设的 `base_url`、`model` 会写进该 CLI 自己的配置层:Claude Code 用 `--settings` 指向数据目录下的 settings 文件,Codex 用 `--profile astrbot-btw-<代理 ID>` 叠加 `$CODEX_HOME/astrbot-btw-<代理 ID>.config.toml`。每个代理各用一份 Codex 配置层,两个代理同时运行时不会互相覆盖。密钥不落盘,只在拉起子进程时通过环境变量传入——Claude Code 读 `ANTHROPIC_AUTH_TOKEN`,Codex 由配置层的 `env_key` 指向按代理 ID 命名的变量——因此持久化的只有端点与模型。切换 provider 不会改写用户的全局 CLI 配置。预设既无 `base_url` 也无 `api_key` 时视为“官方登录”,不写任何配置层,代理沿用用户自己的登录。 +每个代理还可以带一份 `providers` 预设列表与 `active_provider`:Dashboard 已不再编辑它们(代理用的 provider 改由 **更多功能 → 第三方agent配置** 页面切换该 CLI 自己的配置),但配置文件里手写的预设仍按下面的规则生效。预设的 `base_url`、`model` 会写进该 CLI 自己的配置层:Claude Code 用 `--settings` 指向数据目录下的 settings 文件,Codex 用 `--profile astrbot-btw-<代理 ID>` 叠加 `$CODEX_HOME/astrbot-btw-<代理 ID>.config.toml`。每个代理各用一份 Codex 配置层,两个代理同时运行时不会互相覆盖。密钥不落盘,只在拉起子进程时通过环境变量传入——Claude Code 读 `ANTHROPIC_AUTH_TOKEN`,Codex 由配置层的 `env_key` 指向按代理 ID 命名的变量——因此持久化的只有端点与模型。切换 provider 不会改写用户的全局 CLI 配置。预设既无 `base_url` 也无 `api_key` 时视为“官方登录”,不写任何配置层,代理沿用用户自己的登录。 + +同一个页面还维护 `btw.cli_providers`,那是另一份列表:每个 CLI 一份 provider 列表,由操作者手动切换。点「启用」会把选中的 provider 写进该 CLI 在本机的全局配置——Claude Code 写 `~/.claude/settings.json` 的 `env` 块,Codex 在 `~/.codex/config.toml` 顶部写一段受管区块、密钥存进 `~/.codex/auth.json`。首次写入前原文件会被完整备份一次,「取回」用该备份还原并清掉 AstrBot 写入的密钥。代理没有配置自己的 `providers` 预设时,委派任务读的就是这个文件:在这里切换 provider,任务与你手动启动的会话用的是同一份配置。切换改写了不属于 AstrBot 的文件,因此按 `coding_cli.config.write` 授权并要求 step-up。 任务完成后,`btw.work_loop.report_via_conversation`(默认 `true`)让工作循环把结果交给对话循环合成一条汇报:代理的回答在前,随后是完成状态与产物路径。流式结果的中间分片不受影响,只有最终结果带上汇报,且同一次运行只追加一次。关闭此项后结果由工作循环直接投递。 diff --git a/tests/unit/test_config_metadata_i18n.py b/tests/unit/test_config_metadata_i18n.py index ed752312a7..f48900329f 100644 --- a/tests/unit/test_config_metadata_i18n.py +++ b/tests/unit/test_config_metadata_i18n.py @@ -135,8 +135,8 @@ def test_config_metadata_locale_trees_match() -> None: # Fields the generic config renderer must not offer a control for, because the # generic control would corrupt them. ``cli_providers`` is a list of objects -# edited by the CLI config page and written through the same profile, so a -# string-list Control here would flatten every entry to "[object Object]". +# edited by the third-party agent page and written through the same profile, so +# a string-list control would flatten every entry to "[object Object]". BTW_FIELDS_WITHOUT_CONTROLS = frozenset({"btw.cli_providers"}) diff --git a/tests/unit/test_dashboard_coding_cli.py b/tests/unit/test_dashboard_coding_cli.py index 3137c50ea5..8aae2e4c59 100644 --- a/tests/unit/test_dashboard_coding_cli.py +++ b/tests/unit/test_dashboard_coding_cli.py @@ -1,6 +1,7 @@ """Tests for the routes that switch a coding CLI's provider.""" import json +from types import SimpleNamespace import pytest @@ -103,3 +104,82 @@ def test_the_switch_action_is_high_risk(): policy = ACTION_POLICIES[coding_cli.WRITE_ACTION] assert policy.risk == "high" assert policy.requires_step_up is True + + +def test_the_state_action_is_an_ordinary_read(): + """Reading what a switch would do must not need fresh proof. + + A high-risk action is denied on the Dashboard until the operator answers a + step-up challenge, and the state route has no way to ask for one: gating it + behind the write action left the page with a load error instead of the list. + """ + from astrbot.core.auth.models import HIGH_RISK_ACTIONS + from astrbot.core.auth.registry import ACTION_POLICIES + + assert coding_cli.READ_ACTION == "platform.read" + assert coding_cli.READ_ACTION not in HIGH_RISK_ACTIONS + assert ACTION_POLICIES[coding_cli.READ_ACTION].requires_step_up is False + + +@pytest.mark.asyncio +async def test_the_state_route_reads_through_the_read_action(monkeypatch, homes): + """The route asks for the read, and reports both CLIs and their providers.""" + called: list[str] = [] + + async def read(request, auth): + called.append(coding_cli.READ_ACTION) + + async def write(request, auth): # pragma: no cover - failing here is the bug + raise AssertionError("the state route asked for the high-risk action") + + monkeypatch.setattr(coding_cli, "authorize_coding_cli_read", read) + monkeypatch.setattr(coding_cli, "authorize_coding_cli_write", write) + monkeypatch.setattr(coding_cli, "_config_of", lambda request: PROFILE) + + payload = await coding_cli.get_global_config(SimpleNamespace(), None) + + assert called == [coding_cli.READ_ACTION] + clis = payload["data"]["clis"] + assert [entry["cli"] for entry in clis] == ["claude_code", "codex"] + # The scoped preset belongs to Codex alone; the unscoped one to both. + assert [item["id"] for item in clis[0]["providers"]] == ["gw"] + assert [item["id"] for item in clis[1]["providers"]] == ["gw", "scoped"] + + +def _app_state(*, runtime_services, dashboard_services): + return SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + runtime=SimpleNamespace(services=runtime_services), + services=dashboard_services, + ) + ) + ) + + +def test_the_running_profile_comes_from_the_dashboard_services(): + """The config profile service is not on the core runtime's services. + + Reading it from there answered "Configuration is unavailable" on every + request, which is why the page showed a load error instead of its list. + """ + profiles = SimpleNamespace(acm=SimpleNamespace(confs={"default": PROFILE})) + request = _app_state( + # What the core runtime carries: authorization and core pieces only. + runtime_services=SimpleNamespace(authorization=object()), + dashboard_services=SimpleNamespace(config_profiles=profiles), + ) + + assert coding_cli._config_of(request) == PROFILE + + +def test_an_unreadable_configuration_is_reported_rather_than_guessed(): + request = _app_state( + runtime_services=SimpleNamespace(), + dashboard_services=SimpleNamespace(), + ) + + with pytest.raises(ApiError) as error: + coding_cli._config_of(request) + + assert error.value.status_code == 503