Add runtime abstraction and cc-connect provider

This commit is contained in:
ashione
2026-06-07 23:47:06 +08:00
parent 581981f203
commit 7a679a51fa
53 changed files with 4452 additions and 118 deletions
+2
View File
@@ -95,6 +95,8 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runtime とロールバック経路のままで、**設定 → Gateway → Runtime** から任意の同梱 `cc-connect` runtime に切り替えられます。パッケージ版は必要な cc-connect バイナリを app resources に含め、cc-connect は `~/.cc-connect` を自動変更せず、app userData 配下の ClawX 管理ディレクトリを使います。OpenAI API key、OpenAI OAuth/Codex、Ollama provider の選択は、cc-connect モード用の ClawX 管理 Codex 起動 profile に変換されます。
---
## 機能
+2
View File
@@ -95,6 +95,8 @@ 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 the required cc-connect binary in app resources; cc-connect uses a ClawX-managed configuration directory under app user data instead of modifying `~/.cc-connect`. OpenAI API key, OpenAI OAuth/Codex, and Ollama provider selections are converted into managed Codex launch profiles for cc-connect mode.
---
## Features
+2
View File
@@ -96,6 +96,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
打开开发者模式后,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会把 cc-connect 二进制放入应用 resourcescc-connect 使用 ClawX 在 app userData 下托管的配置目录,不会自动修改 `~/.cc-connect`。OpenAI API key、OpenAI OAuth/Codex 和 Ollama provider 选择会被转换为 cc-connect 模式下的托管 Codex 启动 profile。
---
## 功能特性
+129
View File
@@ -0,0 +1,129 @@
# 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 to Codex.
- Sessions and history are stored under ClawX-managed app data.
- Runtime status, logs, and Doctor are runtime-aware.
- Provider, cron, and channel surfaces degrade through cc-connect capability checks instead of writing OpenClaw config.
## Accepted Architecture
ClawX uses a mixed replacement provider:
- ClawX directly drives Codex for GUI chat, session creation, and history.
- cc-connect owns channel/messaging bridge, provider/cron CLI integration, Doctor, managed config, logs, and packaged binary lifecycle.
- 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 --> CodexBridge["CodexCliBridge"]
Provider --> Store["ClawX managed transcripts"]
Provider --> CcCli["cc-connect CLI / managed config"]
CodexBridge --> Codex["codex exec --json"]
```
## Why Not Route GUI Chat Through cc-connect Only
`cc-connect@1.3.2` is designed around projects bound to messaging platforms. Local probing showed the binary rejects project configurations without a real platform and does not expose `bridge`, `custom`, or `web` as project platform types. That makes it unsuitable as the sole GUI chat backend for ClawX until cc-connect adds a first-class local GUI platform or management endpoint that accepts direct GUI prompts.
Direct Codex execution keeps the ClawX GUI usable today while still using cc-connect for packaged runtime, Doctor, channels, cron, and provider management.
## Core Capability Contract
| Capability | Replacement behavior |
|---|---|
| Chat | `CcConnectRuntimeProvider.sendMessageWithMedia` calls `CodexCliBridge.send`. |
| Sessions | ClawX stores cc-connect/Codex sessions under app userData. |
| History | ClawX reads managed JSONL transcripts and returns `RawMessage[]`. |
| 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`; OpenAI API key, OpenAI OAuth/Codex, and Ollama are passed to `codex exec` as launch args/env. |
| Cron/channels | First implementation remains capability-aware; later work should map to cc-connect CLI/management API. |
## 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.
- 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.
`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:
- `CodexCliBridge`
- transcript metadata and JSONL persistence
- `sendMessageWithMedia`
- `listSessions`
- `loadHistory`
- `deleteSession`
- provider/model profile sync for OpenAI API key, OpenAI OAuth/Codex, and Ollama
- runtime logs that include Codex command attempts
- Doctor output that includes Codex CLI version
Cron and 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 as unsupported.
Current implemented capability flags for cc-connect mode:
- `chat`: supported through `CodexCliBridge`
- `sessions`: supported through ClawX managed transcripts
- `history`: supported through ClawX managed transcripts
- `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
- `channels`, `cron`, `skills`, `controlUi`: not yet marked supported in the runtime capability matrix
## Acceptance
- Unit tests prove `cc-connect` runtime sends through a mock Codex binary and stores user/assistant messages.
- Unit tests prove sessions/history/delete operate from managed storage.
- Unit tests prove Doctor includes Codex CLI diagnostics.
- Unit tests prove OpenAI and Ollama provider accounts convert to Codex launch profiles without writing secrets to disk.
- 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 an Ollama provider account is converted into `codex exec --oss --local-provider ollama --model <model>`.
- 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,131 @@
# 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`.
- 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.
+208
View File
@@ -0,0 +1,208 @@
# 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 binaries into packaged app resources so runtime startup does not require global installs 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, and Ollama supported through Codex launch profile | Unsupported providers return stable errors and do not mutate OpenClaw config |
| Channels | Supported | Adapter-dependent | 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
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` is a `devDependency` because the packaged runtime executes the verified binary from `extraResources`, not from asar `node_modules`.
`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/
```
The binary is intentionally outside asar so it remains executable.
## Runtime Path Resolution
- Development: prefer `node_modules/cc-connect/bin/cc-connect[.exe]`.
- Packaged: use `process.resourcesPath/cc-connect/cc-connect[.exe]`.
- If the binary is missing, the provider reports a clear startup error instructing developers to install or bundle cc-connect.
## 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-owned Codex CLI bridge for GUI chat, sessions, history, 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, 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/channel/cron operations do not mutate OpenClaw config.
- 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, not OpenClaw-specific parity.
- ClawX manages cc-connect config and does not modify `~/.cc-connect`.
- Packaged ClawX must run cc-connect 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.
+6
View File
@@ -68,6 +68,8 @@ mac:
to: bin
- from: resources/cli/posix/
to: cli/
- from: build/cc-connect/darwin-${arch}/
to: cc-connect/
category: public.app-category.productivity
icon: resources/icons/icon.icns
target:
@@ -119,6 +121,8 @@ win:
to: bin
- from: resources/cli/win32/
to: cli/
- from: build/cc-connect/win32-${arch}/
to: cc-connect/
icon: resources/icons/icon.ico
target:
- target: nsis
@@ -147,6 +151,8 @@ linux:
to: bin
- from: resources/cli/posix/
to: cli/
- from: build/cc-connect/linux-${arch}/
to: cc-connect/
icon: resources/icons
target:
- target: AppImage
+42 -27
View File
@@ -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 };
+14 -11
View File
@@ -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,28 +131,29 @@ 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 }),
agents: createAgentsApi({ gatewayManager }),
providers: createProvidersApi({ gatewayManager, mainWindow }),
providers: createProvidersApi({ gatewayManager, runtimeManager, mainWindow }),
files: createFilesApi(),
media: createMediaApi(),
sessions: createSessionsApi(),
chat: createChatApi({ gatewayManager }),
sessions: createSessionsApi(runtimeManager),
chat: createChatApi({ gatewayManager, runtimeManager }),
cron: createCronApi({ gatewayManager }),
skills: createSkillsApi({ clawHubService, gatewayManager }),
usage: createUsageApi(),
@@ -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) {
+54
View File
@@ -0,0 +1,54 @@
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 (process.env.CLAWX_CC_CONNECT_PATH?.trim()) {
return process.env.CLAWX_CC_CONNECT_PATH.trim();
}
if (app.isPackaged) {
return join(process.resourcesPath, 'cc-connect', binaryName());
}
const nodeModulesBinary = join(process.cwd(), 'node_modules', 'cc-connect', 'bin', binaryName());
if (existsSync(nodeModulesBinary)) {
return nodeModulesBinary;
}
const bundledDevBinary = join(process.cwd(), 'build', 'cc-connect', `${process.platform}-${process.arch}`, binaryName());
if (existsSync(bundledDevBinary)) {
return bundledDevBinary;
}
return nodeModulesBinary;
}
export function assertCcConnectBinaryPath(candidate = getCcConnectBinaryPath()): string {
if (!existsSync(candidate)) {
throw new Error(
`cc-connect binary not found at ${candidate}. Run pnpm install or pnpm run bundle:cc-connect:current before selecting cc-connect runtime.`,
);
}
return candidate;
}
@@ -0,0 +1,234 @@
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[];
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 ?? {}),
};
}
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,
};
}
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);
}
+469
View File
@@ -0,0 +1,469 @@
import { EventEmitter } from 'node:events';
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import type { OpenClawDoctorMode, OpenClawDoctorResult } from '@shared/host-api/contract';
import type {
RuntimeProvider,
RuntimeSendWithMediaPayload,
RuntimeStatus,
} from './types';
import {
CC_CONNECT_RUNTIME_CAPABILITIES,
withRuntimeStatus,
} from './types';
import {
assertCcConnectBinaryPath,
getCcConnectCodexSessionsDir,
getCcConnectConfigPath,
getCcConnectManagedDir,
getCcConnectProviderProfilePath,
} from './cc-connect-paths';
import { CodexCliBridge } from './codex-cli-bridge';
import {
syncCcConnectProviderProfile,
toPublicCodexProviderProfile,
type CodexProviderProfile,
} from './cc-connect-provider-profile';
type CcConnectRuntimeProviderOptions = {
binaryPath?: string;
codexPath?: string;
workDir?: string;
codexBridge?: CodexCliBridge;
providerProfileLoader?: (payload?: { providerId?: string; reason?: string }) => Promise<CodexProviderProfile>;
};
const CC_CONNECT_DOCTOR_TIMEOUT_MS = 60_000;
const MAX_DOCTOR_OUTPUT_BYTES = 10 * 1024 * 1024;
function unsupported(method: string): never {
throw new Error(`cc-connect runtime does not support RPC method: ${method}`);
}
function appendBoundedOutput(current: string, currentBytes: number, data: Buffer | string) {
const chunk = typeof data === 'string' ? Buffer.from(data) : data;
if (currentBytes + chunk.length <= MAX_DOCTOR_OUTPUT_BYTES) {
return {
output: current + chunk.toString(),
bytes: currentBytes + chunk.length,
};
}
const remaining = Math.max(0, MAX_DOCTOR_OUTPUT_BYTES - currentBytes);
return {
output: current + (remaining > 0 ? chunk.subarray(0, remaining).toString() : ''),
bytes: MAX_DOCTOR_OUTPUT_BYTES,
};
}
function defaultConfig(): string {
const managedDir = getCcConnectManagedDir();
const dataDir = join(managedDir, 'data').replace(/\\/g, '\\\\');
const workDir = (process.env.CLAWX_CODEX_WORKDIR || process.cwd()).replace(/\\/g, '\\\\');
return [
'# Managed by ClawX. Do not edit while ClawX is running.',
'# ClawX stores this file under app userData and does not modify ~/.cc-connect.',
'# cc-connect v1.3.2 requires at least one [[projects]] entry with a real messaging platform.',
'# ClawX GUI chat uses CodexCliBridge directly until a local GUI platform is available.',
'# ClawX stores the active Codex provider/model profile in provider-profile.json.',
'',
`data_dir = "${dataDir}"`,
'',
'[log]',
'level = "info"',
'',
'# Enable when ClawX starts using cc-connect management endpoints for provider/cron/channel parity.',
'# [management]',
'# enabled = true',
'# port = 9820',
'# token = "replace-with-clawx-managed-token"',
'',
'# Enable when external bridge adapters are configured.',
'# [bridge]',
'# enabled = true',
'# port = 9810',
'# token = "replace-with-clawx-managed-token"',
'',
'# Codex project template. Uncomment and add a real [[projects.platforms]] section',
'# such as telegram, feishu, slack, dingtalk, discord, wecom, weixin, qq, qqbot, or line.',
'# [[projects]]',
'# name = "clawx-codex"',
'# [projects.agent]',
'# type = "codex"',
'# [projects.agent.options]',
`# work_dir = "${workDir}"`,
'# mode = "full-auto"',
'# [[projects.platforms]]',
'# type = "telegram"',
'# [projects.platforms.options]',
'# token = "${TELEGRAM_BOT_TOKEN}"',
'',
].join('\n');
}
export class CcConnectRuntimeProvider extends EventEmitter implements RuntimeProvider {
readonly kind = 'cc-connect' as const;
private child: ChildProcess | null = null;
private readonly codexBridge: CodexCliBridge;
private readonly providerProfileLoader: NonNullable<CcConnectRuntimeProviderOptions['providerProfileLoader']>;
private status = withRuntimeStatus({
state: 'stopped',
port: 0,
}, this.kind, CC_CONNECT_RUNTIME_CAPABILITIES, getCcConnectManagedDir());
private readonly binaryPath?: string;
constructor(options: CcConnectRuntimeProviderOptions = {}) {
super();
this.binaryPath = options.binaryPath;
this.codexBridge = options.codexBridge ?? new CodexCliBridge({
codexPath: options.codexPath,
sessionsDir: getCcConnectCodexSessionsDir(),
workDir: options.workDir,
});
this.providerProfileLoader = options.providerProfileLoader ?? syncCcConnectProviderProfile;
}
listCapabilities() {
return CC_CONNECT_RUNTIME_CAPABILITIES;
}
getStatus() {
return this.status;
}
async start(): Promise<void> {
if (this.status.state === 'running' || this.status.state === 'starting') return;
await this.ensureManagedConfig();
assertCcConnectBinaryPath(this.binaryPath);
this.setStatus({ state: 'starting', error: undefined });
const codexDiagnostic = await this.codexBridge.diagnose();
if (!codexDiagnostic.success) {
const error = codexDiagnostic.error || codexDiagnostic.stderr || 'Codex CLI is unavailable';
this.setStatus({ state: 'error', error });
throw new Error(error);
}
await this.syncProviderProfile({ reason: 'runtime-start' });
this.setStatus({
state: 'running',
pid: process.pid,
connectedAt: Date.now(),
gatewayReady: true,
error: undefined,
});
}
async stop(): Promise<void> {
const child = this.child;
this.child = null;
if (child) {
try {
child.kill();
} catch {
// ignore
}
}
this.setStatus({ state: 'stopped', pid: undefined, connectedAt: undefined, gatewayReady: undefined });
}
async restart(): Promise<void> {
await this.stop();
await this.start();
}
async checkHealth() {
return {
ok: this.status.state === 'running',
error: this.status.error,
uptime: this.status.connectedAt ? Date.now() - this.status.connectedAt : undefined,
};
}
async rpc<T = unknown>(method: string, params?: unknown): Promise<T> {
switch (method) {
case 'chat.send':
return await this.sendMessageWithMedia(toSendPayload(params)) as T;
case 'sessions.list':
return await this.listSessions(params) as T;
case 'chat.history':
return await this.loadHistory(params) as T;
case 'sessions.delete':
case 'session.delete':
case 'chat.session.delete':
return await this.deleteSession(params) as T;
case 'providers.sync':
case 'models.sync':
return await this.syncProviderProfile(toProviderSyncPayload(params)) as T;
case 'providers.profile':
case 'models.profile':
return await this.syncProviderProfile(toProviderSyncPayload(params)) as T;
default:
return unsupported(method);
}
}
async sendMessageWithMedia(payload: RuntimeSendWithMediaPayload) {
const result = await this.codexBridge.send(payload);
this.emit('chat:runtime-event', {
type: 'run.started',
runId: result.runId,
sessionKey: payload.sessionKey,
startedAt: Date.now(),
ts: Date.now(),
});
this.emit('chat:message', {
state: 'final',
runId: result.runId,
sessionKey: payload.sessionKey,
message: result.assistantMessage,
});
this.emit('chat:runtime-event', {
type: 'run.ended',
runId: result.runId,
sessionKey: payload.sessionKey,
status: result.assistantMessage.isError ? 'error' : 'completed',
endedAt: Date.now(),
ts: Date.now(),
...(result.assistantMessage.isError ? { error: result.assistantMessage.errorMessage } : {}),
});
return { runId: result.runId };
}
async listSessions(payload?: unknown) {
if (isRecord(payload) && Array.isArray(payload.sessionKeys)) {
return {
success: true,
summaries: await this.codexBridge.summarizeSessions(
payload.sessionKeys.filter((value): value is string => typeof value === 'string'),
),
};
}
const sessions = await this.codexBridge.listSessions();
return {
success: true,
sessions: sessions.map((session) => ({
key: session.key,
displayName: session.displayName,
updatedAt: session.updatedAt,
})),
};
}
async loadHistory(payload?: unknown) {
const body = isRecord(payload) ? payload : {};
const sessionKey = typeof body.sessionKey === 'string' && body.sessionKey.trim()
? body.sessionKey.trim()
: 'agent:main:main';
const limit = typeof body.limit === 'number' && Number.isFinite(body.limit)
? Math.max(1, Math.min(Math.floor(body.limit), 1000))
: 200;
return {
success: true,
messages: await this.codexBridge.loadHistory(sessionKey, limit),
};
}
async deleteSession(payload?: unknown) {
const sessionKey = getSessionKey(payload);
await this.codexBridge.deleteSession(sessionKey);
return { success: true };
}
async listLogs() {
const configPath = getCcConnectConfigPath();
const content = existsSync(configPath)
? await readFile(configPath, 'utf8').catch(() => '')
: '';
return {
content: [
`[cc-connect] config=${configPath}`,
`[cc-connect] providerProfile=${getCcConnectProviderProfilePath()}`,
`[codex] sessions=${this.codexBridge.getSessionsDir()}`,
'',
content,
].join('\n'),
};
}
async runDoctor(mode: OpenClawDoctorMode): Promise<OpenClawDoctorResult> {
const startedAt = Date.now();
const cwd = getCcConnectManagedDir();
const configPath = await this.ensureManagedConfig();
const binaryPath = assertCcConnectBinaryPath(this.binaryPath);
const args = ['doctor', 'user-isolation', '--config', configPath];
const command = `cc-connect ${args.join(' ')}`;
const codexDiagnostic = await this.codexBridge.diagnose();
const codexStdout = [
'Codex CLI:',
codexDiagnostic.success ? 'ok' : 'failed',
codexDiagnostic.stdout.trim(),
codexDiagnostic.error ? `error: ${codexDiagnostic.error}` : '',
].filter(Boolean).join('\n');
if (mode === 'fix') {
return {
mode,
success: false,
exitCode: null,
stdout: codexStdout,
stderr: codexDiagnostic.stderr,
command,
cwd,
durationMs: Date.now() - startedAt,
error: 'cc-connect doctor does not support fix mode in v1.3.2',
};
}
return await new Promise<OpenClawDoctorResult>((resolve) => {
const child = spawn(binaryPath, args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
const finish = (result: Omit<OpenClawDoctorResult, 'durationMs'>) => {
if (settled) return;
settled = true;
resolve({
...result,
durationMs: Date.now() - startedAt,
});
};
const timeout = setTimeout(() => {
try {
child.kill();
} catch {
// ignore
}
finish({
mode,
success: false,
exitCode: null,
stdout,
stderr,
command,
cwd,
timedOut: true,
error: `Timed out after ${CC_CONNECT_DOCTOR_TIMEOUT_MS}ms`,
});
}, CC_CONNECT_DOCTOR_TIMEOUT_MS);
child.stdout?.on('data', (data) => {
const next = appendBoundedOutput(stdout, stdoutBytes, data);
stdout = next.output;
stdoutBytes = next.bytes;
});
child.stderr?.on('data', (data) => {
const next = appendBoundedOutput(stderr, stderrBytes, data);
stderr = next.output;
stderrBytes = next.bytes;
});
child.on('error', (error) => {
clearTimeout(timeout);
finish({
mode,
success: false,
exitCode: null,
stdout,
stderr,
command,
cwd,
error: error instanceof Error ? error.message : String(error),
});
});
child.on('exit', (code) => {
clearTimeout(timeout);
finish({
mode,
success: code === 0,
exitCode: code,
stdout: [stdout, codexStdout].filter(Boolean).join('\n'),
stderr: [stderr, codexDiagnostic.stderr].filter(Boolean).join('\n'),
command,
cwd,
});
});
});
}
private async ensureManagedConfig(): Promise<string> {
const configPath = getCcConnectConfigPath();
await mkdir(dirname(configPath), { recursive: true });
if (!existsSync(configPath)) {
await writeFile(configPath, defaultConfig(), 'utf8');
}
return configPath;
}
private async syncProviderProfile(payload?: { providerId?: string; reason?: string }) {
const profile = await this.providerProfileLoader(payload);
this.codexBridge.setProviderProfile(profile);
return {
success: true,
profile: toPublicCodexProviderProfile(profile),
};
}
private setStatus(patch: Partial<RuntimeStatus>): void {
this.status = {
...this.status,
...patch,
runtimeKind: this.kind,
capabilities: this.listCapabilities(),
configDir: getCcConnectManagedDir(),
};
this.emit('status', this.status);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function getSessionKey(payload: unknown): string {
if (typeof payload === 'string' && payload.trim()) return payload.trim();
if (isRecord(payload)) {
const value = payload.sessionKey ?? payload.id;
if (typeof value === 'string' && value.trim()) return value.trim();
}
return 'agent:main:main';
}
function toSendPayload(payload: unknown): RuntimeSendWithMediaPayload {
const body = isRecord(payload) ? payload : {};
const message = typeof body.message === 'string'
? body.message
: typeof body.content === 'string'
? body.content
: '';
const idempotencyKey = typeof body.idempotencyKey === 'string' && body.idempotencyKey.trim()
? body.idempotencyKey.trim()
: `cc-connect-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const media = Array.isArray(body.media)
? body.media
: Array.isArray(body.attachments)
? body.attachments
: undefined;
return {
sessionKey: getSessionKey(body),
message,
deliver: typeof body.deliver === 'boolean' ? body.deliver : false,
idempotencyKey,
...(media ? { media: media as RuntimeSendWithMediaPayload['media'] } : {}),
};
}
function toProviderSyncPayload(payload: unknown): { providerId?: string; reason?: string } | undefined {
if (!isRecord(payload)) return undefined;
return {
providerId: typeof payload.providerId === 'string' ? payload.providerId : undefined,
reason: typeof payload.reason === 'string' ? payload.reason : undefined,
};
}
+395
View File
@@ -0,0 +1,395 @@
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';
type CodexBridgeOptions = {
codexPath?: string;
sessionsDir: string;
workDir?: string;
mode?: 'suggest' | 'auto-edit' | 'full-auto' | 'yolo';
};
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 providerProfile: CodexProviderProfile | null = null;
constructor(options: CodexBridgeOptions) {
this.codexPath = options.codexPath || process.env.CLAWX_CODEX_PATH || 'codex';
this.sessionsDir = options.sessionsDir;
this.workDir = options.workDir || process.env.CLAWX_CODEX_WORKDIR || process.cwd();
this.mode = options.mode || 'full-auto';
}
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 });
return new Promise((resolve) => {
const child = spawn(this.codexPath, args, {
cwd: this.workDir,
env: {
...process.env,
...(options.env ?? {}),
},
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}` }),
});
});
});
}
}
+111
View File
@@ -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);
});
}
}
}
+97
View File
@@ -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();
}
}
+125
View File
@@ -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: false,
cron: false,
logs: true,
skills: false,
doctor: true,
controlUi: false,
};
export function withRuntimeStatus(
status: GatewayStatus,
runtimeKind: RuntimeKind,
capabilities: RuntimeCapabilities,
configDir?: string,
): RuntimeStatus {
return {
...status,
runtimeKind,
capabilities,
...(configDir ? { configDir } : {}),
};
}
+7 -2
View File
@@ -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();
},
};
}
+36 -8
View File
@@ -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) };
}
},
+16 -8
View File
@@ -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,30 +31,37 @@ 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();
if (!status.capabilities?.controlUi || status.runtimeKind !== 'openclaw' || !gatewayManager) {
return {
success: false,
error: `${status.runtimeKind ?? 'runtime'} runtime does not support OpenClaw Control UI`,
};
}
const body = isRecord(payload) ? payload as ControlUiPayload : {};
const status = gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const view = body.view === 'dreams' ? 'dreams' : undefined;
@@ -72,7 +80,7 @@ export function createGatewayApi(
method,
body.params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
(rpcMethod, rpcParams, rpcTimeoutMs) => runtimeManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
},
};
+143 -38
View File
@@ -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,
+17 -2
View File
@@ -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);
+12 -3
View File
@@ -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
View File
@@ -189,6 +189,7 @@ export type ProviderSecret =
accountId: string;
accessToken: string;
refreshToken: string;
idToken?: string;
expiresAt: number;
scopes?: string[];
email?: string;
+1
View File
@@ -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,
+5 -1
View File
@@ -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),
+3
View File
@@ -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.
+12 -6
View File
@@ -36,10 +36,15 @@
"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 && 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",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -66,11 +71,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",
"package:mac": "pnpm run package && pnpm run bundle:cc-connect: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 && 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 && 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 && 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",
@@ -136,6 +141,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",
+10 -1
View File
@@ -162,6 +162,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
@@ -3174,6 +3177,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==}
@@ -4495,7 +4502,7 @@ packages:
resolution: {integrity: sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==}
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7}
resolution: {gitHosted: true, integrity: sha512-KmRMuAYSfd4upRD0xOgKbURxRXNE8X8NtgmkUOJFXOn9ESSCXguIZ7PJu8OBJIr7KGww41Vf/Pbgko+PgWQ55g==, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7}
version: 6.0.0
lie@3.3.0:
@@ -9870,6 +9877,8 @@ snapshots:
caniuse-lite@1.0.30001781: {}
cc-connect@1.3.2: {}
ccount@2.0.1: {}
cfb@1.2.2:
+120
View File
@@ -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);
}
+68
View File
@@ -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}`,
];
}
+2 -1
View File
@@ -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;
+28 -2
View File
@@ -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",
+28 -2
View File
@@ -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 が完了しました",
+28 -2
View File
@@ -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 завершён",
+28 -2
View File
@@ -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 已完成",
+20
View File
@@ -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;
}
/**
+112 -2
View File
@@ -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">
+8
View File
@@ -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(() => { });
+304
View File
@@ -0,0 +1,304 @@
import { chmod, mkdir, readFile, 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, 'codex-mock.cjs');
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, 'cc-connect-mock.cjs');
await writeExecutable(binaryPath, `#!/usr/bin/env node
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);
}
process.stdout.write('cc-connect e2e mock\\n');
process.exit(0);
`);
return binaryPath;
}
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,
}) => {
const binDir = join(userDataDir, 'mock-bin');
await mkdir(binDir, { recursive: true });
const codexPath = await createMockCodexBinary(binDir);
const ccConnectPath = await createMockCcConnectBinary(binDir);
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 codexArgsPath = join(userDataDir, 'codex-args.json');
const app = await launchElectronApp({
skipSetup: true,
env: {
CLAWX_CODEX_PATH: codexPath,
CLAWX_CODEX_WORKDIR: process.cwd(),
CLAWX_CC_CONNECT_PATH: ccConnectPath,
CLAWX_E2E_CODEX_ARGS_PATH: codexArgsPath,
},
});
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('Codex project template');
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: 'Codex E2E response from mock binary' }),
]),
},
});
await expect(page.getByText('Codex E2E response from mock binary')).toBeVisible({ timeout: 30_000 });
await expect.poll(async () => JSON.parse(await readFile(codexArgsPath, 'utf8'))).toEqual(
expect.arrayContaining(['--oss', '--local-provider', 'ollama', '--model', 'qwen3:latest']),
);
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: 'Codex E2E response from mock binary' }),
]),
},
});
} finally {
await closeElectronApp(app);
}
});
test('starts cc-connect runtime with OpenAI OAuth Codex auth in a managed CODEX_HOME', async ({
launchElectronApp,
userDataDir,
}) => {
const binDir = join(userDataDir, 'mock-bin');
await mkdir(binDir, { recursive: true });
const codexPath = await createMockCodexBinary(binDir);
const ccConnectPath = await createMockCcConnectBinary(binDir);
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 codexArgsPath = join(userDataDir, 'codex-oauth-args.json');
const codexEnvPath = join(userDataDir, 'codex-oauth-env.json');
const app = await launchElectronApp({
skipSetup: true,
env: {
CLAWX_CODEX_PATH: codexPath,
CLAWX_CODEX_WORKDIR: process.cwd(),
CLAWX_CC_CONNECT_PATH: ccConnectPath,
CLAWX_E2E_CODEX_ARGS_PATH: codexArgsPath,
CLAWX_E2E_CODEX_ENV_PATH: codexEnvPath,
},
});
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('Codex E2E response from mock binary')).toBeVisible({ timeout: 30_000 });
const managedCodexHome = join(userDataDir, 'runtimes', 'cc-connect', 'codex-home');
await expect.poll(async () => JSON.parse(await readFile(codexArgsPath, 'utf8'))).toEqual(
expect.arrayContaining(['--model', 'gpt-5.5']),
);
await expect.poll(async () => JSON.parse(await readFile(codexEnvPath, 'utf8'))).toEqual({
CODEX_HOME: managedCodexHome,
});
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);
}
});
});
+2
View File
@@ -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,44 @@
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: false,
cron: false,
logs: true,
skills: false,
doctor: true,
controlUi: false,
},
},
});
});
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();
});
});
+5
View File
@@ -0,0 +1,5 @@
export {
buildCcConnectAssetName,
normalizeCcConnectTarget,
parseCcConnectBundleArgs,
} from '../../scripts/cc-connect-bundle-lib.mjs';
+24
View File
@@ -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' });
});
});
+48
View File
@@ -0,0 +1,48 @@
// @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);
});
});
@@ -0,0 +1,245 @@
// @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('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,293 @@
// @vitest-environment node
import { 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>();
vi.mock('node:child_process', () => ({
spawn: forkMock,
}));
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;
}),
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);
});
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 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 and starts the Codex-backed runtime', async () => {
const binaryPath = join(tempDir, 'cc-connect');
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
const bridge = createBridgeMock();
const providerProfileLoader = vi.fn(async () => createProviderProfile());
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
const provider = new CcConnectRuntimeProvider({
binaryPath,
codexBridge: bridge as never,
providerProfileLoader: providerProfileLoader as never,
});
await provider.start();
const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml');
await expect(readFile(configPath, 'utf8')).resolves.toContain('# Managed by ClawX');
expect(forkMock).not.toHaveBeenCalled();
expect(bridge.diagnose).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: process.pid,
runtimeKind: 'cc-connect',
capabilities: expect.objectContaining({ chat: true, doctor: true, providers: true, models: true, skills: false }),
});
});
it('reports unsupported RPC methods with stable errors', 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.rpc('skills.status')).rejects.toThrow('cc-connect runtime does not support RPC method: skills.status');
});
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 = createBridgeMock();
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
const provider = new CcConnectRuntimeProvider({ binaryPath, codexBridge: 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: 'codex-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');
expect(chatEvents).toHaveLength(1);
});
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 = createBridgeMock();
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
const provider = new CcConnectRuntimeProvider({ binaryPath, codexBridge: bridge as never });
await expect(provider.rpc('chat.send', {
sessionKey: 'agent:main:main',
message: 'hello via rpc',
idempotencyKey: 'idem-rpc',
})).resolves.toEqual({ runId: 'codex-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('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',
});
});
});
+249
View File
@@ -0,0 +1,249 @@
// @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('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));
});
});
+35
View File
@@ -467,6 +467,41 @@ 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('sets the default provider account and syncs runtime defaults', async () => {
providerServiceMock.getDefaultAccountId.mockResolvedValue('old-default');
const gatewayManager = { debouncedReload: vi.fn() };
+112
View File
@@ -0,0 +1,112 @@
// @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: kind === 'openclaw',
})),
};
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(false);
});
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' }),
]);
});
});