mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Merge dev/3.1 for 3.0.9 release
This commit is contained in:
commit
d29f2176e8
60 changed files with 8363 additions and 381 deletions
14
README.md
14
README.md
|
|
@ -177,6 +177,20 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co
|
|||
<strong>TeamoRouter</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://code0.ai?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai icon" />
|
||||
<br />
|
||||
<strong>code0.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.claudeapi.com?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi icon" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
|
|||
14
README_zh.md
14
README_zh.md
|
|
@ -176,6 +176,20 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
|
|||
<strong>TeamoRouter</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://code0.ai?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai 图标" />
|
||||
<br />
|
||||
<strong>code0.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.claudeapi.com?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi 图标" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ function runtimeEntryPointsForSuite(suiteName) {
|
|||
return [];
|
||||
}
|
||||
return [
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
];
|
||||
}
|
||||
|
|
|
|||
BIN
docs/public/provider-icons/claudeapi.png
Normal file
BIN
docs/public/provider-icons/claudeapi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/public/provider-icons/code0.png
Normal file
BIN
docs/public/provider-icons/code0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -2,29 +2,37 @@
|
|||
title: Claude Code Router Detailed Configuration
|
||||
pageTitle: Detailed Configuration
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Configure the overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and the config database location in detail.
|
||||
lead: "Separate main app pages from settings pages while following the app's actual order: main pages cover overview, providers, Agent Config, routing, Fusion, API keys, logs and observability, server, and extensions; settings pages cover ToolHub, Bots, data, and tray."
|
||||
---
|
||||
|
||||
## Page Structure
|
||||
|
||||
Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page.
|
||||
Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page. Main pages follow the app's left navigation order. Settings pages are grouped separately and follow the settings dialog order.
|
||||
|
||||
## Main Pages
|
||||
|
||||
| Page | Covers |
|
||||
| --- | --- |
|
||||
| Overview Dashboard | System status, account balance, usage widgets, layout editing, and share cards |
|
||||
| API Keys | Client access keys, expiration, and local limits |
|
||||
| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate |
|
||||
| Provider Config | Upstream services, protocol, Base URL, model list, and credentials |
|
||||
| One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries |
|
||||
| Routing Config | Default routing, conditional rules, fallback, and request rewrites |
|
||||
| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results |
|
||||
| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model |
|
||||
| Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding |
|
||||
| Routing Config | Default routing, conditional rules, fallback, and request rewrites |
|
||||
| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model |
|
||||
| API Keys | Client access keys, expiration, and local limits |
|
||||
| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results |
|
||||
| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate |
|
||||
| Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging |
|
||||
|
||||
## Settings Pages
|
||||
|
||||
| Page | Covers |
|
||||
| --- | --- |
|
||||
| ToolHub | Collapse many MCP servers into one dynamic tool resolution entry point for agents |
|
||||
| Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages |
|
||||
| Tray Configuration | Tray icon, balance progress, and tray window widgets |
|
||||
| Config Database Location | SQLite config database location maintained by the desktop app |
|
||||
| Tray Configuration | Tray icon, balance progress, and tray window widgets |
|
||||
|
||||
## Content Relationships
|
||||
|
||||
Overview Dashboard shows system status and usage. API Keys control client access to CCR. Server controls the local gateway listener and proxy features. Provider Config and One click import cover how upstream model services enter CCR. Routing determines where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Extension Mechanism covers local plugin creation, installation, and debugging. Bots cover IM platform relay. Tray Configuration covers the menu bar icon and tray window.
|
||||
Overview Dashboard shows system status and usage. Provider Config and One click import cover how upstream model services enter CCR. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Routing determines where model requests go. Fusion covers vision, web search, and MCP tools. API Keys control client access to CCR. Logs & Observability cover request logs and agent execution traces. Server controls the local gateway listener and proxy features. Extension Mechanism covers local plugin creation, installation, and debugging. ToolHub, Bots, Config Database Location, and Tray Configuration match the corresponding settings pages in the settings dialog.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ Choose a provider below to get started. CCR shows what will be added before savi
|
|||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/teamorouter.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">TeamoRouter</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&base_url=https%3A%2F%2Fconsole.code0.ai&protocol=anthropic_messages&source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="Import code0.ai provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/code0.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&base_url=https%3A%2F%2Fgw.claudeapi.com&protocol=anthropic_messages&source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="Import claudeapi provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Embeddable Button Component
|
||||
|
|
|
|||
167
docs/src/content/docs/en/configuration/toolhub.md
Normal file
167
docs/src/content/docs/en/configuration/toolhub.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
---
|
||||
title: ToolHub
|
||||
pageTitle: ToolHub
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Collapse many MCP servers into one compact entry point so agents lazy-load task-specific tools and save context.
|
||||
---
|
||||
|
||||
## When To Use It
|
||||
|
||||
As your MCP setup grows, exposing every tool directly to an agent makes the eager tool list large and easier to misuse. ToolHub exposes one `ccr-toolhub` MCP server with two meta tools:
|
||||
|
||||
- `tool_hub.resolve`: searches the available MCP tool catalog for the current task.
|
||||
- `tool_hub.invoke`: calls a real MCP tool that was selected for this task.
|
||||
|
||||
Use ToolHub for tools that are not needed often but are still useful occasionally. It lazy-loads them only when a task actually needs them. The main value is saving context: large low-frequency tool catalogs do not have to stay in the agent's eager tool list, which reduces context use and the chance of selecting the wrong tool. Simple local code, file, or conversation tasks usually do not need ToolHub.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Enable ToolHub in **Settings → ToolHub**.
|
||||
2. Select a configured model as the **Resolver model**. It reads the MCP tool catalog and chooses the tools needed for the task. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier.
|
||||
3. Add or import backend MCP servers. ToolHub supports `stdio`, `streamable-http`, and `sse`.
|
||||
4. Open Claude Code or Codex from CCR. CCR writes the `ccr-toolhub` MCP server into that agent config.
|
||||
5. When the agent receives a request about external services, installed MCP capabilities, or business APIs, it calls `tool_hub.resolve` first, then uses `tool_hub.invoke` to run the selected tools.
|
||||
|
||||
ToolHub combines MCP servers configured on the ToolHub page with compatible global Agent MCP servers from older configs, and excludes `ccr-toolhub` itself to avoid recursive calls.
|
||||
|
||||
## Built-In Browser Automation
|
||||
|
||||
When ToolHub is enabled in CCR Desktop and **Built-in browser automation** is turned on, agents can use the desktop built-in browser for web tasks. You do not need to add a browser backend on the ToolHub page, and you do not need a separate API key; CCR connects to it through the local gateway authentication path.
|
||||
|
||||
To enable it:
|
||||
|
||||
1. Open **Settings → ToolHub** and turn on **Enable ToolHub**.
|
||||
2. Turn on **Built-in browser automation** on the same page. This switch is shown only after ToolHub is enabled.
|
||||
3. After saving settings, reopen Claude Code or Codex from CCR so the new agent instance loads the latest configuration.
|
||||
|
||||
> Already-running agent instances usually do not pick up this switch immediately. Restart the agent instance, or use the agent's own controls to restart ToolHub.
|
||||
|
||||
Built-in browser automation is useful for tasks that need real browser state, such as opening sites, reading pages, filling forms, clicking buttons, scrolling, or completing web flows like ordering, booking, lookup, and checkout when no domain-specific capability exists. After it is enabled, the agent can:
|
||||
|
||||
- Open or attach built-in browser tabs, then navigate to URLs or search queries.
|
||||
- Read page content and find buttons, links, form fields, and other page elements.
|
||||
- Click, type, select, press keys, and scroll page elements.
|
||||
- Wait for page loads, navigation, dialogs, or human handoff results before continuing.
|
||||
- Request human help for login, verification codes, CAPTCHA, human checks, or manual confirmation.
|
||||
|
||||
When a web flow needs login, verification codes, CAPTCHA, a human check, or manual confirmation, CCR shows the built-in browser window and displays the requested action in the top toolbar. After the user clicks **Done** or **Hide**, the agent receives the result and continues. Handoff waits support up to 10 minutes.
|
||||
|
||||
### Chrome Login Import Extension
|
||||
|
||||
Built-in browser automation can also import login state for selected domains from system Chrome into CCR's in-app browser. This lets the agent reuse sites where you are already signed in to Chrome. It requires the unpacked Chrome extension in this repository: `extension/chrome`.
|
||||
|
||||
Install it:
|
||||
|
||||
1. Open `chrome://extensions` in Chrome.
|
||||
2. Enable **Developer mode**.
|
||||
3. Click **Load unpacked**.
|
||||
4. Select the repository's `extension/chrome` directory.
|
||||
|
||||
Import flow:
|
||||
|
||||
1. When a task needs existing Chrome login state, the agent can request an import; the user can also click the key button in CCR's in-app browser toolbar.
|
||||
2. CCR creates a one-time import job and opens a confirmation page. If your default browser is not Chrome, copy the confirmation URL into Chrome with the extension installed.
|
||||
3. Review the requested domains on the confirmation page, then click **Confirm and Import**.
|
||||
4. The Chrome extension reads cookies and localStorage for those domains and submits them to CCR. After it completes, the agent can continue the task in the built-in browser.
|
||||
|
||||
The extension reads only the domains listed in the CCR import job. It does not enumerate every Chrome cookie. For localStorage, the extension temporarily opens non-active tabs for the selected origins, reads `localStorage`, and closes those tabs. If the confirmation page says the extension does not have site access, allow the extension to access the target domains in Chrome extension settings, reload the unpacked extension, and try again.
|
||||
|
||||
> Note: Built-in browser automation depends on CCR Desktop's built-in browser and is only available in the desktop app. CLI, server deployments, and pure web environments do not include this built-in capability; use an external browser automation MCP server instead.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| Enable ToolHub | Exposes `ccr-toolhub` to agents. If no backend MCP server is available, CCR does not generate a ToolHub MCP config. |
|
||||
| Built-in browser automation | Shown only after ToolHub is enabled. Lets agents use CCR Desktop's built-in browser for web tasks. |
|
||||
| Resolver model | Choose from configured provider models. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier with enough tool-description understanding. |
|
||||
| Max tools | Maximum tools returned by one resolve call. Range `1` to `20`, default `10`. |
|
||||
| Timeout ms | Base timeout for ToolHub resolving and invocation. Range `8000` to `300000`, default `60000`. If a backend MCP server needs a longer request timeout, CCR raises the effective invocation timeout to match the backend. |
|
||||
| MCP servers | Backend tool sources. Each server needs a unique name plus transport, command or URL, environment variables, headers, and timeouts. |
|
||||
| Import JSON | Imports common MCP JSON shapes. Supports a root object, array, `mcpServers`, or `mcp_servers`. |
|
||||
|
||||
## Add MCP Servers
|
||||
|
||||
### stdio
|
||||
|
||||
Use `stdio` for local command-line MCP servers. Configure:
|
||||
|
||||
- **Command**: launch command, such as `npx`, `node`, or `python`.
|
||||
- **Arguments**: command arguments.
|
||||
- **Working directory**: optional working directory.
|
||||
- **Stdio message mode**: keep `content-length` by default; use `newline-json` for line-delimited JSON servers.
|
||||
- **Environment variables**: variables needed only by this MCP server.
|
||||
|
||||
### streamable-http / sse
|
||||
|
||||
Remote MCP servers need a URL. Authentication can use:
|
||||
|
||||
- **API key**: stored directly in the config.
|
||||
- **API key env**: read from an environment variable.
|
||||
- **Headers**: custom request headers.
|
||||
|
||||
If a remote server starts slowly or has long-running calls, adjust that server's **Startup timeout** or **Request timeout**.
|
||||
|
||||
## JSON Example
|
||||
|
||||
The desktop app's SQLite config is the effective source, so prefer editing through the UI. The fields below are useful for backups, migration, or troubleshooting:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolHub": {
|
||||
"enabled": true,
|
||||
"browserAutomation": true,
|
||||
"llm": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-5-mini"
|
||||
},
|
||||
"maxTools": 10,
|
||||
"requestTimeoutMs": 60000,
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "filesystem",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env": {},
|
||||
"stdioMessageMode": "content-length",
|
||||
"requestTimeoutMs": 30000,
|
||||
"startupTimeoutMs": 600000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The import dialog also accepts common MCP JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ToolHub vs Fusion MCP
|
||||
|
||||
| Capability | ToolHub | Fusion Custom MCP Tool |
|
||||
| --- | --- | --- |
|
||||
| Entry point | Agent-side `ccr-toolhub` MCP server | Capability inside one Fusion model |
|
||||
| Tool selection | Dynamically resolves a tool bundle for each task | Fixed tools selected in the model config |
|
||||
| Best for | Many MCP servers, changing tool catalogs, agent-led capability discovery | Adding a known tool set to one model |
|
||||
| Visibility | Claude Code or Codex configs opened through CCR | Routes or agents that select that Fusion model |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Agent cannot see ToolHub: make sure ToolHub is enabled and at least one backend MCP server is configured or **Built-in browser automation** is turned on, then reopen Claude Code or Codex from CCR.
|
||||
- Missing resolver model or API key: select a configured resolver model and confirm the provider credential works.
|
||||
- The agent cannot use built-in browser automation: make sure you are using CCR Desktop, turned on **Built-in browser automation** in **Settings → ToolHub**, and reopened Claude Code or Codex from CCR. CLI, server deployments, and pure web environments do not include this built-in capability.
|
||||
- Chrome login import confirmation keeps waiting for the extension: make sure the unpacked `extension/chrome` extension is loaded in Chrome and has site access for the target domains. If your default browser is not Chrome, copy the confirmation URL into Chrome manually.
|
||||
- No tools are resolved: confirm the MCP server can list tools, improve tool names and descriptions, or increase **Max tools**.
|
||||
- Calls time out: check ToolHub **Timeout ms** and the backend server request/startup timeouts.
|
||||
- Import fails: validate JSON, avoid duplicate server names, make sure `stdio` entries have a command and remote entries have a URL.
|
||||
|
|
@ -2,29 +2,37 @@
|
|||
title: Claude Code Router 详细配置
|
||||
pageTitle: 详细配置
|
||||
eyebrow: 详细配置
|
||||
lead: 深入配置概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置。这里是按功能查字段和扩展能力的地方。
|
||||
lead: 按应用中的实际顺序,将主页页面和设置页分开说明:主页覆盖概览、供应商、Agent配置、路由、Fusion、API 密钥、日志&观测、服务和扩展;设置页覆盖 ToolHub、Bot、数据和托盘。
|
||||
---
|
||||
|
||||
## 页面结构
|
||||
|
||||
详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。
|
||||
详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。主页页面跟随应用左侧主导航顺序;设置页单独分组,并按设置弹窗顺序排列。
|
||||
|
||||
## 主页页面
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| 概览仪表盘 | 系统状态、账户余额、用量组件、布局编辑和分享卡片 |
|
||||
| API 密钥 | 客户端访问 Key、过期时间和本地限额 |
|
||||
| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 |
|
||||
| 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 |
|
||||
| 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 |
|
||||
| 路由配置 | 条件规则、fallback 和请求改写 |
|
||||
| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 |
|
||||
| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 |
|
||||
| Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 |
|
||||
| 路由配置 | 条件规则、fallback 和请求改写 |
|
||||
| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 |
|
||||
| API 密钥 | 客户端访问 Key、过期时间和本地限额 |
|
||||
| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 |
|
||||
| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 |
|
||||
| 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 |
|
||||
|
||||
## 设置页
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| ToolHub | 将多个 MCP server 收束成一个 Agent 可用的动态工具检索入口 |
|
||||
| Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 |
|
||||
| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 |
|
||||
| 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 |
|
||||
| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 |
|
||||
|
||||
## 内容关系
|
||||
|
||||
概览仪表盘用于查看系统状态和用量;API 密钥控制客户端访问 CCR;服务配置控制本地网关监听和代理能力;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;扩展机制页面覆盖本地插件的创建、安装和调试;Bot 页面覆盖 IM 平台接力;托盘配置覆盖菜单栏图标和托盘窗口。
|
||||
概览仪表盘用于查看系统状态和用量;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;路由决定模型请求的上游去向;Fusion 页面覆盖图像、搜索和 MCP 工具;API 密钥控制客户端访问 CCR;日志&观测覆盖请求日志和 Agent 执行链路;服务配置控制本地网关监听和代理能力;扩展机制覆盖本地插件的创建、安装和调试。ToolHub、Bot、配置数据库位置和托盘配置对应设置弹窗中的同名配置页。
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少
|
|||
<span class="provider-import-icon-shell"><img src="../../provider-icons/teamorouter.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">TeamoRouter</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&base_url=https%3A%2F%2Fconsole.code0.ai&protocol=anthropic_messages&source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="导入 code0.ai 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/code0.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&base_url=https%3A%2F%2Fgw.claudeapi.com&protocol=anthropic_messages&source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="导入 claudeapi 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 嵌入式按钮组件
|
||||
|
|
|
|||
167
docs/src/content/docs/zh/configuration/toolhub.md
Normal file
167
docs/src/content/docs/zh/configuration/toolhub.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
---
|
||||
title: ToolHub
|
||||
pageTitle: ToolHub
|
||||
eyebrow: 详细配置
|
||||
lead: 将多个 MCP server 收束成一个紧凑入口,让 Agent 按任务懒加载需要的工具,减少工具列表占用的上下文。
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
当你接入的 MCP server 越来越多时,直接把所有工具暴露给 Agent 会让工具列表变长,也更容易选错工具。ToolHub 会向 Agent 暴露一个 `ccr-toolhub` MCP server,里面只有两个元工具:
|
||||
|
||||
- `tool_hub.resolve`:根据用户任务和上下文检索可用 MCP 工具。
|
||||
- `tool_hub.invoke`:调用已经被本轮任务选中的真实 MCP 工具。
|
||||
|
||||
它适合把不常用但偶尔会用到的工具统一懒加载,在任务真正需要时才交给 Agent 使用。核心价值是节省上下文:避免把大量低频工具常驻在 Agent 的工具列表里,减少上下文占用和选错工具的概率。简单本地代码、文件或普通聊天任务通常不需要经过 ToolHub。
|
||||
|
||||
## 工作方式
|
||||
|
||||
1. 在 **设置 → ToolHub** 中启用 ToolHub。
|
||||
2. 选择一个已配置模型作为 **检索模型**。它负责阅读 MCP 工具目录并挑选本轮任务需要的工具;建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定的轻量模型。
|
||||
3. 添加或导入后端 MCP server。ToolHub 支持 `stdio`、`streamable-http` 和 `sse`。
|
||||
4. 从 CCR 打开 Claude Code 或 Codex。CCR 会在对应 Agent 配置中写入 `ccr-toolhub`。
|
||||
5. Agent 遇到外部服务、已安装 MCP 能力或业务 API 相关请求时,先调用 `tool_hub.resolve`,再用 `tool_hub.invoke` 执行选中的工具。
|
||||
|
||||
ToolHub 会合并 **ToolHub 页面配置的 MCP servers** 和兼容旧配置中的全局 Agent MCP servers,并自动排除 `ccr-toolhub` 自身,避免递归调用。
|
||||
|
||||
## 内置浏览器自动化
|
||||
|
||||
在 CCR Desktop 中启用 ToolHub,并打开 **内置浏览器自动化** 开关后,Agent 可以使用桌面端内置浏览器完成网页操作。不需要在 ToolHub 页面手动添加浏览器后端,也不需要额外 API Key;CCR 会使用本地网关鉴权连接它。
|
||||
|
||||
启用步骤:
|
||||
|
||||
1. 打开 **设置 → ToolHub**,先开启 **启用 ToolHub**。
|
||||
2. 在同一页打开 **内置浏览器自动化** 开关。该开关只会在 ToolHub 已启用时显示。
|
||||
3. 保存设置后,从 CCR 重新打开 Claude Code 或 Codex,让新的 Agent 实例加载最新配置。
|
||||
|
||||
> 已经运行中的 Agent 实例通常不会立即拿到这个开关变化。要让现有会话生效,请重启该 Agent 实例,或使用 Agent 自身能力重启 ToolHub。
|
||||
|
||||
内置浏览器自动化适合让 Agent 处理需要真实浏览器状态的任务,例如打开网站、读取页面、填写表单、点击按钮、在页面中滚动,或在没有专用业务能力时完成下单、预约、查询、结账等网页流程。开启后 Agent 可以:
|
||||
|
||||
- 打开或附加内置浏览器标签页、导航 URL 或搜索词。
|
||||
- 读取页面内容,并找到按钮、链接、输入框等页面元素。
|
||||
- 点击、输入、选择、按键和滚动页面元素。
|
||||
- 等待页面加载、跳转、弹窗或人类接管结果,再继续后续步骤。
|
||||
- 在登录、验证码、CAPTCHA、人机验证或人工确认时请求用户接管。
|
||||
|
||||
当网页流程需要登录、验证码、CAPTCHA、人机验证或人工确认时,CCR 会显示内置浏览器窗口,并在顶部工具栏提示用户需要完成的步骤。用户点击 **Done** 或 **Hide** 后,Agent 会收到结果并继续执行。接管等待最长支持 10 分钟。
|
||||
|
||||
### Chrome 登录态导入扩展
|
||||
|
||||
内置浏览器自动化还支持把系统 Chrome 中指定域名的登录状态导入 CCR 内置浏览器。这样 Agent 处理网页任务时,可以复用你已经在 Chrome 中登录过的网站状态。该能力需要安装仓库里的 Chrome 解包扩展:`extension/chrome`。
|
||||
|
||||
安装方式:
|
||||
|
||||
1. 在 Chrome 打开 `chrome://extensions`。
|
||||
2. 开启 **Developer mode**。
|
||||
3. 点击 **Load unpacked**。
|
||||
4. 选择仓库中的 `extension/chrome` 目录。
|
||||
|
||||
导入流程:
|
||||
|
||||
1. 当任务需要复用 Chrome 登录状态时,Agent 会请求导入;用户也可以在 CCR 内置浏览器工具栏点击钥匙按钮主动发起。
|
||||
2. CCR 创建一次性导入任务,并打开确认页。如果默认浏览器不是 Chrome,请把确认页 URL 复制到已安装扩展的 Chrome 中打开。
|
||||
3. 用户在确认页检查要导入的域名,点击 **Confirm and Import**。
|
||||
4. Chrome 扩展读取这些域名的 cookies 和 localStorage,提交给 CCR;完成后 Agent 可以继续使用内置浏览器执行任务。
|
||||
|
||||
扩展只读取 CCR 导入任务列出的域名,不会枚举 Chrome 中的全部 cookies。读取 localStorage 时,扩展会临时打开对应 origin 的非激活标签页,读取后自动关闭。若确认页提示扩展没有站点访问权限,请在 Chrome 扩展设置中允许该扩展访问目标域名,然后重新加载解包扩展再重试。
|
||||
|
||||
> 注意:内置浏览器自动化依赖 CCR Desktop 的内置浏览器,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有这项内置能力,请改用外部浏览器自动化 MCP server。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| 启用 ToolHub | 开启后才会向 Agent 暴露 `ccr-toolhub`。如果没有可用后端 MCP server,CCR 不会生成 ToolHub MCP 配置。 |
|
||||
| 内置浏览器自动化 | 仅在启用 ToolHub 后显示。开启后让 Agent 可以使用 CCR Desktop 的内置浏览器完成网页操作。 |
|
||||
| 检索模型 | 从已配置供应商模型中选择。建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定、工具理解能力足够的轻量模型。 |
|
||||
| 最大工具数 | 单次解析最多返回的工具数量,范围 `1` 到 `20`,默认 `10`。 |
|
||||
| 超时毫秒 | ToolHub 解析和调用的基础超时时间,范围 `8000` 到 `300000`,默认 `60000`。如果后端 MCP server 需要更长 request timeout,CCR 会按后端超时自动抬高实际调用超时。 |
|
||||
| MCP servers | 后端工具来源。每个 server 需要唯一名称,并配置 transport、命令或 URL、环境变量、headers 和超时。 |
|
||||
| Import JSON | 导入常见 MCP JSON。支持根对象、数组、`mcpServers` 或 `mcp_servers`。 |
|
||||
|
||||
## 添加 MCP Server
|
||||
|
||||
### stdio
|
||||
|
||||
`stdio` 适合本地命令行 MCP server。需要填写:
|
||||
|
||||
- **Command**:启动命令,例如 `npx`、`node`、`python`。
|
||||
- **Arguments**:命令参数。
|
||||
- **Working directory**:可选工作目录。
|
||||
- **Stdio message mode**:默认 `content-length`,如果 server 使用逐行 JSON,选择 `newline-json`。
|
||||
- **Environment variables**:只放这个 MCP server 需要的变量。
|
||||
|
||||
### streamable-http / sse
|
||||
|
||||
远程 MCP server 需要填写 URL。鉴权可以使用:
|
||||
|
||||
- **API key**:直接保存在配置中。
|
||||
- **API key env**:从环境变量读取。
|
||||
- **Headers**:添加自定义请求头。
|
||||
|
||||
如果远程服务启动慢或请求耗时长,可以单独调高该 server 的 **Startup timeout** 或 **Request timeout**。
|
||||
|
||||
## JSON 示例
|
||||
|
||||
桌面 App 的 SQLite 配置是当前生效来源,建议优先通过 UI 修改。下面字段适用于备份、迁移或排查时理解 ToolHub 配置结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolHub": {
|
||||
"enabled": true,
|
||||
"browserAutomation": true,
|
||||
"llm": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-5-mini"
|
||||
},
|
||||
"maxTools": 10,
|
||||
"requestTimeoutMs": 60000,
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "filesystem",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env": {},
|
||||
"stdioMessageMode": "content-length",
|
||||
"requestTimeoutMs": 30000,
|
||||
"startupTimeoutMs": 600000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
导入 MCP JSON 时也可以使用常见格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 与 Fusion MCP 的区别
|
||||
|
||||
| 能力 | ToolHub | Fusion 自定义 MCP 工具 |
|
||||
| --- | --- | --- |
|
||||
| 使用入口 | Agent 侧的 `ccr-toolhub` MCP server | 某个 Fusion 模型内部能力 |
|
||||
| 工具选择 | 每个任务动态检索并返回工具包 | 模型配置中固定选择工具 |
|
||||
| 适合场景 | MCP server 很多、工具目录经常变化、希望 Agent 自主发现能力 | 给某个模型补一组明确工具 |
|
||||
| 可见范围 | 通过 CCR 打开的 Claude Code 或 Codex 配置 | 选择该 Fusion 模型的路由或 Agent |
|
||||
|
||||
## 排查
|
||||
|
||||
- Agent 看不到 ToolHub:确认已启用 ToolHub,并且至少配置了一个后端 MCP server 或开启了 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。
|
||||
- 提示缺少检索模型或 API Key:在 **检索模型** 中选择已配置模型,并确认供应商凭据可用。
|
||||
- Agent 无法使用内置浏览器自动化:确认正在使用 CCR Desktop,并且已在 **设置 → ToolHub** 中开启 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。CLI、服务器部署或纯 Web 环境没有这项内置能力。
|
||||
- Chrome 登录态导入确认页一直等待扩展:确认已在 Chrome 中加载 `extension/chrome` 解包扩展,并允许扩展访问要导入的目标域名。如果默认浏览器不是 Chrome,请手动把确认页 URL 复制到 Chrome。
|
||||
- 解析不到工具:检查 MCP server 是否能正常列出工具,工具名称和描述是否足够清楚,必要时提高 **最大工具数**。
|
||||
- 调用超时:分别检查 ToolHub 的 **超时毫秒** 和单个 MCP server 的 request/startup timeout。
|
||||
- 导入失败:检查 JSON 是否有效、server 名称是否重复、`stdio` 是否有 command,远程 transport 是否有 URL。
|
||||
|
|
@ -60,25 +60,33 @@ export const docsContent = {
|
|||
configuration: {
|
||||
sidebarGroups: [
|
||||
{
|
||||
label: "详细配置",
|
||||
label: "主页页面",
|
||||
icon: "wand",
|
||||
items: [
|
||||
"概览仪表盘",
|
||||
"供应商配置",
|
||||
"一键导入供应商",
|
||||
"路由配置",
|
||||
"日志&观测",
|
||||
"Fusion 组合模型",
|
||||
"Agent配置",
|
||||
"路由配置",
|
||||
"Fusion 组合模型",
|
||||
"API 密钥",
|
||||
"日志&观测",
|
||||
"服务配置",
|
||||
"托盘配置",
|
||||
"扩展机制",
|
||||
"Bot 与 IM 接力 Agent",
|
||||
"配置数据库位置",
|
||||
],
|
||||
active: "概览仪表盘",
|
||||
},
|
||||
{
|
||||
label: "设置页",
|
||||
icon: "book",
|
||||
items: [
|
||||
"ToolHub",
|
||||
"Bot 与 IM 接力 Agent",
|
||||
"配置数据库位置",
|
||||
"托盘配置",
|
||||
],
|
||||
active: "",
|
||||
},
|
||||
],
|
||||
expandableSidebarItems: ["Fusion 组合模型", "Bot 与 IM 接力 Agent"],
|
||||
sidebarChildren: {
|
||||
|
|
@ -105,6 +113,7 @@ export const docsContent = {
|
|||
内置图像能力: "/configuration/fusion-vision/",
|
||||
内置联网搜索: "/configuration/fusion-web-search/",
|
||||
"自定义 MCP 工具": "/configuration/fusion-mcp-tool/",
|
||||
ToolHub: "/configuration/toolhub/",
|
||||
Agent配置: "/configuration/profile/",
|
||||
"API 密钥": "/configuration/api-keys/",
|
||||
服务配置: "/configuration/server/",
|
||||
|
|
@ -208,25 +217,33 @@ export const docsContent = {
|
|||
configuration: {
|
||||
sidebarGroups: [
|
||||
{
|
||||
label: "Detailed Configuration",
|
||||
label: "Main Pages",
|
||||
icon: "wand",
|
||||
items: [
|
||||
"Overview Dashboard",
|
||||
"Provider Config",
|
||||
"One click import",
|
||||
"Routing Config",
|
||||
"Logs & Observability",
|
||||
"Fusion Models",
|
||||
"Agent Config",
|
||||
"Routing Config",
|
||||
"Fusion Models",
|
||||
"API Keys",
|
||||
"Logs & Observability",
|
||||
"Server",
|
||||
"Tray Configuration",
|
||||
"Extension Mechanism",
|
||||
"Bots And IM Agent Relay",
|
||||
"Config Database Location",
|
||||
],
|
||||
active: "Overview Dashboard",
|
||||
},
|
||||
{
|
||||
label: "Settings Pages",
|
||||
icon: "book",
|
||||
items: [
|
||||
"ToolHub",
|
||||
"Bots And IM Agent Relay",
|
||||
"Config Database Location",
|
||||
"Tray Configuration",
|
||||
],
|
||||
active: "",
|
||||
},
|
||||
],
|
||||
expandableSidebarItems: ["Fusion Models", "Bots And IM Agent Relay"],
|
||||
sidebarChildren: {
|
||||
|
|
@ -243,6 +260,7 @@ export const docsContent = {
|
|||
"Built-In Vision": "/en/configuration/fusion-vision/",
|
||||
"Built-In Web Search": "/en/configuration/fusion-web-search/",
|
||||
"Custom MCP Tool": "/en/configuration/fusion-mcp-tool/",
|
||||
ToolHub: "/en/configuration/toolhub/",
|
||||
"Agent Config": "/en/configuration/profiles/",
|
||||
"API Keys": "/en/configuration/api-keys/",
|
||||
Server: "/en/configuration/server/",
|
||||
|
|
|
|||
|
|
@ -291,6 +291,11 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";
|
|||
<ul class="sidebar-children directory-children">
|
||||
{section.groups.map((group) => (
|
||||
<li class="directory-group">
|
||||
{(section.groups.length > 1 || group.label !== section.label) && (
|
||||
<div class="directory-group-label">
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ul>
|
||||
{group.items.map((item) => {
|
||||
const isExpandable = item.children.length > 0;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function getStaticPaths() {
|
|||
"fusion-vision": "内置图像能力",
|
||||
"fusion-web-search": "内置联网搜索",
|
||||
"fusion-mcp-tool": "自定义 MCP 工具",
|
||||
toolhub: "ToolHub",
|
||||
extensions: "扩展机制",
|
||||
server: "服务配置",
|
||||
tray: "托盘配置",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function getStaticPaths() {
|
|||
"fusion-vision": "Built-In Vision",
|
||||
"fusion-web-search": "Built-In Web Search",
|
||||
"fusion-mcp-tool": "Custom MCP Tool",
|
||||
toolhub: "ToolHub",
|
||||
extensions: "Extension Mechanism",
|
||||
server: "Server",
|
||||
tray: "Tray Configuration",
|
||||
|
|
|
|||
|
|
@ -642,28 +642,26 @@ pre {
|
|||
}
|
||||
|
||||
.directory-group {
|
||||
margin: 2px 0 9px;
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
|
||||
.directory-group:last-child {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.directory-group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 7px 8px 3px 0;
|
||||
color: var(--sidebar-heading);
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
line-height: 1.35;
|
||||
.directory-group + .directory-group {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.directory-group-label .group-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.directory-group-label {
|
||||
display: block;
|
||||
min-height: 20px;
|
||||
padding: 2px 8px 5px 0;
|
||||
color: var(--sidebar-subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sidebar-group h2 {
|
||||
|
|
@ -768,7 +766,7 @@ pre {
|
|||
}
|
||||
|
||||
.sidebar-directory .sidebar-children {
|
||||
padding-left: 24px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.sidebar-children::before {
|
||||
|
|
@ -786,6 +784,14 @@ pre {
|
|||
left: 8px;
|
||||
}
|
||||
|
||||
.sidebar-directory .directory-children {
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.sidebar-directory .directory-children::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-details[open] > .sidebar-children {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
|
@ -1312,6 +1318,18 @@ h1 {
|
|||
--provider-brand-3: #f4f4f5;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-code0 {
|
||||
--provider-brand: #101214;
|
||||
--provider-brand-2: #267dff;
|
||||
--provider-brand-3: #d8e8ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-claudeapi {
|
||||
--provider-brand: #0d1b2a;
|
||||
--provider-brand-2: #2f8f83;
|
||||
--provider-brand-3: #d7fff8;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-deepseek {
|
||||
--provider-brand: #173aa8;
|
||||
--provider-brand-2: #4e69ff;
|
||||
|
|
|
|||
23
extension/chrome/README.md
Normal file
23
extension/chrome/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# CCR Login Import Chrome Extension
|
||||
|
||||
This unpacked Chrome extension imports cookies and localStorage for explicitly selected domains into CCR's in-app browser.
|
||||
|
||||
## Development install
|
||||
|
||||
1. Open `chrome://extensions`.
|
||||
2. Enable **Developer mode**.
|
||||
3. Click **Load unpacked**.
|
||||
4. Select this `extension/chrome` directory.
|
||||
|
||||
After changing extension files, click **Reload** for this unpacked extension in `chrome://extensions`.
|
||||
The confirmation-page flow uses the site access declared in `manifest.json`; it does not request new host permissions from the page click.
|
||||
|
||||
## Flow
|
||||
|
||||
1. An agent calls CCR's Chrome login import browser tool, or the user clicks the key button in CCR's in-app browser.
|
||||
2. CCR opens a one-time confirmation page in the system browser.
|
||||
3. Review the requested domains and click **Confirm and Import**.
|
||||
|
||||
The extension reads only the domains listed in the CCR job. It does not enumerate all Chrome cookies.
|
||||
|
||||
For localStorage, the extension temporarily opens non-active tabs for the selected origins, reads `localStorage`, then closes those tabs.
|
||||
262
extension/chrome/background.js
Normal file
262
extension/chrome/background.js
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (!message || message.type !== "ccr-login-import-confirm") {
|
||||
return false;
|
||||
}
|
||||
|
||||
runImport(message.importUrl)
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => sendResponse({ error: formatError(error), ok: false }));
|
||||
return true;
|
||||
});
|
||||
|
||||
async function runImport(importUrl) {
|
||||
const normalizedImportUrl = normalizeImportUrl(importUrl);
|
||||
if (!normalizedImportUrl) {
|
||||
throw new Error("Invalid CCR import URL.");
|
||||
}
|
||||
|
||||
const job = await fetchImportJob(normalizedImportUrl);
|
||||
const domains = Array.isArray(job.domains) ? job.domains.map(normalizeDomain).filter(Boolean) : [];
|
||||
if (domains.length === 0) {
|
||||
throw new Error("CCR import job does not include any domains.");
|
||||
}
|
||||
|
||||
await ensureHostPermissions(domains);
|
||||
|
||||
const cookies = await readCookiesForDomains(domains);
|
||||
const localStorageEntries = await readLocalStorageForDomains(domains, cookies);
|
||||
if (cookies.length === 0 && localStorageEntries.length === 0) {
|
||||
throw new Error("No cookies or localStorage entries were found for the selected domains.");
|
||||
}
|
||||
|
||||
return await submitLoginState(normalizedImportUrl, cookies, localStorageEntries, domains);
|
||||
}
|
||||
|
||||
async function fetchImportJob(importUrl) {
|
||||
const response = await fetch(importUrl, {
|
||||
headers: {
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
}
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR import job request failed (${response.status}).`);
|
||||
}
|
||||
if (!body.job || body.job.status !== "pending") {
|
||||
throw new Error(`CCR import job is ${body.job?.status || "unavailable"}.`);
|
||||
}
|
||||
return body.job;
|
||||
}
|
||||
|
||||
async function submitLoginState(importUrl, cookies, localStorage, domains) {
|
||||
const response = await fetch(`${importUrl.replace(/\/+$/, "")}/cookies`, {
|
||||
body: JSON.stringify({
|
||||
cookies,
|
||||
domains,
|
||||
localStorage,
|
||||
source: {
|
||||
browser: "chrome",
|
||||
extension: chrome.runtime.getManifest().version
|
||||
}
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR login import failed (${response.status}).`);
|
||||
}
|
||||
return body.result || {};
|
||||
}
|
||||
|
||||
async function readCookiesForDomains(domains) {
|
||||
const cookies = [];
|
||||
for (const domain of domains) {
|
||||
cookies.push(...await chrome.cookies.getAll({ domain }));
|
||||
}
|
||||
return dedupeCookies(cookies);
|
||||
}
|
||||
|
||||
async function readLocalStorageForDomains(domains, cookies) {
|
||||
const origins = localStorageOriginsForDomains(domains, cookies);
|
||||
const entries = [];
|
||||
for (const origin of origins) {
|
||||
const entry = await readLocalStorageForOrigin(origin).catch(() => undefined);
|
||||
if (entry && Object.keys(entry.items).length > 0) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readLocalStorageForOrigin(origin) {
|
||||
const tab = await chrome.tabs.create({
|
||||
active: false,
|
||||
url: `${origin}/`
|
||||
});
|
||||
try {
|
||||
await waitForTabLoad(tab.id, 15000);
|
||||
const [frameResult] = await chrome.scripting.executeScript({
|
||||
func: () => {
|
||||
const items = {};
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key !== null) {
|
||||
items[key] = window.localStorage.getItem(key) || "";
|
||||
}
|
||||
}
|
||||
return {
|
||||
items,
|
||||
origin: window.location.origin
|
||||
};
|
||||
},
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN"
|
||||
});
|
||||
const result = frameResult?.result;
|
||||
if (!result || !allowedLocalStorageOrigin(result.origin, origin)) {
|
||||
return undefined;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (tab.id !== undefined) {
|
||||
await chrome.tabs.remove(tab.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForTabLoad(tabId, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
const listener = (updatedTabId, changeInfo) => {
|
||||
if (updatedTabId === tabId && changeInfo.status === "complete") {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
const timeout = setTimeout(finish, timeoutMs);
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeCookies(cookies) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const cookie of cookies) {
|
||||
const partitionKey = cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : "";
|
||||
const key = `${cookie.storeId || ""}\n${cookie.domain}\n${cookie.path}\n${cookie.name}\n${partitionKey}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(cookie);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function localStorageOriginsForDomains(domains, cookies) {
|
||||
const origins = new Set();
|
||||
for (const domain of domains) {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
origins.add(`http://${domain}`);
|
||||
origins.add(`https://${domain}`);
|
||||
} else {
|
||||
origins.add(`https://${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const host = normalizeDomain(cookie.domain);
|
||||
if (!host || !cookie.hostOnly || !domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) {
|
||||
continue;
|
||||
}
|
||||
origins.add(`${cookie.secure === false ? "http" : "https"}://${host}`);
|
||||
}
|
||||
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
function originsForDomains(domains) {
|
||||
return [...new Set(domains.flatMap((domain) => {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`
|
||||
];
|
||||
}
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`,
|
||||
`http://*.${domain}/*`,
|
||||
`https://*.${domain}/*`
|
||||
];
|
||||
}))];
|
||||
}
|
||||
|
||||
async function ensureHostPermissions(domains) {
|
||||
const origins = originsForDomains(domains);
|
||||
const granted = await chrome.permissions.contains({ origins });
|
||||
if (granted) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`CCR Login Import does not have Chrome site access for ${domains.join(", ")}.`,
|
||||
"Reload the unpacked extension after updating it, then grant the extension site access for the requested domains in Chrome extensions settings."
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImportUrl(value) {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!["127.0.0.1", "localhost"].includes(url.hostname)) {
|
||||
return "";
|
||||
}
|
||||
if (!/^\/chrome-import\/jobs\/[^/]+\/?$/.test(url.pathname)) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, "");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDomain(value) {
|
||||
return typeof value === "string"
|
||||
? value.trim().replace(/^\*\./, "").replace(/^\./, "").toLowerCase()
|
||||
: "";
|
||||
}
|
||||
|
||||
function allowedLocalStorageOrigin(actualOrigin, requestedOrigin) {
|
||||
try {
|
||||
const actual = new URL(actualOrigin);
|
||||
const requested = new URL(requestedOrigin);
|
||||
return actual.protocol === requested.protocol && actual.hostname === requested.hostname && actual.port === requested.port;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isIpAddress(value) {
|
||||
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.includes(":");
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
46
extension/chrome/confirm.js
Normal file
46
extension/chrome/confirm.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
const root = document.getElementById("ccr-chrome-login-import");
|
||||
const button = document.getElementById("ccr-confirm-import");
|
||||
const statusElement = document.getElementById("ccr-import-status");
|
||||
|
||||
if (root && button && statusElement) {
|
||||
const importUrl = root.getAttribute("data-import-url") || "";
|
||||
button.disabled = false;
|
||||
button.textContent = "Confirm and Import";
|
||||
setStatus("CCR Login Import extension is connected. Review the domains, then confirm.");
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
void confirmImport(importUrl);
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmImport(importUrl) {
|
||||
button.disabled = true;
|
||||
setStatus("Importing Chrome cookies and localStorage into CCR...");
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
importUrl,
|
||||
type: "ccr-login-import-confirm"
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Chrome login import failed.");
|
||||
}
|
||||
const result = response.result || {};
|
||||
setStatus(
|
||||
`Imported ${result.cookieImported || 0} cookies and ${result.localStorageImported || 0} localStorage items. Skipped ${result.skipped || 0}.`,
|
||||
"ok"
|
||||
);
|
||||
button.textContent = "Imported";
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
setStatus(formatError(error), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${kind}`.trim();
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
35
extension/chrome/manifest.json
Normal file
35
extension/chrome/manifest.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CCR Login Import",
|
||||
"description": "Import selected Chrome site login cookies into CCR's in-app browser.",
|
||||
"version": "0.1.0",
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "CCR Login Import"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": [
|
||||
"confirm.js"
|
||||
],
|
||||
"matches": [
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"scripting",
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*",
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
]
|
||||
}
|
||||
110
extension/chrome/popup.html
Normal file
110
extension/chrome/popup.html
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCR Login Import</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 360px;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: color-mix(in srgb, CanvasText 4%, Canvas);
|
||||
border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
|
||||
border-radius: 8px;
|
||||
color: CanvasText;
|
||||
font: inherit;
|
||||
min-height: 76px;
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
background: #0f766e;
|
||||
border: 1px solid #0f766e;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: 8px;
|
||||
color: color-mix(in srgb, CanvasText 74%, transparent);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
min-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
color: #15803d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>CCR Login Import</h1>
|
||||
<label>
|
||||
Import URL
|
||||
<textarea id="import-url" spellcheck="false" placeholder="Paste the URL copied from CCR"></textarea>
|
||||
</label>
|
||||
<button id="import-button" type="button">Import Selected Domains</button>
|
||||
<div id="status" class="status" role="status"></div>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
297
extension/chrome/popup.js
Normal file
297
extension/chrome/popup.js
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
const importUrlInput = document.getElementById("import-url");
|
||||
const importButton = document.getElementById("import-button");
|
||||
const statusElement = document.getElementById("status");
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void restoreLastImportUrl();
|
||||
});
|
||||
|
||||
importButton.addEventListener("click", () => {
|
||||
void runImport();
|
||||
});
|
||||
|
||||
async function restoreLastImportUrl() {
|
||||
const stored = await chrome.storage.local.get(["lastImportUrl"]);
|
||||
if (typeof stored.lastImportUrl === "string") {
|
||||
importUrlInput.value = stored.lastImportUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const importUrl = normalizeImportUrl(importUrlInput.value);
|
||||
if (!importUrl) {
|
||||
setStatus("Paste the import URL copied from CCR.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
importButton.disabled = true;
|
||||
try {
|
||||
await chrome.storage.local.set({ lastImportUrl: importUrl });
|
||||
setStatus("Reading CCR import job...");
|
||||
const job = await fetchImportJob(importUrl);
|
||||
const domains = Array.isArray(job.domains) ? job.domains.map(normalizeDomain).filter(Boolean) : [];
|
||||
if (domains.length === 0) {
|
||||
throw new Error("CCR import job does not include any domains.");
|
||||
}
|
||||
|
||||
await ensureHostPermissions(domains);
|
||||
|
||||
setStatus(`Reading cookies for ${domains.join(", ")}...`);
|
||||
const cookies = await readCookiesForDomains(domains);
|
||||
setStatus(`Reading localStorage for ${domains.join(", ")}...`);
|
||||
const localStorageEntries = await readLocalStorageForDomains(domains, cookies);
|
||||
|
||||
if (cookies.length === 0 && localStorageEntries.length === 0) {
|
||||
throw new Error("No cookies or localStorage entries were found for the selected domains.");
|
||||
}
|
||||
|
||||
setStatus(`Sending ${cookies.length} cookies and ${localStorageItemCount(localStorageEntries)} localStorage items to CCR...`);
|
||||
const result = await submitCookies(importUrl, cookies, localStorageEntries, domains);
|
||||
setStatus(
|
||||
`Imported ${result.cookieImported ?? 0} cookies and ${result.localStorageImported ?? 0} localStorage items. Skipped ${result.skipped ?? 0}.`,
|
||||
"ok"
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(formatError(error), "error");
|
||||
} finally {
|
||||
importButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchImportJob(importUrl) {
|
||||
const response = await fetch(importUrl, {
|
||||
headers: {
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
}
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR import job request failed (${response.status}).`);
|
||||
}
|
||||
if (!body.job || body.job.status !== "pending") {
|
||||
throw new Error(`CCR import job is ${body.job?.status || "unavailable"}.`);
|
||||
}
|
||||
return body.job;
|
||||
}
|
||||
|
||||
async function submitCookies(importUrl, cookies, localStorage, domains) {
|
||||
const response = await fetch(`${importUrl.replace(/\/+$/, "")}/cookies`, {
|
||||
body: JSON.stringify({
|
||||
cookies,
|
||||
domains,
|
||||
localStorage,
|
||||
source: {
|
||||
browser: "chrome",
|
||||
extension: chrome.runtime.getManifest().version
|
||||
}
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR cookie import failed (${response.status}).`);
|
||||
}
|
||||
return body.result || {};
|
||||
}
|
||||
|
||||
async function readCookiesForDomains(domains) {
|
||||
const cookies = [];
|
||||
for (const domain of domains) {
|
||||
cookies.push(...await chrome.cookies.getAll({ domain }));
|
||||
}
|
||||
return dedupeCookies(cookies);
|
||||
}
|
||||
|
||||
async function readLocalStorageForDomains(domains, cookies) {
|
||||
const origins = localStorageOriginsForDomains(domains, cookies);
|
||||
const entries = [];
|
||||
for (const origin of origins) {
|
||||
const entry = await readLocalStorageForOrigin(origin).catch(() => undefined);
|
||||
if (entry && Object.keys(entry.items).length > 0) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readLocalStorageForOrigin(origin) {
|
||||
const tab = await chrome.tabs.create({
|
||||
active: false,
|
||||
url: `${origin}/`
|
||||
});
|
||||
try {
|
||||
await waitForTabLoad(tab.id, 15000);
|
||||
const [frameResult] = await chrome.scripting.executeScript({
|
||||
func: () => {
|
||||
const items = {};
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key !== null) {
|
||||
items[key] = window.localStorage.getItem(key) || "";
|
||||
}
|
||||
}
|
||||
return {
|
||||
items,
|
||||
origin: window.location.origin
|
||||
};
|
||||
},
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN"
|
||||
});
|
||||
const result = frameResult?.result;
|
||||
if (!result || !allowedLocalStorageOrigin(result.origin, origin)) {
|
||||
return undefined;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (tab.id !== undefined) {
|
||||
await chrome.tabs.remove(tab.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForTabLoad(tabId, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
window.clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
const listener = (updatedTabId, changeInfo) => {
|
||||
if (updatedTabId === tabId && changeInfo.status === "complete") {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
const timeout = window.setTimeout(finish, timeoutMs);
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeCookies(cookies) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const cookie of cookies) {
|
||||
const partitionKey = cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : "";
|
||||
const key = `${cookie.storeId || ""}\n${cookie.domain}\n${cookie.path}\n${cookie.name}\n${partitionKey}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(cookie);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function localStorageOriginsForDomains(domains, cookies) {
|
||||
const origins = new Set();
|
||||
for (const domain of domains) {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
origins.add(`http://${domain}`);
|
||||
origins.add(`https://${domain}`);
|
||||
} else {
|
||||
origins.add(`https://${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const host = normalizeDomain(cookie.domain);
|
||||
if (!host || !cookie.hostOnly || !domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) {
|
||||
continue;
|
||||
}
|
||||
origins.add(`${cookie.secure === false ? "http" : "https"}://${host}`);
|
||||
}
|
||||
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
function allowedLocalStorageOrigin(actualOrigin, requestedOrigin) {
|
||||
try {
|
||||
const actual = new URL(actualOrigin);
|
||||
const requested = new URL(requestedOrigin);
|
||||
return actual.protocol === requested.protocol && actual.hostname === requested.hostname && actual.port === requested.port;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function localStorageItemCount(entries) {
|
||||
return entries.reduce((count, entry) => count + Object.keys(entry.items || {}).length, 0);
|
||||
}
|
||||
|
||||
function originsForDomains(domains) {
|
||||
return [...new Set(domains.flatMap((domain) => {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`
|
||||
];
|
||||
}
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`,
|
||||
`http://*.${domain}/*`,
|
||||
`https://*.${domain}/*`
|
||||
];
|
||||
}))];
|
||||
}
|
||||
|
||||
async function ensureHostPermissions(domains) {
|
||||
const origins = originsForDomains(domains);
|
||||
const granted = await chrome.permissions.contains({ origins });
|
||||
if (granted) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`CCR Login Import does not have Chrome site access for ${domains.join(", ")}.`,
|
||||
"Reload the unpacked extension after updating it, then grant the extension site access for the requested domains in Chrome extensions settings."
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImportUrl(value) {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!["127.0.0.1", "localhost"].includes(url.hostname)) {
|
||||
return "";
|
||||
}
|
||||
if (!/^\/chrome-import\/jobs\/[^/]+\/?$/.test(url.pathname)) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, "");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDomain(value) {
|
||||
return typeof value === "string"
|
||||
? value.trim().replace(/^\*\./, "").replace(/^\./, "").toLowerCase()
|
||||
: "";
|
||||
}
|
||||
|
||||
function isIpAddress(value) {
|
||||
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.includes(":");
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${kind}`.trim();
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -700,6 +700,10 @@ function parseToolHub(value: unknown): Partial<ToolHubConfig> | undefined {
|
|||
if (typeof value.enabled === "boolean") {
|
||||
toolHub.enabled = value.enabled;
|
||||
}
|
||||
const browserAutomation = value.browserAutomation ?? value.browser_automation;
|
||||
if (typeof browserAutomation === "boolean") {
|
||||
toolHub.browserAutomation = browserAutomation;
|
||||
}
|
||||
const maxTools = readNumber(value.maxTools ?? value.max_tools);
|
||||
if (maxTools !== undefined) {
|
||||
toolHub.maxTools = clampNumber(maxTools, 1, 20);
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
|
|||
trayWidgets: DEFAULT_TRAY_WIDGETS,
|
||||
trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES,
|
||||
toolHub: {
|
||||
browserAutomation: false,
|
||||
enabled: false,
|
||||
llm: {
|
||||
apiKey: "",
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "u
|
|||
export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests";
|
||||
export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string;
|
||||
export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | string;
|
||||
export type ProviderAccountHttpJsonParser = "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self";
|
||||
|
||||
export type ProviderAccountConfig = {
|
||||
connectors?: ProviderAccountConnectorConfig[];
|
||||
|
|
@ -193,7 +194,7 @@ export type ProviderAccountHttpJsonConnectorConfig = ProviderAccountConnectorBas
|
|||
headers?: Record<string, string>;
|
||||
mapping: ProviderAccountMappingConfig;
|
||||
method?: "GET" | "POST";
|
||||
parser?: "kimi-code-usages";
|
||||
parser?: ProviderAccountHttpJsonParser;
|
||||
type: "http-json";
|
||||
};
|
||||
|
||||
|
|
@ -405,6 +406,8 @@ export type GatewayProviderCapability = {
|
|||
type: GatewayProviderProtocol;
|
||||
};
|
||||
|
||||
export type GatewayProviderDetectedProvider = "new-api";
|
||||
|
||||
export type GatewayProviderProbeRequest = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
|
|
@ -445,6 +448,7 @@ export type ProviderIconDetectionResult = {
|
|||
|
||||
export type GatewayProviderProbeProtocolResult = {
|
||||
baseUrl?: string;
|
||||
detectedProvider?: GatewayProviderDetectedProvider;
|
||||
endpoint: string;
|
||||
message: string;
|
||||
protocol: GatewayProviderProtocol;
|
||||
|
|
@ -453,7 +457,9 @@ export type GatewayProviderProbeProtocolResult = {
|
|||
};
|
||||
|
||||
export type GatewayProviderProbeResult = {
|
||||
account?: ProviderAccountConfig;
|
||||
capabilities?: GatewayProviderCapability[];
|
||||
detectedProvider?: GatewayProviderDetectedProvider;
|
||||
detectedProtocol?: GatewayProviderProtocol;
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
modelSource?: "anthropic" | "gemini" | "openai";
|
||||
|
|
@ -642,6 +648,7 @@ export type ToolHubLlmConfig = {
|
|||
};
|
||||
|
||||
export type ToolHubConfig = {
|
||||
browserAutomation: boolean;
|
||||
enabled: boolean;
|
||||
llm: ToolHubLlmConfig;
|
||||
mcpServers: GatewayMcpServerConfig[];
|
||||
|
|
@ -1468,12 +1475,91 @@ export type BuiltInBrowserTabState = {
|
|||
url: string;
|
||||
};
|
||||
|
||||
export type BuiltInBrowserAutomationHandoffKind =
|
||||
| "blocked"
|
||||
| "human_verification"
|
||||
| "login_required"
|
||||
| "other"
|
||||
| "verification_code";
|
||||
|
||||
export type BuiltInBrowserAutomationHandoff = {
|
||||
id: string;
|
||||
kind: BuiltInBrowserAutomationHandoffKind;
|
||||
message: string;
|
||||
reason?: string;
|
||||
requestedAt: number;
|
||||
sessionId?: string;
|
||||
status: "pending";
|
||||
tabId?: string;
|
||||
};
|
||||
|
||||
export type BuiltInBrowserState = {
|
||||
activeTabId?: string;
|
||||
apps: InstalledBrowserApp[];
|
||||
automationHandoff?: BuiltInBrowserAutomationHandoff;
|
||||
tabs: BuiltInBrowserTabState[];
|
||||
};
|
||||
|
||||
export type ChromeLoginImportTarget = "browser" | "browser-and-web-search";
|
||||
|
||||
export type ChromeLoginImportStatus =
|
||||
| "completed"
|
||||
| "expired"
|
||||
| "failed"
|
||||
| "pending";
|
||||
|
||||
export type ChromeLoginImportRequest = {
|
||||
domains: string[];
|
||||
openConfirmationPage?: boolean;
|
||||
target?: ChromeLoginImportTarget;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportResult = {
|
||||
completedAt: number;
|
||||
cookieImported: number;
|
||||
cookieSkipped: number;
|
||||
domains: string[];
|
||||
errors?: string[];
|
||||
imported: number;
|
||||
localStorageImported: number;
|
||||
localStorageSkipped: number;
|
||||
partitions: string[];
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportJob = {
|
||||
confirmUrl: string;
|
||||
createdAt: number;
|
||||
domains: string[];
|
||||
endpointUrl: string;
|
||||
expiresAt: number;
|
||||
id: string;
|
||||
importUrl: string;
|
||||
result?: ChromeLoginImportResult;
|
||||
status: ChromeLoginImportStatus;
|
||||
target: ChromeLoginImportTarget;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportCookie = {
|
||||
domain: string;
|
||||
expirationDate?: number;
|
||||
hostOnly?: boolean;
|
||||
httpOnly?: boolean;
|
||||
name: string;
|
||||
partitionKey?: unknown;
|
||||
path?: string;
|
||||
sameSite?: "lax" | "no_restriction" | "strict" | "unspecified";
|
||||
secure?: boolean;
|
||||
session?: boolean;
|
||||
storeId?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportLocalStorage = {
|
||||
items: Record<string, string>;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
export type ProxyCertificateInstallResult = {
|
||||
caCertFile: string;
|
||||
manualCommand?: string;
|
||||
|
|
|
|||
|
|
@ -73,10 +73,13 @@ export const IPC_CHANNELS = {
|
|||
browserBack: "ccr:browser:back",
|
||||
browserCloseTab: "ccr:browser:close-tab",
|
||||
browserForward: "ccr:browser:forward",
|
||||
browserGetChromeLoginImport: "ccr:browser:get-chrome-login-import",
|
||||
browserGetState: "ccr:browser:get-state",
|
||||
browserNavigate: "ccr:browser:navigate",
|
||||
browserNewTab: "ccr:browser:new-tab",
|
||||
browserReload: "ccr:browser:reload",
|
||||
browserResolveAutomationHandoff: "ccr:browser:resolve-automation-handoff",
|
||||
browserSelectTab: "ccr:browser:select-tab",
|
||||
browserStartChromeLoginImport: "ccr:browser:start-chrome-login-import",
|
||||
browserStateChanged: "ccr:browser:state-changed"
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ function resolveBuiltInClaudeCodeSubagentRouteDecision(
|
|||
return undefined;
|
||||
}
|
||||
const target = normalizeRouteSelector(request.builtInSubagentModel);
|
||||
if (!target) {
|
||||
if (!target || isSubagentModelPlaceholder(target) || !isKnownInlineRoute(target, config)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
|
|
@ -322,6 +322,7 @@ function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
|
|||
const ccrSubagentModelOpenTag = "<CCR-SUBAGENT-MODEL>";
|
||||
const ccrSubagentModelCloseTag = "</CCR-SUBAGENT-MODEL>";
|
||||
const ccrSubagentModelTagExample = `${ccrSubagentModelOpenTag}Provider/model${ccrSubagentModelCloseTag}`;
|
||||
const ccrSubagentModelPlaceholder = "provider/model";
|
||||
const claudeCodeBillingSystemHeaderPrefix = "x-anthropic-billing-header";
|
||||
const ccrSubagentToolModelInstruction =
|
||||
`CCR subagent routing is enabled. When calling this tool, the prompt parameter MUST start with ` +
|
||||
|
|
@ -365,9 +366,11 @@ function injectClaudeCodeToolHubInstructions(body: Record<string, unknown>, conf
|
|||
const invokeName = toolNames.invoke ?? "tool_hub.invoke";
|
||||
appendSystemInstruction(body, [
|
||||
ccrToolHubSystemInstructionMarker,
|
||||
`You MUST call ${toolNames.resolve} before answering any request that asks about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`,
|
||||
`Do this even if the user did not mention ToolHub or ${toolNames.resolve}. Only skip ${toolNames.resolve} when the request is clearly local code/file/shell work or simple conversation that does not need an external or MCP capability.`,
|
||||
`If ${toolNames.resolve} returns selected tools, use ${invokeName} to call the selected tool instead of telling the user that no such capability is available.`,
|
||||
`The ToolHub search/resolution tool is ${toolNames.resolve}; call this actual tool, do not merely mention its name in text.`,
|
||||
`You MUST call the ToolHub search/resolution tool ${toolNames.resolve} before answering any request that asks about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`,
|
||||
`Do this even if the user did not mention ToolHub or ${toolNames.resolve}. Only skip the ToolHub search/resolution tool when the request is clearly local code/file/shell work or simple conversation that does not need an external or MCP capability.`,
|
||||
`If ${toolNames.resolve} returns selected tools, call the ToolHub invocation tool ${invokeName} to run the selected tool instead of telling the user that no such capability is available.`,
|
||||
"When the ToolHub resolve result includes executionPlanJs or workflowSketch, treat that JavaScript as the invocation dependency plan: await means serial order, and only callTool calls grouped inside the same Promise.all may be issued in parallel.",
|
||||
"Use the user's request as the task and include concise context when resolving. Do not ask the user to name the MCP tool unless the task is genuinely ambiguous after resolution."
|
||||
].join("\n"));
|
||||
}
|
||||
|
|
@ -380,16 +383,40 @@ function claudeCodeToolHubToolNames(tools: unknown[]): { invoke?: string; resolv
|
|||
}
|
||||
const name = claudeCodeToolName(tool);
|
||||
const normalized = normalizeClaudeCodeToolName(name);
|
||||
if (!names.resolve && normalized.endsWith("toolhubresolve")) {
|
||||
if (normalized.endsWith("toolhubresolve") && shouldUseClaudeCodeToolHubName(names.resolve, name)) {
|
||||
names.resolve = name;
|
||||
}
|
||||
if (!names.invoke && normalized.endsWith("toolhubinvoke")) {
|
||||
if (normalized.endsWith("toolhubinvoke") && shouldUseClaudeCodeToolHubName(names.invoke, name)) {
|
||||
names.invoke = name;
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function shouldUseClaudeCodeToolHubName(current: string | undefined, candidate: string | undefined): boolean {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
if (!current) {
|
||||
return true;
|
||||
}
|
||||
return claudeCodeToolHubNameScore(candidate) > claudeCodeToolHubNameScore(current);
|
||||
}
|
||||
|
||||
function claudeCodeToolHubNameScore(name: string): number {
|
||||
const normalized = name.toLowerCase();
|
||||
if (normalized.startsWith("mcp__ccr-toolhub__") || normalized.startsWith("mcp__ccr_toolhub__")) {
|
||||
return 3;
|
||||
}
|
||||
if (normalized.startsWith("mcp__") && normalized.includes("toolhub")) {
|
||||
return 2;
|
||||
}
|
||||
if (normalized.startsWith("mcp__")) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function appendSystemInstruction(body: Record<string, unknown>, instruction: string): void {
|
||||
if (systemContainsInstruction(body.system, ccrToolHubSystemInstructionMarker)) {
|
||||
return;
|
||||
|
|
@ -1174,6 +1201,10 @@ function isKnownInlineRoute(model: string | undefined, config: AppConfig): boole
|
|||
return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName);
|
||||
}
|
||||
|
||||
function isSubagentModelPlaceholder(model: string): boolean {
|
||||
return model.trim().toLowerCase() === ccrSubagentModelPlaceholder;
|
||||
}
|
||||
|
||||
function calculateTokenCount(messages: unknown, system: unknown, tools: unknown): number {
|
||||
return countMessageTokens(messages) + countSystemTokens(system) + countToolTokens(tools);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store";
|
|||
import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp";
|
||||
import { TOOL_HUB_MCP_SERVER_NAME, toolHubMcpRuntimeConfig } from "@ccr/core/mcp/toolhub-config";
|
||||
import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store";
|
||||
|
|
@ -130,6 +130,7 @@ export type BrowserWebSearchMcpRegistration = {
|
|||
|
||||
export type BrowserWebSearchProtocolResult = {
|
||||
content?: string;
|
||||
diagnostics?: string[];
|
||||
snippet?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
|
|
@ -151,6 +152,11 @@ export type BrowserWebSearchMcpIntegration = {
|
|||
stopBrowserWebSearchMcpServers: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type BrowserAutomationMcpIntegration = {
|
||||
handleBrowserAutomationMcpRequest: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
||||
stopBrowserAutomationMcpServer: () => Promise<void>;
|
||||
};
|
||||
|
||||
type CoreGatewayHealth = {
|
||||
runtimeId?: string;
|
||||
status?: string;
|
||||
|
|
@ -312,6 +318,7 @@ const persistedApiKeyCacheTtlMs = 1000;
|
|||
let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined;
|
||||
|
||||
class GatewayService {
|
||||
private browserAutomationMcpIntegration?: BrowserAutomationMcpIntegration;
|
||||
private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration;
|
||||
private child?: ChildProcess;
|
||||
private config?: AppConfig;
|
||||
|
|
@ -332,6 +339,10 @@ class GatewayService {
|
|||
this.browserWebSearchMcpIntegration = integration;
|
||||
}
|
||||
|
||||
setBrowserAutomationMcpIntegration(integration: BrowserAutomationMcpIntegration): void {
|
||||
this.browserAutomationMcpIntegration = integration;
|
||||
}
|
||||
|
||||
async start(config: AppConfig): Promise<GatewayStatus> {
|
||||
const coreHostError = loopbackCoreHostError(config.gateway.coreHost);
|
||||
if (coreHostError) {
|
||||
|
|
@ -380,7 +391,7 @@ class GatewayService {
|
|||
}
|
||||
|
||||
if (shouldRunGateway) {
|
||||
await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.browserWebSearchMcpIntegration);
|
||||
await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.coreAuthToken, this.browserWebSearchMcpIntegration);
|
||||
await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint);
|
||||
if (await isCoreGatewayHealthy(this.status.coreEndpoint)) {
|
||||
throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`);
|
||||
|
|
@ -439,6 +450,9 @@ class GatewayService {
|
|||
await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => {
|
||||
console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`);
|
||||
});
|
||||
await this.browserAutomationMcpIntegration?.stopBrowserAutomationMcpServer().catch((error) => {
|
||||
console.warn(`[gateway] Failed to stop browser automation MCP: ${formatError(error)}`);
|
||||
});
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
|
|
@ -551,6 +565,31 @@ class GatewayService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (path === BROWSER_AUTOMATION_MCP_PATH || path === `${BROWSER_AUTOMATION_MCP_PATH}/`) {
|
||||
if (!browserAutomationMcpEnabled(this.config)) {
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
message: "CCR browser automation MCP is disabled."
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
if (!this.browserAutomationMcpIntegration) {
|
||||
sendJson(response, 503, {
|
||||
error: {
|
||||
message: "CCR browser automation MCP is only available in the Electron desktop app."
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.browserAutomationMcpIntegration.handleBrowserAutomationMcpRequest(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNetworkCaptureMcpPath(path)) {
|
||||
if (!this.config.proxy.captureNetwork) {
|
||||
sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } });
|
||||
|
|
@ -819,6 +858,15 @@ class GatewayService {
|
|||
sinceMs: startedAt - 1_000
|
||||
});
|
||||
|
||||
if (hostedWebSearchProtocolContext && !this.browserWebSearchMcpIntegration) {
|
||||
const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName);
|
||||
const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" });
|
||||
const responseBody = JSON.stringify({ error: { message } });
|
||||
writeRequestLog(503, responseHeaders, responseBody, false, message);
|
||||
sendJson(response, 503, { error: { message } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) {
|
||||
const records = await selectHostedWebSearchProtocolRecords(
|
||||
hostedWebSearchProtocolContext,
|
||||
|
|
@ -1102,6 +1150,7 @@ export const gatewayService = new GatewayService();
|
|||
async function writeCoreGatewayConfig(
|
||||
config: AppConfig,
|
||||
rawTraceSyncToken: string,
|
||||
coreAuthToken: string,
|
||||
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
|
||||
): Promise<void> {
|
||||
assertLoopbackCoreHost(config.gateway.coreHost);
|
||||
|
|
@ -1117,7 +1166,7 @@ async function writeCoreGatewayConfig(
|
|||
...pluginService.getVirtualModelProfiles()
|
||||
])), config);
|
||||
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
|
||||
const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, browserWebSearchMcpIntegration);
|
||||
const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration);
|
||||
const providers = [
|
||||
...config.Providers
|
||||
.flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames)))
|
||||
|
|
@ -1411,6 +1460,7 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|||
async function fusionBuiltinToolArtifacts(
|
||||
profiles: unknown[],
|
||||
coreEndpoint: string,
|
||||
coreAuthToken: string,
|
||||
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
|
||||
): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> {
|
||||
const providers: CoreGatewayProvider[] = [];
|
||||
|
|
@ -1433,12 +1483,14 @@ async function fusionBuiltinToolArtifacts(
|
|||
const toolServerKey = `vision:${visionConfig.toolName}`;
|
||||
if (!toolServerKeys.has(toolServerKey)) {
|
||||
toolServerKeys.add(toolServerKey);
|
||||
const useGatewayVisionRuntime = !visionConfig.baseUrl;
|
||||
mcpServers.push(fusionBuiltinMcpServer({
|
||||
entry,
|
||||
env: {
|
||||
FUSION_BUILTIN_TOOL_KIND: "vision",
|
||||
FUSION_TOOL_NAME: visionConfig.toolName,
|
||||
...(visionConfig.baseUrl ? { VISION_BASE_URL: visionConfig.baseUrl } : { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` }),
|
||||
...(useGatewayVisionRuntime ? { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` } : { VISION_BASE_URL: visionConfig.baseUrl || "" }),
|
||||
...(useGatewayVisionRuntime && coreAuthToken ? { VISION_GATEWAY_API_KEY: coreAuthToken } : {}),
|
||||
...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}),
|
||||
...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}),
|
||||
...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {})
|
||||
|
|
@ -1486,6 +1538,15 @@ async function fusionBuiltinToolArtifacts(
|
|||
return { mcpServers, providers };
|
||||
}
|
||||
|
||||
export async function fusionBuiltinToolArtifactsForTest(
|
||||
profiles: unknown[],
|
||||
coreEndpoint: string,
|
||||
coreAuthToken: string,
|
||||
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
|
||||
): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> {
|
||||
return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration);
|
||||
}
|
||||
|
||||
function fusionBuiltinMcpServer({
|
||||
entry,
|
||||
env,
|
||||
|
|
@ -1546,16 +1607,20 @@ function bundledFusionToolFallbackMcpEntryPath(): string {
|
|||
|
||||
function toolHubMcpServer(config: AppConfig, backendServers: unknown[]): GatewayMcpServerConfig | undefined {
|
||||
const toolHub = config.toolHub;
|
||||
const runtimeConfig = toolHubMcpRuntimeConfig(config, backendServers);
|
||||
const runtimeBackendServers = [
|
||||
...toolHubBuiltInBackendServers(config),
|
||||
...backendServers
|
||||
];
|
||||
const runtimeConfig = toolHubMcpRuntimeConfig(config, runtimeBackendServers);
|
||||
if (!toolHub?.enabled || !runtimeConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...runtimeConfig,
|
||||
name: uniqueMcpServerName(TOOL_HUB_MCP_SERVER_NAME, backendServers),
|
||||
name: uniqueMcpServerName(TOOL_HUB_MCP_SERVER_NAME, runtimeBackendServers),
|
||||
protocolVersion: "2024-11-05",
|
||||
requestTimeoutMs: Math.max(toolHub.requestTimeoutMs ?? 60000, 60000),
|
||||
requestTimeoutMs: toolHubRequestTimeoutMs(config, runtimeBackendServers),
|
||||
startupTimeoutMs: 600000,
|
||||
stdioMessageMode: "content-length",
|
||||
transport: "stdio"
|
||||
|
|
@ -1565,53 +1630,121 @@ function toolHubMcpServer(config: AppConfig, backendServers: unknown[]): Gateway
|
|||
export function fusionFallbackToolDefinitions(
|
||||
profiles: unknown[],
|
||||
backedToolNames: Set<string> = new Set()
|
||||
): Array<{ description?: string; inputSchema?: Record<string, unknown>; name: string }> {
|
||||
const byName = new Map<string, { description?: string; inputSchema?: Record<string, unknown>; name: string }>();
|
||||
): FusionFallbackToolDefinition[] {
|
||||
const byName = new Map<string, FusionFallbackToolDefinition>();
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (!isRecord(profile) || profile.enabled === false || !Array.isArray(profile.tools)) {
|
||||
if (!isRecord(profile) || profile.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
for (const tool of profile.tools) {
|
||||
if (!isRecord(tool)) {
|
||||
continue;
|
||||
}
|
||||
const name = stringValue(tool.name);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
if (backedToolNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = byName.get(name);
|
||||
const description = stringValue(tool.description);
|
||||
const inputSchema = isRecord(tool.inputSchema)
|
||||
? tool.inputSchema
|
||||
: isRecord(tool.input_schema)
|
||||
? tool.input_schema
|
||||
: undefined;
|
||||
if (existing) {
|
||||
if (!existing.description && description) {
|
||||
existing.description = description;
|
||||
if (Array.isArray(profile.tools)) {
|
||||
for (const tool of profile.tools) {
|
||||
if (!isRecord(tool)) {
|
||||
continue;
|
||||
}
|
||||
if (!existing.inputSchema && inputSchema) {
|
||||
existing.inputSchema = inputSchema;
|
||||
const name = stringValue(tool.name);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
if (backedToolNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
byName.set(name, {
|
||||
...(description ? { description } : {}),
|
||||
...(inputSchema ? { inputSchema } : {}),
|
||||
name
|
||||
});
|
||||
const existing = byName.get(name);
|
||||
const description = stringValue(tool.description);
|
||||
const inputSchema = isRecord(tool.inputSchema)
|
||||
? tool.inputSchema
|
||||
: isRecord(tool.input_schema)
|
||||
? tool.input_schema
|
||||
: undefined;
|
||||
const unavailableMessage = fusionFallbackToolUnavailableMessage(profile, name);
|
||||
if (existing) {
|
||||
if (!existing.description && description) {
|
||||
existing.description = description;
|
||||
}
|
||||
if (!existing.inputSchema && inputSchema) {
|
||||
existing.inputSchema = inputSchema;
|
||||
}
|
||||
if (!existing.unavailableMessage && unavailableMessage) {
|
||||
existing.unavailableMessage = unavailableMessage;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
byName.set(name, {
|
||||
...(description ? { description } : {}),
|
||||
...(inputSchema ? { inputSchema } : {}),
|
||||
...(unavailableMessage ? { unavailableMessage } : {}),
|
||||
name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const browserFallback = browserWebSearchFallbackToolDefinition(profile, backedToolNames);
|
||||
if (browserFallback && !byName.has(browserFallback.name)) {
|
||||
byName.set(browserFallback.name, browserFallback);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
type FusionFallbackToolDefinition = {
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
name: string;
|
||||
unavailableMessage?: string;
|
||||
};
|
||||
|
||||
function fusionFallbackToolUnavailableMessage(profile: unknown, toolName: string): string | undefined {
|
||||
if (!isRecord(profile)) {
|
||||
return undefined;
|
||||
}
|
||||
const metadata = isRecord(profile.metadata) ? profile.metadata : undefined;
|
||||
const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined;
|
||||
const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch);
|
||||
if (webSearchConfig?.provider !== "browser" || webSearchConfig.toolName !== toolName) {
|
||||
return undefined;
|
||||
}
|
||||
return browserWebSearchUnavailableMessage(toolName);
|
||||
}
|
||||
|
||||
function browserWebSearchUnavailableMessage(toolName: string): string {
|
||||
return [
|
||||
`Fusion MCP tool "${toolName}" is unavailable because In-app Browser web search requires CCR Desktop.`,
|
||||
"This runtime did not register the Electron browser web search integration, so the hidden browser search tool cannot run here.",
|
||||
"Run the profile in CCR Desktop or switch the Fusion web search provider to Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, or Exa."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function browserWebSearchFallbackToolDefinition(
|
||||
profile: Record<string, unknown>,
|
||||
backedToolNames: Set<string>
|
||||
): FusionFallbackToolDefinition | undefined {
|
||||
const metadata = isRecord(profile.metadata) ? profile.metadata : undefined;
|
||||
const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined;
|
||||
const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch);
|
||||
if (webSearchConfig?.provider !== "browser" || !webSearchConfig.toolName || backedToolNames.has(webSearchConfig.toolName)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
description: "Fallback registration for CCR In-app Browser web search when the Electron browser integration is unavailable.",
|
||||
inputSchema: {
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
count: { maximum: 20, minimum: 1, type: "number" },
|
||||
prompt: { type: "string" },
|
||||
query: { type: "string" }
|
||||
},
|
||||
required: ["prompt"],
|
||||
type: "object"
|
||||
},
|
||||
name: webSearchConfig.toolName,
|
||||
unavailableMessage: fusionFallbackToolUnavailableMessage(profile, webSearchConfig.toolName)
|
||||
};
|
||||
}
|
||||
|
||||
export function fusionToolNamesBackedByMcpServers(servers: unknown[]): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const server of servers) {
|
||||
|
|
@ -3037,7 +3170,8 @@ function hostedWebSearchEvidenceText(records: BrowserWebSearchProtocolRecord[],
|
|||
const content = focusedWebSearchContent(result.content, queryHint);
|
||||
const details = [
|
||||
result.snippet ? `Search snippet: ${result.snippet}` : "",
|
||||
content ? `Extracted page content: ${content}` : ""
|
||||
content ? `Extracted page content: ${content}` : "",
|
||||
result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : ""
|
||||
].filter(Boolean).join("\n");
|
||||
return [
|
||||
`${resultIndex + 1}. ${result.title}`,
|
||||
|
|
@ -5201,7 +5335,8 @@ function anthropicWebSearchResultBlock(result: BrowserWebSearchProtocolResult):
|
|||
function anthropicWebSearchResultSnippet(result: BrowserWebSearchProtocolResult): string | undefined {
|
||||
const parts = [
|
||||
result.snippet ? `Search snippet: ${sanitizeWebSearchEvidenceText(result.snippet)}` : "",
|
||||
result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : ""
|
||||
result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : "",
|
||||
result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : ""
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type McpTool = {
|
|||
description: string;
|
||||
inputSchema: JsonValue;
|
||||
name: string;
|
||||
unavailableMessage?: string;
|
||||
};
|
||||
|
||||
type ToolCallResult = {
|
||||
|
|
@ -134,10 +135,11 @@ async function handleJsonRpcRequest(payload: unknown): Promise<JsonRpcResponse |
|
|||
function callTool(params: unknown): ToolCallResult {
|
||||
const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : "";
|
||||
const toolLabel = name || "unknown";
|
||||
const tool = tools.find((item) => item.name === toolLabel);
|
||||
const knownSuffix = toolNames.has(toolLabel) ? "" : " The requested tool was not in the fallback catalog.";
|
||||
return {
|
||||
content: [{
|
||||
text:
|
||||
text: tool?.unavailableMessage ||
|
||||
`Fusion MCP tool "${toolLabel}" is temporarily unavailable. ` +
|
||||
"CCR registered a fallback definition because the real MCP server did not provide the tool during discovery. " +
|
||||
`Check the Fusion MCP server logs and retry.${knownSuffix}`,
|
||||
|
|
@ -179,7 +181,8 @@ function readFallbackTools(): McpTool[] {
|
|||
readString(item.description) ||
|
||||
`Fallback registration for Fusion MCP tool "${name}". The real MCP server should handle successful calls.`,
|
||||
inputSchema: isRecord(item.inputSchema) ? item.inputSchema as JsonValue : objectSchema({}),
|
||||
name
|
||||
name,
|
||||
unavailableMessage: readString(item.unavailableMessage)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { join as pathJoin } from "node:path";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import type { AppConfig, GatewayMcpServerConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
export const TOOL_HUB_MCP_SERVER_NAME = "ccr-toolhub";
|
||||
export const TOOL_HUB_MCP_RUNTIME_FILE_NAME = "toolhub-mcp.js";
|
||||
export const BROWSER_AUTOMATION_MCP_SERVER_NAME = "ccr-browser-automation";
|
||||
export const BROWSER_AUTOMATION_MCP_PATH = "/__ccr/browser-automation/mcp";
|
||||
export const BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS = 600000;
|
||||
export const TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS = 60000;
|
||||
|
||||
export type ToolHubMcpRuntimeConfig = {
|
||||
args: string[];
|
||||
|
|
@ -26,17 +30,53 @@ export type ToolHubClaudeCodeMcpConfig = {
|
|||
mcpServers: Record<string, ClaudeCodeMcpServerConfig>;
|
||||
};
|
||||
|
||||
export function toolHubBackendServers(config: AppConfig | undefined, extraServers: unknown[] = []): unknown[] {
|
||||
export function toolHubBackendServers(
|
||||
config: AppConfig | undefined,
|
||||
extraServers: unknown[] = [],
|
||||
options: {
|
||||
apiKey?: string;
|
||||
includeBuiltIns?: boolean;
|
||||
} = {}
|
||||
): unknown[] {
|
||||
return [
|
||||
...(options.includeBuiltIns === false ? [] : toolHubBuiltInBackendServers(config, options)),
|
||||
...(Array.isArray(config?.agent?.mcpServers) ? config.agent.mcpServers : []),
|
||||
...(Array.isArray(config?.toolHub?.mcpServers) ? config.toolHub.mcpServers : []),
|
||||
...extraServers
|
||||
].filter(isToolHubBackendServer);
|
||||
}
|
||||
|
||||
export function toolHubBuiltInBackendServers(
|
||||
config: AppConfig | undefined,
|
||||
options: {
|
||||
apiKey?: string;
|
||||
} = {}
|
||||
): GatewayMcpServerConfig[] {
|
||||
if (!config || !browserAutomationMcpEnabled(config) || !hasGatewayEndpoint(config)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
apiKey: options.apiKey || firstConfiguredApiKey(config),
|
||||
headers: {},
|
||||
name: BROWSER_AUTOMATION_MCP_SERVER_NAME,
|
||||
protocolVersion: "2024-11-05",
|
||||
requestTimeoutMs: BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS,
|
||||
startupTimeoutMs: 60000,
|
||||
transport: "streamable-http",
|
||||
url: `${gatewayEndpoint(config)}${BROWSER_AUTOMATION_MCP_PATH}`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function browserAutomationMcpEnabled(config: AppConfig | undefined): boolean {
|
||||
return Boolean(config?.toolHub?.enabled && config.toolHub.browserAutomation);
|
||||
}
|
||||
|
||||
export function toolHubMcpRuntimeConfig(
|
||||
config: AppConfig | undefined,
|
||||
backendServers = toolHubBackendServers(config),
|
||||
backendServers?: unknown[],
|
||||
options: {
|
||||
command?: string;
|
||||
entryPath?: string;
|
||||
|
|
@ -52,10 +92,14 @@ export function toolHubMcpRuntimeConfig(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedBackendServers = backendServers.filter(isToolHubBackendServer);
|
||||
const resolvedBackendServers = backendServers ?? toolHubBackendServers(config, [], {
|
||||
apiKey: options.resolver?.apiKey
|
||||
});
|
||||
const normalizedBackendServers = resolvedBackendServers.filter(isToolHubBackendServer);
|
||||
if (normalizedBackendServers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const requestTimeoutMs = toolHubRequestTimeoutMs(config, normalizedBackendServers);
|
||||
|
||||
return {
|
||||
args: [options.entryPath ?? bundledToolHubMcpEntryPath()],
|
||||
|
|
@ -68,11 +112,18 @@ export function toolHubMcpRuntimeConfig(
|
|||
TOOLHUB_OPENAI_API_KEY: options.resolver?.apiKey ?? toolHub.llm?.apiKey ?? "",
|
||||
TOOLHUB_OPENAI_BASE_URL: options.resolver?.baseUrl ?? toolHub.llm?.baseUrl ?? "https://api.openai.com/v1",
|
||||
TOOLHUB_OPENAI_MODEL: options.resolver?.model ?? toolHub.llm?.model ?? "",
|
||||
TOOLHUB_REQUEST_TIMEOUT_MS: String(toolHub.requestTimeoutMs ?? 60000)
|
||||
TOOLHUB_REQUEST_TIMEOUT_MS: String(requestTimeoutMs)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function toolHubRequestTimeoutMs(config: AppConfig | undefined, backendServers?: unknown[]): number {
|
||||
const configuredTimeout = positiveInteger(config?.toolHub?.requestTimeoutMs, TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
const backendTimeouts = (backendServers ?? toolHubBackendServers(config))
|
||||
.map((server) => isRecord(server) ? positiveInteger(server.requestTimeoutMs, 0) : 0);
|
||||
return Math.max(configuredTimeout, ...backendTimeouts);
|
||||
}
|
||||
|
||||
export function toolHubClaudeCodeMcpConfig(
|
||||
config: AppConfig | undefined,
|
||||
options: Parameters<typeof toolHubMcpRuntimeConfig>[2] = {}
|
||||
|
|
@ -82,7 +133,9 @@ export function toolHubClaudeCodeMcpConfig(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const backendServers = toolHubBackendServers(config);
|
||||
const backendServers = toolHubBackendServers(config, [], {
|
||||
apiKey: options.resolver?.apiKey
|
||||
});
|
||||
if (backendServers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -126,6 +179,39 @@ function stringValue(value: unknown): string | undefined {
|
|||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function firstConfiguredApiKey(config: AppConfig): string | undefined {
|
||||
return (Array.isArray(config.APIKEYS) ? config.APIKEYS : [])
|
||||
.find((apiKey) => apiKey.key.trim())?.key.trim() || stringValue(config.APIKEY);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
||||
}
|
||||
|
||||
function gatewayEndpoint(config: AppConfig): string {
|
||||
return `http://${formatHost(clientGatewayHost(config.gateway.host))}:${config.gateway.port}`;
|
||||
}
|
||||
|
||||
function hasGatewayEndpoint(config: AppConfig): boolean {
|
||||
const gateway = (config as Partial<AppConfig>).gateway;
|
||||
return Boolean(gateway && stringValue(gateway.host) && Number.isFinite(gateway.port));
|
||||
}
|
||||
|
||||
function formatHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function clientGatewayHost(host: string): string {
|
||||
const value = stringValue(host) ?? "127.0.0.1";
|
||||
if (value === "0.0.0.0") {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
if (value === "::" || value === "[::]") {
|
||||
return "::1";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ type LlmToolResolution = {
|
|||
|
||||
type ResolveOutput = {
|
||||
alreadyResolved?: boolean;
|
||||
executionPlanInstructions?: string;
|
||||
executionPlanJs?: string;
|
||||
nextAction?: {
|
||||
confirmationRequiredFor: string[];
|
||||
firstAction: {
|
||||
|
|
@ -186,6 +188,17 @@ const defaultRequestTimeoutMs = 60_000;
|
|||
const defaultMaxTools = 10;
|
||||
const discoveryCacheMaxAgeMs = 10_000;
|
||||
const repeatedResolveWindowMs = 5 * 60_000;
|
||||
const executionPlanInstructions = [
|
||||
"Treat executionPlanJs as the dependency plan for ToolHub invocations.",
|
||||
"Each callTool(toolName, args) call maps to one tool_hub.invoke call with tool=toolName and args=args.",
|
||||
"await means the next ToolHub invocation depends on the previous result and must not be started early.",
|
||||
"Only callTool calls inside the same Promise.all([...]) expression may be issued as parallel tool calls.",
|
||||
"If a later call needs values from an earlier result, wait for that earlier result and fill the arguments after it returns."
|
||||
].join(" ");
|
||||
const reservedJavaScriptWords = new Set(
|
||||
"await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield"
|
||||
.split(" ")
|
||||
);
|
||||
|
||||
type RuntimeLike = {
|
||||
callTool(params: unknown): Promise<unknown>;
|
||||
|
|
@ -365,15 +378,25 @@ class ToolHubRuntime {
|
|||
await this.registry.refreshServers(undefined, true);
|
||||
catalog = await this.registry.listTools();
|
||||
}
|
||||
if (taskWantsChromeLoginImport(task) && !catalogHasChromeLoginImportTool(catalog)) {
|
||||
await this.registry.refreshServers(["ccr-browser-automation"], true);
|
||||
catalog = await this.registry.listTools();
|
||||
}
|
||||
if (catalog.length === 0) {
|
||||
throw new Error("No MCP tools are available to resolve.");
|
||||
}
|
||||
|
||||
const cached = this.findRecentlyResolvedTask(scopeKey, taskHash);
|
||||
if (cached) {
|
||||
const selectedTools = cached.toolNames
|
||||
.map((toolName) => this.resolveCatalogEntry(toolName, catalog))
|
||||
.filter((entry): entry is CatalogEntry => Boolean(entry));
|
||||
const selectedTools = expandToolBundleWithCompanionTools(
|
||||
uniqueToolEntries([
|
||||
...cached.toolNames
|
||||
.map((toolName) => this.resolveCatalogEntry(toolName, catalog))
|
||||
.filter((entry): entry is CatalogEntry => Boolean(entry)),
|
||||
...getDeterministicTaskTools(task, catalog)
|
||||
]),
|
||||
catalog
|
||||
);
|
||||
if (selectedTools.length > 0) {
|
||||
this.markToolsLoaded(scopeKey, selectedTools);
|
||||
return toolResult(this.buildRepeatedResolveOutput(selectedTools));
|
||||
|
|
@ -383,9 +406,15 @@ class ToolHubRuntime {
|
|||
const inFlightResolve = session.inFlightResolves.get(taskHash);
|
||||
if (inFlightResolve) {
|
||||
const output = await inFlightResolve;
|
||||
const selectedTools = output.selectedTools
|
||||
.map((tool) => this.resolveCatalogEntry(tool.toolName, catalog) ?? tool)
|
||||
.filter((entry): entry is CatalogEntry => Boolean(entry));
|
||||
const selectedTools = expandToolBundleWithCompanionTools(
|
||||
uniqueToolEntries([
|
||||
...output.selectedTools
|
||||
.map((tool) => this.resolveCatalogEntry(tool.toolName, catalog) ?? tool)
|
||||
.filter((entry): entry is CatalogEntry => Boolean(entry)),
|
||||
...getDeterministicTaskTools(task, catalog)
|
||||
]),
|
||||
catalog
|
||||
);
|
||||
if (selectedTools.length > 0) {
|
||||
this.markToolsLoaded(scopeKey, selectedTools);
|
||||
return toolResult(this.buildRepeatedResolveOutput(selectedTools));
|
||||
|
|
@ -461,7 +490,13 @@ class ToolHubRuntime {
|
|||
usedLlm = false;
|
||||
}
|
||||
|
||||
let selectedTools = resolution.selectedTools;
|
||||
let selectedTools = expandToolBundleWithCompanionTools(
|
||||
uniqueToolEntries([
|
||||
...resolution.selectedTools,
|
||||
...getDeterministicTaskTools(input.task, input.catalog)
|
||||
]),
|
||||
input.catalog
|
||||
);
|
||||
if (input.withoutSideEffects) {
|
||||
selectedTools = selectedTools.filter((tool) => !tool.invocation.sideEffect);
|
||||
}
|
||||
|
|
@ -471,13 +506,14 @@ class ToolHubRuntime {
|
|||
|
||||
this.markToolsLoaded(input.scopeKey, selectedTools);
|
||||
this.rememberResolvedTask(input.scopeKey, input.taskHash, selectedTools);
|
||||
const executionPlanJs = buildExecutionPlanJs(resolution.workflowSketch, selectedTools);
|
||||
return {
|
||||
...this.buildResolveOutput(resolution.summary, selectedTools),
|
||||
...this.buildResolveOutput(resolution.summary, selectedTools, executionPlanJs),
|
||||
plannedSteps: resolution.plannedSteps,
|
||||
referencedTokens: resolution.referencedTokens,
|
||||
retriever,
|
||||
usedLlm,
|
||||
workflowSketch: resolution.workflowSketch
|
||||
workflowSketch: executionPlanJs
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -587,39 +623,50 @@ class ToolHubRuntime {
|
|||
return catalog.find((entry) => entry.toolName === resolvedName);
|
||||
}
|
||||
|
||||
private buildResolveOutput(summary: string, selectedTools: CatalogEntry[]): ResolveOutput {
|
||||
private buildResolveOutput(summary: string, selectedTools: CatalogEntry[], executionPlanJs = buildSequentialExecutionPlanJs(selectedTools)): ResolveOutput {
|
||||
return {
|
||||
executionPlanInstructions,
|
||||
executionPlanJs,
|
||||
nextAction: this.buildResolveNextAction(selectedTools),
|
||||
reasoningSummary: summary,
|
||||
runtimeContext: {
|
||||
availableContextKeys: ["selectedTools"],
|
||||
summary: selectedTools.map((tool) => `${tool.toolName}: ${tool.description || tool.title}`)
|
||||
availableContextKeys: ["selectedTools", "executionPlanJs", "executionPlanInstructions"],
|
||||
summary: [
|
||||
...selectedTools.map((tool) => `${tool.toolName}: ${tool.description || tool.title}`),
|
||||
"Follow executionPlanJs for dependency ordering before invoking selected tools."
|
||||
]
|
||||
},
|
||||
selectedToolNames: selectedTools.map((tool) => tool.toolName),
|
||||
selectedTools,
|
||||
tsDefinitions: buildTsDefinitions(selectedTools)
|
||||
tsDefinitions: buildTsDefinitions(selectedTools),
|
||||
workflowSketch: executionPlanJs
|
||||
};
|
||||
}
|
||||
|
||||
private buildRepeatedResolveOutput(selectedTools: CatalogEntry[]): ResolveOutput {
|
||||
const nextAction = this.buildResolveNextAction(selectedTools);
|
||||
const executionPlanJs = buildSequentialExecutionPlanJs(selectedTools);
|
||||
return {
|
||||
alreadyResolved: true,
|
||||
executionPlanInstructions,
|
||||
executionPlanJs,
|
||||
nextAction,
|
||||
reasoningSummary: [
|
||||
"This task has already been resolved in the current ToolHub session.",
|
||||
nextAction.instruction
|
||||
].join(" "),
|
||||
runtimeContext: {
|
||||
availableContextKeys: ["selectedTools", "nextAction"],
|
||||
availableContextKeys: ["selectedTools", "nextAction", "executionPlanJs", "executionPlanInstructions"],
|
||||
summary: [
|
||||
"The selected tools are already loaded for tool_hub.invoke.",
|
||||
"Do not repeat discovery for this task.",
|
||||
nextAction.instruction
|
||||
nextAction.instruction,
|
||||
"Follow executionPlanJs for dependency ordering before invoking selected tools."
|
||||
]
|
||||
},
|
||||
selectedToolNames: selectedTools.map((tool) => tool.toolName),
|
||||
selectedTools
|
||||
selectedTools,
|
||||
workflowSketch: executionPlanJs
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -646,7 +693,10 @@ class ToolHubRuntime {
|
|||
toolName: firstTool?.toolName,
|
||||
type: "invoke_tool" as const
|
||||
};
|
||||
const instructionParts = ["Do not call tool_hub.resolve again for this task."];
|
||||
const instructionParts = [
|
||||
"Do not call tool_hub.resolve again for this task.",
|
||||
"Follow executionPlanJs: await is serial dependency; only Promise.all groups may run in parallel."
|
||||
];
|
||||
if (firstTool && firstRequiredArguments.length > 0) {
|
||||
instructionParts.push(`Ask the user for missing required arguments for ${firstTool.toolName}: ${firstRequiredArguments.join(", ")}.`);
|
||||
} else if (firstTool) {
|
||||
|
|
@ -790,7 +840,7 @@ class ToolHubRegistry {
|
|||
|
||||
private async ensureDiscoveryFresh(maxAgeMs = discoveryCacheMaxAgeMs): Promise<void> {
|
||||
const staleServerNames = [...this.entries.values()]
|
||||
.filter((entry) => !entry.loadedFromCache && (!entry.lastCheckedAt || Date.now() - entry.lastCheckedAt > maxAgeMs))
|
||||
.filter((entry) => !entry.lastCheckedAt || Date.now() - entry.lastCheckedAt > maxAgeMs)
|
||||
.map((entry) => entry.config.name);
|
||||
if (staleServerNames.length === 0) {
|
||||
return;
|
||||
|
|
@ -1752,6 +1802,7 @@ function metaTools(): Array<{ description: string; inputSchema: Record<string, u
|
|||
`MUST be called before answering any request about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`,
|
||||
`Call this even if the user did not mention ToolHub or ${resolveToolName}.`,
|
||||
`Use ${invokeToolName} after this tool returns selected tools.`,
|
||||
"Follow the returned executionPlanJs: await means serial dependency, and only calls grouped by Promise.all may be invoked in parallel.",
|
||||
"Use the user's request as task and include concise context so the resolver can select the right tools."
|
||||
].join(" "),
|
||||
inputSchema: {
|
||||
|
|
@ -1773,7 +1824,7 @@ function metaTools(): Array<{ description: string; inputSchema: Record<string, u
|
|||
},
|
||||
{
|
||||
name: invokeToolName,
|
||||
description: `Invoke one MCP tool selected by ${resolveToolName}.`,
|
||||
description: `Invoke one MCP tool selected by ${resolveToolName}. Follow executionPlanJs from ${resolveToolName}; do not parallelize invoke calls unless that plan groups them in Promise.all.`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -1989,7 +2040,7 @@ function buildSearchSystemPrompt(catalog: SearchCatalogItem[], topK: number): st
|
|||
"If your first workflow sketch yields zero, partial, or weak tool matches, revise the sketch and call the tree-sitter tool again before answering.",
|
||||
"Workflow you must follow:",
|
||||
"1. Plan the likely execution steps.",
|
||||
"2. Draft a short TypeScript workflow sketch that uses the exact catalog tool names or aliases you expect to need.",
|
||||
"2. Draft a short JavaScript/TypeScript workflow sketch that uses the exact catalog tool names or aliases you expect to need.",
|
||||
"2a. Prefer sketches that call tools through string literals such as callTool(\"<exact catalog tool name>\", {...}) or tools.call(\"<exact catalog tool name>\", {...}).",
|
||||
"3. Call the tree-sitter tool on that sketch.",
|
||||
"4. Inspect the tree-sitter result and check whether the bundle is complete end-to-end.",
|
||||
|
|
@ -2000,10 +2051,18 @@ function buildSearchSystemPrompt(catalog: SearchCatalogItem[], topK: number): st
|
|||
"{ \"summary\": string, \"steps\": string[], \"workflowSketch\": string, \"toolNames\": string[] }",
|
||||
"Rules:",
|
||||
"- toolNames must be exact catalog names or aliases.",
|
||||
"- workflowSketch is the dependency plan that will be returned to the caller as executionPlanJs.",
|
||||
"- In workflowSketch, use await to show serial dependencies and Promise.all([...]) only for tool calls that are independent and safe to invoke in parallel.",
|
||||
"- Do not put side-effecting calls in Promise.all unless they are truly independent and safe to run concurrently.",
|
||||
"- In workflowSketch, prefer string-literal tool calls over reconstructed member chains whenever possible.",
|
||||
"- Include prerequisite and downstream tools you will likely need after the first action.",
|
||||
"- Never invent tool names.",
|
||||
"- Generic browser automation tools are a strong match for web tasks such as ordering, buying, booking, delivery, or checkout when no domain-specific MCP tool exists.",
|
||||
"- For browser navigation/open calls, prefer omitting waitUntil or using waitUntil: \"interactive\" so the agent can inspect and act as soon as the page is usable.",
|
||||
"- Do not use waitUntil: \"network_idle\" for Gmail, Google sign-in, SPAs, mail, chat, auth, checkout, verification, or pages with long-lived requests. Use network_idle only when the user explicitly asks for network quiescence.",
|
||||
"- When selecting CCR browser automation tools, include the human-handoff follow-up tools needed if login, CAPTCHA, verification, blocked navigation, or manual confirmation appears.",
|
||||
"- For CCR browser automation bundles, include browser_handoff_request and browser_handoff_wait when the workflow may need user help. Do not assume all browser tools are preloaded.",
|
||||
"- For importing existing Chrome login state into CCR's in-app browser, select browser_chrome_login_import and include browser_chrome_login_import_status to check completion.",
|
||||
"- If using member-call syntax, prefer tools.<catalog alias>(...) or mcp.<server namespace>.<remote tool name>(...).",
|
||||
"- For lookup tasks, do not stop at opening or navigating. Include the tools needed to read or extract the answer.",
|
||||
"- If the catalog has no strong match, return an empty toolNames array.",
|
||||
|
|
@ -2312,6 +2371,21 @@ function getLocalFallbackPreferredTools(taskText: string): Map<string, number> {
|
|||
add("browser_screenshot", 60);
|
||||
add("browser_events_await", 44);
|
||||
}
|
||||
if (hasAnyToken(tokens, [
|
||||
"auth",
|
||||
"chrome",
|
||||
"cookie",
|
||||
"cookies",
|
||||
"import",
|
||||
"localstorage",
|
||||
"login",
|
||||
"session",
|
||||
"signin",
|
||||
"storage"
|
||||
]) || /chrome|cookie|cookies|localstorage|local storage|登录态|登录状态|导入登录|浏览器登录|本地存储/.test(normalizedText)) {
|
||||
add("browser_chrome_login_import", 128);
|
||||
add("browser_chrome_login_import_status", 92);
|
||||
}
|
||||
if (hasAnyToken(tokens, ["address", "delivery", "geo", "geolocation", "location", "nearby", "pickup", "store"]) ||
|
||||
/地址|定位|位置|附近|配送|外卖|自取|门店/.test(normalizedText)) {
|
||||
add("location_permission_status", 90);
|
||||
|
|
@ -2324,6 +2398,28 @@ function getLocalFallbackPreferredTools(taskText: string): Map<string, number> {
|
|||
return scores;
|
||||
}
|
||||
|
||||
function getDeterministicTaskTools(taskText: string, catalog: CatalogEntry[]): CatalogEntry[] {
|
||||
if (!taskWantsChromeLoginImport(taskText)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
catalog.find((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import"),
|
||||
catalog.find((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import_status")
|
||||
].filter((tool): tool is CatalogEntry => Boolean(tool));
|
||||
}
|
||||
|
||||
function taskWantsChromeLoginImport(taskText: string): boolean {
|
||||
const normalizedText = taskText.toLowerCase();
|
||||
return normalizedText.includes("browser_chrome_login_import") ||
|
||||
/(chrome|谷歌浏览器|浏览器).*(login|signin|auth|cookie|localstorage|local storage|session|登录态|登录状态|登录信息|本地存储|cookie)/.test(normalizedText) ||
|
||||
/(import|导入|迁移|同步).*(chrome|谷歌浏览器).*(login|signin|auth|cookie|localstorage|local storage|session|登录态|登录状态|登录信息|本地存储)/.test(normalizedText) ||
|
||||
/(登录态|登录状态|登录信息).*(chrome|谷歌浏览器|浏览器).*(导入|迁移|同步)/.test(normalizedText);
|
||||
}
|
||||
|
||||
function catalogHasChromeLoginImportTool(catalog: CatalogEntry[]): boolean {
|
||||
return catalog.some((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import");
|
||||
}
|
||||
|
||||
function tokenizeLocalSearchText(text: string): string[] {
|
||||
const tokens = text
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
|
|
@ -2350,10 +2446,113 @@ function uniqueToolEntries(entries: CatalogEntry[]): CatalogEntry[] {
|
|||
return output;
|
||||
}
|
||||
|
||||
function expandToolBundleWithCompanionTools(selectedTools: CatalogEntry[], catalog: CatalogEntry[]): CatalogEntry[] {
|
||||
const output = uniqueToolEntries(selectedTools);
|
||||
const selectedNames = new Set(output.map((tool) => tool.toolName));
|
||||
const selectedRemoteNames = new Set(output.map((tool) => tool.remoteToolName));
|
||||
const hasBrowserAutomationTool = output.some((tool) => isBrowserAutomationTool(tool));
|
||||
const hasHandoffRequestTool = output.some((tool) =>
|
||||
isBrowserAutomationTool(tool) &&
|
||||
(tool.remoteToolName === "browser_handoff_request" || tool.remoteToolName === "askHumanHelp")
|
||||
);
|
||||
const hasChromeLoginImportTool = output.some((tool) =>
|
||||
isBrowserAutomationTool(tool) &&
|
||||
tool.remoteToolName === "browser_chrome_login_import"
|
||||
);
|
||||
const companionRemoteNames = new Set<string>();
|
||||
|
||||
if (hasBrowserAutomationTool) {
|
||||
companionRemoteNames.add("browser_handoff_request");
|
||||
companionRemoteNames.add("browser_handoff_status");
|
||||
companionRemoteNames.add("browser_handoff_wait");
|
||||
}
|
||||
if (hasHandoffRequestTool) {
|
||||
companionRemoteNames.add("browser_handoff_wait");
|
||||
}
|
||||
if (hasChromeLoginImportTool) {
|
||||
companionRemoteNames.add("browser_chrome_login_import_status");
|
||||
}
|
||||
|
||||
for (const remoteToolName of companionRemoteNames) {
|
||||
if (selectedRemoteNames.has(remoteToolName)) {
|
||||
continue;
|
||||
}
|
||||
const companion = catalog.find((tool) =>
|
||||
isBrowserAutomationTool(tool) &&
|
||||
tool.remoteToolName === remoteToolName &&
|
||||
!selectedNames.has(tool.toolName)
|
||||
);
|
||||
if (companion) {
|
||||
output.push(companion);
|
||||
selectedNames.add(companion.toolName);
|
||||
selectedRemoteNames.add(companion.remoteToolName);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function isBrowserAutomationTool(tool: CatalogEntry): boolean {
|
||||
return tool.serverName === "ccr-browser-automation" ||
|
||||
tool.serverId === "ccr-browser-automation" ||
|
||||
tool.serverNamespace === "ccr_browser_automation" ||
|
||||
tool.toolName.startsWith("mcp.ccr_browser_automation.");
|
||||
}
|
||||
|
||||
function buildLocalFallbackWorkflowSketch(selectedTools: CatalogEntry[]): string | undefined {
|
||||
return selectedTools.length > 0
|
||||
? selectedTools.slice(0, 5).map((tool) => `await callTool(${JSON.stringify(tool.toolName)}, {});`).join("\n")
|
||||
: undefined;
|
||||
return selectedTools.length > 0 ? buildSequentialExecutionPlanJs(selectedTools) : undefined;
|
||||
}
|
||||
|
||||
function isBrowserNavigationTool(tool: CatalogEntry): boolean {
|
||||
return isBrowserAutomationTool(tool) && (
|
||||
tool.toolName.endsWith("browser_session_open") ||
|
||||
tool.toolName.endsWith("browser_navigate") ||
|
||||
tool.remoteToolName === "browser_session_open" ||
|
||||
tool.remoteToolName === "browser_navigate"
|
||||
);
|
||||
}
|
||||
|
||||
function buildExecutionPlanJs(workflowSketch: string | undefined, selectedTools: CatalogEntry[]): string {
|
||||
const trimmed = typeof workflowSketch === "string" ? workflowSketch.trim() : "";
|
||||
return trimmed || buildSequentialExecutionPlanJs(selectedTools);
|
||||
}
|
||||
|
||||
function buildSequentialExecutionPlanJs(selectedTools: CatalogEntry[]): string {
|
||||
if (selectedTools.length === 0) {
|
||||
return [
|
||||
"async function runWithToolHub() {",
|
||||
" // Ask the user for missing task details before invoking tools.",
|
||||
"}"
|
||||
].join("\n");
|
||||
}
|
||||
const lines = [
|
||||
"async function runWithToolHub() {",
|
||||
" // Invoke calls in this order unless the plan explicitly uses Promise.all."
|
||||
];
|
||||
selectedTools.forEach((tool, index) => {
|
||||
lines.push(` const step${index + 1} = await callTool(${JSON.stringify(tool.toolName)}, ${buildExecutionPlanArgs(tool)});`);
|
||||
});
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildExecutionPlanArgs(tool: CatalogEntry): string {
|
||||
if (isBrowserNavigationTool(tool)) {
|
||||
return "{ url, waitUntil: \"interactive\" }";
|
||||
}
|
||||
const required = getSchemaRequiredProperties(tool.inputSchema);
|
||||
if (required.length === 0) {
|
||||
return "{}";
|
||||
}
|
||||
return `{ ${required.map((key) => `${JSON.stringify(key)}: ${toPlanVariableName(key)}`).join(", ")} }`;
|
||||
}
|
||||
|
||||
function toPlanVariableName(value: string): string {
|
||||
const identifier = toIdentifier(value).replace(/^[A-Z]/, (match) => match.toLowerCase());
|
||||
if (!identifier || reservedJavaScriptWords.has(identifier)) {
|
||||
return "value";
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function inferInvocation(tool: ToolDefinition): ToolInvocation {
|
||||
|
|
|
|||
|
|
@ -146,6 +146,26 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
|
|||
status: statusFromMeters(meters, [], 1)
|
||||
};
|
||||
}
|
||||
if (connector.parser === "new-api-key-usage") {
|
||||
const meters = newApiKeyUsageMeters(payload);
|
||||
return {
|
||||
meters,
|
||||
message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload),
|
||||
paths: flattenJsonPaths(payload),
|
||||
payload,
|
||||
status: statusFromMeters(meters, [], 1)
|
||||
};
|
||||
}
|
||||
if (connector.parser === "new-api-user-self") {
|
||||
const meters = newApiUserSelfMeters(payload);
|
||||
return {
|
||||
meters,
|
||||
message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload),
|
||||
paths: flattenJsonPaths(payload),
|
||||
payload,
|
||||
status: statusFromMeters(meters, [], 1)
|
||||
};
|
||||
}
|
||||
|
||||
const meters = mappedMetersFromPayload(connector, payload);
|
||||
|
||||
|
|
@ -158,6 +178,18 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
|
|||
};
|
||||
}
|
||||
|
||||
export function newApiKeyUsageMetersForTest(payload: unknown): ProviderAccountMeter[] {
|
||||
return newApiKeyUsageMeters(payload);
|
||||
}
|
||||
|
||||
export function newApiKeyUsageFallbackMessageForTest(payload: unknown): string {
|
||||
return newApiKeyUsageFallbackMessage(payload);
|
||||
}
|
||||
|
||||
export function newApiUserSelfMetersForTest(payload: unknown): ProviderAccountMeter[] {
|
||||
return newApiUserSelfMeters(payload);
|
||||
}
|
||||
|
||||
export async function resetCodexRateLimitCredit(request: ProviderAccountResetRequest): Promise<ProviderAccountResetResult> {
|
||||
const providerName = request.provider?.trim();
|
||||
const creditId = request.creditId?.trim();
|
||||
|
|
@ -586,6 +618,24 @@ async function resolveHttpJsonConnector(
|
|||
source: "http-json"
|
||||
};
|
||||
}
|
||||
if (connector.parser === "new-api-key-usage") {
|
||||
const meters = newApiKeyUsageMeters(payload);
|
||||
return {
|
||||
errors: [],
|
||||
message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload),
|
||||
meters,
|
||||
source: "http-json"
|
||||
};
|
||||
}
|
||||
if (connector.parser === "new-api-user-self") {
|
||||
const meters = newApiUserSelfMeters(payload);
|
||||
return {
|
||||
errors: [],
|
||||
message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload),
|
||||
meters,
|
||||
source: "http-json"
|
||||
};
|
||||
}
|
||||
|
||||
const meters = mappedMetersFromPayload(connector, payload);
|
||||
return {
|
||||
|
|
@ -758,6 +808,90 @@ function normalizeRemoteSnapshot(
|
|||
};
|
||||
}
|
||||
|
||||
function newApiKeyUsageMeters(payload: unknown): ProviderAccountMeter[] {
|
||||
const meter = newApiKeyUsageMeter(payload);
|
||||
return meter ? [meter] : [];
|
||||
}
|
||||
|
||||
function newApiKeyUsageMeter(payload: unknown): ProviderAccountMeter | undefined {
|
||||
const data = newApiKeyUsageData(payload);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const unlimited = readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true;
|
||||
const remaining = normalizeNumber(readJsonRecordValue(data, "total_available"));
|
||||
const limit = normalizeNumber(readJsonRecordValue(data, "total_granted"));
|
||||
const used = normalizeNumber(readJsonRecordValue(data, "total_used"));
|
||||
if (unlimited || (limit === undefined && remaining === undefined && used === undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: "new_api_key_quota",
|
||||
kind: "quota",
|
||||
label: "API key quota",
|
||||
limit,
|
||||
remaining,
|
||||
source: "http-json",
|
||||
unit: "quota",
|
||||
used
|
||||
};
|
||||
}
|
||||
|
||||
function newApiKeyUsageFallbackMessage(payload: unknown): string {
|
||||
const data = newApiKeyUsageData(payload);
|
||||
if (data && readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true) {
|
||||
return "API key has no dedicated quota limit. Configure a New API user balance connector with an access token and New-Api-User to read account balance.";
|
||||
}
|
||||
return readMappedString("$.message", payload) ?? "No API key quota data available.";
|
||||
}
|
||||
|
||||
function newApiUserSelfMeters(payload: unknown): ProviderAccountMeter[] {
|
||||
const meter = newApiUserSelfMeter(payload);
|
||||
return meter ? [meter] : [];
|
||||
}
|
||||
|
||||
function newApiUserSelfMeter(payload: unknown): ProviderAccountMeter | undefined {
|
||||
const data = newApiUserSelfData(payload);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const remaining = normalizeNumber(readJsonRecordValue(data, "quota"));
|
||||
const used = normalizeNumber(readJsonRecordValue(data, "used_quota"));
|
||||
if (remaining === undefined && used === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: "new_api_user_balance",
|
||||
kind: "balance",
|
||||
label: "User balance",
|
||||
limit: remaining !== undefined && used !== undefined ? remaining + used : undefined,
|
||||
remaining,
|
||||
source: "http-json",
|
||||
unit: "quota",
|
||||
used
|
||||
};
|
||||
}
|
||||
|
||||
function newApiKeyUsageData(payload: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
const data = readJsonRecordValue(payload, "data");
|
||||
return isRecord(data) ? data : payload;
|
||||
}
|
||||
|
||||
function newApiUserSelfData(payload: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
const data = readJsonRecordValue(payload, "data");
|
||||
return isRecord(data) ? data : payload;
|
||||
}
|
||||
|
||||
function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] {
|
||||
if (!isRecord(payload)) {
|
||||
return [];
|
||||
|
|
|
|||
92
packages/core/src/providers/new-api.ts
Normal file
92
packages/core/src/providers/new-api.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { ProviderAccountConfig, ProviderAccountHttpJsonConnectorConfig } from "@ccr/core/contracts/app";
|
||||
import { compactProviderUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
|
||||
|
||||
export type DetectedProviderKind = "new-api";
|
||||
|
||||
const newApiHeaderNames = ["x-new-api-version", "x-oneapi-request-id"];
|
||||
|
||||
export function detectedProviderFromHeaders(headers: Record<string, string | undefined>): DetectedProviderKind | undefined {
|
||||
return hasNewApiHeaders(headers) ? "new-api" : undefined;
|
||||
}
|
||||
|
||||
export function hasNewApiHeaders(headers: Record<string, string | undefined>): boolean {
|
||||
const normalized = new Set(Object.keys(headers).map((key) => key.toLowerCase()));
|
||||
return newApiHeaderNames.some((header) => normalized.has(header));
|
||||
}
|
||||
|
||||
export function newApiKeyUsageAccountConfig(baseUrl: string): ProviderAccountConfig {
|
||||
return {
|
||||
connectors: [
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
endpoint: newApiKeyUsageEndpoint(baseUrl),
|
||||
mapping: {
|
||||
message: "$.message",
|
||||
meters: [
|
||||
{
|
||||
id: "new_api_key_quota",
|
||||
kind: "quota",
|
||||
label: "API key quota",
|
||||
limit: "$.data.total_granted",
|
||||
remaining: "$.data.total_available",
|
||||
unit: "quota",
|
||||
used: "$.data.total_used",
|
||||
}
|
||||
]
|
||||
},
|
||||
method: "GET",
|
||||
parser: "new-api-key-usage",
|
||||
type: "http-json"
|
||||
}
|
||||
],
|
||||
enabled: true
|
||||
};
|
||||
}
|
||||
|
||||
export function newApiKeyUsageEndpoint(baseUrl: string): string {
|
||||
const root = newApiRootBaseUrl(baseUrl);
|
||||
return `${root}/api/usage/token/`;
|
||||
}
|
||||
|
||||
export function newApiUserSelfConnectorConfig(baseUrl: string): ProviderAccountHttpJsonConnectorConfig {
|
||||
return {
|
||||
auth: "none",
|
||||
endpoint: newApiUserSelfEndpoint(baseUrl),
|
||||
headers: {
|
||||
Authorization: "Bearer <new-api-access-token>",
|
||||
"New-Api-User": "<user-id>"
|
||||
},
|
||||
mapping: {
|
||||
meters: [
|
||||
{
|
||||
id: "new_api_user_balance",
|
||||
kind: "balance",
|
||||
label: "User balance",
|
||||
remaining: "$.data.quota",
|
||||
unit: "quota",
|
||||
used: "$.data.used_quota"
|
||||
}
|
||||
]
|
||||
},
|
||||
method: "GET",
|
||||
parser: "new-api-user-self",
|
||||
type: "http-json"
|
||||
};
|
||||
}
|
||||
|
||||
export function newApiUserSelfEndpoint(baseUrl: string): string {
|
||||
const root = newApiRootBaseUrl(baseUrl);
|
||||
return `${root}/api/user/self`;
|
||||
}
|
||||
|
||||
export function newApiRootBaseUrl(baseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(providerUrlWithDefaultScheme(baseUrl.trim()));
|
||||
url.pathname = url.pathname.replace(/\/+(v1|api)$/i, "").replace(/\/+$/, "") || "/";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return compactProviderUrl(url);
|
||||
} catch {
|
||||
return baseUrl.trim().replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\/(v1|api)$/i, "");
|
||||
}
|
||||
}
|
||||
15
packages/core/src/providers/presets/claudeapi/index.ts
Normal file
15
packages/core/src/providers/presets/claudeapi/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types";
|
||||
|
||||
export const claudeApiProviderPreset: ProviderPreset = {
|
||||
account: defaultProviderAccountConfig,
|
||||
aliases: ["claudeapi", "claudeapi.com", "www.claudeapi.com"],
|
||||
endpoints: [
|
||||
{
|
||||
baseUrl: "https://gw.claudeapi.com",
|
||||
protocols: ["anthropic_messages"]
|
||||
}
|
||||
],
|
||||
id: "claudeapi",
|
||||
name: "claudeapi",
|
||||
websiteUrl: "https://www.claudeapi.com?source=claudecoderouter"
|
||||
};
|
||||
15
packages/core/src/providers/presets/code0/index.ts
Normal file
15
packages/core/src/providers/presets/code0/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types";
|
||||
|
||||
export const code0ProviderPreset: ProviderPreset = {
|
||||
account: defaultProviderAccountConfig,
|
||||
aliases: ["code0", "code0.ai", "code 0"],
|
||||
endpoints: [
|
||||
{
|
||||
baseUrl: "https://console.code0.ai",
|
||||
protocols: ["anthropic_messages", "openai_chat_completions", "openai_responses"]
|
||||
}
|
||||
],
|
||||
id: "code0",
|
||||
name: "code0.ai",
|
||||
websiteUrl: "https://code0.ai?source=claudecoderouter"
|
||||
};
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { anthropicProviderPreset } from "@ccr/core/providers/presets/anthropic/index";
|
||||
import { bailianProviderPreset } from "@ccr/core/providers/presets/bailian/index";
|
||||
import { claudeApiProviderPreset } from "@ccr/core/providers/presets/claudeapi/index";
|
||||
import { code0ProviderPreset } from "@ccr/core/providers/presets/code0/index";
|
||||
import { deepSeekProviderPreset } from "@ccr/core/providers/presets/deepseek/index";
|
||||
import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index";
|
||||
import { kimiCodingProviderPreset } from "@ccr/core/providers/presets/kimi-coding/index";
|
||||
|
|
@ -42,7 +44,9 @@ export const providerPresets: ProviderPreset[] = [
|
|||
bailianProviderPreset,
|
||||
siliconFlowProviderPreset,
|
||||
runApiProviderPreset,
|
||||
teamoRouterProviderPreset
|
||||
teamoRouterProviderPreset,
|
||||
code0ProviderPreset,
|
||||
claudeApiProviderPreset
|
||||
];
|
||||
|
||||
export function getProviderPresets(): ProviderPreset[] {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ import {
|
|||
providerBaseUrlForProtocol,
|
||||
type ParsedProviderBaseUrl
|
||||
} from "@ccr/core/providers/url";
|
||||
import {
|
||||
detectedProviderFromHeaders,
|
||||
newApiKeyUsageAccountConfig,
|
||||
type DetectedProviderKind
|
||||
} from "@ccr/core/providers/new-api";
|
||||
|
||||
type ModelSource = NonNullable<GatewayProviderProbeResult["modelSource"]>;
|
||||
|
||||
|
|
@ -27,6 +32,8 @@ type ParsedProviderUrl = ParsedProviderBaseUrl & {
|
|||
};
|
||||
|
||||
type FetchJsonResult = {
|
||||
detectedProvider?: DetectedProviderKind;
|
||||
headers?: Record<string, string>;
|
||||
payload?: unknown;
|
||||
status?: number;
|
||||
text: string;
|
||||
|
|
@ -226,16 +233,21 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest)
|
|||
: typedModels;
|
||||
const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode);
|
||||
const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols);
|
||||
const normalizedBaseUrl = detectedProtocol
|
||||
? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe)
|
||||
: parsed.normalizedInputBaseUrl;
|
||||
const detectedProvider = detectProvider(protocolResults);
|
||||
const account = detectedProvider === "new-api" ? newApiKeyUsageAccountConfig(normalizedBaseUrl) : undefined;
|
||||
|
||||
return {
|
||||
...(account ? { account } : {}),
|
||||
capabilities: capabilitiesFromProtocolResults(protocolResults),
|
||||
...(detectedProvider ? { detectedProvider } : {}),
|
||||
detectedProtocol,
|
||||
modelDisplayNames: modelProbe.modelDisplayNames,
|
||||
modelSource: modelProbe.source,
|
||||
models: modelProbe.models,
|
||||
normalizedBaseUrl: detectedProtocol
|
||||
? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe)
|
||||
: parsed.normalizedInputBaseUrl,
|
||||
normalizedBaseUrl,
|
||||
protocols: protocolResults
|
||||
};
|
||||
}
|
||||
|
|
@ -491,6 +503,7 @@ async function probeProtocolSupport(
|
|||
const supported = isProviderProtocolEndpointSupportedForProbe(result.status, message, protocol, parsed.hints);
|
||||
const probeResult = {
|
||||
baseUrl: candidate.baseUrl,
|
||||
...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}),
|
||||
endpoint: candidate.endpoint,
|
||||
message,
|
||||
protocol,
|
||||
|
|
@ -539,6 +552,7 @@ async function probeProtocolConnectivity(
|
|||
const supported = isProtocolSupported(result.status, message, protocol);
|
||||
const probeResult = {
|
||||
baseUrl: candidate.baseUrl,
|
||||
...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}),
|
||||
endpoint: candidate.endpoint,
|
||||
message,
|
||||
protocol,
|
||||
|
|
@ -663,7 +677,10 @@ async function requestJson(url: string, init: RequestInit): Promise<FetchJsonRes
|
|||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
const headers = responseHeadersRecord(response.headers);
|
||||
return {
|
||||
detectedProvider: detectedProviderFromHeaders(headers),
|
||||
headers,
|
||||
payload: parseJson(text),
|
||||
status: response.status,
|
||||
text
|
||||
|
|
@ -677,6 +694,18 @@ async function requestJson(url: string, init: RequestInit): Promise<FetchJsonRes
|
|||
}
|
||||
}
|
||||
|
||||
function detectProvider(protocols: GatewayProviderProbeProtocolResult[]): DetectedProviderKind | undefined {
|
||||
return protocols.find((item) => item.detectedProvider)?.detectedProvider;
|
||||
}
|
||||
|
||||
function responseHeadersRecord(headers: Headers): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
record[key] = value;
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
function parseProviderUrl(value: string): ParsedProviderUrl {
|
||||
const parsed = parseProviderBaseUrl(value);
|
||||
const url = new URL(parsed.normalizedInputBaseUrl);
|
||||
|
|
|
|||
3517
packages/electron/src/main/browser-automation-mcp.ts
Normal file
3517
packages/electron/src/main/browser-automation-mcp.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +1,19 @@
|
|||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron";
|
||||
import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels";
|
||||
import type { BuiltInBrowserState } from "@ccr/core/contracts/app";
|
||||
import type { BuiltInBrowserState, ChromeLoginImportJob, ChromeLoginImportRequest } from "@ccr/core/contracts/app";
|
||||
|
||||
contextBridge.exposeInMainWorld("ccrBrowser", {
|
||||
back: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserBack, tabId) as Promise<BuiltInBrowserState>,
|
||||
closeTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserCloseTab, tabId) as Promise<BuiltInBrowserState>,
|
||||
forward: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserForward, tabId) as Promise<BuiltInBrowserState>,
|
||||
getChromeLoginImport: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.browserGetChromeLoginImport, id) as Promise<ChromeLoginImportJob | undefined>,
|
||||
getState: () => ipcRenderer.invoke(IPC_CHANNELS.browserGetState) as Promise<BuiltInBrowserState>,
|
||||
navigate: (url: string, tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNavigate, url, tabId) as Promise<BuiltInBrowserState>,
|
||||
newTab: (url?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNewTab, url) as Promise<BuiltInBrowserState>,
|
||||
reload: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserReload, tabId) as Promise<BuiltInBrowserState>,
|
||||
resolveAutomationHandoff: (status: "completed" | "dismissed") => ipcRenderer.invoke(IPC_CHANNELS.browserResolveAutomationHandoff, status) as Promise<BuiltInBrowserState>,
|
||||
selectTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserSelectTab, tabId) as Promise<BuiltInBrowserState>,
|
||||
startChromeLoginImport: (request: ChromeLoginImportRequest) => ipcRenderer.invoke(IPC_CHANNELS.browserStartChromeLoginImport, request) as Promise<ChromeLoginImportJob>,
|
||||
onStateChanged: (callback: (state: BuiltInBrowserState) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, state: BuiltInBrowserState) => callback(state);
|
||||
ipcRenderer.on(IPC_CHANNELS.browserStateChanged, handler);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
|
|
@ -10,28 +11,64 @@ import {
|
|||
WebContentsView,
|
||||
type ContextMenuParams,
|
||||
type IpcMainInvokeEvent,
|
||||
type MenuItemConstructorOptions
|
||||
type MenuItemConstructorOptions,
|
||||
type WebContents
|
||||
} from "electron";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { AppConfig, BuiltInBrowserState, BuiltInBrowserTabState, GatewayPluginAppConfig, InstalledBrowserApp } from "@ccr/core/contracts/app";
|
||||
import type {
|
||||
AppConfig,
|
||||
BuiltInBrowserAutomationHandoff,
|
||||
BuiltInBrowserAutomationHandoffKind,
|
||||
BuiltInBrowserState,
|
||||
BuiltInBrowserTabState,
|
||||
ChromeLoginImportRequest,
|
||||
GatewayPluginAppConfig,
|
||||
InstalledBrowserApp
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels";
|
||||
import { APP_NAME } from "@ccr/core/config/constants";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { chromeLoginImportService } from "./chrome-login-import";
|
||||
|
||||
type BrowserTab = BuiltInBrowserTabState & {
|
||||
view: WebContentsView;
|
||||
};
|
||||
|
||||
const browserChromeHeight = 82;
|
||||
export type BrowserAutomationEvent = {
|
||||
errorCode?: number;
|
||||
errorDescription?: string;
|
||||
handoffId?: string;
|
||||
handoffStatus?: "completed" | "dismissed";
|
||||
kind: string;
|
||||
seq: number;
|
||||
summary?: string;
|
||||
tabId?: string;
|
||||
title?: string;
|
||||
ts: number;
|
||||
url?: string;
|
||||
windowId?: string;
|
||||
};
|
||||
|
||||
export type BrowserAutomationEventListener = (event: BrowserAutomationEvent) => void;
|
||||
|
||||
const browserChromeBaseHeight = 82;
|
||||
const browserHandoffToolbarHeight = 44;
|
||||
const browserHomeUrl = "about:blank";
|
||||
const browserPartition = "persist:ccr-built-in-browser";
|
||||
const browserAutomationWindowId = "ccr-built-in-browser";
|
||||
const maxAutomationEventHistory = 512;
|
||||
const maxAutomationEventAgeMs = 60_000;
|
||||
const titleBarHeight = 46;
|
||||
|
||||
class BuiltInBrowserService {
|
||||
private activeTabId?: string;
|
||||
private apps: InstalledBrowserApp[] = [];
|
||||
private automationHandoff?: BuiltInBrowserAutomationHandoff;
|
||||
private automationEventSeq = 0;
|
||||
private readonly automationEvents = new EventEmitter();
|
||||
private readonly automationEventHistory: BrowserAutomationEvent[] = [];
|
||||
private hideWindowAfterAutomationHandoff = false;
|
||||
private proxyConfigKey = "";
|
||||
private tabOrder: string[] = [];
|
||||
private tabs = new Map<string, BrowserTab>();
|
||||
|
|
@ -44,10 +81,7 @@ class BuiltInBrowserService {
|
|||
async open(config: AppConfig): Promise<void> {
|
||||
await this.syncProxy(config);
|
||||
|
||||
const window = this.window && !this.window.isDestroyed() ? this.window : this.createWindow();
|
||||
if (this.tabs.size === 0) {
|
||||
this.createTab(browserHomeUrl);
|
||||
}
|
||||
const window = this.ensureWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
|
|
@ -57,28 +91,18 @@ class BuiltInBrowserService {
|
|||
this.sendState();
|
||||
}
|
||||
|
||||
async openHidden(config: AppConfig): Promise<void> {
|
||||
await this.syncProxy(config);
|
||||
this.ensureWindow();
|
||||
this.layoutActiveView();
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
async syncProxy(config: AppConfig): Promise<void> {
|
||||
this.syncApps(config);
|
||||
|
||||
const browserSession = session.fromPartition(browserPartition);
|
||||
if (config.proxy.enabled) {
|
||||
await proxyService.refreshUpstreamProxyFromCurrentSystem();
|
||||
}
|
||||
const proxyStatus = proxyService.getStatus();
|
||||
const shouldUseProxy = Boolean(
|
||||
config.proxy.enabled &&
|
||||
proxyStatus.state === "running" &&
|
||||
proxyStatus.endpoint
|
||||
);
|
||||
const proxyConfig = shouldUseProxy
|
||||
? {
|
||||
mode: "fixed_servers" as const,
|
||||
proxyBypassRules: "<-loopback>",
|
||||
proxyRules: electronProxyRules(proxyStatus.endpoint)
|
||||
}
|
||||
: {
|
||||
mode: "direct" as const
|
||||
};
|
||||
const proxyConfig = { mode: "system" as const };
|
||||
const nextKey = JSON.stringify(proxyConfig);
|
||||
if (nextKey === this.proxyConfigKey) {
|
||||
return;
|
||||
|
|
@ -100,9 +124,211 @@ class BuiltInBrowserService {
|
|||
|
||||
async clearProxy(): Promise<void> {
|
||||
const browserSession = session.fromPartition(browserPartition);
|
||||
await browserSession.setProxy({ mode: "direct" });
|
||||
const proxyConfig = { mode: "system" as const };
|
||||
await browserSession.setProxy(proxyConfig);
|
||||
await browserSession.forceReloadProxyConfig();
|
||||
this.proxyConfigKey = JSON.stringify({ mode: "direct" });
|
||||
this.proxyConfigKey = JSON.stringify(proxyConfig);
|
||||
}
|
||||
|
||||
getAutomationState(): BuiltInBrowserState {
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
getAutomationWindowId(): string {
|
||||
return browserAutomationWindowId;
|
||||
}
|
||||
|
||||
getAutomationEvents(options: { replayRecentMs?: number } = {}): BrowserAutomationEvent[] {
|
||||
const replayRecentMs = Math.max(0, Math.floor(options.replayRecentMs ?? 0));
|
||||
const cutoff = replayRecentMs > 0 ? Date.now() - replayRecentMs : 0;
|
||||
this.pruneAutomationEventHistory();
|
||||
return this.automationEventHistory
|
||||
.filter((event) => event.ts >= cutoff)
|
||||
.map((event) => ({ ...event }));
|
||||
}
|
||||
|
||||
requestAutomationHandoff(request: {
|
||||
kind?: BuiltInBrowserAutomationHandoffKind;
|
||||
message?: string;
|
||||
reason?: string;
|
||||
sessionId?: string;
|
||||
tabId?: string;
|
||||
}): BuiltInBrowserAutomationHandoff {
|
||||
const existingWindow = this.window && !this.window.isDestroyed() ? this.window : undefined;
|
||||
if (!this.automationHandoff) {
|
||||
this.hideWindowAfterAutomationHandoff = !existingWindow || !existingWindow.isVisible() || existingWindow.isMinimized();
|
||||
}
|
||||
const tab = this.getTab(request.tabId);
|
||||
if (request.tabId && tab?.id) {
|
||||
this.selectTab(request.tabId);
|
||||
}
|
||||
const targetTab = tab || this.getTab();
|
||||
const handoff: BuiltInBrowserAutomationHandoff = {
|
||||
id: randomUUID(),
|
||||
kind: request.kind || "other",
|
||||
message: request.message?.trim() || defaultAutomationHandoffMessage(request.kind),
|
||||
...(request.reason?.trim() ? { reason: request.reason.trim() } : {}),
|
||||
requestedAt: Date.now(),
|
||||
...(request.sessionId?.trim() ? { sessionId: request.sessionId.trim() } : {}),
|
||||
status: "pending",
|
||||
tabId: targetTab?.id || request.tabId || this.activeTabId
|
||||
};
|
||||
this.automationHandoff = handoff;
|
||||
this.layoutActiveView();
|
||||
this.showAutomationWindow();
|
||||
this.sendState();
|
||||
this.emitAutomationEvent({
|
||||
handoffId: handoff.id,
|
||||
kind: "handoff.requested",
|
||||
summary: handoff.message,
|
||||
tabId: handoff.tabId,
|
||||
title: targetTab?.title,
|
||||
url: targetTab?.url
|
||||
});
|
||||
return handoff;
|
||||
}
|
||||
|
||||
resolveAutomationHandoff(status: "completed" | "dismissed" = "completed"): BuiltInBrowserState {
|
||||
const handoff = this.automationHandoff;
|
||||
const shouldHideWindow = this.hideWindowAfterAutomationHandoff;
|
||||
this.automationHandoff = undefined;
|
||||
this.hideWindowAfterAutomationHandoff = false;
|
||||
this.layoutActiveView();
|
||||
this.sendState();
|
||||
if (handoff) {
|
||||
const tab = this.getTab(handoff.tabId);
|
||||
if (shouldHideWindow) {
|
||||
this.hideAutomationWindow();
|
||||
}
|
||||
this.emitAutomationEvent({
|
||||
handoffId: handoff.id,
|
||||
handoffStatus: status,
|
||||
kind: status === "completed" ? "handoff.completed" : "handoff.dismissed",
|
||||
summary: status === "completed" ? "User completed the requested browser handoff." : "User dismissed the requested browser handoff.",
|
||||
tabId: handoff.tabId,
|
||||
title: tab?.title,
|
||||
url: tab?.url
|
||||
});
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
subscribeAutomationEvents(
|
||||
listener: BrowserAutomationEventListener,
|
||||
options: { replayRecentMs?: number } = {}
|
||||
): () => void {
|
||||
this.automationEvents.on("event", listener);
|
||||
const replayRecentMs = Math.max(0, Math.floor(options.replayRecentMs ?? 0));
|
||||
if (replayRecentMs > 0) {
|
||||
const cutoff = Date.now() - replayRecentMs;
|
||||
this.pruneAutomationEventHistory();
|
||||
for (const event of this.automationEventHistory) {
|
||||
if (event.ts >= cutoff) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => this.automationEvents.off("event", listener);
|
||||
}
|
||||
|
||||
createAutomationTab(url = browserHomeUrl): BuiltInBrowserTabState {
|
||||
const tab = this.createTab(url);
|
||||
const { view: _view, ...state } = tab;
|
||||
return state;
|
||||
}
|
||||
|
||||
selectAutomationTab(tabId: string): BuiltInBrowserState {
|
||||
this.selectTab(tabId);
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
closeAutomationTab(tabId: string): BuiltInBrowserState {
|
||||
this.closeTab(tabId);
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async navigateAutomationTab(url: string, tabId?: string): Promise<BuiltInBrowserState> {
|
||||
const tab = this.getTab(tabId);
|
||||
if (!tab) {
|
||||
throw new Error("Browser tab was not found.");
|
||||
}
|
||||
|
||||
const nextUrl = normalizeBrowserUrl(url);
|
||||
tab.url = nextUrl;
|
||||
if (tab.id === this.activeTabId) {
|
||||
this.layoutActiveView();
|
||||
}
|
||||
this.sendState();
|
||||
void tab.view.webContents.loadURL(nextUrl).catch((error) => {
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.load_failed",
|
||||
summary: `Navigation request failed: ${formatError(error)}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: nextUrl
|
||||
});
|
||||
});
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.navigation_requested",
|
||||
summary: `Navigation requested: ${nextUrl}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: nextUrl
|
||||
});
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
goBackAutomationTab(tabId?: string): BuiltInBrowserState {
|
||||
const tab = this.getTab(tabId);
|
||||
tab?.view.webContents.navigationHistory.goBack();
|
||||
if (tab) {
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.go_back",
|
||||
summary: `Go back in tab ${tab.id}.`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
goForwardAutomationTab(tabId?: string): BuiltInBrowserState {
|
||||
const tab = this.getTab(tabId);
|
||||
tab?.view.webContents.navigationHistory.goForward();
|
||||
if (tab) {
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.go_forward",
|
||||
summary: `Go forward in tab ${tab.id}.`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
reloadAutomationTab(tabId?: string): BuiltInBrowserState {
|
||||
const tab = this.getTab(tabId);
|
||||
tab?.view.webContents.reload();
|
||||
if (tab) {
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.reload",
|
||||
summary: `Reload tab ${tab.id}.`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
getAutomationWebContents(tabId?: string): WebContents {
|
||||
const tab = this.getTab(tabId);
|
||||
if (!tab || tab.view.webContents.isDestroyed()) {
|
||||
throw new Error("Browser tab is unavailable.");
|
||||
}
|
||||
return tab.view.webContents;
|
||||
}
|
||||
|
||||
private registerIpcHandlers(): void {
|
||||
|
|
@ -110,6 +336,14 @@ class BuiltInBrowserService {
|
|||
this.assertBrowserSender(event);
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserStartChromeLoginImport, async (event, request: ChromeLoginImportRequest) => {
|
||||
this.assertBrowserSender(event);
|
||||
return await chromeLoginImportService.createJob(request);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserGetChromeLoginImport, (event, id: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
return chromeLoginImportService.getJob(typeof id === "string" ? id : "");
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserNewTab, (event, url?: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.createTab(url);
|
||||
|
|
@ -145,6 +379,34 @@ class BuiltInBrowserService {
|
|||
this.getTab(tabId)?.view.webContents.reload();
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserResolveAutomationHandoff, (event, status?: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
return this.resolveAutomationHandoff(status === "dismissed" ? "dismissed" : "completed");
|
||||
});
|
||||
}
|
||||
|
||||
private ensureWindow(): BrowserWindow {
|
||||
const window = this.window && !this.window.isDestroyed() ? this.window : this.createWindow();
|
||||
if (this.tabs.size === 0) {
|
||||
this.createTab(browserHomeUrl);
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private showAutomationWindow(): void {
|
||||
const window = this.ensureWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
private hideAutomationWindow(): void {
|
||||
const window = this.window;
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
|
||||
private createWindow(): BrowserWindow {
|
||||
|
|
@ -187,15 +449,12 @@ class BuiltInBrowserService {
|
|||
this.window = undefined;
|
||||
}
|
||||
});
|
||||
window.once("ready-to-show", () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.show();
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("did-finish-load", () => this.sendState());
|
||||
|
||||
void window.loadURL(this.resolveRendererUrl("pages/browser/index.html"));
|
||||
void window.loadURL(this.resolveRendererUrl("pages/browser/index.html")).catch((error) => {
|
||||
console.warn(`[browser] Failed to load browser chrome: ${formatError(error)}`);
|
||||
});
|
||||
return window;
|
||||
}
|
||||
|
||||
|
|
@ -225,8 +484,23 @@ class BuiltInBrowserService {
|
|||
this.window?.contentView.addChildView(view);
|
||||
view.setVisible(false);
|
||||
this.selectTab(tab.id);
|
||||
void view.webContents.loadURL(tab.url);
|
||||
void view.webContents.loadURL(tab.url).catch((error) => {
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.load_failed",
|
||||
summary: `Initial tab load failed: ${formatError(error)}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
});
|
||||
this.sendState();
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.created",
|
||||
summary: `Created tab ${tab.id}.`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +508,14 @@ class BuiltInBrowserService {
|
|||
const { webContents } = tab.view;
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (isHttpUrl(url)) {
|
||||
this.createTab(url);
|
||||
const opened = this.createTab(url);
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.opened",
|
||||
summary: `Opened new tab from window.open: ${url}`,
|
||||
tabId: opened.id,
|
||||
title: opened.title,
|
||||
url: opened.url
|
||||
});
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
|
@ -244,14 +525,35 @@ class BuiltInBrowserService {
|
|||
webContents.on("page-title-updated", (_event, title) => {
|
||||
tab.title = title || titleFromUrl(tab.url);
|
||||
this.sendState();
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.title",
|
||||
summary: `Title updated: ${tab.title}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
});
|
||||
webContents.on("did-start-loading", () => {
|
||||
tab.isLoading = true;
|
||||
this.updateTabNavigationState(tab);
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.loading_started",
|
||||
summary: `Loading started: ${tab.url}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
});
|
||||
webContents.on("did-stop-loading", () => {
|
||||
tab.isLoading = false;
|
||||
this.updateTabNavigationState(tab);
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.loading_stopped",
|
||||
summary: `Loading stopped: ${tab.url}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
});
|
||||
webContents.on("did-navigate", (_event, url) => {
|
||||
tab.url = url;
|
||||
|
|
@ -260,6 +562,13 @@ class BuiltInBrowserService {
|
|||
this.layoutActiveView();
|
||||
}
|
||||
this.updateTabNavigationState(tab);
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.navigation",
|
||||
summary: `Navigated to ${url}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url
|
||||
});
|
||||
});
|
||||
webContents.on("did-navigate-in-page", (_event, url) => {
|
||||
tab.url = url;
|
||||
|
|
@ -267,8 +576,15 @@ class BuiltInBrowserService {
|
|||
this.layoutActiveView();
|
||||
}
|
||||
this.updateTabNavigationState(tab);
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.navigation_in_page",
|
||||
summary: `In-page navigation to ${url}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url
|
||||
});
|
||||
});
|
||||
webContents.on("did-fail-load", (_event, errorCode, _errorDescription, validatedUrl) => {
|
||||
webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedUrl) => {
|
||||
if (errorCode !== -3) {
|
||||
tab.isLoading = false;
|
||||
tab.url = validatedUrl || tab.url;
|
||||
|
|
@ -276,6 +592,35 @@ class BuiltInBrowserService {
|
|||
this.layoutActiveView();
|
||||
}
|
||||
this.updateTabNavigationState(tab);
|
||||
this.emitAutomationEvent({
|
||||
errorCode,
|
||||
errorDescription,
|
||||
kind: "page.load_failed",
|
||||
summary: `Load failed (${errorCode}): ${errorDescription}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
}
|
||||
});
|
||||
webContents.on("dom-ready", () => {
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.dom_ready",
|
||||
summary: `DOM ready: ${tab.url}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
});
|
||||
webContents.on("console-message", (_event, level, message) => {
|
||||
if (level >= 2) {
|
||||
this.emitAutomationEvent({
|
||||
kind: "runtime.console",
|
||||
summary: message,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
}
|
||||
});
|
||||
webContents.on("destroyed", () => {
|
||||
|
|
@ -286,6 +631,13 @@ class BuiltInBrowserService {
|
|||
this.activeTabId = this.tabOrder[0];
|
||||
}
|
||||
this.sendState();
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.destroyed",
|
||||
summary: `Destroyed tab ${tab.id}.`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -403,6 +755,13 @@ class BuiltInBrowserService {
|
|||
selected.view.webContents.focus();
|
||||
}
|
||||
this.sendState();
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.activated",
|
||||
summary: `Activated tab ${tabId}.`,
|
||||
tabId,
|
||||
title: selected.title,
|
||||
url: selected.url
|
||||
});
|
||||
}
|
||||
|
||||
private closeTab(tabId: string): void {
|
||||
|
|
@ -415,6 +774,13 @@ class BuiltInBrowserService {
|
|||
this.tabs.delete(tabId);
|
||||
this.tabOrder = this.tabOrder.filter((id) => id !== tabId);
|
||||
tab.view.webContents.close({ waitForBeforeUnload: false });
|
||||
this.emitAutomationEvent({
|
||||
kind: "tab.closed",
|
||||
summary: `Closed tab ${tabId}.`,
|
||||
tabId,
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
});
|
||||
|
||||
if (this.tabOrder.length === 0) {
|
||||
this.createTab(browserHomeUrl);
|
||||
|
|
@ -440,8 +806,23 @@ class BuiltInBrowserService {
|
|||
if (tab.id === this.activeTabId) {
|
||||
this.layoutActiveView();
|
||||
}
|
||||
void tab.view.webContents.loadURL(nextUrl);
|
||||
void tab.view.webContents.loadURL(nextUrl).catch((error) => {
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.load_failed",
|
||||
summary: `Navigation request failed: ${formatError(error)}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: nextUrl
|
||||
});
|
||||
});
|
||||
this.sendState();
|
||||
this.emitAutomationEvent({
|
||||
kind: "page.navigation_requested",
|
||||
summary: `Navigation requested: ${nextUrl}`,
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: nextUrl
|
||||
});
|
||||
}
|
||||
|
||||
private getTab(tabId?: string): BrowserTab | undefined {
|
||||
|
|
@ -469,17 +850,22 @@ class BuiltInBrowserService {
|
|||
const { height, width } = window.getContentBounds();
|
||||
activeTab.view.setVisible(true);
|
||||
activeTab.view.setBounds({
|
||||
height: Math.max(0, height - browserChromeHeight),
|
||||
height: Math.max(0, height - this.browserChromeHeight()),
|
||||
width,
|
||||
x: 0,
|
||||
y: browserChromeHeight
|
||||
y: this.browserChromeHeight()
|
||||
});
|
||||
}
|
||||
|
||||
private browserChromeHeight(): number {
|
||||
return browserChromeBaseHeight + (this.automationHandoff ? browserHandoffToolbarHeight : 0);
|
||||
}
|
||||
|
||||
private getState(): BuiltInBrowserState {
|
||||
return {
|
||||
activeTabId: this.activeTabId,
|
||||
apps: this.apps.map((app) => ({ ...app })),
|
||||
...(this.automationHandoff ? { automationHandoff: { ...this.automationHandoff } } : {}),
|
||||
tabs: this.tabOrder
|
||||
.map((id) => this.tabs.get(id))
|
||||
.filter((tab): tab is BrowserTab => Boolean(tab))
|
||||
|
|
@ -512,6 +898,27 @@ class BuiltInBrowserService {
|
|||
this.activeTabId = undefined;
|
||||
}
|
||||
|
||||
private emitAutomationEvent(event: Omit<BrowserAutomationEvent, "seq" | "ts" | "windowId">): void {
|
||||
const nextEvent: BrowserAutomationEvent = {
|
||||
...event,
|
||||
seq: ++this.automationEventSeq,
|
||||
ts: Date.now(),
|
||||
windowId: browserAutomationWindowId
|
||||
};
|
||||
this.automationEventHistory.push(nextEvent);
|
||||
this.pruneAutomationEventHistory(nextEvent.ts);
|
||||
this.automationEvents.emit("event", nextEvent);
|
||||
}
|
||||
|
||||
private pruneAutomationEventHistory(now = Date.now()): void {
|
||||
while (this.automationEventHistory.length > 0 && now - this.automationEventHistory[0].ts > maxAutomationEventAgeMs) {
|
||||
this.automationEventHistory.shift();
|
||||
}
|
||||
if (this.automationEventHistory.length > maxAutomationEventHistory) {
|
||||
this.automationEventHistory.splice(0, this.automationEventHistory.length - maxAutomationEventHistory);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRendererUrl(relativeHtmlPath: string): string {
|
||||
return pathToFileURL(path.join(__dirname, "../renderer", relativeHtmlPath)).toString();
|
||||
}
|
||||
|
|
@ -619,9 +1026,22 @@ function titleFromUrl(value: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function electronProxyRules(endpoint: string): string {
|
||||
const parsed = new URL(endpoint);
|
||||
const host = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname;
|
||||
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
||||
return `http=${host}:${port};https=${host}:${port}`;
|
||||
function defaultAutomationHandoffMessage(kind?: BuiltInBrowserAutomationHandoffKind): string {
|
||||
if (kind === "login_required") {
|
||||
return "Please sign in on this page, then click Done.";
|
||||
}
|
||||
if (kind === "verification_code") {
|
||||
return "Please enter the verification code, then click Done.";
|
||||
}
|
||||
if (kind === "human_verification") {
|
||||
return "Please complete the human verification, then click Done.";
|
||||
}
|
||||
if (kind === "blocked") {
|
||||
return "Please resolve the blocker on this page, then click Done.";
|
||||
}
|
||||
return "Please complete the requested browser step, then click Done.";
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
|
|||
593
packages/electron/src/main/chrome-login-import.ts
Normal file
593
packages/electron/src/main/chrome-login-import.ts
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { BrowserWindow, session, shell } from "electron";
|
||||
import type {
|
||||
ChromeLoginImportCookie,
|
||||
ChromeLoginImportJob,
|
||||
ChromeLoginImportLocalStorage,
|
||||
ChromeLoginImportRequest,
|
||||
ChromeLoginImportResult,
|
||||
ChromeLoginImportTarget
|
||||
} from "@ccr/core/contracts/app";
|
||||
|
||||
type ServerInfo = {
|
||||
server: Server;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type StoredChromeLoginImportJob = ChromeLoginImportJob;
|
||||
|
||||
type CookieSetDetails = Parameters<Electron.Cookies["set"]>[0];
|
||||
|
||||
const browserPartition = "persist:ccr-built-in-browser";
|
||||
const webSearchPartition = "persist:ccr-browser-web-search-mcp";
|
||||
const importJobTtlMs = 5 * 60_000;
|
||||
const maxImportRequestBytes = 16 * 1024 * 1024;
|
||||
const maxStoredErrors = 20;
|
||||
const localStorageWriteTimeoutMs = 15_000;
|
||||
|
||||
class ChromeLoginImportService {
|
||||
private readonly jobs = new Map<string, StoredChromeLoginImportJob>();
|
||||
private serverInfo?: ServerInfo;
|
||||
private serverStartPromise?: Promise<ServerInfo>;
|
||||
|
||||
async createJob(request: ChromeLoginImportRequest): Promise<ChromeLoginImportJob> {
|
||||
const domains = uniqueStrings(request.domains.map(normalizeImportDomain).filter((item): item is string => Boolean(item)));
|
||||
if (domains.length === 0) {
|
||||
throw new Error("At least one Chrome import domain is required.");
|
||||
}
|
||||
|
||||
const target = normalizeImportTarget(request.target);
|
||||
const serverInfo = await this.ensureServer();
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
const job: StoredChromeLoginImportJob = {
|
||||
confirmUrl: `${serverInfo.url}/chrome-import/jobs/${id}/confirm`,
|
||||
createdAt: now,
|
||||
domains,
|
||||
endpointUrl: serverInfo.url,
|
||||
expiresAt: now + importJobTtlMs,
|
||||
id,
|
||||
importUrl: `${serverInfo.url}/chrome-import/jobs/${id}`,
|
||||
status: "pending",
|
||||
target
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
this.pruneJobs();
|
||||
if (request.openConfirmationPage) {
|
||||
await this.openConfirmationPage(job.id);
|
||||
}
|
||||
return cloneJob(job);
|
||||
}
|
||||
|
||||
getJob(id: string): ChromeLoginImportJob | undefined {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) {
|
||||
return undefined;
|
||||
}
|
||||
this.refreshExpiredJob(job);
|
||||
return cloneJob(job);
|
||||
}
|
||||
|
||||
async openConfirmationPage(id: string): Promise<ChromeLoginImportJob> {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) {
|
||||
throw new Error("Chrome login import job was not found.");
|
||||
}
|
||||
this.refreshExpiredJob(job);
|
||||
if (job.status !== "pending") {
|
||||
throw new Error(`Chrome login import job is ${job.status}.`);
|
||||
}
|
||||
await shell.openExternal(job.confirmUrl);
|
||||
return cloneJob(job);
|
||||
}
|
||||
|
||||
private async ensureServer(): Promise<ServerInfo> {
|
||||
if (this.serverInfo) {
|
||||
return this.serverInfo;
|
||||
}
|
||||
if (this.serverStartPromise) {
|
||||
return this.serverStartPromise;
|
||||
}
|
||||
|
||||
this.serverStartPromise = new Promise<ServerInfo>((resolve, reject) => {
|
||||
const server = http.createServer((request, response) => {
|
||||
void this.handleRequest(request, response).catch((error) => {
|
||||
sendJson(response, 500, { error: { message: formatError(error) } });
|
||||
});
|
||||
});
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Chrome login import server failed to start."));
|
||||
return;
|
||||
}
|
||||
const info = {
|
||||
server,
|
||||
url: `http://127.0.0.1:${address.port}`
|
||||
};
|
||||
this.serverInfo = info;
|
||||
resolve(info);
|
||||
});
|
||||
}).finally(() => {
|
||||
this.serverStartPromise = undefined;
|
||||
});
|
||||
return this.serverStartPromise;
|
||||
}
|
||||
|
||||
private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
setCorsHeaders(response);
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const path = request.url ? new URL(request.url, "http://127.0.0.1").pathname : "/";
|
||||
const match = path.match(/^\/chrome-import\/jobs\/([^/]+)(?:\/(confirm|cookies))?$/);
|
||||
if (!match) {
|
||||
sendJson(response, 404, { error: { message: "Chrome login import endpoint not found." } });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = decodeURIComponent(match[1]);
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) {
|
||||
sendJson(response, 404, { error: { message: "Chrome login import job was not found." } });
|
||||
return;
|
||||
}
|
||||
this.refreshExpiredJob(job);
|
||||
if (job.status === "expired") {
|
||||
sendJson(response, 410, { error: { message: "Chrome login import job expired." }, job: cloneJob(job) });
|
||||
return;
|
||||
}
|
||||
|
||||
const action = match[2];
|
||||
if (request.method === "GET" && action === "confirm") {
|
||||
sendHtml(response, 200, confirmationPageHtml(job));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && !action) {
|
||||
sendJson(response, 200, { job: cloneJob(job) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && action === "cookies") {
|
||||
if (job.status !== "pending") {
|
||||
sendJson(response, 409, {
|
||||
error: {
|
||||
message: `Chrome login import job is ${job.status}.`
|
||||
},
|
||||
job: cloneJob(job)
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload = await readJsonRequest(request);
|
||||
const result = await this.importLoginState(job, payload);
|
||||
sendJson(response, 200, { job: cloneJob(job), result });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 405, { error: { message: "Unsupported Chrome login import method." } });
|
||||
}
|
||||
|
||||
private async importLoginState(job: StoredChromeLoginImportJob, payload: unknown): Promise<ChromeLoginImportResult> {
|
||||
const importPayload = readImportPayload(payload);
|
||||
const partitions = targetPartitions(job.target);
|
||||
const result: ChromeLoginImportResult = {
|
||||
completedAt: Date.now(),
|
||||
cookieImported: 0,
|
||||
cookieSkipped: 0,
|
||||
domains: [...job.domains],
|
||||
imported: 0,
|
||||
localStorageImported: 0,
|
||||
localStorageSkipped: 0,
|
||||
partitions,
|
||||
skipped: 0
|
||||
};
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const cookie of importPayload.cookies) {
|
||||
const normalized = normalizeChromeCookie(cookie, job.domains);
|
||||
if (!normalized) {
|
||||
result.cookieSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(partitions.map((partition) => session.fromPartition(partition).cookies.set(normalized)));
|
||||
result.cookieImported += 1;
|
||||
} catch (error) {
|
||||
result.cookieSkipped += 1;
|
||||
if (errors.length < maxStoredErrors) {
|
||||
errors.push(`${normalized.domain || new URL(normalized.url).hostname}:${normalized.name}: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(partitions.map((partition) => session.fromPartition(partition).cookies.flushStore()));
|
||||
|
||||
for (const localStorageEntry of importPayload.localStorage) {
|
||||
const normalized = normalizeLocalStorageEntry(localStorageEntry, job.domains);
|
||||
if (!normalized) {
|
||||
result.localStorageSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemCount = Object.keys(normalized.items).length;
|
||||
if (itemCount === 0) {
|
||||
result.localStorageSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(partitions.map((partition) => writeLocalStorage(partition, normalized.origin, normalized.items)));
|
||||
result.localStorageImported += itemCount;
|
||||
} catch (error) {
|
||||
result.localStorageSkipped += itemCount;
|
||||
if (errors.length < maxStoredErrors) {
|
||||
errors.push(`${normalized.origin}: localStorage: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.imported = result.cookieImported + result.localStorageImported;
|
||||
result.skipped = result.cookieSkipped + result.localStorageSkipped;
|
||||
if (errors.length > 0) {
|
||||
result.errors = errors;
|
||||
}
|
||||
job.result = result;
|
||||
job.status = result.imported > 0 || result.skipped > 0 ? "completed" : "failed";
|
||||
return result;
|
||||
}
|
||||
|
||||
private refreshExpiredJob(job: StoredChromeLoginImportJob): void {
|
||||
if (job.status === "pending" && Date.now() > job.expiresAt) {
|
||||
job.status = "expired";
|
||||
}
|
||||
}
|
||||
|
||||
private pruneJobs(): void {
|
||||
const cutoff = Date.now() - importJobTtlMs;
|
||||
for (const [id, job] of this.jobs) {
|
||||
this.refreshExpiredJob(job);
|
||||
if (job.expiresAt < cutoff) {
|
||||
this.jobs.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const chromeLoginImportService = new ChromeLoginImportService();
|
||||
|
||||
function targetPartitions(target: ChromeLoginImportTarget): string[] {
|
||||
return target === "browser-and-web-search"
|
||||
? [browserPartition, webSearchPartition]
|
||||
: [browserPartition];
|
||||
}
|
||||
|
||||
function normalizeChromeCookie(cookie: ChromeLoginImportCookie, allowedDomains: string[]): CookieSetDetails | undefined {
|
||||
if (!isRecord(cookie) || typeof cookie.name !== "string" || typeof cookie.value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
if (cookie.partitionKey !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawDomain = readString(cookie.domain).toLowerCase();
|
||||
const cookieHost = normalizeCookieHost(rawDomain);
|
||||
if (!cookieHost || !allowedDomains.some((domain) => cookieDomainMatches(cookieHost, domain))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = normalizeCookiePath(cookie.path);
|
||||
const expirationDate = typeof cookie.expirationDate === "number" && Number.isFinite(cookie.expirationDate)
|
||||
? cookie.expirationDate
|
||||
: undefined;
|
||||
if (expirationDate !== undefined && expirationDate <= Date.now() / 1000) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const secure = cookie.secure === true;
|
||||
const details: CookieSetDetails = {
|
||||
httpOnly: cookie.httpOnly === true,
|
||||
name: cookie.name,
|
||||
path,
|
||||
secure,
|
||||
url: cookieUrl(cookieHost, path, secure),
|
||||
value: cookie.value
|
||||
};
|
||||
if (cookie.hostOnly !== true) {
|
||||
details.domain = rawDomain || cookieHost;
|
||||
}
|
||||
if (expirationDate !== undefined && cookie.session !== true) {
|
||||
details.expirationDate = expirationDate;
|
||||
}
|
||||
const sameSite = normalizeSameSite(cookie.sameSite);
|
||||
if (sameSite) {
|
||||
details.sameSite = sameSite;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
function normalizeLocalStorageEntry(
|
||||
entry: ChromeLoginImportLocalStorage,
|
||||
allowedDomains: string[]
|
||||
): ChromeLoginImportLocalStorage | undefined {
|
||||
if (!isRecord(entry) || !isRecord(entry.items)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let origin: URL;
|
||||
try {
|
||||
origin = new URL(readString(entry.origin));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(origin.protocol)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!allowedDomains.some((domain) => cookieDomainMatches(origin.hostname.toLowerCase(), domain))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(entry.items)) {
|
||||
if (typeof key === "string" && typeof value === "string") {
|
||||
items[key] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
items,
|
||||
origin: origin.origin
|
||||
};
|
||||
}
|
||||
|
||||
async function writeLocalStorage(partition: string, origin: string, items: Record<string, string>): Promise<void> {
|
||||
const window = new BrowserWindow({
|
||||
height: 480,
|
||||
paintWhenInitiallyHidden: true,
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
title: "CCR Chrome Login Import",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
partition,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
},
|
||||
width: 640
|
||||
});
|
||||
try {
|
||||
await withTimeout(window.webContents.loadURL(`${origin}/`), localStorageWriteTimeoutMs, "Timed out loading localStorage origin.");
|
||||
const loadedOrigin = new URL(window.webContents.getURL()).origin;
|
||||
if (loadedOrigin !== origin) {
|
||||
throw new Error(`Origin redirected to ${loadedOrigin}.`);
|
||||
}
|
||||
const entries = Object.entries(items);
|
||||
await window.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const entries = ${JSON.stringify(entries)};
|
||||
for (const [key, value] of entries) {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
return entries.length;
|
||||
})()`,
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImportTarget(value: unknown): ChromeLoginImportTarget {
|
||||
return value === "browser-and-web-search" ? "browser-and-web-search" : "browser";
|
||||
}
|
||||
|
||||
function normalizeImportDomain(value: unknown): string | undefined {
|
||||
const raw = readString(value).toLowerCase();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(raw.includes("://") ? raw : `https://${raw}`);
|
||||
return normalizeCookieHost(parsed.hostname);
|
||||
} catch {
|
||||
return normalizeCookieHost(raw.replace(/^\*\./, "").split("/")[0] || "");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCookieHost(value: string): string | undefined {
|
||||
const host = value.trim().replace(/^\./, "").toLowerCase();
|
||||
if (!host || host.includes("*") || host.includes("/") || host.includes(" ")) {
|
||||
return undefined;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function normalizeCookiePath(value: unknown): string {
|
||||
const path = readString(value);
|
||||
return path.startsWith("/") ? path : "/";
|
||||
}
|
||||
|
||||
function cookieUrl(host: string, path: string, secure: boolean): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : "/";
|
||||
return `${secure ? "https" : "http"}://${host}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function cookieDomainMatches(cookieHost: string, allowedDomain: string): boolean {
|
||||
return cookieHost === allowedDomain || cookieHost.endsWith(`.${allowedDomain}`);
|
||||
}
|
||||
|
||||
function normalizeSameSite(value: unknown): CookieSetDetails["sameSite"] | undefined {
|
||||
return value === "lax" || value === "strict" || value === "no_restriction" || value === "unspecified"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readImportPayload(payload: unknown): {
|
||||
cookies: ChromeLoginImportCookie[];
|
||||
localStorage: ChromeLoginImportLocalStorage[];
|
||||
} {
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("Chrome login import payload must be an object.");
|
||||
}
|
||||
const cookies = Array.isArray(payload.cookies)
|
||||
? payload.cookies.filter(isRecord) as ChromeLoginImportCookie[]
|
||||
: [];
|
||||
const localStorage = Array.isArray(payload.localStorage)
|
||||
? payload.localStorage.filter(isRecord) as ChromeLoginImportLocalStorage[]
|
||||
: [];
|
||||
if (cookies.length === 0 && localStorage.length === 0) {
|
||||
throw new Error("Chrome login import payload must include cookies or localStorage.");
|
||||
}
|
||||
return { cookies, localStorage };
|
||||
}
|
||||
|
||||
function cloneJob(job: StoredChromeLoginImportJob): ChromeLoginImportJob {
|
||||
return {
|
||||
...job,
|
||||
domains: [...job.domains],
|
||||
...(job.result
|
||||
? {
|
||||
result: {
|
||||
...job.result,
|
||||
domains: [...job.result.domains],
|
||||
...(job.result.errors ? { errors: [...job.result.errors] } : {}),
|
||||
partitions: [...job.result.partitions]
|
||||
}
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonRequest(request: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buffer.byteLength;
|
||||
if (total > maxImportRequestBytes) {
|
||||
throw new Error("Chrome login import payload is too large.");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, statusCode: number, body: unknown): void {
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(statusCode, { "content-type": "application/json" });
|
||||
}
|
||||
response.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function sendHtml(response: ServerResponse, statusCode: number, body: string): void {
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(statusCode, {
|
||||
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; connect-src http://127.0.0.1:* http://localhost:*",
|
||||
"content-type": "text/html; charset=utf-8"
|
||||
});
|
||||
}
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function confirmationPageHtml(job: StoredChromeLoginImportJob): string {
|
||||
const domains = job.domains.map((domain) => `<li>${escapeHtml(domain)}</li>`).join("");
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCR Chrome Login Import</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { align-items: center; background: Canvas; color: CanvasText; display: flex; justify-content: center; margin: 0; min-height: 100vh; padding: 24px; }
|
||||
main { border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 10px; box-shadow: 0 18px 44px color-mix(in srgb, CanvasText 10%, transparent); display: grid; gap: 16px; max-width: 560px; padding: 22px; width: min(560px, 100%); }
|
||||
h1 { font-size: 20px; line-height: 1.2; margin: 0; }
|
||||
p { color: color-mix(in srgb, CanvasText 72%, transparent); font-size: 13px; line-height: 1.45; margin: 0; }
|
||||
ul { display: grid; gap: 6px; margin: 0; padding-left: 20px; }
|
||||
li { font-size: 13px; }
|
||||
button { background: #0f766e; border: 1px solid #0f766e; border-radius: 8px; color: white; cursor: pointer; font: inherit; font-weight: 750; height: 38px; padding: 0 14px; }
|
||||
button:disabled { cursor: default; opacity: 0.52; }
|
||||
.status { border-radius: 8px; color: color-mix(in srgb, CanvasText 68%, transparent); font-size: 12px; line-height: 1.4; min-height: 18px; overflow-wrap: anywhere; }
|
||||
.status.error { color: #b91c1c; }
|
||||
.status.ok { color: #15803d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main
|
||||
id="ccr-chrome-login-import"
|
||||
data-import-url="${escapeHtml(job.importUrl)}"
|
||||
data-job-id="${escapeHtml(job.id)}"
|
||||
data-domains="${escapeHtml(job.domains.join(","))}"
|
||||
>
|
||||
<h1>Import Chrome Login State into CCR</h1>
|
||||
<p>CCR is requesting permission to import cookies and localStorage for these domains into the in-app browser.</p>
|
||||
<ul>${domains}</ul>
|
||||
<button id="ccr-confirm-import" disabled type="button">Waiting for CCR Chrome extension</button>
|
||||
<div id="ccr-import-status" class="status" role="status">Install or enable the CCR Login Import extension in Chrome to continue.</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function setCorsHeaders(response: ServerResponse): void {
|
||||
response.setHeader("access-control-allow-headers", "content-type, x-ccr-login-import");
|
||||
response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
||||
response.setHeader("access-control-allow-origin", "*");
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case "\"":
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ type BrowserSearchRequest = BrowserSearchInput & {
|
|||
|
||||
type BrowserSearchResult = {
|
||||
content?: string;
|
||||
diagnostics?: string[];
|
||||
snippet?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
|
|
@ -597,11 +598,15 @@ class HiddenBrowserSearchPool {
|
|||
return {
|
||||
...result,
|
||||
...(content ? { content } : {}),
|
||||
...(!content ? { diagnostics: appendSearchResultDiagnostic(result.diagnostics, "No extractable page content found.") } : {}),
|
||||
...(snippet ? { snippet } : {}),
|
||||
...(page.title && page.title.length > result.title.length ? { title: page.title.slice(0, 240) } : {})
|
||||
};
|
||||
} catch {
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
...result,
|
||||
diagnostics: appendSearchResultDiagnostic(result.diagnostics, `Page extraction failed: ${formatError(error)}`)
|
||||
};
|
||||
} finally {
|
||||
if (worker) {
|
||||
this.release(worker);
|
||||
|
|
@ -635,7 +640,16 @@ async function pollSearchResults(webContents: WebContents, request: BrowserSearc
|
|||
const deadline = Date.now() + request.timeoutMs;
|
||||
let last: BrowserSearchPageResult = { results: [] };
|
||||
while (Date.now() < deadline) {
|
||||
last = await webContents.executeJavaScript(searchResultExtractionScript(request.engine, request.count), true) as BrowserSearchPageResult;
|
||||
try {
|
||||
last = await withTimeout(
|
||||
webContents.executeJavaScript(searchResultExtractionScript(request.engine, request.count), true) as Promise<BrowserSearchPageResult>,
|
||||
Math.max(100, Math.min(1000, deadline - Date.now())),
|
||||
"Browser search result extraction timed out."
|
||||
);
|
||||
} catch {
|
||||
await sleep(150);
|
||||
continue;
|
||||
}
|
||||
if (last.blocked || (last.results?.length ?? 0) >= Math.min(request.count, 3)) {
|
||||
return last;
|
||||
}
|
||||
|
|
@ -823,18 +837,46 @@ function searchResultExtractionScript(engine: BrowserSearchEngine, count: number
|
|||
const host = url.hostname.toLowerCase();
|
||||
const path = url.pathname.toLowerCase();
|
||||
if (host === location.hostname.toLowerCase() && (path === "/" || path === "/search" || path.startsWith("/search/"))) return true;
|
||||
if (host.includes("google.") && /\\/(preferences|advanced_search|intl|policies|support|sorry)/.test(path)) return true;
|
||||
if (host.endsWith("bing.com") && /\\/(search|account|profile|images|videos|maps|news)/.test(path)) return true;
|
||||
if (host.endsWith("duckduckgo.com") && path === "/") return true;
|
||||
if (host.includes("google.") && /\\/(preferences|advanced_search|intl|policies|support|sorry|search|aclk|imgres)/.test(path)) return true;
|
||||
if (host.endsWith("bing.com") && /\\/(search|account|profile|images|videos|maps|news|aclick)/.test(path)) return true;
|
||||
if (host.endsWith("bing.com") && path.startsWith("/ck/")) return true;
|
||||
if (host.endsWith("duckduckgo.com") && (path === "/" || path.startsWith("/settings") || path.startsWith("/y.js"))) return true;
|
||||
return false;
|
||||
}
|
||||
function resultContainer(anchor, title) {
|
||||
const root = anchor && anchor.closest("li.b_algo, li.b_ad, div.g, div[data-sokoban-container], div[data-testid='result'], article, .result, .result__body, .web-result");
|
||||
if (root) return root;
|
||||
let node = anchor;
|
||||
for (let depth = 0; node && depth < 8; depth += 1, node = node.parentElement) {
|
||||
if (node.matches && node.matches("nav, header, footer, form, aside")) return undefined;
|
||||
if (node.matches && node.matches("main, #search, #b_results, #rso")) break;
|
||||
const text = compact(node.textContent || "");
|
||||
if (text.length > title.length + 40) return node;
|
||||
}
|
||||
return anchor.closest("li.b_algo, article, div.g, div[data-sokoban-container], div[data-testid='result'], .result, li, div");
|
||||
return undefined;
|
||||
}
|
||||
function isLikelyOrganicResult(anchor, container) {
|
||||
if (!anchor || !container) return false;
|
||||
if (anchor.closest("nav, header, footer, form, aside")) return false;
|
||||
if (anchor.closest("li.b_algo, div.g, div[data-sokoban-container], div[data-testid='result'], article, .result, .result__body, .web-result")) return true;
|
||||
const heading = anchor.closest("h1, h2, h3, [role='heading']");
|
||||
const searchRegion = anchor.closest("main, #search, #b_results, #rso");
|
||||
return Boolean(heading && searchRegion);
|
||||
}
|
||||
function isAdOrUtilityResult(anchor, container) {
|
||||
let node = anchor;
|
||||
for (let depth = 0; node && depth < 6; depth += 1, node = node.parentElement) {
|
||||
const attributes = [
|
||||
node.getAttribute && node.getAttribute("aria-label"),
|
||||
node.getAttribute && node.getAttribute("data-text-ad"),
|
||||
node.getAttribute && node.getAttribute("data-testid"),
|
||||
node.className
|
||||
].filter(Boolean).join(" ");
|
||||
if (/\\b(?:ad|ads|sponsored|promo|shopping)\\b|广告|赞助|推廣/i.test(attributes)) return true;
|
||||
}
|
||||
const firstText = compact(container && container.textContent).slice(0, 180);
|
||||
if (/^(?:ad|ads|sponsored|promo|shopping)\\b|^(?:广告|赞助|推廣)/i.test(firstText)) return true;
|
||||
return false;
|
||||
}
|
||||
function cleanSnippetCandidate(value, title, url) {
|
||||
const host = (() => { try { return new URL(url).hostname.replace(/^www\\./, ""); } catch (_) { return ""; } })();
|
||||
|
|
@ -881,6 +923,7 @@ function searchResultExtractionScript(engine: BrowserSearchEngine, count: number
|
|||
const title = titleFor(anchor, titleOverride);
|
||||
if (!title || title.length < 2) return;
|
||||
const container = resultContainer(anchor, title);
|
||||
if (!isLikelyOrganicResult(anchor, container) || isAdOrUtilityResult(anchor, container)) return;
|
||||
seen.add(url);
|
||||
results.push({
|
||||
snippet: snippetFor(anchor, container, title, url),
|
||||
|
|
@ -908,19 +951,22 @@ function searchResultExtractionScript(engine: BrowserSearchEngine, count: number
|
|||
collect(".result__a");
|
||||
collect("article h2 a[href]");
|
||||
}
|
||||
function collectGenericHeadings() {
|
||||
collect("main h1 a[href], main h2 a[href], main h3 a[href], #search h3, #b_results h2 a[href], #rso h3, article h2 a[href], article h3 a[href]");
|
||||
}
|
||||
if (engine === "google") collectGoogle();
|
||||
if (engine === "bing") collectBing();
|
||||
if (engine === "duckduckgo") collectDuckDuckGo();
|
||||
collect("main a[href], #search a[href], #b_results a[href], article a[href], a[href]");
|
||||
collectGenericHeadings();
|
||||
return { results, title: document.title || "" };
|
||||
|
||||
function detectBlocked() {
|
||||
const text = compact(document.body && document.body.innerText).toLowerCase();
|
||||
if (!text) return "";
|
||||
if (text.includes("unusual traffic") || text.includes("detected unusual") || text.includes("not a robot") || text.includes("captcha")) {
|
||||
if (text.includes("unusual traffic") || text.includes("detected unusual") || text.includes("not a robot") || text.includes("captcha") || text.includes("验证码") || text.includes("人机验证")) {
|
||||
return "Search engine returned an anti-bot or CAPTCHA page.";
|
||||
}
|
||||
if (text.includes("before you continue to google") || text.includes("consent.google")) {
|
||||
if (text.includes("before you continue to google") || text.includes("consent.google") || text.includes("cookie consent")) {
|
||||
return "Google returned a consent page instead of search results.";
|
||||
}
|
||||
return "";
|
||||
|
|
@ -996,11 +1042,16 @@ function formatSearchResponse(
|
|||
`${index + 1}. ${result.title}`,
|
||||
`URL: ${result.url}`,
|
||||
result.snippet ? `Snippet: ${result.snippet}` : "",
|
||||
result.content ? `Extracted content: ${result.content}` : ""
|
||||
result.content ? `Extracted content: ${result.content}` : "",
|
||||
result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : ""
|
||||
].filter(Boolean).join("\n"))
|
||||
].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
function appendSearchResultDiagnostic(existing: string[] | undefined, message: string): string[] {
|
||||
return uniqueStrings([...(existing ?? []), message]);
|
||||
}
|
||||
|
||||
function uniqueSearchResults(results: BrowserSearchResult[]): BrowserSearchResult[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: BrowserSearchResult[] = [];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { deflateSync, inflateSync } from "node:zlib";
|
||||
import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/config/app-config-store";
|
||||
|
|
@ -133,7 +132,6 @@ ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, async (_event, serverName: st
|
|||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => {
|
||||
const config = await loadAppConfig();
|
||||
await ensureBuiltInBrowserProxyReady(config);
|
||||
await builtInBrowserService.open(config);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appCloseTray, () => {
|
||||
|
|
@ -361,157 +359,6 @@ function logProfileApplyResult(result: ProfileApplyResult): void {
|
|||
}
|
||||
}
|
||||
|
||||
type ProxyConnectProbeResult = {
|
||||
detail?: string;
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
const browserProxyConnectProbeTarget = "claude.ai:443";
|
||||
const proxyConnectProbeTimeoutMs = 3000;
|
||||
|
||||
async function ensureBuiltInBrowserProxyReady(config: AppConfig): Promise<void> {
|
||||
if (!config.proxy.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let proxyStatus = proxyService.getStatus();
|
||||
if (proxyStatus.state === "running" && proxyStatus.endpoint) {
|
||||
const probe = await probeProxyConnect(proxyStatus.endpoint);
|
||||
if (probe.ok) {
|
||||
return;
|
||||
}
|
||||
console.warn(`[browser] Proxy CONNECT probe failed at ${proxyStatus.endpoint}; restarting proxy: ${probe.detail || "unknown error"}`);
|
||||
}
|
||||
|
||||
const status = await gatewayService.start(config);
|
||||
if (status.state === "error") {
|
||||
throw new Error(status.lastError || "Failed to start proxy mode.");
|
||||
}
|
||||
|
||||
proxyStatus = proxyService.getStatus();
|
||||
if (proxyStatus.state !== "running" || !proxyStatus.endpoint) {
|
||||
throw new Error(proxyStatus.lastError || "Proxy mode is not running.");
|
||||
}
|
||||
|
||||
const probe = await probeProxyConnect(proxyStatus.endpoint);
|
||||
if (probe.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[browser] Shared proxy endpoint ${proxyStatus.endpoint} still does not accept CONNECT after restart; starting dedicated proxy endpoint: ${probe.detail || "unknown error"}`
|
||||
);
|
||||
const dedicatedProxyConfig = await createBuiltInBrowserProxyConfig(config);
|
||||
const dedicatedProxyStatus = await proxyService.start(dedicatedProxyConfig);
|
||||
if (dedicatedProxyStatus.state !== "running" || !dedicatedProxyStatus.endpoint) {
|
||||
throw new Error(dedicatedProxyStatus.lastError || "Failed to start the dedicated proxy endpoint for the built-in browser.");
|
||||
}
|
||||
|
||||
const dedicatedProbe = await probeProxyConnect(dedicatedProxyStatus.endpoint);
|
||||
if (!dedicatedProbe.ok) {
|
||||
const detail = dedicatedProbe.detail ? `: ${dedicatedProbe.detail}` : "";
|
||||
throw new Error(`Proxy mode is running at ${dedicatedProxyStatus.endpoint}, but HTTPS CONNECT is not available${detail}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBuiltInBrowserProxyConfig(config: AppConfig): Promise<AppConfig> {
|
||||
return {
|
||||
...config,
|
||||
proxy: {
|
||||
...config.proxy,
|
||||
host: "127.0.0.1",
|
||||
port: await findAvailableLoopbackPort(),
|
||||
systemProxy: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function findAvailableLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Failed to allocate a local proxy port."));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function probeProxyConnect(endpoint: string): Promise<ProxyConnectProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(endpoint);
|
||||
} catch {
|
||||
resolve({ detail: `Invalid proxy endpoint: ${endpoint}`, ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
resolve({ detail: `Invalid proxy port in endpoint: ${endpoint}`, ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
const socket = net.connect({ host, port });
|
||||
let response = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: ProxyConnectProbeResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
const parseResponse = (): ProxyConnectProbeResult | undefined => {
|
||||
const firstLine = response.split(/\r?\n/, 1)[0]?.trim();
|
||||
if (!firstLine) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^HTTP\/1\.[01]\s+200\b/i.test(firstLine)) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (/^HTTP\/1\.[01]\s+\d{3}\b/i.test(firstLine)) {
|
||||
return { detail: firstLine, ok: false };
|
||||
}
|
||||
return { detail: `Unexpected response: ${firstLine}`, ok: false };
|
||||
};
|
||||
|
||||
socket.setTimeout(proxyConnectProbeTimeoutMs, () => {
|
||||
finish({ detail: `Timed out after ${proxyConnectProbeTimeoutMs}ms`, ok: false });
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
finish({ detail: formatError(error), ok: false });
|
||||
});
|
||||
socket.once("connect", () => {
|
||||
socket.write(`CONNECT ${browserProxyConnectProbeTarget} HTTP/1.1\r\nHost: ${browserProxyConnectProbeTarget}\r\n\r\n`);
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
response += chunk.toString("latin1");
|
||||
const result = parseResponse();
|
||||
if (result) {
|
||||
finish(result);
|
||||
}
|
||||
});
|
||||
socket.once("close", () => {
|
||||
finish(parseResponse() ?? { detail: "Connection closed without a CONNECT response", ok: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function exportAppData(window: BrowserWindow | null): Promise<AppDataExportResult> {
|
||||
const exportedAt = new Date().toISOString();
|
||||
const result = window
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { syncLaunchAtLogin } from "./launch-at-login";
|
|||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import trayController from "./tray-controller";
|
||||
import { appUpdateService } from "./update-service";
|
||||
import { browserAutomationMcpService } from "./browser-automation-mcp";
|
||||
import { browserWebSearchMcpService } from "./electron-web-search-mcp";
|
||||
import windowsManager from "./windows";
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ if (!gotTheLock) {
|
|||
}
|
||||
|
||||
function startPrimaryInstance(): void {
|
||||
gatewayService.setBrowserAutomationMcpIntegration(browserAutomationMcpService);
|
||||
gatewayService.setBrowserWebSearchMcpIntegration(browserWebSearchMcpService);
|
||||
deepLinkService.register();
|
||||
deepLinkService.handleArgv(process.argv);
|
||||
|
|
|
|||
BIN
packages/ui/src/assets/provider-icons/claudeapi.png
Normal file
BIN
packages/ui/src/assets/provider-icons/claudeapi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
packages/ui/src/assets/provider-icons/code0.png
Normal file
BIN
packages/ui/src/assets/provider-icons/code0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ArrowLeft, ArrowRight, Globe2, LoaderCircle, Plus, RotateCw, Search, X } from "lucide-react";
|
||||
import type { BuiltInBrowserState } from "@ccr/core/contracts/app";
|
||||
import { ArrowLeft, ArrowRight, Check, KeyRound, LoaderCircle, Plus, RotateCw, Search, UserRound, X } from "lucide-react";
|
||||
import type { BuiltInBrowserState, ChromeLoginImportJob, ChromeLoginImportRequest } from "@ccr/core/contracts/app";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -9,11 +9,14 @@ declare global {
|
|||
back: (tabId?: string) => Promise<BuiltInBrowserState>;
|
||||
closeTab: (tabId: string) => Promise<BuiltInBrowserState>;
|
||||
forward: (tabId?: string) => Promise<BuiltInBrowserState>;
|
||||
getChromeLoginImport: (id: string) => Promise<ChromeLoginImportJob | undefined>;
|
||||
getState: () => Promise<BuiltInBrowserState>;
|
||||
navigate: (url: string, tabId?: string) => Promise<BuiltInBrowserState>;
|
||||
newTab: (url?: string) => Promise<BuiltInBrowserState>;
|
||||
reload: (tabId?: string) => Promise<BuiltInBrowserState>;
|
||||
resolveAutomationHandoff: (status: "completed" | "dismissed") => Promise<BuiltInBrowserState>;
|
||||
selectTab: (tabId: string) => Promise<BuiltInBrowserState>;
|
||||
startChromeLoginImport: (request: ChromeLoginImportRequest) => Promise<ChromeLoginImportJob>;
|
||||
onStateChanged: (callback: (state: BuiltInBrowserState) => void) => () => void;
|
||||
};
|
||||
}
|
||||
|
|
@ -29,10 +32,12 @@ function BrowserChrome() {
|
|||
const [state, setState] = useState<BuiltInBrowserState>(emptyState);
|
||||
const [addressDraft, setAddressDraft] = useState("");
|
||||
const [homeDraft, setHomeDraft] = useState("");
|
||||
const [chromeImportJob, setChromeImportJob] = useState<ChromeLoginImportJob | undefined>();
|
||||
const activeTab = useMemo(
|
||||
() => state.tabs.find((tab) => tab.id === state.activeTabId),
|
||||
[state.activeTabId, state.tabs]
|
||||
);
|
||||
const handoff = state.automationHandoff;
|
||||
const homeVisible = activeTab?.url === browserHomeUrl;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -57,6 +62,25 @@ function BrowserChrome() {
|
|||
setHomeDraft("");
|
||||
}, [activeTab?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chromeImportJob || chromeImportJob.status !== "pending") {
|
||||
return;
|
||||
}
|
||||
const interval = window.setInterval(() => {
|
||||
void window.ccrBrowser?.getChromeLoginImport(chromeImportJob.id).then((job) => {
|
||||
if (!job) {
|
||||
setChromeImportJob(undefined);
|
||||
return;
|
||||
}
|
||||
setChromeImportJob(job);
|
||||
if (job.status === "completed" && activeTab?.id && activeTabMatchesImport(activeTab.url, job.domains)) {
|
||||
void run(window.ccrBrowser?.reload(activeTab.id));
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [activeTab?.id, activeTab?.url, chromeImportJob]);
|
||||
|
||||
async function run(action: Promise<BuiltInBrowserState> | undefined): Promise<void> {
|
||||
if (!action) {
|
||||
return;
|
||||
|
|
@ -79,8 +103,53 @@ function BrowserChrome() {
|
|||
void run(window.ccrBrowser?.navigate(url, activeTab?.id));
|
||||
}
|
||||
|
||||
async function startChromeLoginImport() {
|
||||
const defaultDomain = activeTabDomain(activeTab?.url);
|
||||
const rawDomains = window.prompt("Chrome domain(s) to import. Separate multiple domains with commas.", defaultDomain);
|
||||
if (!rawDomains) {
|
||||
return;
|
||||
}
|
||||
const domains = parseImportDomains(rawDomains);
|
||||
if (domains.length === 0) {
|
||||
return;
|
||||
}
|
||||
const job = await window.ccrBrowser?.startChromeLoginImport({ domains, openConfirmationPage: true });
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
setChromeImportJob(job);
|
||||
}
|
||||
|
||||
function chromeImportTitle(): string {
|
||||
if (!chromeImportJob) {
|
||||
return "Import login from Chrome";
|
||||
}
|
||||
if (chromeImportJob.status === "completed") {
|
||||
return `Chrome import complete: ${chromeImportJob.result?.imported ?? 0} imported, ${chromeImportJob.result?.skipped ?? 0} skipped`;
|
||||
}
|
||||
if (chromeImportJob.status === "expired") {
|
||||
return "Chrome import expired";
|
||||
}
|
||||
if (chromeImportJob.status === "failed") {
|
||||
return "Chrome import failed";
|
||||
}
|
||||
return "Chrome import pending. Confirm it in the browser window.";
|
||||
}
|
||||
|
||||
async function handleChromeImportButton() {
|
||||
if (chromeImportJob?.status === "pending") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(chromeImportJob.confirmUrl);
|
||||
} catch {
|
||||
window.prompt("Open this Chrome import confirmation URL.", chromeImportJob.confirmUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await startChromeLoginImport();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="browser-shell">
|
||||
<div className={`browser-shell ${handoff ? "has-handoff" : ""}`}>
|
||||
<div className="tabs-row">
|
||||
<div className="traffic-space" />
|
||||
<div className="tabs-strip">
|
||||
|
|
@ -149,8 +218,45 @@ function BrowserChrome() {
|
|||
spellCheck={false}
|
||||
value={addressDraft}
|
||||
/>
|
||||
<button
|
||||
className={`icon-button ${chromeImportJob?.status === "pending" ? "active-import" : ""}`}
|
||||
disabled={!activeTab}
|
||||
onClick={() => void handleChromeImportButton()}
|
||||
title={chromeImportTitle()}
|
||||
type="button"
|
||||
>
|
||||
<KeyRound size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{handoff ? (
|
||||
<div className="automation-handoff" role="status">
|
||||
<div className="handoff-copy">
|
||||
<UserRound size={16} strokeWidth={2.2} />
|
||||
<span className="handoff-message">{handoff.message}</span>
|
||||
{handoff.reason ? <span className="handoff-reason">{handoff.reason}</span> : null}
|
||||
</div>
|
||||
<div className="handoff-actions">
|
||||
<button
|
||||
className="handoff-button primary"
|
||||
onClick={() => void run(window.ccrBrowser?.resolveAutomationHandoff("completed"))}
|
||||
type="button"
|
||||
>
|
||||
<Check size={15} strokeWidth={2.4} />
|
||||
<span>Done</span>
|
||||
</button>
|
||||
<button
|
||||
className="handoff-button"
|
||||
onClick={() => void run(window.ccrBrowser?.resolveAutomationHandoff("dismissed"))}
|
||||
type="button"
|
||||
>
|
||||
<X size={14} strokeWidth={2.3} />
|
||||
<span>Hide</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{homeVisible ? (
|
||||
<main className="home-page">
|
||||
<section className="home-content" aria-label="New tab">
|
||||
|
|
@ -189,6 +295,42 @@ function BrowserChrome() {
|
|||
const root = createRoot(document.getElementById("root") as HTMLElement);
|
||||
root.render(<BrowserChrome />);
|
||||
|
||||
function activeTabDomain(url: string | undefined): string {
|
||||
if (!url || url === browserHomeUrl) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function activeTabMatchesImport(url: string | undefined, domains: string[]): boolean {
|
||||
const host = activeTabDomain(url).toLowerCase();
|
||||
return Boolean(host && domains.some((domain) => host === domain || host.endsWith(`.${domain}`)));
|
||||
}
|
||||
|
||||
function parseImportDomains(value: string): string[] {
|
||||
return [...new Set(value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => normalizeImportDomain(item))
|
||||
.filter((item): item is string => Boolean(item)))];
|
||||
}
|
||||
|
||||
function normalizeImportDomain(value: string): string | undefined {
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(raw.includes("://") ? raw : `https://${raw}`).hostname;
|
||||
} catch {
|
||||
const domain = raw.replace(/^\*\./, "").split("/")[0];
|
||||
return domain && !domain.includes(" ") ? domain : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
:root {
|
||||
|
|
@ -231,6 +373,10 @@ style.textContent = `
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.browser-shell.has-handoff {
|
||||
grid-template-rows: 38px 44px 44px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.tabs-row {
|
||||
-webkit-app-region: drag;
|
||||
align-items: end;
|
||||
|
|
@ -325,7 +471,7 @@ style.textContent = `
|
|||
border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: 32px 32px 32px minmax(0, 1fr);
|
||||
grid-template-columns: 32px 32px 32px minmax(0, 1fr) 32px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +485,11 @@ style.textContent = `
|
|||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.icon-button.active-import {
|
||||
background: color-mix(in srgb, #0f766e 16%, transparent);
|
||||
color: color-mix(in srgb, #0f766e 82%, CanvasText);
|
||||
}
|
||||
|
||||
input {
|
||||
background: color-mix(in srgb, CanvasText 4%, Canvas);
|
||||
border: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
|
||||
|
|
@ -355,6 +506,79 @@ style.textContent = `
|
|||
border-color: color-mix(in srgb, #2563eb 70%, CanvasText 30%);
|
||||
}
|
||||
|
||||
.automation-handoff {
|
||||
-webkit-app-region: drag;
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, #f59e0b 16%, Canvas);
|
||||
border-bottom: 1px solid color-mix(in srgb, #92400e 24%, transparent);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.handoff-copy {
|
||||
align-items: center;
|
||||
color: color-mix(in srgb, #78350f 76%, CanvasText);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.handoff-message,
|
||||
.handoff-reason {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.handoff-message {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.handoff-reason {
|
||||
color: color-mix(in srgb, CanvasText 58%, transparent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.handoff-actions {
|
||||
-webkit-app-region: no-drag;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.handoff-button {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, CanvasText 5%, Canvas);
|
||||
border: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.handoff-button:hover {
|
||||
background: color-mix(in srgb, CanvasText 9%, Canvas);
|
||||
}
|
||||
|
||||
.handoff-button.primary {
|
||||
background: #166534;
|
||||
border-color: #166534;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.handoff-button.primary:hover {
|
||||
background: #14532d;
|
||||
}
|
||||
|
||||
.home-page {
|
||||
align-items: flex-start;
|
||||
background:
|
||||
|
|
@ -487,6 +711,24 @@ style.textContent = `
|
|||
.installed-apps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.automation-handoff {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.handoff-reason {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.handoff-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.handoff-button {
|
||||
padding: 0 8px;
|
||||
width: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
|
||||
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint,
|
||||
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
|
||||
providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
|
||||
providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
|
||||
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
|
||||
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
|
||||
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
|
||||
|
|
@ -2085,7 +2085,11 @@ function ProviderUsageSettings({
|
|||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ProviderAccountTestResult>();
|
||||
const [testError, setTestError] = useState("");
|
||||
const [newApiUserId, setNewApiUserId] = useState("");
|
||||
const modeOptions = translateOptions(providerAccountModeOptions, t);
|
||||
const showNewApiUserBalanceTemplate = probe?.detectedProvider === "new-api" ||
|
||||
draft.accountConnectorsText.includes("new-api-key-usage") ||
|
||||
draft.accountConnectorsText.includes("new-api-user-self");
|
||||
|
||||
useEffect(() => {
|
||||
setTestResult(undefined);
|
||||
|
|
@ -2135,6 +2139,16 @@ function ProviderUsageSettings({
|
|||
onChange(providerUsageFieldPatch(target, path));
|
||||
}
|
||||
|
||||
function insertNewApiUserBalanceTemplate() {
|
||||
onChange({
|
||||
accountConnectorsText: providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
|
||||
draft.accountConnectorsText,
|
||||
probe?.normalizedBaseUrl || draft.baseUrl,
|
||||
newApiUserId
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sm:col-span-2 space-y-3 rounded-md border border-border bg-background/60 p-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
|
||||
|
|
@ -2253,23 +2267,40 @@ function ProviderUsageSettings({
|
|||
) : null}
|
||||
|
||||
{draft.accountMode === "raw" ? (
|
||||
<Field className="sm:col-span-2" label={t("Connectors JSON")}>
|
||||
<Textarea
|
||||
className="min-h-[180px] font-mono text-[11px]"
|
||||
value={draft.accountConnectorsText}
|
||||
onChange={(event) => onChange({ accountConnectorsText: event.target.value })}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{t("Supports standard, http-json, plugin, and local-estimate connectors.")}</span>
|
||||
<button
|
||||
className="shrink-0 text-primary hover:underline"
|
||||
type="button"
|
||||
onClick={() => onChange({ accountConnectorsText: providerAccountConnectorExample() })}
|
||||
>
|
||||
{t("Insert example")}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Field label={t("Connectors JSON")}>
|
||||
<Textarea
|
||||
className="min-h-[180px] font-mono text-[11px]"
|
||||
value={draft.accountConnectorsText}
|
||||
onChange={(event) => onChange({ accountConnectorsText: event.target.value })}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{t("Supports standard, http-json, plugin, and local-estimate connectors.")}</span>
|
||||
<button
|
||||
className="shrink-0 text-primary hover:underline"
|
||||
type="button"
|
||||
onClick={() => onChange({ accountConnectorsText: providerAccountConnectorExample() })}
|
||||
>
|
||||
{t("Insert example")}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{showNewApiUserBalanceTemplate ? (
|
||||
<div className="grid grid-cols-1 items-end gap-2 rounded-md border border-border/60 bg-muted/20 p-2 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">{t("New API user ID")}</Label>
|
||||
<Input
|
||||
placeholder="<user-id>"
|
||||
value={newApiUserId}
|
||||
onChange={(event) => setNewApiUserId(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" type="button" variant="outline" onClick={insertNewApiUserBalanceTemplate}>
|
||||
{t("Insert New API user balance")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
botGatewaySavedConfigFromDraft, botGatewaySavedConfigLabel, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button,
|
||||
CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
|
||||
DialogFooter, DialogHeader, DialogTitle, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge,
|
||||
Globe,
|
||||
createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, createRouteModelOptions, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle,
|
||||
mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeToolHubConfig, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel,
|
||||
PanelLeftOpen, Power, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl,
|
||||
|
|
@ -512,6 +513,13 @@ function ToolHubSettingsPage({
|
|||
</div>
|
||||
{toolHub.enabled ? (
|
||||
<>
|
||||
<SettingsSwitchRow
|
||||
checked={toolHub.browserAutomation}
|
||||
description={copy.settings.toolHubBrowserAutomationDescription}
|
||||
icon={Globe}
|
||||
label={copy.settings.toolHubBrowserAutomation}
|
||||
onChange={(browserAutomation) => onChange({ browserAutomation })}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 rounded-md border border-border/70 bg-card/70 p-3 md:grid-cols-2">
|
||||
<Field className="md:col-span-2" label={copy.settings.toolHubModel}>
|
||||
<SelectControl
|
||||
|
|
@ -547,7 +555,6 @@ function ToolHubSettingsPage({
|
|||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{t("MCP servers")}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{String(toolHub.mcpServers.length)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={openImportMcpJsonDialog} size="sm" type="button" variant="outline">
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ export function normalizeToolHubConfig(config: Partial<AppConfig["toolHub"]> | u
|
|||
return {
|
||||
...fallbackConfig.toolHub,
|
||||
...(config || {}),
|
||||
browserAutomation: Boolean(config?.browserAutomation),
|
||||
enabled: Boolean(config?.enabled),
|
||||
llm: {
|
||||
...fallbackConfig.toolHub.llm,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export type AppCopy = {
|
|||
themeLight: string;
|
||||
themeSystem: string;
|
||||
toolHub: string;
|
||||
toolHubBrowserAutomation: string;
|
||||
toolHubBrowserAutomationDescription: string;
|
||||
toolHubDescription: string;
|
||||
toolHubEnabled: string;
|
||||
toolHubEnabledDescription: string;
|
||||
|
|
@ -139,6 +141,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
themeLight: "Light",
|
||||
themeSystem: "System",
|
||||
toolHub: "ToolHub",
|
||||
toolHubBrowserAutomation: "Built-in browser automation",
|
||||
toolHubBrowserAutomationDescription: "Expose CCR Desktop's built-in browser automation tools through ToolHub.",
|
||||
toolHubDescription: "Route configured MCP servers through ToolHub, where the model finds the tools needed for each task.",
|
||||
toolHubEnabled: "Enable ToolHub",
|
||||
toolHubEnabledDescription: "Expose a compact ToolHub MCP server instead of all configured MCP tools.",
|
||||
|
|
@ -495,6 +499,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
themeLight: "亮色",
|
||||
themeSystem: "跟随系统",
|
||||
toolHub: "ToolHub",
|
||||
toolHubBrowserAutomation: "内置浏览器自动化",
|
||||
toolHubBrowserAutomationDescription: "通过 ToolHub 暴露 CCR Desktop 的内置浏览器自动化工具。",
|
||||
toolHubDescription: "将已配置的 MCP servers 交给 ToolHub,由模型负责检索需要的工具。",
|
||||
toolHubEnabled: "启用 ToolHub",
|
||||
toolHubEnabledDescription: "向 Agent 暴露紧凑的 ToolHub MCP,而不是所有已配置 MCP 工具。",
|
||||
|
|
@ -1223,6 +1229,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Usage Trend": "用量趋势",
|
||||
"User idle": "用户空闲",
|
||||
"Insert example": "插入示例",
|
||||
"Insert New API user balance": "插入 New API 用户余额",
|
||||
"New API user ID": "New API 用户 ID",
|
||||
"Virtual model": "Fusion",
|
||||
"Virtual Models": "Fusion",
|
||||
"Add MCP Server": "添加 MCP 服务",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import type {
|
|||
} from "@ccr/core/contracts/app";
|
||||
import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png";
|
||||
import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico";
|
||||
import claudeapiProviderIconUrl from "@/assets/provider-icons/claudeapi.png";
|
||||
import code0ProviderIconUrl from "@/assets/provider-icons/code0.png";
|
||||
import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico";
|
||||
import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg";
|
||||
import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp";
|
||||
|
|
@ -319,6 +321,8 @@ export const mcpStdioMessageModeOptions: Array<{ label: string; value: GatewayMc
|
|||
export const providerPresetIconUrls: Record<string, string> = {
|
||||
anthropic: anthropicProviderIconUrl,
|
||||
bailian: bailianProviderIconUrl,
|
||||
claudeapi: claudeapiProviderIconUrl,
|
||||
code0: code0ProviderIconUrl,
|
||||
deepseek: deepseekProviderIconUrl,
|
||||
gemini: geminiProviderIconUrl,
|
||||
"kimi-coding": moonshotProviderIconUrl,
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ import {
|
|||
providerEndpointCanReceiveProviderApiKeyInList,
|
||||
providerIdentitySafetyIssueInList
|
||||
} from "@ccr/core/providers/presets/utils";
|
||||
import { newApiUserSelfConnectorConfig } from "@ccr/core/providers/new-api";
|
||||
import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
|
||||
import {
|
||||
fallbackConfig,
|
||||
|
|
@ -899,7 +900,8 @@ export function createProviderConfigFromDeepLink(
|
|||
if (identityIssue) {
|
||||
throw new Error(identityIssue.message);
|
||||
}
|
||||
const accountKeySafetyIssue = providerAccountApiKeySafetyIssue(payload.account, {
|
||||
const account = payload.account ?? probe?.account ?? defaultProviderAccountConfigForBaseUrl(baseUrl);
|
||||
const accountKeySafetyIssue = providerAccountApiKeySafetyIssue(account, {
|
||||
apiKey: payload.apiKey,
|
||||
baseUrl,
|
||||
providerName: name
|
||||
|
|
@ -914,7 +916,7 @@ export function createProviderConfigFromDeepLink(
|
|||
);
|
||||
|
||||
return {
|
||||
account: payload.account ? cloneProviderAccountConfig(payload.account) : defaultProviderAccountConfigForBaseUrl(baseUrl),
|
||||
account: cloneProviderAccountConfig(account),
|
||||
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
|
||||
api_key: apiKey,
|
||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||||
|
|
@ -1262,6 +1264,15 @@ export function createProviderAccountDraftFromConfig(account: ProviderAccountCon
|
|||
accountRefreshIntervalMs: account.refreshIntervalMs ? String(account.refreshIntervalMs) : ""
|
||||
};
|
||||
}
|
||||
if (httpJsonConnector.parser) {
|
||||
return {
|
||||
...base,
|
||||
accountConnectorsText: JSON.stringify(connectors, null, 2),
|
||||
accountEnabled: account.enabled === true,
|
||||
accountMode: "raw",
|
||||
accountRefreshIntervalMs: account.refreshIntervalMs ? String(account.refreshIntervalMs) : ""
|
||||
};
|
||||
}
|
||||
|
||||
const balanceMeter = httpJsonConnector.mapping.meters.find((meter) => meter.kind === "balance" || meter.id === "balance");
|
||||
const subscriptionMeter = httpJsonConnector.mapping.meters.find((meter) =>
|
||||
|
|
@ -1609,6 +1620,31 @@ export function providerAccountConnectorExample(): string {
|
|||
], null, 2);
|
||||
}
|
||||
|
||||
export function providerAccountConnectorsTextWithNewApiUserBalanceTemplate(connectorsText: string, baseUrl: string, userId: string): string {
|
||||
const connector = newApiUserSelfConnectorConfig(baseUrl.trim() || "https://new-api.example/v1");
|
||||
connector.headers = {
|
||||
...(connector.headers ?? {}),
|
||||
"New-Api-User": userId.trim() || "<user-id>"
|
||||
};
|
||||
|
||||
let connectors: unknown[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(connectorsText.trim() || "[]") as unknown;
|
||||
connectors = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
connectors = [];
|
||||
}
|
||||
|
||||
return JSON.stringify([
|
||||
...connectors.filter((item) => !isNewApiUserSelfConnector(item)),
|
||||
connector
|
||||
], null, 2);
|
||||
}
|
||||
|
||||
function isNewApiUserSelfConnector(value: unknown): boolean {
|
||||
return isPlainRecord(value) && value.type === "http-json" && value.parser === "new-api-user-self";
|
||||
}
|
||||
|
||||
export function toProviderProtocol(value: string | undefined): GatewayProviderProtocol | undefined {
|
||||
return providerProtocolOptions.some((option) => option.value === value) ? value as GatewayProviderProtocol : undefined;
|
||||
}
|
||||
|
|
@ -1845,10 +1881,12 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
|
|||
const protocol = probe.detectedProtocol ?? draft.protocol;
|
||||
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
|
||||
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
|
||||
const accountDraft = providerProbeAccountDraftPatch(draft, probe.account);
|
||||
|
||||
if (probe.models.length === 0) {
|
||||
return {
|
||||
...draft,
|
||||
...accountDraft,
|
||||
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
||||
modelDisplayNames,
|
||||
protocol,
|
||||
|
|
@ -1871,6 +1909,7 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
|
|||
|
||||
return {
|
||||
...draft,
|
||||
...accountDraft,
|
||||
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
||||
modelDisplayNames,
|
||||
protocol,
|
||||
|
|
@ -1880,6 +1919,16 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
|
|||
};
|
||||
}
|
||||
|
||||
function providerProbeAccountDraftPatch(
|
||||
draft: AddProviderDraft,
|
||||
account: ProviderAccountConfig | undefined
|
||||
): Partial<AddProviderDraft> {
|
||||
if (!account || (draft.accountEnabled && draft.accountMode !== "standard")) {
|
||||
return {};
|
||||
}
|
||||
return createProviderAccountDraftFromConfig(account);
|
||||
}
|
||||
|
||||
export function mergeModelDisplayNames(
|
||||
...groups: Array<Record<string, string> | undefined>
|
||||
): Record<string, string> | undefined {
|
||||
|
|
|
|||
188
tests/main/fusion-vision-mcp.test.mjs
Normal file
188
tests/main/fusion-vision-mcp.test.mjs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
test("Fusion vision MCP sends the injected core gateway API key", async (t) => {
|
||||
const seen = {
|
||||
authorization: "",
|
||||
body: undefined,
|
||||
path: ""
|
||||
};
|
||||
const server = http.createServer(async (request, response) => {
|
||||
seen.authorization = request.headers.authorization ?? "";
|
||||
seen.path = request.url ?? "";
|
||||
const body = await readRequestBody(request);
|
||||
seen.body = JSON.parse(body);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "vision ok"
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
});
|
||||
|
||||
try {
|
||||
await listen(server);
|
||||
} catch (error) {
|
||||
if (isLocalListenUnavailable(error)) {
|
||||
t.skip(`Local HTTP listen is unavailable: ${formatError(error)}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
t.after(() => server.close());
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const child = spawn(process.execPath, [path.join(process.cwd(), "dist", "tests", "main", "fusion-vision-mcp.js")], {
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
FUSION_BUILTIN_TOOL_KIND: "vision",
|
||||
FUSION_TOOL_NAME: "vision_understand_glm_5_2v",
|
||||
VISION_GATEWAY_API_KEY: "core-token",
|
||||
VISION_GATEWAY_BASE_URL: `http://127.0.0.1:${address.port}/v1`,
|
||||
VISION_MODEL: "provider-zhipu-ai-china---coding-plan-d63a2c4b21::openai_chat_completions::cred:test-1/glm-5v-turbo"
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
t.after(() => {
|
||||
if (!child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
const response = await sendJsonRpc(child, {
|
||||
id: 1,
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
arguments: {
|
||||
imageBase64: "iVBORw0KGgo=",
|
||||
mimeType: "image/png",
|
||||
prompt: "Describe this image."
|
||||
},
|
||||
name: "vision_understand_glm_5_2v"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(response.error, undefined);
|
||||
assert.equal(response.result?.isError, undefined);
|
||||
assert.equal(response.result?.content?.[0]?.text, "vision ok");
|
||||
assert.equal(seen.authorization, "Bearer core-token");
|
||||
assert.equal(seen.path, "/v1/chat/completions");
|
||||
assert.equal(seen.body?.model, "provider-zhipu-ai-china---coding-plan-d63a2c4b21::openai_chat_completions::cred:test-1/glm-5v-turbo");
|
||||
assert.equal(seen.body?.messages?.[0]?.content?.[1]?.type, "image_url");
|
||||
});
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const cleanup = () => {
|
||||
server.off("error", onError);
|
||||
server.off("listening", onListening);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(0, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function readRequestBody(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
request.on("error", reject);
|
||||
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
});
|
||||
}
|
||||
|
||||
function sendJsonRpc(child, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = Buffer.alloc(0);
|
||||
let stderr = "";
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout.off("data", onStdout);
|
||||
child.stderr.off("data", onStderr);
|
||||
child.off("exit", onExit);
|
||||
child.off("error", onError);
|
||||
};
|
||||
const finish = (value) => {
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const fail = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onStdout = (chunk) => {
|
||||
stdout = Buffer.concat([stdout, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
const parsed = readJsonRpcFrame(stdout);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
finish(parsed.value);
|
||||
};
|
||||
const onStderr = (chunk) => {
|
||||
stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
};
|
||||
const onExit = (code, signal) => {
|
||||
fail(new Error(`fusion-vision MCP exited before response: code=${code ?? ""} signal=${signal ?? ""} stderr=${stderr}`));
|
||||
};
|
||||
const onError = (error) => fail(error);
|
||||
const timer = setTimeout(() => fail(new Error(`Timed out waiting for fusion-vision MCP response. stderr=${stderr}`)), 5000);
|
||||
|
||||
child.stdout.on("data", onStdout);
|
||||
child.stderr.on("data", onStderr);
|
||||
child.once("exit", onExit);
|
||||
child.once("error", onError);
|
||||
|
||||
const message = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
child.stdin.write(`Content-Length: ${message.byteLength}\r\n\r\n`);
|
||||
child.stdin.write(message);
|
||||
});
|
||||
}
|
||||
|
||||
function readJsonRpcFrame(buffer) {
|
||||
const headerEnd = buffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const header = buffer.subarray(0, headerEnd).toString("utf8");
|
||||
const match = header.match(/content-length:\s*(\d+)/i);
|
||||
if (!match) {
|
||||
throw new Error(`Missing Content-Length in MCP response: ${header}`);
|
||||
}
|
||||
const length = Number(match[1]);
|
||||
const bodyStart = headerEnd + 4;
|
||||
const bodyEnd = bodyStart + length;
|
||||
if (buffer.length < bodyEnd) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
rest: buffer.subarray(bodyEnd),
|
||||
value: JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString("utf8"))
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalListenUnavailable(error) {
|
||||
return error && typeof error === "object" && (error.code === "EPERM" || error.code === "EACCES");
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
fusionFallbackToolDefinitions,
|
||||
fusionWebSearchToolNameForRequest,
|
||||
fusionToolNamesBackedByMcpServers,
|
||||
fusionBuiltinToolArtifactsForTest,
|
||||
extractHostedWebSearchQueryHint,
|
||||
hostedWebSearchProtocolResponseStream,
|
||||
prepareGatewayUpstreamAttemptForTest,
|
||||
|
|
@ -80,6 +81,164 @@ test("gateway config rewrites Fusion fixed base and vision models to core provid
|
|||
assert.equal(profiles[0].baseModel.fixedModel, `${providerName}/glm-5.2`);
|
||||
});
|
||||
|
||||
test("issue 1480 Fusion vision config injects core auth token into MCP gateway runtime", async () => {
|
||||
const providerName = "Zhipu AI (China) - Coding Plan";
|
||||
const config = {
|
||||
CUSTOM_ROUTER_PATH: "",
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://nebulacoder.example/v1/chat/completions",
|
||||
credentials: [{ apiKey: "nebulacoder-key", id: "nebulacoder-main" }],
|
||||
models: ["nebulacoder-v8.0", "nebulacoder-cot-v8.0"],
|
||||
name: "NebulaCoder"
|
||||
},
|
||||
{
|
||||
api_base_url: "https://coclaw.example/v1/chat/completions",
|
||||
credentials: [{ apiKey: "coclaw-key", id: "coclaw-main" }],
|
||||
models: ["Qwen3-235B-A22B"],
|
||||
name: "CoClaw"
|
||||
},
|
||||
{
|
||||
api_base_url: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
capabilities: [
|
||||
{ baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", type: "openai_chat_completions" }
|
||||
],
|
||||
credentials: [{ apiKey: "opencode-key", id: "opencode-main" }],
|
||||
models: ["kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash"],
|
||||
name: "OpenCode"
|
||||
},
|
||||
{
|
||||
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
capabilities: [
|
||||
{ baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "openai_chat_completions" },
|
||||
{ baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "anthropic_messages" }
|
||||
],
|
||||
credentials: [{ apiKey: "zhipu-key", id: "test-1" }],
|
||||
models: ["glm-5.2", "glm-4.5-air", "glm-5v-turbo"],
|
||||
name: providerName,
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
],
|
||||
Router: {
|
||||
fallback: { mode: "retry", models: [], retryCount: 1 },
|
||||
rules: [
|
||||
{
|
||||
condition: { left: "request.url", operator: "contains", right: "/v1" },
|
||||
enabled: true,
|
||||
id: "rule-default",
|
||||
name: "Default route",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "OpenCode" },
|
||||
{ key: "request.body.model", operation: "set", value: "deepseek-v4-flash" }
|
||||
],
|
||||
type: "condition"
|
||||
},
|
||||
{
|
||||
condition: { left: "request.header.anthropic-background", operator: "==", right: "true" },
|
||||
enabled: true,
|
||||
id: "rule-background",
|
||||
name: "Background tasks",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "CoClaw" },
|
||||
{ key: "request.body.model", operation: "set", value: "Qwen3-235B-A22B" }
|
||||
],
|
||||
type: "condition"
|
||||
},
|
||||
{
|
||||
condition: { left: "request.header.anthropic-thinking", operator: "==", right: "true" },
|
||||
enabled: true,
|
||||
id: "rule-thinking",
|
||||
name: "Thinking/reasoning tasks",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "OpenCode" },
|
||||
{ key: "request.body.model", operation: "set", value: "deepseek-v4-pro" }
|
||||
],
|
||||
type: "condition"
|
||||
}
|
||||
]
|
||||
},
|
||||
gateway: { coreHost: "127.0.0.1", corePort: 3457 },
|
||||
profile: {
|
||||
enabled: true,
|
||||
profiles: [
|
||||
{
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
id: "claude-code-main",
|
||||
model: "kimi-k2.6",
|
||||
name: "Claude Code",
|
||||
scope: "global"
|
||||
}
|
||||
]
|
||||
},
|
||||
virtualModelProfiles: [
|
||||
{
|
||||
baseModel: { fixedModel: `${providerName}/glm-5.2`, mode: "fixed" },
|
||||
displayName: "GLM 5 2V",
|
||||
enabled: true,
|
||||
execution: {
|
||||
clientToolsPolicy: "allow",
|
||||
matchMultimodal: true,
|
||||
maxToolCalls: 8,
|
||||
maxTurns: 6,
|
||||
mode: "tool_loop",
|
||||
streamMode: "buffered"
|
||||
},
|
||||
id: "glm-5.2v",
|
||||
key: "glm-5.2v",
|
||||
match: { exactAliases: ["GLM-5.2V", "Fusion/GLM-5.2V"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true },
|
||||
metadata: {
|
||||
fusionVision: {
|
||||
modelSelector: `${providerName}/glm-5v-turbo`,
|
||||
toolName: "vision_understand_glm_5_2v"
|
||||
}
|
||||
},
|
||||
tools: [{ name: "vision_understand_glm_5_2v", visibility: "internal" }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const profiles = normalizeCoreGatewayVirtualModelProfiles(config.virtualModelProfiles, config);
|
||||
const artifacts = await fusionBuiltinToolArtifactsForTest(profiles, "http://127.0.0.1:3457", "core-token");
|
||||
const server = artifacts.mcpServers.find((item) => item.name === "fusion-vision-glm-5.2v");
|
||||
|
||||
assert.ok(server);
|
||||
assert.equal(server.env.VISION_GATEWAY_BASE_URL, "http://127.0.0.1:3457/v1");
|
||||
assert.equal(server.env.VISION_GATEWAY_API_KEY, "core-token");
|
||||
assert.equal(server.env.VISION_API_KEY, undefined);
|
||||
assert.match(
|
||||
server.env.VISION_MODEL,
|
||||
/^provider-zhipu-ai-china---coding-plan-[a-f0-9]{10}::openai_chat_completions::cred:test-1\/glm-5v-turbo$/
|
||||
);
|
||||
});
|
||||
|
||||
test("gateway config does not inject core auth token into external Fusion vision MCP runtime", async () => {
|
||||
const profiles = [
|
||||
{
|
||||
enabled: true,
|
||||
id: "external-vision",
|
||||
key: "external-vision",
|
||||
metadata: {
|
||||
fusionVision: {
|
||||
apiKey: "external-key",
|
||||
baseUrl: "https://vision.example/v1",
|
||||
model: "gpt-vision",
|
||||
toolName: "vision_understand_external"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const artifacts = await fusionBuiltinToolArtifactsForTest(profiles, "http://127.0.0.1:3457", "core-token");
|
||||
const server = artifacts.mcpServers.find((item) => item.name === "fusion-vision-external-vision");
|
||||
|
||||
assert.ok(server);
|
||||
assert.equal(server.env.VISION_BASE_URL, "https://vision.example/v1");
|
||||
assert.equal(server.env.VISION_API_KEY, "external-key");
|
||||
assert.equal(server.env.VISION_GATEWAY_API_KEY, undefined);
|
||||
});
|
||||
|
||||
test("gateway ignores non-Gemini capabilities on Gemini preset providers", () => {
|
||||
const providerName = "Google Gemini";
|
||||
const config = {
|
||||
|
|
@ -404,6 +563,24 @@ test("gateway config does not create fallback tools for MCP-backed Fusion tools"
|
|||
assert.deepEqual(definitions.map((definition) => definition.name), ["missing_fusion_tool"]);
|
||||
});
|
||||
|
||||
test("gateway fallback explains In-app Browser web search requires Desktop integration", () => {
|
||||
const profiles = [
|
||||
{
|
||||
enabled: true,
|
||||
metadata: {
|
||||
fusionWebSearch: { provider: "browser", toolName: "fusion_2_web_search" }
|
||||
},
|
||||
tools: [{ description: "Browser search", name: "fusion_2_web_search", visibility: "internal" }]
|
||||
}
|
||||
];
|
||||
|
||||
const definitions = fusionFallbackToolDefinitions(profiles);
|
||||
|
||||
assert.equal(definitions[0].name, "fusion_2_web_search");
|
||||
assert.match(definitions[0].unavailableMessage, /requires CCR Desktop/);
|
||||
assert.match(definitions[0].unavailableMessage, /switch the Fusion web search provider/);
|
||||
});
|
||||
|
||||
test("gateway response injects Anthropic web search protocol blocks into JSON responses", () => {
|
||||
const response = {
|
||||
content: [
|
||||
|
|
@ -792,6 +969,24 @@ test("gateway injects prefetched web search evidence into OpenAI chat requests",
|
|||
assert.match(parsed.messages[0].content, /hidden in-app browser web search/);
|
||||
});
|
||||
|
||||
test("gateway includes browser extraction diagnostics in hosted web search evidence", () => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
messages: [{ role: "user", content: "Perform a web search for the query: today gold price per ounce USD July 2026" }],
|
||||
model: "Fusion/kimisearch",
|
||||
tools: [{ type: "web_search_preview" }]
|
||||
}));
|
||||
const record = sampleSearchRecord();
|
||||
record.results[0].diagnostics = ["Page extraction failed: Browser search navigation timed out."];
|
||||
|
||||
const transformed = prepareHostedWebSearchProtocolRequestBody(body, [record], {
|
||||
protocol: "openai_chat_completions",
|
||||
queryHint: "today gold price per ounce USD July 2026"
|
||||
});
|
||||
const parsed = JSON.parse(transformed.toString("utf8"));
|
||||
|
||||
assert.match(parsed.messages[0].content, /Diagnostics: Page extraction failed/);
|
||||
});
|
||||
|
||||
test("gateway hosted web search rewrites preserve custom web_search-named tools", () => {
|
||||
const anthropicBody = Buffer.from(JSON.stringify({
|
||||
messages: [{ role: "user", content: "search docs" }],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
newApiKeyUsageFallbackMessageForTest,
|
||||
newApiKeyUsageMetersForTest,
|
||||
newApiUserSelfMetersForTest
|
||||
} from "../../packages/core/src/providers/account-service.ts";
|
||||
import { detectedProviderFromHeaders, newApiKeyUsageAccountConfig, newApiUserSelfConnectorConfig } from "../../packages/core/src/providers/new-api.ts";
|
||||
import { isProviderProtocolEndpointSupportedForProbe } from "../../packages/core/src/providers/probe.ts";
|
||||
|
||||
test("protocol support probe does not treat Gemini auth errors as every protocol", () => {
|
||||
|
|
@ -62,3 +68,64 @@ test("protocol support probe treats Gemini Interactions input validation as Inte
|
|||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("New API response headers enable key quota account connector", () => {
|
||||
assert.equal(detectedProviderFromHeaders({ "X-New-Api-Version": "0.8.0" }), "new-api");
|
||||
assert.equal(detectedProviderFromHeaders({ "x-oneapi-request-id": "req-1" }), "new-api");
|
||||
assert.equal(detectedProviderFromHeaders({ "content-type": "application/json" }), undefined);
|
||||
|
||||
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
|
||||
const connector = account.connectors?.[0];
|
||||
assert.equal(account.enabled, true);
|
||||
assert.equal(connector?.type, "http-json");
|
||||
assert.equal(connector?.endpoint, "https://gateway.example/api/usage/token/");
|
||||
assert.equal(connector?.parser, "new-api-key-usage");
|
||||
assert.equal(connector?.mapping.meters[0]?.kind, "quota");
|
||||
assert.equal(connector?.mapping.meters[0]?.remaining, "$.data.total_available");
|
||||
assert.equal(connector?.mapping.meters[0]?.limit, "$.data.total_granted");
|
||||
assert.equal(connector?.mapping.meters[0]?.used, "$.data.total_used");
|
||||
|
||||
const userBalanceConnector = newApiUserSelfConnectorConfig("https://gateway.example/v1");
|
||||
assert.equal(userBalanceConnector.endpoint, "https://gateway.example/api/user/self");
|
||||
assert.equal(userBalanceConnector.parser, "new-api-user-self");
|
||||
assert.equal(userBalanceConnector.headers?.["New-Api-User"], "<user-id>");
|
||||
});
|
||||
|
||||
test("New API key usage parser ignores keys without a dedicated quota", () => {
|
||||
const payload = {
|
||||
code: true,
|
||||
data: {
|
||||
name: "default",
|
||||
total_available: 0,
|
||||
total_granted: 0,
|
||||
total_used: 0,
|
||||
unlimited_quota: true
|
||||
},
|
||||
message: "ok"
|
||||
};
|
||||
|
||||
assert.deepEqual(newApiKeyUsageMetersForTest(payload), []);
|
||||
assert.match(newApiKeyUsageFallbackMessageForTest(payload), /no dedicated quota/i);
|
||||
});
|
||||
|
||||
test("New API user self parser returns user balance", () => {
|
||||
const payload = {
|
||||
data: {
|
||||
quota: 1250,
|
||||
used_quota: 250
|
||||
},
|
||||
message: "",
|
||||
success: true
|
||||
};
|
||||
|
||||
assert.deepEqual(newApiUserSelfMetersForTest(payload), [{
|
||||
id: "new_api_user_balance",
|
||||
kind: "balance",
|
||||
label: "User balance",
|
||||
limit: 1500,
|
||||
remaining: 1250,
|
||||
source: "http-json",
|
||||
unit: "quota",
|
||||
used: 250
|
||||
}]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,13 +42,14 @@ function createRouterPlugin(options = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
function createIssue1480RouterConfig() {
|
||||
function createIssue1480UserConfig() {
|
||||
return {
|
||||
APIKEY: "gateway-key",
|
||||
CUSTOM_ROUTER_PATH: "",
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://nebulacoder.example/v1/chat/completions",
|
||||
credentials: [{ apiKey: "nebulacoder-key", id: "nebulacoder-main" }],
|
||||
api_key: "nebulacoder-key",
|
||||
modelDescriptions: {
|
||||
"nebulacoder-v8.0": "Code reading model.",
|
||||
"nebulacoder-cot-v8.0": "Code reading reasoning model."
|
||||
|
|
@ -58,7 +59,7 @@ function createIssue1480RouterConfig() {
|
|||
},
|
||||
{
|
||||
api_base_url: "https://coclaw.example/v1/chat/completions",
|
||||
credentials: [{ apiKey: "coclaw-key", id: "coclaw-main" }],
|
||||
api_key: "coclaw-key",
|
||||
modelDescriptions: {
|
||||
"Qwen3-235B-A22B": "Background task model."
|
||||
},
|
||||
|
|
@ -67,24 +68,43 @@ function createIssue1480RouterConfig() {
|
|||
},
|
||||
{
|
||||
api_base_url: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
api_key: "opencode-key",
|
||||
capabilities: [
|
||||
{ baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", type: "openai_chat_completions" }
|
||||
],
|
||||
credentials: [{ apiKey: "opencode-key", id: "opencode-main" }],
|
||||
modelDescriptions: {
|
||||
"glm-5.1": "Previous generation flagship model.",
|
||||
"glm-5.2": "Flagship model.",
|
||||
"deepseek-v4-flash": "Default route model.",
|
||||
"deepseek-v4-pro": "Thinking route model.",
|
||||
"kimi-k2.6": "Claude Code profile model."
|
||||
"kimi-k2.6": "Claude Code profile model.",
|
||||
"kimi-k2.7": "Fast model.",
|
||||
"mimo-v2.5": "Lightweight model.",
|
||||
"mimo-v2.5-pro": "Enhanced lightweight model."
|
||||
},
|
||||
models: ["kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash"],
|
||||
models: [
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"kimi-k2.7",
|
||||
"kimi-k2.6",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-pro"
|
||||
],
|
||||
name: "OpenCode"
|
||||
},
|
||||
{
|
||||
api_base_url: "https://reasoning.example/v1/chat/completions",
|
||||
api_key: "reasoning-key",
|
||||
modelDescriptions: {
|
||||
"sap-glm-2-2": "Reasoning model."
|
||||
},
|
||||
models: ["sap-glm-2-2"],
|
||||
name: "Reasoning"
|
||||
}
|
||||
],
|
||||
Router: {
|
||||
builtInRules: {
|
||||
"claude-code": { enabled: true },
|
||||
codex: { enabled: true }
|
||||
},
|
||||
fallback: { mode: "retry", models: [], retryCount: 1 },
|
||||
rules: [
|
||||
{
|
||||
|
|
@ -134,6 +154,13 @@ function createIssue1480RouterConfig() {
|
|||
}
|
||||
]
|
||||
},
|
||||
gateway: {
|
||||
coreHost: "0.0.0.0",
|
||||
corePort: 3457,
|
||||
enabled: true,
|
||||
host: "0.0.0.0",
|
||||
port: 3456
|
||||
},
|
||||
profile: {
|
||||
enabled: true,
|
||||
profiles: [
|
||||
|
|
@ -147,7 +174,68 @@ function createIssue1480RouterConfig() {
|
|||
}
|
||||
]
|
||||
},
|
||||
virtualModelProfiles: []
|
||||
providerPlugins: [
|
||||
{
|
||||
enabled: true,
|
||||
key: "opencode",
|
||||
opencode: { enabled: true },
|
||||
provider: "openai"
|
||||
}
|
||||
],
|
||||
virtualModelProfiles: [
|
||||
{
|
||||
description: "Automatically search the web using Tavily",
|
||||
displayName: "Fusion Tavily",
|
||||
enabled: true,
|
||||
execution: {
|
||||
clientToolsPolicy: "allow",
|
||||
matchWebSearch: true,
|
||||
maxToolCalls: 10,
|
||||
maxTurns: 20,
|
||||
mode: "tool_loop",
|
||||
streamMode: "optimistic"
|
||||
},
|
||||
id: "fusion-tavily-web-search",
|
||||
key: "fusion-tavily",
|
||||
match: {
|
||||
exactAliases: [],
|
||||
prefixes: [],
|
||||
suffixes: []
|
||||
},
|
||||
materialization: {
|
||||
enabled: true,
|
||||
includeInGatewayModels: true
|
||||
},
|
||||
metadata: {
|
||||
fusionVision: {
|
||||
apiKey: "opencode-key",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
model: "kimi-k2.6",
|
||||
toolName: "vision_understand"
|
||||
},
|
||||
fusionWebSearch: {
|
||||
env: {
|
||||
TAVILY_API_KEY: "tavily-key"
|
||||
},
|
||||
provider: "tavily",
|
||||
resultCount: 5,
|
||||
toolName: "web_search"
|
||||
}
|
||||
},
|
||||
tools: []
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue1480RouterConfig() {
|
||||
const config = createIssue1480UserConfig();
|
||||
return {
|
||||
...config,
|
||||
Providers: config.Providers.map((provider) => ({
|
||||
...provider,
|
||||
credentials: [{ apiKey: provider.api_key, id: `${provider.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-main` }]
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +322,8 @@ test("built-in Claude Code route injects ToolHub resolver instructions when Tool
|
|||
model: "claude-default",
|
||||
tools: [
|
||||
{ name: "tool_hub.resolve", input_schema: { type: "object" } },
|
||||
{ name: "mcp__ccr-toolhub__tool_hub.invoke", input_schema: { type: "object" } }
|
||||
{ name: "mcp__ccr-toolhub__tool_hub_resolve", input_schema: { type: "object" } },
|
||||
{ name: "mcp__ccr-toolhub__tool_hub_invoke", input_schema: { type: "object" } }
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
|
|
@ -245,10 +334,13 @@ test("built-in Claude Code route injects ToolHub resolver instructions when Tool
|
|||
});
|
||||
|
||||
assert.match(result.body.system.at(-1).text, /CCR ToolHub tool resolution is enabled/);
|
||||
assert.match(result.body.system.at(-1).text, /MUST call tool_hub\.resolve before answering/);
|
||||
assert.match(result.body.system.at(-1).text, /ToolHub search\/resolution tool is mcp__ccr-toolhub__tool_hub_resolve/);
|
||||
assert.match(result.body.system.at(-1).text, /call this actual tool, do not merely mention its name in text/);
|
||||
assert.match(result.body.system.at(-1).text, /MUST call the ToolHub search\/resolution tool mcp__ccr-toolhub__tool_hub_resolve before answering/);
|
||||
assert.match(result.body.system.at(-1).text, /external services.*business APIs.*orders.*coupons.*stores.*accounts/);
|
||||
assert.match(result.body.system.at(-1).text, /Only skip tool_hub\.resolve when the request is clearly local/);
|
||||
assert.match(result.body.system.at(-1).text, /use mcp__ccr-toolhub__tool_hub\.invoke/);
|
||||
assert.match(result.body.system.at(-1).text, /Only skip the ToolHub search\/resolution tool when the request is clearly local/);
|
||||
assert.match(result.body.system.at(-1).text, /call the ToolHub invocation tool mcp__ccr-toolhub__tool_hub_invoke/);
|
||||
assert.match(result.body.system.at(-1).text, /executionPlanJs.*Promise\.all/);
|
||||
});
|
||||
|
||||
test("built-in Claude Code route does not inject ToolHub instructions without the resolve tool", async () => {
|
||||
|
|
@ -363,6 +455,83 @@ test("router rules can add headers after the built-in Claude Code profile route"
|
|||
assert.equal(result.decision.reason, "rule:target-provider");
|
||||
});
|
||||
|
||||
test("issue 1480 raw user config no longer reproduces the Claude Code profile routing failure", async () => {
|
||||
const config = createIssue1480UserConfig();
|
||||
const plugin = new ClaudeCodeRouterPlugin(config);
|
||||
const headers = {
|
||||
"user-agent": "claude-cli/2.1.187 (external, cli)"
|
||||
};
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "claude-sonnet-4-6",
|
||||
tools: []
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages?beta=true"
|
||||
});
|
||||
|
||||
assert.equal(headers["x-target-provider"], "OpenCode");
|
||||
assert.equal(result.body.model, "deepseek-v4-flash");
|
||||
assert.equal(result.decision.model, "deepseek-v4-flash");
|
||||
assert.equal(result.decision.reason, "rule:rule-default");
|
||||
});
|
||||
|
||||
test("issue 1480 raw user config reproduces the old failure precondition when Router rules are skipped", async () => {
|
||||
const config = createIssue1480UserConfig();
|
||||
const plugin = new ClaudeCodeRouterPlugin({
|
||||
...config,
|
||||
Router: {
|
||||
...config.Router,
|
||||
rules: []
|
||||
}
|
||||
});
|
||||
const headers = {
|
||||
"user-agent": "claude-cli/2.1.187 (external, cli)"
|
||||
};
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "claude-sonnet-4-6",
|
||||
tools: []
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages?beta=true"
|
||||
});
|
||||
|
||||
assert.equal(headers["x-target-provider"], undefined);
|
||||
assert.equal(result.body.model, "kimi-k2.6");
|
||||
assert.equal(result.decision.model, "kimi-k2.6");
|
||||
assert.equal(result.decision.reason, "builtin:claude-code");
|
||||
});
|
||||
|
||||
test("issue 1480 raw user config ignores an unreplaced Provider/model subagent placeholder", async () => {
|
||||
const config = createIssue1480UserConfig();
|
||||
const plugin = new ClaudeCodeRouterPlugin(config);
|
||||
const headers = {
|
||||
"user-agent": "claude-cli/2.1.187 (external, cli)"
|
||||
};
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "claude-sonnet-4-6",
|
||||
system: "Use <CCR-SUBAGENT-MODEL>provider/model</CCR-SUBAGENT-MODEL> for this subagent.",
|
||||
tools: []
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages?beta=true"
|
||||
});
|
||||
|
||||
assert.equal(headers["x-target-provider"], "OpenCode");
|
||||
assert.equal(result.body.model, "deepseek-v4-flash");
|
||||
assert.equal(result.body.system, "Use for this subagent.");
|
||||
assert.equal(result.decision.model, "deepseek-v4-flash");
|
||||
assert.equal(result.decision.reason, "rule:rule-default");
|
||||
});
|
||||
|
||||
test("issue 1480 config routes Claude Code profile traffic through the user default OpenCode rule", async () => {
|
||||
const config = createIssue1480RouterConfig();
|
||||
const plugin = new ClaudeCodeRouterPlugin(config);
|
||||
|
|
@ -850,6 +1019,27 @@ test("built-in Claude Code subagent route uses model tag from system", async ()
|
|||
assert.equal(result.decision.reason, "builtin:claude-code-subagent");
|
||||
});
|
||||
|
||||
test("built-in Claude Code subagent route ignores the Provider/model placeholder", async () => {
|
||||
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "claude-default",
|
||||
system: "Use <CCR-SUBAGENT-MODEL>Provider/model</CCR-SUBAGENT-MODEL> for this subagent."
|
||||
},
|
||||
headers: {
|
||||
"user-agent": "Claude Code"
|
||||
},
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(result.body.model, "Provider/claude-sonnet");
|
||||
assert.equal(result.body.system, "Use for this subagent.");
|
||||
assert.equal(result.decision.model, "Provider/claude-sonnet");
|
||||
assert.equal(result.decision.reason, "builtin:claude-code");
|
||||
});
|
||||
|
||||
test("built-in Claude Code route removes the first billing system block before subagent tag extraction", async () => {
|
||||
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
|
||||
const result = await plugin.routeRequest({
|
||||
|
|
|
|||
72
tests/main/toolhub-browser-automation-config.test.mjs
Normal file
72
tests/main/toolhub-browser-automation-config.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
|
||||
import {
|
||||
BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS,
|
||||
BROWSER_AUTOMATION_MCP_PATH,
|
||||
BROWSER_AUTOMATION_MCP_SERVER_NAME,
|
||||
browserAutomationMcpEnabled,
|
||||
toolHubMcpRuntimeConfig
|
||||
} from "../../packages/core/src/mcp/toolhub-config.ts";
|
||||
|
||||
test("ToolHub runtime includes the built-in browser automation backend", () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.toolHub = {
|
||||
...config.toolHub,
|
||||
browserAutomation: true,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const runtime = toolHubMcpRuntimeConfig(config, undefined, {
|
||||
resolver: {
|
||||
apiKey: "ccr-profile-test",
|
||||
baseUrl: "http://127.0.0.1:3456/v1",
|
||||
model: "Provider/model"
|
||||
}
|
||||
});
|
||||
assert.ok(runtime);
|
||||
|
||||
const servers = JSON.parse(runtime.env.TOOLHUB_MCP_SERVERS_JSON);
|
||||
const browserAutomation = servers.find((server) => server.name === BROWSER_AUTOMATION_MCP_SERVER_NAME);
|
||||
assert.ok(browserAutomation);
|
||||
assert.equal(browserAutomation.apiKey, "ccr-profile-test");
|
||||
assert.equal(browserAutomation.requestTimeoutMs, BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS);
|
||||
assert.equal(browserAutomation.transport, "streamable-http");
|
||||
assert.equal(browserAutomation.url, `http://127.0.0.1:${config.gateway.port}${BROWSER_AUTOMATION_MCP_PATH}`);
|
||||
assert.equal(runtime.env.TOOLHUB_REQUEST_TIMEOUT_MS, String(BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS));
|
||||
});
|
||||
|
||||
test("ToolHub browser automation backend uses a connectable loopback host", () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.gateway.host = "0.0.0.0";
|
||||
config.toolHub = {
|
||||
...config.toolHub,
|
||||
browserAutomation: true,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const runtime = toolHubMcpRuntimeConfig(config, undefined, {
|
||||
resolver: {
|
||||
apiKey: "ccr-profile-test",
|
||||
baseUrl: "http://127.0.0.1:3456/v1",
|
||||
model: "Provider/model"
|
||||
}
|
||||
});
|
||||
assert.ok(runtime);
|
||||
|
||||
const servers = JSON.parse(runtime.env.TOOLHUB_MCP_SERVERS_JSON);
|
||||
const browserAutomation = servers.find((server) => server.name === BROWSER_AUTOMATION_MCP_SERVER_NAME);
|
||||
assert.equal(browserAutomation.url, `http://127.0.0.1:${config.gateway.port}${BROWSER_AUTOMATION_MCP_PATH}`);
|
||||
});
|
||||
|
||||
test("ToolHub runtime skips built-in browser automation until enabled", () => {
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
|
||||
config.toolHub = {
|
||||
...config.toolHub,
|
||||
browserAutomation: false,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
assert.equal(browserAutomationMcpEnabled(config), false);
|
||||
assert.equal(toolHubMcpRuntimeConfig(config), undefined);
|
||||
});
|
||||
|
|
@ -54,6 +54,7 @@ test("built ToolHub MCP runtime accepts newline JSON stdio used by MCP Inspector
|
|||
const resolveTool = message.result.tools.find((tool) => tool.name === "tool_hub.resolve");
|
||||
assert.match(resolveTool.description, /MUST be called before answering/);
|
||||
assert.match(resolveTool.description, /external services.*business APIs.*orders.*coupons.*stores.*accounts/);
|
||||
assert.match(resolveTool.description, /executionPlanJs/);
|
||||
child.kill();
|
||||
resolve();
|
||||
return;
|
||||
|
|
@ -176,10 +177,368 @@ test("built ToolHub MCP runtime waits for local CCR resolver readiness", async (
|
|||
assert.equal(response.error, undefined);
|
||||
assert.deepEqual(response.result.selectedToolNames, ["mcp.mcd_mcp.campaign-calendar"]);
|
||||
assert.deepEqual(response.result.structuredContent.selectedToolNames, ["mcp.mcd_mcp.campaign-calendar"]);
|
||||
assert.match(response.result.executionPlanInstructions, /Promise\.all/);
|
||||
assert.match(response.result.executionPlanJs, /await callTool\("mcp\.mcd_mcp\.campaign-calendar"/);
|
||||
assert.equal(response.result.workflowSketch, response.result.executionPlanJs);
|
||||
assert.match(response.result.structuredContent.executionPlanJs, /mcp\.mcd_mcp\.campaign-calendar/);
|
||||
assert.equal(response.result.content[0].type, "text");
|
||||
assert.match(response.result.content[0].text, /mcp\.mcd_mcp\.campaign-calendar/);
|
||||
});
|
||||
|
||||
test("built ToolHub MCP runtime expands browser automation bundles with handoff tools", async (t) => {
|
||||
const runtime = toolHubRuntimePath();
|
||||
if (!existsSync(runtime)) {
|
||||
t.skip("ToolHub MCP runtime has not been built.");
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = createMcpHttpServer({
|
||||
serverName: "ccr-browser-automation",
|
||||
tools: [
|
||||
{
|
||||
description: "Open a URL or attach an existing CCR built-in browser tab and create an automation session.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_session_open"
|
||||
},
|
||||
{
|
||||
description: "Request human intervention for the current browser task.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_handoff_request"
|
||||
},
|
||||
{
|
||||
description: "Read the current browser human handoff status.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_handoff_status"
|
||||
},
|
||||
{
|
||||
description: "Wait until the user clicks Done or Hide on the current browser handoff toolbar.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_handoff_wait"
|
||||
}
|
||||
]
|
||||
});
|
||||
try {
|
||||
await listen(backend);
|
||||
} catch (error) {
|
||||
backend.close();
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const backendPort = backend.address().port;
|
||||
t.after(() => backend.close());
|
||||
|
||||
const resolver = createFixedResolverServer(["mcp.ccr_browser_automation.browser_session_open"]);
|
||||
try {
|
||||
await listen(resolver);
|
||||
} catch (error) {
|
||||
resolver.close();
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const resolverPort = resolver.address().port;
|
||||
t.after(() => resolver.close());
|
||||
|
||||
const child = spawn(process.execPath, [runtime], {
|
||||
env: {
|
||||
...process.env,
|
||||
TOOLHUB_MCP_SERVERS_JSON: JSON.stringify([
|
||||
{
|
||||
name: "ccr-browser-automation",
|
||||
transport: "streamable-http",
|
||||
url: `http://127.0.0.1:${backendPort}/mcp`
|
||||
}
|
||||
]),
|
||||
TOOLHUB_OPENAI_API_KEY: "test-key",
|
||||
TOOLHUB_OPENAI_BASE_URL: `http://127.0.0.1:${resolverPort}/v1`,
|
||||
TOOLHUB_OPENAI_MODEL: "resolver-model",
|
||||
TOOLHUB_REQUEST_TIMEOUT_MS: "10000"
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
t.after(() => child.kill());
|
||||
|
||||
const stderr = [];
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk.toString("utf8")));
|
||||
const reader = jsonLineReader(child);
|
||||
writeJsonLine(child, {
|
||||
id: 1,
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
capabilities: {},
|
||||
clientInfo: { name: "browser-handoff-bundle-test", version: "1.0.0" },
|
||||
protocolVersion: "2024-11-05"
|
||||
}
|
||||
});
|
||||
await reader.nextMessage(1);
|
||||
writeJsonLine(child, {
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
params: {}
|
||||
});
|
||||
writeJsonLine(child, {
|
||||
id: 2,
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "tool_hub.resolve",
|
||||
arguments: {
|
||||
constraints: { maxTools: 10 },
|
||||
task: "打开 Gmail,如果需要登录就让用户接管"
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await reader.nextMessage(2, 12_000).catch((error) => {
|
||||
error.message += ` stderr: ${stderr.join("")}`;
|
||||
throw error;
|
||||
});
|
||||
assert.equal(response.error, undefined);
|
||||
assert.deepEqual(response.result.selectedToolNames, [
|
||||
"mcp.ccr_browser_automation.browser_session_open",
|
||||
"mcp.ccr_browser_automation.browser_handoff_request",
|
||||
"mcp.ccr_browser_automation.browser_handoff_status",
|
||||
"mcp.ccr_browser_automation.browser_handoff_wait"
|
||||
]);
|
||||
assert.match(response.result.tsDefinitions, /browser_handoff_wait/);
|
||||
});
|
||||
|
||||
test("built ToolHub MCP runtime deterministically resolves Chrome login import tools", async (t) => {
|
||||
const runtime = toolHubRuntimePath();
|
||||
if (!existsSync(runtime)) {
|
||||
t.skip("ToolHub MCP runtime has not been built.");
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = createMcpHttpServer({
|
||||
serverName: "ccr-browser-automation",
|
||||
tools: [
|
||||
{
|
||||
description: "Ask the user to confirm importing Chrome cookies and localStorage into CCR's in-app browser.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_chrome_login_import"
|
||||
},
|
||||
{
|
||||
description: "Read the status of a Chrome login import job.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_chrome_login_import_status"
|
||||
}
|
||||
]
|
||||
});
|
||||
try {
|
||||
await listen(backend);
|
||||
} catch (error) {
|
||||
backend.close();
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const backendPort = backend.address().port;
|
||||
t.after(() => backend.close());
|
||||
|
||||
const resolver = createFixedResolverServer([]);
|
||||
try {
|
||||
await listen(resolver);
|
||||
} catch (error) {
|
||||
resolver.close();
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const resolverPort = resolver.address().port;
|
||||
t.after(() => resolver.close());
|
||||
|
||||
const child = spawn(process.execPath, [runtime], {
|
||||
env: {
|
||||
...process.env,
|
||||
TOOLHUB_MCP_SERVERS_JSON: JSON.stringify([
|
||||
{
|
||||
name: "ccr-browser-automation",
|
||||
transport: "streamable-http",
|
||||
url: `http://127.0.0.1:${backendPort}/mcp`
|
||||
}
|
||||
]),
|
||||
TOOLHUB_OPENAI_API_KEY: "test-key",
|
||||
TOOLHUB_OPENAI_BASE_URL: `http://127.0.0.1:${resolverPort}/v1`,
|
||||
TOOLHUB_OPENAI_MODEL: "resolver-model",
|
||||
TOOLHUB_REQUEST_TIMEOUT_MS: "10000"
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
t.after(() => child.kill());
|
||||
|
||||
const stderr = [];
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk.toString("utf8")));
|
||||
const reader = jsonLineReader(child);
|
||||
writeJsonLine(child, {
|
||||
id: 1,
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
capabilities: {},
|
||||
clientInfo: { name: "chrome-login-import-test", version: "1.0.0" },
|
||||
protocolVersion: "2024-11-05"
|
||||
}
|
||||
});
|
||||
await reader.nextMessage(1);
|
||||
writeJsonLine(child, {
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
params: {}
|
||||
});
|
||||
writeJsonLine(child, {
|
||||
id: 2,
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "tool_hub.resolve",
|
||||
arguments: {
|
||||
constraints: { maxTools: 10 },
|
||||
task: "把 Chrome 里 github.com 的登录态导入 CCR in-app browser"
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await reader.nextMessage(2, 12_000).catch((error) => {
|
||||
error.message += ` stderr: ${stderr.join("")}`;
|
||||
throw error;
|
||||
});
|
||||
assert.equal(response.error, undefined);
|
||||
assert.deepEqual(response.result.selectedToolNames, [
|
||||
"mcp.ccr_browser_automation.browser_chrome_login_import",
|
||||
"mcp.ccr_browser_automation.browser_chrome_login_import_status"
|
||||
]);
|
||||
assert.match(response.result.tsDefinitions, /browser_chrome_login_import/);
|
||||
});
|
||||
|
||||
test("built ToolHub MCP runtime does not add CCR handoff tools for non-CCR browser tools", async (t) => {
|
||||
const runtime = toolHubRuntimePath();
|
||||
if (!existsSync(runtime)) {
|
||||
t.skip("ToolHub MCP runtime has not been built.");
|
||||
return;
|
||||
}
|
||||
|
||||
const externalBrowser = createMcpHttpServer({
|
||||
serverName: "playwright",
|
||||
tools: [
|
||||
{
|
||||
description: "Navigate a browser page in an external automation backend.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "navigate",
|
||||
tags: ["browser"]
|
||||
}
|
||||
]
|
||||
});
|
||||
try {
|
||||
await listen(externalBrowser);
|
||||
} catch (error) {
|
||||
externalBrowser.close();
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const externalBrowserPort = externalBrowser.address().port;
|
||||
t.after(() => externalBrowser.close());
|
||||
|
||||
const ccrBrowser = createMcpHttpServer({
|
||||
serverName: "ccr-browser-automation",
|
||||
tools: [
|
||||
{
|
||||
description: "Request human intervention for the current browser task.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_handoff_request"
|
||||
},
|
||||
{
|
||||
description: "Read the current browser human handoff status.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_handoff_status"
|
||||
},
|
||||
{
|
||||
description: "Wait until the user clicks Done or Hide on the current browser handoff toolbar.",
|
||||
inputSchema: { type: "object" },
|
||||
name: "browser_handoff_wait"
|
||||
}
|
||||
]
|
||||
});
|
||||
try {
|
||||
await listen(ccrBrowser);
|
||||
} catch (error) {
|
||||
ccrBrowser.close();
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const ccrBrowserPort = ccrBrowser.address().port;
|
||||
t.after(() => ccrBrowser.close());
|
||||
|
||||
const resolver = createFixedResolverServer(["mcp.playwright.navigate"]);
|
||||
try {
|
||||
await listen(resolver);
|
||||
} catch (error) {
|
||||
resolver.close();
|
||||
t.skip(`Local HTTP listen is unavailable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const resolverPort = resolver.address().port;
|
||||
t.after(() => resolver.close());
|
||||
|
||||
const child = spawn(process.execPath, [runtime], {
|
||||
env: {
|
||||
...process.env,
|
||||
TOOLHUB_MCP_SERVERS_JSON: JSON.stringify([
|
||||
{
|
||||
name: "playwright",
|
||||
transport: "streamable-http",
|
||||
url: `http://127.0.0.1:${externalBrowserPort}/mcp`
|
||||
},
|
||||
{
|
||||
name: "ccr-browser-automation",
|
||||
transport: "streamable-http",
|
||||
url: `http://127.0.0.1:${ccrBrowserPort}/mcp`
|
||||
}
|
||||
]),
|
||||
TOOLHUB_OPENAI_API_KEY: "test-key",
|
||||
TOOLHUB_OPENAI_BASE_URL: `http://127.0.0.1:${resolverPort}/v1`,
|
||||
TOOLHUB_OPENAI_MODEL: "resolver-model",
|
||||
TOOLHUB_REQUEST_TIMEOUT_MS: "10000"
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
t.after(() => child.kill());
|
||||
|
||||
const stderr = [];
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk.toString("utf8")));
|
||||
const reader = jsonLineReader(child);
|
||||
writeJsonLine(child, {
|
||||
id: 1,
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
capabilities: {},
|
||||
clientInfo: { name: "external-browser-tag-test", version: "1.0.0" },
|
||||
protocolVersion: "2024-11-05"
|
||||
}
|
||||
});
|
||||
await reader.nextMessage(1);
|
||||
writeJsonLine(child, {
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
params: {}
|
||||
});
|
||||
writeJsonLine(child, {
|
||||
id: 2,
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "tool_hub.resolve",
|
||||
arguments: {
|
||||
constraints: { maxTools: 10 },
|
||||
task: "Use the external Playwright browser to navigate a page."
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await reader.nextMessage(2, 12_000).catch((error) => {
|
||||
error.message += ` stderr: ${stderr.join("")}`;
|
||||
throw error;
|
||||
});
|
||||
assert.equal(response.error, undefined);
|
||||
assert.deepEqual(response.result.selectedToolNames, ["mcp.playwright.navigate"]);
|
||||
});
|
||||
|
||||
test("built ToolHub MCP runtime keeps resolve cache scoped by task", async (t) => {
|
||||
const runtime = toolHubRuntimePath();
|
||||
if (!existsSync(runtime)) {
|
||||
|
|
@ -442,6 +801,36 @@ function createTaskAwareResolverServer() {
|
|||
});
|
||||
}
|
||||
|
||||
function createFixedResolverServer(toolNames) {
|
||||
return createServer(async (request, response) => {
|
||||
if (request.method === "GET" && request.url === "/v1/models") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ data: [], object: "list" }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && request.url === "/v1/chat/completions") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
summary: "ok",
|
||||
toolNames
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
id: "chatcmpl-fixed-test",
|
||||
object: "chat.completion"
|
||||
}));
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end("not found");
|
||||
});
|
||||
}
|
||||
|
||||
function readResolverQuery(payload) {
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts";
|
||||
import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts";
|
||||
import {
|
||||
applyProviderProbeResult,
|
||||
createProviderDraft,
|
||||
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
|
||||
providerProtocolOptions,
|
||||
providerProbeCandidates,
|
||||
setProviderPresets
|
||||
|
|
@ -71,3 +73,59 @@ test("provider probe result keeps only supported selected protocols", () => {
|
|||
|
||||
assert.deepEqual(next.selectedProtocols, ["gemini_generate_content"]);
|
||||
});
|
||||
|
||||
test("provider probe result applies detected New API key quota account connector", () => {
|
||||
const draft = {
|
||||
...createProviderDraft([]),
|
||||
accountEnabled: false,
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
protocol: "openai_chat_completions"
|
||||
};
|
||||
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
|
||||
|
||||
const next = applyProviderProbeResult(draft, {
|
||||
account,
|
||||
detectedProvider: "new-api",
|
||||
detectedProtocol: "openai_chat_completions",
|
||||
models: [],
|
||||
normalizedBaseUrl: draft.baseUrl,
|
||||
protocols: [
|
||||
{
|
||||
detectedProvider: "new-api",
|
||||
endpoint: "https://gateway.example/v1/chat/completions",
|
||||
message: "HTTP 401",
|
||||
protocol: "openai_chat_completions",
|
||||
status: 401,
|
||||
supported: true
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(next.accountEnabled, true);
|
||||
assert.equal(next.accountMode, "raw");
|
||||
const connectors = JSON.parse(next.accountConnectorsText);
|
||||
assert.equal(connectors.length, 1);
|
||||
assert.equal(connectors[0].endpoint, "https://gateway.example/api/usage/token/");
|
||||
assert.equal(connectors[0].parser, "new-api-key-usage");
|
||||
assert.equal(connectors[0].mapping.meters[0].id, "new_api_key_quota");
|
||||
assert.equal(connectors[0].mapping.meters[0].kind, "quota");
|
||||
assert.equal(connectors[0].mapping.meters[0].remaining, "$.data.total_available");
|
||||
});
|
||||
|
||||
test("New API user balance template adds configurable user self connector", () => {
|
||||
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
|
||||
const text = providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
|
||||
JSON.stringify(account.connectors ?? [], null, 2),
|
||||
"https://gateway.example/v1",
|
||||
"42"
|
||||
);
|
||||
const connectors = JSON.parse(text);
|
||||
|
||||
assert.equal(connectors.length, 2);
|
||||
assert.equal(connectors[0].parser, "new-api-key-usage");
|
||||
assert.equal(connectors[1].endpoint, "https://gateway.example/api/user/self");
|
||||
assert.equal(connectors[1].parser, "new-api-user-self");
|
||||
assert.equal(connectors[1].headers.Authorization, "Bearer <new-api-access-token>");
|
||||
assert.equal(connectors[1].headers["New-Api-User"], "42");
|
||||
assert.equal(connectors[1].mapping.meters[0].id, "new_api_user_balance");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue