mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
788572ab5a | ||
|
|
4de96d75bc | ||
|
|
72017b6a5c | ||
|
|
2574e13fe7 | ||
|
|
06b16dde9a | ||
|
|
ec4362531f | ||
|
|
414b7d1f4b | ||
|
|
7a679a51fa |
+9
-1
@@ -95,6 +95,12 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
|
||||
開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
|
||||
|
||||
ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runtime とロールバック経路のままで、**設定 → Gateway → Runtime** から任意の同梱 `cc-connect` runtime に切り替えられます。パッケージ版は cc-connect バイナリと OpenAI Codex ネイティブ CLI bundle の両方を app resources に含め、runtime 起動はグローバルインストール、PATH 上のバイナリ、起動時ダウンロードに依存しません。cc-connect は `~/.cc-connect` を自動変更せず、app userData 配下の ClawX 管理ディレクトリを使います。GUI chat は cc-connect BridgePlatform 経由で Codex project agent に接続し、provider/model、cron、enabled skills は管理された cc-connect/Codex runtime に同期されます。
|
||||
|
||||
cc-connect mode では、Codex provider sync は OpenAI API key、OpenAI OAuth/Codex、Ollama、および Responses API を公開する OpenAI-compatible Custom provider をサポートします。ByteDance ModelHub-compatible Custom provider は Codex が使う `/api/modelhub/online` Responses endpoint に正規化され、管理 config には環境変数形式の header 参照だけを書き込むため、secret や sticky-session header は永続化されません。Chat Completions として設定された Custom provider は、Codex 0.137 が Responses wire API のみを受け付けるため、chat 配信前に unsupported として報告されます。
|
||||
|
||||
cc-connect はメッセージング platform bridge も担当します。cc-connect が active runtime の場合、channel status probe は OpenClaw Gateway に固定せず runtime abstraction 経由でルーティングされ、設定済み channel account は cc-connect project platform blocks にミラーされます。channel の保存や削除では管理 cc-connect runtime を再起動し、platform 変更を反映します。Developer Mode のサイドバーのページショートカットは cc-connect Web Admin を開きます。
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
@@ -123,6 +129,7 @@ AIタスクを自動的に実行するようスケジュール設定できます
|
||||
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
|
||||
ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills`、`self-improving-agent`、`tavily-search`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
|
||||
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
|
||||
cc-connect runtime が有効な場合、有効化されたローカル skills は app userData 配下の管理 Codex home にミラーされ、同梱 Codex agent がグローバル skill ディレクトリを読まずに同じ skill セットを使えます。
|
||||
|
||||
主な検索スキルで必要な環境変数:
|
||||
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
|
||||
@@ -184,7 +191,7 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
|
||||
|
||||
### プロキシ設定
|
||||
|
||||
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
|
||||
ClawXには、Electron、OpenClaw Gateway、任意の cc-connect/Codex runtime、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
|
||||
|
||||
**設定 → ゲートウェイ → プロキシ**を開いて以下を設定します:
|
||||
|
||||
@@ -205,6 +212,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
||||
- `host:port`のみの値はHTTPとして扱われます。
|
||||
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
|
||||
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
|
||||
- cc-connect runtime モードでは、Codex 子プロセスが同じ `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、バイパス環境値を継承します。
|
||||
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
|
||||
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
|
||||
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
|
||||
|
||||
@@ -95,6 +95,12 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
|
||||
|
||||
When Developer Mode is enabled, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
|
||||
|
||||
ClawX also includes a runtime abstraction layer. OpenClaw remains the default runtime and rollback path, while **Settings → Gateway → Runtime** can switch to an optional bundled `cc-connect` runtime. Packaged builds include both the cc-connect binary and the native OpenAI Codex CLI bundle in app resources; runtime startup does not depend on global installs, PATH binaries, or app-time downloads. cc-connect uses a ClawX-managed configuration directory under app user data instead of modifying `~/.cc-connect`, and GUI chat connects through cc-connect BridgePlatform with Codex as the project agent. Provider/model selections, cron tasks, and enabled skills are synchronized into the managed cc-connect/Codex runtime.
|
||||
|
||||
In cc-connect mode, Codex provider sync supports OpenAI API key, OpenAI OAuth/Codex, Ollama, and Custom OpenAI-compatible providers that expose the Responses API. ByteDance ModelHub-compatible Custom providers are normalized to Codex's `/api/modelhub/online` Responses endpoint and written with environment-variable header references so secrets and sticky-session headers are not persisted in managed config files. Custom providers configured for Chat Completions are reported as unsupported before chat delivery because Codex 0.137 accepts only the Responses wire API.
|
||||
|
||||
cc-connect also owns messaging platform bridges. When cc-connect is the active runtime, channel status probes are routed through the runtime abstraction instead of the OpenClaw Gateway, configured channel accounts are mirrored into cc-connect project platform blocks, and channel saves/deletes restart the managed cc-connect runtime so platform changes take effect. The Developer Mode sidebar page shortcut opens cc-connect Web Admin.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
@@ -123,6 +129,7 @@ The Cron page now lets you configure external delivery directly in the task form
|
||||
Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed/workspace skill directories, lets you enable or disable skills without depending on the Gateway, and can optionally expose an extension-provided marketplace in enterprise builds.
|
||||
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime.
|
||||
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds now ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both dev and packaged startup, and any stale `openclaw.json` entries left behind for those removed bundled skills are pruned.
|
||||
When cc-connect runtime is active, enabled local skills are mirrored into the managed Codex home under app user data so the bundled Codex agent can use the same skill set without reading global skill directories.
|
||||
|
||||
Environment variables for bundled search skills:
|
||||
- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime)
|
||||
@@ -188,7 +195,7 @@ The wizard preselects your system language when it is supported, and falls back
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client.
|
||||
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, the optional cc-connect/Codex runtime, or channels such as Telegram need to reach the internet through a local proxy client.
|
||||
|
||||
Open **Settings → Gateway → Proxy** and configure:
|
||||
|
||||
@@ -209,6 +216,7 @@ Notes:
|
||||
- A bare `host:port` value is treated as HTTP.
|
||||
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
|
||||
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
|
||||
- In cc-connect runtime mode, Codex child processes inherit the same `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and bypass environment values.
|
||||
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
|
||||
- Gateway restarts preserve an existing Telegram channel proxy if ClawX proxy is currently disabled.
|
||||
- To explicitly clear Telegram channel proxy from OpenClaw config, save proxy settings with proxy disabled.
|
||||
|
||||
+9
-1
@@ -96,6 +96,12 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
|
||||
打开开发者模式后,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
|
||||
|
||||
ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会同时内置 cc-connect 二进制和 OpenAI Codex 原生 CLI bundle;runtime 启动不依赖全局安装、PATH 二进制或运行时下载。cc-connect 使用 ClawX 在 app userData 下托管的配置目录,不会自动修改 `~/.cc-connect`,GUI chat 会通过 cc-connect BridgePlatform 连接到 Codex project agent。Provider/model、cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。
|
||||
|
||||
在 cc-connect 模式下,Codex provider 同步支持 OpenAI API Key、OpenAI OAuth/Codex、Ollama,以及暴露 Responses API 的 OpenAI-compatible Custom provider。ByteDance ModelHub-compatible Custom provider 会被规范化到 Codex 使用的 `/api/modelhub/online` Responses endpoint,并且托管配置中只写入环境变量形式的 header 引用,避免持久化密钥或 sticky-session header。配置为 Chat Completions 的 Custom provider 会在 chat 投递前被明确标记为不支持,因为 Codex 0.137 只接受 Responses wire API。
|
||||
|
||||
cc-connect 也负责消息平台桥接。当 cc-connect 是当前 runtime 时,频道状态探测会通过 runtime 抽象层路由,而不是继续固定查询 OpenClaw Gateway;已配置的频道账号会同步为 cc-connect project platform blocks,频道保存/删除会重启托管 cc-connect runtime,让 platform 变更立即生效;开发者模式侧边栏的页面入口会打开 cc-connect Web Admin。
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
@@ -124,6 +130,7 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
|
||||
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。
|
||||
ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills`、`self-improving-agent`、`tavily-search`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
|
||||
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
|
||||
当 cc-connect runtime 处于启用状态时,ClawX 会把已启用的本地 skills 镜像到 app userData 下托管的 Codex home 中,让内置 Codex agent 使用同一套技能,而不读取全局 skill 目录。
|
||||
|
||||
重点搜索技能所需环境变量:
|
||||
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth)
|
||||
@@ -188,7 +195,7 @@ pnpm dev
|
||||
|
||||
### 代理设置
|
||||
|
||||
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。
|
||||
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway、可选的 cc-connect/Codex runtime,以及 Telegram 这类频道的联网请求。
|
||||
|
||||
打开 **设置 → 网关 → 代理**,配置以下内容:
|
||||
|
||||
@@ -209,6 +216,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
- 只填写 `host:port` 时,会按 HTTP 代理处理。
|
||||
- 高级代理项留空时,会自动回退到“代理服务器”。
|
||||
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
|
||||
- 在 cc-connect runtime 模式下,Codex 子进程会继承同一组 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 和绕过规则环境变量。
|
||||
- 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
|
||||
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
|
||||
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# cc-connect + Codex Core Replacement
|
||||
|
||||
## Status
|
||||
|
||||
Status: approved for implementation
|
||||
|
||||
This document upgrades the runtime abstraction work from "cc-connect is selectable" to "cc-connect + Codex can replace OpenClaw core GUI functionality." OpenClaw remains the fallback runtime, but cc-connect mode must no longer be a mostly unsupported stub.
|
||||
|
||||
## Goal
|
||||
|
||||
When `cc-connect` is selected as the runtime, ClawX should provide a working core loop without OpenClaw Gateway:
|
||||
|
||||
- GUI chat sends prompts through cc-connect BridgePlatform to the Codex project agent.
|
||||
- Sessions and history are exposed through the cc-connect bridge adapter under ClawX-managed app data.
|
||||
- Runtime status, logs, and Doctor are runtime-aware.
|
||||
- Provider, cron, and channel surfaces route through cc-connect capability checks instead of assuming OpenClaw Gateway.
|
||||
|
||||
## Accepted Architecture
|
||||
|
||||
ClawX uses cc-connect as the replacement provider:
|
||||
|
||||
- cc-connect owns the GUI BridgePlatform connection, provider/cron integration, Doctor, managed config, logs, and packaged binary lifecycle.
|
||||
- ClawX bundles the native OpenAI Codex CLI and writes it into the cc-connect project config as the Codex agent command.
|
||||
- ClawX converts supported provider/model settings into a managed Codex launch profile for cc-connect mode.
|
||||
- `RuntimeManager` remains the boundary exposed to host services.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI["Renderer host-api/api-client"] --> Host["Host Services"]
|
||||
Host --> RuntimeManager["RuntimeManager"]
|
||||
RuntimeManager --> Provider["CcConnectRuntimeProvider"]
|
||||
Provider --> CcProcess["cc-connect process"]
|
||||
Provider --> Bridge["ClawX Bridge Adapter"]
|
||||
Provider --> Management["cc-connect Management API"]
|
||||
CcProcess --> Codex["bundled Codex native CLI"]
|
||||
Bridge --> CcProcess
|
||||
Management --> CcProcess
|
||||
```
|
||||
|
||||
## GUI Chat Through cc-connect
|
||||
|
||||
ClawX registers a local `clawx` BridgePlatform adapter over cc-connect WebSocket bridge. GUI messages are sent as bridge `message` packets, cc-connect runs the configured Codex project agent, and replies are converted back into the existing `chat:message` and `chat:runtime-event` host events.
|
||||
|
||||
## Core Capability Contract
|
||||
|
||||
| Capability | Replacement behavior |
|
||||
|---|---|
|
||||
| Chat | `CcConnectRuntimeProvider.sendMessageWithMedia` sends through the ClawX cc-connect BridgePlatform adapter. |
|
||||
| Sessions | ClawX exposes bridge-backed cc-connect/Codex sessions under app userData. |
|
||||
| History | ClawX returns `RawMessage[]` from the bridge adapter history. |
|
||||
| Delete session | Deletes managed transcript and metadata. |
|
||||
| Logs/status | Shows provider logs, Codex command logs, and cc-connect managed config hints. |
|
||||
| Doctor | Runs cc-connect Doctor plus Codex CLI availability/version checks. |
|
||||
| Providers/models | ClawX syncs the active provider account into `provider-profile.json` and cc-connect project config/env. |
|
||||
| Cron | Host cron APIs route to cc-connect Management API when cc-connect runtime is active. |
|
||||
| Skills | Enabled local skills are mirrored into managed `codex-home/skills`. |
|
||||
| Channels | cc-connect owns messaging platform bridges; ClawX routes channel status probes through the active runtime. |
|
||||
|
||||
## Managed Paths
|
||||
|
||||
All cc-connect/Codex runtime state stays under:
|
||||
|
||||
```text
|
||||
app.getPath('userData')/runtimes/cc-connect/
|
||||
```
|
||||
|
||||
Subdirectories:
|
||||
|
||||
- `config.toml`
|
||||
- `codex-sessions/`
|
||||
- `codex-home/`
|
||||
- `logs/`
|
||||
- `provider-profile.json`
|
||||
|
||||
ClawX must not read or mutate user `~/.cc-connect` or rely on user `~/.codex` auth state for cc-connect mode.
|
||||
|
||||
## Provider And Model Conversion
|
||||
|
||||
The cc-connect runtime converts the active ClawX provider account into a Codex launch profile:
|
||||
|
||||
- OpenAI API key accounts: `codex exec --model <model>` with `OPENAI_API_KEY` in the child process environment.
|
||||
- OpenAI OAuth browser accounts: `codex exec --model <model>` with `CODEX_HOME` pointing at `app userData/runtimes/cc-connect/codex-home/`. ClawX writes a managed Codex `auth.json` from the stored OpenAI OAuth access, refresh, optional ID token, and account id.
|
||||
- OpenAI-compatible Custom providers: supported only when configured for `openai-responses`; ClawX passes Codex `model_provider`, `model_providers.<name>.base_url`, `env_key`, and `wire_api="responses"` overrides while keeping the API key in process environment only.
|
||||
- Custom providers configured for Chat Completions are rejected before chat delivery with a stable unsupported reason because Codex 0.137 no longer accepts the Chat Completions wire API.
|
||||
- Ollama local accounts: `codex exec --oss --local-provider ollama --model <model>`.
|
||||
- Unsupported vendors return a stable unsupported error before spawning Codex and do not mutate OpenClaw configuration.
|
||||
- Codex child processes inherit ClawX proxy settings as `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` environment variables, matching Gateway launch behavior.
|
||||
|
||||
`provider-profile.json` is intentionally public/diagnostic: it records provider id, model, args, and environment key names only. It must not contain API keys or OAuth token values.
|
||||
|
||||
## First Implementation Slice
|
||||
|
||||
The first replacement-grade slice implements:
|
||||
|
||||
- cc-connect process lifecycle with management and bridge enabled
|
||||
- ClawX BridgePlatform adapter
|
||||
- `sendMessageWithMedia`
|
||||
- `listSessions`
|
||||
- `loadHistory`
|
||||
- `deleteSession`
|
||||
- provider/model profile sync for OpenAI API key, OpenAI OAuth/Codex, and Ollama
|
||||
- cron list/create/update/delete/trigger through cc-connect Management API
|
||||
- skill mirroring into managed Codex home
|
||||
- runtime logs that include Codex command attempts
|
||||
- Doctor output that includes Codex CLI version
|
||||
|
||||
Channel deep integration remains visible in capability docs and follow-up gates, but the cc-connect runtime should no longer report chat/session/history/provider/model/cron/skills as unsupported.
|
||||
|
||||
Current implemented capability flags for cc-connect mode:
|
||||
|
||||
- `chat`: supported through cc-connect BridgePlatform
|
||||
- `sessions`: supported through the ClawX bridge adapter
|
||||
- `history`: supported through the ClawX bridge adapter
|
||||
- `providers`: supported for OpenAI API key, OpenAI OAuth/Codex, and Ollama via managed Codex launch profile
|
||||
- `models`: supported for OpenAI/Codex and Ollama via `codex exec --model` or `--oss --local-provider ollama`
|
||||
- `logs`: supported through managed config/session path output
|
||||
- `doctor`: supported through cc-connect Doctor plus Codex CLI diagnostics
|
||||
- `cron`: supported through cc-connect Management API
|
||||
- `skills`: supported through managed Codex home mirroring
|
||||
- `channels`: supported through cc-connect platform bridge status snapshots
|
||||
- `controlUi`: supported through the cc-connect Web Admin on the managed management port
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Unit tests prove `cc-connect` runtime starts a managed cc-connect process and sends through the ClawX bridge adapter.
|
||||
- Unit tests prove sessions/history/delete operate from managed storage.
|
||||
- Unit tests prove Doctor includes Codex CLI diagnostics.
|
||||
- Unit tests prove OpenAI, OpenAI-compatible Responses, and Ollama provider accounts convert to Codex launch profiles without writing secrets to disk, and Chat Completions Custom providers are rejected before bridge delivery.
|
||||
- Unit tests prove cc-connect/Codex child processes inherit ClawX proxy environment values.
|
||||
- E2E tests prove a ClawX-managed cc-connect runtime can start from Settings-seeded config, write managed `config.toml`, send a real UI chat through the Codex bridge, and read back managed history through Host API.
|
||||
- E2E tests prove the managed config points cc-connect at the bundled Codex binary and enables the bridge.
|
||||
- Typecheck passes.
|
||||
- Existing runtime abstraction tests still pass.
|
||||
- Comms replay/compare remains required before PR or merge because chat routing changed.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Switch runtime back to OpenClaw in Settings.
|
||||
- Stop cc-connect runtime.
|
||||
- Managed cc-connect/Codex session files remain under app userData and are not deleted automatically.
|
||||
@@ -0,0 +1,132 @@
|
||||
# ClawX Runtime Abstraction and cc-connect Delivery Readiness
|
||||
|
||||
## Delivery Status
|
||||
|
||||
Status: local implementation complete; release readiness incomplete
|
||||
|
||||
The current validation set is enough to support code review and local engineering confidence. It is not enough to claim production release readiness because full Electron package artifacts, cross-platform cc-connect resources, remote CI, and real-runtime parity checks have not all been completed.
|
||||
|
||||
## Validation Sufficiency
|
||||
|
||||
| Question | Current Answer | Reason |
|
||||
|---|---|---|
|
||||
| Is the implementation locally coherent? | Yes | Typecheck, focused unit tests, Settings E2E, comms regression, and diff checks pass. |
|
||||
| Is OpenClaw rollback protected? | Mostly yes | OpenClaw remains default and GatewayManager is wrapped, not removed. A full OpenClaw app smoke is still recommended before PR merge. |
|
||||
| Is cc-connect packaging proven for this machine? | Partially | `bundle:cc-connect:current` produced and verified darwin-arm64 binary. |
|
||||
| Is packaged ClawX proven offline-ready? | No | `package:mac:local` resource verification has not been run. |
|
||||
| Are Windows and Linux packages proven? | No | They require CI or platform-specific package validation. |
|
||||
| Is cc-connect feature parity proven? | Partial | Chat/session/history plus OpenAI API key, OpenAI OAuth/Codex, and Ollama provider/model selection are validated; channel/cron parity still needs runtime-specific validation. |
|
||||
| Is PR/CI delivery complete? | No | No commit, push, PR, or remote CI was requested or created. |
|
||||
|
||||
## Completed Local Evidence
|
||||
|
||||
Passed local checks:
|
||||
|
||||
- `pnpm exec vitest run tests/unit/runtime-manager.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-provider-profile.test.ts tests/unit/codex-cli-bridge.test.ts tests/unit/cc-connect-bundle.test.ts tests/unit/host-api-facade.test.ts`
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm run build:vite && pnpm exec playwright test tests/e2e/cc-connect-codex-runtime.spec.ts tests/e2e/settings-runtime-selector.spec.ts`
|
||||
- `pnpm harness validate --spec harness/specs/tasks/runtime-abstraction-cc-connect.md`
|
||||
- `pnpm run harness:ci`
|
||||
- `pnpm run comms:replay && pnpm run comms:compare`
|
||||
- `pnpm run bundle:cc-connect:current`
|
||||
- `build/cc-connect/darwin-arm64/cc-connect --version`
|
||||
- i18n JSON parse check
|
||||
- `git diff --check`
|
||||
|
||||
Focused scan result:
|
||||
|
||||
- `gitleaks` and `detect-secrets` were not available.
|
||||
- A fallback changed-file scan found field-name and documentation matches such as token/API key labels.
|
||||
- No credential values were identified.
|
||||
|
||||
## Implementation Delivered
|
||||
|
||||
Runtime/backend:
|
||||
|
||||
- `RuntimeKind`, runtime capabilities, and runtime-aware status.
|
||||
- `RuntimeManager`.
|
||||
- `OpenClawRuntimeProvider` wrapping existing Gateway behavior.
|
||||
- `CcConnectRuntimeProvider` with managed config, provider profile sync, binary path resolution, process lifecycle, logs, status, unsupported fallbacks, and Doctor diagnose.
|
||||
- OpenAI OAuth/Codex mode writes a managed `CODEX_HOME/auth.json` under app userData and passes `CODEX_HOME` to Codex without relying on user `~/.codex`.
|
||||
- Codex child processes in cc-connect mode inherit ClawX proxy environment values, including `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and bypass rules.
|
||||
- Runtime-aware host services while preserving legacy gateway IPC.
|
||||
|
||||
UI:
|
||||
|
||||
- Settings runtime selector.
|
||||
- Runtime status, config directory, and capability visibility.
|
||||
- Runtime Doctor surface.
|
||||
- cc-connect mode keeps Doctor diagnose available and disables Doctor Fix because `cc-connect@1.3.2` lacks a fix subcommand.
|
||||
|
||||
Packaging:
|
||||
|
||||
- Locked `cc-connect@1.3.2` as a devDependency.
|
||||
- Build-time bundler scripts.
|
||||
- Electron `extraResources` configuration for platform resources.
|
||||
- Current-platform bundle manifest and version verification.
|
||||
|
||||
Docs/tests:
|
||||
|
||||
- Architecture and migration doc.
|
||||
- Proposal doc.
|
||||
- Delivery readiness doc.
|
||||
- README sync.
|
||||
- Unit and E2E coverage.
|
||||
|
||||
## Missing Release Evidence
|
||||
|
||||
These items should remain open before declaring production release readiness:
|
||||
|
||||
| Missing Evidence | Required Command or Action | Owner |
|
||||
|---|---|---|
|
||||
| Full macOS packaged resource check | `pnpm run package:mac:local`, then verify `ClawX.app/Contents/Resources/cc-connect/cc-connect --version` | Release owner |
|
||||
| Windows packaged resource check | CI or Windows runner verifies `resources/cc-connect/cc-connect.exe --version` | CI/release owner |
|
||||
| Linux packaged resource check | CI or Linux runner verifies `resources/cc-connect/cc-connect --version` | CI/release owner |
|
||||
| OpenClaw default app smoke | Start app with default runtime and verify existing chat/session surfaces still load | QA/release owner |
|
||||
| cc-connect packaged smoke | Start packaged app with cc-connect selected and verify runtime status/log/doctor | QA/release owner |
|
||||
| Channel/cron parity decision | Decide which cc-connect channel and cron capabilities are supported, disabled, or deferred | Runtime owner |
|
||||
| Remote CI | Commit, push, open PR, and observe terminal checks or record async follow-up | PR owner |
|
||||
|
||||
## Delivery Gate Ledger
|
||||
|
||||
| Gate | Status | Evidence | Release Impact |
|
||||
|---|---|---|---|
|
||||
| G1 Requirements | pass with process exception | `.delivery/runs/runtime-abstraction-cc-connect/requirements.md` | Requirements are specific enough; Mobius was invoked after initial implementation. |
|
||||
| G2 Plan / Proposal | pass after supplement | `docs/runtime-abstraction-cc-connect-proposal.md`; `.delivery/runs/runtime-abstraction-cc-connect/plan.md` | Architecture review can proceed. |
|
||||
| G3 Local Development | pass | Linked worktree and branch recorded. | Local changes isolated from main checkout. |
|
||||
| G4 Implementation | pass | Runtime, UI, packaging, docs, and tests changed intentionally. | Ready for review. |
|
||||
| G5 Verification | pass for local scope | Commands listed above. | Enough for local confidence, not enough for production release. |
|
||||
| G6 PR/MR | not applicable | No commit/push/PR requested. | Required before normal merge workflow. |
|
||||
| G7 CI/CD | not applicable | No remote head SHA exists. | Required before merge/release. |
|
||||
| G8 Report | pass after supplement | `docs/runtime-abstraction-cc-connect-delivery.md`; `.delivery/runs/runtime-abstraction-cc-connect/delivery-report.md` | Delivery state is auditable. |
|
||||
|
||||
## Go / No-Go
|
||||
|
||||
Code review readiness: go
|
||||
|
||||
Alpha or internal validation: go, if testers accept capability-gated cc-connect behavior and OpenClaw rollback remains available.
|
||||
|
||||
Production release readiness: no-go until the missing release evidence above is completed.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
Before PR:
|
||||
|
||||
- Review diff for runtime boundary and OpenClaw compatibility.
|
||||
- Decide whether to keep `.delivery/` ignored or attach its report externally.
|
||||
- Commit with a scoped message.
|
||||
- Push and open PR.
|
||||
|
||||
Before merge:
|
||||
|
||||
- Run remote CI.
|
||||
- Confirm macOS package resource check.
|
||||
- Confirm no renderer direct runtime HTTP calls were introduced.
|
||||
- Confirm unsupported cc-connect features are disabled or return stable errors.
|
||||
|
||||
Before release:
|
||||
|
||||
- Complete platform package checks.
|
||||
- Run OpenClaw default smoke.
|
||||
- Run cc-connect packaged smoke.
|
||||
- Record final release notes and rollback instructions.
|
||||
@@ -0,0 +1,170 @@
|
||||
# ClawX Runtime Abstraction and cc-connect Proposal
|
||||
|
||||
## Proposal Status
|
||||
|
||||
Status: ready for engineering review
|
||||
|
||||
This proposal recommends introducing a runtime provider layer in the Electron main process, keeping OpenClaw as the default provider and adding cc-connect as an optional packaged provider. The first delivery should be treated as a capability-gated runtime platform foundation, not as full OpenClaw feature parity.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
ClawX currently assumes OpenClaw Gateway is the only runtime. When OpenClaw runtime startup, diagnostics, or protocol behavior becomes unstable, ClawX has no clean fallback path. The renderer, host services, OpenClaw config paths, Doctor, Skills, sessions, channels, and cron surfaces are coupled to OpenClaw assumptions.
|
||||
|
||||
The product needs a runtime abstraction that lets ClawX switch between OpenClaw and cc-connect without rewriting renderer workflows or requiring users to install runtime dependencies manually.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Implement `RuntimeManager` and provider-specific adapters in the Electron main process:
|
||||
|
||||
- `OpenClawRuntimeProvider` wraps the existing `GatewayManager`.
|
||||
- `CcConnectRuntimeProvider` manages the cc-connect binary, config, working directory, logs, status, and supported commands.
|
||||
- Renderer code continues using `src/lib/host-api.ts` and `src/lib/api-client.ts`.
|
||||
- Legacy `gateway:*` IPC and event names remain as a compatibility layer during migration.
|
||||
- OpenClaw remains the default runtime and rollback path.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
| Driver | Requirement |
|
||||
|---|---|
|
||||
| Stability | OpenClaw instability must not force a ClawX-wide renderer rewrite. |
|
||||
| Rollback | Users and developers must be able to switch back to OpenClaw. |
|
||||
| Offline packaging | Packaged ClawX must include cc-connect binaries and must not download them at app runtime. |
|
||||
| Config isolation | ClawX must not mutate user `~/.cc-connect` state automatically. |
|
||||
| Capability clarity | Unsupported runtime features must be visible and fail predictably. |
|
||||
| Minimal churn | Existing chat, sessions, and Settings entry points should remain stable. |
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Runtime contract and manager.
|
||||
- OpenClaw adapter wrapping current Gateway behavior.
|
||||
- cc-connect adapter with managed config, packaged binary path resolution, process lifecycle, logs, status, and Doctor diagnose.
|
||||
- Settings runtime selector and capability display.
|
||||
- cc-connect build-time bundling scripts and Electron `extraResources`.
|
||||
- Tests, docs, and communication regression validation.
|
||||
|
||||
Out of scope for the first delivery:
|
||||
|
||||
- Strict parity for OpenClaw Skills or ClawHub.
|
||||
- OpenClaw internal config repair in cc-connect mode.
|
||||
- Full provider/channel/cron parity if cc-connect does not expose equivalent interfaces.
|
||||
- Removing `GatewayManager`.
|
||||
- Runtime selection in renderer business logic.
|
||||
|
||||
## Current cc-connect Facts
|
||||
|
||||
- `cc-connect@1.3.2` is an npm package with a wrapper, install script, README, and downloaded release binary.
|
||||
- The install script downloads release assets into `node_modules/cc-connect/bin/`.
|
||||
- Packaged ClawX cannot depend on runtime postinstall or network access.
|
||||
- The binary supports `doctor user-isolation`.
|
||||
- `cc-connect@1.3.2` does not expose a `doctor fix` subcommand in local verification.
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Renderer["Renderer host-api/api-client"] --> Host["Main-process host services"]
|
||||
Host --> RuntimeManager["RuntimeManager"]
|
||||
RuntimeManager --> OpenClaw["OpenClawRuntimeProvider"]
|
||||
RuntimeManager --> CcConnect["CcConnectRuntimeProvider"]
|
||||
OpenClaw --> GatewayManager["Existing GatewayManager"]
|
||||
CcConnect --> Binary["resources/cc-connect/cc-connect"]
|
||||
CcConnect --> ManagedDir["app userData/runtimes/cc-connect"]
|
||||
```
|
||||
|
||||
Runtime status extends the existing gateway status shape with:
|
||||
|
||||
- `runtimeKind`
|
||||
- `capabilities`
|
||||
- `configDir`
|
||||
|
||||
The provider contract covers:
|
||||
|
||||
- lifecycle: `start`, `stop`, `restart`, `status`, `health`
|
||||
- messaging: `rpc`, `sendMessageWithMedia`
|
||||
- sessions: `listSessions`, `loadHistory`, `deleteSession`
|
||||
- diagnostics: `listLogs`, `runDoctor`
|
||||
- feature discovery: `listCapabilities`
|
||||
|
||||
## Options Considered
|
||||
|
||||
| Option | Decision | Reason |
|
||||
|---|---|---|
|
||||
| Replace GatewayManager immediately | Rejected | Too much blast radius; weakens OpenClaw rollback. |
|
||||
| Keep OpenClaw-only code and add cc-connect UI branch logic | Rejected | Pushes runtime concerns into renderer and duplicates behavior. |
|
||||
| Add runtime manager in main process and wrap providers | Selected | Keeps renderer stable, centralizes lifecycle, and allows capability-aware fallback. |
|
||||
| Declare `cc-connect` as runtime dependency only | Rejected | Does not satisfy offline packaged binary requirement. |
|
||||
| Bundle verified cc-connect binaries through `extraResources` | Selected | Keeps binaries executable outside asar and supports offline app startup. |
|
||||
|
||||
## Packaging Proposal
|
||||
|
||||
`cc-connect@1.3.2` should be a locked `devDependency`. The packaged app should execute only the bundled resource binary:
|
||||
|
||||
```text
|
||||
process.resourcesPath/cc-connect/cc-connect[.exe]
|
||||
```
|
||||
|
||||
Build-time bundling should:
|
||||
|
||||
- read `cc-connect/package.json` for the locked version,
|
||||
- download the target release asset,
|
||||
- extract the binary to `build/cc-connect/<platform>-<arch>/`,
|
||||
- chmod POSIX binaries,
|
||||
- verify `--version` on the current host target,
|
||||
- write `manifest.json` with version, source URL, platform, arch, and sha256.
|
||||
|
||||
Cross-target bundles can be downloaded and hashed locally, but they need platform CI to execute `--version`.
|
||||
|
||||
## Delivery Phases
|
||||
|
||||
| Phase | Purpose | Exit Evidence |
|
||||
|---|---|---|
|
||||
| Proposal | Align architecture, scope, non-goals, dependency policy, and rollout gates. | This document plus `docs/runtime-abstraction-cc-connect.md`. |
|
||||
| Implementation | Add runtime manager, providers, packaging scripts, Settings UI, tests, and docs. | Local diff and mapped acceptance criteria. |
|
||||
| Local verification | Validate typecheck, focused unit/E2E tests, comms regression, bundler current target, and docs sanity. | Commands recorded in delivery report. |
|
||||
| Release readiness | Validate full package artifacts and cross-platform resources. | Platform package commands and CI artifacts. |
|
||||
| PR/CI delivery | Commit, push, open PR, and observe CI. | PR URL, head SHA, terminal CI state or async follow-up. |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
| Acceptance | Evidence Needed |
|
||||
|---|---|
|
||||
| OpenClaw default and rollback preserved | Runtime default unit test; Settings switch; OpenClaw provider wrapper. |
|
||||
| cc-connect selectable | Settings E2E and runtime status includes `runtimeKind`. |
|
||||
| Managed cc-connect config | Provider unit test and path resolver under app userData. |
|
||||
| OpenAI OAuth works in cc-connect mode | Provider profile unit test and E2E verify managed `CODEX_HOME/auth.json` plus Codex launch env. |
|
||||
| cc-connect Doctor diagnose works | Unit test and command mapping to `doctor user-isolation --config <managed config>`. |
|
||||
| Unsupported cc-connect capabilities degrade cleanly | Provider stable unsupported responses and capability-aware UI. |
|
||||
| Packaged binary is offline-ready | `extraResources` config plus package artifact checks. |
|
||||
| Communication paths remain stable | `pnpm run comms:replay` and `pnpm run comms:compare`. |
|
||||
|
||||
## Release Gates
|
||||
|
||||
Local implementation gates:
|
||||
|
||||
- focused unit tests pass,
|
||||
- typecheck passes,
|
||||
- Settings E2E passes,
|
||||
- comms replay/compare passes,
|
||||
- current-target cc-connect bundle verifies version,
|
||||
- diff review and sensitive scan complete.
|
||||
|
||||
Release readiness gates:
|
||||
|
||||
- `pnpm run package:mac:local` verifies packaged resources on the developer platform,
|
||||
- CI validates Windows/Linux cc-connect resources,
|
||||
- current target app starts with OpenClaw default,
|
||||
- cc-connect runtime can start from packaged resources or a mock binary in CI,
|
||||
- PR remote CI reaches terminal success or has explicit async follow-up.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- Switch runtime back to OpenClaw in Settings.
|
||||
- Stop any cc-connect process owned by ClawX.
|
||||
- Keep managed cc-connect config in app userData.
|
||||
- Remove cc-connect bundling scripts/resources and devDependency only if the feature is fully reverted.
|
||||
|
||||
## Proposal Decision
|
||||
|
||||
Recommended decision: approve this as the first runtime abstraction increment for review and alpha validation, but do not mark it production-release-ready until the release readiness gates pass.
|
||||
@@ -0,0 +1,211 @@
|
||||
# ClawX Runtime Abstraction and cc-connect Migration Plan
|
||||
|
||||
## Background
|
||||
|
||||
ClawX currently treats OpenClaw Gateway as the only runtime. That keeps the renderer and host services simple, but it also means OpenClaw runtime instability directly affects chat, sessions, channels, cron, diagnostics, and packaging. ClawX needs a replaceable runtime layer so OpenClaw can remain the default and rollback path while cc-connect can be evaluated as an optional runtime.
|
||||
|
||||
## Goals
|
||||
|
||||
- Support `openclaw` and `cc-connect` behind one runtime contract.
|
||||
- Keep `openclaw` as the default runtime.
|
||||
- Add a Settings runtime selector with status, managed config path, and capability visibility.
|
||||
- Run cc-connect from ClawX-managed app data, not the user's `~/.cc-connect`.
|
||||
- Bundle cc-connect and native OpenAI Codex CLI binaries into packaged app resources so runtime startup does not require global installs, PATH binaries, or network downloads.
|
||||
- Keep existing renderer entry points through `host-api` and the legacy `gateway:*` compatibility layer.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- The first cc-connect release does not need strict parity for OpenClaw Skills or ClawHub integration.
|
||||
- The first cc-connect release does not need to repair OpenClaw internal configuration.
|
||||
- This plan does not remove `GatewayManager`; it wraps it as the OpenClaw provider first.
|
||||
|
||||
## cc-connect Facts
|
||||
|
||||
- `cc-connect@1.3.2` currently ships an npm package containing a CLI wrapper, `install.js`, `run.js`, `package.json`, and README.
|
||||
- `install.js` downloads a GitHub or Gitee release binary into `node_modules/cc-connect/bin/`.
|
||||
- Therefore ClawX packaging cannot rely on declaring the npm dependency alone. The build must explicitly download, verify, and copy the target platform binary into Electron `extraResources`.
|
||||
- Runtime startup must execute the bundled resource binary in packaged builds.
|
||||
|
||||
## Capability Matrix
|
||||
|
||||
| Capability | OpenClaw | cc-connect first version | Behavior when unsupported |
|
||||
| --- | --- | --- | --- |
|
||||
| Chat | Supported | Adapter-dependent | Stable unsupported error |
|
||||
| Sessions | Supported | Adapter-dependent | Empty/stable response or unsupported |
|
||||
| History | Supported | Adapter-dependent | Empty/stable response or unsupported |
|
||||
| Providers/models | Supported | OpenAI API key, OpenAI OAuth/Codex, OpenAI-compatible Responses Custom providers, and Ollama supported through Codex launch profile | Chat Completions Custom providers and unsupported vendors return stable errors and do not mutate OpenClaw config |
|
||||
| Channels | Supported | cc-connect platform bridges with runtime-routed status probes | Capability-aware degradation |
|
||||
| Cron | Supported | Not first-version parity | Disabled or stable unsupported |
|
||||
| Logs/status | Supported | Supported through process logs/status | Runtime manager log/status surface |
|
||||
| Skills | Supported | Not supported initially | OpenClaw-only controls hidden or disabled |
|
||||
| Doctor | Supported | `doctor user-isolation` supported; fix unavailable in 1.3.2 | Runtime-aware doctor output; fix disabled for cc-connect |
|
||||
|
||||
## Architecture
|
||||
|
||||
The host process owns runtime selection and process lifecycle.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Renderer["Renderer host-api/api-client"] --> Host["Typed Host Services"]
|
||||
Host --> RuntimeManager["RuntimeManager"]
|
||||
RuntimeManager --> OpenClaw["OpenClawRuntimeProvider"]
|
||||
RuntimeManager --> CcConnect["CcConnectRuntimeProvider"]
|
||||
OpenClaw --> GatewayManager["Existing GatewayManager"]
|
||||
CcConnect --> Binary["resources/cc-connect/cc-connect"]
|
||||
CcConnect --> ManagedDir["app userData/runtimes/cc-connect"]
|
||||
```
|
||||
|
||||
### Runtime Contract
|
||||
|
||||
- `RuntimeKind = 'openclaw' | 'cc-connect'`
|
||||
- `RuntimeStatus` extends the existing gateway status semantics and adds:
|
||||
- `runtimeKind`
|
||||
- `capabilities`
|
||||
- `configDir`
|
||||
- `RuntimeProvider` exposes:
|
||||
- `start`
|
||||
- `stop`
|
||||
- `restart`
|
||||
- `getStatus`
|
||||
- `checkHealth`
|
||||
- `rpc`
|
||||
- `sendMessageWithMedia`
|
||||
- `listSessions`
|
||||
- `loadHistory`
|
||||
- `deleteSession`
|
||||
- `listLogs`
|
||||
- `listCapabilities`
|
||||
|
||||
### Provider Ownership
|
||||
|
||||
- `OpenClawRuntimeProvider` wraps the existing `GatewayManager`. OpenClaw behavior stays the default and the rollback path.
|
||||
- `CcConnectRuntimeProvider` owns:
|
||||
- binary path resolution
|
||||
- managed config creation
|
||||
- process lifecycle
|
||||
- stdout/stderr capture
|
||||
- `doctor user-isolation` execution against the managed config
|
||||
- provider/model profile sync for supported Codex launch modes
|
||||
- managed `CODEX_HOME` creation for OpenAI OAuth so cc-connect mode does not depend on user `~/.codex`
|
||||
- stable unsupported responses for missing capabilities
|
||||
- `HostApiContext` and typed host services use `RuntimeManager`. Legacy `gateway:*` IPC and events remain available for compatibility.
|
||||
|
||||
OpenClaw-specific logic remains scoped to the OpenClaw path:
|
||||
|
||||
- `openclaw-auth`
|
||||
- `openclaw-proxy`
|
||||
- OpenClaw Doctor
|
||||
- OpenClaw Skills
|
||||
- OpenClaw Control UI
|
||||
- OpenClaw config repair
|
||||
|
||||
When `cc-connect` is active, the same typed `gateway.controlUi` host route opens cc-connect Web Admin instead of OpenClaw Control UI.
|
||||
|
||||
Provider, agent, channel, and cron routes should be migrated capability-by-capability. They must not assume `~/.openclaw` when the active runtime is not OpenClaw.
|
||||
|
||||
## cc-connect Managed Runtime
|
||||
|
||||
ClawX owns cc-connect state under:
|
||||
|
||||
```text
|
||||
app.getPath('userData')/runtimes/cc-connect/
|
||||
```
|
||||
|
||||
The first managed files are:
|
||||
|
||||
- `config.toml`
|
||||
- `provider-profile.json`
|
||||
- `codex-sessions/`
|
||||
- runtime logs
|
||||
- runtime working directory
|
||||
|
||||
ClawX must not read or mutate `~/.cc-connect` automatically.
|
||||
|
||||
## Packaging Design
|
||||
|
||||
`cc-connect` and `@openai/codex` are `devDependency` entries because the packaged runtime executes verified bundle artifacts from `extraResources`, not from asar `node_modules` or global installs.
|
||||
|
||||
`scripts/bundle-cc-connect.mjs`:
|
||||
|
||||
- Reads `cc-connect/package.json` version.
|
||||
- Resolves release assets named `cc-connect-v${version}-${platform}-${arch}`.
|
||||
- Supports:
|
||||
- `darwin-x64`
|
||||
- `darwin-arm64`
|
||||
- `linux-x64`
|
||||
- `linux-arm64`
|
||||
- `win32-x64`
|
||||
- Downloads from release sources during build.
|
||||
- Extracts to `build/cc-connect/<platform>-<arch>/cc-connect[.exe]`.
|
||||
- Runs `--version` and requires the expected version.
|
||||
- Writes `manifest.json` containing version, platform, arch, source URL, and SHA-256 integrity.
|
||||
- Applies executable permissions on POSIX binaries.
|
||||
|
||||
`electron-builder.yml` copies the prepared platform directory to:
|
||||
|
||||
```text
|
||||
process.resourcesPath/cc-connect/
|
||||
process.resourcesPath/codex/
|
||||
```
|
||||
|
||||
The binary is intentionally outside asar so it remains executable.
|
||||
|
||||
## Runtime Path Resolution
|
||||
|
||||
- Development: use `build/cc-connect/<platform>-<arch>/cc-connect[.exe]` and `build/codex/<platform>-<arch>/bin/codex[.exe]`.
|
||||
- Packaged: use `process.resourcesPath/cc-connect/cc-connect[.exe]` and `process.resourcesPath/codex/bin/codex[.exe]`.
|
||||
- If a binary is missing, the provider reports a clear startup error instructing developers to run the matching bundle script.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Introduce shared runtime types and `RuntimeManager`.
|
||||
2. Wrap existing `GatewayManager` with `OpenClawRuntimeProvider`.
|
||||
3. Add `CcConnectRuntimeProvider` with managed config and binary lifecycle.
|
||||
4. Move host gateway status/start/stop/restart/health/rpc/chat/session paths through `RuntimeManager`.
|
||||
5. Add Settings runtime selector and capability-aware UI.
|
||||
6. Add cc-connect bundling scripts and electron-builder resources.
|
||||
7. Add tests and harness coverage.
|
||||
8. Update README files and developer docs.
|
||||
9. Continue migrating provider/channel/cron/skills routes to capability dispatch.
|
||||
|
||||
The replacement-grade follow-up is documented in
|
||||
`docs/cc-connect-codex-core-replacement.md`. That slice makes cc-connect runtime
|
||||
mode use a ClawX BridgePlatform adapter for GUI chat, sessions, history, cron,
|
||||
skills, and supported provider/model selection so the core chat loop no longer
|
||||
depends on OpenClaw Gateway.
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
- Switch Settings runtime back to OpenClaw.
|
||||
- Stop the cc-connect process.
|
||||
- Keep the managed cc-connect config directory intact for future reuse.
|
||||
- OpenClaw remains the default runtime and the release rollback path.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit:
|
||||
- `RuntimeManager` default selection, switching, fallback, and event forwarding.
|
||||
- `OpenClawRuntimeProvider` preserves Gateway behavior.
|
||||
- `CcConnectRuntimeProvider` mock binary startup, stop, crash, config path, provider profile, and logs.
|
||||
- cc-connect provider profile conversion for OpenAI/Codex, OpenAI-compatible Responses Custom providers, Ollama, and unsupported providers.
|
||||
- cc-connect bundler URL mapping, manifest generation, version mismatch, and failure cases.
|
||||
- Integration:
|
||||
- Host API returns stable envelopes in both runtimes.
|
||||
- Unsupported provider/cron operations do not mutate OpenClaw config, and channel status probes use the active runtime.
|
||||
- Provider API sync uses cc-connect runtime profile when cc-connect is active.
|
||||
- E2E:
|
||||
- Settings runtime selector.
|
||||
- OpenClaw default smoke.
|
||||
- cc-connect mock runtime chat smoke, including provider/model args for Codex.
|
||||
- OpenClaw-only controls unavailable in cc-connect mode.
|
||||
- Packaging:
|
||||
- `pnpm run package:mac:local` then verify `release/mac-arm64/ClawX.app/Contents/Resources/cc-connect/cc-connect --version`.
|
||||
- Windows/Linux CI checks `resources/cc-connect/cc-connect[.exe]`.
|
||||
- Because this touches communication paths, run `pnpm run comms:replay` and `pnpm run comms:compare`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- OpenClaw remains the default runtime.
|
||||
- First-version cc-connect acceptance is core-equivalent for chat, sessions, history, providers/models, cron, and skills, not full OpenClaw-specific parity.
|
||||
- ClawX manages cc-connect config and does not modify `~/.cc-connect`.
|
||||
- Packaged ClawX must run cc-connect and Codex offline without global install or runtime download.
|
||||
@@ -0,0 +1,90 @@
|
||||
# cc-connect + Codex Core Replacement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make cc-connect runtime mode provide working ClawX GUI chat, sessions, history, and supported provider/model selection through Codex without OpenClaw Gateway.
|
||||
|
||||
**Architecture:** Add a focused Codex CLI bridge under `electron/runtime`, then delegate cc-connect provider chat/session/history/delete calls to it. Convert supported ClawX provider/model accounts into a managed Codex launch profile. Keep renderer and host API contracts stable.
|
||||
|
||||
**Tech Stack:** Electron main process, TypeScript, Node child_process, JSONL transcript files, Vitest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Codex CLI Bridge
|
||||
|
||||
**Files:**
|
||||
- Create: `electron/runtime/codex-cli-bridge.ts`
|
||||
- Test: `tests/unit/codex-cli-bridge.test.ts`
|
||||
|
||||
- [ ] Create a bridge that accepts `sessionKey`, `message`, `media`, and `workDir`.
|
||||
- [ ] Persist user and assistant messages to managed JSONL transcript files.
|
||||
- [ ] Spawn `codex exec --json -C <workDir> <prompt>`.
|
||||
- [ ] Extract assistant final text from tolerant JSONL parsing.
|
||||
- [ ] Return `runId` and stored assistant message.
|
||||
- [ ] Unit test success, malformed JSONL tolerance, and non-zero exit.
|
||||
|
||||
### Task 2: Provider Integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `electron/runtime/cc-connect-provider.ts`
|
||||
- Modify: `electron/runtime/types.ts`
|
||||
- Test: `tests/unit/cc-connect-runtime-provider.test.ts`
|
||||
|
||||
- [ ] Instantiate `CodexCliBridge` in `CcConnectRuntimeProvider`.
|
||||
- [ ] Route `sendMessageWithMedia` to the bridge.
|
||||
- [ ] Route `listSessions`, `loadHistory`, and `deleteSession` to the bridge.
|
||||
- [ ] Emit `chat:message` after assistant messages are stored.
|
||||
- [ ] Update cc-connect capabilities for core replacement coverage.
|
||||
- [ ] Unit test provider-level send/history/delete behavior.
|
||||
|
||||
### Task 2A: Provider/Model Profile Integration
|
||||
|
||||
**Files:**
|
||||
- Create: `electron/runtime/cc-connect-provider-profile.ts`
|
||||
- Modify: `electron/runtime/codex-cli-bridge.ts`
|
||||
- Modify: `electron/services/providers-api.ts`
|
||||
- Test: `tests/unit/cc-connect-provider-profile.test.ts`
|
||||
- Test: `tests/unit/host-services.test.ts`
|
||||
|
||||
- [ ] Convert OpenAI/Codex accounts into `codex exec --model <model>` plus process-only `OPENAI_API_KEY` env when available.
|
||||
- [ ] Convert Ollama accounts into `codex exec --oss --local-provider ollama --model <model>`.
|
||||
- [ ] Write a managed `provider-profile.json` without secret values.
|
||||
- [ ] Dispatch providers Host API sync through `RuntimeManager` when cc-connect is active.
|
||||
- [ ] Return stable unsupported errors for other provider types without mutating OpenClaw config.
|
||||
|
||||
### Task 3: Doctor and Logs
|
||||
|
||||
**Files:**
|
||||
- Modify: `electron/runtime/codex-cli-bridge.ts`
|
||||
- Modify: `electron/runtime/cc-connect-provider.ts`
|
||||
- Test: `tests/unit/cc-connect-runtime-provider.test.ts`
|
||||
|
||||
- [ ] Add `codex --version` diagnostic helper.
|
||||
- [ ] Include Codex diagnostic output in cc-connect runtime Doctor result.
|
||||
- [ ] Add runtime logs for Codex command attempts and managed transcript paths.
|
||||
- [ ] Unit test Doctor output includes Codex diagnostics.
|
||||
|
||||
### Task 4: Documentation and Delivery Artifacts
|
||||
|
||||
**Files:**
|
||||
- Create: `.delivery/runs/cc-connect-codex-core-replacement/requirements.md`
|
||||
- Create: `.delivery/runs/cc-connect-codex-core-replacement/plan.md`
|
||||
- Create: `.delivery/runs/cc-connect-codex-core-replacement/verification.md`
|
||||
- Create: `.delivery/runs/cc-connect-codex-core-replacement/delivery-report.md`
|
||||
- Modify: `docs/runtime-abstraction-cc-connect.md`
|
||||
|
||||
- [ ] Record G1 through G8 status with evidence.
|
||||
- [ ] Update runtime abstraction docs to point to the replacement design.
|
||||
- [ ] Keep release readiness gaps explicit.
|
||||
|
||||
### Task 5: Validation
|
||||
|
||||
**Files:**
|
||||
- Existing test files only unless fixes are required.
|
||||
|
||||
- [ ] Run `pnpm exec vitest run tests/unit/codex-cli-bridge.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-provider-profile.test.ts tests/unit/runtime-manager.test.ts`.
|
||||
- [ ] Run `pnpm run typecheck`.
|
||||
- [ ] Run `pnpm run build:vite && pnpm exec playwright test tests/e2e/cc-connect-codex-runtime.spec.ts tests/e2e/settings-runtime-selector.spec.ts`.
|
||||
- [ ] Run `pnpm run comms:replay && pnpm run comms:compare`.
|
||||
- [ ] Run `git diff --check`.
|
||||
- [ ] Run focused sensitive-information scan over changed files.
|
||||
@@ -0,0 +1,48 @@
|
||||
# cc-connect + Codex Core Replacement Design
|
||||
|
||||
## Status
|
||||
|
||||
Approved by user on 2026-06-07.
|
||||
|
||||
## Design
|
||||
|
||||
ClawX will treat `cc-connect` runtime mode as a mixed provider. The GUI chat/session/history loop is powered directly by Codex CLI, while cc-connect remains responsible for managed runtime packaging, Doctor, channel bridge, cron, provider CLI integration, and future management API integration. Supported provider/model settings are converted into a ClawX-managed Codex launch profile. OpenAI OAuth uses a managed `CODEX_HOME` under app userData and must not depend on user `~/.codex`.
|
||||
|
||||
The first implementation slice adds a `CodexCliBridge` owned by `CcConnectRuntimeProvider`. The bridge runs `codex exec --json`, captures the final assistant text, applies supported provider/model launch args, and persists a ClawX-owned transcript under `app userData/runtimes/cc-connect/codex-sessions/`. The runtime provider returns existing host API envelopes so renderer entry points do not change.
|
||||
|
||||
## Components
|
||||
|
||||
- `electron/runtime/codex-cli-bridge.ts`: isolated Codex execution, JSONL parsing, transcript persistence, and session listing.
|
||||
- `electron/runtime/cc-connect-provider-profile.ts`: converts OpenAI API key, OpenAI OAuth/Codex, and Ollama provider accounts into safe Codex launch profiles.
|
||||
- `electron/runtime/cc-connect-provider.ts`: delegates chat/session/history/delete to the bridge and keeps cc-connect binary/Doctor ownership.
|
||||
- `electron/runtime/types.ts`: capability matrix reflects real cc-connect/Codex core coverage.
|
||||
- `tests/unit/codex-cli-bridge.test.ts`: bridge parsing and persistence tests.
|
||||
- `tests/unit/cc-connect-runtime-provider.test.ts`: provider-level replacement behavior tests.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Renderer calls `hostApi.chat.sendWithMedia`.
|
||||
2. `createChatApi` calls `runtimeManager.getActiveProvider().sendMessageWithMedia`.
|
||||
3. In cc-connect mode, provider appends the user message to managed transcript.
|
||||
4. `CcConnectRuntimeProvider` syncs the active provider/model into `provider-profile.json`; OpenAI OAuth also writes managed `codex-home/auth.json`.
|
||||
5. `CodexCliBridge` runs `codex exec --json -C <workDir> <provider args> <prompt>`.
|
||||
6. Bridge extracts the assistant final text from JSONL events and stores it.
|
||||
7. Provider emits a runtime chat message event and returns a run id.
|
||||
8. Session/history APIs read the managed transcript.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing Codex binary returns a stable runtime error and a Doctor diagnostic.
|
||||
- Codex non-zero exit stores a system error message in the transcript and returns a failed send result.
|
||||
- Malformed JSONL lines are retained in logs but do not crash parsing.
|
||||
- Media attachments are converted into prompt references in the first slice; image passthrough can be added after Codex media behavior is verified.
|
||||
- Unsupported provider accounts fail with a stable message before spawning Codex, and do not write OpenClaw config.
|
||||
|
||||
## Testing
|
||||
|
||||
- Mock child process spawn for successful Codex output.
|
||||
- Mock non-zero Codex exit and malformed output.
|
||||
- Verify transcript JSONL shape and session metadata.
|
||||
- Verify provider `listSessions`, `loadHistory`, and `deleteSession`.
|
||||
- Verify OpenAI API key, OpenAI OAuth, and Ollama provider profile conversion and E2E Codex launch args/env.
|
||||
- Re-run focused runtime tests, typecheck, and comms checks before delivery.
|
||||
@@ -68,6 +68,10 @@ mac:
|
||||
to: bin
|
||||
- from: resources/cli/posix/
|
||||
to: cli/
|
||||
- from: build/cc-connect/darwin-${arch}/
|
||||
to: cc-connect/
|
||||
- from: build/codex/darwin-${arch}/
|
||||
to: codex/
|
||||
category: public.app-category.productivity
|
||||
icon: resources/icons/icon.icns
|
||||
target:
|
||||
@@ -119,6 +123,10 @@ win:
|
||||
to: bin
|
||||
- from: resources/cli/win32/
|
||||
to: cli/
|
||||
- from: build/cc-connect/win32-${arch}/
|
||||
to: cc-connect/
|
||||
- from: build/codex/win32-${arch}/
|
||||
to: codex/
|
||||
icon: resources/icons/icon.ico
|
||||
target:
|
||||
- target: nsis
|
||||
@@ -147,6 +155,10 @@ linux:
|
||||
to: bin
|
||||
- from: resources/cli/posix/
|
||||
to: cli/
|
||||
- from: build/cc-connect/linux-${arch}/
|
||||
to: cc-connect/
|
||||
- from: build/codex/linux-${arch}/
|
||||
to: codex/
|
||||
icon: resources/icons
|
||||
target:
|
||||
- target: AppImage
|
||||
|
||||
+42
-27
@@ -5,6 +5,9 @@
|
||||
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { RuntimeManager } from '../runtime/manager';
|
||||
import { OpenClawRuntimeProvider } from '../runtime/openclaw-provider';
|
||||
import { CcConnectRuntimeProvider } from '../runtime/cc-connect-provider';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { HostApiRegistry } from './ipc/host-invoke';
|
||||
import { createTray } from './tray';
|
||||
@@ -131,6 +134,7 @@ const gotTheLock = gotElectronLock && gotFileLock;
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let gatewayManager!: GatewayManager;
|
||||
let runtimeManager!: RuntimeManager;
|
||||
let clawHubService!: ClawHubService;
|
||||
const hostApiRegistry = new HostApiRegistry();
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
@@ -336,6 +340,8 @@ async function initialize(): Promise<void> {
|
||||
createTray(window);
|
||||
}
|
||||
|
||||
await runtimeManager.getActiveKind();
|
||||
|
||||
// Override security headers ONLY for the OpenClaw Gateway Control UI.
|
||||
// The URL filter ensures this callback only fires for gateway requests,
|
||||
// avoiding unnecessary overhead on every other HTTP response.
|
||||
@@ -360,7 +366,7 @@ async function initialize(): Promise<void> {
|
||||
);
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry);
|
||||
registerIpcHandlers(gatewayManager, runtimeManager, clawHubService, window, hostApiRegistry);
|
||||
|
||||
// Initialize extension system
|
||||
await extensionRegistry.initialize({
|
||||
@@ -441,44 +447,44 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// Bridge gateway and host-side events before any auto-start logic runs, so
|
||||
// renderer subscribers observe the full startup lifecycle.
|
||||
gatewayManager.on('status', (status: { state: string }) => {
|
||||
runtimeManager.on('status', (status: { state: string; runtimeKind?: string }) => {
|
||||
sendMainWindowEvent('gateway:status-changed', status);
|
||||
if (status.state === 'running' && !isE2EMode) {
|
||||
if (status.runtimeKind === 'openclaw' && status.state === 'running' && !isE2EMode) {
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gatewayManager.on('error', (error) => {
|
||||
runtimeManager.on('error', (error) => {
|
||||
sendMainWindowEvent('gateway:error', { message: error.message });
|
||||
});
|
||||
|
||||
gatewayManager.on('notification', (notification) => {
|
||||
runtimeManager.on('notification', (notification) => {
|
||||
sendMainWindowEvent('gateway:notification', notification);
|
||||
});
|
||||
|
||||
gatewayManager.on('gateway:health', (data) => {
|
||||
runtimeManager.on('gateway:health', (data) => {
|
||||
sendMainWindowEvent('gateway:health-changed', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('gateway:presence', (data) => {
|
||||
runtimeManager.on('gateway:presence', (data) => {
|
||||
sendMainWindowEvent('gateway:presence-changed', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:message', (data) => {
|
||||
runtimeManager.on('chat:message', (data) => {
|
||||
sendMainWindowEvent('gateway:chat-message', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:runtime-event', (data) => {
|
||||
runtimeManager.on('chat:runtime-event', (data) => {
|
||||
sendMainWindowEvent('chat:runtime-event', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('channel:status', (data) => {
|
||||
runtimeManager.on('channel:status', (data) => {
|
||||
sendMainWindowEvent('gateway:channel-status', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('exit', (code) => {
|
||||
runtimeManager.on('exit', (code) => {
|
||||
sendMainWindowEvent('gateway:exit', { code });
|
||||
});
|
||||
|
||||
@@ -522,12 +528,14 @@ async function initialize(): Promise<void> {
|
||||
const gatewayAutoStart = await getSetting('gatewayAutoStart');
|
||||
if (!isE2EMode && gatewayAutoStart) {
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
await gatewayManager.start();
|
||||
logger.info('Gateway auto-start succeeded');
|
||||
if (await runtimeManager.getActiveKind() === 'openclaw') {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
}
|
||||
logger.debug(`Auto-starting ${await runtimeManager.getActiveKind()} runtime...`);
|
||||
await runtimeManager.start();
|
||||
logger.info('Runtime auto-start succeeded');
|
||||
} catch (error) {
|
||||
logger.error('Gateway auto-start failed:', error);
|
||||
logger.error('Runtime auto-start failed:', error);
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
}
|
||||
} else if (isE2EMode) {
|
||||
@@ -580,6 +588,10 @@ if (gotTheLock) {
|
||||
}
|
||||
|
||||
gatewayManager = new GatewayManager();
|
||||
runtimeManager = new RuntimeManager({
|
||||
openclaw: new OpenClawRuntimeProvider(gatewayManager),
|
||||
ccConnect: new CcConnectRuntimeProvider(),
|
||||
});
|
||||
clawHubService = new ClawHubService();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
@@ -646,8 +658,8 @@ if (gotTheLock) {
|
||||
|
||||
void extensionRegistry.teardownAll();
|
||||
|
||||
const stopPromise = gatewayManager.stop().catch((err) => {
|
||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||
const stopPromise = runtimeManager.stop().catch((err) => {
|
||||
logger.warn('runtimeManager.stop() error during quit:', err);
|
||||
});
|
||||
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
||||
setTimeout(() => resolve('timeout'), 5000);
|
||||
@@ -655,14 +667,16 @@ if (gotTheLock) {
|
||||
|
||||
void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => {
|
||||
if (result === 'timeout') {
|
||||
logger.warn('Gateway shutdown timed out during app quit; proceeding with forced quit');
|
||||
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
|
||||
if (terminated) {
|
||||
logger.warn('Forced gateway process termination completed after quit timeout');
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.warn('Forced gateway termination failed after quit timeout:', err);
|
||||
});
|
||||
logger.warn('Runtime shutdown timed out during app quit; proceeding with forced quit');
|
||||
if (runtimeManager.getActiveProvider().kind === 'openclaw') {
|
||||
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
|
||||
if (terminated) {
|
||||
logger.warn('Forced gateway process termination completed after quit timeout');
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.warn('Forced gateway termination failed after quit timeout:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
markQuitCleanupCompleted(quitLifecycleState);
|
||||
app.quit();
|
||||
@@ -676,6 +690,7 @@ if (gotTheLock) {
|
||||
logger.error(`${reason}:`, error);
|
||||
try {
|
||||
void gatewayManager?.stop().catch(() => { /* ignore */ });
|
||||
void runtimeManager?.stop().catch(() => { /* ignore */ });
|
||||
} catch {
|
||||
// ignore — stop() may not be callable if state is corrupted
|
||||
}
|
||||
@@ -695,4 +710,4 @@ if (gotTheLock) {
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { mainWindow, gatewayManager };
|
||||
export { mainWindow, gatewayManager, runtimeManager };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
|
||||
import { join, extname, basename, resolve, sep, relative } from 'node:path';
|
||||
import { syncMacTrafficLightPosition } from './traffic-light-layout';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { RuntimeManager } from '../runtime/manager';
|
||||
import { ClawHubService } from '../gateway/clawhub';
|
||||
import {
|
||||
type ProviderConfig,
|
||||
@@ -80,6 +81,7 @@ const gatewayRpcBackpressure = new GatewayRpcBackpressure();
|
||||
*/
|
||||
export function registerIpcHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
runtimeManager: RuntimeManager,
|
||||
clawHubService: ClawHubService,
|
||||
mainWindow: BrowserWindow,
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
@@ -88,10 +90,10 @@ export function registerIpcHandlers(
|
||||
registerUnifiedRequestHandlers(gatewayManager);
|
||||
|
||||
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
|
||||
registerTypedHostHandlers(gatewayManager, clawHubService, mainWindow, hostApiRegistry);
|
||||
registerTypedHostHandlers(gatewayManager, runtimeManager, clawHubService, mainWindow, hostApiRegistry);
|
||||
|
||||
// Gateway handlers
|
||||
registerGatewayHandlers(gatewayManager);
|
||||
registerGatewayHandlers(runtimeManager);
|
||||
|
||||
// OpenClaw handlers
|
||||
registerOpenClawHandlers();
|
||||
@@ -129,30 +131,31 @@ export function registerIpcHandlers(
|
||||
|
||||
function registerTypedHostHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
runtimeManager: RuntimeManager,
|
||||
clawHubService: ClawHubService,
|
||||
mainWindow: BrowserWindow,
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
): void {
|
||||
hostApiRegistry.registerCoreServices({
|
||||
app: createAppApi(),
|
||||
app: createAppApi(runtimeManager),
|
||||
openclaw: createOpenClawApi(),
|
||||
shell: createShellApi(),
|
||||
dialog: createDialogApi(),
|
||||
window: createWindowApi(mainWindow),
|
||||
updates: createUpdatesApi(appUpdater),
|
||||
uv: createUvApi(),
|
||||
settings: createSettingsApi(gatewayManager),
|
||||
gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure),
|
||||
settings: createSettingsApi(gatewayManager, runtimeManager),
|
||||
gateway: createGatewayApi(runtimeManager, gatewayRpcBackpressure, gatewayManager),
|
||||
logs: createLogsApi(),
|
||||
channels: createChannelsApi({ gatewayManager, mainWindow }),
|
||||
channels: createChannelsApi({ gatewayManager, runtimeManager, mainWindow }),
|
||||
agents: createAgentsApi({ gatewayManager }),
|
||||
providers: createProvidersApi({ gatewayManager, mainWindow }),
|
||||
providers: createProvidersApi({ gatewayManager, runtimeManager, mainWindow }),
|
||||
files: createFilesApi(),
|
||||
media: createMediaApi(),
|
||||
sessions: createSessionsApi(),
|
||||
chat: createChatApi({ gatewayManager }),
|
||||
cron: createCronApi({ gatewayManager }),
|
||||
skills: createSkillsApi({ clawHubService, gatewayManager }),
|
||||
sessions: createSessionsApi(runtimeManager),
|
||||
chat: createChatApi({ gatewayManager, runtimeManager }),
|
||||
cron: createCronApi({ gatewayManager, runtimeManager }),
|
||||
skills: createSkillsApi({ clawHubService, gatewayManager, runtimeManager }),
|
||||
usage: createUsageApi(),
|
||||
});
|
||||
registerHostInvokeHandler(hostApiRegistry);
|
||||
@@ -686,10 +689,10 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
|
||||
/**
|
||||
* Gateway-related IPC handlers
|
||||
*/
|
||||
function registerGatewayHandlers(gatewayManager: GatewayManager): void {
|
||||
function registerGatewayHandlers(runtimeManager: RuntimeManager): void {
|
||||
// Get Gateway status
|
||||
ipcMain.handle('gateway:status', () => {
|
||||
return gatewayManager.getStatus();
|
||||
return runtimeManager.getStatus();
|
||||
});
|
||||
|
||||
// Gateway RPC call
|
||||
@@ -699,7 +702,7 @@ function registerGatewayHandlers(gatewayManager: GatewayManager): void {
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => runtimeManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
);
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import WebSocket from 'ws';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import type { RuntimeSendWithMediaPayload } from './types';
|
||||
|
||||
type BridgeAdapterOptions = {
|
||||
port: number;
|
||||
token: string;
|
||||
project: string;
|
||||
emit: EventEmitter['emit'];
|
||||
};
|
||||
|
||||
type SessionMetadata = {
|
||||
key: string;
|
||||
displayName?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type PendingRun = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
function messageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.flatMap((block) => {
|
||||
if (!block || typeof block !== 'object') return [];
|
||||
const record = block as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') return [record.text];
|
||||
if (typeof record.thinking === 'string') return [record.thinking];
|
||||
return [];
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toBridgeSessionKey(sessionKey: string): string {
|
||||
if (sessionKey.startsWith('clawx:')) return sessionKey;
|
||||
const [, scope = 'main', user = 'main'] = sessionKey.split(':');
|
||||
return `clawx:${scope || 'main'}:${user || 'main'}`;
|
||||
}
|
||||
|
||||
export class CcConnectBridgeAdapter {
|
||||
private readonly port: number;
|
||||
private readonly token: string;
|
||||
private readonly project: string;
|
||||
private readonly emitRuntimeEvent: EventEmitter['emit'];
|
||||
private socket: WebSocket | null = null;
|
||||
private readonly messagesBySession = new Map<string, RawMessage[]>();
|
||||
private readonly pendingRuns = new Map<string, PendingRun>();
|
||||
|
||||
constructor(options: BridgeAdapterOptions) {
|
||||
this.port = options.port;
|
||||
this.token = options.token;
|
||||
this.project = options.project;
|
||||
this.emitRuntimeEvent = options.emit;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) return;
|
||||
const startedAt = Date.now();
|
||||
let lastError: unknown;
|
||||
while (Date.now() - startedAt < CONNECT_TIMEOUT_MS) {
|
||||
try {
|
||||
await this.connectOnce();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError || 'cc-connect bridge did not become ready'));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
if (!socket) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (socket.readyState === WebSocket.CLOSED) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
socket.once('close', () => resolve());
|
||||
socket.close();
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
}
|
||||
|
||||
async send(payload: RuntimeSendWithMediaPayload): Promise<{ runId: string }> {
|
||||
await this.connect();
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('cc-connect bridge is not connected');
|
||||
}
|
||||
const runId = `cc-connect-${randomUUID()}`;
|
||||
const now = Date.now();
|
||||
const userMessage: RawMessage = {
|
||||
id: `${runId}:user`,
|
||||
role: 'user',
|
||||
content: payload.message,
|
||||
timestamp: now,
|
||||
};
|
||||
this.appendMessage(payload.sessionKey, userMessage);
|
||||
this.pendingRuns.set(runId, { runId, sessionKey: payload.sessionKey, startedAt: now });
|
||||
this.emitRuntimeEvent('chat:runtime-event', {
|
||||
type: 'run.started',
|
||||
runId,
|
||||
sessionKey: payload.sessionKey,
|
||||
startedAt: now,
|
||||
ts: now,
|
||||
});
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'message',
|
||||
msg_id: payload.idempotencyKey || runId,
|
||||
session_key: toBridgeSessionKey(payload.sessionKey),
|
||||
user_id: 'main',
|
||||
user_name: 'ClawX',
|
||||
content: payload.message,
|
||||
reply_ctx: runId,
|
||||
project: this.project,
|
||||
images: [],
|
||||
files: payload.media?.map((item) => ({
|
||||
mime_type: item.mimeType,
|
||||
file_name: item.fileName,
|
||||
path: item.filePath,
|
||||
})) ?? [],
|
||||
}));
|
||||
return { runId };
|
||||
}
|
||||
|
||||
async listSessions(): Promise<SessionMetadata[]> {
|
||||
const sessions: SessionMetadata[] = [];
|
||||
for (const [key, messages] of this.messagesBySession.entries()) {
|
||||
const firstUser = messages.find((message) => message.role === 'user');
|
||||
const lastTimestamp = messages.reduce((latest, message) => {
|
||||
const ts = normalizeTimestamp(message.timestamp);
|
||||
return ts ? Math.max(latest, ts) : latest;
|
||||
}, 0);
|
||||
sessions.push({
|
||||
key,
|
||||
displayName: messageText(firstUser?.content).slice(0, 80) || key,
|
||||
createdAt: normalizeTimestamp(messages[0]?.timestamp) ?? lastTimestamp,
|
||||
updatedAt: lastTimestamp,
|
||||
});
|
||||
}
|
||||
return sessions.sort((left, right) => right.updatedAt - left.updatedAt);
|
||||
}
|
||||
|
||||
async loadHistory(sessionKey: string, limit = 200): Promise<RawMessage[]> {
|
||||
const messages = this.messagesBySession.get(sessionKey) ?? [];
|
||||
return messages.slice(-Math.max(1, Math.min(Math.floor(limit), 1000)));
|
||||
}
|
||||
|
||||
async deleteSession(sessionKey: string): Promise<void> {
|
||||
this.messagesBySession.delete(sessionKey);
|
||||
}
|
||||
|
||||
async summarizeSessions(sessionKeys: string[]): Promise<Array<{ sessionKey: string; firstUserText: string | null; lastTimestamp: number | null }>> {
|
||||
return sessionKeys.map((sessionKey) => {
|
||||
const messages = this.messagesBySession.get(sessionKey) ?? [];
|
||||
const firstUser = messages.find((message) => message.role === 'user');
|
||||
const lastTimestamp = messages.reduce<number | null>((latest, message) => {
|
||||
const ts = normalizeTimestamp(message.timestamp);
|
||||
if (!ts) return latest;
|
||||
return latest == null ? ts : Math.max(latest, ts);
|
||||
}, null);
|
||||
return {
|
||||
sessionKey,
|
||||
firstUserText: messageText(firstUser?.content) || null,
|
||||
lastTimestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private connectOnce(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${this.port}/bridge/ws?token=${encodeURIComponent(this.token)}`);
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.close();
|
||||
reject(new Error('cc-connect bridge connection timed out'));
|
||||
}, 1500);
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
socket.once('open', () => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'register',
|
||||
platform: 'clawx',
|
||||
project: this.project,
|
||||
capabilities: ['text', 'card', 'buttons', 'typing', 'preview', 'update_message', 'delete_message', 'reconstruct_reply'],
|
||||
metadata: {
|
||||
protocol_version: 1,
|
||||
description: 'ClawX GUI bridge adapter',
|
||||
},
|
||||
}));
|
||||
});
|
||||
socket.on('message', (data) => {
|
||||
const parsed = this.parseMessage(data);
|
||||
if (!parsed) return;
|
||||
if (parsed.type === 'register_ack') {
|
||||
if (parsed.ok === true) {
|
||||
this.socket = socket;
|
||||
finish();
|
||||
} else {
|
||||
finish(new Error(typeof parsed.error === 'string' ? parsed.error : 'cc-connect bridge registration failed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.handleServerMessage(parsed);
|
||||
});
|
||||
socket.once('error', (error) => finish(error));
|
||||
socket.once('close', () => {
|
||||
if (this.socket === socket) this.socket = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private parseMessage(data: WebSocket.RawData): Record<string, unknown> | null {
|
||||
try {
|
||||
const text = Array.isArray(data)
|
||||
? Buffer.concat(data).toString('utf8')
|
||||
: Buffer.isBuffer(data)
|
||||
? data.toString('utf8')
|
||||
: String(data);
|
||||
const parsed = JSON.parse(text);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerMessage(message: Record<string, unknown>): void {
|
||||
if (message.type === 'ping') {
|
||||
this.socket?.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
|
||||
return;
|
||||
}
|
||||
if (message.type === 'preview_start') {
|
||||
this.socket?.send(JSON.stringify({
|
||||
type: 'preview_ack',
|
||||
ref_id: message.ref_id,
|
||||
preview_handle: String(message.ref_id || randomUUID()),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (message.type === 'reply') {
|
||||
this.finishRun(message, typeof message.content === 'string' ? message.content : '');
|
||||
return;
|
||||
}
|
||||
if (message.type === 'reply_stream' && message.done === true) {
|
||||
const text = typeof message.full_text === 'string'
|
||||
? message.full_text
|
||||
: typeof message.delta === 'string'
|
||||
? message.delta
|
||||
: '';
|
||||
this.finishRun(message, text);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'card') {
|
||||
this.finishRun(message, JSON.stringify(message.card ?? {}));
|
||||
return;
|
||||
}
|
||||
if (message.type === 'buttons') {
|
||||
this.finishRun(message, typeof message.content === 'string' ? message.content : '');
|
||||
return;
|
||||
}
|
||||
if (message.type === 'error') {
|
||||
this.finishRun(message, typeof message.message === 'string' ? message.message : 'cc-connect bridge error', true);
|
||||
}
|
||||
}
|
||||
|
||||
private finishRun(message: Record<string, unknown>, text: string, isError = false): void {
|
||||
const runId = typeof message.reply_ctx === 'string' ? message.reply_ctx : '';
|
||||
const pending = this.pendingRuns.get(runId);
|
||||
const sessionKey = pending?.sessionKey ?? 'agent:main:main';
|
||||
const assistantMessage: RawMessage = {
|
||||
id: `${runId || randomUUID()}:assistant`,
|
||||
role: isError ? 'system' : 'assistant',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
...(isError ? { isError: true, errorMessage: text } : {}),
|
||||
};
|
||||
this.appendMessage(sessionKey, assistantMessage);
|
||||
this.emitRuntimeEvent('chat:message', {
|
||||
state: 'final',
|
||||
runId,
|
||||
sessionKey,
|
||||
message: assistantMessage,
|
||||
});
|
||||
this.emitRuntimeEvent('chat:runtime-event', {
|
||||
type: 'run.ended',
|
||||
runId,
|
||||
sessionKey,
|
||||
status: isError ? 'error' : 'completed',
|
||||
endedAt: Date.now(),
|
||||
ts: Date.now(),
|
||||
...(isError ? { error: text } : {}),
|
||||
});
|
||||
if (runId) this.pendingRuns.delete(runId);
|
||||
}
|
||||
|
||||
private appendMessage(sessionKey: string, message: RawMessage): void {
|
||||
this.messagesBySession.set(sessionKey, [
|
||||
...(this.messagesBySession.get(sessionKey) ?? []),
|
||||
message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const CC_CONNECT_MANAGEMENT_PORT = 9820;
|
||||
|
||||
export function buildCcConnectWebAdminUrl(port = CC_CONNECT_MANAGEMENT_PORT): string {
|
||||
const normalizedPort = Number.isFinite(port) && port > 0
|
||||
? Math.trunc(port)
|
||||
: CC_CONNECT_MANAGEMENT_PORT;
|
||||
return `http://127.0.0.1:${normalizedPort}/`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { app } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect';
|
||||
}
|
||||
|
||||
export function getCcConnectManagedDir(): string {
|
||||
return join(app.getPath('userData'), 'runtimes', 'cc-connect');
|
||||
}
|
||||
|
||||
export function getCcConnectConfigPath(): string {
|
||||
return join(getCcConnectManagedDir(), 'config.toml');
|
||||
}
|
||||
|
||||
export function getCcConnectCodexSessionsDir(): string {
|
||||
return join(getCcConnectManagedDir(), 'codex-sessions');
|
||||
}
|
||||
|
||||
export function getCcConnectCodexHomeDir(): string {
|
||||
return join(getCcConnectManagedDir(), 'codex-home');
|
||||
}
|
||||
|
||||
export function getCcConnectProviderProfilePath(): string {
|
||||
return join(getCcConnectManagedDir(), 'provider-profile.json');
|
||||
}
|
||||
|
||||
export function getCcConnectBinaryPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'cc-connect', binaryName());
|
||||
}
|
||||
const bundledDevBinary = join(process.cwd(), 'build', 'cc-connect', `${process.platform}-${process.arch}`, binaryName());
|
||||
if (existsSync(bundledDevBinary)) {
|
||||
return bundledDevBinary;
|
||||
}
|
||||
return bundledDevBinary;
|
||||
}
|
||||
|
||||
export function assertCcConnectBinaryPath(candidate = getCcConnectBinaryPath()): string {
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`cc-connect binary not found at ${candidate}. Run pnpm run bundle:cc-connect:current before selecting cc-connect runtime.`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import { getProviderAccount, getDefaultProviderAccountId } from '@electron/services/providers/provider-store';
|
||||
import { getProviderSecret } from '@electron/services/secrets/secret-store';
|
||||
import { getProviderDefaultModel } from '@electron/utils/provider-registry';
|
||||
import type { ProviderAccount, ProviderSecret } from '@electron/shared/providers/types';
|
||||
import { getCcConnectCodexHomeDir, getCcConnectProviderProfilePath } from './cc-connect-paths';
|
||||
|
||||
export type CodexProviderProfile = {
|
||||
providerId: string | null;
|
||||
vendorId: string | null;
|
||||
label?: string;
|
||||
authMode?: string;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
supported: boolean;
|
||||
unsupportedReason?: string;
|
||||
codexArgs: string[];
|
||||
env?: Record<string, string>;
|
||||
envKeys?: string[];
|
||||
ccConnectProvider?: {
|
||||
name: string;
|
||||
apiKeyEnvKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
wireApi?: 'responses';
|
||||
};
|
||||
secretAvailable: boolean;
|
||||
codexHomeDir?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type OpenAIOAuthTokenSet = {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
function resolveModel(account: ProviderAccount): string | undefined {
|
||||
const model = account.model?.trim();
|
||||
if (model) return model;
|
||||
return getProviderDefaultModel(account.vendorId)?.trim() || undefined;
|
||||
}
|
||||
|
||||
function publicProfile(profile: CodexProviderProfile): CodexProviderProfile {
|
||||
const { env, ...rest } = profile;
|
||||
return {
|
||||
...rest,
|
||||
envKeys: Object.keys(env ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function tomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function tomlInlineStringMap(values: Record<string, string>): string {
|
||||
return `{ ${Object.entries(values).map(([key, value]) => `${tomlString(key)} = ${tomlString(value)}`).join(', ')} }`;
|
||||
}
|
||||
|
||||
function normalizeOpenAIResponsesBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, '').replace(/\/responses$/i, '');
|
||||
}
|
||||
|
||||
function normalizeModelHubCodexResponsesBaseUrl(baseUrl: string): string | null {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname !== 'aidp.bytedance.net') return null;
|
||||
if (!url.pathname.startsWith('/api/modelhub/online')) return null;
|
||||
url.pathname = '/api/modelhub/online';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManagedCodexResponsesConfig(options: {
|
||||
providerKey: string;
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
envKey: string;
|
||||
model?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
}): Promise<string> {
|
||||
const codexHomeDir = getCcConnectCodexHomeDir();
|
||||
await mkdir(codexHomeDir, { recursive: true });
|
||||
const configPath = join(codexHomeDir, 'config.toml');
|
||||
const tableKey = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(options.providerKey)
|
||||
? options.providerKey
|
||||
: tomlString(options.providerKey);
|
||||
const envHeaderEntries = Object.entries(options.envHttpHeaders ?? {});
|
||||
const lines = [
|
||||
...(options.model ? [`model = ${tomlString(options.model)}`] : []),
|
||||
`model_provider = ${tomlString(options.providerKey)}`,
|
||||
'',
|
||||
`[model_providers.${tableKey}]`,
|
||||
`name = ${tomlString(options.providerName)}`,
|
||||
`base_url = ${tomlString(options.baseUrl)}`,
|
||||
`env_key = ${tomlString(options.envKey)}`,
|
||||
'wire_api = "responses"',
|
||||
...(envHeaderEntries.length > 0
|
||||
? [`env_http_headers = ${tomlInlineStringMap(options.envHttpHeaders ?? {})}`]
|
||||
: []),
|
||||
'',
|
||||
];
|
||||
await writeFile(configPath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
|
||||
await chmod(configPath, 0o600).catch(() => {});
|
||||
return codexHomeDir;
|
||||
}
|
||||
|
||||
function stableModelHubSessionId(account: ProviderAccount): string {
|
||||
return `clawx-cc-connect-${account.id}`;
|
||||
}
|
||||
|
||||
async function writeManagedOpenAIOAuthAuthFile(
|
||||
tokens: OpenAIOAuthTokenSet,
|
||||
): Promise<string> {
|
||||
const codexHomeDir = getCcConnectCodexHomeDir();
|
||||
await mkdir(codexHomeDir, { recursive: true });
|
||||
const authPath = join(codexHomeDir, 'auth.json');
|
||||
|
||||
await writeFile(authPath, JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: tokens.idToken,
|
||||
access_token: tokens.accessToken,
|
||||
refresh_token: tokens.refreshToken,
|
||||
account_id: tokens.accountId,
|
||||
},
|
||||
last_refresh: new Date().toISOString(),
|
||||
}, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
await chmod(authPath, 0o600).catch(() => {});
|
||||
return codexHomeDir;
|
||||
}
|
||||
|
||||
async function resolveOpenAIOAuthTokens(
|
||||
account: ProviderAccount,
|
||||
secret: Extract<ProviderSecret, { type: 'oauth' }>,
|
||||
): Promise<OpenAIOAuthTokenSet | undefined> {
|
||||
const stored = secret.idToken?.trim();
|
||||
if (stored) {
|
||||
return {
|
||||
idToken: stored,
|
||||
accessToken: secret.accessToken,
|
||||
refreshToken: secret.refreshToken,
|
||||
accountId: secret.subject?.trim() || account.id,
|
||||
};
|
||||
}
|
||||
|
||||
const authPath = join(app.getPath('home'), '.codex', 'auth.json');
|
||||
try {
|
||||
const auth = JSON.parse(await readFile(authPath, 'utf8')) as {
|
||||
tokens?: {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
account_id?: unknown;
|
||||
};
|
||||
};
|
||||
const tokens = auth.tokens;
|
||||
if (
|
||||
!tokens ||
|
||||
typeof tokens.id_token !== 'string' ||
|
||||
typeof tokens.access_token !== 'string' ||
|
||||
typeof tokens.refresh_token !== 'string' ||
|
||||
typeof tokens.account_id !== 'string' ||
|
||||
!tokens.id_token.trim() ||
|
||||
!tokens.access_token.trim() ||
|
||||
!tokens.refresh_token.trim() ||
|
||||
!tokens.account_id.trim()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const expectedAccountId = secret.subject?.trim();
|
||||
const userAccountId = typeof tokens.account_id === 'string' ? tokens.account_id.trim() : '';
|
||||
const accessMatches = typeof tokens.access_token === 'string' && tokens.access_token === secret.accessToken;
|
||||
const refreshMatches = typeof tokens.refresh_token === 'string' && tokens.refresh_token === secret.refreshToken;
|
||||
const accountMatches = Boolean(expectedAccountId && userAccountId && expectedAccountId === userAccountId);
|
||||
const providerIdMatches = Boolean(userAccountId && account.id === userAccountId);
|
||||
|
||||
if (accessMatches || refreshMatches || accountMatches || providerIdMatches) {
|
||||
return {
|
||||
idToken: tokens.id_token.trim(),
|
||||
accessToken: tokens.access_token.trim(),
|
||||
refreshToken: tokens.refresh_token.trim(),
|
||||
accountId: tokens.account_id.trim(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function buildProfileForAccount(account: ProviderAccount): Promise<CodexProviderProfile> {
|
||||
const secret = await getProviderSecret(account.id);
|
||||
const model = resolveModel(account);
|
||||
const base = {
|
||||
providerId: account.id,
|
||||
vendorId: account.vendorId,
|
||||
label: account.label,
|
||||
authMode: account.authMode,
|
||||
model,
|
||||
modelRef: model ? `${account.vendorId}/${model}` : undefined,
|
||||
secretAvailable: Boolean(secret),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (account.vendorId === 'openai') {
|
||||
if (account.authMode === 'oauth_browser') {
|
||||
if (secret?.type !== 'oauth' || !secret.accessToken || !secret.refreshToken) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: 'OpenAI OAuth credentials are missing. Sign in to OpenAI again before using cc-connect Codex runtime.',
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
const tokens = await resolveOpenAIOAuthTokens(account, secret);
|
||||
if (!tokens) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: 'OpenAI OAuth credentials are missing an id_token required by Codex. Sign in to OpenAI again before using cc-connect Codex runtime.',
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
const codexHomeDir = await writeManagedOpenAIOAuthAuthFile(tokens);
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: model ? ['--model', model] : [],
|
||||
env: { CODEX_HOME: codexHomeDir },
|
||||
codexHomeDir,
|
||||
};
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
if ((secret?.type === 'api_key' || secret?.type === 'local') && secret.apiKey) {
|
||||
env.OPENAI_API_KEY = secret.apiKey;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: model ? ['--model', model] : [],
|
||||
env,
|
||||
ccConnectProvider: {
|
||||
name: 'openai',
|
||||
...(env.OPENAI_API_KEY ? { apiKeyEnvKey: 'OPENAI_API_KEY' } : {}),
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (account.vendorId === 'custom') {
|
||||
const protocol = account.apiProtocol || 'openai-completions';
|
||||
if (protocol !== 'openai-responses') {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because Codex 0.137 only supports the Responses wire API. This provider is configured for Chat Completions.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because a Responses-compatible base URL is required.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
if ((secret?.type !== 'api_key' && secret?.type !== 'local') || !secret.apiKey) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because its API key is missing.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
const modelHubBaseUrl = normalizeModelHubCodexResponsesBaseUrl(baseUrl);
|
||||
const providerKey = modelHubBaseUrl ? 'modelhub_openapi' : 'clawx-custom';
|
||||
const envKey = modelHubBaseUrl ? 'BYTEDANCE_OPENAI_API_KEY' : 'CLAWX_CODEX_CUSTOM_API_KEY';
|
||||
const extraHeaderEnvKey = 'CODEX_MODELHUB_EXTRA_HEADER';
|
||||
const normalizedBaseUrl = modelHubBaseUrl ?? normalizeOpenAIResponsesBaseUrl(baseUrl);
|
||||
const env: Record<string, string> = { [envKey]: secret.apiKey };
|
||||
const envHttpHeaders = modelHubBaseUrl
|
||||
? {
|
||||
'Api-Key': envKey,
|
||||
extra: extraHeaderEnvKey,
|
||||
}
|
||||
: undefined;
|
||||
if (modelHubBaseUrl) {
|
||||
env[extraHeaderEnvKey] = JSON.stringify({ session_id: stableModelHubSessionId(account) });
|
||||
}
|
||||
const codexHomeDir = await writeManagedCodexResponsesConfig({
|
||||
providerKey,
|
||||
providerName: modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'),
|
||||
baseUrl: normalizedBaseUrl,
|
||||
envKey,
|
||||
model,
|
||||
envHttpHeaders,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: [
|
||||
'-c',
|
||||
`model_provider=${tomlString(providerKey)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.name=${tomlString(modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'))}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.base_url=${tomlString(normalizedBaseUrl)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.env_key=${tomlString(envKey)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.wire_api="responses"`,
|
||||
...(envHttpHeaders ? [
|
||||
'-c',
|
||||
`model_providers.${providerKey}.env_http_headers=${tomlInlineStringMap(envHttpHeaders)}`,
|
||||
] : []),
|
||||
...(model ? ['--model', model] : []),
|
||||
],
|
||||
env: {
|
||||
...env,
|
||||
CODEX_HOME: codexHomeDir,
|
||||
},
|
||||
codexHomeDir,
|
||||
ccConnectProvider: {
|
||||
name: providerKey,
|
||||
apiKeyEnvKey: envKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
wireApi: 'responses',
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (account.vendorId === 'ollama') {
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: [
|
||||
'--oss',
|
||||
'--local-provider',
|
||||
'ollama',
|
||||
...(model ? ['--model', model] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime currently supports OpenAI/Codex and Ollama provider accounts; "${account.vendorId}" is not supported yet.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncCcConnectProviderProfile(
|
||||
payload?: { providerId?: string; reason?: string },
|
||||
): Promise<CodexProviderProfile> {
|
||||
const providerId = payload?.providerId?.trim() || await getDefaultProviderAccountId();
|
||||
const account = providerId ? await getProviderAccount(providerId) : null;
|
||||
const profile: CodexProviderProfile = account
|
||||
? await buildProfileForAccount(account)
|
||||
: {
|
||||
providerId: null,
|
||||
vendorId: null,
|
||||
supported: true,
|
||||
codexArgs: [],
|
||||
secretAvailable: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const profilePath = getCcConnectProviderProfilePath();
|
||||
await mkdir(dirname(profilePath), { recursive: true });
|
||||
await writeFile(profilePath, JSON.stringify({
|
||||
...publicProfile(profile),
|
||||
reason: payload?.reason ?? 'sync',
|
||||
}, null, 2), 'utf8');
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function toPublicCodexProviderProfile(profile: CodexProviderProfile): CodexProviderProfile {
|
||||
return publicProfile(profile);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import type { SkillsStatusResult } from '@shared/host-api/contract';
|
||||
import { getCcConnectCodexHomeDir } from './cc-connect-paths';
|
||||
import { listLocalSkills, type LocalSkillRecord } from '../services/skills/local-skill-service';
|
||||
|
||||
function safeSkillDirName(skill: Pick<LocalSkillRecord, 'id' | 'slug' | 'baseDir'>): string {
|
||||
const candidate = skill.slug || skill.id || (skill.baseDir ? basename(skill.baseDir) : 'skill');
|
||||
return candidate.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'skill';
|
||||
}
|
||||
|
||||
export async function syncCcConnectSkillRecords(records: LocalSkillRecord[]): Promise<SkillsStatusResult> {
|
||||
const skillsRoot = join(getCcConnectCodexHomeDir(), 'skills');
|
||||
await mkdir(skillsRoot, { recursive: true });
|
||||
const enabled = records.filter((skill) => skill.enabled !== false && skill.baseDir);
|
||||
const manifest: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const skill of enabled) {
|
||||
const targetDirName = safeSkillDirName(skill);
|
||||
const targetDir = join(skillsRoot, targetDirName);
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
await cp(skill.baseDir!, targetDir, { recursive: true, force: true });
|
||||
manifest.push({
|
||||
skillKey: skill.id,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: skill.source,
|
||||
baseDir: targetDir,
|
||||
filePath: join(targetDir, 'SKILL.md'),
|
||||
version: skill.version,
|
||||
bundled: skill.isBundled,
|
||||
always: skill.isCore,
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(join(skillsRoot, 'manifest.json'), JSON.stringify({
|
||||
updatedAt: new Date().toISOString(),
|
||||
skills: manifest,
|
||||
}, null, 2), 'utf8');
|
||||
|
||||
return {
|
||||
skills: manifest.map((skill) => ({
|
||||
skillKey: String(skill.skillKey || ''),
|
||||
slug: typeof skill.slug === 'string' ? skill.slug : undefined,
|
||||
name: typeof skill.name === 'string' ? skill.name : undefined,
|
||||
description: typeof skill.description === 'string' ? skill.description : undefined,
|
||||
disabled: false,
|
||||
version: typeof skill.version === 'string' ? skill.version : undefined,
|
||||
bundled: skill.bundled === true,
|
||||
always: skill.always === true,
|
||||
source: typeof skill.source === 'string' ? skill.source : undefined,
|
||||
baseDir: typeof skill.baseDir === 'string' ? skill.baseDir : undefined,
|
||||
filePath: typeof skill.filePath === 'string' ? skill.filePath : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncCcConnectSkills(): Promise<SkillsStatusResult> {
|
||||
return syncCcConnectSkillRecords(await listLocalSkills());
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rm, appendFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import type { RuntimeSendWithMediaPayload } from './types';
|
||||
import type { CodexProviderProfile } from './cc-connect-provider-profile';
|
||||
import { assertCodexBundle, getCodexBundle, prependCodexPathDir, type CodexBundle } from './codex-paths';
|
||||
|
||||
type CodexBridgeOptions = {
|
||||
codexPath?: string;
|
||||
sessionsDir: string;
|
||||
workDir?: string;
|
||||
mode?: 'suggest' | 'auto-edit' | 'full-auto' | 'yolo';
|
||||
proxyEnvProvider?: () => Record<string, string> | Promise<Record<string, string>>;
|
||||
codexBundle?: CodexBundle;
|
||||
};
|
||||
|
||||
export type CodexBridgeSendResult = {
|
||||
runId: string;
|
||||
assistantMessage: RawMessage;
|
||||
};
|
||||
|
||||
type SessionMetadata = {
|
||||
key: string;
|
||||
displayName?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type TranscriptLine = {
|
||||
type: 'message';
|
||||
message: RawMessage;
|
||||
};
|
||||
|
||||
const MAX_HISTORY_MESSAGES_IN_PROMPT = 16;
|
||||
const MAX_PROMPT_CHARS = 80_000;
|
||||
|
||||
function safeSessionFileName(sessionKey: string): string {
|
||||
return `${createHash('sha256').update(sessionKey).digest('hex')}.jsonl`;
|
||||
}
|
||||
|
||||
function messageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.flatMap((block) => {
|
||||
if (!block || typeof block !== 'object') return [];
|
||||
const record = block as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') return [record.text];
|
||||
if (typeof record.thinking === 'string') return [record.thinking];
|
||||
return [];
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
function buildPrompt(previousMessages: RawMessage[], nextMessage: string): string {
|
||||
const visibleHistory = previousMessages
|
||||
.filter((message) => message.role === 'user' || message.role === 'assistant')
|
||||
.slice(-MAX_HISTORY_MESSAGES_IN_PROMPT)
|
||||
.map((message) => {
|
||||
const role = message.role === 'assistant' ? 'Assistant' : 'User';
|
||||
const text = messageText(message.content);
|
||||
return text ? `${role}: ${text}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = visibleHistory
|
||||
? [
|
||||
'Continue the existing ClawX GUI conversation. Use the prior messages as context.',
|
||||
'',
|
||||
visibleHistory,
|
||||
'',
|
||||
`User: ${nextMessage}`,
|
||||
].join('\n')
|
||||
: nextMessage;
|
||||
|
||||
if (prompt.length <= MAX_PROMPT_CHARS) return prompt;
|
||||
return prompt.slice(prompt.length - MAX_PROMPT_CHARS);
|
||||
}
|
||||
|
||||
function appendMediaReferences(message: string, media: RuntimeSendWithMediaPayload['media'] | undefined): string {
|
||||
if (!media || media.length === 0) return message;
|
||||
const refs = media
|
||||
.map((item) => `[media attached: ${item.filePath} (${item.mimeType}) | ${item.fileName}]`)
|
||||
.join('\n');
|
||||
return message ? `${message}\n\n${refs}` : refs;
|
||||
}
|
||||
|
||||
function extractTextFromCodexEvent(event: unknown): string {
|
||||
if (!event || typeof event !== 'object') return '';
|
||||
const record = event as Record<string, unknown>;
|
||||
|
||||
const directText = record.text ?? record.delta;
|
||||
if (typeof directText === 'string' && directText.trim()) return directText;
|
||||
|
||||
const item = record.item;
|
||||
if (item && typeof item === 'object') {
|
||||
const itemRecord = item as Record<string, unknown>;
|
||||
if (itemRecord.role === 'assistant') {
|
||||
const text = messageText(itemRecord.content);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
|
||||
const message = record.message;
|
||||
if (message && typeof message === 'object') {
|
||||
const messageRecord = message as Record<string, unknown>;
|
||||
if (messageRecord.role === 'assistant') {
|
||||
const text = messageText(messageRecord.content);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function codexModeArgs(mode: CodexBridgeOptions['mode']): string[] {
|
||||
switch (mode) {
|
||||
case 'yolo':
|
||||
return ['--dangerously-bypass-approvals-and-sandbox'];
|
||||
case 'suggest':
|
||||
return ['-c', 'approval_policy="never"', '--sandbox', 'read-only'];
|
||||
case 'auto-edit':
|
||||
case 'full-auto':
|
||||
default:
|
||||
return ['-c', 'approval_policy="never"', '--sandbox', 'workspace-write'];
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexCliBridge {
|
||||
private readonly codexPath: string;
|
||||
private readonly sessionsDir: string;
|
||||
private readonly workDir: string;
|
||||
private readonly mode: CodexBridgeOptions['mode'];
|
||||
private readonly proxyEnvProvider: NonNullable<CodexBridgeOptions['proxyEnvProvider']>;
|
||||
private readonly codexBundle: CodexBundle;
|
||||
private providerProfile: CodexProviderProfile | null = null;
|
||||
|
||||
constructor(options: CodexBridgeOptions) {
|
||||
this.codexBundle = options.codexBundle ?? getCodexBundle();
|
||||
this.codexPath = options.codexPath || assertCodexBundle(this.codexBundle).binaryPath;
|
||||
this.sessionsDir = options.sessionsDir;
|
||||
this.workDir = options.workDir || process.env.CLAWX_CODEX_WORKDIR || process.cwd();
|
||||
this.mode = options.mode || 'full-auto';
|
||||
this.proxyEnvProvider = options.proxyEnvProvider ?? defaultProxyEnvProvider;
|
||||
}
|
||||
|
||||
getSessionsDir(): string {
|
||||
return this.sessionsDir;
|
||||
}
|
||||
|
||||
setProviderProfile(profile: CodexProviderProfile | null): void {
|
||||
this.providerProfile = profile;
|
||||
}
|
||||
|
||||
async diagnose(): Promise<{ success: boolean; stdout: string; stderr: string; error?: string }> {
|
||||
return this.runProcess(['--version'], { captureStdout: true });
|
||||
}
|
||||
|
||||
async send(payload: RuntimeSendWithMediaPayload): Promise<CodexBridgeSendResult> {
|
||||
if (!payload.sessionKey || typeof payload.sessionKey !== 'string') {
|
||||
throw new Error('Invalid Codex send payload: sessionKey is required');
|
||||
}
|
||||
if (!payload.idempotencyKey || typeof payload.idempotencyKey !== 'string') {
|
||||
throw new Error('Invalid Codex send payload: idempotencyKey is required');
|
||||
}
|
||||
if (typeof payload.message !== 'string') {
|
||||
throw new Error('Invalid Codex send payload: message is required');
|
||||
}
|
||||
if (this.providerProfile && !this.providerProfile.supported) {
|
||||
throw new Error(this.providerProfile.unsupportedReason || 'Selected provider is not supported by the cc-connect Codex runtime');
|
||||
}
|
||||
const sessionKey = payload.sessionKey;
|
||||
const runId = `codex-${randomUUID()}`;
|
||||
const startedAt = Date.now();
|
||||
const userMessage: RawMessage = {
|
||||
id: `${runId}:user`,
|
||||
role: 'user',
|
||||
content: payload.message,
|
||||
timestamp: startedAt,
|
||||
...(payload.media && payload.media.length > 0
|
||||
? {
|
||||
_attachedFiles: payload.media.map((item) => ({
|
||||
fileName: item.fileName,
|
||||
mimeType: item.mimeType,
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath: item.filePath,
|
||||
source: 'user-upload' as const,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const previousMessages = await this.readMessages(sessionKey);
|
||||
await this.appendMessage(sessionKey, userMessage);
|
||||
|
||||
const prompt = buildPrompt(
|
||||
previousMessages,
|
||||
appendMediaReferences(payload.message, payload.media),
|
||||
);
|
||||
const outputFile = join(this.sessionsDir, `${runId}.last-message.txt`);
|
||||
const args = [
|
||||
'exec',
|
||||
'--json',
|
||||
'--ignore-user-config',
|
||||
'-C',
|
||||
this.workDir,
|
||||
'--output-last-message',
|
||||
outputFile,
|
||||
...(this.providerProfile?.codexArgs ?? []),
|
||||
...codexModeArgs(this.mode),
|
||||
prompt,
|
||||
];
|
||||
|
||||
const result = await this.runProcess(args, {
|
||||
captureStdout: true,
|
||||
env: this.providerProfile?.env,
|
||||
});
|
||||
let assistantText = '';
|
||||
if (existsSync(outputFile)) {
|
||||
assistantText = (await readFile(outputFile, 'utf8').catch(() => '')).trim();
|
||||
await rm(outputFile, { force: true }).catch(() => {});
|
||||
}
|
||||
if (!assistantText) {
|
||||
assistantText = this.extractLastAssistantText(result.stdout).trim();
|
||||
}
|
||||
if (!assistantText && result.stderr) {
|
||||
assistantText = result.stderr.trim();
|
||||
}
|
||||
if (!assistantText) assistantText = result.success ? '' : 'Codex did not return a response.';
|
||||
|
||||
const assistantMessage: RawMessage = {
|
||||
id: `${runId}:assistant`,
|
||||
role: result.success ? 'assistant' : 'system',
|
||||
content: assistantText,
|
||||
timestamp: Date.now(),
|
||||
...(result.success ? {} : { isError: true, errorMessage: result.error || result.stderr || 'Codex failed' }),
|
||||
};
|
||||
await this.appendMessage(sessionKey, assistantMessage);
|
||||
return { runId, assistantMessage };
|
||||
}
|
||||
|
||||
async listSessions(): Promise<SessionMetadata[]> {
|
||||
await mkdir(this.sessionsDir, { recursive: true });
|
||||
const names = await readdir(this.sessionsDir).catch(() => []);
|
||||
const sessions: SessionMetadata[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.jsonl')) continue;
|
||||
const messages = await this.readMessagesFromPath(join(this.sessionsDir, name));
|
||||
if (messages.length === 0) continue;
|
||||
const firstUser = messages.find((message) => message.role === 'user');
|
||||
const lastTimestamp = messages.reduce((latest, message) => {
|
||||
const ts = normalizeTimestamp(message.timestamp);
|
||||
return ts ? Math.max(latest, ts) : latest;
|
||||
}, 0);
|
||||
const sessionKey = await this.readSessionKeyFromPath(join(this.sessionsDir, name));
|
||||
if (!sessionKey) continue;
|
||||
sessions.push({
|
||||
key: sessionKey,
|
||||
displayName: messageText(firstUser?.content).slice(0, 80) || sessionKey,
|
||||
createdAt: normalizeTimestamp(messages[0]?.timestamp) ?? lastTimestamp,
|
||||
updatedAt: lastTimestamp,
|
||||
});
|
||||
}
|
||||
return sessions.sort((left, right) => right.updatedAt - left.updatedAt);
|
||||
}
|
||||
|
||||
async loadHistory(sessionKey: string, limit = 200): Promise<RawMessage[]> {
|
||||
const messages = await this.readMessages(sessionKey);
|
||||
return messages.slice(-Math.max(1, Math.min(Math.floor(limit), 1000)));
|
||||
}
|
||||
|
||||
async deleteSession(sessionKey: string): Promise<void> {
|
||||
await rm(this.transcriptPath(sessionKey), { force: true });
|
||||
}
|
||||
|
||||
async summarizeSessions(sessionKeys: string[]): Promise<Array<{ sessionKey: string; firstUserText: string | null; lastTimestamp: number | null }>> {
|
||||
return Promise.all(sessionKeys.map(async (sessionKey) => {
|
||||
const messages = await this.readMessages(sessionKey);
|
||||
const firstUser = messages.find((message) => message.role === 'user');
|
||||
const lastTimestamp = messages.reduce<number | null>((latest, message) => {
|
||||
const ts = normalizeTimestamp(message.timestamp);
|
||||
if (!ts) return latest;
|
||||
return latest == null ? ts : Math.max(latest, ts);
|
||||
}, null);
|
||||
return {
|
||||
sessionKey,
|
||||
firstUserText: messageText(firstUser?.content) || null,
|
||||
lastTimestamp,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
private async appendMessage(sessionKey: string, message: RawMessage): Promise<void> {
|
||||
await mkdir(this.sessionsDir, { recursive: true });
|
||||
const line = JSON.stringify({ type: 'message', sessionKey, message }) + '\n';
|
||||
await appendFile(this.transcriptPath(sessionKey), line, 'utf8');
|
||||
}
|
||||
|
||||
private async readMessages(sessionKey: string): Promise<RawMessage[]> {
|
||||
return this.readMessagesFromPath(this.transcriptPath(sessionKey));
|
||||
}
|
||||
|
||||
private async readMessagesFromPath(path: string): Promise<RawMessage[]> {
|
||||
const raw = await readFile(path, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return [];
|
||||
return raw.split(/\r?\n/).flatMap((line): RawMessage[] => {
|
||||
if (!line.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(line) as TranscriptLine;
|
||||
if (parsed?.type === 'message' && parsed.message && typeof parsed.message === 'object') {
|
||||
return [parsed.message];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
private async readSessionKeyFromPath(path: string): Promise<string | null> {
|
||||
const raw = await readFile(path, 'utf8').catch(() => '');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { sessionKey?: unknown };
|
||||
if (typeof parsed.sessionKey === 'string' && parsed.sessionKey) return parsed.sessionKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private transcriptPath(sessionKey: string): string {
|
||||
return join(this.sessionsDir, safeSessionFileName(sessionKey));
|
||||
}
|
||||
|
||||
private extractLastAssistantText(stdout: string): string {
|
||||
let last = '';
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const text = extractTextFromCodexEvent(JSON.parse(line));
|
||||
if (text.trim()) last = text.trim();
|
||||
} catch {
|
||||
// Ignore non-JSON diagnostic lines.
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private async runProcess(
|
||||
args: string[],
|
||||
options: { captureStdout?: boolean; env?: Record<string, string> } = {},
|
||||
): Promise<{ success: boolean; stdout: string; stderr: string; error?: string }> {
|
||||
await mkdir(this.sessionsDir, { recursive: true });
|
||||
const proxyEnv = await this.proxyEnvProvider();
|
||||
return new Promise((resolve) => {
|
||||
const baseEnv = prependCodexPathDir({
|
||||
...process.env,
|
||||
...proxyEnv,
|
||||
...(options.env ?? {}),
|
||||
}, this.codexBundle);
|
||||
const child = spawn(this.codexPath, args, {
|
||||
cwd: this.workDir,
|
||||
env: baseEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (data) => {
|
||||
if (options.captureStdout) stdout += String(data);
|
||||
});
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += String(data);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
stdout,
|
||||
stderr,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
resolve({
|
||||
success: code === 0,
|
||||
stdout,
|
||||
stderr,
|
||||
...(code === 0 ? {} : { error: `Codex exited with code ${code}` }),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultProxyEnvProvider(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const [{ getAllSettings }, { buildProxyEnv }] = await Promise.all([
|
||||
import('@electron/utils/store'),
|
||||
import('@electron/utils/proxy'),
|
||||
]);
|
||||
return buildProxyEnv(await getAllSettings());
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { app } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type CodexBundle = {
|
||||
baseDir: string;
|
||||
binaryPath: string;
|
||||
pathDir: string;
|
||||
targetTriple: string;
|
||||
};
|
||||
|
||||
function codexBinaryName(): string {
|
||||
return process.platform === 'win32' ? 'codex.exe' : 'codex';
|
||||
}
|
||||
|
||||
function codexTargetTriple(platform = process.platform, arch = process.arch): string {
|
||||
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin';
|
||||
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin';
|
||||
if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-musl';
|
||||
if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-musl';
|
||||
if (platform === 'win32' && arch === 'x64') return 'x86_64-pc-windows-msvc';
|
||||
if (platform === 'win32' && arch === 'arm64') return 'aarch64-pc-windows-msvc';
|
||||
throw new Error(`Unsupported Codex target: ${platform}-${arch}`);
|
||||
}
|
||||
|
||||
function baseDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'codex');
|
||||
}
|
||||
return join(process.cwd(), 'build', 'codex', `${process.platform}-${process.arch}`);
|
||||
}
|
||||
|
||||
export function getCodexBundle(): CodexBundle {
|
||||
const base = baseDir();
|
||||
return {
|
||||
baseDir: base,
|
||||
binaryPath: join(base, 'bin', codexBinaryName()),
|
||||
pathDir: join(base, 'codex-path'),
|
||||
targetTriple: codexTargetTriple(),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertCodexBundle(candidate = getCodexBundle()): CodexBundle {
|
||||
if (!existsSync(candidate.binaryPath)) {
|
||||
throw new Error(
|
||||
`Codex binary not found at ${candidate.binaryPath}. Run pnpm run bundle:codex:current before selecting cc-connect runtime.`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function prependCodexPathDir(env: NodeJS.ProcessEnv, bundle = getCodexBundle()): NodeJS.ProcessEnv {
|
||||
if (!existsSync(bundle.pathDir)) return env;
|
||||
const delimiter = process.platform === 'win32' ? ';' : ':';
|
||||
return {
|
||||
...env,
|
||||
PATH: [bundle.pathDir, env.PATH || ''].filter(Boolean).join(delimiter),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getSetting, setSetting } from '@electron/utils/store';
|
||||
import type {
|
||||
RuntimeCapabilities,
|
||||
RuntimeEventName,
|
||||
RuntimeKind,
|
||||
RuntimeProvider,
|
||||
RuntimeStatus,
|
||||
} from './types';
|
||||
|
||||
export type RuntimeManagerOptions = {
|
||||
openclaw: RuntimeProvider;
|
||||
ccConnect: RuntimeProvider;
|
||||
};
|
||||
|
||||
function normalizeRuntimeKind(value: unknown): RuntimeKind {
|
||||
return value === 'cc-connect' ? 'cc-connect' : 'openclaw';
|
||||
}
|
||||
|
||||
export class RuntimeManager extends EventEmitter {
|
||||
private activeKind: RuntimeKind | null = null;
|
||||
private readonly providers: Record<RuntimeKind, RuntimeProvider>;
|
||||
|
||||
constructor(options: RuntimeManagerOptions) {
|
||||
super();
|
||||
this.providers = {
|
||||
openclaw: options.openclaw,
|
||||
'cc-connect': options.ccConnect,
|
||||
};
|
||||
this.forwardProviderEvents(options.openclaw);
|
||||
this.forwardProviderEvents(options.ccConnect);
|
||||
}
|
||||
|
||||
async getActiveKind(): Promise<RuntimeKind> {
|
||||
if (!this.activeKind) {
|
||||
this.activeKind = normalizeRuntimeKind(await getSetting('runtimeKind'));
|
||||
}
|
||||
return this.activeKind;
|
||||
}
|
||||
|
||||
getActiveProvider(): RuntimeProvider {
|
||||
return this.providers[this.activeKind ?? 'openclaw'];
|
||||
}
|
||||
|
||||
async setActiveKind(kind: RuntimeKind): Promise<void> {
|
||||
const nextKind = normalizeRuntimeKind(kind);
|
||||
const previous = this.getActiveProvider();
|
||||
if ((this.activeKind ?? 'openclaw') !== nextKind) {
|
||||
await previous.stop();
|
||||
}
|
||||
this.activeKind = nextKind;
|
||||
await setSetting('runtimeKind', nextKind);
|
||||
this.emit('status', this.getStatus());
|
||||
}
|
||||
|
||||
listCapabilities(): RuntimeCapabilities {
|
||||
return this.getActiveProvider().listCapabilities();
|
||||
}
|
||||
|
||||
getStatus(): RuntimeStatus {
|
||||
return this.getActiveProvider().getStatus();
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return this.getActiveProvider().start();
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return this.getActiveProvider().stop();
|
||||
}
|
||||
|
||||
restart(): Promise<void> {
|
||||
return this.getActiveProvider().restart();
|
||||
}
|
||||
|
||||
checkHealth(options?: { probe?: boolean }) {
|
||||
return this.getActiveProvider().checkHealth(options);
|
||||
}
|
||||
|
||||
rpc<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
|
||||
return this.getActiveProvider().rpc(method, params, timeoutMs);
|
||||
}
|
||||
|
||||
private forwardProviderEvents(provider: RuntimeProvider): void {
|
||||
const events: RuntimeEventName[] = [
|
||||
'status',
|
||||
'error',
|
||||
'notification',
|
||||
'gateway:health',
|
||||
'gateway:presence',
|
||||
'chat:message',
|
||||
'chat:runtime-event',
|
||||
'channel:status',
|
||||
'exit',
|
||||
];
|
||||
for (const eventName of events) {
|
||||
provider.on(eventName, (payload: unknown) => {
|
||||
if (provider !== this.getActiveProvider()) return;
|
||||
if (eventName === 'status' && payload && typeof payload === 'object') {
|
||||
this.emit(eventName, {
|
||||
...(payload as Record<string, unknown>),
|
||||
runtimeKind: provider.kind,
|
||||
capabilities: provider.listCapabilities(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.emit(eventName, payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type {
|
||||
RuntimeProvider,
|
||||
RuntimeSendWithMediaPayload,
|
||||
} from './types';
|
||||
import {
|
||||
OPENCLAW_RUNTIME_CAPABILITIES,
|
||||
withRuntimeStatus,
|
||||
} from './types';
|
||||
import { createChatSendWithMediaHandler } from '../services/chat-api';
|
||||
import { createSessionsApi } from '../services/sessions-api';
|
||||
import { logger } from '../utils/logger';
|
||||
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
|
||||
import type { OpenClawDoctorMode } from '@shared/host-api/contract';
|
||||
|
||||
export class OpenClawRuntimeProvider extends EventEmitter implements RuntimeProvider {
|
||||
readonly kind = 'openclaw' as const;
|
||||
private readonly sessionsApi = createSessionsApi();
|
||||
|
||||
constructor(private readonly gatewayManager: GatewayManager) {
|
||||
super();
|
||||
const forward = (eventName: string) => (payload: unknown) => {
|
||||
this.emit(eventName, payload);
|
||||
};
|
||||
for (const eventName of [
|
||||
'status',
|
||||
'error',
|
||||
'notification',
|
||||
'gateway:health',
|
||||
'gateway:presence',
|
||||
'chat:message',
|
||||
'chat:runtime-event',
|
||||
'channel:status',
|
||||
'exit',
|
||||
]) {
|
||||
this.gatewayManager.on(eventName, forward(eventName));
|
||||
}
|
||||
}
|
||||
|
||||
listCapabilities() {
|
||||
return OPENCLAW_RUNTIME_CAPABILITIES;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return withRuntimeStatus(this.gatewayManager.getStatus(), this.kind, this.listCapabilities());
|
||||
}
|
||||
|
||||
start() {
|
||||
return this.gatewayManager.start();
|
||||
}
|
||||
|
||||
stop() {
|
||||
return this.gatewayManager.stop();
|
||||
}
|
||||
|
||||
restart() {
|
||||
return this.gatewayManager.restart();
|
||||
}
|
||||
|
||||
checkHealth(options?: { probe?: boolean }) {
|
||||
return this.gatewayManager.checkHealth(options);
|
||||
}
|
||||
|
||||
rpc<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
|
||||
return this.gatewayManager.rpc(method, params, timeoutMs);
|
||||
}
|
||||
|
||||
async sendMessageWithMedia(payload: RuntimeSendWithMediaPayload) {
|
||||
const handler = createChatSendWithMediaHandler(this.gatewayManager, logger);
|
||||
const response = await handler(payload);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'OpenClaw chat send failed');
|
||||
}
|
||||
return response.result ?? {};
|
||||
}
|
||||
|
||||
async listSessions(payload?: unknown) {
|
||||
return await this.sessionsApi.summaries(payload as never);
|
||||
}
|
||||
|
||||
async loadHistory(payload?: unknown) {
|
||||
return await this.sessionsApi.history(payload as never);
|
||||
}
|
||||
|
||||
async deleteSession(payload?: unknown) {
|
||||
return await this.sessionsApi.delete(payload as never);
|
||||
}
|
||||
|
||||
async listLogs() {
|
||||
return { content: logger.getRecentLogs().join('\n') };
|
||||
}
|
||||
|
||||
runDoctor(mode: OpenClawDoctorMode) {
|
||||
return mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import type { OpenClawDoctorMode, OpenClawDoctorResult } from '@shared/host-api/contract';
|
||||
import type {
|
||||
GatewayHealth,
|
||||
GatewayStatus,
|
||||
RuntimeCapabilities,
|
||||
RuntimeKind,
|
||||
} from '@shared/types/gateway';
|
||||
|
||||
export type { RuntimeCapabilities, RuntimeKind };
|
||||
|
||||
export type RuntimeStatus = GatewayStatus & {
|
||||
runtimeKind: RuntimeKind;
|
||||
capabilities: RuntimeCapabilities;
|
||||
configDir?: string;
|
||||
};
|
||||
|
||||
export type RuntimeHealth = GatewayHealth;
|
||||
|
||||
export type RuntimeSessionListResult = {
|
||||
success?: boolean;
|
||||
sessions?: Array<{ key: string; displayName?: string }>;
|
||||
summaries?: Array<{ sessionKey: string; firstUserText: string | null; lastTimestamp: number | null }>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RuntimeHistoryResult = {
|
||||
success?: boolean;
|
||||
messages?: RawMessage[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RuntimeDeleteSessionResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RuntimeLogResult = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type RuntimeSendWithMediaPayload = {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
|
||||
};
|
||||
|
||||
export type RuntimeSendWithMediaResult = {
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
export type RuntimeEventName =
|
||||
| 'status'
|
||||
| 'error'
|
||||
| 'notification'
|
||||
| 'gateway:health'
|
||||
| 'gateway:presence'
|
||||
| 'chat:message'
|
||||
| 'chat:runtime-event'
|
||||
| 'channel:status'
|
||||
| 'exit';
|
||||
|
||||
export type RuntimeProvider = {
|
||||
kind: RuntimeKind;
|
||||
on: EventEmitter['on'];
|
||||
off: EventEmitter['off'];
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
getStatus: () => RuntimeStatus;
|
||||
checkHealth: (options?: { probe?: boolean }) => Promise<RuntimeHealth>;
|
||||
rpc: <T = unknown>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
|
||||
sendMessageWithMedia: (payload: RuntimeSendWithMediaPayload) => Promise<RuntimeSendWithMediaResult>;
|
||||
listSessions: (payload?: unknown) => Promise<RuntimeSessionListResult>;
|
||||
loadHistory: (payload?: unknown) => Promise<RuntimeHistoryResult>;
|
||||
deleteSession: (payload?: unknown) => Promise<RuntimeDeleteSessionResult>;
|
||||
listLogs: (payload?: { tailLines?: number }) => Promise<RuntimeLogResult>;
|
||||
runDoctor: (mode: OpenClawDoctorMode) => Promise<OpenClawDoctorResult>;
|
||||
listCapabilities: () => RuntimeCapabilities;
|
||||
};
|
||||
|
||||
export const OPENCLAW_RUNTIME_CAPABILITIES: RuntimeCapabilities = {
|
||||
chat: true,
|
||||
sessions: true,
|
||||
history: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
channels: true,
|
||||
cron: true,
|
||||
logs: true,
|
||||
skills: true,
|
||||
doctor: true,
|
||||
controlUi: true,
|
||||
};
|
||||
|
||||
export const CC_CONNECT_RUNTIME_CAPABILITIES: RuntimeCapabilities = {
|
||||
chat: true,
|
||||
sessions: true,
|
||||
history: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
channels: true,
|
||||
cron: true,
|
||||
logs: true,
|
||||
skills: true,
|
||||
doctor: true,
|
||||
controlUi: true,
|
||||
};
|
||||
|
||||
export function withRuntimeStatus(
|
||||
status: GatewayStatus,
|
||||
runtimeKind: RuntimeKind,
|
||||
capabilities: RuntimeCapabilities,
|
||||
configDir?: string,
|
||||
): RuntimeStatus {
|
||||
return {
|
||||
...status,
|
||||
runtimeKind,
|
||||
capabilities,
|
||||
...(configDir ? { configDir } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
@@ -6,11 +7,15 @@ type OpenClawDoctorPayload = {
|
||||
mode?: unknown;
|
||||
};
|
||||
|
||||
export function createAppApi(): CompleteHostServiceRegistry['app'] {
|
||||
export function createAppApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['app'] {
|
||||
return {
|
||||
openClawDoctor: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {};
|
||||
return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
|
||||
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
|
||||
if (runtimeManager) {
|
||||
return runtimeManager.getActiveProvider().runDoctor(mode);
|
||||
}
|
||||
return mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
import { buildGatewayHealthSummary } from '../utils/gateway-health';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { GatewayManager, GatewayHealthSummary } from '../gateway/manager';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000;
|
||||
@@ -83,6 +84,7 @@ async function listWhatsAppDirectoryPeersFromConfig(_params: unknown): Promise<u
|
||||
|
||||
type ChannelsApiContext = {
|
||||
gatewayManager: GatewayManager;
|
||||
runtimeManager?: RuntimeManager;
|
||||
mainWindow?: BrowserWindow;
|
||||
};
|
||||
|
||||
@@ -265,11 +267,12 @@ export async function buildChannelAccountsView(
|
||||
]);
|
||||
|
||||
let gatewayStatus: GatewayChannelStatusPayload | null = null;
|
||||
const runtimeStatusSource = ctx.runtimeManager ?? ctx.gatewayManager;
|
||||
if (!skipRuntime) {
|
||||
try {
|
||||
const probe = options?.probe === true;
|
||||
const rpcStartedAt = Date.now();
|
||||
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>(
|
||||
gatewayStatus = await runtimeStatusSource.rpc<GatewayChannelStatusPayload>(
|
||||
'channels.status',
|
||||
{ probe },
|
||||
probe ? 5000 : 8000,
|
||||
@@ -292,8 +295,9 @@ export async function buildChannelAccountsView(
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
consecutiveRpcFailures: 0,
|
||||
};
|
||||
const status = ctx.runtimeManager?.getStatus() ?? ctx.gatewayManager.getStatus();
|
||||
const gatewayHealth = buildGatewayHealthSummary({
|
||||
status: ctx.gatewayManager.getStatus(),
|
||||
status,
|
||||
diagnostics: gatewayDiagnostics,
|
||||
lastChannelsStatusOkAt,
|
||||
lastChannelsStatusFailureAt,
|
||||
@@ -379,7 +383,7 @@ export async function buildChannelAccountsView(
|
||||
const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, {
|
||||
gatewayHealthState: effectiveGatewayHealthState,
|
||||
});
|
||||
const groupStatus = !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
|
||||
const groupStatus = !gatewayStatus && !skipRuntime && status.state === 'running'
|
||||
? 'degraded'
|
||||
: effectiveGatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected'
|
||||
? 'degraded'
|
||||
@@ -391,7 +395,7 @@ export async function buildChannelAccountsView(
|
||||
channelType: uiChannelType,
|
||||
defaultAccountId,
|
||||
status: groupStatus,
|
||||
statusReason: !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
|
||||
statusReason: !gatewayStatus && !skipRuntime && status.state === 'running'
|
||||
? 'channels_status_timeout'
|
||||
: groupStatus === 'degraded' && effectiveGatewayHealthState
|
||||
? overlayStatusReason(gatewayHealth, 'gateway_degraded')
|
||||
@@ -985,13 +989,29 @@ async function ensureScopedChannelBinding(channelType: string, accountId?: strin
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
}
|
||||
|
||||
function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void {
|
||||
async function isCcConnectRuntime(ctx: ChannelsApiContext): Promise<boolean> {
|
||||
return ctx.runtimeManager ? await ctx.runtimeManager.getActiveKind() === 'cc-connect' : false;
|
||||
}
|
||||
|
||||
async function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
if (ctx.runtimeManager?.getStatus().state === 'stopped') return;
|
||||
await ctx.runtimeManager?.restart();
|
||||
void reason;
|
||||
return;
|
||||
}
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
void reason;
|
||||
}
|
||||
|
||||
function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void {
|
||||
async function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
if (ctx.runtimeManager?.getStatus().state === 'stopped') return;
|
||||
await ctx.runtimeManager?.restart();
|
||||
void reason;
|
||||
return;
|
||||
}
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
|
||||
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
|
||||
@@ -1072,7 +1092,7 @@ async function awaitWeChatQrLogin(
|
||||
});
|
||||
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
|
||||
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
|
||||
await scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
|
||||
|
||||
if (activeQrLogins.get(loginKey) !== sessionKey) return;
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
|
||||
@@ -1135,7 +1155,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = requireString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
await setChannelDefaultAccount(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
|
||||
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
bindingSave: async (payload) => {
|
||||
@@ -1152,7 +1172,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
}
|
||||
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
|
||||
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
bindingDelete: async (payload) => {
|
||||
@@ -1160,7 +1180,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = optionalString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
|
||||
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
validateConfig: async (payload) => {
|
||||
@@ -1182,19 +1202,19 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const existingValues = await getChannelFormValues(channelType, accountId);
|
||||
if (isSameConfigValues(existingValues, config)) {
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
|
||||
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
|
||||
return { success: true, noChange: true };
|
||||
}
|
||||
await saveChannelConfig(channelType, config, accountId);
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
|
||||
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
setEnabled: async (payload) => {
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
const enabled = isRecord(payload) && payload.enabled === true;
|
||||
await setChannelEnabled(channelType, enabled);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
|
||||
await scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
|
||||
return { success: true };
|
||||
},
|
||||
formValues: async (payload) => {
|
||||
@@ -1209,11 +1229,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
if (accountId) {
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
await clearChannelBinding(storedChannelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
|
||||
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
|
||||
} else {
|
||||
await deleteChannelConfig(channelType);
|
||||
await clearAllBindingsForChannel(storedChannelType);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
|
||||
await scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import type { RuntimeSendWithMediaPayload } from '../runtime/types';
|
||||
import { logger } from '../utils/logger';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
@@ -38,9 +40,11 @@ function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: str
|
||||
});
|
||||
}
|
||||
|
||||
export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['chat'] {
|
||||
return {
|
||||
sendWithMedia: async (payload) => {
|
||||
export function createChatSendWithMediaHandler(
|
||||
gatewayManager: GatewayManager,
|
||||
log = logger,
|
||||
): (payload?: unknown) => ReturnType<CompleteHostServiceRegistry['chat']['sendWithMedia']> {
|
||||
return async (payload) => {
|
||||
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
|
||||
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
|
||||
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
|
||||
@@ -58,7 +62,7 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const item of media) {
|
||||
const exists = await fsP.access(item.filePath).then(() => true, () => false);
|
||||
logger.info(
|
||||
log.info(
|
||||
`[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`,
|
||||
);
|
||||
|
||||
@@ -69,7 +73,7 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
if (VISION_MIME_TYPES.has(item.mimeType)) {
|
||||
const fileBuffer = await fsP.readFile(item.filePath);
|
||||
const base64Data = fileBuffer.toString('base64');
|
||||
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
|
||||
log.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
|
||||
imageAttachments.push({
|
||||
content: base64Data,
|
||||
mimeType: item.mimeType,
|
||||
@@ -94,17 +98,41 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
rpcParams.attachments = imageAttachments;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
log.info(
|
||||
`[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
|
||||
);
|
||||
const result = await gatewayManager.rpc('chat.send', rpcParams, 120000);
|
||||
logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`);
|
||||
log.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`);
|
||||
const response = isRecord(result) && typeof result.runId === 'string'
|
||||
? { runId: result.runId }
|
||||
: undefined;
|
||||
return { success: true, ...(response ? { result: response } : {}) };
|
||||
} catch (error) {
|
||||
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
|
||||
log.error(`[chat:sendWithMedia] Error: ${String(error)}`);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createChatApi({
|
||||
gatewayManager,
|
||||
runtimeManager,
|
||||
}: {
|
||||
gatewayManager: GatewayManager;
|
||||
runtimeManager?: RuntimeManager;
|
||||
}): CompleteHostServiceRegistry['chat'] {
|
||||
const openClawHandler = createChatSendWithMediaHandler(gatewayManager, logger);
|
||||
return {
|
||||
sendWithMedia: async (payload) => {
|
||||
if (!runtimeManager) {
|
||||
return openClawHandler(payload);
|
||||
}
|
||||
try {
|
||||
const result = await runtimeManager.getActiveProvider().sendMessageWithMedia(
|
||||
(isRecord(payload) ? payload : {}) as RuntimeSendWithMediaPayload,
|
||||
);
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import { getOpenClawConfigDir } from '../utils/paths';
|
||||
import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
|
||||
@@ -468,10 +469,28 @@ function getId(payload: unknown): string {
|
||||
return id.trim();
|
||||
}
|
||||
|
||||
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
|
||||
async function isCcConnectRuntime(runtimeManager?: RuntimeManager): Promise<boolean> {
|
||||
return runtimeManager ? await runtimeManager.getActiveKind() === 'cc-connect' : false;
|
||||
}
|
||||
|
||||
export function createCronApi({
|
||||
gatewayManager,
|
||||
runtimeManager,
|
||||
}: {
|
||||
gatewayManager: GatewayManager;
|
||||
runtimeManager?: RuntimeManager;
|
||||
}): CompleteHostServiceRegistry['cron'] {
|
||||
return {
|
||||
list: async () => listCronJobs(gatewayManager),
|
||||
list: async () => {
|
||||
if (await isCcConnectRuntime(runtimeManager)) {
|
||||
return await runtimeManager!.rpc<CronJob[]>('cron.list');
|
||||
}
|
||||
return listCronJobs(gatewayManager);
|
||||
},
|
||||
create: async (payload) => {
|
||||
if (await isCcConnectRuntime(runtimeManager)) {
|
||||
return await runtimeManager!.rpc<CronJob>('cron.create', payload);
|
||||
}
|
||||
const input = payload;
|
||||
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
|
||||
const delivery = normalizeCronDelivery(input.delivery);
|
||||
@@ -495,6 +514,9 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
return transformCronJob(result as GatewayCronJob);
|
||||
},
|
||||
update: async (payload) => {
|
||||
if (await isCcConnectRuntime(runtimeManager)) {
|
||||
return await runtimeManager!.rpc<CronJob>('cron.update', payload);
|
||||
}
|
||||
const body = payload;
|
||||
const id = getId(body);
|
||||
const input = isRecord(body.input) ? body.input : {};
|
||||
@@ -520,16 +542,36 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
}
|
||||
return transformCronJob(result as GatewayCronJob);
|
||||
},
|
||||
delete: async (payload) => gatewayManager.rpc('cron.remove', { id: getId(payload) }),
|
||||
delete: async (payload) => {
|
||||
if (await isCcConnectRuntime(runtimeManager)) {
|
||||
return await runtimeManager!.rpc('cron.remove', { id: getId(payload) });
|
||||
}
|
||||
return gatewayManager.rpc('cron.remove', { id: getId(payload) });
|
||||
},
|
||||
toggle: async (payload) => {
|
||||
if (await isCcConnectRuntime(runtimeManager)) {
|
||||
return await runtimeManager!.rpc('cron.update', { id: getId(payload), input: { enabled: payload.enabled === true } });
|
||||
}
|
||||
const body = payload;
|
||||
return gatewayManager.rpc('cron.update', {
|
||||
id: getId(body),
|
||||
patch: { enabled: body.enabled === true },
|
||||
});
|
||||
},
|
||||
trigger: async (payload) => gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }),
|
||||
trigger: async (payload) => {
|
||||
if (await isCcConnectRuntime(runtimeManager)) {
|
||||
return await runtimeManager!.rpc('cron.run', { id: getId(payload), mode: 'force' });
|
||||
}
|
||||
return gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' });
|
||||
},
|
||||
sessionHistory: async (payload) => {
|
||||
if (await isCcConnectRuntime(runtimeManager)) {
|
||||
const body = payload;
|
||||
return await runtimeManager!.rpc('chat.history', {
|
||||
sessionKey: typeof body.sessionKey === 'string' ? body.sessionKey : '',
|
||||
limit: typeof body.limit === 'number' ? body.limit : 200,
|
||||
});
|
||||
}
|
||||
const body = payload;
|
||||
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
|
||||
const parsedSession = parseCronSessionKey(sessionKey);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing';
|
||||
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
|
||||
@@ -30,33 +31,50 @@ function parseTimeoutMs(timeoutMs: unknown): number | undefined {
|
||||
}
|
||||
|
||||
export function createGatewayApi(
|
||||
gatewayManager: GatewayManager,
|
||||
runtimeManager: RuntimeManager,
|
||||
gatewayRpcBackpressure: GatewayRpcBackpressure,
|
||||
gatewayManager?: GatewayManager,
|
||||
): CompleteHostServiceRegistry['gateway'] {
|
||||
return {
|
||||
status: () => gatewayManager.getStatus(),
|
||||
status: () => runtimeManager.getStatus(),
|
||||
start: async () => {
|
||||
await gatewayManager.start();
|
||||
await runtimeManager.start();
|
||||
return { success: true };
|
||||
},
|
||||
stop: async () => {
|
||||
await gatewayManager.stop();
|
||||
await runtimeManager.stop();
|
||||
return { success: true };
|
||||
},
|
||||
restart: async () => {
|
||||
await gatewayManager.restart();
|
||||
await runtimeManager.restart();
|
||||
return { success: true };
|
||||
},
|
||||
health: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as HealthPayload : {};
|
||||
return gatewayManager.checkHealth({ probe: body.probe === true });
|
||||
return runtimeManager.checkHealth({ probe: body.probe === true });
|
||||
},
|
||||
controlUi: async (payload) => {
|
||||
const status = runtimeManager.getStatus();
|
||||
const body = isRecord(payload) ? payload as ControlUiPayload : {};
|
||||
const status = gatewayManager.getStatus();
|
||||
const view = body.view === 'dreams' ? 'dreams' : undefined;
|
||||
const runtimeControlUiPayload = view ? { view } : {};
|
||||
if (status.runtimeKind === 'cc-connect') {
|
||||
if (!status.capabilities?.controlUi) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'cc-connect runtime does not support Web Admin',
|
||||
};
|
||||
}
|
||||
return await runtimeManager.rpc('runtime.controlUi', runtimeControlUiPayload, 5000);
|
||||
}
|
||||
if (!status.capabilities?.controlUi || status.runtimeKind !== 'openclaw' || !gatewayManager) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${status.runtimeKind ?? 'runtime'} runtime does not support OpenClaw Control UI`,
|
||||
};
|
||||
}
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
const view = body.view === 'dreams' ? 'dreams' : undefined;
|
||||
const url = buildOpenClawControlUiUrl(port, token, { view });
|
||||
scheduleControlUiDeviceAutoApproval(gatewayManager);
|
||||
return { success: true, url, token, port };
|
||||
@@ -72,7 +90,7 @@ export function createGatewayApi(
|
||||
method,
|
||||
body.params,
|
||||
timeoutMs,
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => runtimeManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron';
|
||||
import type { HostApiContract } from '@shared/host-api/contract';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import type { ProviderConfig } from '../utils/secure-storage';
|
||||
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
|
||||
import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth';
|
||||
@@ -25,6 +26,7 @@ import { isRecord } from './payload-utils';
|
||||
|
||||
type ProvidersApiContext = {
|
||||
gatewayManager: GatewayManager;
|
||||
runtimeManager?: RuntimeManager;
|
||||
mainWindow: BrowserWindow;
|
||||
};
|
||||
|
||||
@@ -135,6 +137,104 @@ function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: st
|
||||
};
|
||||
}
|
||||
|
||||
async function isCcConnectRuntime(ctx: Pick<ProvidersApiContext, 'runtimeManager'>): Promise<boolean> {
|
||||
if (!ctx.runtimeManager) return false;
|
||||
return await ctx.runtimeManager.getActiveKind() === 'cc-connect';
|
||||
}
|
||||
|
||||
async function syncCcConnectProviders(
|
||||
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
|
||||
payload: { providerId?: string; reason: string },
|
||||
): Promise<void> {
|
||||
await ctx.runtimeManager?.rpc('providers.sync', payload);
|
||||
}
|
||||
|
||||
async function syncProviderApiKeyToActiveRuntime(
|
||||
providerType: string,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
|
||||
): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId, reason: 'api-key' });
|
||||
return;
|
||||
}
|
||||
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
|
||||
}
|
||||
|
||||
async function syncSavedProviderToActiveRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
|
||||
): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId: config.id, reason: 'save' });
|
||||
return;
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, apiKey, ctx.gatewayManager);
|
||||
}
|
||||
|
||||
async function syncUpdatedProviderToActiveRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
|
||||
): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId: config.id, reason: 'update' });
|
||||
return;
|
||||
}
|
||||
await syncUpdatedProviderToRuntime(config, apiKey, ctx.gatewayManager);
|
||||
}
|
||||
|
||||
async function syncDeletedProviderToActiveRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId, reason: 'delete' });
|
||||
return;
|
||||
}
|
||||
await syncDeletedProviderToRuntime(provider, providerId, ctx.gatewayManager, runtimeProviderKey);
|
||||
}
|
||||
|
||||
async function syncDeletedProviderApiKeyToActiveRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId, reason: 'delete-api-key' });
|
||||
return;
|
||||
}
|
||||
await syncDeletedProviderApiKeyToRuntime(provider, providerId, runtimeProviderKey);
|
||||
}
|
||||
|
||||
async function syncDefaultProviderToActiveRuntime(
|
||||
providerId: string,
|
||||
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
|
||||
): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId, reason: 'set-default' });
|
||||
return;
|
||||
}
|
||||
await syncDefaultProviderToRuntime(providerId, ctx.gatewayManager);
|
||||
}
|
||||
|
||||
async function removeProviderFromActiveRuntime(
|
||||
providerKey: string,
|
||||
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId, reason: 'remove-provider' });
|
||||
return;
|
||||
}
|
||||
await removeProviderFromOpenClaw(providerKey);
|
||||
}
|
||||
|
||||
async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
const body = getPayloadRecord(payload, 'validateKey');
|
||||
@@ -174,7 +274,7 @@ async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ v
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: GatewayManager) {
|
||||
async function saveProvider(payload: ProviderPayload<'save'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const { config, apiKey } = getSavePayload(payload);
|
||||
try {
|
||||
@@ -183,44 +283,44 @@ async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: G
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
await syncProviderApiKeyToActiveRuntime(config.type, config.id, trimmedKey, ctx);
|
||||
}
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
await syncSavedProviderToActiveRuntime(config, apiKey, ctx);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager?: GatewayManager) {
|
||||
async function deleteProvider(payload: ProviderPayload<'delete'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const providerId = getProviderId(payload, 'delete');
|
||||
try {
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
await providerService._deleteProviderInternal(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
await syncDeletedProviderToActiveRuntime(existing, providerId, ctx);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>) {
|
||||
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const { providerId, apiKey } = getApiKeyPayload(payload, 'setApiKey');
|
||||
try {
|
||||
await providerService._setProviderApiKeyInternal(providerId, apiKey);
|
||||
const provider = await providerService._getProviderInternal(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
|
||||
await syncProviderApiKeyToActiveRuntime(providerType, providerId, apiKey, ctx);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, gatewayManager?: GatewayManager) {
|
||||
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const { providerId, updates, apiKey } = getProviderUpdatePayload(payload);
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
@@ -244,24 +344,28 @@ async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>,
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||
await syncProviderApiKeyToActiveRuntime(nextConfig.type, providerId, trimmedKey, ctx);
|
||||
} else {
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
await removeProviderFromActiveRuntime(ock, ctx, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
await syncUpdatedProviderToActiveRuntime(nextConfig, apiKey, ctx);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
try {
|
||||
await providerService._saveProviderInternal(existing);
|
||||
if (previousKey) {
|
||||
await providerService._setProviderApiKeyInternal(providerId, previousKey);
|
||||
await saveProviderKeyToOpenClaw(previousOck, previousKey);
|
||||
if (await isCcConnectRuntime(ctx)) {
|
||||
await syncCcConnectProviders(ctx, { providerId, reason: 'rollback' });
|
||||
} else {
|
||||
await saveProviderKeyToOpenClaw(previousOck, previousKey);
|
||||
}
|
||||
} else {
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
await removeProviderFromOpenClaw(previousOck);
|
||||
await removeProviderFromActiveRuntime(previousOck, ctx, providerId);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
logger.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
||||
@@ -270,32 +374,32 @@ async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>,
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>) {
|
||||
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const providerId = getProviderId(payload, 'deleteApiKey');
|
||||
try {
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
const provider = await providerService._getProviderInternal(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
|
||||
await syncDeletedProviderApiKeyToActiveRuntime(provider, providerId, ctx);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, gatewayManager?: GatewayManager) {
|
||||
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const providerId = getProviderId(payload, 'setDefault');
|
||||
try {
|
||||
await providerService._setDefaultProviderInternal(providerId);
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
await syncDefaultProviderToActiveRuntime(providerId, ctx);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayManager?: GatewayManager) {
|
||||
async function createAccount(payload: ProviderPayload<'createAccount'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const body = getPayloadRecord(payload, 'createAccount');
|
||||
if (!isRecord(body.account)) {
|
||||
@@ -304,14 +408,14 @@ async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayM
|
||||
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
|
||||
try {
|
||||
const account = await providerService.createAccount(body.account as unknown as ProviderAccount, apiKey);
|
||||
await syncSavedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
|
||||
await syncSavedProviderToActiveRuntime(providerAccountToConfig(account), apiKey, ctx);
|
||||
return { success: true, account };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayManager?: GatewayManager) {
|
||||
async function updateAccount(payload: ProviderPayload<'updateAccount'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const body = getPayloadRecord(payload, 'updateAccount');
|
||||
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
|
||||
@@ -330,7 +434,7 @@ async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayM
|
||||
return { success: true, noChange: true, account: existing };
|
||||
}
|
||||
const account = await providerService.updateAccount(accountId, updates, apiKey);
|
||||
await syncUpdatedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
|
||||
await syncUpdatedProviderToActiveRuntime(providerAccountToConfig(account), apiKey, ctx);
|
||||
return { success: true, account };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
@@ -339,7 +443,7 @@ async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayM
|
||||
|
||||
async function deleteAccount(
|
||||
payload: ProviderPayload<'deleteAccount'> & { apiKeyOnly?: boolean },
|
||||
gatewayManager?: GatewayManager,
|
||||
ctx: ProvidersApiContext,
|
||||
) {
|
||||
const providerService = getProviderService();
|
||||
const body = getPayloadRecord(payload, 'deleteAccount');
|
||||
@@ -355,18 +459,19 @@ async function deleteAccount(
|
||||
: undefined;
|
||||
if (apiKeyOnly) {
|
||||
await providerService._deleteProviderApiKeyInternal(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
await syncDeletedProviderApiKeyToActiveRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
ctx,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
await providerService.deleteAccount(accountId);
|
||||
await syncDeletedProviderToRuntime(
|
||||
await syncDeletedProviderToActiveRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
gatewayManager,
|
||||
ctx,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
return { success: true };
|
||||
@@ -375,7 +480,7 @@ async function deleteAccount(
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, gatewayManager?: GatewayManager) {
|
||||
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, ctx: ProvidersApiContext) {
|
||||
const providerService = getProviderService();
|
||||
const accountId = getAccountId(payload, 'setDefaultAccount');
|
||||
try {
|
||||
@@ -384,7 +489,7 @@ async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>,
|
||||
return { success: true, noChange: true };
|
||||
}
|
||||
await providerService.setDefaultAccount(accountId);
|
||||
await syncDefaultProviderToRuntime(accountId, gatewayManager);
|
||||
await syncDefaultProviderToActiveRuntime(accountId, ctx);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
@@ -452,12 +557,12 @@ export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServic
|
||||
hasApiKey: async (payload) => providerService._hasProviderApiKeyInternal(getProviderId(payload, 'hasApiKey')),
|
||||
getApiKey: async (payload) => providerService._getProviderApiKeyInternal(getProviderId(payload, 'getApiKey')),
|
||||
validateKey,
|
||||
save: async (payload) => saveProvider(payload, ctx.gatewayManager),
|
||||
delete: async (payload) => deleteProvider(payload, ctx.gatewayManager),
|
||||
setApiKey: setProviderApiKey,
|
||||
updateWithKey: async (payload) => updateProviderWithKey(payload, ctx.gatewayManager),
|
||||
deleteApiKey: deleteProviderApiKey,
|
||||
setDefault: async (payload) => setDefaultProvider(payload, ctx.gatewayManager),
|
||||
save: async (payload) => saveProvider(payload, ctx),
|
||||
delete: async (payload) => deleteProvider(payload, ctx),
|
||||
setApiKey: async (payload) => setProviderApiKey(payload, ctx),
|
||||
updateWithKey: async (payload) => updateProviderWithKey(payload, ctx),
|
||||
deleteApiKey: async (payload) => deleteProviderApiKey(payload, ctx),
|
||||
setDefault: async (payload) => setDefaultProvider(payload, ctx),
|
||||
accounts: async () => providerService.listAccounts(),
|
||||
vendors: async () => providerService.listVendors(),
|
||||
accountKeyInfo: async () => providerService.listAccountsKeyInfo(),
|
||||
@@ -465,11 +570,11 @@ export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServic
|
||||
getAccount: async (payload) => providerService.getAccount(getAccountId(payload, 'getAccount')),
|
||||
getAccountApiKey: async (payload) => providerService.getAccountApiKey(getAccountId(payload, 'getAccountApiKey')),
|
||||
hasAccountApiKey: async (payload) => providerService.hasAccountApiKey(getAccountId(payload, 'hasAccountApiKey')),
|
||||
createAccount: async (payload) => createAccount(payload, ctx.gatewayManager),
|
||||
updateAccount: async (payload) => updateAccount(payload, ctx.gatewayManager),
|
||||
deleteAccount: async (payload) => deleteAccount(payload, ctx.gatewayManager),
|
||||
deleteAccountApiKey: async (payload) => deleteAccount({ accountId: getAccountId(payload, 'deleteAccountApiKey'), apiKeyOnly: true }, ctx.gatewayManager),
|
||||
setDefaultAccount: async (payload) => setDefaultAccount(payload, ctx.gatewayManager),
|
||||
createAccount: async (payload) => createAccount(payload, ctx),
|
||||
updateAccount: async (payload) => updateAccount(payload, ctx),
|
||||
deleteAccount: async (payload) => deleteAccount(payload, ctx),
|
||||
deleteAccountApiKey: async (payload) => deleteAccount({ accountId: getAccountId(payload, 'deleteAccountApiKey'), apiKeyOnly: true }, ctx),
|
||||
setDefaultAccount: async (payload) => setDefaultAccount(payload, ctx),
|
||||
requestOAuth,
|
||||
cancelOAuth,
|
||||
submitOAuth,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { openSync, closeSync, fstatSync, readSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import { getOpenClawConfigDir } from '../utils/paths';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -411,9 +412,15 @@ async function renameSession(sessionKey: string, label: string): Promise<{ succe
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
|
||||
export function createSessionsApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['sessions'] {
|
||||
return {
|
||||
delete: async (payload) => deleteSession(getSessionKey(payload)),
|
||||
delete: async (payload) => {
|
||||
const provider = runtimeManager?.getActiveProvider();
|
||||
if (provider && provider.kind !== 'openclaw') {
|
||||
return provider.deleteSession(payload);
|
||||
}
|
||||
return deleteSession(getSessionKey(payload));
|
||||
},
|
||||
rename: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const sessionKey = getSessionKey(payload);
|
||||
@@ -424,6 +431,10 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
|
||||
return renameSession(sessionKey, label);
|
||||
},
|
||||
summaries: async (payload) => {
|
||||
const provider = runtimeManager?.getActiveProvider();
|
||||
if (provider && provider.kind !== 'openclaw') {
|
||||
return provider.listSessions(payload) as ReturnType<CompleteHostServiceRegistry['sessions']['summaries']>;
|
||||
}
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const sessionKeys = Array.isArray(body.sessionKeys)
|
||||
? body.sessionKeys.filter((value): value is string => typeof value === 'string' && value.startsWith('agent:'))
|
||||
@@ -435,6 +446,10 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
|
||||
};
|
||||
},
|
||||
history: async (payload) => {
|
||||
const provider = runtimeManager?.getActiveProvider();
|
||||
if (provider && provider.kind !== 'openclaw') {
|
||||
return provider.loadHistory(payload) as ReturnType<CompleteHostServiceRegistry['sessions']['history']>;
|
||||
}
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const limit = getLimit(payload);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import type { RuntimeKind } from '@shared/types/gateway';
|
||||
import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup';
|
||||
import { createMenu } from '../main/menu';
|
||||
import { applyProxySettings } from '../main/proxy';
|
||||
@@ -86,7 +88,11 @@ async function handleProxySettingsChange(gatewayManager: GatewayManager): Promis
|
||||
async function runSettingsSideEffects(
|
||||
gatewayManager: GatewayManager,
|
||||
patch: Partial<AppSettings>,
|
||||
runtimeManager?: RuntimeManager,
|
||||
): Promise<void> {
|
||||
if (typeof patch.runtimeKind === 'string' && runtimeManager) {
|
||||
await runtimeManager.setActiveKind(patch.runtimeKind as RuntimeKind);
|
||||
}
|
||||
if (patchTouchesProxy(patch)) {
|
||||
await handleProxySettingsChange(gatewayManager);
|
||||
}
|
||||
@@ -98,7 +104,10 @@ async function runSettingsSideEffects(
|
||||
}
|
||||
}
|
||||
|
||||
export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['settings'] {
|
||||
export function createSettingsApi(
|
||||
gatewayManager: GatewayManager,
|
||||
runtimeManager?: RuntimeManager,
|
||||
): CompleteHostServiceRegistry['settings'] {
|
||||
return {
|
||||
getAll: () => getAllSettings(),
|
||||
get: async (payload) => {
|
||||
@@ -109,7 +118,7 @@ export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostS
|
||||
const body = payload as SetPayload | undefined;
|
||||
const key = await requireSettingKey(body);
|
||||
await setSetting(key as never, body?.value as never);
|
||||
await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial<AppSettings>);
|
||||
await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial<AppSettings>, runtimeManager);
|
||||
return { success: true };
|
||||
},
|
||||
setMany: async (payload) => {
|
||||
@@ -118,7 +127,7 @@ export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostS
|
||||
for (const [key, value] of entries) {
|
||||
await setSetting(key, value as never);
|
||||
}
|
||||
await runSettingsSideEffects(gatewayManager, patch);
|
||||
await runSettingsSideEffects(gatewayManager, patch, runtimeManager);
|
||||
return { success: true };
|
||||
},
|
||||
reset: async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import type { ClawHubService, ClawHubInstallParams, ClawHubSearchParams, ClawHubUninstallParams } from '../gateway/clawhub';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { getAllSkillConfigs, getSkillConfig, updateSkillConfig, updateSkillConfigs } from '../utils/skill-config';
|
||||
@@ -86,10 +87,13 @@ function getConfigUpdates(payload: unknown): NormalizedSkillConfigUpdate[] {
|
||||
export function createSkillsApi({
|
||||
clawHubService,
|
||||
gatewayManager,
|
||||
runtimeManager,
|
||||
}: {
|
||||
clawHubService: ClawHubService;
|
||||
gatewayManager: GatewayManager;
|
||||
runtimeManager?: RuntimeManager;
|
||||
}): CompleteHostServiceRegistry['skills'] {
|
||||
const isCcConnectRuntime = async () => runtimeManager ? await runtimeManager.getActiveKind() === 'cc-connect' : false;
|
||||
return {
|
||||
local: async () => ({ success: true, skills: await listLocalSkills() }),
|
||||
configs: async () => getAllSkillConfigs(),
|
||||
@@ -103,8 +107,14 @@ export function createSkillsApi({
|
||||
return updateSkillConfig(skillKey, updates);
|
||||
},
|
||||
updateConfigs: async (payload) => updateSkillConfigs(getConfigUpdates(payload)),
|
||||
status: async () => gatewayManager.rpc('skills.status'),
|
||||
update: async (payload) => gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {}),
|
||||
status: async () => {
|
||||
if (await isCcConnectRuntime()) return await runtimeManager!.rpc('skills.status');
|
||||
return gatewayManager.rpc('skills.status');
|
||||
},
|
||||
update: async (payload) => {
|
||||
if (await isCcConnectRuntime()) return await runtimeManager!.rpc('skills.update', isRecord(payload) ? payload : {});
|
||||
return gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {});
|
||||
},
|
||||
quickAccess: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as QuickAccessPayload : {};
|
||||
const [scannedSkills, configs] = await Promise.all([
|
||||
@@ -114,7 +124,14 @@ export function createSkillsApi({
|
||||
getAllSkillConfigs(),
|
||||
]);
|
||||
let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined;
|
||||
if (gatewayManager.getStatus().state === 'running') {
|
||||
if (await isCcConnectRuntime()) {
|
||||
try {
|
||||
const runtimeStatus = await runtimeManager!.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
|
||||
runtimeSkills = runtimeStatus.skills || [];
|
||||
} catch {
|
||||
runtimeSkills = undefined;
|
||||
}
|
||||
} else if (gatewayManager.getStatus().state === 'running') {
|
||||
try {
|
||||
const runtimeStatus = await gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
|
||||
runtimeSkills = runtimeStatus.skills || [];
|
||||
|
||||
@@ -189,6 +189,7 @@ export type ProviderSecret =
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken?: string;
|
||||
expiresAt: number;
|
||||
scopes?: string[];
|
||||
email?: string;
|
||||
|
||||
@@ -174,6 +174,7 @@ class BrowserOAuthManager extends EventEmitter {
|
||||
accountId,
|
||||
accessToken: token.access,
|
||||
refreshToken: token.refresh,
|
||||
idToken: token.idToken,
|
||||
expiresAt: token.expires,
|
||||
email: oauthTokenEmail,
|
||||
subject: oauthTokenSubject,
|
||||
|
||||
@@ -26,6 +26,7 @@ const SUCCESS_HTML = `<!doctype html>
|
||||
export interface OpenAICodexOAuthCredentials {
|
||||
access: string;
|
||||
refresh: string;
|
||||
idToken?: string;
|
||||
expires: number;
|
||||
accountId: string;
|
||||
email?: string;
|
||||
@@ -219,7 +220,7 @@ function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer |
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
): Promise<{ access: string; refresh: string; idToken?: string; expires: number }> {
|
||||
const response = await proxyAwareFetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -240,6 +241,7 @@ async function exchangeAuthorizationCode(
|
||||
const json = await response.json() as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== 'number') {
|
||||
@@ -249,6 +251,7 @@ async function exchangeAuthorizationCode(
|
||||
return {
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
idToken: typeof json.id_token === 'string' && json.id_token.trim() ? json.id_token.trim() : undefined,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
@@ -306,6 +309,7 @@ export async function loginOpenAICodexOAuth(options: {
|
||||
return {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
idToken: token.idToken,
|
||||
expires: token.expires,
|
||||
accountId,
|
||||
email: getEmailFromAccessToken(token.access),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { app } from 'electron';
|
||||
import { resolveSupportedLanguage } from '@shared/language';
|
||||
import type { RuntimeKind } from '@shared/types/gateway';
|
||||
|
||||
// Lazy-load electron-store (ESM module)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -33,6 +34,7 @@ export interface AppSettings {
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: boolean;
|
||||
runtimeKind: RuntimeKind;
|
||||
gatewayPort: number;
|
||||
gatewayToken: string;
|
||||
proxyEnabled: boolean;
|
||||
@@ -84,6 +86,7 @@ function createDefaultSettings(): AppSettings {
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: true,
|
||||
runtimeKind: 'openclaw',
|
||||
gatewayPort: 18789,
|
||||
gatewayToken: generateToken(),
|
||||
proxyEnabled: false,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: runtime-abstraction-cc-connect
|
||||
title: Add runtime abstraction and packaged cc-connect runtime support
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Introduce a runtime abstraction so ClawX can keep OpenClaw as the default runtime while exposing cc-connect as an optional packaged runtime.
|
||||
touchedAreas:
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- docs/**
|
||||
- harness/specs/tasks/runtime-abstraction-cc-connect.md
|
||||
- electron/gateway/**
|
||||
- electron/main/**
|
||||
- electron/services/**
|
||||
- electron/main/ipc/**
|
||||
- electron/runtime/**
|
||||
- electron/shared/providers/**
|
||||
- electron/utils/**
|
||||
- src/lib/host-api.ts
|
||||
- src/stores/settings.ts
|
||||
- src/pages/Settings/index.tsx
|
||||
- shared/host-api/contract.ts
|
||||
- shared/i18n/locales/*/settings.json
|
||||
- shared/types/gateway.ts
|
||||
- scripts/**
|
||||
- tests/e2e/**
|
||||
- tests/fixtures/**
|
||||
- tests/unit/**
|
||||
- electron-builder.yml
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
expectedUserBehavior:
|
||||
- OpenClaw remains the default runtime and existing Gateway UI keeps working.
|
||||
- Settings exposes a runtime selector with OpenClaw and cc-connect choices.
|
||||
- cc-connect can be selected without writing to the user's global ~/.cc-connect directory.
|
||||
- Packaged builds contain the cc-connect executable for the target platform.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/runtime-manager.test.ts
|
||||
- tests/unit/cc-connect-runtime-provider.test.ts
|
||||
- tests/unit/cc-connect-provider-profile.test.ts
|
||||
- tests/unit/codex-cli-bridge.test.ts
|
||||
- tests/unit/cc-connect-bundle.test.ts
|
||||
- tests/unit/host-api-facade.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway or cc-connect HTTP endpoints directly.
|
||||
- OpenClaw-specific features are capability-aware when cc-connect is selected.
|
||||
- cc-connect packaging does not rely on runtime postinstall downloads.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
Runtime abstraction work must preserve the existing renderer/Main boundary. The first cc-connect adapter can expose unsupported capability results for features that do not have a stable cc-connect API yet, but the runtime selector, packaged binary resolver, managed config directory, and OpenClaw compatibility path must be implemented in the same delivery.
|
||||
+18
-6
@@ -36,10 +36,20 @@
|
||||
"predev": "node scripts/generate-ext-bridge.mjs && zx scripts/prepare-preinstalled-skills-dev.mjs",
|
||||
"dev": "vite",
|
||||
"ext:bridge": "node scripts/generate-ext-bridge.mjs",
|
||||
"build": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && node scripts/run-electron-builder.mjs",
|
||||
"build": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current && node scripts/run-electron-builder.mjs",
|
||||
"build:vite": "node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
|
||||
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
||||
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
|
||||
"bundle:cc-connect:current": "zx scripts/bundle-cc-connect.mjs",
|
||||
"bundle:cc-connect:mac": "zx scripts/bundle-cc-connect.mjs --platform=mac",
|
||||
"bundle:cc-connect:win": "zx scripts/bundle-cc-connect.mjs --platform=win",
|
||||
"bundle:cc-connect:linux": "zx scripts/bundle-cc-connect.mjs --platform=linux",
|
||||
"bundle:cc-connect:all": "zx scripts/bundle-cc-connect.mjs --all",
|
||||
"bundle:codex:current": "zx scripts/bundle-codex.mjs",
|
||||
"bundle:codex:mac": "zx scripts/bundle-codex.mjs --platform=mac",
|
||||
"bundle:codex:win": "zx scripts/bundle-codex.mjs --platform=win",
|
||||
"bundle:codex:linux": "zx scripts/bundle-codex.mjs --platform=linux",
|
||||
"bundle:codex:all": "zx scripts/bundle-codex.mjs --all",
|
||||
"lint": "eslint . --fix",
|
||||
"lint:check": "eslint .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
@@ -66,11 +76,11 @@
|
||||
"node:download:win": "zx scripts/download-bundled-node.mjs --platform=win",
|
||||
"prep:win-binaries": "pnpm run uv:download:win && pnpm run agent-browser:download:win && pnpm run node:download:win",
|
||||
"icons": "zx scripts/generate-icons.mjs",
|
||||
"package": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs",
|
||||
"package:mac": "pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never",
|
||||
"package:mac:local": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never",
|
||||
"package:win": "pnpm run prep:win-binaries && pnpm run package && node scripts/patch-nsis-win.mjs && node scripts/run-electron-builder.mjs --win --publish never",
|
||||
"package:linux": "pnpm run package && node scripts/run-electron-builder.mjs --linux --publish never",
|
||||
"package": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current",
|
||||
"package:mac": "pnpm run package && pnpm run bundle:cc-connect:mac && pnpm run bundle:codex:mac && node scripts/run-electron-builder.mjs --mac --publish never",
|
||||
"package:mac:local": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && pnpm run bundle:cc-connect:mac && pnpm run bundle:codex:mac && node scripts/run-electron-builder.mjs --mac --publish never",
|
||||
"package:win": "pnpm run prep:win-binaries && pnpm run package && pnpm run bundle:cc-connect:win && pnpm run bundle:codex:win && node scripts/patch-nsis-win.mjs && node scripts/run-electron-builder.mjs --win --publish never",
|
||||
"package:linux": "pnpm run package && pnpm run bundle:cc-connect:linux && pnpm run bundle:codex:linux && node scripts/run-electron-builder.mjs --linux --publish never",
|
||||
"release": "pnpm run uv:download && pnpm run agent-browser:download && pnpm run package && node scripts/run-electron-builder.mjs --publish always",
|
||||
"preversion": "node scripts/pre-version-fetch-tags.mjs",
|
||||
"version": "node scripts/assert-release-version.mjs",
|
||||
@@ -102,6 +112,7 @@
|
||||
"@larksuite/openclaw-lark": "2026.5.20",
|
||||
"@larksuiteoapi/node-sdk": "^1.61.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@openai/codex": "0.137.0",
|
||||
"@openclaw/discord": "2026.5.20",
|
||||
"@openclaw/qqbot": "2026.5.20",
|
||||
"@openclaw/whatsapp": "2026.5.20",
|
||||
@@ -136,6 +147,7 @@
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"acpx": "0.5.3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cc-connect": "1.3.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+80
@@ -60,6 +60,9 @@ importers:
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@openai/codex':
|
||||
specifier: 0.137.0
|
||||
version: 0.137.0
|
||||
'@openclaw/discord':
|
||||
specifier: 2026.5.20
|
||||
version: 2026.5.20(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(openclaw@2026.5.20(encoding@0.1.13))
|
||||
@@ -162,6 +165,9 @@ importers:
|
||||
autoprefixer:
|
||||
specifier: ^10.4.24
|
||||
version: 10.4.27(postcss@8.5.8)
|
||||
cc-connect:
|
||||
specifier: 1.3.2
|
||||
version: 1.3.2
|
||||
chokidar:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
@@ -1632,6 +1638,47 @@ packages:
|
||||
resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
'@openai/codex@0.137.0':
|
||||
resolution: {integrity: sha512-1jUsCnzDBwv7Z4VFZajIlsz41fC18qg6d5qK4PEZhiUk0zJHS90/uGBA70aQPUJLTUZShvyKVAANjw6J/D9eYQ==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
'@openai/codex@0.137.0-darwin-arm64':
|
||||
resolution: {integrity: sha512-YjKmre7DlKslQVhSfocHscgxntZKaZc1LQySKh7q+hNL8jdK+c8nSWSePi583yKFNIxZ8Z/zCkewtjFNvOpQiQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@openai/codex@0.137.0-darwin-x64':
|
||||
resolution: {integrity: sha512-zjzrFV80LZby9et44dan82e3cwUd46U7u1LSVXTIz5AUcY4y1KZpAeN6cSLVKMZuOHXTDpi15MUQdRwzdeqIOg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@openai/codex@0.137.0-linux-arm64':
|
||||
resolution: {integrity: sha512-R3ZZymQQA1qpp6OpowN49XJ4scHwSckq7CjVvgmLv3bIs3X+F0XXK3xPFkC9vs2mX3wPekPi3ONpxx+yPAsJ6Q==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@openai/codex@0.137.0-linux-x64':
|
||||
resolution: {integrity: sha512-n+26MUj8rekbEDUeYTGoD6HXuGS0MmLHn2LOn0i5qTNYIJvXV82B7cCLSTzVKF/RJxRMRl22se9Q0Z035JIVng==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@openai/codex@0.137.0-win32-arm64':
|
||||
resolution: {integrity: sha512-Cofktt213TycdQ/v+nAUuwXUBzjMWfA/ZkXyqefyXxDgw0TMtaiM3cgDna3I8YdXnR0PM9AMbx4t7VloJ3ZZYQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@openai/codex@0.137.0-win32-x64':
|
||||
resolution: {integrity: sha512-g9qZ9ERrm5OWXMWJOgojYv1kOc5jajTKq37PBMSe56aJfAr9Jk/qBvIOy7LKq3rABdXuz8k+W65PIt2E1hXilw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@openclaw/discord@2026.5.20':
|
||||
resolution: {integrity: sha512-sQQ5fx5BoMtfqMj6k2wn3z3AwPEzXab5xhedDOiHJWuKZOfXOcIBfkiVl237ASVyopJKdh4LKed5BODmXiR20w==}
|
||||
peerDependencies:
|
||||
@@ -3174,6 +3221,10 @@ packages:
|
||||
caniuse-lite@1.0.30001781:
|
||||
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
||||
|
||||
cc-connect@1.3.2:
|
||||
resolution: {integrity: sha512-IexOVNPS0aIGKyYaRwwFrupk/Lq7BkqvqpwkADbSy69+QE88OkZRxTAs7PFCn7qvYaHAoMx6+pauuHLlEEMKFQ==}
|
||||
hasBin: true
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -8219,6 +8270,33 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
'@openai/codex@0.137.0':
|
||||
optionalDependencies:
|
||||
'@openai/codex-darwin-arm64': '@openai/codex@0.137.0-darwin-arm64'
|
||||
'@openai/codex-darwin-x64': '@openai/codex@0.137.0-darwin-x64'
|
||||
'@openai/codex-linux-arm64': '@openai/codex@0.137.0-linux-arm64'
|
||||
'@openai/codex-linux-x64': '@openai/codex@0.137.0-linux-x64'
|
||||
'@openai/codex-win32-arm64': '@openai/codex@0.137.0-win32-arm64'
|
||||
'@openai/codex-win32-x64': '@openai/codex@0.137.0-win32-x64'
|
||||
|
||||
'@openai/codex@0.137.0-darwin-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.137.0-darwin-x64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.137.0-linux-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.137.0-linux-x64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.137.0-win32-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.137.0-win32-x64':
|
||||
optional: true
|
||||
|
||||
'@openclaw/discord@2026.5.20(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(openclaw@2026.5.20(encoding@0.1.13))':
|
||||
dependencies:
|
||||
'@discordjs/voice': 0.19.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(opusscript@0.1.1)
|
||||
@@ -9870,6 +9948,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001781: {}
|
||||
|
||||
cc-connect@1.3.2: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env zx
|
||||
import 'zx/globals';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
CC_CONNECT_VERSION_FALLBACK,
|
||||
buildCcConnectAssetName,
|
||||
getCcConnectDownloadUrls,
|
||||
normalizeCcConnectTarget,
|
||||
parseCcConnectBundleArgs,
|
||||
} from './cc-connect-bundle-lib.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OUTPUT_ROOT = path.join(ROOT, 'build', 'cc-connect');
|
||||
|
||||
function readCcConnectVersion() {
|
||||
const pkgPath = path.join(ROOT, 'node_modules', 'cc-connect', 'package.json');
|
||||
if (!fs.existsSync(pkgPath)) return CC_CONNECT_VERSION_FALLBACK;
|
||||
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || CC_CONNECT_VERSION_FALLBACK;
|
||||
}
|
||||
|
||||
function nodeTargetDir(nodePlatform, nodeArch) {
|
||||
return path.join(OUTPUT_ROOT, `${nodePlatform}-${nodeArch}`);
|
||||
}
|
||||
|
||||
async function download(urls) {
|
||||
for (const url of urls) {
|
||||
try {
|
||||
echo` Downloading ${url}`;
|
||||
return { url, data: await fetch(url).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
}).then((buffer) => Buffer.from(buffer)) };
|
||||
} catch (error) {
|
||||
echo` WARN ${url} failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not download cc-connect from ${urls.join(', ')}`);
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath, outputDir, isWindows) {
|
||||
if (isWindows) {
|
||||
try {
|
||||
await $`unzip -o ${archivePath} -d ${outputDir}`;
|
||||
} catch {
|
||||
await $`powershell -NoProfile -Command Expand-Archive -Force ${archivePath} ${outputDir}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await $`tar xzf ${archivePath} -C ${outputDir}`;
|
||||
}
|
||||
|
||||
function canExecuteTargetOnHost(nodePlatform, nodeArch) {
|
||||
return nodePlatform === process.platform && nodeArch === process.arch;
|
||||
}
|
||||
|
||||
async function bundleTarget(version, nodePlatform, nodeArch) {
|
||||
const target = normalizeCcConnectTarget(nodePlatform, nodeArch);
|
||||
const assetName = buildCcConnectAssetName(version, target);
|
||||
const urls = getCcConnectDownloadUrls(version, assetName);
|
||||
const outputDir = nodeTargetDir(nodePlatform, nodeArch);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const { url, data } = await download(urls);
|
||||
const archivePath = path.join(outputDir, assetName);
|
||||
fs.writeFileSync(archivePath, data);
|
||||
await extractArchive(archivePath, outputDir, target.platform === 'windows');
|
||||
fs.rmSync(archivePath, { force: true });
|
||||
|
||||
const binaryName = target.platform === 'windows' ? 'cc-connect.exe' : 'cc-connect';
|
||||
const extracted = fs.readdirSync(outputDir).find((name) => name.startsWith('cc-connect') && name !== binaryName);
|
||||
if (extracted) {
|
||||
fs.renameSync(path.join(outputDir, extracted), path.join(outputDir, binaryName));
|
||||
}
|
||||
const binaryPath = path.join(outputDir, binaryName);
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`cc-connect binary missing after extraction: ${binaryPath}`);
|
||||
}
|
||||
if (target.platform !== 'windows') {
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
let verifiedWithVersionCommand = false;
|
||||
if (canExecuteTargetOnHost(nodePlatform, nodeArch)) {
|
||||
const versionOutput = await $`${binaryPath} --version`.text();
|
||||
if (!versionOutput.includes(version)) {
|
||||
throw new Error(`cc-connect version mismatch: expected ${version}, got ${versionOutput.trim()}`);
|
||||
}
|
||||
verifiedWithVersionCommand = true;
|
||||
} else {
|
||||
echo` Skipping --version for cross target ${nodePlatform}-${nodeArch}`;
|
||||
}
|
||||
|
||||
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex');
|
||||
fs.writeFileSync(path.join(outputDir, 'manifest.json'), JSON.stringify({
|
||||
name: 'cc-connect',
|
||||
version,
|
||||
nodePlatform,
|
||||
nodeArch,
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
sourceUrl: url,
|
||||
assetName,
|
||||
binaryName,
|
||||
sha256,
|
||||
verifiedWithVersionCommand,
|
||||
}, null, 2));
|
||||
echo` OK cc-connect ${version} bundled for ${nodePlatform}-${nodeArch}`;
|
||||
}
|
||||
|
||||
const version = readCcConnectVersion();
|
||||
const { targets } = parseCcConnectBundleArgs();
|
||||
echo`Bundling cc-connect v${version}...`;
|
||||
for (const { nodePlatform, nodeArch } of targets) {
|
||||
await bundleTarget(version, nodePlatform, nodeArch);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env zx
|
||||
import 'zx/globals';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
CODEX_VERSION_FALLBACK,
|
||||
getCodexNativeTarballUrl,
|
||||
normalizeCodexTarget,
|
||||
parseCodexBundleArgs,
|
||||
} from './codex-bundle-lib.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OUTPUT_ROOT = path.join(ROOT, 'build', 'codex');
|
||||
|
||||
function readCodexVersion() {
|
||||
const pkgPath = path.join(ROOT, 'node_modules', '.pnpm', '@openai+codex@0.137.0', 'node_modules', '@openai', 'codex', 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || CODEX_VERSION_FALLBACK;
|
||||
}
|
||||
const appPackage = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
||||
return appPackage.devDependencies?.['@openai/codex']?.replace(/^[^\d]*/, '') || CODEX_VERSION_FALLBACK;
|
||||
}
|
||||
|
||||
function outputDirFor(nodePlatform, nodeArch) {
|
||||
return path.join(OUTPUT_ROOT, `${nodePlatform}-${nodeArch}`);
|
||||
}
|
||||
|
||||
function installedNativePackageRoot(version, packageSuffix) {
|
||||
const root = path.join(
|
||||
ROOT,
|
||||
'node_modules',
|
||||
'.pnpm',
|
||||
`@openai+codex@${version}-${packageSuffix}`,
|
||||
'node_modules',
|
||||
'@openai',
|
||||
'codex',
|
||||
);
|
||||
return fs.existsSync(root) ? root : null;
|
||||
}
|
||||
|
||||
async function extractNativePackage(version, packageSuffix, tempDir) {
|
||||
const installed = installedNativePackageRoot(version, packageSuffix);
|
||||
if (installed) return installed;
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
const url = getCodexNativeTarballUrl(version, packageSuffix);
|
||||
const archivePath = path.join(tempDir, `codex-${version}-${packageSuffix}.tgz`);
|
||||
echo` Downloading ${url}`;
|
||||
const data = await fetch(url).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
}).then((buffer) => Buffer.from(buffer));
|
||||
fs.writeFileSync(archivePath, data);
|
||||
await $`tar xzf ${archivePath} -C ${tempDir}`;
|
||||
return path.join(tempDir, 'package');
|
||||
}
|
||||
|
||||
function canExecuteTargetOnHost(nodePlatform, nodeArch) {
|
||||
return nodePlatform === process.platform && nodeArch === process.arch;
|
||||
}
|
||||
|
||||
async function bundleTarget(version, nodePlatform, nodeArch) {
|
||||
const target = normalizeCodexTarget(nodePlatform, nodeArch);
|
||||
const outputDir = outputDirFor(nodePlatform, nodeArch);
|
||||
const tempDir = path.join(ROOT, 'temp_codex_extract', `${nodePlatform}-${nodeArch}`);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(outputDir, 'bin'), { recursive: true });
|
||||
|
||||
const packageRoot = await extractNativePackage(version, target.packageSuffix, tempDir);
|
||||
const vendorRoot = path.join(packageRoot, 'vendor', target.targetTriple);
|
||||
const sourceBinary = path.join(vendorRoot, 'bin', target.binaryName);
|
||||
const sourcePathDir = path.join(vendorRoot, 'codex-path');
|
||||
if (!fs.existsSync(sourceBinary)) {
|
||||
throw new Error(`Codex native binary missing: ${sourceBinary}`);
|
||||
}
|
||||
fs.copyFileSync(sourceBinary, path.join(outputDir, 'bin', target.binaryName));
|
||||
if (fs.existsSync(sourcePathDir)) {
|
||||
fs.cpSync(sourcePathDir, path.join(outputDir, 'codex-path'), { recursive: true });
|
||||
}
|
||||
if (target.nodePlatform !== 'win32') {
|
||||
fs.chmodSync(path.join(outputDir, 'bin', target.binaryName), 0o755);
|
||||
const rgPath = path.join(outputDir, 'codex-path', 'rg');
|
||||
if (fs.existsSync(rgPath)) fs.chmodSync(rgPath, 0o755);
|
||||
}
|
||||
|
||||
let verifiedWithVersionCommand = false;
|
||||
if (canExecuteTargetOnHost(nodePlatform, nodeArch)) {
|
||||
const binaryPath = path.join(outputDir, 'bin', target.binaryName);
|
||||
const versionOutput = await $`${binaryPath} --version`.text();
|
||||
if (!versionOutput.includes(version)) {
|
||||
throw new Error(`Codex version mismatch: expected ${version}, got ${versionOutput.trim()}`);
|
||||
}
|
||||
verifiedWithVersionCommand = true;
|
||||
} else {
|
||||
echo` Skipping --version for cross target ${nodePlatform}-${nodeArch}`;
|
||||
}
|
||||
|
||||
const binaryPath = path.join(outputDir, 'bin', target.binaryName);
|
||||
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex');
|
||||
fs.writeFileSync(path.join(outputDir, 'manifest.json'), JSON.stringify({
|
||||
name: 'codex',
|
||||
version,
|
||||
nodePlatform,
|
||||
nodeArch,
|
||||
packageSuffix: target.packageSuffix,
|
||||
targetTriple: target.targetTriple,
|
||||
binaryName: target.binaryName,
|
||||
sha256,
|
||||
verifiedWithVersionCommand,
|
||||
}, null, 2));
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
echo` OK Codex ${version} bundled for ${nodePlatform}-${nodeArch}`;
|
||||
}
|
||||
|
||||
const version = readCodexVersion();
|
||||
const { targets } = parseCodexBundleArgs();
|
||||
echo`Bundling Codex v${version}...`;
|
||||
for (const { nodePlatform, nodeArch } of targets) {
|
||||
await bundleTarget(version, nodePlatform, nodeArch);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import os from 'node:os';
|
||||
|
||||
export const CC_CONNECT_VERSION_FALLBACK = '1.3.2';
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
darwin: 'darwin',
|
||||
linux: 'linux',
|
||||
win32: 'windows',
|
||||
};
|
||||
|
||||
const ARCH_MAP = {
|
||||
x64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
};
|
||||
|
||||
const PRESETS = {
|
||||
current: [{ nodePlatform: process.platform, nodeArch: process.arch }],
|
||||
mac: [
|
||||
{ nodePlatform: 'darwin', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
|
||||
],
|
||||
win: [{ nodePlatform: 'win32', nodeArch: 'x64' }],
|
||||
linux: [
|
||||
{ nodePlatform: 'linux', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'linux', nodeArch: 'arm64' },
|
||||
],
|
||||
all: [
|
||||
{ nodePlatform: 'darwin', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
|
||||
{ nodePlatform: 'linux', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'linux', nodeArch: 'arm64' },
|
||||
{ nodePlatform: 'win32', nodeArch: 'x64' },
|
||||
],
|
||||
};
|
||||
|
||||
export function normalizeCcConnectTarget(nodePlatform = os.platform(), nodeArch = os.arch()) {
|
||||
const platform = PLATFORM_MAP[nodePlatform];
|
||||
const arch = ARCH_MAP[nodeArch];
|
||||
if (!platform || !arch) {
|
||||
throw new Error(`Unsupported cc-connect target: ${nodePlatform}-${nodeArch}`);
|
||||
}
|
||||
return { platform, arch };
|
||||
}
|
||||
|
||||
export function buildCcConnectAssetName(version, target) {
|
||||
const ext = target.platform === 'windows' ? '.zip' : '.tar.gz';
|
||||
return `cc-connect-v${version}-${target.platform}-${target.arch}${ext}`;
|
||||
}
|
||||
|
||||
export function parseCcConnectBundleArgs(argv = process.argv.slice(2)) {
|
||||
let preset = 'current';
|
||||
for (const arg of argv) {
|
||||
if (arg === '--all') preset = 'all';
|
||||
else if (arg.startsWith('--platform=')) preset = arg.slice('--platform='.length);
|
||||
}
|
||||
const targets = PRESETS[preset];
|
||||
if (!targets) {
|
||||
throw new Error(`Unsupported cc-connect bundle preset: ${preset}`);
|
||||
}
|
||||
return { preset, targets };
|
||||
}
|
||||
|
||||
export function getCcConnectDownloadUrls(version, assetName) {
|
||||
return [
|
||||
`https://github.com/chenhg5/cc-connect/releases/download/v${version}/${assetName}`,
|
||||
`https://gitee.com/cg33/cc-connect/releases/download/v${version}/${assetName}`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import os from 'node:os';
|
||||
|
||||
export const CODEX_VERSION_FALLBACK = '0.137.0';
|
||||
|
||||
const TARGETS = {
|
||||
'darwin-x64': {
|
||||
nodePlatform: 'darwin',
|
||||
nodeArch: 'x64',
|
||||
packageSuffix: 'darwin-x64',
|
||||
targetTriple: 'x86_64-apple-darwin',
|
||||
binaryName: 'codex',
|
||||
},
|
||||
'darwin-arm64': {
|
||||
nodePlatform: 'darwin',
|
||||
nodeArch: 'arm64',
|
||||
packageSuffix: 'darwin-arm64',
|
||||
targetTriple: 'aarch64-apple-darwin',
|
||||
binaryName: 'codex',
|
||||
},
|
||||
'linux-x64': {
|
||||
nodePlatform: 'linux',
|
||||
nodeArch: 'x64',
|
||||
packageSuffix: 'linux-x64',
|
||||
targetTriple: 'x86_64-unknown-linux-musl',
|
||||
binaryName: 'codex',
|
||||
},
|
||||
'linux-arm64': {
|
||||
nodePlatform: 'linux',
|
||||
nodeArch: 'arm64',
|
||||
packageSuffix: 'linux-arm64',
|
||||
targetTriple: 'aarch64-unknown-linux-musl',
|
||||
binaryName: 'codex',
|
||||
},
|
||||
'win32-x64': {
|
||||
nodePlatform: 'win32',
|
||||
nodeArch: 'x64',
|
||||
packageSuffix: 'win32-x64',
|
||||
targetTriple: 'x86_64-pc-windows-msvc',
|
||||
binaryName: 'codex.exe',
|
||||
},
|
||||
'win32-arm64': {
|
||||
nodePlatform: 'win32',
|
||||
nodeArch: 'arm64',
|
||||
packageSuffix: 'win32-arm64',
|
||||
targetTriple: 'aarch64-pc-windows-msvc',
|
||||
binaryName: 'codex.exe',
|
||||
},
|
||||
};
|
||||
|
||||
const PRESETS = {
|
||||
current: [{ nodePlatform: process.platform, nodeArch: process.arch }],
|
||||
mac: [
|
||||
{ nodePlatform: 'darwin', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
|
||||
],
|
||||
win: [
|
||||
{ nodePlatform: 'win32', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'win32', nodeArch: 'arm64' },
|
||||
],
|
||||
linux: [
|
||||
{ nodePlatform: 'linux', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'linux', nodeArch: 'arm64' },
|
||||
],
|
||||
all: [
|
||||
{ nodePlatform: 'darwin', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
|
||||
{ nodePlatform: 'linux', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'linux', nodeArch: 'arm64' },
|
||||
{ nodePlatform: 'win32', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'win32', nodeArch: 'arm64' },
|
||||
],
|
||||
};
|
||||
|
||||
export function normalizeCodexTarget(nodePlatform = os.platform(), nodeArch = os.arch()) {
|
||||
const target = TARGETS[`${nodePlatform}-${nodeArch}`];
|
||||
if (!target) {
|
||||
throw new Error(`Unsupported Codex target: ${nodePlatform}-${nodeArch}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function parseCodexBundleArgs(argv = process.argv.slice(2)) {
|
||||
let preset = 'current';
|
||||
for (const arg of argv) {
|
||||
if (arg === '--all') preset = 'all';
|
||||
else if (arg.startsWith('--platform=')) preset = arg.slice('--platform='.length);
|
||||
}
|
||||
const targets = PRESETS[preset];
|
||||
if (!targets) {
|
||||
throw new Error(`Unsupported Codex bundle preset: ${preset}`);
|
||||
}
|
||||
return { preset, targets };
|
||||
}
|
||||
|
||||
export function getCodexNativePackageName(packageSuffix, version = CODEX_VERSION_FALLBACK) {
|
||||
return `@openai/codex@${version}-${packageSuffix}`;
|
||||
}
|
||||
|
||||
export function buildCodexNativeTarballName(version, packageSuffix) {
|
||||
return `codex-${version}-${packageSuffix}.tgz`;
|
||||
}
|
||||
|
||||
export function getCodexNativeTarballUrl(version, packageSuffix) {
|
||||
return `https://registry.npmjs.org/@openai/codex/-/${buildCodexNativeTarballName(version, packageSuffix)}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RawMessage } from '../chat/types';
|
||||
import type { AgentsSnapshot } from '../types/agent';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
import type { GatewayHealth, GatewayStatus } from '../types/gateway';
|
||||
import type { GatewayHealth, GatewayStatus, RuntimeKind } from '../types/gateway';
|
||||
import type { MarketplaceSkill, QuickAccessSkill, Skill } from '../types/skill';
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
@@ -103,6 +103,7 @@ export type SettingsSnapshot = Partial<{
|
||||
launchAtStartup: boolean;
|
||||
telemetryEnabled: boolean;
|
||||
gatewayAutoStart: boolean;
|
||||
runtimeKind: RuntimeKind;
|
||||
gatewayPort: number;
|
||||
proxyEnabled: boolean;
|
||||
proxyServer: string;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"models": "Models",
|
||||
"deleteSessionConfirm": "Delete \"{{label}}\"?",
|
||||
"openClawPage": "OpenClaw Page",
|
||||
"ccConnectPage": "CC Connect Page",
|
||||
"openClawDreams": "Dreams",
|
||||
"renameSession": "Rename",
|
||||
"deleteSession": "Delete",
|
||||
|
||||
@@ -155,6 +155,32 @@
|
||||
"proxySaved": "Proxy settings saved",
|
||||
"proxySaveFailed": "Failed to save proxy settings"
|
||||
},
|
||||
"runtime": {
|
||||
"title": "Runtime",
|
||||
"description": "Choose the managed agent runtime. OpenClaw remains the default fallback.",
|
||||
"changed": "Runtime selection saved. Restart the runtime to apply it.",
|
||||
"restart": "Restart Runtime",
|
||||
"restartHint": "Switching runtime stops the previous process; restart applies the selected runtime.",
|
||||
"configDir": "Config directory",
|
||||
"supported": "supported",
|
||||
"unsupported": "unavailable",
|
||||
"openclawOnly": "This OpenClaw-specific tool is unavailable for the selected runtime.",
|
||||
"kinds": {
|
||||
"openclaw": "OpenClaw",
|
||||
"cc-connect": "cc-connect"
|
||||
},
|
||||
"capabilities": {
|
||||
"chat": "Chat",
|
||||
"sessions": "Sessions",
|
||||
"history": "History",
|
||||
"providers": "Providers",
|
||||
"channels": "Channels",
|
||||
"cron": "Cron",
|
||||
"logs": "Logs",
|
||||
"skills": "Skills",
|
||||
"doctor": "Doctor"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"description": "Keep ClawX up to date",
|
||||
@@ -218,8 +244,8 @@
|
||||
"cliPowershell": "PowerShell command.",
|
||||
"cmdUnavailable": "Command unavailable",
|
||||
"cmdCopied": "CLI command copied",
|
||||
"doctor": "OpenClaw Doctor",
|
||||
"doctorDesc": "Run `openclaw doctor` and inspect the raw diagnostic output.",
|
||||
"doctor": "Runtime Doctor",
|
||||
"doctorDesc": "Run the selected runtime's doctor command and inspect the raw diagnostic output.",
|
||||
"runDoctor": "Run Doctor",
|
||||
"runDoctorFix": "Run Doctor Fix",
|
||||
"doctorSucceeded": "OpenClaw doctor completed",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"models": "モデル",
|
||||
"deleteSessionConfirm": "「{{label}}」を削除しますか?",
|
||||
"openClawPage": "OpenClaw ページ",
|
||||
"ccConnectPage": "CC Connect ページ",
|
||||
"openClawDreams": "夢",
|
||||
"renameSession": "名前を変更",
|
||||
"deleteSession": "削除",
|
||||
|
||||
@@ -155,6 +155,32 @@
|
||||
"proxySaved": "プロキシ設定を保存しました",
|
||||
"proxySaveFailed": "プロキシ設定の保存に失敗しました"
|
||||
},
|
||||
"runtime": {
|
||||
"title": "Runtime",
|
||||
"description": "ClawX が管理する agent runtime を選択します。OpenClaw は既定のフォールバックです。",
|
||||
"changed": "Runtime の選択を保存しました。適用するには runtime を再起動してください。",
|
||||
"restart": "Runtime を再起動",
|
||||
"restartHint": "runtime を切り替えると前のプロセスは停止します。再起動後に選択した runtime が使われます。",
|
||||
"configDir": "設定ディレクトリ",
|
||||
"supported": "対応",
|
||||
"unsupported": "利用不可",
|
||||
"openclawOnly": "この OpenClaw 専用ツールは選択中の runtime では利用できません。",
|
||||
"kinds": {
|
||||
"openclaw": "OpenClaw",
|
||||
"cc-connect": "cc-connect"
|
||||
},
|
||||
"capabilities": {
|
||||
"chat": "チャット",
|
||||
"sessions": "セッション",
|
||||
"history": "履歴",
|
||||
"providers": "プロバイダー",
|
||||
"channels": "チャンネル",
|
||||
"cron": "Cron",
|
||||
"logs": "ログ",
|
||||
"skills": "Skills",
|
||||
"doctor": "Doctor"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"title": "アップデート",
|
||||
"description": "ClawX を最新に保つ",
|
||||
@@ -218,8 +244,8 @@
|
||||
"cliPowershell": "PowerShell コマンド。",
|
||||
"cmdUnavailable": "コマンドが利用できません",
|
||||
"cmdCopied": "CLI コマンドをコピーしました",
|
||||
"doctor": "OpenClaw Doctor",
|
||||
"doctorDesc": "`openclaw doctor` を実行して診断の生出力を確認します。",
|
||||
"doctor": "Runtime Doctor",
|
||||
"doctorDesc": "選択中 runtime の doctor コマンドを実行して診断の生出力を確認します。",
|
||||
"runDoctor": "Doctor を実行",
|
||||
"runDoctorFix": "Doctor 修復を実行",
|
||||
"doctorSucceeded": "OpenClaw doctor が完了しました",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"models": "Модели",
|
||||
"deleteSessionConfirm": "Удалить \"{{label}}\"?",
|
||||
"openClawPage": "Страница OpenClaw",
|
||||
"ccConnectPage": "Страница CC Connect",
|
||||
"openClawDreams": "Dreams",
|
||||
"renameSession": "Переименовать",
|
||||
"deleteSession": "Удалить",
|
||||
|
||||
@@ -155,6 +155,32 @@
|
||||
"proxySaved": "Настройки прокси сохранены",
|
||||
"proxySaveFailed": "Не удалось сохранить настройки прокси"
|
||||
},
|
||||
"runtime": {
|
||||
"title": "Runtime",
|
||||
"description": "Выберите управляемую ClawX среду agent runtime. OpenClaw остаётся резервным вариантом по умолчанию.",
|
||||
"changed": "Выбор runtime сохранён. Перезапустите runtime, чтобы применить его.",
|
||||
"restart": "Перезапустить Runtime",
|
||||
"restartHint": "При смене runtime предыдущий процесс останавливается; перезапуск применит выбранный runtime.",
|
||||
"configDir": "Каталог конфигурации",
|
||||
"supported": "доступно",
|
||||
"unsupported": "недоступно",
|
||||
"openclawOnly": "Этот инструмент OpenClaw недоступен для выбранного runtime.",
|
||||
"kinds": {
|
||||
"openclaw": "OpenClaw",
|
||||
"cc-connect": "cc-connect"
|
||||
},
|
||||
"capabilities": {
|
||||
"chat": "Чат",
|
||||
"sessions": "Сессии",
|
||||
"history": "История",
|
||||
"providers": "Провайдеры",
|
||||
"channels": "Каналы",
|
||||
"cron": "Cron",
|
||||
"logs": "Логи",
|
||||
"skills": "Skills",
|
||||
"doctor": "Doctor"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"title": "Обновления",
|
||||
"description": "Поддерживайте ClawX в актуальном состоянии",
|
||||
@@ -218,8 +244,8 @@
|
||||
"cliPowershell": "Команда PowerShell.",
|
||||
"cmdUnavailable": "Команда недоступна",
|
||||
"cmdCopied": "CLI-команда скопирована",
|
||||
"doctor": "OpenClaw Doctor",
|
||||
"doctorDesc": "Запустите `openclaw doctor` и просмотрите вывод диагностики.",
|
||||
"doctor": "Runtime Doctor",
|
||||
"doctorDesc": "Запустите doctor выбранного runtime и просмотрите вывод диагностики.",
|
||||
"runDoctor": "Запустить Doctor",
|
||||
"runDoctorFix": "Запустить исправление Doctor",
|
||||
"doctorSucceeded": "OpenClaw doctor завершён",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"models": "模型",
|
||||
"deleteSessionConfirm": "删除「{{label}}」?",
|
||||
"openClawPage": "OpenClaw 页面",
|
||||
"ccConnectPage": "CC Connect 页面",
|
||||
"openClawDreams": "梦境",
|
||||
"renameSession": "重命名",
|
||||
"deleteSession": "删除",
|
||||
|
||||
@@ -155,6 +155,32 @@
|
||||
"proxySaved": "代理设置已保存",
|
||||
"proxySaveFailed": "保存代理设置失败"
|
||||
},
|
||||
"runtime": {
|
||||
"title": "Runtime",
|
||||
"description": "选择由 ClawX 托管的 agent runtime。OpenClaw 仍是默认和回滚路径。",
|
||||
"changed": "Runtime 选择已保存。请重启 runtime 以应用。",
|
||||
"restart": "重启 Runtime",
|
||||
"restartHint": "切换 runtime 会停止旧进程;重启后使用当前选择的 runtime。",
|
||||
"configDir": "配置目录",
|
||||
"supported": "支持",
|
||||
"unsupported": "不可用",
|
||||
"openclawOnly": "当前 runtime 不支持此 OpenClaw 专属工具。",
|
||||
"kinds": {
|
||||
"openclaw": "OpenClaw",
|
||||
"cc-connect": "cc-connect"
|
||||
},
|
||||
"capabilities": {
|
||||
"chat": "聊天",
|
||||
"sessions": "会话",
|
||||
"history": "历史",
|
||||
"providers": "提供商",
|
||||
"channels": "频道",
|
||||
"cron": "定时任务",
|
||||
"logs": "日志",
|
||||
"skills": "Skills",
|
||||
"doctor": "Doctor"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"title": "更新",
|
||||
"description": "保持 ClawX 最新",
|
||||
@@ -218,8 +244,8 @@
|
||||
"cliPowershell": "PowerShell 命令。",
|
||||
"cmdUnavailable": "命令不可用",
|
||||
"cmdCopied": "CLI 命令已复制",
|
||||
"doctor": "OpenClaw Doctor 诊断",
|
||||
"doctorDesc": "运行 `openclaw doctor` 并查看原始诊断输出。",
|
||||
"doctor": "Runtime Doctor 诊断",
|
||||
"doctorDesc": "运行当前 runtime 的 doctor 命令并查看原始诊断输出。",
|
||||
"runDoctor": "运行 Doctor",
|
||||
"runDoctorFix": "运行 Doctor 并修复",
|
||||
"doctorSucceeded": "OpenClaw doctor 已完成",
|
||||
|
||||
@@ -14,6 +14,23 @@ export type GatewayRuntimeJsonValue =
|
||||
export type GatewayRuntimePayload = GatewayRuntimeJsonValue | undefined;
|
||||
export type GatewayRuntimeRecord = { [key: string]: GatewayRuntimeJsonValue | undefined };
|
||||
|
||||
export type RuntimeKind = 'openclaw' | 'cc-connect';
|
||||
|
||||
export type RuntimeCapabilityName =
|
||||
| 'chat'
|
||||
| 'sessions'
|
||||
| 'history'
|
||||
| 'providers'
|
||||
| 'models'
|
||||
| 'channels'
|
||||
| 'cron'
|
||||
| 'logs'
|
||||
| 'skills'
|
||||
| 'doctor'
|
||||
| 'controlUi';
|
||||
|
||||
export type RuntimeCapabilities = Record<RuntimeCapabilityName, boolean>;
|
||||
|
||||
/**
|
||||
* Gateway connection status
|
||||
*/
|
||||
@@ -28,6 +45,9 @@ export interface GatewayStatus {
|
||||
reconnectAttempts?: number;
|
||||
/** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */
|
||||
gatewayReady?: boolean;
|
||||
runtimeKind?: RuntimeKind;
|
||||
capabilities?: RuntimeCapabilities;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -161,11 +161,16 @@ export function Sidebar() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isOnChat = useLocation().pathname === '/';
|
||||
const { t } = useTranslation(['common', 'chat']);
|
||||
|
||||
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
|
||||
sessionLabels[key] ?? label ?? displayName ?? key;
|
||||
|
||||
const openControlUi = async (view?: 'dreams', label = 'OpenClaw Page') => {
|
||||
const controlUiLabel = gatewayStatus.runtimeKind === 'cc-connect'
|
||||
? t('common:sidebar.ccConnectPage')
|
||||
: t('common:sidebar.openClawPage');
|
||||
|
||||
const openControlUi = async (view?: 'dreams', label = controlUiLabel) => {
|
||||
try {
|
||||
const result = await hostApi.gateway.controlUi(view);
|
||||
if (result.success && result.url) {
|
||||
@@ -179,10 +184,9 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const openDevConsole = async () => {
|
||||
await openControlUi(undefined, 'OpenClaw Page');
|
||||
await openControlUi(undefined, controlUiLabel);
|
||||
};
|
||||
|
||||
const { t } = useTranslation(['common', 'chat']);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
|
||||
@@ -609,7 +613,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
|
||||
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{controlUiLabel}</span>
|
||||
<ExternalLink className="ml-auto h-3 w-3 shrink-0 opacity-50 text-current" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
FileText,
|
||||
ServerCog,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -53,6 +54,8 @@ export function Settings() {
|
||||
setLaunchAtStartup,
|
||||
gatewayAutoStart,
|
||||
setGatewayAutoStart,
|
||||
runtimeKind,
|
||||
setRuntimeKind,
|
||||
proxyEnabled,
|
||||
proxyServer,
|
||||
proxyHttpServer,
|
||||
@@ -94,6 +97,21 @@ export function Settings() {
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [doctorRunningMode, setDoctorRunningMode] = useState<'diagnose' | 'fix' | null>(null);
|
||||
const [doctorResult, setDoctorResult] = useState<OpenClawDoctorResult | null>(null);
|
||||
const activeRuntimeKind = gatewayStatus.runtimeKind ?? runtimeKind ?? 'openclaw';
|
||||
const runtimeCapabilities = gatewayStatus.capabilities;
|
||||
const supportsDoctor = runtimeCapabilities?.doctor ?? true;
|
||||
const supportsDoctorFix = activeRuntimeKind === 'openclaw';
|
||||
const runtimeCapabilityEntries = [
|
||||
['chat', t('runtime.capabilities.chat')],
|
||||
['sessions', t('runtime.capabilities.sessions')],
|
||||
['history', t('runtime.capabilities.history')],
|
||||
['providers', t('runtime.capabilities.providers')],
|
||||
['channels', t('runtime.capabilities.channels')],
|
||||
['cron', t('runtime.capabilities.cron')],
|
||||
['logs', t('runtime.capabilities.logs')],
|
||||
['skills', t('runtime.capabilities.skills')],
|
||||
['doctor', t('runtime.capabilities.doctor')],
|
||||
] as const;
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
@@ -418,6 +436,13 @@ export function Settings() {
|
||||
toast.success(translateNext('appearance.menuLanguageUpdated'));
|
||||
};
|
||||
|
||||
const handleRuntimeChange = (nextRuntimeKind: 'openclaw' | 'cc-connect') => {
|
||||
if (nextRuntimeKind === runtimeKind) return;
|
||||
setRuntimeKind(nextRuntimeKind);
|
||||
setDoctorResult(null);
|
||||
toast.success(t('runtime.changed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="settings-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
|
||||
@@ -576,6 +601,80 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4" data-testid="settings-runtime-section">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground">{t('runtime.title')}</Label>
|
||||
<p className="text-meta text-muted-foreground mt-1">
|
||||
{t('runtime.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="rounded-full px-3 py-1 bg-black/5 dark:bg-white/10 border border-black/5 dark:border-white/5">
|
||||
{t(`runtime.kinds.${activeRuntimeKind}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t('runtime.title')}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={runtimeKind === 'openclaw' ? 'secondary' : 'outline'}
|
||||
data-testid="settings-runtime-openclaw"
|
||||
onClick={() => handleRuntimeChange('openclaw')}
|
||||
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", runtimeKind === 'openclaw' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
|
||||
>
|
||||
<ServerCog className="h-4 w-4 mr-2" />
|
||||
{t('runtime.kinds.openclaw')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={runtimeKind === 'cc-connect' ? 'secondary' : 'outline'}
|
||||
data-testid="settings-runtime-cc-connect"
|
||||
onClick={() => handleRuntimeChange('cc-connect')}
|
||||
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", runtimeKind === 'cc-connect' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
|
||||
>
|
||||
<ServerCog className="h-4 w-4 mr-2" />
|
||||
{t('runtime.kinds.cc-connect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={restartGateway}
|
||||
data-testid="settings-runtime-restart"
|
||||
className="rounded-full h-10 px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('runtime.restart')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('runtime.restartHint')}
|
||||
</p>
|
||||
{gatewayStatus.configDir && (
|
||||
<p className="text-xs text-muted-foreground font-mono break-all" data-testid="settings-runtime-config-dir">
|
||||
{t('runtime.configDir')}: {gatewayStatus.configDir}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" data-testid="settings-runtime-capabilities">
|
||||
{runtimeCapabilityEntries.map(([key, label]) => {
|
||||
const supported = runtimeCapabilities?.[key] ?? (activeRuntimeKind === 'openclaw');
|
||||
return (
|
||||
<Badge
|
||||
key={key}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 border",
|
||||
supported
|
||||
? "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20"
|
||||
: "bg-black/5 dark:bg-white/5 text-muted-foreground border-transparent",
|
||||
)}
|
||||
>
|
||||
{label}: {supported ? t('runtime.supported') : t('runtime.unsupported')}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -762,7 +861,7 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCliTools && (
|
||||
{showCliTools && activeRuntimeKind === 'openclaw' && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-foreground">{t('developer.cli')}</Label>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
@@ -794,6 +893,7 @@ export function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supportsDoctor ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -806,6 +906,7 @@ export function Settings() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="settings-run-doctor-button"
|
||||
onClick={() => void handleRunOpenClawDoctor('diagnose')}
|
||||
disabled={doctorRunningMode !== null}
|
||||
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
@@ -816,8 +917,9 @@ export function Settings() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="settings-run-doctor-fix-button"
|
||||
onClick={() => void handleRunOpenClawDoctor('fix')}
|
||||
disabled={doctorRunningMode !== null}
|
||||
disabled={doctorRunningMode !== null || !supportsDoctorFix}
|
||||
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'fix' ? ' animate-spin' : ''}`} />
|
||||
@@ -873,6 +975,14 @@ export function Settings() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3" data-testid="settings-openclaw-doctor-unavailable">
|
||||
<Label className="text-sm font-medium text-foreground">{t('developer.doctor')}</Label>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{t('runtime.openclawOnly')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveSupportedLanguage } from '@shared/language';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type UpdateChannel = 'stable' | 'beta' | 'dev';
|
||||
type RuntimeKind = 'openclaw' | 'cc-connect';
|
||||
|
||||
interface SettingsState {
|
||||
// General
|
||||
@@ -21,6 +22,7 @@ interface SettingsState {
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: boolean;
|
||||
runtimeKind: RuntimeKind;
|
||||
gatewayPort: number;
|
||||
proxyEnabled: boolean;
|
||||
proxyServer: string;
|
||||
@@ -49,6 +51,7 @@ interface SettingsState {
|
||||
setLaunchAtStartup: (value: boolean) => void;
|
||||
setTelemetryEnabled: (value: boolean) => void;
|
||||
setGatewayAutoStart: (value: boolean) => void;
|
||||
setRuntimeKind: (value: RuntimeKind) => void;
|
||||
setGatewayPort: (port: number) => void;
|
||||
setProxyEnabled: (value: boolean) => void;
|
||||
setProxyServer: (value: string) => void;
|
||||
@@ -72,6 +75,7 @@ const defaultSettings = {
|
||||
launchAtStartup: false,
|
||||
telemetryEnabled: true,
|
||||
gatewayAutoStart: true,
|
||||
runtimeKind: 'openclaw' as RuntimeKind,
|
||||
gatewayPort: 18789,
|
||||
proxyEnabled: false,
|
||||
proxyServer: '',
|
||||
@@ -140,6 +144,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
set({ gatewayAutoStart });
|
||||
void hostApi.settings.set('gatewayAutoStart', gatewayAutoStart).catch(() => { });
|
||||
},
|
||||
setRuntimeKind: (runtimeKind) => {
|
||||
set({ runtimeKind });
|
||||
void hostApi.settings.set('runtimeKind', runtimeKind).catch(() => { });
|
||||
},
|
||||
setGatewayPort: (gatewayPort) => {
|
||||
set({ gatewayPort });
|
||||
void hostApi.settings.set('gatewayPort', gatewayPort).catch(() => { });
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
async function writeExecutable(path: string, content: string): Promise<void> {
|
||||
await writeFile(path, content, 'utf8');
|
||||
await chmod(path, 0o755);
|
||||
}
|
||||
|
||||
async function createMockCodexBinary(dir: string): Promise<string> {
|
||||
const binaryPath = join(dir, 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex');
|
||||
await mkdir(join(binaryPath, '..'), { recursive: true });
|
||||
await writeExecutable(binaryPath, `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--version')) {
|
||||
process.stdout.write('codex-cli e2e-mock\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] !== 'exec') {
|
||||
process.stderr.write('unexpected codex args: ' + JSON.stringify(args));
|
||||
process.exit(2);
|
||||
}
|
||||
if (process.env.CLAWX_E2E_CODEX_ARGS_PATH) {
|
||||
fs.writeFileSync(process.env.CLAWX_E2E_CODEX_ARGS_PATH, JSON.stringify(args, null, 2));
|
||||
}
|
||||
if (process.env.CLAWX_E2E_CODEX_ENV_PATH) {
|
||||
fs.writeFileSync(process.env.CLAWX_E2E_CODEX_ENV_PATH, JSON.stringify({
|
||||
CODEX_HOME: process.env.CODEX_HOME || null,
|
||||
}, null, 2));
|
||||
}
|
||||
const outputIndex = args.indexOf('--output-last-message');
|
||||
if (outputIndex >= 0 && args[outputIndex + 1]) {
|
||||
fs.writeFileSync(args[outputIndex + 1], 'Codex E2E response from mock binary');
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ item: { role: 'assistant', content: [{ type: 'text', text: 'Codex E2E response from stdout' }] } }) + '\\n');
|
||||
process.exit(0);
|
||||
`);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
async function createMockCcConnectBinary(dir: string): Promise<string> {
|
||||
const binaryPath = join(dir, process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeExecutable(binaryPath, `#!/usr/bin/env node
|
||||
const http = require('node:http');
|
||||
const WebSocket = require('ws');
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--version')) {
|
||||
process.stdout.write('cc-connect v1.3.2 e2e-mock\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === 'doctor') {
|
||||
process.stdout.write('cc-connect doctor e2e ok\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url && req.url.startsWith('/api/v1/status')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.url && req.url.startsWith('/api/v1/cron')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ jobs: [] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('{}');
|
||||
});
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
if (!req.url || !req.url.startsWith('/bridge/ws')) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
||||
});
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.type === 'register') {
|
||||
ws.send(JSON.stringify({ type: 'register_ack', ok: true }));
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'message') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'reply',
|
||||
reply_ctx: msg.reply_ctx,
|
||||
content: 'cc-connect bridge E2E response',
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
server.listen(9810, '127.0.0.1');
|
||||
process.stdout.write('cc-connect bridge e2e mock ready\\n');
|
||||
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
||||
process.on('SIGINT', () => server.close(() => process.exit(0)));
|
||||
`);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
async function prepareMockBundles(userDataDir: string): Promise<void> {
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
await rm(join(process.cwd(), 'build', 'codex', target), { recursive: true, force: true });
|
||||
await rm(join(process.cwd(), 'build', 'cc-connect', target), { recursive: true, force: true });
|
||||
await createMockCodexBinary(join(process.cwd(), 'build', 'codex', target));
|
||||
await createMockCcConnectBinary(join(process.cwd(), 'build', 'cc-connect', target));
|
||||
await writeFile(join(userDataDir, 'codex-bundle-ready'), 'ok', 'utf8');
|
||||
}
|
||||
|
||||
test.describe('cc-connect + Codex runtime E2E', () => {
|
||||
test.skip(process.platform === 'win32', 'POSIX executable mock binaries are used in this E2E');
|
||||
|
||||
test('starts cc-connect runtime, writes managed config, and sends chat through Codex bridge', async ({
|
||||
launchElectronApp,
|
||||
userDataDir,
|
||||
}) => {
|
||||
await prepareMockBundles(userDataDir);
|
||||
|
||||
await writeFile(join(userDataDir, 'settings.json'), JSON.stringify({
|
||||
language: 'en',
|
||||
runtimeKind: 'cc-connect',
|
||||
gatewayAutoStart: false,
|
||||
}, null, 2), 'utf8');
|
||||
await writeFile(join(userDataDir, 'clawx-providers.json'), JSON.stringify({
|
||||
schemaVersion: 0,
|
||||
providerAccounts: {
|
||||
'ollama-local': {
|
||||
id: 'ollama-local',
|
||||
vendorId: 'ollama',
|
||||
label: 'Ollama Local',
|
||||
authMode: 'local',
|
||||
model: 'qwen3:latest',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
providerSecrets: {},
|
||||
apiKeys: {},
|
||||
defaultProviderAccountId: 'ollama-local',
|
||||
}, null, 2), 'utf8');
|
||||
const app = await launchElectronApp({
|
||||
skipSetup: true,
|
||||
env: {
|
||||
CLAWX_CODEX_WORKDIR: process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-page')).toBeVisible();
|
||||
|
||||
const startResult = await page.evaluate(async () => {
|
||||
return await window.clawx.hostInvoke({
|
||||
id: 'runtime-start',
|
||||
module: 'gateway',
|
||||
action: 'start',
|
||||
});
|
||||
});
|
||||
expect(startResult).toMatchObject({
|
||||
ok: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
const status = await page.evaluate(async () => {
|
||||
return await window.clawx.hostInvoke({
|
||||
id: 'runtime-status',
|
||||
module: 'gateway',
|
||||
action: 'status',
|
||||
});
|
||||
});
|
||||
expect(status).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
state: 'running',
|
||||
runtimeKind: 'cc-connect',
|
||||
capabilities: expect.objectContaining({
|
||||
chat: true,
|
||||
sessions: true,
|
||||
history: true,
|
||||
doctor: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const managedConfig = join(userDataDir, 'runtimes', 'cc-connect', 'config.toml');
|
||||
await expect.poll(async () => await readFile(managedConfig, 'utf8')).toContain('path = "/bridge/ws"');
|
||||
await expect.poll(async () => await readFile(managedConfig, 'utf8')).toContain('cmd = "');
|
||||
|
||||
await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByTestId('chat-composer-input').fill('hello codex runtime');
|
||||
await page.getByTestId('chat-composer-send').click();
|
||||
|
||||
const readHistory = async () => await page.evaluate(async () => {
|
||||
return await window.clawx.hostInvoke({
|
||||
id: `runtime-history-${Date.now()}`,
|
||||
module: 'sessions',
|
||||
action: 'history',
|
||||
payload: { sessionKey: 'agent:main:main', limit: 20 },
|
||||
});
|
||||
});
|
||||
await expect.poll(async () => readHistory(), { timeout: 30_000 }).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
success: true,
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'user', content: 'hello codex runtime' }),
|
||||
expect.objectContaining({ role: 'assistant', content: 'cc-connect bridge E2E response' }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByText('cc-connect bridge E2E response')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const history = await readHistory();
|
||||
expect(history).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
success: true,
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'user', content: 'hello codex runtime' }),
|
||||
expect.objectContaining({ role: 'assistant', content: 'cc-connect bridge E2E response' }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('starts cc-connect runtime with OpenAI OAuth Codex auth in a managed CODEX_HOME', async ({
|
||||
launchElectronApp,
|
||||
userDataDir,
|
||||
}) => {
|
||||
await prepareMockBundles(userDataDir);
|
||||
const createdAt = '2026-06-07T00:00:00.000Z';
|
||||
|
||||
await writeFile(join(userDataDir, 'settings.json'), JSON.stringify({
|
||||
language: 'en',
|
||||
runtimeKind: 'cc-connect',
|
||||
gatewayAutoStart: false,
|
||||
}, null, 2), 'utf8');
|
||||
await writeFile(join(userDataDir, 'clawx-providers.json'), JSON.stringify({
|
||||
schemaVersion: 0,
|
||||
providerAccounts: {
|
||||
'openai-oauth': {
|
||||
id: 'openai-oauth',
|
||||
vendorId: 'openai',
|
||||
label: 'OpenAI OAuth',
|
||||
authMode: 'oauth_browser',
|
||||
model: 'gpt-5.5',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
metadata: { email: 'user@example.com', resourceUrl: 'openai-codex' },
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
},
|
||||
providerSecrets: {
|
||||
'openai-oauth': {
|
||||
type: 'oauth',
|
||||
accountId: 'openai-oauth',
|
||||
accessToken: 'fake-access-token',
|
||||
refreshToken: 'fake-refresh-token',
|
||||
idToken: 'fake-id-token',
|
||||
expiresAt: 1_780_000_000_000,
|
||||
email: 'user@example.com',
|
||||
subject: 'acct_e2e',
|
||||
},
|
||||
},
|
||||
apiKeys: {},
|
||||
defaultProviderAccountId: 'openai-oauth',
|
||||
}, null, 2), 'utf8');
|
||||
const app = await launchElectronApp({
|
||||
skipSetup: true,
|
||||
env: {
|
||||
CLAWX_CODEX_WORKDIR: process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
const startResult = await page.evaluate(async () => {
|
||||
return await window.clawx.hostInvoke({
|
||||
id: 'runtime-start-oauth',
|
||||
module: 'gateway',
|
||||
action: 'start',
|
||||
});
|
||||
});
|
||||
expect(startResult).toMatchObject({
|
||||
ok: true,
|
||||
data: { success: true },
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByTestId('chat-composer-input').fill('hello openai oauth codex runtime');
|
||||
await page.getByTestId('chat-composer-send').click();
|
||||
await expect(page.getByText('cc-connect bridge E2E response')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const managedCodexHome = join(userDataDir, 'runtimes', 'cc-connect', 'codex-home');
|
||||
|
||||
const authJson = JSON.parse(await readFile(join(managedCodexHome, 'auth.json'), 'utf8'));
|
||||
expect(authJson).toMatchObject({
|
||||
auth_mode: 'chatgpt',
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: 'fake-id-token',
|
||||
access_token: 'fake-access-token',
|
||||
refresh_token: 'fake-refresh-token',
|
||||
account_id: 'acct_e2e',
|
||||
},
|
||||
});
|
||||
|
||||
const publicProfile = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8');
|
||||
expect(publicProfile).toContain('CODEX_HOME');
|
||||
expect(publicProfile).not.toContain('fake-access-token');
|
||||
expect(publicProfile).not.toContain('fake-refresh-token');
|
||||
expect(publicProfile).not.toContain('fake-id-token');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { join, resolve } from 'node:path';
|
||||
|
||||
type LaunchElectronOptions = {
|
||||
skipSetup?: boolean;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
type IpcMockConfig = {
|
||||
@@ -158,6 +159,7 @@ async function launchClawXElectron(
|
||||
CLAWX_USER_DATA_DIR: userDataDir,
|
||||
...(options.skipSetup ? { CLAWX_E2E_SKIP_SETUP: '1' } : {}),
|
||||
CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort),
|
||||
...(options.env ?? {}),
|
||||
},
|
||||
timeout: 90_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
test.describe('Settings runtime selector', () => {
|
||||
test('switches to cc-connect and shows runtime capabilities', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
window.electron.ipcRenderer.__setMockConfig?.({
|
||||
gatewayStatus: {
|
||||
state: 'stopped',
|
||||
port: 0,
|
||||
runtimeKind: 'cc-connect',
|
||||
configDir: '/tmp/clawx/runtimes/cc-connect',
|
||||
capabilities: {
|
||||
chat: true,
|
||||
sessions: true,
|
||||
history: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
channels: true,
|
||||
cron: true,
|
||||
logs: true,
|
||||
skills: true,
|
||||
doctor: true,
|
||||
controlUi: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-nav-settings').click();
|
||||
|
||||
await expect(page.getByTestId('settings-runtime-section')).toBeVisible();
|
||||
await page.getByTestId('settings-runtime-cc-connect').click();
|
||||
|
||||
await expect(page.getByTestId('settings-runtime-cc-connect')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-runtime-config-dir')).toContainText('cc-connect');
|
||||
await expect(page.getByTestId('settings-runtime-capabilities')).toContainText('Doctor');
|
||||
|
||||
const devModeToggle = page.getByTestId('settings-dev-mode-switch');
|
||||
await devModeToggle.click();
|
||||
await expect(page.getByTestId('settings-run-doctor-button')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-run-doctor-fix-button')).toBeDisabled();
|
||||
await expect(page.getByTestId('sidebar-open-dev-console')).toContainText('CC Connect Page');
|
||||
});
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
buildCcConnectAssetName,
|
||||
normalizeCcConnectTarget,
|
||||
parseCcConnectBundleArgs,
|
||||
} from '../../scripts/cc-connect-bundle-lib.mjs';
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
buildCodexNativeTarballName,
|
||||
getCodexNativePackageName,
|
||||
normalizeCodexTarget,
|
||||
} from '../../scripts/codex-bundle-lib.mjs';
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildCcConnectAssetName,
|
||||
normalizeCcConnectTarget,
|
||||
parseCcConnectBundleArgs,
|
||||
} from '../fixtures/cc-connect-bundle-api';
|
||||
|
||||
describe('cc-connect bundle helpers', () => {
|
||||
it('maps Node platform and arch to cc-connect release asset names', () => {
|
||||
expect(normalizeCcConnectTarget('darwin', 'arm64')).toEqual({ platform: 'darwin', arch: 'arm64' });
|
||||
expect(normalizeCcConnectTarget('win32', 'x64')).toEqual({ platform: 'windows', arch: 'amd64' });
|
||||
expect(buildCcConnectAssetName('1.3.2', { platform: 'linux', arch: 'amd64' })).toBe('cc-connect-v1.3.2-linux-amd64.tar.gz');
|
||||
expect(buildCcConnectAssetName('1.3.2', { platform: 'windows', arch: 'amd64' })).toBe('cc-connect-v1.3.2-windows-amd64.zip');
|
||||
});
|
||||
|
||||
it('expands platform bundle presets without relying on runtime downloads', () => {
|
||||
expect(parseCcConnectBundleArgs(['--platform=mac']).targets).toEqual([
|
||||
{ nodePlatform: 'darwin', nodeArch: 'x64' },
|
||||
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
|
||||
]);
|
||||
expect(parseCcConnectBundleArgs(['--all']).targets).toContainEqual({ nodePlatform: 'win32', nodeArch: 'x64' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => tmpdir()),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('cc-connect path resolver', () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalOverride = process.env.CLAWX_CC_CONNECT_PATH;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
delete process.env.CLAWX_CC_CONNECT_PATH;
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-paths-'));
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(originalCwd);
|
||||
if (originalOverride === undefined) {
|
||||
delete process.env.CLAWX_CC_CONNECT_PATH;
|
||||
} else {
|
||||
process.env.CLAWX_CC_CONNECT_PATH = originalOverride;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the dev bundled cc-connect binary when node_modules postinstall did not create one', async () => {
|
||||
const binaryName = process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect';
|
||||
const bundledPath = join(process.cwd(), 'build', 'cc-connect', `${process.platform}-${process.arch}`, binaryName);
|
||||
await mkdir(join(bundledPath, '..'), { recursive: true });
|
||||
await writeFile(bundledPath, 'mock cc-connect', 'utf8');
|
||||
await chmod(bundledPath, 0o755);
|
||||
|
||||
const { getCcConnectBinaryPath, assertCcConnectBinaryPath } = await import('@electron/runtime/cc-connect-paths');
|
||||
|
||||
expect(getCcConnectBinaryPath()).toBe(bundledPath);
|
||||
expect(assertCcConnectBinaryPath()).toBe(bundledPath);
|
||||
});
|
||||
|
||||
it('does not use the npm postinstall binary as a runtime fallback', async () => {
|
||||
const binaryName = process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect';
|
||||
const nodeModulesBinary = join(process.cwd(), 'node_modules', 'cc-connect', 'bin', binaryName);
|
||||
await mkdir(join(nodeModulesBinary, '..'), { recursive: true });
|
||||
await writeFile(nodeModulesBinary, 'mock cc-connect from node_modules', 'utf8');
|
||||
await chmod(nodeModulesBinary, 0o755);
|
||||
|
||||
const { getCcConnectBinaryPath, assertCcConnectBinaryPath } = await import('@electron/runtime/cc-connect-paths');
|
||||
|
||||
expect(getCcConnectBinaryPath()).toContain(join('build', 'cc-connect', `${process.platform}-${process.arch}`));
|
||||
expect(getCcConnectBinaryPath()).not.toBe(nodeModulesBinary);
|
||||
expect(() => assertCcConnectBinaryPath()).toThrow('pnpm run bundle:cc-connect:current');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
// @vitest-environment node
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appPath = new Map<string, string>();
|
||||
const getDefaultProviderAccountIdMock = vi.fn();
|
||||
const getProviderAccountMock = vi.fn();
|
||||
const getProviderSecretMock = vi.fn();
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn((name: string) => appPath.get(name) ?? tmpdir()),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@electron/services/providers/provider-store', () => ({
|
||||
getDefaultProviderAccountId: (...args: unknown[]) => getDefaultProviderAccountIdMock(...args),
|
||||
getProviderAccount: (...args: unknown[]) => getProviderAccountMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/services/secrets/secret-store', () => ({
|
||||
getProviderSecret: (...args: unknown[]) => getProviderSecretMock(...args),
|
||||
}));
|
||||
|
||||
describe('cc-connect provider profile sync', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
getDefaultProviderAccountIdMock.mockReset();
|
||||
getProviderAccountMock.mockReset();
|
||||
getProviderSecretMock.mockReset();
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-provider-profile-'));
|
||||
appPath.set('userData', tempDir);
|
||||
appPath.set('home', tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('converts OpenAI provider accounts to Codex model args without writing secrets to disk', async () => {
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('openai-main');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
label: 'OpenAI',
|
||||
authMode: 'api_key',
|
||||
model: 'gpt-5.5',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue({
|
||||
type: 'api_key',
|
||||
accountId: 'openai-main',
|
||||
apiKey: 'sk-secret-value',
|
||||
});
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
const profile = await syncCcConnectProviderProfile({ reason: 'set-default' });
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
providerId: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
codexArgs: ['--model', 'gpt-5.5'],
|
||||
env: { OPENAI_API_KEY: 'sk-secret-value' },
|
||||
secretAvailable: true,
|
||||
supported: true,
|
||||
});
|
||||
const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8');
|
||||
expect(profileFile).toContain('"envKeys"');
|
||||
expect(profileFile).toContain('OPENAI_API_KEY');
|
||||
expect(profileFile).not.toContain('sk-secret-value');
|
||||
});
|
||||
|
||||
it('converts OpenAI OAuth accounts to a managed Codex auth home without writing tokens to the public profile', async () => {
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'openai-oauth',
|
||||
vendorId: 'openai',
|
||||
label: 'OpenAI OAuth',
|
||||
authMode: 'oauth_browser',
|
||||
model: 'gpt-5.5',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
metadata: { email: 'user@example.com', resourceUrl: 'openai-codex' },
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue({
|
||||
type: 'oauth',
|
||||
accountId: 'openai-oauth',
|
||||
accessToken: 'oauth-access-token',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
idToken: 'oauth-id-token',
|
||||
expiresAt: 1_780_000_000_000,
|
||||
email: 'user@example.com',
|
||||
subject: 'acct_123',
|
||||
});
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
const profile = await syncCcConnectProviderProfile({ reason: 'oauth-login' });
|
||||
|
||||
const codexHome = join(tempDir, 'runtimes', 'cc-connect', 'codex-home');
|
||||
expect(profile).toMatchObject({
|
||||
providerId: 'openai-oauth',
|
||||
vendorId: 'openai',
|
||||
authMode: 'oauth_browser',
|
||||
model: 'gpt-5.5',
|
||||
codexArgs: ['--model', 'gpt-5.5'],
|
||||
env: { CODEX_HOME: codexHome },
|
||||
secretAvailable: true,
|
||||
supported: true,
|
||||
});
|
||||
|
||||
const authFile = JSON.parse(await readFile(join(codexHome, 'auth.json'), 'utf8')) as {
|
||||
auth_mode?: string;
|
||||
OPENAI_API_KEY?: string | null;
|
||||
tokens?: Record<string, string>;
|
||||
};
|
||||
expect(authFile).toEqual({
|
||||
auth_mode: 'chatgpt',
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: 'oauth-id-token',
|
||||
access_token: 'oauth-access-token',
|
||||
refresh_token: 'oauth-refresh-token',
|
||||
account_id: 'acct_123',
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
|
||||
const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8');
|
||||
expect(profileFile).toContain('CODEX_HOME');
|
||||
expect(profileFile).not.toContain('oauth-access-token');
|
||||
expect(profileFile).not.toContain('oauth-refresh-token');
|
||||
expect(profileFile).not.toContain('oauth-id-token');
|
||||
});
|
||||
|
||||
it('imports a matching Codex id token for existing OpenAI OAuth secrets that predate idToken storage', async () => {
|
||||
await mkdir(join(tempDir, '.codex'), { recursive: true });
|
||||
await writeFile(join(tempDir, '.codex', 'auth.json'), JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: 'imported-id-token',
|
||||
access_token: 'imported-access-token',
|
||||
refresh_token: 'imported-refresh-token',
|
||||
account_id: 'acct_123',
|
||||
},
|
||||
last_refresh: '2026-06-07T00:00:00.000Z',
|
||||
}, null, 2), 'utf8');
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'openai-oauth',
|
||||
vendorId: 'openai',
|
||||
label: 'OpenAI OAuth',
|
||||
authMode: 'oauth_browser',
|
||||
model: 'gpt-5.5',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue({
|
||||
type: 'oauth',
|
||||
accountId: 'openai-oauth',
|
||||
accessToken: 'oauth-access-token',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
expiresAt: 1_780_000_000_000,
|
||||
subject: 'acct_123',
|
||||
});
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
const profile = await syncCcConnectProviderProfile({ reason: 'runtime-start' });
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
supported: true,
|
||||
env: { CODEX_HOME: join(tempDir, 'runtimes', 'cc-connect', 'codex-home') },
|
||||
});
|
||||
const authFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'codex-home', 'auth.json'), 'utf8');
|
||||
expect(authFile).toContain('"id_token": "imported-id-token"');
|
||||
expect(authFile).toContain('"access_token": "imported-access-token"');
|
||||
expect(authFile).toContain('"refresh_token": "imported-refresh-token"');
|
||||
const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8');
|
||||
expect(profileFile).not.toContain('imported-id-token');
|
||||
expect(profileFile).not.toContain('imported-access-token');
|
||||
expect(profileFile).not.toContain('imported-refresh-token');
|
||||
});
|
||||
|
||||
it('converts Ollama provider accounts to Codex OSS local-provider args', async () => {
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('ollama-local');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'ollama-local',
|
||||
vendorId: 'ollama',
|
||||
label: 'Ollama',
|
||||
authMode: 'local',
|
||||
model: 'qwen3:latest',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue(null);
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
await expect(syncCcConnectProviderProfile()).resolves.toMatchObject({
|
||||
providerId: 'ollama-local',
|
||||
vendorId: 'ollama',
|
||||
model: 'qwen3:latest',
|
||||
codexArgs: ['--oss', '--local-provider', 'ollama', '--model', 'qwen3:latest'],
|
||||
supported: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('converts OpenAI-compatible custom responses providers to Codex provider config args', async () => {
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('custom-responses');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'custom-responses',
|
||||
vendorId: 'custom',
|
||||
label: 'Custom Responses',
|
||||
authMode: 'api_key',
|
||||
baseUrl: 'https://gateway.example/openai/responses',
|
||||
apiProtocol: 'openai-responses',
|
||||
model: 'gpt-custom',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue({
|
||||
type: 'api_key',
|
||||
accountId: 'custom-responses',
|
||||
apiKey: 'custom-secret-value',
|
||||
});
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
const profile = await syncCcConnectProviderProfile();
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
providerId: 'custom-responses',
|
||||
vendorId: 'custom',
|
||||
model: 'gpt-custom',
|
||||
supported: true,
|
||||
env: { CLAWX_CODEX_CUSTOM_API_KEY: 'custom-secret-value' },
|
||||
codexHomeDir: join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
|
||||
ccConnectProvider: {
|
||||
name: 'clawx-custom',
|
||||
apiKeyEnvKey: 'CLAWX_CODEX_CUSTOM_API_KEY',
|
||||
baseUrl: 'https://gateway.example/openai',
|
||||
model: 'gpt-custom',
|
||||
wireApi: 'responses',
|
||||
},
|
||||
secretAvailable: true,
|
||||
});
|
||||
expect(profile.codexArgs).toEqual([
|
||||
'-c',
|
||||
'model_provider="clawx-custom"',
|
||||
'-c',
|
||||
'model_providers.clawx-custom.name="Custom Responses"',
|
||||
'-c',
|
||||
'model_providers.clawx-custom.base_url="https://gateway.example/openai"',
|
||||
'-c',
|
||||
'model_providers.clawx-custom.env_key="CLAWX_CODEX_CUSTOM_API_KEY"',
|
||||
'-c',
|
||||
'model_providers.clawx-custom.wire_api="responses"',
|
||||
'--model',
|
||||
'gpt-custom',
|
||||
]);
|
||||
const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8');
|
||||
expect(profileFile).toContain('CLAWX_CODEX_CUSTOM_API_KEY');
|
||||
expect(profileFile).not.toContain('custom-secret-value');
|
||||
const codexConfig = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'codex-home', 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain('model_provider = "clawx-custom"');
|
||||
expect(codexConfig).toContain('base_url = "https://gateway.example/openai"');
|
||||
expect(codexConfig).not.toContain('custom-secret-value');
|
||||
});
|
||||
|
||||
it('maps ByteDance ModelHub custom providers to the Codex Responses endpoint with env header refs', async () => {
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('modelhub-responses');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'modelhub-responses',
|
||||
vendorId: 'custom',
|
||||
label: 'modelhub-openai',
|
||||
authMode: 'api_key',
|
||||
baseUrl: 'https://aidp.bytedance.net/api/modelhub/online/v2/crawl',
|
||||
apiProtocol: 'openai-responses',
|
||||
model: 'gpt-5.5-2026-04-24',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue({
|
||||
type: 'api_key',
|
||||
accountId: 'modelhub-responses',
|
||||
apiKey: 'modelhub-secret-value',
|
||||
});
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
const profile = await syncCcConnectProviderProfile();
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
providerId: 'modelhub-responses',
|
||||
vendorId: 'custom',
|
||||
supported: true,
|
||||
model: 'gpt-5.5-2026-04-24',
|
||||
env: {
|
||||
BYTEDANCE_OPENAI_API_KEY: 'modelhub-secret-value',
|
||||
CODEX_MODELHUB_EXTRA_HEADER: '{"session_id":"clawx-cc-connect-modelhub-responses"}',
|
||||
CODEX_HOME: join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
|
||||
},
|
||||
ccConnectProvider: {
|
||||
name: 'modelhub_openapi',
|
||||
apiKeyEnvKey: 'BYTEDANCE_OPENAI_API_KEY',
|
||||
baseUrl: 'https://aidp.bytedance.net/api/modelhub/online',
|
||||
model: 'gpt-5.5-2026-04-24',
|
||||
wireApi: 'responses',
|
||||
},
|
||||
});
|
||||
expect(profile.codexArgs).toContain('model_provider="modelhub_openapi"');
|
||||
expect(profile.codexArgs).toContain('model_providers.modelhub_openapi.base_url="https://aidp.bytedance.net/api/modelhub/online"');
|
||||
expect(profile.codexArgs).toContain('model_providers.modelhub_openapi.env_http_headers={ "Api-Key" = "BYTEDANCE_OPENAI_API_KEY", "extra" = "CODEX_MODELHUB_EXTRA_HEADER" }');
|
||||
|
||||
const codexConfig = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'codex-home', 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain('model_provider = "modelhub_openapi"');
|
||||
expect(codexConfig).toContain('base_url = "https://aidp.bytedance.net/api/modelhub/online"');
|
||||
expect(codexConfig).toContain('env_http_headers = { "Api-Key" = "BYTEDANCE_OPENAI_API_KEY", "extra" = "CODEX_MODELHUB_EXTRA_HEADER" }');
|
||||
expect(codexConfig).not.toContain('modelhub-secret-value');
|
||||
expect(codexConfig).not.toContain('clawx-cc-connect-modelhub-responses');
|
||||
|
||||
const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8');
|
||||
expect(profileFile).toContain('BYTEDANCE_OPENAI_API_KEY');
|
||||
expect(profileFile).toContain('CODEX_MODELHUB_EXTRA_HEADER');
|
||||
expect(profileFile).not.toContain('modelhub-secret-value');
|
||||
expect(profileFile).not.toContain('clawx-cc-connect-modelhub-responses');
|
||||
});
|
||||
|
||||
it('marks custom chat completions providers unsupported because Codex only accepts responses wire api', async () => {
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('custom-chat');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'custom-chat',
|
||||
vendorId: 'custom',
|
||||
label: 'Custom Chat',
|
||||
authMode: 'api_key',
|
||||
baseUrl: 'https://gateway.example/openai',
|
||||
apiProtocol: 'openai-completions',
|
||||
model: 'gpt-custom',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue({
|
||||
type: 'api_key',
|
||||
accountId: 'custom-chat',
|
||||
apiKey: 'custom-secret-value',
|
||||
});
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
await expect(syncCcConnectProviderProfile()).resolves.toMatchObject({
|
||||
providerId: 'custom-chat',
|
||||
vendorId: 'custom',
|
||||
supported: false,
|
||||
unsupportedReason: expect.stringContaining('Chat Completions'),
|
||||
codexArgs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks non-Codex-compatible providers unsupported without mutating OpenClaw', async () => {
|
||||
getDefaultProviderAccountIdMock.mockResolvedValue('anthropic-main');
|
||||
getProviderAccountMock.mockResolvedValue({
|
||||
id: 'anthropic-main',
|
||||
vendorId: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
authMode: 'api_key',
|
||||
model: 'claude-opus-4-6',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
getProviderSecretMock.mockResolvedValue({ type: 'api_key', accountId: 'anthropic-main', apiKey: 'sk-ant' });
|
||||
const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile');
|
||||
|
||||
await expect(syncCcConnectProviderProfile()).resolves.toMatchObject({
|
||||
providerId: 'anthropic-main',
|
||||
vendorId: 'anthropic',
|
||||
supported: false,
|
||||
unsupportedReason: expect.stringContaining('not supported yet'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,653 @@
|
||||
// @vitest-environment node
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const forkMock = vi.fn();
|
||||
const appPath = new Map<string, string>();
|
||||
const { readOpenClawConfigMock } = vi.hoisted(() => ({
|
||||
readOpenClawConfigMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: forkMock,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/channel-config', () => ({
|
||||
readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn((name: string) => appPath.get(name) ?? tmpdir()),
|
||||
},
|
||||
}));
|
||||
|
||||
function createChild() {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
const stdoutHandlers: Array<(data: Buffer) => void> = [];
|
||||
const stderrHandlers: Array<(data: Buffer) => void> = [];
|
||||
return {
|
||||
pid: 4242,
|
||||
stdout: { on: vi.fn((_event: string, handler: (data: Buffer) => void) => stdoutHandlers.push(handler)) },
|
||||
stderr: { on: vi.fn((_event: string, handler: (data: Buffer) => void) => stderrHandlers.push(handler)) },
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
if (event === 'spawn') queueMicrotask(handler);
|
||||
return undefined;
|
||||
}),
|
||||
once: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
if (event === 'spawn') queueMicrotask(handler);
|
||||
return undefined;
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
writeStdout: (data: string) => {
|
||||
for (const handler of stdoutHandlers) handler(Buffer.from(data));
|
||||
},
|
||||
writeStderr: (data: string) => {
|
||||
for (const handler of stderrHandlers) handler(Buffer.from(data));
|
||||
},
|
||||
emit: (event: string, ...args: unknown[]) => {
|
||||
for (const handler of handlers.get(event) ?? []) handler(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CcConnectRuntimeProvider', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
forkMock.mockReset();
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-connect-'));
|
||||
appPath.set('userData', tempDir);
|
||||
readOpenClawConfigMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createBridgeMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
diagnose: vi.fn(async () => ({ success: true, stdout: 'codex-cli 0.130.0\n', stderr: '' })),
|
||||
send: vi.fn(async () => ({
|
||||
runId: 'codex-run-1',
|
||||
assistantMessage: { role: 'assistant', content: 'assistant ok', timestamp: 2 },
|
||||
})),
|
||||
listSessions: vi.fn(async () => [{ key: 'agent:main:main', displayName: 'main', updatedAt: 2 }]),
|
||||
loadHistory: vi.fn(async () => [{ role: 'assistant', content: 'assistant ok', timestamp: 2 }]),
|
||||
deleteSession: vi.fn(async () => undefined),
|
||||
summarizeSessions: vi.fn(async (sessionKeys: string[]) => sessionKeys.map((sessionKey) => ({
|
||||
sessionKey,
|
||||
firstUserText: 'hello',
|
||||
lastTimestamp: 2,
|
||||
}))),
|
||||
getSessionsDir: vi.fn(() => join(tempDir, 'runtimes', 'cc-connect', 'codex-sessions')),
|
||||
setProviderProfile: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createBridgeAdapterMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
connect: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
send: vi.fn(async () => ({ runId: 'cc-connect-run-1' })),
|
||||
listSessions: vi.fn(async () => [{ key: 'agent:main:main', displayName: 'main', updatedAt: 2 }]),
|
||||
loadHistory: vi.fn(async () => [{ role: 'assistant', content: 'assistant ok', timestamp: 2 }]),
|
||||
deleteSession: vi.fn(async () => undefined),
|
||||
summarizeSessions: vi.fn(async (sessionKeys: string[]) => sessionKeys.map((sessionKey) => ({
|
||||
sessionKey,
|
||||
firstUserText: 'hello',
|
||||
lastTimestamp: 2,
|
||||
}))),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderProfile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
providerId: 'ollama-local',
|
||||
vendorId: 'ollama',
|
||||
model: 'qwen3:latest',
|
||||
modelRef: 'ollama/qwen3:latest',
|
||||
supported: true,
|
||||
codexArgs: ['--oss', '--local-provider', 'ollama', '--model', 'qwen3:latest'],
|
||||
secretAvailable: false,
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('creates managed config, starts cc-connect, and connects the ClawX bridge adapter', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const bridge = createBridgeMock();
|
||||
const bridgeAdapter = createBridgeAdapterMock();
|
||||
const providerProfileLoader = vi.fn(async () => createProviderProfile());
|
||||
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexPath: join(tempDir, 'codex'),
|
||||
codexBridge: bridge as never,
|
||||
bridgeAdapter: bridgeAdapter as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
providerProfileLoader: providerProfileLoader as never,
|
||||
});
|
||||
const child = createChild();
|
||||
forkMock.mockReturnValueOnce(child);
|
||||
|
||||
const startPromise = provider.start();
|
||||
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce());
|
||||
child.emit('spawn');
|
||||
await startPromise;
|
||||
|
||||
const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml');
|
||||
const config = await readFile(configPath, 'utf8');
|
||||
expect(config).toContain('[management]');
|
||||
expect(config).toContain('enabled = true');
|
||||
expect(config).toContain('[bridge]');
|
||||
expect(config).toContain('path = "/bridge/ws"');
|
||||
expect(config).toContain('name = "clawx-main"');
|
||||
expect(config).toContain('type = "codex"');
|
||||
expect(config).toContain('[[projects.platforms]]');
|
||||
expect(config).toContain('type = "line"');
|
||||
expect(config).toContain('channel_secret = "clawx-local-placeholder"');
|
||||
expect(config).toContain('channel_token = "clawx-local-placeholder"');
|
||||
expect(config).toContain('port = "0"');
|
||||
expect(config).toContain(`cmd = "${join(tempDir, 'codex').replace(/\\/g, '\\\\')}"`);
|
||||
expect(forkMock).toHaveBeenCalledWith(binaryPath, [
|
||||
'-config',
|
||||
configPath,
|
||||
], expect.objectContaining({
|
||||
cwd: join(tempDir, 'runtimes', 'cc-connect'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: expect.objectContaining({
|
||||
CODEX_HOME: join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
|
||||
}),
|
||||
}));
|
||||
expect(bridge.diagnose).toHaveBeenCalledOnce();
|
||||
expect(bridgeAdapter.connect).toHaveBeenCalledOnce();
|
||||
expect(providerProfileLoader).toHaveBeenCalledWith({ reason: 'runtime-start' });
|
||||
expect(bridge.setProviderProfile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
providerId: 'ollama-local',
|
||||
vendorId: 'ollama',
|
||||
model: 'qwen3:latest',
|
||||
}));
|
||||
expect(provider.getStatus()).toMatchObject({
|
||||
state: 'running',
|
||||
pid: 4242,
|
||||
runtimeKind: 'cc-connect',
|
||||
capabilities: expect.objectContaining({ chat: true, doctor: true, providers: true, models: true, skills: true, cron: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('writes cc-connect provider config for custom Responses providers without persisting secrets', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const providerProfileLoader = vi.fn(async () => createProviderProfile({
|
||||
providerId: 'custom-responses',
|
||||
vendorId: 'custom',
|
||||
label: 'ModelHub',
|
||||
model: 'gpt-5.5',
|
||||
codexArgs: [],
|
||||
env: { CLAWX_CODEX_CUSTOM_API_KEY: 'secret-value' },
|
||||
ccConnectProvider: {
|
||||
name: 'clawx-custom',
|
||||
apiKeyEnvKey: 'CLAWX_CODEX_CUSTOM_API_KEY',
|
||||
baseUrl: 'https://aidp.bytedance.net/api/modelhub/online/v2/crawl',
|
||||
model: 'gpt-5.5',
|
||||
wireApi: 'responses',
|
||||
},
|
||||
secretAvailable: true,
|
||||
}));
|
||||
const bridgeAdapter = createBridgeAdapterMock();
|
||||
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexPath: join(tempDir, 'codex'),
|
||||
codexBridge: createBridgeMock() as never,
|
||||
bridgeAdapter: bridgeAdapter as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
providerProfileLoader: providerProfileLoader as never,
|
||||
});
|
||||
const child = createChild();
|
||||
forkMock.mockReturnValueOnce(child);
|
||||
|
||||
const startPromise = provider.start();
|
||||
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce());
|
||||
child.emit('spawn');
|
||||
await startPromise;
|
||||
|
||||
const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8');
|
||||
expect(config).toContain('provider = "clawx-custom"');
|
||||
expect(config).toContain('[[projects.agent.providers]]');
|
||||
expect(config).toContain('name = "clawx-custom"');
|
||||
expect(config).toContain('api_key = "${CLAWX_CODEX_CUSTOM_API_KEY}"');
|
||||
expect(config).toContain('base_url = "https://aidp.bytedance.net/api/modelhub/online/v2/crawl"');
|
||||
expect(config).toContain('model = "gpt-5.5"');
|
||||
expect(config).toContain('wire_api = "responses"');
|
||||
expect(config).not.toContain('secret-value');
|
||||
expect(forkMock).toHaveBeenCalledWith(binaryPath, [
|
||||
'-config',
|
||||
join(tempDir, 'runtimes', 'cc-connect', 'config.toml'),
|
||||
], expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAWX_CODEX_CUSTOM_API_KEY: 'secret-value',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns cc-connect skills status instead of rejecting skill RPC', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexBridge: createBridgeMock() as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
});
|
||||
|
||||
await expect(provider.rpc('skills.status')).resolves.toMatchObject({
|
||||
skills: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the cc-connect Web Admin URL for runtime control UI', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexBridge: createBridgeMock() as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
});
|
||||
|
||||
await expect(provider.rpc('runtime.controlUi')).resolves.toMatchObject({
|
||||
success: true,
|
||||
url: 'http://127.0.0.1:9820/',
|
||||
port: 9820,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a stable channel status snapshot for cc-connect channel-capable runtime', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexBridge: createBridgeMock() as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
});
|
||||
|
||||
await expect(provider.rpc('channels.status', { probe: true })).resolves.toEqual({
|
||||
channels: {},
|
||||
channelAccounts: {},
|
||||
channelDefaultAccountId: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors configured OpenClaw channel accounts into cc-connect platform blocks', async () => {
|
||||
readOpenClawConfigMock.mockResolvedValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: 'ops_bot',
|
||||
accounts: {
|
||||
ops_bot: {
|
||||
token: 'telegram-secret-token',
|
||||
allowFrom: ['12345', '67890'],
|
||||
shareSessionInChannel: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
feishu: {
|
||||
defaultAccount: 'lark_bot',
|
||||
accounts: {
|
||||
lark_bot: {
|
||||
appId: 'cli_lark',
|
||||
appSecret: 'lark-secret',
|
||||
domain: 'lark',
|
||||
enableFeishuCard: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexPath: join(tempDir, 'codex'),
|
||||
codexBridge: createBridgeMock() as never,
|
||||
bridgeAdapter: createBridgeAdapterMock() as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
providerProfileLoader: vi.fn(async () => createProviderProfile()) as never,
|
||||
});
|
||||
const child = createChild();
|
||||
forkMock.mockReturnValueOnce(child);
|
||||
|
||||
const startPromise = provider.start();
|
||||
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce());
|
||||
child.emit('spawn');
|
||||
await startPromise;
|
||||
|
||||
const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8');
|
||||
expect(config).toContain('type = "telegram"');
|
||||
expect(config).toContain('token = "telegram-secret-token"');
|
||||
expect(config).toContain('allow_from = "12345,67890"');
|
||||
expect(config).toContain('share_session_in_channel = true');
|
||||
expect(config).toContain('type = "lark"');
|
||||
expect(config).toContain('app_id = "cli_lark"');
|
||||
expect(config).toContain('app_secret = "lark-secret"');
|
||||
expect(config).toContain('domain = "https://open.larksuite.com"');
|
||||
expect(config).toContain('enable_feishu_card = false');
|
||||
|
||||
const logs = await provider.listLogs();
|
||||
expect(logs.content).not.toContain('telegram-secret-token');
|
||||
expect(logs.content).not.toContain('lark-secret');
|
||||
expect(logs.content).toContain('token = "<redacted>"');
|
||||
expect(logs.content).toContain('app_secret = "<redacted>"');
|
||||
|
||||
await expect(provider.rpc('channels.status')).resolves.toMatchObject({
|
||||
channelAccounts: {
|
||||
telegram: [{
|
||||
accountId: 'ops_bot',
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: true,
|
||||
linked: true,
|
||||
}],
|
||||
feishu: [{
|
||||
accountId: 'lark_bot',
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: true,
|
||||
linked: true,
|
||||
name: 'lark',
|
||||
}],
|
||||
},
|
||||
channelDefaultAccountId: {
|
||||
telegram: 'ops_bot',
|
||||
feishu: 'lark_bot',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose the managed local placeholder as a user channel', async () => {
|
||||
const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml');
|
||||
await mkdir(join(tempDir, 'runtimes', 'cc-connect'), { recursive: true });
|
||||
await writeFile(configPath, [
|
||||
'[[projects]]',
|
||||
'name = "clawx-main"',
|
||||
'',
|
||||
'[[projects.platforms]]',
|
||||
'type = "line"',
|
||||
'',
|
||||
'[projects.platforms.options]',
|
||||
'channel_secret = "clawx-local-placeholder"',
|
||||
'channel_token = "clawx-local-placeholder"',
|
||||
'port = "0"',
|
||||
'',
|
||||
'[[projects.platforms]]',
|
||||
'type = "feishu"',
|
||||
].join('\n'), 'utf8');
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexBridge: createBridgeMock() as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
});
|
||||
|
||||
await expect(provider.rpc('channels.status')).resolves.toEqual({
|
||||
channels: {},
|
||||
channelAccounts: {},
|
||||
channelDefaultAccountId: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects chat sends before bridge delivery when selected provider is unsupported', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const bridgeAdapter = createBridgeAdapterMock();
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexPath: join(tempDir, 'codex'),
|
||||
codexBridge: createBridgeMock() as never,
|
||||
bridgeAdapter: bridgeAdapter as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
providerProfileLoader: vi.fn(async () => createProviderProfile({
|
||||
providerId: 'custom-chat',
|
||||
vendorId: 'custom',
|
||||
supported: false,
|
||||
unsupportedReason: 'Custom Chat Completions is not supported by Codex',
|
||||
codexArgs: [],
|
||||
})) as never,
|
||||
});
|
||||
const child = createChild();
|
||||
forkMock.mockReturnValueOnce(child);
|
||||
|
||||
const startPromise = provider.start();
|
||||
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce());
|
||||
child.emit('spawn');
|
||||
await startPromise;
|
||||
|
||||
await expect(provider.sendMessageWithMedia({
|
||||
sessionKey: 'agent:main:main',
|
||||
idempotencyKey: 'send-1',
|
||||
message: 'hello',
|
||||
})).rejects.toThrow('Custom Chat Completions is not supported by Codex');
|
||||
expect(bridgeAdapter.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes chat, sessions, history, and delete to the Codex bridge', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const bridge = createBridgeAdapterMock();
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({ binaryPath, codexPath: join(tempDir, 'codex'), bridgeAdapter: bridge as never });
|
||||
const chatEvents: unknown[] = [];
|
||||
provider.on('chat:message', (event) => chatEvents.push(event));
|
||||
|
||||
await expect(provider.sendMessageWithMedia({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello',
|
||||
idempotencyKey: 'idem-1',
|
||||
})).resolves.toEqual({ runId: 'cc-connect-run-1' });
|
||||
await expect(provider.listSessions()).resolves.toMatchObject({
|
||||
success: true,
|
||||
sessions: [{ key: 'agent:main:main', displayName: 'main' }],
|
||||
});
|
||||
await expect(provider.listSessions({ sessionKeys: ['agent:main:main'] })).resolves.toMatchObject({
|
||||
success: true,
|
||||
summaries: [{ sessionKey: 'agent:main:main', firstUserText: 'hello', lastTimestamp: 2 }],
|
||||
});
|
||||
await expect(provider.loadHistory({ sessionKey: 'agent:main:main' })).resolves.toMatchObject({
|
||||
success: true,
|
||||
messages: [{ role: 'assistant', content: 'assistant ok' }],
|
||||
});
|
||||
await expect(provider.deleteSession({ sessionKey: 'agent:main:main' })).resolves.toEqual({ success: true });
|
||||
expect(bridge.send).toHaveBeenCalledOnce();
|
||||
expect(bridge.deleteSession).toHaveBeenCalledWith('agent:main:main');
|
||||
});
|
||||
|
||||
it('keeps legacy Gateway RPC chat/session/history calls working for cc-connect', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const bridge = createBridgeAdapterMock();
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({ binaryPath, codexPath: join(tempDir, 'codex'), bridgeAdapter: bridge as never });
|
||||
|
||||
await expect(provider.rpc('chat.send', {
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello via rpc',
|
||||
idempotencyKey: 'idem-rpc',
|
||||
})).resolves.toEqual({ runId: 'cc-connect-run-1' });
|
||||
await expect(provider.rpc('sessions.list', { includeDerivedTitles: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
sessions: [{ key: 'agent:main:main', displayName: 'main' }],
|
||||
});
|
||||
await expect(provider.rpc('chat.history', { sessionKey: 'agent:main:main', limit: 20 })).resolves.toMatchObject({
|
||||
success: true,
|
||||
messages: [{ role: 'assistant', content: 'assistant ok' }],
|
||||
});
|
||||
await expect(provider.rpc('sessions.delete', { sessionKey: 'agent:main:main' })).resolves.toEqual({ success: true });
|
||||
|
||||
expect(bridge.send).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello via rpc',
|
||||
idempotencyKey: 'idem-rpc',
|
||||
}));
|
||||
expect(bridge.loadHistory).toHaveBeenCalledWith('agent:main:main', 20);
|
||||
expect(bridge.deleteSession).toHaveBeenCalledWith('agent:main:main');
|
||||
});
|
||||
|
||||
it('syncs provider and model profile through runtime RPC', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const bridge = createBridgeMock();
|
||||
const providerProfileLoader = vi.fn(async () => createProviderProfile({
|
||||
providerId: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
codexArgs: ['--model', 'gpt-5.5'],
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
secretAvailable: true,
|
||||
}));
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexBridge: bridge as never,
|
||||
providerProfileLoader: providerProfileLoader as never,
|
||||
});
|
||||
|
||||
await expect(provider.rpc('providers.sync', {
|
||||
providerId: 'openai-main',
|
||||
reason: 'set-default',
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
profile: {
|
||||
providerId: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
codexArgs: ['--model', 'gpt-5.5'],
|
||||
envKeys: ['OPENAI_API_KEY'],
|
||||
secretAvailable: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(providerProfileLoader).toHaveBeenCalledWith({
|
||||
providerId: 'openai-main',
|
||||
reason: 'set-default',
|
||||
});
|
||||
expect(bridge.setProviderProfile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
providerId: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('restarts the running cc-connect process when provider sync changes launch config', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const bridgeAdapter = createBridgeAdapterMock();
|
||||
const providerProfileLoader = vi.fn(async () => createProviderProfile({
|
||||
providerId: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
codexArgs: ['--model', 'gpt-5.5'],
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
ccConnectProvider: {
|
||||
name: 'openai',
|
||||
apiKeyEnvKey: 'OPENAI_API_KEY',
|
||||
model: 'gpt-5.5',
|
||||
},
|
||||
secretAvailable: true,
|
||||
}));
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({
|
||||
binaryPath,
|
||||
codexPath: join(tempDir, 'codex'),
|
||||
codexBridge: createBridgeMock() as never,
|
||||
bridgeAdapter: bridgeAdapter as never,
|
||||
skillSyncer: vi.fn(async () => ({ skills: [] })),
|
||||
providerProfileLoader: providerProfileLoader as never,
|
||||
});
|
||||
const firstChild = createChild();
|
||||
const secondChild = createChild();
|
||||
forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild);
|
||||
const startPromise = provider.start();
|
||||
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(1));
|
||||
firstChild.emit('spawn');
|
||||
await startPromise;
|
||||
|
||||
const syncPromise = provider.rpc('providers.sync', {
|
||||
providerId: 'openai-main',
|
||||
reason: 'set-default',
|
||||
});
|
||||
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2));
|
||||
secondChild.emit('spawn');
|
||||
await syncPromise;
|
||||
|
||||
expect(firstChild.kill).toHaveBeenCalledOnce();
|
||||
expect(bridgeAdapter.close).toHaveBeenCalledOnce();
|
||||
expect(bridgeAdapter.connect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('runs cc-connect doctor against the managed config', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const child = createChild();
|
||||
forkMock.mockReturnValueOnce(child);
|
||||
const bridge = createBridgeMock();
|
||||
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({ binaryPath, codexBridge: bridge as never });
|
||||
const resultPromise = provider.runDoctor('diagnose');
|
||||
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce());
|
||||
child.writeStdout('doctor ok\n');
|
||||
child.emit('exit', 0);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
mode: 'diagnose',
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout: expect.stringContaining('doctor ok\n'),
|
||||
command: expect.stringContaining('doctor user-isolation'),
|
||||
});
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
stdout: expect.stringContaining('codex-cli 0.130.0'),
|
||||
});
|
||||
expect(forkMock).toHaveBeenCalledWith(binaryPath, [
|
||||
'doctor',
|
||||
'user-isolation',
|
||||
'--config',
|
||||
join(tempDir, 'runtimes', 'cc-connect', 'config.toml'),
|
||||
], expect.objectContaining({
|
||||
cwd: join(tempDir, 'runtimes', 'cc-connect'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns a stable unsupported result for cc-connect doctor fix', async () => {
|
||||
const binaryPath = join(tempDir, 'cc-connect');
|
||||
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
|
||||
const provider = new CcConnectRuntimeProvider({ binaryPath, codexBridge: createBridgeMock() as never });
|
||||
|
||||
await expect(provider.runDoctor('fix')).resolves.toMatchObject({
|
||||
mode: 'fix',
|
||||
success: false,
|
||||
error: 'cc-connect doctor does not support fix mode in v1.3.2',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appPath = new Map<string, string>();
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn((name: string) => appPath.get(name) ?? tmpdir()),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('cc-connect skill sync', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-skills-'));
|
||||
appPath.set('userData', tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('copies enabled local skills into the managed Codex home', async () => {
|
||||
const sourceDir = join(tempDir, 'source-skill');
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(join(sourceDir, 'SKILL.md'), '---\nname: demo\n---\nDemo skill', 'utf8');
|
||||
await writeFile(join(sourceDir, 'helper.txt'), 'helper', 'utf8');
|
||||
|
||||
const { syncCcConnectSkillRecords } = await import('@electron/runtime/cc-connect-skills');
|
||||
const result = await syncCcConnectSkillRecords([
|
||||
{
|
||||
id: 'demo/skill',
|
||||
name: 'Demo Skill',
|
||||
description: 'Demo',
|
||||
enabled: true,
|
||||
baseDir: sourceDir,
|
||||
},
|
||||
{
|
||||
id: 'disabled',
|
||||
name: 'Disabled',
|
||||
description: 'Disabled',
|
||||
enabled: false,
|
||||
baseDir: sourceDir,
|
||||
},
|
||||
]);
|
||||
|
||||
const targetDir = join(tempDir, 'runtimes', 'cc-connect', 'codex-home', 'skills', 'demo-skill');
|
||||
await expect(readFile(join(targetDir, 'SKILL.md'), 'utf8')).resolves.toContain('Demo skill');
|
||||
await expect(readFile(join(targetDir, 'helper.txt'), 'utf8')).resolves.toBe('helper');
|
||||
await expect(readFile(join(tempDir, 'runtimes', 'cc-connect', 'codex-home', 'skills', 'manifest.json'), 'utf8'))
|
||||
.resolves.toContain('demo/skill');
|
||||
expect(result.skills).toEqual([
|
||||
expect.objectContaining({ skillKey: 'demo/skill', name: 'Demo Skill', disabled: false }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildCodexNativeTarballName,
|
||||
getCodexNativePackageName,
|
||||
normalizeCodexTarget,
|
||||
} from '../fixtures/codex-bundle-api';
|
||||
|
||||
describe('Codex bundle helpers', () => {
|
||||
it('maps Node platform and arch to Codex native package metadata', () => {
|
||||
expect(normalizeCodexTarget('darwin', 'arm64')).toEqual({
|
||||
nodePlatform: 'darwin',
|
||||
nodeArch: 'arm64',
|
||||
packageSuffix: 'darwin-arm64',
|
||||
targetTriple: 'aarch64-apple-darwin',
|
||||
binaryName: 'codex',
|
||||
});
|
||||
expect(normalizeCodexTarget('linux', 'x64')).toMatchObject({
|
||||
packageSuffix: 'linux-x64',
|
||||
targetTriple: 'x86_64-unknown-linux-musl',
|
||||
});
|
||||
expect(normalizeCodexTarget('win32', 'x64')).toMatchObject({
|
||||
packageSuffix: 'win32-x64',
|
||||
targetTriple: 'x86_64-pc-windows-msvc',
|
||||
binaryName: 'codex.exe',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds npm tarball names for Codex native packages', () => {
|
||||
expect(getCodexNativePackageName('darwin-arm64')).toBe('@openai/codex@0.137.0-darwin-arm64');
|
||||
expect(buildCodexNativeTarballName('0.137.0', 'linux-x64')).toBe('codex-0.137.0-linux-x64.tgz');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const spawnMock = vi.fn();
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
function createChild() {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
const stdoutHandlers: Array<(data: Buffer) => void> = [];
|
||||
const stderrHandlers: Array<(data: Buffer) => void> = [];
|
||||
return {
|
||||
stdout: { on: vi.fn((_event: string, handler: (data: Buffer) => void) => stdoutHandlers.push(handler)) },
|
||||
stderr: { on: vi.fn((_event: string, handler: (data: Buffer) => void) => stderrHandlers.push(handler)) },
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
return undefined;
|
||||
}),
|
||||
writeStdout: (data: string) => {
|
||||
for (const handler of stdoutHandlers) handler(Buffer.from(data));
|
||||
},
|
||||
writeStderr: (data: string) => {
|
||||
for (const handler of stderrHandlers) handler(Buffer.from(data));
|
||||
},
|
||||
emit: (event: string, ...args: unknown[]) => {
|
||||
for (const handler of handlers.get(event) ?? []) handler(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CodexCliBridge', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
spawnMock.mockReset();
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'clawx-codex-bridge-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs codex exec, stores transcript, and lists sessions', async () => {
|
||||
const child = createChild();
|
||||
spawnMock.mockReturnValueOnce(child);
|
||||
const { CodexCliBridge } = await import('@electron/runtime/codex-cli-bridge');
|
||||
const bridge = new CodexCliBridge({
|
||||
codexPath: '/mock/codex',
|
||||
sessionsDir: tempDir,
|
||||
workDir: '/tmp/project',
|
||||
});
|
||||
|
||||
const sendPromise = bridge.send({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello',
|
||||
idempotencyKey: 'idem-1',
|
||||
});
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
const args = spawnMock.mock.calls[0]?.[1] as string[];
|
||||
const outputFile = args[args.indexOf('--output-last-message') + 1];
|
||||
await writeFile(outputFile, 'assistant ok\n', 'utf8');
|
||||
child.emit('exit', 0);
|
||||
|
||||
await expect(sendPromise).resolves.toMatchObject({
|
||||
runId: expect.stringMatching(/^codex-/),
|
||||
assistantMessage: { role: 'assistant', content: 'assistant ok' },
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledWith('/mock/codex', expect.arrayContaining([
|
||||
'exec',
|
||||
'--json',
|
||||
'--ignore-user-config',
|
||||
'-C',
|
||||
'/tmp/project',
|
||||
'-c',
|
||||
'approval_policy="never"',
|
||||
'--sandbox',
|
||||
'workspace-write',
|
||||
]), expect.objectContaining({ cwd: '/tmp/project' }));
|
||||
expect(args).not.toContain('--ask-for-approval');
|
||||
|
||||
await expect(bridge.loadHistory('agent:main:main')).resolves.toMatchObject([
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: 'assistant ok' },
|
||||
]);
|
||||
await expect(bridge.listSessions()).resolves.toMatchObject([
|
||||
{ key: 'agent:main:main', displayName: 'hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to assistant text parsed from codex JSONL stdout', async () => {
|
||||
const child = createChild();
|
||||
spawnMock.mockReturnValueOnce(child);
|
||||
const { CodexCliBridge } = await import('@electron/runtime/codex-cli-bridge');
|
||||
const bridge = new CodexCliBridge({
|
||||
codexPath: '/mock/codex',
|
||||
sessionsDir: tempDir,
|
||||
workDir: '/tmp/project',
|
||||
});
|
||||
|
||||
const sendPromise = bridge.send({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello',
|
||||
idempotencyKey: 'idem-1',
|
||||
});
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
child.writeStdout('not json\n');
|
||||
child.writeStdout(JSON.stringify({
|
||||
item: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'json assistant' }],
|
||||
},
|
||||
}) + '\n');
|
||||
child.emit('exit', 0);
|
||||
|
||||
await expect(sendPromise).resolves.toMatchObject({
|
||||
assistantMessage: { role: 'assistant', content: 'json assistant' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes synced provider model args and environment to codex exec', async () => {
|
||||
const child = createChild();
|
||||
spawnMock.mockReturnValueOnce(child);
|
||||
const { CodexCliBridge } = await import('@electron/runtime/codex-cli-bridge');
|
||||
const bridge = new CodexCliBridge({
|
||||
codexPath: '/mock/codex',
|
||||
sessionsDir: tempDir,
|
||||
workDir: '/tmp/project',
|
||||
});
|
||||
bridge.setProviderProfile({
|
||||
providerId: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
modelRef: 'openai/gpt-5.5',
|
||||
supported: true,
|
||||
codexArgs: ['--model', 'gpt-5.5'],
|
||||
env: { OPENAI_API_KEY: 'sk-test', CODEX_HOME: '/tmp/clawx-codex-home' },
|
||||
secretAvailable: true,
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const sendPromise = bridge.send({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello',
|
||||
idempotencyKey: 'idem-1',
|
||||
});
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
child.writeStdout(JSON.stringify({
|
||||
item: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
}) + '\n');
|
||||
child.emit('exit', 0);
|
||||
|
||||
await expect(sendPromise).resolves.toMatchObject({
|
||||
assistantMessage: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledWith('/mock/codex', expect.arrayContaining([
|
||||
'--model',
|
||||
'gpt-5.5',
|
||||
]), expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENAI_API_KEY: 'sk-test',
|
||||
CODEX_HOME: '/tmp/clawx-codex-home',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('passes ClawX proxy settings to codex child processes', async () => {
|
||||
const child = createChild();
|
||||
spawnMock.mockReturnValueOnce(child);
|
||||
const { CodexCliBridge } = await import('@electron/runtime/codex-cli-bridge');
|
||||
const bridge = new CodexCliBridge({
|
||||
codexPath: '/mock/codex',
|
||||
sessionsDir: tempDir,
|
||||
workDir: '/tmp/project',
|
||||
proxyEnvProvider: () => ({
|
||||
HTTP_PROXY: 'http://127.0.0.1:7890',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:7891',
|
||||
ALL_PROXY: 'socks5://127.0.0.1:7892',
|
||||
http_proxy: 'http://127.0.0.1:7890',
|
||||
https_proxy: 'http://127.0.0.1:7891',
|
||||
all_proxy: 'socks5://127.0.0.1:7892',
|
||||
NO_PROXY: 'localhost,127.0.0.1',
|
||||
no_proxy: 'localhost,127.0.0.1',
|
||||
}),
|
||||
});
|
||||
bridge.setProviderProfile({
|
||||
providerId: 'openai-main',
|
||||
vendorId: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
modelRef: 'openai/gpt-5.5',
|
||||
supported: true,
|
||||
codexArgs: ['--model', 'gpt-5.5'],
|
||||
env: { OPENAI_API_KEY: 'sk-test', CODEX_HOME: '/tmp/clawx-codex-home' },
|
||||
secretAvailable: true,
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const sendPromise = bridge.send({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello',
|
||||
idempotencyKey: 'idem-1',
|
||||
});
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
child.writeStdout(JSON.stringify({
|
||||
item: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
}) + '\n');
|
||||
child.emit('exit', 0);
|
||||
|
||||
await expect(sendPromise).resolves.toMatchObject({
|
||||
assistantMessage: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledWith('/mock/codex', expect.any(Array), expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENAI_API_KEY: 'sk-test',
|
||||
CODEX_HOME: '/tmp/clawx-codex-home',
|
||||
HTTP_PROXY: 'http://127.0.0.1:7890',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:7891',
|
||||
ALL_PROXY: 'socks5://127.0.0.1:7892',
|
||||
NO_PROXY: 'localhost,127.0.0.1',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects unsupported provider profiles before spawning codex', async () => {
|
||||
const { CodexCliBridge } = await import('@electron/runtime/codex-cli-bridge');
|
||||
const bridge = new CodexCliBridge({
|
||||
codexPath: '/mock/codex',
|
||||
sessionsDir: tempDir,
|
||||
workDir: '/tmp/project',
|
||||
});
|
||||
bridge.setProviderProfile({
|
||||
providerId: 'anthropic-main',
|
||||
vendorId: 'anthropic',
|
||||
supported: false,
|
||||
unsupportedReason: 'anthropic is unsupported',
|
||||
codexArgs: [],
|
||||
secretAvailable: true,
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await expect(bridge.send({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello',
|
||||
idempotencyKey: 'idem-1',
|
||||
})).rejects.toThrow('anthropic is unsupported');
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores a system error message when codex exits non-zero', async () => {
|
||||
const child = createChild();
|
||||
spawnMock.mockReturnValueOnce(child);
|
||||
const { CodexCliBridge } = await import('@electron/runtime/codex-cli-bridge');
|
||||
const bridge = new CodexCliBridge({
|
||||
codexPath: '/mock/codex',
|
||||
sessionsDir: tempDir,
|
||||
workDir: '/tmp/project',
|
||||
});
|
||||
|
||||
const sendPromise = bridge.send({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'hello',
|
||||
idempotencyKey: 'idem-1',
|
||||
});
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
child.writeStderr('auth missing');
|
||||
child.emit('exit', 1);
|
||||
|
||||
await expect(sendPromise).resolves.toMatchObject({
|
||||
assistantMessage: {
|
||||
role: 'system',
|
||||
content: 'auth missing',
|
||||
isError: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('diagnoses codex CLI availability with --version', async () => {
|
||||
const child = createChild();
|
||||
spawnMock.mockReturnValueOnce(child);
|
||||
const { CodexCliBridge } = await import('@electron/runtime/codex-cli-bridge');
|
||||
const bridge = new CodexCliBridge({
|
||||
codexPath: '/mock/codex',
|
||||
sessionsDir: tempDir,
|
||||
workDir: '/tmp/project',
|
||||
});
|
||||
|
||||
const resultPromise = bridge.diagnose();
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
child.writeStdout('codex-cli 0.130.0\n');
|
||||
child.emit('exit', 0);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
success: true,
|
||||
stdout: 'codex-cli 0.130.0\n',
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledWith('/mock/codex', ['--version'], expect.any(Object));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment node
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => tmpdir()),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Codex bundle path resolver', () => {
|
||||
const originalCwd = process.cwd();
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'clawx-codex-paths-'));
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(originalCwd);
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the dev bundled native Codex binary and helper path', async () => {
|
||||
const binaryName = process.platform === 'win32' ? 'codex.exe' : 'codex';
|
||||
const bundledDir = join(process.cwd(), 'build', 'codex', `${process.platform}-${process.arch}`);
|
||||
const binaryPath = join(bundledDir, 'bin', binaryName);
|
||||
const pathDir = join(bundledDir, 'codex-path');
|
||||
await mkdir(join(binaryPath, '..'), { recursive: true });
|
||||
await mkdir(pathDir, { recursive: true });
|
||||
await writeFile(binaryPath, 'mock codex', 'utf8');
|
||||
await chmod(binaryPath, 0o755);
|
||||
|
||||
const { getCodexBundle, assertCodexBundle } = await import('@electron/runtime/codex-paths');
|
||||
|
||||
expect(getCodexBundle()).toMatchObject({
|
||||
binaryPath,
|
||||
pathDir,
|
||||
baseDir: bundledDir,
|
||||
});
|
||||
expect(assertCodexBundle()).toMatchObject({ binaryPath, pathDir });
|
||||
});
|
||||
|
||||
it('fails without falling back to a PATH/global codex binary', async () => {
|
||||
const { getCodexBundle, assertCodexBundle } = await import('@electron/runtime/codex-paths');
|
||||
|
||||
const candidate = getCodexBundle();
|
||||
expect(candidate.binaryPath).toContain(join('build', 'codex', `${process.platform}-${process.arch}`, 'bin'));
|
||||
expect(candidate.binaryPath).not.toBe('codex');
|
||||
expect(() => assertCodexBundle()).toThrow('pnpm run bundle:codex:current');
|
||||
});
|
||||
});
|
||||
@@ -379,6 +379,36 @@ describe('host services', () => {
|
||||
expect(gatewayManager.rpc).toHaveBeenCalledWith('chat.history', { limit: 1 }, 42);
|
||||
});
|
||||
|
||||
it('opens the cc-connect Web Admin URL through the active runtime', async () => {
|
||||
const runtimeManager = {
|
||||
getStatus: vi.fn(() => ({
|
||||
state: 'running',
|
||||
port: 9820,
|
||||
runtimeKind: 'cc-connect',
|
||||
capabilities: { controlUi: true },
|
||||
})),
|
||||
rpc: vi.fn(async () => ({
|
||||
success: true,
|
||||
url: 'http://127.0.0.1:9820/',
|
||||
token: 'cc-connect-management-token',
|
||||
port: 9820,
|
||||
})),
|
||||
};
|
||||
const backpressure = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
const { createGatewayApi } = await import('@electron/services/gateway-api');
|
||||
|
||||
await expect(createGatewayApi(runtimeManager as never, backpressure as never).controlUi()).resolves.toEqual({
|
||||
success: true,
|
||||
url: 'http://127.0.0.1:9820/',
|
||||
token: 'cc-connect-management-token',
|
||||
port: 9820,
|
||||
});
|
||||
|
||||
expect(runtimeManager.rpc).toHaveBeenCalledWith('runtime.controlUi', {}, 5000);
|
||||
});
|
||||
|
||||
it('exposes provider account snapshot actions through the typed providers service', async () => {
|
||||
const account = {
|
||||
id: 'custom-local',
|
||||
@@ -467,6 +497,70 @@ describe('host services', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs provider accounts through cc-connect runtime when cc-connect is active', async () => {
|
||||
const account = {
|
||||
id: 'ollama-local',
|
||||
vendorId: 'ollama',
|
||||
label: 'Ollama',
|
||||
authMode: 'local',
|
||||
model: 'qwen3:latest',
|
||||
enabled: true,
|
||||
createdAt: '2026-06-07T00:00:00.000Z',
|
||||
updatedAt: '2026-06-07T00:00:00.000Z',
|
||||
};
|
||||
providerServiceMock.createAccount.mockResolvedValue(account);
|
||||
const runtimeManager = {
|
||||
getActiveKind: vi.fn(async () => 'cc-connect'),
|
||||
rpc: vi.fn(async () => ({ success: true })),
|
||||
};
|
||||
const { createProvidersApi } = await import('@electron/services/providers-api');
|
||||
|
||||
await expect(createProvidersApi({
|
||||
gatewayManager: { debouncedReload: vi.fn() } as never,
|
||||
runtimeManager: runtimeManager as never,
|
||||
mainWindow: {} as never,
|
||||
}).createAccount({ account })).resolves.toEqual({
|
||||
success: true,
|
||||
account,
|
||||
});
|
||||
|
||||
expect(providerServiceMock.createAccount).toHaveBeenCalledWith(account, undefined);
|
||||
expect(runtimeManager.rpc).toHaveBeenCalledWith('providers.sync', {
|
||||
providerId: 'ollama-local',
|
||||
reason: 'save',
|
||||
});
|
||||
expect(syncSavedProviderToRuntimeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes cron API through cc-connect runtime when cc-connect is active', async () => {
|
||||
const runtimeManager = {
|
||||
getActiveKind: vi.fn(async () => 'cc-connect'),
|
||||
rpc: vi.fn(async () => [{
|
||||
id: 'cron-1',
|
||||
name: 'Daily summary',
|
||||
message: 'summarize',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||
enabled: true,
|
||||
createdAt: '2026-06-08T00:00:00.000Z',
|
||||
updatedAt: '2026-06-08T00:00:00.000Z',
|
||||
}]),
|
||||
};
|
||||
const gatewayManager = {
|
||||
rpc: vi.fn(),
|
||||
};
|
||||
const { createCronApi } = await import('@electron/services/cron-api');
|
||||
|
||||
await expect(createCronApi({
|
||||
gatewayManager: gatewayManager as never,
|
||||
runtimeManager: runtimeManager as never,
|
||||
}).list()).resolves.toMatchObject([
|
||||
{ id: 'cron-1', name: 'Daily summary' },
|
||||
]);
|
||||
|
||||
expect(runtimeManager.rpc).toHaveBeenCalledWith('cron.list');
|
||||
expect(gatewayManager.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets the default provider account and syncs runtime defaults', async () => {
|
||||
providerServiceMock.getDefaultAccountId.mockResolvedValue('old-default');
|
||||
const gatewayManager = { debouncedReload: vi.fn() };
|
||||
@@ -539,6 +633,71 @@ describe('host services', () => {
|
||||
expect(gatewayManager.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('probes active runtime for channel status when runtime manager is provided', async () => {
|
||||
readOpenClawConfigMock.mockResolvedValue({
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
team_bot: { enabled: true },
|
||||
},
|
||||
defaultAccount: 'team_bot',
|
||||
},
|
||||
},
|
||||
});
|
||||
listConfiguredChannelsFromConfigMock.mockResolvedValue(['feishu']);
|
||||
listConfiguredChannelAccountsFromConfigMock.mockReturnValue({
|
||||
feishu: {
|
||||
defaultAccountId: 'team_bot',
|
||||
accountIds: ['team_bot'],
|
||||
},
|
||||
});
|
||||
const gatewayManager = {
|
||||
rpc: vi.fn(),
|
||||
getStatus: vi.fn(() => ({ state: 'stopped', port: 18789 })),
|
||||
getDiagnostics: vi.fn(() => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 })),
|
||||
};
|
||||
const runtimeManager = {
|
||||
rpc: vi.fn(async () => ({
|
||||
channelAccounts: {
|
||||
feishu: [{
|
||||
accountId: 'team_bot',
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: true,
|
||||
linked: true,
|
||||
}],
|
||||
},
|
||||
})),
|
||||
getStatus: vi.fn(() => ({
|
||||
state: 'running',
|
||||
port: 9820,
|
||||
runtimeKind: 'cc-connect',
|
||||
capabilities: { channels: true },
|
||||
})),
|
||||
};
|
||||
const { createChannelsApi } = await import('@electron/services/channels-api');
|
||||
|
||||
await expect(createChannelsApi({
|
||||
gatewayManager: gatewayManager as never,
|
||||
runtimeManager: runtimeManager as never,
|
||||
}).accounts({ mode: 'runtime', probe: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
channels: [{
|
||||
channelType: 'feishu',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'team_bot',
|
||||
connected: true,
|
||||
running: true,
|
||||
linked: true,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
expect(runtimeManager.rpc).toHaveBeenCalledWith('channels.status', { probe: true }, 5000);
|
||||
expect(gatewayManager.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists channel targets from session history and validates channel type', async () => {
|
||||
const sessionsDir = join(testOpenClawConfigDir, 'agents', 'main', 'sessions');
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
@@ -640,6 +799,46 @@ describe('host services', () => {
|
||||
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150);
|
||||
});
|
||||
|
||||
it('restarts active cc-connect runtime after saving channel config', async () => {
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
agents: [{ id: 'main', name: 'Main' }],
|
||||
defaultAgentId: 'main',
|
||||
defaultModelRef: null,
|
||||
configuredChannelTypes: ['feishu'],
|
||||
channelOwners: {},
|
||||
channelAccountOwners: {},
|
||||
});
|
||||
getChannelFormValuesMock.mockResolvedValue({ appId: 'old', appSecret: 'old-secret' });
|
||||
const gatewayManager = {
|
||||
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
|
||||
debouncedRestart: vi.fn(),
|
||||
debouncedReload: vi.fn(),
|
||||
};
|
||||
const runtimeManager = {
|
||||
getActiveKind: vi.fn(async () => 'cc-connect'),
|
||||
getStatus: vi.fn(() => ({
|
||||
state: 'running',
|
||||
port: 9820,
|
||||
runtimeKind: 'cc-connect',
|
||||
})),
|
||||
restart: vi.fn(async () => undefined),
|
||||
};
|
||||
const { createChannelsApi } = await import('@electron/services/channels-api');
|
||||
|
||||
await expect(createChannelsApi({
|
||||
gatewayManager: gatewayManager as never,
|
||||
runtimeManager: runtimeManager as never,
|
||||
}).saveConfig({
|
||||
channelType: 'feishu',
|
||||
accountId: 'default',
|
||||
config: { appId: 'cli_new', appSecret: 'new-secret' },
|
||||
})).resolves.toEqual({ success: true });
|
||||
|
||||
expect(runtimeManager.restart).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes agents by restarting gateway, removing workspace, and returning snapshot', async () => {
|
||||
const snapshot = {
|
||||
agents: [],
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment node
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { RuntimeProvider } from '@electron/runtime/types';
|
||||
|
||||
const settings = new Map<string, unknown>();
|
||||
|
||||
vi.mock('@electron/utils/store', () => ({
|
||||
getSetting: vi.fn(async (key: string) => settings.get(key)),
|
||||
setSetting: vi.fn(async (key: string, value: unknown) => {
|
||||
settings.set(key, value);
|
||||
}),
|
||||
}));
|
||||
|
||||
function createProvider(kind: RuntimeProvider['kind']) {
|
||||
const emitter = new EventEmitter();
|
||||
const provider: RuntimeProvider = {
|
||||
kind,
|
||||
on: emitter.on.bind(emitter) as RuntimeProvider['on'],
|
||||
off: emitter.off.bind(emitter) as RuntimeProvider['off'],
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
restart: vi.fn(async () => undefined),
|
||||
getStatus: vi.fn(() => ({
|
||||
state: 'stopped',
|
||||
port: kind === 'openclaw' ? 18789 : 19876,
|
||||
runtimeKind: kind,
|
||||
capabilities: provider.listCapabilities(),
|
||||
})),
|
||||
checkHealth: vi.fn(async () => ({ ok: true })),
|
||||
rpc: vi.fn(async () => ({ ok: true })),
|
||||
sendMessageWithMedia: vi.fn(async () => ({ runId: `${kind}-run` })),
|
||||
listSessions: vi.fn(async () => ({ sessions: [] })),
|
||||
loadHistory: vi.fn(async () => ({ messages: [] })),
|
||||
deleteSession: vi.fn(async () => ({ success: true })),
|
||||
listLogs: vi.fn(async () => ({ content: `${kind} logs` })),
|
||||
runDoctor: vi.fn(async (mode) => ({
|
||||
mode,
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
command: `${kind} doctor`,
|
||||
cwd: '/tmp',
|
||||
durationMs: 1,
|
||||
})),
|
||||
listCapabilities: vi.fn(() => ({
|
||||
chat: true,
|
||||
sessions: true,
|
||||
history: true,
|
||||
providers: kind === 'openclaw',
|
||||
models: kind === 'openclaw',
|
||||
channels: kind === 'openclaw',
|
||||
cron: kind === 'openclaw',
|
||||
logs: true,
|
||||
skills: kind === 'openclaw',
|
||||
doctor: true,
|
||||
controlUi: true,
|
||||
})),
|
||||
};
|
||||
return { provider, emitter };
|
||||
}
|
||||
|
||||
describe('RuntimeManager', () => {
|
||||
beforeEach(() => {
|
||||
settings.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('defaults to OpenClaw when no runtime setting is stored', async () => {
|
||||
const { RuntimeManager } = await import('@electron/runtime/manager');
|
||||
const openclaw = createProvider('openclaw').provider;
|
||||
const ccConnect = createProvider('cc-connect').provider;
|
||||
const manager = new RuntimeManager({ openclaw, ccConnect });
|
||||
|
||||
await expect(manager.getActiveKind()).resolves.toBe('openclaw');
|
||||
expect(manager.getActiveProvider()).toBe(openclaw);
|
||||
expect(manager.getStatus().runtimeKind).toBe('openclaw');
|
||||
});
|
||||
|
||||
it('switches runtime setting and stops the previous provider', async () => {
|
||||
const { RuntimeManager } = await import('@electron/runtime/manager');
|
||||
const openclaw = createProvider('openclaw').provider;
|
||||
const ccConnect = createProvider('cc-connect').provider;
|
||||
const manager = new RuntimeManager({ openclaw, ccConnect });
|
||||
await manager.getActiveKind();
|
||||
|
||||
await manager.setActiveKind('cc-connect');
|
||||
|
||||
expect(openclaw.stop).toHaveBeenCalledOnce();
|
||||
expect(settings.get('runtimeKind')).toBe('cc-connect');
|
||||
expect(manager.getActiveProvider()).toBe(ccConnect);
|
||||
expect(manager.listCapabilities().controlUi).toBe(true);
|
||||
});
|
||||
|
||||
it('marks cc-connect channels as supported because cc-connect owns messaging platforms', async () => {
|
||||
const { CC_CONNECT_RUNTIME_CAPABILITIES } = await import('@electron/runtime/types');
|
||||
|
||||
expect(CC_CONNECT_RUNTIME_CAPABILITIES.channels).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards provider status events with runtimeKind preserved', async () => {
|
||||
const { RuntimeManager } = await import('@electron/runtime/manager');
|
||||
const openclawFixture = createProvider('openclaw');
|
||||
const manager = new RuntimeManager({
|
||||
openclaw: openclawFixture.provider,
|
||||
ccConnect: createProvider('cc-connect').provider,
|
||||
});
|
||||
const statuses: unknown[] = [];
|
||||
manager.on('status', (status) => statuses.push(status));
|
||||
|
||||
openclawFixture.emitter.emit('status', { state: 'running', port: 18789 });
|
||||
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({ state: 'running', port: 18789, runtimeKind: 'openclaw' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user