refactor: Unify Electron IPC Runtime, Shared Contracts, and App UX/DX Fixes (#1102)

Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
This commit is contained in:
ZHUO Xu
2026-06-06 13:05:25 +08:00
committed by GitHub
co-authored by paisley
parent 48d80bf660
commit 581981f203
285 changed files with 12201 additions and 14395 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.
- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `src/i18n/locales/<lang>/<ns>.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table.
- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `shared/i18n/locales/<lang>/<ns>.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table.
- **Renderer/Main API boundary (important)**:
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
+10 -12
View File
@@ -240,18 +240,17 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬─────────────────────────────────────┘
Main管理のトランスポート戦略
│(WS優先、HTTP次点、IPCフォールバック)
型付き IPC リクエスト
┌─────────────────────────────────────────────────────────────────┐
Host API と Main プロキシ層
Main Host Services と Gateway Manager
│ │
│ • hostapi:fetchMainプロキシ、CORS回避)
│ • gateway:httpProxyRendererはGateway HTTPに直アクセスしない)
│ • 統一エラーマッピングとリトライ/バックオフ
│ • host:invoke 型付きサービスディスパッチ
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス
│ • Main が Gateway WebSocket とプロセス監視を所有
└──────────────────────────────┬──────────────────────────────────┘
WS / HTTP / IPC フォールバック
Main 所有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw ゲートウェイ │
@@ -266,10 +265,11 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
- **Mainによるトランスポート制御**: Gateway WebSocket は Electron Main のみが所有し、Renderer は型付き IPC で Main と通信します
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
- **CORSセーフ設計**: Renderer はローカル Gateway や Host API HTTP エンドポイントを直接呼び出しません
### プロセスモデルと Gateway トラブルシューティング
@@ -317,9 +317,7 @@ AI を開発ワークフローに統合できます。エージェントを使
```ClawX/
├── electron/ # Electron メインプロセス
│ ├── api/ # メイン側 API ルーターとハンドラー
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
│ ├── services/ # Provider/Secrets/ランタイムサービス
│ ├── services/ # 型付き Host API、Provider/Secrets/ランタイムサービス
│ │ ├── providers/ # provider/account モデル同期ロジック
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
│ ├── shared/ # 共通 Provider スキーマ/定数
+10 -12
View File
@@ -244,18 +244,17 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
Main-owned transport strategy
│ (WS first, HTTP then IPC fallback)
Typed IPC requests
┌──────────────────────────────────────────────────────────────────┐
Host API & Main Process Proxies
Main Host Services & Gateway Manager
│ │
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod)
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct)
│ • Unified error mapping & retry/backoff
│ • host:invoke typed service dispatcher
│ • Settings, files, sessions, skills, providers, diagnostics
│ • Main-owned Gateway WebSocket and process supervision
└──────────────────────────────┬───────────────────────────────────┘
WS / HTTP / IPC fallback
Main-owned WebSocket
┌──────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
@@ -270,10 +269,11 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability
- **Main-Process Transport Ownership**: Electron Main owns the Gateway WebSocket; the renderer talks to Main over typed IPC
- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly
### Process Model & Gateway Troubleshooting
@@ -321,9 +321,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
```ClawX/
├── electron/ # Electron Main Process
│ ├── api/ # Main-side API router and handlers
│ │ └── routes/ # RPC/HTTP proxy route modules
│ ├── services/ # Provider, secrets and runtime services
│ ├── services/ # Typed host APIs, provider, secrets and runtime services
│ │ ├── providers/ # Provider/account model sync logic
│ │ └── secrets/ # OS keychain and secret storage
│ ├── shared/ # Shared provider schemas/constants
+10 -12
View File
@@ -244,18 +244,17 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
主进程统一传输策略
│(WS 优先,HTTP 次之,IPC 回退)
类型化 IPC 请求
┌─────────────────────────────────────────────────────────────────┐
│ Host API 与主进程代理层
主进程 Host Services 与 Gateway Manager
│ │
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS)
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP
│ • 统一错误映射与重试/退避策略
│ • host:invoke 类型化服务分发
│ • 设置、文件、会话、技能、供应商、诊断服务
│ • 主进程持有 Gateway WebSocket 并负责进程监控
└──────────────────────────────┬──────────────────────────────────┘
WS / HTTP / IPC 回退
主进程持有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关 │
@@ -270,10 +269,11 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
- **主进程掌控传输策略**Gateway WebSocket 只由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
- **CORS 安全**本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
- **CORS 安全**渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点
### 进程模型与 Gateway 排障
@@ -321,9 +321,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
```ClawX/
├── electron/ # Electron 主进程
│ ├── api/ # 主进程 API 路由与处理器
│ │ └── routes/ # RPC/HTTP 代理路由模块
│ ├── services/ # Provider、Secrets 与运行时服务
│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务
│ │ ├── providers/ # Provider/account 模型同步逻辑
│ │ └── secrets/ # 系统钥匙串与密钥存储
│ ├── shared/ # 共享 Provider schema/常量
-11
View File
@@ -1,11 +0,0 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { ClawHubService } from '../gateway/clawhub';
import type { HostEventBus } from './event-bus';
export interface HostApiContext {
gatewayManager: GatewayManager;
clawHubService: ClawHubService;
eventBus: HostEventBus;
mainWindow: BrowserWindow | null;
}
-36
View File
@@ -1,36 +0,0 @@
import type { ServerResponse } from 'http';
type EventPayload = unknown;
export class HostEventBus {
private readonly clients = new Set<ServerResponse>();
addSseClient(res: ServerResponse): void {
this.clients.add(res);
res.on('close', () => {
this.clients.delete(res);
});
}
emit(eventName: string, payload: EventPayload): void {
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.clients) {
try {
client.write(message);
} catch {
this.clients.delete(client);
}
}
}
closeAll(): void {
for (const client of this.clients) {
try {
client.end();
} catch {
// Ignore individual client close failures.
}
}
this.clients.clear();
}
}
-73
View File
@@ -1,73 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../utils/config';
/**
* Allowed CORS origins — only the Electron renderer (Vite dev or production)
* and the OpenClaw Gateway are permitted to make cross-origin requests.
*/
const ALLOWED_ORIGINS = new Set([
`http://127.0.0.1:${PORTS.CLAWX_DEV}`,
`http://localhost:${PORTS.CLAWX_DEV}`,
`http://127.0.0.1:${PORTS.OPENCLAW_GATEWAY}`,
`http://localhost:${PORTS.OPENCLAW_GATEWAY}`,
]);
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) {
return {} as T;
}
return JSON.parse(raw) as T;
}
/**
* Validate that mutation requests (POST/PUT/DELETE) carry a JSON Content-Type.
* This prevents "simple request" CSRF where the browser skips the preflight
* when Content-Type is text/plain or application/x-www-form-urlencoded.
*/
export function requireJsonContentType(req: IncomingMessage): boolean {
if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') {
return true;
}
// Requests without a body (content-length 0 or absent) are safe — CSRF
// "simple request" attacks rely on sending a crafted body.
const contentLength = req.headers['content-length'];
if (contentLength === '0' || contentLength === undefined) {
return true;
}
const ct = req.headers['content-type'] || '';
return ct.includes('application/json');
}
export function setCorsHeaders(res: ServerResponse, origin?: string): void {
// Only reflect the Origin header back if it is in the allow-list.
// Omitting the header for unknown origins causes the browser to block
// the response — this is the intended behavior for untrusted callers.
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
export function sendNoContent(res: ServerResponse): void {
res.statusCode = 204;
res.end();
}
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(text);
}
-252
View File
@@ -1,252 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
assignChannelToAgent,
clearChannelBinding,
createAgent,
deleteAgentConfig,
listAgentsSnapshot,
removeAgentWorkspaceDirectory,
resolveAccountIdForAgent,
updateAgentModel,
updateAgentName,
} from '../../utils/agent-config';
import { deleteChannelAccountConfig } from '../../utils/channel-config';
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { ensureClawXContext } from '../../utils/openclaw-workspace';
function scheduleGatewayReload(ctx: HostApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
return;
}
void reason;
}
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Force a full Gateway process restart after agent deletion.
*
* A SIGUSR1 in-process reload is NOT sufficient here: channel plugins
* (e.g. Feishu) maintain long-lived WebSocket connections to external
* services and do not disconnect accounts that were removed from the
* config during an in-process reload. The only reliable way to drop
* stale bot connections is to kill the Gateway process entirely and
* spawn a fresh one that reads the updated openclaw.json from scratch.
*/
export async function restartGatewayForAgentDeletion(ctx: HostApiContext): Promise<void> {
try {
// Capture the PID of the running Gateway BEFORE stop() clears it.
const status = ctx.gatewayManager.getStatus();
const pid = status.pid;
const port = status.port;
console.log('[agents] Triggering Gateway restart (kill+respawn) after agent deletion', { pid, port });
// Force-kill the Gateway process by PID. The manager's stop() only
// kills "owned" processes; if the manager connected to an already-
// running Gateway (ownsProcess=false), stop() simply closes the WS
// and the old process stays alive with its stale channel connections.
if (pid) {
try {
if (process.platform === 'win32') {
await execAsync(`taskkill /F /PID ${pid} /T`);
} else {
process.kill(pid, 'SIGTERM');
// Give it a moment to die
await new Promise((resolve) => setTimeout(resolve, 500));
try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
}
} catch {
// process already gone that's fine
}
} else if (port) {
// If we don't know the PID (e.g. connected to an orphaned Gateway from
// a previous pnpm dev run), forcefully kill whatever is on the port.
try {
if (process.platform === 'darwin' || process.platform === 'linux') {
// MUST use -sTCP:LISTEN. Otherwise lsof returns the client process (ClawX itself)
// that has an ESTABLISHED WebSocket connection to the port, causing us to kill ourselves.
const { stdout } = await execAsync(`lsof -t -i :${port} -sTCP:LISTEN`);
const pids = stdout.trim().split('\n').filter(Boolean);
for (const p of pids) {
try { process.kill(parseInt(p, 10), 'SIGTERM'); } catch { /* ignore */ }
}
await new Promise((resolve) => setTimeout(resolve, 500));
for (const p of pids) {
try { process.kill(parseInt(p, 10), 'SIGKILL'); } catch { /* ignore */ }
}
} else if (process.platform === 'win32') {
// Find PID listening on the port
const { stdout } = await execAsync(`netstat -ano | findstr :${port}`);
const lines = stdout.trim().split('\n');
const pids = new Set<string>();
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5 && parts[1].endsWith(`:${port}`) && parts[3] === 'LISTENING') {
pids.add(parts[4]);
}
}
for (const p of pids) {
try { await execAsync(`taskkill /F /PID ${p} /T`); } catch { /* ignore */ }
}
}
} catch {
// Port might not be bound or command failed; ignore
}
}
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Gateway restart after agent deletion failed:', err);
}
}
export async function handleAgentRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/agents' && req.method === 'GET') {
sendJson(res, 200, { success: true, ...(await listAgentsSnapshot()) });
return true;
}
if (url.pathname === '/api/agents' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ name: string; inheritWorkspace?: boolean }>(req);
const snapshot = await createAgent(body.name, { inheritWorkspace: body.inheritWorkspace });
// Sync provider API keys to the new agent's auth-profiles.json so the
// embedded runner can authenticate with LLM providers when messages
// arrive via channel bots (e.g. Feishu). Without this, the copied
// auth-profiles.json may contain a stale key → 401 from the LLM.
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
scheduleGatewayReload(ctx, 'create-agent');
// Ensure newly provisioned workspaces get ClawX context merge/cleanup
// even when gateway status events do not fire (e.g. in-process reload).
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/agents/') && req.method === 'PUT') {
const suffix = url.pathname.slice('/api/agents/'.length);
const parts = suffix.split('/').filter(Boolean);
if (parts.length === 1) {
try {
const body = await parseJsonBody<{ name: string }>(req);
const agentId = decodeURIComponent(parts[0]);
const snapshot = await updateAgentName(agentId, body.name);
scheduleGatewayReload(ctx, 'update-agent');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 2 && parts[1] === 'model') {
try {
const body = await parseJsonBody<{ modelRef?: string | null }>(req);
const agentId = decodeURIComponent(parts[0]);
const snapshot = await updateAgentModel(agentId, body.modelRef ?? null);
try {
await syncAllProviderAuthToRuntime();
// Ensure this agent's runtime model registry reflects the new model override.
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
}
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);
const channelType = decodeURIComponent(parts[2]);
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
}
if (url.pathname.startsWith('/api/agents/') && req.method === 'DELETE') {
const suffix = url.pathname.slice('/api/agents/'.length);
const parts = suffix.split('/').filter(Boolean);
if (parts.length === 1) {
try {
const agentId = decodeURIComponent(parts[0]);
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
// Await reload synchronously BEFORE responding to the client.
// This ensures the Feishu plugin has disconnected the deleted bot
// before the UI shows "delete success" and the user tries chatting.
await restartGatewayForAgentDeletion(ctx);
// Delete workspace after reload so the new config is already live.
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);
const channelType = decodeURIComponent(parts[2]);
const ownerId = agentId.trim().toLowerCase();
const snapshotBefore = await listAgentsSnapshot();
const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== ownerId) return false;
return channelAccountKey.startsWith(`${channelType}:`);
})
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
// Backward compatibility for legacy agentId->accountId mapping.
if (ownedAccountIds.length === 0) {
const legacyAccountId = resolveAccountIdForAgent(agentId);
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
ownedAccountIds.push(legacyAccountId);
}
}
for (const accountId of ownedAccountIds) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
}
return false;
}
-37
View File
@@ -1,37 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
export async function handleAppRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/events' && req.method === 'GET') {
// CORS headers are already set by the server middleware.
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
ctx.eventBus.addSseClient(res);
// Send a current-state snapshot immediately so renderer subscribers do not
// miss lifecycle transitions that happened before the SSE connection opened.
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
return true;
}
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
return true;
}
// OPTIONS is handled by the server middleware; no route-level handler needed.
return false;
}
-695
View File
@@ -1,695 +0,0 @@
import { readFile } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { getOpenClawConfigDir } from '../../utils/paths';
import { resolveAccountIdFromSessionHistory } from '../../utils/session-util';
import { toOpenClawChannelType, toUiChannelType } from '../../utils/channel-alias';
import { resolveAgentIdFromChannel } from '../../utils/agent-config';
/**
* Find agentId from session history by delivery "to" address.
* Efficiently searches only agent session directories for matching deliveryContext.to.
*/
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
error?: string;
summary?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
runAtMs?: number;
durationMs?: number;
model?: string;
provider?: string;
}
interface CronSessionKeyParts {
agentId: string;
jobId: string;
runSessionId?: string;
}
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 4 || parts[2] !== 'cron') return null;
const agentId = parts[1] || 'main';
const jobId = parts[3];
if (!jobId) return null;
if (parts.length === 4) {
return { agentId, jobId };
}
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
return { agentId, jobId, runSessionId: parts[5] };
}
return null;
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function formatDuration(durationMs: number | undefined): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
if (!content) {
content = status === 'error'
? 'Scheduled task failed.'
: 'Scheduled task completed.';
}
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
content = `Run failed: ${content}`;
}
const meta: string[] = [];
const duration = formatDuration(entry.durationMs);
if (duration) meta.push(`Duration: ${duration}`);
if (entry.provider && entry.model) {
meta.push(`Model: ${entry.provider}/${entry.model}`);
} else if (entry.model) {
meta.push(`Model: ${entry.model}`);
}
if (meta.length > 0) {
content = `${content}\n\n${meta.join(' | ')}`;
}
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
const entries: CronRunLogEntry[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CronRunLogEntry;
if (!entry || entry.jobId !== jobId) continue;
if (entry.action && entry.action !== 'finished') continue;
entries.push(entry);
} catch {
// Ignore malformed log lines so one bad entry does not hide the rest.
}
}
return entries;
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
): Promise<Record<string, unknown> | undefined> {
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await readFile(storePath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
try {
const store = JSON.parse(raw) as Record<string, unknown>;
const directEntry = store[sessionKey];
if (directEntry && typeof directEntry === 'object') {
return directEntry as Record<string, unknown>;
}
const sessions = (store as { sessions?: unknown }).sessions;
if (Array.isArray(sessions)) {
const arrayEntry = sessions.find((entry) => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return record.key === sessionKey || record.sessionKey === sessionKey;
});
if (arrayEntry && typeof arrayEntry === 'object') {
return arrayEntry as Record<string, unknown>;
}
}
} catch {
return undefined;
}
return undefined;
}
export function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
const parsed = parseCronSessionKey(params.sessionKey);
if (!parsed) return [];
const matchingRuns = params.runs
.filter((entry) => {
if (!parsed.runSessionId) return true;
return entry.sessionId === parsed.runSessionId
|| entry.sessionKey === `${params.sessionKey}`;
})
.sort((a, b) => {
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
return left - right;
});
const messages: CronSessionFallbackMessage[] = [];
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
const taskName = params.job?.name?.trim()
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|| '';
const firstRelevantTimestamp = matchingRuns.length > 0
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
if (matchingRuns.length === 0) {
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: messages.length;
return messages.slice(-limit);
}
type JsonRecord = Record<string, unknown>;
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
// Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS).
// No per-channel backend blocks are needed.
return null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: GatewayCronDelivery['mode'] = 'none',
): GatewayCronDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return { mode: fallbackMode };
}
const delivery = rawDelivery as JsonRecord;
const mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim()
? delivery.to.trim()
: undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) {
return { mode: 'none' };
}
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return {};
}
const delivery = rawDelivery as JsonRecord;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) {
patch.to = typeof delivery.to === 'string' ? delivery.to : '';
}
if ('accountId' in delivery) {
patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
}
return patch;
}
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if ('delivery' in patch) {
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
}
if ('agentId' in patch) {
const agentId = typeof patch.agentId === 'string' && patch.agentId.trim()
? patch.agentId.trim()
: 'main';
patch.agentId = agentId;
// Keep sessionTarget as isolated when agentId changes
}
return patch;
}
function transformCronJob(job: GatewayCronJob) {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType
? { ...gatewayDelivery, channel: channelType }
: gatewayDelivery;
const target = channelType
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs
? new Date(job.state.nextRunAtMs).toISOString()
: undefined;
// Parse agentId from the job's agentId field
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
return {
id: job.id,
name: job.name,
message,
schedule: job.schedule,
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
agentId,
};
}
export async function handleCronRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/cron/session-history' && req.method === 'GET') {
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
const parsedSession = parseCronSessionKey(sessionKey);
if (!parsedSession) {
sendJson(res, 400, { success: false, error: `Invalid cron sessionKey: ${sessionKey}` });
return true;
}
const rawLimit = Number(url.searchParams.get('limit') || '200');
const limit = Number.isFinite(rawLimit)
? Math.min(Math.max(Math.floor(rawLimit), 1), 200)
: 200;
try {
const [jobsResult, runs, sessionEntry] = await Promise.all([
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
const messages = buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
} : undefined,
limit,
});
sendJson(res, 200, { messages });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
try {
let jobs: GatewayCronJob[] = [];
let usedFallback = false;
try {
// 8s timeout — fail fast when Gateway is busy with AI tasks.
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000);
const data = result as { jobs?: GatewayCronJob[] };
jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []);
// DEBUG: log name and agentId for each job
console.debug('Fetched cron jobs from Gateway:');
for (const job of jobs) {
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
const deliveryInfo = job.delivery ? `delivery={mode:${job.delivery.mode}, channel:${job.delivery.channel || '(none)'}, accountId:${job.delivery.accountId || '(none)'}, to:${job.delivery.to || '(none)'}}` : 'delivery=(none)';
console.debug(` - name: "${job.name}", agentId: "${jobAgentId || '(undefined)'}", ${deliveryInfo}, sessionTarget: "${job.sessionTarget || '(none)'}", payload.kind: "${job.payload?.kind || '(none)'}"`);
}
} catch {
// Fallback: read cron.json directly when Gateway RPC fails/times out.
try {
const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json');
const raw = await readFile(cronJsonPath, 'utf-8');
const parsed = JSON.parse(raw);
const fileJobs = Array.isArray(parsed) ? parsed : (parsed?.jobs ?? []);
jobs = fileJobs as GatewayCronJob[];
usedFallback = true;
} catch {
// No fallback data available either
}
}
// Run repair in background — don't block the response.
if (!usedFallback && jobs.length > 0) {
// Repair 1: delivery channel missing
const jobsToRepairDelivery = jobs.filter((job) => {
const isIsolatedAgent =
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
job.payload?.kind === 'agentTurn';
return (
isIsolatedAgent &&
job.delivery?.mode === 'announce' &&
!job.delivery?.channel
);
});
if (jobsToRepairDelivery.length > 0) {
// Fire-and-forget: repair in background
void (async () => {
for (const job of jobsToRepairDelivery) {
try {
await ctx.gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
} catch {
// ignore per-job repair failure
}
}
})();
// Optimistically fix the response data
for (const job of jobsToRepairDelivery) {
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
}
}
// Repair 2: agentId is undefined for jobs with announce delivery
// Only repair undefined -> inferred agent, NOT main -> inferred agent
const jobsToRepairAgent = jobs.filter((job) => {
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
return (
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
job.payload?.kind === 'agentTurn' &&
job.delivery?.mode === 'announce' &&
job.delivery?.channel &&
jobAgentId === undefined // Only repair when agentId is completely undefined
);
});
if (jobsToRepairAgent.length > 0) {
console.debug(`Found ${jobsToRepairAgent.length} jobs needing agent repair:`);
for (const job of jobsToRepairAgent) {
console.debug(` - Job "${job.name}" (id: ${job.id}): current agentId="${(job as unknown as { agentId?: string }).agentId || '(undefined)'}", channel="${job.delivery?.channel}", accountId="${job.delivery?.accountId || '(none)'}"`);
}
// Fire-and-forget: repair in background
void (async () => {
for (const job of jobsToRepairAgent) {
try {
const channel = toOpenClawChannelType(job.delivery!.channel!);
const accountId = job.delivery!.accountId;
const toAddress = job.delivery!.to;
// Try 1: resolve from channel + accountId binding
let correctAgentId = await resolveAgentIdFromChannel(channel, accountId);
// If no accountId, try to resolve it from session history using "to" address, then get agentId
let resolvedAccountId: string | null = null;
if (!correctAgentId && !accountId && toAddress) {
console.debug(`No binding found for channel="${channel}", accountId="${accountId || '(none)'}", trying session history for to="${toAddress}"`);
resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel);
if (resolvedAccountId) {
console.debug(`Resolved accountId="${resolvedAccountId}" from session history, now resolving agentId`);
correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId);
}
}
if (correctAgentId) {
console.debug(`Repairing job "${job.name}": agentId "${(job as unknown as { agentId?: string }).agentId || '(undefined)'}" -> "${correctAgentId}"`);
// When accountId was resolved via to address, include it in the patch
const patch: Record<string, unknown> = { agentId: correctAgentId };
if (resolvedAccountId && !accountId) {
patch.delivery = { accountId: resolvedAccountId };
}
await ctx.gatewayManager.rpc('cron.update', { id: job.id, patch });
// Update the local job object so response reflects correct agentId
(job as unknown as { agentId: string }).agentId = correctAgentId;
if (resolvedAccountId && !accountId && job.delivery) {
job.delivery.accountId = resolvedAccountId;
}
} else {
console.warn(`Could not resolve agent for job "${job.name}": channel="${channel}", accountId="${accountId || '(none)'}", to="${toAddress || '(none)'}"`);
}
} catch (error) {
console.error(`Failed to repair agent for job "${job.name}":`, error);
}
}
})();
}
}
sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) })));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{
name: string;
message: string;
schedule: string;
delivery?: GatewayCronDelivery;
enabled?: boolean;
agentId?: string;
}>(req);
const agentId = typeof input.agentId === 'string' && input.agentId.trim()
? input.agentId.trim()
: 'main';
// DEBUG: log the input and resolved agentId
console.debug(`Creating cron job: name="${input.name}", input.agentId="${input.agentId || '(not provided)'}", resolved agentId="${agentId}"`);
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
const result = await ctx.gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = buildCronUpdatePatch(input);
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
const result = await ctx.gatewayManager.rpc('cron.update', { id, patch });
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-272
View File
@@ -1,272 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { dialog, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { basename, extname, join } from 'node:path';
import { homedir } from 'node:os';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
const DIRECTORY_MIME_TYPE = 'application/x-directory';
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const { readFile } = await import('node:fs/promises');
if (mimeType === 'image/svg+xml') {
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
}
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
/**
* Resolve a Gateway-emitted outgoing-media URL to the original file on disk.
* Mirror of `electron/main/ipc-handlers.ts::resolveOutgoingMediaUrl` kept
* in sync so the host-api HTTP path serves the same data as the IPC path.
*/
async function resolveOutgoingMediaUrl(
gatewayUrl: string,
): Promise<{ path: string; mimeType: string } | null> {
try {
const m = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//);
if (!m) return null;
const attachmentId = decodeURIComponent(m[1]);
if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null;
const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`);
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(recordPath, 'utf8');
const record = JSON.parse(raw) as {
original?: { path?: string; contentType?: string };
};
const original = record?.original;
if (!original?.path) return null;
return {
path: original.path,
mimeType: typeof original.contentType === 'string' && original.contentType
? original.contentType
: 'application/octet-stream',
};
} catch {
return null;
}
}
export async function handleFileRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ filePaths: string[] }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of body.filePaths) {
const id = crypto.randomUUID();
const fileName = basename(filePath);
const sourceStat = await fsP.stat(filePath);
if (sourceStat.isDirectory()) {
results.push({
id,
fileName,
mimeType: DIRECTORY_MIME_TYPE,
fileSize: 0,
stagedPath: filePath,
preview: null,
});
continue;
}
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
await fsP.copyFile(filePath, stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = body.mimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
sendJson(res, 200, {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
paths: Array<{ filePath?: string; gatewayUrl?: string; mimeType: string }>;
}>(req);
const fsP = await import('node:fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const entry of body.paths) {
if (entry.filePath) {
try {
const s = await fsP.stat(entry.filePath);
const preview = entry.mimeType.startsWith('image/')
? await generateImagePreview(entry.filePath, entry.mimeType)
: null;
results[entry.filePath] = { preview, fileSize: s.size };
} catch {
results[entry.filePath] = { preview: null, fileSize: 0 };
}
continue;
}
if (entry.gatewayUrl) {
const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl);
if (!resolved) {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
continue;
}
try {
const s = await fsP.stat(resolved.path);
const preview = resolved.mimeType.startsWith('image/')
? await generateImagePreview(resolved.path, resolved.mimeType)
: null;
results[entry.gatewayUrl] = { preview, fileSize: s.size };
} catch {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
}
}
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/save-image' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
base64?: string;
mimeType?: string;
filePath?: string;
defaultFileName: string;
}>(req);
const ext = body.defaultFileName.includes('.')
? body.defaultFileName.split('.').pop()!
: (body.mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', body.defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) {
sendJson(res, 200, { success: false });
return true;
}
const fsP = await import('node:fs/promises');
if (body.filePath) {
await fsP.copyFile(body.filePath, result.filePath);
} else if (body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
sendJson(res, 400, { success: false, error: 'No image data provided' });
return true;
}
sendJson(res, 200, { success: true, savedPath: result.filePath });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-215
View File
@@ -1,215 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../../utils/config';
import { scheduleControlUiDeviceAutoApproval } from '../../utils/control-ui-device-pairing';
import { buildOpenClawControlUiUrl } from '../../utils/openclaw-control-ui';
import { getSetting } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function runGatewayRpc<T>(ctx: HostApiContext, method: string, params?: unknown, timeoutMs?: number): Promise<T> {
return await ctx.gatewayManager.rpc<T>(method, params, timeoutMs);
}
export async function handleGatewayRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
sendJson(res, 200, {
wsUrl: `ws://127.0.0.1:${port}/ws`,
token,
port,
});
return true;
}
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
sendJson(res, 200, ctx.gatewayManager.getStatus());
return true;
}
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
const health = await ctx.gatewayManager.checkHealth({
probe: url.searchParams.get('probe') === '1' || url.searchParams.get('probe') === 'true',
});
sendJson(res, 200, health);
return true;
}
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
try {
await ctx.gatewayManager.start();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
try {
await ctx.gatewayManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
try {
await ctx.gatewayManager.restart();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
try {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const view = url.searchParams.get('view') === 'dreams' ? 'dreams' : undefined;
const urlValue = buildOpenClawControlUiUrl(port, token, { view });
scheduleControlUiDeviceAutoApproval(ctx.gatewayManager);
sendJson(res, 200, { success: true, url: urlValue, token, port });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/sessions' && req.method === 'GET') {
try {
const result = await runGatewayRpc<Record<string, unknown>>(ctx, 'sessions.list', {
includeDerivedTitles: true,
includeLastMessage: true,
});
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/history' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
sessionKey: string;
limit?: number;
maxChars?: number;
timeoutMs?: number;
}>(req);
const params: Record<string, unknown> = {
sessionKey: body.sessionKey,
...(typeof body.limit === 'number' ? { limit: body.limit } : {}),
...(typeof body.maxChars === 'number' ? { maxChars: body.maxChars } : {}),
};
const result = await runGatewayRpc<Record<string, unknown>>(
ctx,
'chat.history',
params,
typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined,
);
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/send' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
}>(req);
const result = await runGatewayRpc<{ runId?: string }>(
ctx,
'chat.send',
{
sessionKey: body.sessionKey,
message: body.message,
deliver: body.deliver ?? false,
idempotencyKey: body.idempotencyKey,
},
120000,
);
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/abort' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
const result = await runGatewayRpc<Record<string, unknown>>(ctx, 'chat.abort', { sessionKey: body.sessionKey });
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
}>(req);
const VISION_MIME_TYPES = new Set([
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
]);
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
const fileReferences: string[] = [];
if (body.media && body.media.length > 0) {
const fsP = await import('node:fs/promises');
for (const m of body.media) {
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
if (VISION_MIME_TYPES.has(m.mimeType)) {
const fileBuffer = await fsP.readFile(m.filePath);
imageAttachments.push({
content: fileBuffer.toString('base64'),
mimeType: m.mimeType,
fileName: m.fileName,
});
}
}
}
const message = fileReferences.length > 0
? [body.message, ...fileReferences].filter(Boolean).join('\n')
: body.message;
const rpcParams: Record<string, unknown> = {
sessionKey: body.sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey: body.idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
const result = await runGatewayRpc(ctx, 'chat.send', rpcParams, 120000);
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-29
View File
@@ -1,29 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleLogRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/logs' && req.method === 'GET') {
const tailLines = Number(url.searchParams.get('tailLines') || '100');
sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) });
return true;
}
if (url.pathname === '/api/logs/dir' && req.method === 'GET') {
sendJson(res, 200, { dir: logger.getLogDir() });
return true;
}
if (url.pathname === '/api/logs/files' && req.method === 'GET') {
sendJson(res, 200, { files: await logger.listLogFiles() });
return true;
}
return false;
}
-112
View File
@@ -1,112 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
} from '../../utils/openclaw-image-relay-constants';
import { parseJsonBody, sendJson } from '../route-utils';
import {
applyOpenAiImageRelaySettings,
getImageGenerationSettingsSnapshot,
listImageGenerationProvidersFromRuntime,
runImageGenerationTest,
setImageGenerationConfig,
type ImageGenerationModelConfig,
} from '../../utils/openclaw-image-generation';
export async function handleMediaRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/media/image-generation' && req.method === 'GET') {
try {
sendJson(res, 200, { success: true, ...(await getImageGenerationSettingsSnapshot()) });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/media/image-generation' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{
timeoutMs?: number | null;
openAiRelayEnabled?: boolean;
openAiRelayBaseUrl?: string | null;
openAiRelayModel?: string | null;
openAiRelayApiKey?: string;
}>(req);
const current = await getImageGenerationSettingsSnapshot();
const normalizeRelayModel = (value: unknown): string => {
const raw = typeof value === 'string' && value.trim()
? value.trim()
: (current.openAiRelay.model || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
const slash = raw.indexOf('/');
return (slash > 0 ? raw.slice(slash + 1) : raw).trim() || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL;
};
const relayModel = normalizeRelayModel(body.openAiRelayModel);
let nextPrimary = current.config.primary;
if (body.openAiRelayEnabled === true) {
nextPrimary = `${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/${relayModel}`;
} else if (body.openAiRelayEnabled === false) {
nextPrimary = null;
}
const next: ImageGenerationModelConfig = {
primary: nextPrimary,
fallbacks: [],
timeoutMs: body.timeoutMs !== undefined
? (typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? Math.floor(body.timeoutMs) : null)
: current.config.timeoutMs,
};
if (typeof body.openAiRelayEnabled === 'boolean') {
await applyOpenAiImageRelaySettings({
enabled: body.openAiRelayEnabled,
baseUrl: body.openAiRelayBaseUrl,
apiKey: body.openAiRelayApiKey,
model: relayModel,
});
}
const config = await setImageGenerationConfig(next);
sendJson(res, 200, {
success: true,
config,
...(await getImageGenerationSettingsSnapshot()),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/media/image-generation/providers' && req.method === 'GET') {
try {
const providers = await listImageGenerationProvidersFromRuntime();
sendJson(res, 200, { success: true, providers });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/media/image-generation/test' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
agentId?: string;
prompt?: string;
model?: string;
}>(req);
const result = await runImageGenerationTest(body);
sendJson(res, result.success ? 200 : 500, { success: result.success, ...result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-455
View File
@@ -1,455 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
type ProviderConfig,
} from '../../utils/secure-storage';
import {
getProviderConfig,
} from '../../utils/provider-registry';
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import {
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from '../../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
import { getProviderService } from '../../services/providers/provider-service';
import { providerAccountToConfig } from '../../services/providers/provider-store';
import type { ProviderAccount } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
const legacyProviderRoutesWarned = new Set<string>();
function hasObjectChanges<T extends Record<string, unknown>>(
existing: T,
patch: Partial<T> | undefined,
): boolean {
if (!patch) return false;
const keys = Object.keys(patch) as Array<keyof T>;
if (keys.length === 0) return false;
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
}
export async function handleProviderRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const providerService = getProviderService();
const logLegacyProviderRoute = (route: string): void => {
if (legacyProviderRoutesWarned.has(route)) return;
legacyProviderRoutesWarned.add(route);
logger.warn(
`[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`,
);
};
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
sendJson(res, 200, await providerService.listVendors());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
sendJson(res, 200, await providerService.listAccounts());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
const account = await providerService.createAccount(body.account, body.apiKey);
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
const currentDefault = await providerService.getDefaultAccountId();
if (currentDefault === body.accountId) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
await providerService.setDefaultAccount(body.accountId);
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
// ── New account-based companion endpoints ─────────────────────────
// Exposed alongside the existing /api/provider-accounts surface so the
// renderer (and any future external client) can drop the legacy
// /api/providers paths without losing functionality. Specific paths
// must be matched BEFORE the generic /api/provider-accounts/:id rule
// below to avoid being captured as account ids.
if (url.pathname === '/api/provider-accounts/key-info' && req.method === 'GET') {
sendJson(res, 200, await providerService.listAccountsKeyInfo());
return true;
}
if (url.pathname === '/api/provider-accounts/validate' && req.method === 'POST') {
try {
// Accept legacy `providerId` as a fallback so external clients that
// migrate by URL alone (without renaming their request body) continue
// to work. The renderer always sends all three fields; older callers
// may send only `providerId`.
const body = await parseJsonBody<{
accountId?: string;
vendorId?: string;
providerId?: string;
apiKey: string;
options?: { baseUrl?: string; apiProtocol?: string };
}>(req);
const accountId = body.accountId || body.vendorId || body.providerId || '';
const account = accountId ? await providerService.getAccount(accountId) : null;
const providerType = account?.vendorId || body.vendorId || body.providerId || accountId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = body.options?.baseUrl || account?.baseUrl || registryBaseUrl;
const resolvedProtocol = body.options?.apiProtocol || account?.apiProtocol;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, {
baseUrl: resolvedBaseUrl,
apiProtocol: resolvedProtocol,
}));
} catch (error) {
sendJson(res, 500, { valid: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/oauth/start' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
provider: OAuthProviderType | BrowserOAuthProviderType;
region?: 'global' | 'cn';
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
});
} else {
await deviceOAuthManager.startFlow(body.provider, body.region, {
accountId: body.accountId,
label: body.label,
});
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/oauth/cancel' && req.method === 'POST') {
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/oauth/submit' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ code: string }>(req);
const accepted = browserOAuthManager.submitManualCode(body.code || '');
if (!accepted) {
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
return true;
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
const remainder = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
if (remainder.endsWith('/api-key')) {
const accountId = remainder.slice(0, -'/api-key'.length);
sendJson(res, 200, { apiKey: await providerService.getAccountApiKey(accountId) });
return true;
}
if (remainder.endsWith('/has-api-key')) {
const accountId = remainder.slice(0, -'/has-api-key'.length);
sendJson(res, 200, { hasKey: await providerService.hasAccountApiKey(accountId) });
return true;
}
sendJson(res, 200, await providerService.getAccount(remainder));
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
const existing = await providerService.getAccount(accountId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider account not found' });
return true;
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
if (!hasPatchChanges && body.apiKey === undefined) {
sendJson(res, 200, { success: true, noChange: true, account: existing });
return true;
}
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account: nextAccount });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai'
? 'openai-codex'
: undefined;
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteAccount(accountId);
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx.gatewayManager,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers');
sendJson(res, 200, await providerService._listProvidersWithKeyInfoInternal());
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/default');
sendJson(res, 200, { providerId: await providerService._getDefaultProviderInternal() ?? null });
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/default');
try {
const body = await parseJsonBody<{ providerId: string }>(req);
const currentDefault = await providerService._getDefaultProviderInternal();
if (currentDefault === body.providerId) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
await providerService._setDefaultProviderInternal(body.providerId);
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/validate');
try {
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
const provider = await providerService._getProviderInternal(body.providerId);
const providerType = provider?.type || body.providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol }));
} catch (error) {
sendJson(res, 500, { valid: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/start');
try {
const body = await parseJsonBody<{
provider: OAuthProviderType | BrowserOAuthProviderType;
region?: 'global' | 'cn';
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
});
} else {
await deviceOAuthManager.startFlow(body.provider, body.region, {
accountId: body.accountId,
label: body.label,
});
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/submit');
try {
const body = await parseJsonBody<{ code: string }>(req);
const accepted = browserOAuthManager.submitManualCode(body.code || '');
if (!accepted) {
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
return true;
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers');
try {
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
const config = body.config;
await providerService._saveProviderInternal(config);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
if (providerId.endsWith('/api-key')) {
const actualId = providerId.slice(0, -('/api-key'.length));
sendJson(res, 200, { apiKey: await providerService._getProviderApiKeyInternal(actualId) });
return true;
}
if (providerId.endsWith('/has-api-key')) {
const actualId = providerId.slice(0, -('/has-api-key'.length));
sendJson(res, 200, { hasKey: await providerService._hasProviderApiKeyInternal(actualId) });
return true;
}
sendJson(res, 200, await providerService._getProviderInternal(providerId));
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
const existing = await providerService._getProviderInternal(providerId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider not found' });
return true;
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
if (!hasPatchChanges && body.apiKey === undefined) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
await providerService._saveProviderInternal(nextConfig);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
}
}
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
logLegacyProviderRoute('DELETE /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const existing = await providerService._getProviderInternal(providerId);
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService._deleteProviderApiKeyInternal(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
sendJson(res, 200, { success: true });
return true;
}
await providerService._deleteProviderInternal(providerId);
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-112
View File
@@ -1,112 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { applyProxySettings } from '../../main/proxy';
import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup';
import { syncProxyConfigToOpenClaw } from '../../utils/openclaw-proxy';
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
const settings = await getAllSettings();
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
await applyProxySettings(settings);
if (ctx.gatewayManager.getStatus().state === 'running') {
await ctx.gatewayManager.restart();
}
}
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
return Object.keys(patch).some((key) => (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
));
}
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
}
export async function handleSettingsRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/settings' && req.method === 'GET') {
sendJson(res, 200, await getAllSettings());
return true;
}
if (url.pathname === '/api/settings' && req.method === 'PUT') {
try {
const patch = await parseJsonBody<Partial<AppSettings>>(req);
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value);
}
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(ctx);
}
if (patchTouchesLaunchAtStartup(patch)) {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
sendJson(res, 200, { value: await getSetting(key) });
} catch (error) {
sendJson(res, 404, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
await setSetting(key, body.value);
if (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
) {
await handleProxySettingsChange(ctx);
}
if (key === 'launchAtStartup') {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
try {
await resetSettings();
await handleProxySettingsChange(ctx);
await syncLaunchAtStartupSettingFromStore();
sendJson(res, 200, { success: true, settings: await getAllSettings() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-242
View File
@@ -1,242 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllSkillConfigs, updateSkillConfig, updateSkillConfigs } from '../../utils/skill-config';
import { collectQuickAccessSkills, filterEnabledQuickAccessSkills, type QuickAccessRuntimeSkillStatus } from '../../utils/skill-quick-access';
import { listLocalSkills } from '../../services/skills/local-skill-service';
import type { MarketplaceInstallParams, MarketplaceSearchParams, MarketplaceUninstallParams } from '../../gateway/clawhub';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function handleMarketplaceCapability(res: ServerResponse, ctx: HostApiContext): Promise<void> {
sendJson(res, 200, {
success: true,
capability: await ctx.clawHubService.getMarketplaceCapability(),
});
}
async function handleMarketplaceSearch(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise<void> {
const body = await parseJsonBody<MarketplaceSearchParams>(req);
sendJson(res, 200, {
success: true,
results: await ctx.clawHubService.search(body),
});
}
async function handleMarketplaceInstall(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise<void> {
const body = await parseJsonBody<MarketplaceInstallParams>(req);
await ctx.clawHubService.install(body);
sendJson(res, 200, { success: true });
}
async function handleMarketplaceUninstall(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise<void> {
const body = await parseJsonBody<MarketplaceUninstallParams>(req);
await ctx.clawHubService.uninstall(body);
sendJson(res, 200, { success: true });
}
async function handleMarketplaceList(res: ServerResponse, ctx: HostApiContext): Promise<void> {
sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() });
}
export async function handleSkillRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
sendJson(res, 200, await getAllSkillConfigs());
return true;
}
if (url.pathname === '/api/skills/config' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{
skillKey: string;
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
}>(req);
sendJson(res, 200, await updateSkillConfig(body.skillKey, {
enabled: body.enabled,
apiKey: body.apiKey,
env: body.env,
}));
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/configs' && req.method === 'PATCH') {
try {
const body = await parseJsonBody<{
updates?: Array<{
skillKey: string;
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
}>;
}>(req);
sendJson(res, 200, await updateSkillConfigs(body.updates || []));
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/local' && req.method === 'GET') {
try {
sendJson(res, 200, {
success: true,
skills: await listLocalSkills(),
});
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/quick-access' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
workspace?: string;
}>(req);
const [scannedSkills, configs] = await Promise.all([
collectQuickAccessSkills({
workspace: body.workspace,
}),
getAllSkillConfigs(),
]);
let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined;
if (ctx.gatewayManager.getStatus().state === 'running') {
try {
const runtimeStatus = await ctx.gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
runtimeSkills = runtimeStatus.skills || [];
} catch {
runtimeSkills = undefined;
}
}
sendJson(res, 200, {
success: true,
skills: filterEnabledQuickAccessSkills(scannedSkills, runtimeSkills, configs),
});
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/marketplace/capability' && req.method === 'GET') {
try {
await handleMarketplaceCapability(res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/marketplace/search' && req.method === 'POST') {
try {
await handleMarketplaceSearch(req, res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/marketplace/install' && req.method === 'POST') {
try {
await handleMarketplaceInstall(req, res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/marketplace/uninstall' && req.method === 'POST') {
try {
await handleMarketplaceUninstall(req, res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/skills/marketplace/list' && req.method === 'GET') {
try {
await handleMarketplaceList(res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/capability' && req.method === 'GET') {
try {
await handleMarketplaceCapability(res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
try {
await handleMarketplaceSearch(req, res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/install' && req.method === 'POST') {
try {
await handleMarketplaceInstall(req, res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') {
try {
await handleMarketplaceUninstall(req, res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/list' && req.method === 'GET') {
try {
await handleMarketplaceList(res, ctx);
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-path' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillPath(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
return false;
}
-26
View File
@@ -1,26 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getRecentTokenUsageHistory } from '../../utils/token-usage';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleUsageRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
const rawLimit = url.searchParams.get('limit');
let limit: number | undefined;
if (rawLimit != null && rawLimit.trim() !== '') {
const parsedLimit = Number(rawLimit);
if (Number.isFinite(parsedLimit)) {
limit = Math.max(Math.floor(parsedLimit), 1);
}
}
sendJson(res, 200, await getRecentTokenUsageHistory(limit));
return true;
}
return false;
}
-137
View File
@@ -1,137 +0,0 @@
import { randomBytes } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { getPort } from '../utils/config';
import { logger } from '../utils/logger';
import { extensionRegistry } from '../extensions/registry';
import type { HostApiContext } from './context';
import { handleAppRoutes } from './routes/app';
import { handleGatewayRoutes } from './routes/gateway';
import { handleSettingsRoutes } from './routes/settings';
import { handleProviderRoutes } from './routes/providers';
import { handleAgentRoutes } from './routes/agents';
import { handleChannelRoutes } from './routes/channels';
import { handleLogRoutes } from './routes/logs';
import { handleUsageRoutes } from './routes/usage';
import { handleSkillRoutes } from './routes/skills';
import { handleFileRoutes } from './routes/files';
import { handleSessionRoutes } from './routes/sessions';
import { handleCronRoutes } from './routes/cron';
import { handleDiagnosticsRoutes } from './routes/diagnostics';
import { handleMediaRoutes } from './routes/media';
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
const coreRouteHandlers: RouteHandler[] = [
handleAppRoutes,
handleGatewayRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleAgentRoutes,
handleChannelRoutes,
handleSkillRoutes,
handleFileRoutes,
handleSessionRoutes,
handleCronRoutes,
handleDiagnosticsRoutes,
handleLogRoutes,
handleUsageRoutes,
handleMediaRoutes,
];
function buildRouteHandlers(): RouteHandler[] {
const extensionHandlers = extensionRegistry.getRouteHandlers();
return [...coreRouteHandlers, ...extensionHandlers];
}
/**
* Per-session secret token used to authenticate Host API requests.
* Generated once at server start and shared with the renderer via IPC.
* This prevents cross-origin attackers from reading sensitive data even
* if they can reach 127.0.0.1:13210 (the CORS wildcard alone is not
* sufficient because browsers attach the Origin header but not a secret).
*/
let hostApiToken: string = '';
/** Retrieve the current Host API auth token (for use by IPC proxy). */
export function getHostApiToken(): string {
return hostApiToken;
}
export function startHostApiServer(ctx: HostApiContext, port = getPort('CLAWX_HOST_API')): Server {
// Generate a cryptographically random token for this session.
hostApiToken = randomBytes(32).toString('hex');
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
// ── CORS headers ─────────────────────────────────────────
// Set origin-aware CORS headers early so every response
// (including error responses) carries them consistently.
const origin = req.headers.origin;
setCorsHeaders(res, origin);
// CORS preflight — respond before auth so browsers can negotiate.
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
// ── Auth gate ──────────────────────────────────────────────
// Every non-preflight request must carry a valid Bearer token.
// Accept via Authorization header (preferred) or ?token= query
// parameter (for EventSource which cannot set custom headers).
const authHeader = req.headers.authorization || '';
const bearerToken = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: (requestUrl.searchParams.get('token') || '');
if (bearerToken !== hostApiToken) {
sendJson(res, 401, { success: false, error: 'Unauthorized' });
return;
}
// ── Content-Type gate (anti-CSRF) ──────────────────────────
// Mutation requests must use application/json to force a CORS
// preflight, preventing "simple request" CSRF attacks.
if (!requireJsonContentType(req)) {
sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' });
return;
}
const routeHandlers = buildRouteHandlers();
for (const handler of routeHandlers) {
if (await handler(req, res, requestUrl, ctx)) {
return;
}
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
} catch (error) {
logger.error('Host API request failed:', error);
sendJson(res, 500, { success: false, error: String(error) });
}
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EACCES' || error.code === 'EADDRINUSE') {
logger.error(
`Host API server failed to bind port ${port}: ${error.message}. ` +
'On Windows this is often caused by Hyper-V reserving the port range. ' +
`Set CLAWX_PORT_CLAWX_HOST_API env var to override the default port.`,
);
} else {
logger.error('Host API server error:', error);
}
});
server.listen(port, '127.0.0.1', () => {
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
});
return server;
}
+9 -9
View File
@@ -1,22 +1,22 @@
import { createDiagnosticsApi } from '../../services/diagnostics-api';
import type {
Extension,
ExtensionContext,
HostApiRouteExtension,
RouteHandler,
HostApiProviderExtension,
} from '../types';
class DiagnosticsExtension implements HostApiRouteExtension {
class DiagnosticsExtension implements HostApiProviderExtension {
readonly id = 'builtin/diagnostics';
setup(_ctx: ExtensionContext): void {
// Diagnostics routes are stateless; no setup needed.
// Diagnostics are exposed through host IPC contributions.
}
getRouteHandler(): RouteHandler {
return async (req, res, url, ctx) => {
const { handleDiagnosticsRoutes } = await import('../../api/routes/diagnostics');
return handleDiagnosticsRoutes(req, res, url, ctx);
};
getHostApiContributions(ctx: ExtensionContext) {
return [{
module: 'diagnostics',
actions: createDiagnosticsApi({ gatewayManager: ctx.gatewayManager }),
}];
}
}
+2
View File
@@ -1,6 +1,8 @@
import { registerBuiltinExtension } from '../loader';
import { createClawHubMarketplaceExtension } from './clawhub-marketplace';
import { createDiagnosticsExtension } from './diagnostics';
export function registerAllBuiltinExtensions(): void {
registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension);
registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);
}
+2 -3
View File
@@ -3,15 +3,14 @@ export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
export type {
Extension,
ExtensionContext,
HostApiRouteExtension,
HostApiProviderExtension,
MarketplaceProviderExtension,
MarketplaceCapability,
AuthProviderExtension,
AuthStatus,
RouteHandler,
} from './types';
export {
isHostApiRouteExtension,
isHostApiProviderExtension,
isMarketplaceProviderExtension,
isAuthProviderExtension,
} from './types';
+29 -12
View File
@@ -2,24 +2,24 @@ import { logger } from '../utils/logger';
import type {
Extension,
ExtensionContext,
HostApiRouteExtension,
MarketplaceProviderExtension,
RouteHandler,
} from './types';
import {
isHostApiRouteExtension,
isHostApiProviderExtension,
isMarketplaceProviderExtension,
} from './types';
class ExtensionRegistry {
private extensions = new Map<string, Extension>();
private ctx: ExtensionContext | null = null;
private hostApiUnregisters = new Map<string, () => void>();
async initialize(ctx: ExtensionContext): Promise<void> {
this.ctx = ctx;
for (const ext of this.extensions.values()) {
try {
await ext.setup(ctx);
this.registerHostApiContributions(ext, ctx);
logger.info(`[extensions] Extension "${ext.id}" initialized`);
} catch (err) {
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
@@ -36,9 +36,15 @@ class ExtensionRegistry {
logger.debug(`[extensions] Registered extension "${extension.id}"`);
if (this.ctx) {
void Promise.resolve(extension.setup(this.ctx)).catch((err) => {
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
});
void Promise.resolve(extension.setup(this.ctx))
.then(() => {
if (this.ctx) {
this.registerHostApiContributions(extension, this.ctx);
}
})
.catch((err) => {
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
});
}
}
@@ -50,12 +56,6 @@ class ExtensionRegistry {
return [...this.extensions.values()];
}
getRouteHandlers(): RouteHandler[] {
return this.getAll()
.filter(isHostApiRouteExtension)
.map((ext: HostApiRouteExtension) => ext.getRouteHandler());
}
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
}
@@ -63,6 +63,8 @@ class ExtensionRegistry {
async teardownAll(): Promise<void> {
for (const ext of this.extensions.values()) {
try {
this.hostApiUnregisters.get(ext.id)?.();
this.hostApiUnregisters.delete(ext.id);
await ext.teardown?.();
} catch (err) {
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
@@ -71,6 +73,21 @@ class ExtensionRegistry {
this.extensions.clear();
this.ctx = null;
}
private registerHostApiContributions(ext: Extension, ctx: ExtensionContext): void {
this.hostApiUnregisters.get(ext.id)?.();
this.hostApiUnregisters.delete(ext.id);
if (!isHostApiProviderExtension(ext)) {
return;
}
const contributions = ext.getHostApiContributions(ctx);
if (contributions.length === 0) {
return;
}
this.hostApiUnregisters.set(ext.id, ctx.hostApi.register(ext.id, contributions));
}
}
export const extensionRegistry = new ExtensionRegistry();
+11 -19
View File
@@ -1,8 +1,6 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { HostEventBus } from '../api/event-bus';
import type { HostApiContext } from '../api/context';
import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract';
import type {
MarketplaceSearchParams,
MarketplaceInstallParams,
@@ -12,17 +10,10 @@ import type {
ClawHubSkillResult,
} from '../gateway/clawhub';
export type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
export interface ExtensionContext {
gatewayManager: GatewayManager;
eventBus: HostEventBus;
getMainWindow: () => BrowserWindow | null;
hostApi: HostApiContributionRegistrar;
}
export interface Extension {
@@ -31,10 +22,6 @@ export interface Extension {
teardown?(): void | Promise<void>;
}
export interface HostApiRouteExtension extends Extension {
getRouteHandler(): RouteHandler;
}
export interface MarketplaceCapability {
mode: string;
canSearch: boolean;
@@ -48,6 +35,10 @@ export interface MarketplaceProviderExtension extends Extension {
install(params: MarketplaceInstallParams): Promise<void>;
}
export interface HostApiProviderExtension extends Extension {
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[];
}
export type LegacyMarketplaceSearchParams = ClawHubSearchParams;
export type LegacyMarketplaceInstallParams = ClawHubInstallParams;
export type LegacyMarketplaceSkillResult = ClawHubSkillResult;
@@ -63,14 +54,15 @@ export interface AuthProviderExtension extends Extension {
onStartup?(mainWindow: BrowserWindow): Promise<void>;
}
export function isHostApiRouteExtension(ext: Extension): ext is HostApiRouteExtension {
return 'getRouteHandler' in ext && typeof (ext as HostApiRouteExtension).getRouteHandler === 'function';
}
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
}
export function isHostApiProviderExtension(ext: Extension): ext is HostApiProviderExtension {
return 'getHostApiContributions' in ext
&& typeof (ext as HostApiProviderExtension).getHostApiContributions === 'function';
}
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function';
}
+7 -6
View File
@@ -3,6 +3,7 @@ import type {
GatewayHealthSummary,
GatewayStatus,
} from './manager';
import type { GatewayRuntimePayload } from '@shared/types/gateway';
export type GatewayCapabilityName = 'openclawHealth' | 'openclawStatus' | 'channels' | 'memory';
@@ -11,7 +12,7 @@ export interface GatewayCapabilityProbe {
checkedAt?: number;
durationMs?: number;
error?: string;
payload?: unknown;
payload?: GatewayRuntimePayload;
}
export interface GatewayCoreProbe {
@@ -41,7 +42,7 @@ function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function capabilityFromPayload(payload: unknown, checkedAt = Date.now()): GatewayCapabilityProbe {
function capabilityFromPayload(payload: GatewayRuntimePayload, checkedAt = Date.now()): GatewayCapabilityProbe {
return {
state: 'healthy',
checkedAt,
@@ -67,15 +68,15 @@ export class GatewayCapabilityMonitor {
private memory: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private lastCoreProbe: GatewayCoreProbe | undefined;
recordOpenClawHealth(payload: unknown): void {
recordOpenClawHealth(payload: GatewayRuntimePayload): void {
this.openclawHealth = capabilityFromPayload(payload);
}
recordOpenClawStatus(payload: unknown): void {
recordOpenClawStatus(payload: GatewayRuntimePayload): void {
this.openclawStatus = capabilityFromPayload(payload);
}
recordPresence(payload: unknown): void {
recordPresence(payload: GatewayRuntimePayload): void {
this.presence = capabilityFromPayload(payload);
}
@@ -83,7 +84,7 @@ export class GatewayCapabilityMonitor {
this.lastCoreProbe = probe;
}
recordCapabilitySuccess(name: GatewayCapabilityName, payload: unknown, durationMs?: number): void {
recordCapabilitySuccess(name: GatewayCapabilityName, payload: GatewayRuntimePayload, durationMs?: number): void {
const probe: GatewayCapabilityProbe = {
state: 'healthy',
checkedAt: Date.now(),
+11 -4
View File
@@ -12,10 +12,17 @@ function readNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function withBase(
type: ChatRuntimeEvent['type'],
type ChatRuntimeEventType = ChatRuntimeEvent['type'];
type ChatRuntimeEventFor<T extends ChatRuntimeEventType> = Extract<ChatRuntimeEvent, { type: T }>;
type ChatRuntimeEventBaseFor<T extends ChatRuntimeEventType> = Pick<
ChatRuntimeEventFor<T>,
'type' | 'runId' | 'sessionKey' | 'seq' | 'ts'
>;
function withBase<T extends ChatRuntimeEventType>(
type: T,
payload: Record<string, unknown>,
): Pick<ChatRuntimeEvent, 'type' | 'runId' | 'sessionKey' | 'seq' | 'ts'> | null {
): ChatRuntimeEventBaseFor<T> | null {
const runId = readString(payload.runId);
if (!runId) return null;
return {
@@ -24,7 +31,7 @@ function withBase(
sessionKey: readString(payload.sessionKey),
seq: readNumber(payload.seq),
ts: readNumber(payload.ts),
};
} as ChatRuntimeEventBaseFor<T>;
}
export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeEvent | null {
+1 -1
View File
@@ -183,7 +183,7 @@ export class ClawHubService {
baseDir: skillDir,
};
}));
return items.filter((item): item is ClawHubInstalledSkillResult => item !== null);
return items.filter((item): item is NonNullable<typeof item> => item !== null);
} catch (error) {
console.error('ClawHub list error:', error);
return [];
+10 -5
View File
@@ -1,6 +1,11 @@
import { GatewayEventType, type JsonRpcNotification } from './protocol';
import { logger } from '../utils/logger';
import { normalizeGatewayChatRuntimeEvent } from './chat-runtime-events';
import type {
GatewayChannelStatusEvent,
GatewayChatMessageEvent,
GatewayRuntimePayload,
} from '@shared/host-events/contract';
type GatewayEventEmitter = {
emit: (event: string, payload: unknown) => boolean;
@@ -27,17 +32,17 @@ export function dispatchProtocolEvent(
}
case 'channel.status':
case 'channel.status_changed':
emitter.emit('channel:status', payload as { channelId: string; status: string });
emitter.emit('channel:status', payload as GatewayChannelStatusEvent);
break;
case 'gateway.ready':
case 'ready':
emitter.emit('gateway:ready', payload);
break;
case 'health':
emitter.emit('gateway:health', payload);
emitter.emit('gateway:health', payload as GatewayRuntimePayload);
break;
case 'presence':
emitter.emit('gateway:presence', payload);
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
break;
default:
emitter.emit('notification', { method: event, params: payload });
@@ -57,10 +62,10 @@ export function dispatchJsonRpcNotification(
}
switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED:
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
break;
case GatewayEventType.MESSAGE_RECEIVED:
emitter.emit('chat:message', notification.params as { message: unknown });
emitter.emit('chat:message', notification.params as GatewayChatMessageEvent);
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
+35 -9
View File
@@ -56,6 +56,17 @@ import {
type GatewayCapabilityName,
type GatewayCapabilitySnapshot,
} from './capability-monitor';
import {
isGatewayWsTraceEnabled,
redactGatewayFrameForTrace,
summarizeGatewayFrameForTrace,
} from './ws-trace';
import type {
GatewayChannelStatusEvent,
GatewayChatMessageEvent,
GatewayRuntimePayload,
} from '@shared/host-events/contract';
import type { ChatRuntimeEvent } from '@shared/chat-runtime-events';
export interface GatewayStatus {
state: GatewayLifecycleState;
@@ -134,11 +145,11 @@ export interface GatewayManagerEvents {
notification: (notification: JsonRpcNotification) => void;
exit: (code: number | null) => void;
error: (error: Error) => void;
'gateway:health': (data: unknown) => void;
'gateway:presence': (data: unknown) => void;
'channel:status': (data: { channelId: string; status: string }) => void;
'chat:message': (data: { message: unknown }) => void;
'chat:runtime-event': (data: unknown) => void;
'gateway:health': (data: GatewayRuntimePayload) => void;
'gateway:presence': (data: GatewayRuntimePayload) => void;
'channel:status': (data: GatewayChannelStatusEvent) => void;
'chat:message': (data: GatewayChatMessageEvent) => void;
'chat:runtime-event': (data: ChatRuntimeEvent) => void;
}
/**
@@ -346,7 +357,6 @@ export class GatewayManager extends EventEmitter {
try {
await runGatewayStartupSequence({
port: this.status.port,
ownedPid: this.process?.pid,
shouldWaitForPortFree: process.platform === 'win32',
hasOwnedProcess: () => this.process?.pid != null && this.ownsProcess,
resetStartupStderrLines: () => {
@@ -885,6 +895,12 @@ export class GatewayManager extends EventEmitter {
};
try {
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] send', {
summary: summarizeGatewayFrameForTrace(request),
frame: redactGatewayFrameForTrace(request),
});
}
this.ws.send(JSON.stringify(request));
} catch (error) {
rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`Failed to send RPC request: ${error}`));
@@ -900,7 +916,11 @@ export class GatewayManager extends EventEmitter {
}
const capability = classifyCapabilityMethod(method);
if (capability) {
this.capabilityMonitor.recordCapabilitySuccess(capability, result, Date.now() - startedAt);
this.capabilityMonitor.recordCapabilitySuccess(
capability,
result as GatewayRuntimePayload,
Date.now() - startedAt,
);
}
return result;
}).catch((error) => {
@@ -965,10 +985,10 @@ export class GatewayManager extends EventEmitter {
]);
if (healthResult.status === 'fulfilled') {
this.capabilityMonitor.recordOpenClawHealth(healthResult.value);
this.capabilityMonitor.recordOpenClawHealth(healthResult.value as GatewayRuntimePayload);
}
if (statusResult.status === 'fulfilled') {
this.capabilityMonitor.recordOpenClawStatus(statusResult.value);
this.capabilityMonitor.recordOpenClawStatus(statusResult.value as GatewayRuntimePayload);
}
}
@@ -1144,6 +1164,12 @@ export class GatewayManager extends EventEmitter {
private handleMessage(message: unknown): void {
this.connectionMonitor.markAlive('message');
this.recordGatewayAlive();
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] recv', {
summary: summarizeGatewayFrameForTrace(message),
frame: redactGatewayFrameForTrace(message),
});
}
if (typeof message !== 'object' || message === null) {
logger.debug('Received non-object Gateway message');
+4 -3
View File
@@ -176,10 +176,11 @@ export async function launchGatewayProcess(options: {
reject(error);
};
child.on('error', (error) => {
child.on('error', (error: unknown) => {
const normalizedError = error instanceof Error ? error : new Error(String(error));
logger.error('Gateway process spawn error:', error);
options.onError(error);
rejectOnce(error);
options.onError(normalizedError);
rejectOnce(normalizedError);
});
child.on('exit', (code: number) => {
+17
View File
@@ -7,6 +7,11 @@ import {
signDevicePayload,
} from '../utils/device-identity';
import { logger } from '../utils/logger';
import {
isGatewayWsTraceEnabled,
redactGatewayFrameForTrace,
summarizeGatewayFrameForTrace,
} from './ws-trace';
export const GATEWAY_CHALLENGE_TIMEOUT_MS = 10_000;
export const GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS = 20_000;
@@ -245,6 +250,12 @@ export async function connectGatewaySocket(options: {
});
connectId = connectPayload.connectId;
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] send', {
summary: summarizeGatewayFrameForTrace(connectPayload.frame),
frame: redactGatewayFrameForTrace(connectPayload.frame),
});
}
ws.send(JSON.stringify(connectPayload.frame));
const requestTimeout = setTimeout(() => {
@@ -286,6 +297,12 @@ export async function connectGatewaySocket(options: {
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] recv', {
summary: summarizeGatewayFrameForTrace(message),
frame: redactGatewayFrameForTrace(message),
});
}
if (
!challengeReceived &&
typeof message === 'object' && message !== null &&
+51
View File
@@ -0,0 +1,51 @@
const SECRET_KEYS = new Set([
'token',
'authorization',
'apikey',
'api_key',
'signature',
'cookie',
'set-cookie',
'accesstoken',
'refreshtoken',
]);
export function isGatewayWsTraceEnabled(): boolean {
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
}
export function redactGatewayFrameForTrace(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => redactGatewayFrameForTrace(item));
}
if (!value || typeof value !== 'object') {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const normalizedKey = key.toLowerCase();
result[key] = SECRET_KEYS.has(normalizedKey)
? '[redacted]'
: redactGatewayFrameForTrace(item);
}
return result;
}
export function summarizeGatewayFrameForTrace(value: unknown): string {
if (!value || typeof value !== 'object') return typeof value;
const frame = value as Record<string, unknown>;
if (frame.type === 'req') {
return `req id=${String(frame.id ?? '-')} method=${String(frame.method ?? '-')}`;
}
if (frame.type === 'res') {
return `res id=${String(frame.id ?? '-')} ok=${String(frame.ok ?? !frame.error)}`;
}
if (frame.type === 'event') {
return `event ${String(frame.event ?? '-')}`;
}
if (typeof frame.method === 'string') {
return `jsonrpc method=${frame.method}`;
}
return 'unknown gateway frame';
}
+35 -45
View File
@@ -3,10 +3,10 @@
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
import type { Server } from 'node:http';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { registerIpcHandlers } from './ipc-handlers';
import { HostApiRegistry } from './ipc/host-invoke';
import { createTray } from './tray';
import { createMenu } from './menu';
import { registerZoomShortcuts } from './zoom-shortcuts';
@@ -47,8 +47,6 @@ import { createSignalQuitHandler } from './signal-quit';
import { acquireProcessInstanceFileLock } from './process-instance-lock';
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config';
import { startHostApiServer } from '../api/server';
import { HostEventBus } from '../api/event-bus';
import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
@@ -88,7 +86,8 @@ app.disableHardwareAcceleration();
// on X11 it supplements the StartupWMClass matching.
// Must be called before app.whenReady() / before any window is created.
if (process.platform === 'linux') {
app.setDesktopName('clawx.desktop');
const linuxApp = app as typeof app & { setDesktopName?: (desktopName: string) => void };
linuxApp.setDesktopName?.('clawx.desktop');
}
// Prevent multiple instances of the app from running simultaneously.
@@ -133,11 +132,16 @@ const gotTheLock = gotElectronLock && gotFileLock;
let mainWindow: BrowserWindow | null = null;
let gatewayManager!: GatewayManager;
let clawHubService!: ClawHubService;
let hostEventBus!: HostEventBus;
let hostApiServer: Server | null = null;
const hostApiRegistry = new HostApiRegistry();
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
function sendMainWindowEvent(channel: string, payload: unknown): void {
const win = mainWindow;
if (!win || win.isDestroyed()) return;
win.webContents.send(channel, payload);
}
/**
* Resolve the icons directory path (works in both dev and packaged mode)
*/
@@ -322,7 +326,7 @@ async function initialize(): Promise<void> {
}
// Set application menu
createMenu();
await createMenu();
// Create the main window
const window = createMainWindow();
@@ -356,20 +360,17 @@ async function initialize(): Promise<void> {
);
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, window);
hostApiServer = startHostApiServer({
gatewayManager,
clawHubService,
eventBus: hostEventBus,
mainWindow: window,
});
registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry);
// Initialize extension system
await extensionRegistry.initialize({
gatewayManager,
eventBus: hostEventBus,
getMainWindow: () => mainWindow,
hostApi: {
register: (extensionId, contributions) => (
hostApiRegistry.registerExtensionContributions(extensionId, contributions)
),
},
});
// Wire marketplace provider to ClawHubService if an extension provides one
@@ -441,7 +442,7 @@ 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 }) => {
hostEventBus.emit('gateway:status', status);
sendMainWindowEvent('gateway:status-changed', status);
if (status.state === 'running' && !isE2EMode) {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
@@ -450,79 +451,71 @@ async function initialize(): Promise<void> {
});
gatewayManager.on('error', (error) => {
hostEventBus.emit('gateway:error', { message: error.message });
sendMainWindowEvent('gateway:error', { message: error.message });
});
gatewayManager.on('notification', (notification) => {
hostEventBus.emit('gateway:notification', notification);
sendMainWindowEvent('gateway:notification', notification);
});
gatewayManager.on('gateway:health', (data) => {
hostEventBus.emit('gateway:health', data);
sendMainWindowEvent('gateway:health-changed', data);
});
gatewayManager.on('gateway:presence', (data) => {
hostEventBus.emit('gateway:presence', data);
sendMainWindowEvent('gateway:presence-changed', data);
});
gatewayManager.on('chat:message', (data) => {
hostEventBus.emit('gateway:chat-message', data);
sendMainWindowEvent('gateway:chat-message', data);
});
gatewayManager.on('chat:runtime-event', (data) => {
hostEventBus.emit('chat:runtime-event', data);
sendMainWindowEvent('chat:runtime-event', data);
});
gatewayManager.on('channel:status', (data) => {
hostEventBus.emit('gateway:channel-status', data);
sendMainWindowEvent('gateway:channel-status', data);
});
gatewayManager.on('exit', (code) => {
hostEventBus.emit('gateway:exit', { code });
sendMainWindowEvent('gateway:exit', { code });
});
deviceOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
});
deviceOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
sendMainWindowEvent('oauth:code', payload);
});
deviceOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
sendMainWindowEvent('oauth:success', { ...payload, success: true });
});
deviceOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
});
browserOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
sendMainWindowEvent('oauth:error', error);
});
browserOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
sendMainWindowEvent('oauth:code', payload);
});
browserOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
sendMainWindowEvent('oauth:success', { ...payload, success: true });
});
browserOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
sendMainWindowEvent('oauth:error', error);
});
whatsAppLoginManager.on('qr', (data) => {
hostEventBus.emit('channel:whatsapp-qr', data);
sendMainWindowEvent('channel:whatsapp-qr', data);
});
whatsAppLoginManager.on('success', (data) => {
hostEventBus.emit('channel:whatsapp-success', data);
sendMainWindowEvent('channel:whatsapp-success', data);
});
whatsAppLoginManager.on('error', (error) => {
hostEventBus.emit('channel:whatsapp-error', error);
sendMainWindowEvent('channel:whatsapp-error', error);
});
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
@@ -588,7 +581,6 @@ if (gotTheLock) {
gatewayManager = new GatewayManager();
clawHubService = new ClawHubService();
hostEventBus = new HostEventBus();
// Register builtin extensions and load manifest
registerAllBuiltinExtensions();
@@ -652,8 +644,6 @@ if (gotTheLock) {
return;
}
hostEventBus.closeAll();
hostApiServer?.close();
void extensionRegistry.teardownAll();
const stopPromise = gatewayManager.stop().catch((err) => {
File diff suppressed because it is too large Load Diff
-76
View File
@@ -1,76 +0,0 @@
import { ipcMain } from 'electron';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { getPort } from '../../utils/config';
import { getHostApiToken } from '../../api/server';
type HostApiFetchRequest = {
path: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
};
export function registerHostApiProxyHandlers(): void {
const hostApiPort = getPort('CLAWX_HOST_API');
// Expose the per-session auth token to the renderer so the browser-fallback
// path in host-api.ts can authenticate against the Host API server.
ipcMain.handle('hostapi:token', () => getHostApiToken());
ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => {
try {
const path = typeof request?.path === 'string' ? request.path : '';
if (!path || !path.startsWith('/')) {
throw new Error(`Invalid host API path: ${String(request?.path)}`);
}
const method = (request.method || 'GET').toUpperCase();
const headers: Record<string, string> = { ...(request.headers || {}) };
// Inject the per-session auth token so the Host API server accepts this request.
headers['Authorization'] = `Bearer ${getHostApiToken()}`;
let body: string | undefined;
if (request.body !== undefined && request.body !== null) {
if (typeof request.body === 'string') {
body = request.body;
} else {
body = JSON.stringify(request.body);
}
// Ensure Content-Type is set for requests with a body so the
// server's anti-CSRF Content-Type gate does not reject them.
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json';
}
}
const response = await proxyAwareFetch(`http://127.0.0.1:${hostApiPort}${path}`, {
method,
headers,
body,
});
const data: { status: number; ok: boolean; json?: unknown; text?: string } = {
status: response.status,
ok: response.ok,
};
if (response.status !== 204) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data.json = await response.json().catch(() => undefined);
} else {
data.text = await response.text().catch(() => '');
}
}
return { ok: true, data };
} catch (error) {
return {
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
};
}
});
}
+52
View File
@@ -0,0 +1,52 @@
import type { HostApiContract } from '@shared/host-api/contract';
export type HostRequest = {
id: string;
module: string;
action: string;
payload?: unknown;
};
export type HostErrorCode = 'VALIDATION' | 'UNSUPPORTED' | 'INTERNAL';
export type HostResponse<T = unknown> =
| { id?: string; ok: true; data: T }
| { id?: string; ok: false; error: { code: HostErrorCode; message: string; details?: unknown } };
export type RuntimeHostAction = (payload?: unknown) => Promise<unknown> | unknown;
type MaybePromise<T> = T | Promise<T>;
type HostServiceFunction<TFunction> = TFunction extends (...args: infer Args) => infer Result
? (...args: Args) => MaybePromise<Awaited<Result>>
: never;
type HostServiceModule<TModule> = {
[A in keyof TModule]: HostServiceFunction<TModule[A]>;
};
export type HostServiceRegistry = {
[M in keyof HostApiContract]?: Partial<HostServiceModule<HostApiContract[M]>>;
};
export type CompleteHostServiceRegistry = {
[M in keyof HostApiContract]: HostServiceModule<HostApiContract[M]>;
};
export type HostApiContribution = {
module: string;
actions: Record<string, RuntimeHostAction>;
};
export type HostApiContributionRegistrar = {
register: (extensionId: string, contributions: HostApiContribution[]) => () => void;
};
export function isHostRequest(value: unknown): value is HostRequest {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return typeof record.id === 'string'
&& record.id.length > 0
&& typeof record.module === 'string'
&& record.module.length > 0
&& typeof record.action === 'string'
&& record.action.length > 0;
}
+134
View File
@@ -0,0 +1,134 @@
import { ipcMain } from 'electron';
import {
type HostApiContribution,
type HostResponse,
type HostServiceRegistry,
type RuntimeHostAction,
isHostRequest,
} from './host-contract';
type RegisteredHostAction = {
action: RuntimeHostAction;
ownerId: string;
};
function assertValidContributionKey(kind: 'module' | 'action', value: string): void {
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) {
throw new Error(`Invalid host API ${kind}: ${value}`);
}
}
export class HostApiRegistry {
private modules = new Map<string, Map<string, RegisteredHostAction>>();
registerCoreServices(services: HostServiceRegistry): void {
for (const [moduleName, actions] of Object.entries(services)) {
if (!actions || typeof actions !== 'object') continue;
for (const [actionName, action] of Object.entries(actions)) {
if (typeof action !== 'function') continue;
this.registerAction(moduleName, actionName, action as RuntimeHostAction, 'core');
}
}
}
registerExtensionContributions(extensionId: string, contributions: HostApiContribution[]): () => void {
const registered: Array<{ module: string; action: string }> = [];
for (const contribution of contributions) {
assertValidContributionKey('module', contribution.module);
for (const [actionName, action] of Object.entries(contribution.actions)) {
assertValidContributionKey('action', actionName);
this.registerAction(contribution.module, actionName, action, extensionId);
registered.push({ module: contribution.module, action: actionName });
}
}
return () => {
for (const { module, action } of registered) {
const moduleActions = this.modules.get(module);
const registeredAction = moduleActions?.get(action);
if (registeredAction?.ownerId === extensionId) {
moduleActions?.delete(action);
}
if (moduleActions?.size === 0) {
this.modules.delete(module);
}
}
};
}
resolve(moduleName: string, actionName: string): RuntimeHostAction | undefined {
return this.modules.get(moduleName)?.get(actionName)?.action;
}
private registerAction(
moduleName: string,
actionName: string,
action: RuntimeHostAction,
ownerId: string,
): void {
const moduleActions = this.modules.get(moduleName) ?? new Map<string, RegisteredHostAction>();
if (moduleActions.has(actionName)) {
throw new Error(`Host API action already registered: ${moduleName}.${actionName}`);
}
moduleActions.set(actionName, { action, ownerId });
this.modules.set(moduleName, moduleActions);
}
}
function toHostApiRegistry(registryOrServices: HostApiRegistry | HostServiceRegistry): HostApiRegistry {
if (registryOrServices instanceof HostApiRegistry) {
return registryOrServices;
}
const registry = new HostApiRegistry();
registry.registerCoreServices(registryOrServices);
return registry;
}
export function createHostInvokeDispatcher(registryOrServices: HostApiRegistry | HostServiceRegistry) {
const registry = toHostApiRegistry(registryOrServices);
return async function dispatchHostRequest(request: unknown): Promise<HostResponse> {
const requestId = request && typeof request === 'object'
? String((request as Record<string, unknown>).id ?? '')
: undefined;
if (!isHostRequest(request)) {
return {
id: requestId,
ok: false,
error: { code: 'VALIDATION', message: 'Invalid host request format' },
};
}
const action = registry.resolve(request.module, request.action);
if (typeof action !== 'function') {
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `Unsupported host request: ${request.module}.${request.action}`,
},
};
}
try {
const data = await action(request.payload);
return { id: request.id, ok: true, data };
} catch (error) {
return {
id: request.id,
ok: false,
error: {
code: 'INTERNAL',
message: error instanceof Error ? error.message : String(error),
},
};
}
};
}
export function registerHostInvokeHandler(registry: HostApiRegistry): void {
const dispatch = createHostInvokeDispatcher(registry);
ipcMain.handle('host:invoke', async (_event, request: unknown) => dispatch(request));
}
+81 -56
View File
@@ -3,12 +3,33 @@
* Creates the native application menu for macOS/Windows/Linux
*/
import { Menu, app, shell, BrowserWindow } from 'electron';
import { MENU_LABELS } from '@shared/i18n/resources';
import { resolveSupportedLanguage, type LanguageCode } from '@shared/language';
import { getSetting } from '../utils/store';
function applyAppName(label: string): string {
return label.replaceAll('{{appName}}', app.name);
}
async function resolveMenuLanguage(language?: string): Promise<LanguageCode> {
if (language) return resolveSupportedLanguage(language);
try {
return resolveSupportedLanguage(await getSetting('language'));
} catch {
return resolveSupportedLanguage(app.getLocale());
}
}
function getMenuTargetWindow(): BrowserWindow | null {
return BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null;
}
/**
* Create application menu
*/
export function createMenu(): void {
export async function createMenu(language?: string): Promise<void> {
const isMac = process.platform === 'darwin';
const labels = MENU_LABELS[await resolveMenuLanguage(language)];
const template: Electron.MenuItemConstructorOptions[] = [
// App menu (macOS only)
@@ -17,24 +38,24 @@ export function createMenu(): void {
{
label: app.name,
submenu: [
{ role: 'about' as const },
{ role: 'about' as const, label: applyAppName(labels.app.about) },
{ type: 'separator' as const },
{
label: 'Preferences...',
label: labels.app.preferences,
accelerator: 'Cmd+,',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/settings');
},
},
{ type: 'separator' as const },
{ role: 'services' as const },
{ role: 'services' as const, label: labels.app.services },
{ type: 'separator' as const },
{ role: 'hide' as const },
{ role: 'hideOthers' as const },
{ role: 'unhide' as const },
{ role: 'hide' as const, label: applyAppName(labels.app.hide) },
{ role: 'hideOthers' as const, label: labels.app.hideOthers },
{ role: 'unhide' as const, label: labels.app.unhide },
{ type: 'separator' as const },
{ role: 'quit' as const },
{ role: 'quit' as const, label: applyAppName(labels.app.quit) },
],
},
]
@@ -42,110 +63,113 @@ export function createMenu(): void {
// File menu
{
label: 'File',
label: labels.file.label,
submenu: [
{
label: 'New Chat',
id: 'new-chat',
label: labels.file.newChat,
accelerator: 'CmdOrCtrl+N',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/chat');
const win = getMenuTargetWindow();
win?.webContents.send('new-chat');
},
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
isMac
? { role: 'close', label: labels.file.close }
: { role: 'quit', label: applyAppName(labels.app.quit) },
],
},
// Edit menu
{
label: 'Edit',
label: labels.edit.label,
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ role: 'undo', label: labels.edit.undo },
{ role: 'redo', label: labels.edit.redo },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'cut', label: labels.edit.cut },
{ role: 'copy', label: labels.edit.copy },
{ role: 'paste', label: labels.edit.paste },
...(isMac
? [
{ role: 'pasteAndMatchStyle' as const },
{ role: 'delete' as const },
{ role: 'selectAll' as const },
{ role: 'pasteAndMatchStyle' as const, label: labels.edit.pasteAndMatchStyle },
{ role: 'delete' as const, label: labels.edit.delete },
{ role: 'selectAll' as const, label: labels.edit.selectAll },
]
: [
{ role: 'delete' as const },
{ role: 'delete' as const, label: labels.edit.delete },
{ type: 'separator' as const },
{ role: 'selectAll' as const },
{ role: 'selectAll' as const, label: labels.edit.selectAll },
]),
],
},
// View menu
{
label: 'View',
label: labels.view.label,
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ role: 'reload', label: labels.view.reload },
{ role: 'forceReload', label: labels.view.forceReload },
{ role: 'toggleDevTools', label: labels.view.toggleDevTools },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ role: 'resetZoom', label: labels.view.resetZoom },
{ role: 'zoomIn', label: labels.view.zoomIn },
{ role: 'zoomOut', label: labels.view.zoomOut },
{ type: 'separator' },
{ role: 'togglefullscreen' },
{ role: 'togglefullscreen', label: labels.view.toggleFullscreen },
],
},
// Navigate menu
{
label: 'Navigate',
label: labels.navigate.label,
submenu: [
{
label: 'Dashboard',
label: labels.navigate.dashboard,
accelerator: 'CmdOrCtrl+1',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/');
},
},
{
label: 'Chat',
label: labels.navigate.chat,
accelerator: 'CmdOrCtrl+2',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/chat');
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/');
},
},
{
label: 'Channels',
label: labels.navigate.channels,
accelerator: 'CmdOrCtrl+3',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/channels');
},
},
{
label: 'Skills',
label: labels.navigate.skills,
accelerator: 'CmdOrCtrl+4',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/skills');
},
},
{
label: 'Cron Tasks',
label: labels.navigate.cronTasks,
accelerator: 'CmdOrCtrl+5',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/cron');
},
},
{
label: 'Settings',
label: labels.navigate.settings,
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/settings');
},
},
@@ -154,40 +178,41 @@ export function createMenu(): void {
// Window menu
{
label: 'Window',
label: labels.window.label,
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ role: 'minimize', label: labels.window.minimize },
{ role: 'zoom', label: labels.window.zoom },
...(isMac
? [
{ type: 'separator' as const },
{ role: 'front' as const },
{ role: 'front' as const, label: labels.window.front },
{ type: 'separator' as const },
{ role: 'window' as const },
{ role: 'window' as const, label: labels.window.label },
]
: [{ role: 'close' as const }]),
: [{ role: 'close' as const, label: labels.window.close }]),
],
},
// Help menu
{
role: 'help',
label: labels.help.label,
submenu: [
{
label: 'Documentation',
label: labels.help.documentation,
click: async () => {
await shell.openExternal('https://claw-x.com');
},
},
{
label: 'Report Issue',
label: labels.help.reportIssue,
click: async () => {
await shell.openExternal('https://github.com/ValueCell-ai/ClawX/issues');
},
},
{ type: 'separator' },
{
label: 'OpenClaw Documentation',
label: labels.help.openClawDocumentation,
click: async () => {
await shell.openExternal('https://docs.openclaw.ai');
},
+8 -1
View File
@@ -4,7 +4,14 @@ import { buildElectronProxyConfig } from '../utils/proxy';
import { logger } from '../utils/logger';
export async function applyProxySettings(
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
partialSettings?: Pick<AppSettings,
| 'proxyEnabled'
| 'proxyServer'
| 'proxyHttpServer'
| 'proxyHttpsServer'
| 'proxyAllServer'
| 'proxyBypassRules'
>,
): Promise<void> {
const settings = partialSettings ?? await getAllSettings();
const config = buildElectronProxyConfig(settings);
+1 -1
View File
@@ -230,7 +230,7 @@ export class AppUpdater extends EventEmitter {
* Start a countdown that auto-installs the downloaded update.
* Sends `update:auto-install-countdown` events to the renderer each second.
*/
private startAutoInstallCountdown(): void {
startAutoInstallCountdown(): void {
this.clearAutoInstallTimer();
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
+20 -133
View File
@@ -3,6 +3,19 @@
* Exposes safe APIs to the renderer process via contextBridge
*/
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import type { HostRequest } from '@shared/host-api/types';
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
const validStaticEventChannels: Set<string> = new Set(
Object.values(HOST_EVENT_CHANNELS).flatMap((moduleChannels) => Object.values(moduleChannels)),
);
const DYNAMIC_CHANNEL_EVENT_RE = /^channel:[a-z0-9_-]+-(?:qr|success|error)$/i;
function isValidEventChannel(channel: string): boolean {
return validStaticEventChannels.has(channel)
|| DYNAMIC_CHANNEL_EVENT_RE.test(channel)
|| channel.startsWith('ext:');
}
/**
* IPC renderer methods exposed to the renderer process
@@ -16,34 +29,19 @@ const electronAPI = {
const validChannels = [
// Gateway
'gateway:status',
'gateway:isConnected',
'gateway:start',
'gateway:stop',
'gateway:restart',
'gateway:rpc',
'gateway:httpProxy',
'hostapi:fetch',
'hostapi:token',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw
'openclaw:status',
'openclaw:isReady',
// Shell
'shell:openExternal',
'shell:showItemInFolder',
'shell:openPath',
// Dialog
'dialog:open',
'dialog:save',
'dialog:message',
// App
'app:version',
'app:name',
'app:getPath',
'app:platform',
'app:quit',
'app:relaunch',
'app:request',
// Window controls
'window:minimize',
@@ -84,51 +82,6 @@ const electronAPI = {
'provider:setDefault',
'provider:getDefault',
'provider:validateKey',
'provider:requestOAuth',
'provider:cancelOAuth',
// Cron
'cron:list',
'cron:create',
'cron:update',
'cron:delete',
'cron:toggle',
'cron:trigger',
// Channel Config
'channel:saveConfig',
'channel:getConfig',
'channel:getFormValues',
'channel:deleteConfig',
'channel:listConfigured',
'channel:setEnabled',
'channel:validate',
'channel:validateCredentials',
// WhatsApp
'channel:requestWhatsAppQr',
'channel:cancelWhatsAppQr',
// ClawHub
'clawhub:search',
'clawhub:install',
'clawhub:uninstall',
'clawhub:list',
'clawhub:openSkillReadme',
// UV
'uv:check',
'uv:install-all',
// Skill config (direct file access)
'skill:updateConfig',
'skill:getConfig',
'skill:getAllConfigs',
// Logs
'log:getRecent',
'log:readFile',
'log:getFilePath',
'log:getDir',
'log:listFiles',
// File staging & media
'file:stage',
'file:stageBuffer',
'media:getThumbnails',
'media:saveImage',
// File preview (sandboxed read/write/list/tree)
'file:readText',
'file:readBinary',
@@ -136,14 +89,7 @@ const electronAPI = {
'file:stat',
'file:listDir',
'file:listTree',
// Chat send with media (reads staged files in main process)
'chat:sendWithMedia',
// Session management
'session:delete',
'session:rename',
// OpenClaw extras
'openclaw:getDir',
'openclaw:getConfigDir',
'openclaw:getSkillsDir',
'openclaw:getCliCommand',
];
@@ -159,40 +105,7 @@ const electronAPI = {
* Listen for events from main process
*/
on: (channel: string, callback: (...args: unknown[]) => void) => {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:health-changed',
'gateway:presence-changed',
'gateway:channel-status',
'gateway:chat-message',
'chat:runtime-event',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'channel:wechat-qr',
'channel:wechat-success',
'channel:wechat-error',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'cron:updated',
'oauth:code',
'oauth:success',
'oauth:error',
'openclaw:cli-installed',
];
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
if (isValidEventChannel(channel)) {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
callback(...args);
};
@@ -211,38 +124,7 @@ const electronAPI = {
* Listen for a single event from main process
*/
once: (channel: string, callback: (...args: unknown[]) => void) => {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:health-changed',
'gateway:presence-changed',
'gateway:channel-status',
'gateway:chat-message',
'chat:runtime-event',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'channel:wechat-qr',
'channel:wechat-success',
'channel:wechat-error',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'oauth:code',
'oauth:success',
'oauth:error',
];
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
if (isValidEventChannel(channel)) {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
return;
}
@@ -286,8 +168,13 @@ const electronAPI = {
isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL,
};
const clawxAPI = {
hostInvoke: (request: HostRequest) => ipcRenderer.invoke('host:invoke', request),
};
// Expose the API to the renderer process
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('clawx', clawxAPI);
// Type declarations for the renderer process
export type ElectronAPI = typeof electronAPI;
+125
View File
@@ -0,0 +1,125 @@
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
assignChannelToAgent,
clearChannelBinding,
createAgent,
deleteAgentConfig,
listAgentsSnapshot,
removeAgentWorkspaceDirectory,
resolveAccountIdForAgent,
updateAgentModel,
updateAgentName,
} from '../utils/agent-config';
import { deleteChannelAccountConfig } from '../utils/channel-config';
import { ensureClawXContext } from '../utils/openclaw-workspace';
import { isRecord } from './payload-utils';
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from './providers/provider-runtime-sync';
type AgentsApiContext = {
gatewayManager: GatewayManager;
};
function requireString(payload: unknown, key: string): string {
if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) {
throw new Error(`${key} is required`);
}
return payload[key].trim();
}
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
return;
}
void reason;
}
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
try {
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Gateway restart after agent deletion failed:', err);
}
}
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
return {
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
create: async (payload) => {
const name = requireString(payload, 'name');
const inheritWorkspace = isRecord(payload) ? payload.inheritWorkspace === true : undefined;
const snapshot = await createAgent(name, { inheritWorkspace });
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
scheduleGatewayReload(ctx, 'create-agent');
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
return { success: true, ...snapshot };
},
update: async (payload) => {
const agentId = requireString(payload, 'id');
const name = requireString(payload, 'name');
const snapshot = await updateAgentName(agentId, name);
scheduleGatewayReload(ctx, 'update-agent');
return { success: true, ...snapshot };
},
updateModel: async (payload) => {
const agentId = requireString(payload, 'id');
const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null;
const snapshot = await updateAgentModel(agentId, modelRef);
try {
await syncAllProviderAuthToRuntime();
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
}
return { success: true, ...snapshot };
},
delete: async (payload) => {
const agentId = requireString(payload, 'id');
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
await restartGatewayForAgentDeletion(ctx);
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
return { success: true, ...snapshot };
},
assignChannel: async (payload) => {
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
return { success: true, ...snapshot };
},
removeChannel: async (payload) => {
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const ownerId = agentId.trim().toLowerCase();
const snapshotBefore = await listAgentsSnapshot();
const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== ownerId) return false;
return channelAccountKey.startsWith(`${channelType}:`);
})
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
if (ownedAccountIds.length === 0) {
const legacyAccountId = resolveAccountIdForAgent(agentId);
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
ownedAccountIds.push(legacyAccountId);
}
}
for (const accountId of ownedAccountIds) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
return { success: true, ...snapshot };
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
import { isRecord } from './payload-utils';
type OpenClawDoctorPayload = {
mode?: unknown;
};
export function createAppApi(): CompleteHostServiceRegistry['app'] {
return {
openClawDoctor: async (payload) => {
const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {};
return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
},
};
}
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { logger } from '../utils/logger';
import { isRecord } from './payload-utils';
const VISION_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/bmp',
'image/webp',
]);
type ChatSendWithMediaPayload = {
sessionKey?: unknown;
message?: unknown;
deliver?: unknown;
idempotencyKey?: unknown;
media?: unknown;
};
type MediaPayload = {
filePath?: unknown;
mimeType?: unknown;
fileName?: unknown;
};
function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> {
if (!Array.isArray(media)) return [];
return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => {
if (!isRecord(entry)) return [];
const item = entry as MediaPayload;
if (typeof item.filePath !== 'string' || !item.filePath) return [];
return [{
filePath: item.filePath,
mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream',
fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file',
}];
});
}
export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['chat'] {
return {
sendWithMedia: 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 : '';
if (!sessionKey || !idempotencyKey) {
return { success: false, error: 'Invalid chat send payload' };
}
try {
let message = typeof body.message === 'string' ? body.message : '';
const imageAttachments: Array<Record<string, unknown>> = [];
const fileReferences: string[] = [];
const media = normalizeMedia(body.media);
if (media.length > 0) {
const fsP = await import('node:fs/promises');
for (const item of media) {
const exists = await fsP.access(item.filePath).then(() => true, () => false);
logger.info(
`[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`,
);
fileReferences.push(
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
);
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}`);
imageAttachments.push({
content: base64Data,
mimeType: item.mimeType,
fileName: item.fileName,
});
}
}
}
if (fileReferences.length > 0) {
const refs = fileReferences.join('\n');
message = message ? `${message}\n\n${refs}` : refs;
}
const rpcParams: Record<string, unknown> = {
sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
logger.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)}`);
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)}`);
return { success: false, error: String(error) };
}
},
};
}
+563
View File
@@ -0,0 +1,563 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
import type { GatewayManager } from '../gateway/manager';
import { getOpenClawConfigDir } from '../utils/paths';
import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
import { isRecord } from './payload-utils';
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
error?: string;
summary?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
runAtMs?: number;
durationMs?: number;
model?: string;
provider?: string;
}
interface CronSessionKeyParts {
agentId: string;
jobId: string;
runSessionId?: string;
}
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
type JsonRecord = Record<string, unknown>;
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 4 || parts[2] !== 'cron') return null;
const agentId = parts[1] || 'main';
const jobId = parts[3];
if (!jobId) return null;
if (parts.length === 4) return { agentId, jobId };
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
return { agentId, jobId, runSessionId: parts[5] };
}
return null;
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function formatDuration(durationMs: number | undefined): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
if (!content) {
content = status === 'error' ? 'Scheduled task failed.' : 'Scheduled task completed.';
}
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
content = `Run failed: ${content}`;
}
const meta: string[] = [];
const duration = formatDuration(entry.durationMs);
if (duration) meta.push(`Duration: ${duration}`);
if (entry.provider && entry.model) meta.push(`Model: ${entry.provider}/${entry.model}`);
else if (entry.model) meta.push(`Model: ${entry.model}`);
if (meta.length > 0) content = `${content}\n\n${meta.join(' | ')}`;
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
const entries: CronRunLogEntry[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CronRunLogEntry;
if (!entry || entry.jobId !== jobId) continue;
if (entry.action && entry.action !== 'finished') continue;
entries.push(entry);
} catch {
// Ignore malformed log lines.
}
}
return entries;
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
): Promise<Record<string, unknown> | undefined> {
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await readFile(storePath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
try {
const store = JSON.parse(raw) as Record<string, unknown>;
const directEntry = store[sessionKey];
if (directEntry && typeof directEntry === 'object') return directEntry as Record<string, unknown>;
const sessions = (store as { sessions?: unknown }).sessions;
if (Array.isArray(sessions)) {
const arrayEntry = sessions.find((entry) => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return record.key === sessionKey || record.sessionKey === sessionKey;
});
if (arrayEntry && typeof arrayEntry === 'object') return arrayEntry as Record<string, unknown>;
}
} catch {
return undefined;
}
return undefined;
}
function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
const parsed = parseCronSessionKey(params.sessionKey);
if (!parsed) return [];
const matchingRuns = params.runs
.filter((entry) => {
if (!parsed.runSessionId) return true;
return entry.sessionId === parsed.runSessionId || entry.sessionKey === `${params.sessionKey}`;
})
.sort((a, b) => {
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
return left - right;
});
const messages: CronSessionFallbackMessage[] = [];
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
const taskName = params.job?.name?.trim()
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|| '';
const firstRelevantTimestamp = matchingRuns.length > 0
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
if (matchingRuns.length === 0) {
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: messages.length;
return messages.slice(-limit);
}
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
return null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: CronJobDelivery['mode'] = 'none',
): CronJobDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') return { mode: fallbackMode };
const delivery = rawDelivery as JsonRecord;
const mode = delivery.mode === 'announce' ? 'announce' : fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim() ? delivery.to.trim() : undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) return { mode: 'none' };
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronSchedule(schedule: GatewayCronJob['schedule']): CronJob['schedule'] {
if (schedule.kind === 'at' && typeof schedule.at === 'string') {
return { kind: 'at', at: schedule.at };
}
if (schedule.kind === 'every' && typeof schedule.everyMs === 'number') {
return {
kind: 'every',
everyMs: schedule.everyMs,
...(typeof (schedule as CronSchedule & { anchorMs?: unknown }).anchorMs === 'number'
? { anchorMs: (schedule as CronSchedule & { anchorMs: number }).anchorMs }
: {}),
};
}
if (schedule.kind === 'cron' && typeof schedule.expr === 'string') {
return { kind: 'cron', expr: schedule.expr, ...(schedule.tz ? { tz: schedule.tz } : {}) };
}
return typeof schedule.expr === 'string' ? schedule.expr : '';
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') return {};
const delivery = rawDelivery as JsonRecord;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() ? delivery.mode.trim() : 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) patch.to = typeof delivery.to === 'string' ? delivery.to : '';
if ('accountId' in delivery) patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
return patch;
}
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
if (typeof patch.schedule === 'string') patch.schedule = { kind: 'cron', expr: patch.schedule };
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if ('delivery' in patch) patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
if ('agentId' in patch) {
patch.agentId = typeof patch.agentId === 'string' && patch.agentId.trim() ? patch.agentId.trim() : 'main';
}
return patch;
}
function transformCronJob(job: GatewayCronJob): CronJob {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType ? { ...gatewayDelivery, channel: channelType } : gatewayDelivery;
const target = channelType
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel || channelType,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : undefined;
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
return {
id: job.id,
name: job.name,
message,
schedule: normalizeCronSchedule(job.schedule),
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
agentId,
};
}
async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
let jobs: GatewayCronJob[] = [];
let usedFallback = false;
try {
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000);
const data = result as { jobs?: GatewayCronJob[] };
jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []);
} catch {
try {
const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json');
const raw = await readFile(cronJsonPath, 'utf-8');
const parsed = JSON.parse(raw);
jobs = (Array.isArray(parsed) ? parsed : (parsed?.jobs ?? [])) as GatewayCronJob[];
usedFallback = true;
} catch {
// No fallback data available.
}
}
if (!usedFallback && jobs.length > 0) {
repairCronJobsInBackground(gatewayManager, jobs);
}
return jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) }));
}
function repairCronJobsInBackground(gatewayManager: GatewayManager, jobs: GatewayCronJob[]): void {
const jobsToRepairDelivery = jobs.filter((job) => {
const isIsolatedAgent = (job.sessionTarget === 'isolated' || !job.sessionTarget)
&& job.payload?.kind === 'agentTurn';
return isIsolatedAgent && job.delivery?.mode === 'announce' && !job.delivery?.channel;
});
if (jobsToRepairDelivery.length > 0) {
void (async () => {
for (const job of jobsToRepairDelivery) {
try {
await gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
} catch {
// ignore per-job repair failure
}
}
})();
for (const job of jobsToRepairDelivery) {
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
}
}
const jobsToRepairAgent = jobs.filter((job) => {
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
return (
(job.sessionTarget === 'isolated' || !job.sessionTarget)
&& job.payload?.kind === 'agentTurn'
&& job.delivery?.mode === 'announce'
&& job.delivery?.channel
&& jobAgentId === undefined
);
});
if (jobsToRepairAgent.length > 0) {
void (async () => {
for (const job of jobsToRepairAgent) {
try {
const channel = toOpenClawChannelType(job.delivery!.channel!);
const accountId = job.delivery!.accountId;
const toAddress = job.delivery!.to;
let correctAgentId = await resolveAgentIdFromChannel(channel, accountId);
let resolvedAccountId: string | null = null;
if (!correctAgentId && !accountId && toAddress) {
resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel);
if (resolvedAccountId) {
correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId);
}
}
if (correctAgentId) {
const patch: Record<string, unknown> = { agentId: correctAgentId };
if (resolvedAccountId && !accountId) patch.delivery = { accountId: resolvedAccountId };
await gatewayManager.rpc('cron.update', { id: job.id, patch });
(job as unknown as { agentId: string }).agentId = correctAgentId;
if (resolvedAccountId && !accountId && job.delivery) job.delivery.accountId = resolvedAccountId;
}
} catch {
// ignore per-job repair failure
}
}
})();
}
}
function getId(payload: unknown): string {
const body = isRecord(payload) ? payload : {};
const id = body.id;
if (typeof id !== 'string' || !id.trim()) throw new Error('id is required');
return id.trim();
}
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
return {
list: async () => listCronJobs(gatewayManager),
create: async (payload) => {
const input = payload;
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: typeof input.enabled === 'boolean' ? input.enabled : true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
if (!result || typeof result !== 'object') {
throw new Error('Cron create returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
},
update: async (payload) => {
const body = payload;
const id = getId(body);
const input = isRecord(body.input) ? body.input : {};
const patch = buildCronUpdatePatch(input);
delete patch.id;
delete patch.input;
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
if (!result || typeof result !== 'object') {
throw new Error('Cron update returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
},
delete: async (payload) => gatewayManager.rpc('cron.remove', { id: getId(payload) }),
toggle: async (payload) => {
const body = payload;
return gatewayManager.rpc('cron.update', {
id: getId(body),
patch: { enabled: body.enabled === true },
});
},
trigger: async (payload) => gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }),
sessionHistory: async (payload) => {
const body = payload;
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
const parsedSession = parseCronSessionKey(sessionKey);
if (!parsedSession) return { success: false, error: `Invalid cron sessionKey: ${sessionKey}` };
const rawLimit = typeof body.limit === 'number' ? body.limit : Number(body.limit || 200);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 200;
const [jobsResult, runs, sessionEntry] = await Promise.all([
gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
return {
messages: buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
} : undefined,
limit,
}),
};
},
deliveryTargets: async () => ({ success: true, targets: [] }),
};
}
@@ -1,15 +1,18 @@
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import type { IncomingMessage, ServerResponse } from 'http';
import { logger } from '../../utils/logger';
import { getOpenClawConfigDir } from '../../utils/paths';
import { buildGatewayHealthSummary } from '../../utils/gateway-health';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import { logger } from '../utils/logger';
import { getOpenClawConfigDir } from '../utils/paths';
import { buildGatewayHealthSummary } from '../utils/gateway-health';
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels-api';
const DEFAULT_TAIL_LINES = 200;
type DiagnosticsApiContext = {
gatewayManager: GatewayManager;
};
async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promise<string> {
const safeTailLines = Math.max(1, Math.floor(tailLines));
try {
@@ -42,17 +45,9 @@ async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promi
}
}
export async function handleDiagnosticsRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (
(url.pathname === '/api/diagnostics/gateway-snapshot' || url.pathname === '/api/gateway/diagnostics')
&& req.method === 'GET'
) {
try {
export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostServiceRegistry['diagnostics'] {
return {
gatewaySnapshot: async () => {
const { channels } = await buildChannelAccountsView(ctx, { probe: false });
const diagnostics = ctx.gatewayManager.getDiagnostics?.() ?? {
consecutiveHeartbeatMisses: 0,
@@ -74,7 +69,7 @@ export async function handleDiagnosticsRoutes(
: undefined,
};
const openClawDir = getOpenClawConfigDir();
sendJson(res, 200, {
return {
capturedAt: Date.now(),
platform: process.platform,
gateway,
@@ -82,12 +77,7 @@ export async function handleDiagnosticsRoutes(
clawxLogTail: await logger.readLogFile(DEFAULT_TAIL_LINES),
gatewayLogTail: await readTail(join(openClawDir, 'logs', 'gateway.log')),
gatewayErrLogTail: await readTail(join(openClawDir, 'logs', 'gateway.err.log')),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
};
},
};
}
+9
View File
@@ -0,0 +1,9 @@
import { dialog, type MessageBoxOptions, type OpenDialogOptions } from 'electron';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
export function createDialogApi(): CompleteHostServiceRegistry['dialog'] {
return {
open: (payload) => dialog.showOpenDialog(payload as OpenDialogOptions),
message: (payload) => dialog.showMessageBox(payload as MessageBoxOptions),
};
}
+485
View File
@@ -0,0 +1,485 @@
import { app, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { homedir } from 'node:os';
import { basename, extname, join, relative, resolve, sep } from 'node:path';
import type {
FilePreviewTreeNode,
FilePreviewTreeOptions,
FileReadBinaryOptions,
} from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { expandPath } from '../utils/paths';
import { isRecord } from './payload-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
};
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
const DIRECTORY_MIME_TYPE = 'application/x-directory';
const FILE_PREVIEW_MAX_TEXT_BYTES = 2 * 1024 * 1024;
const FILE_PREVIEW_MAX_BINARY_BYTES = 50 * 1024 * 1024;
const FILE_PREVIEW_TREE_MAX_DEPTH = 6;
const FILE_PREVIEW_TREE_MAX_NODES = 5000;
const FILE_PREVIEW_DIR_BLACKLIST = new Set([
'node_modules',
'.venv',
'__pycache__',
'.git',
'dist',
'build',
'.next',
'.turbo',
'.cache',
]);
type StagePathsPayload = {
filePaths?: unknown;
};
type StageBufferPayload = {
base64?: unknown;
fileName?: unknown;
mimeType?: unknown;
};
type PathPayload = {
path?: unknown;
content?: unknown;
opts?: unknown;
};
type ResolvedSandboxedPath = {
realPath: string;
readOnly: boolean;
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const { readFile } = await import('node:fs/promises');
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
function requirePath(payload: unknown): string {
const path = isRecord(payload) ? payload.path : payload;
if (typeof path !== 'string' || !path.trim()) {
throw new Error('Invalid file path');
}
return path;
}
function isPathInside(child: string, parent: string): boolean {
const c = resolve(child);
const p = resolve(parent);
if (process.platform === 'win32') {
const cl = c.toLowerCase();
const pl = p.toLowerCase();
return cl === pl || cl.startsWith(pl + sep);
}
return c === p || c.startsWith(p + sep);
}
function getFilePreviewWriteRoots(): string[] {
const roots: string[] = [];
roots.push(resolve(join(homedir(), '.openclaw')));
try {
roots.push(resolve(app.getPath('userData')));
} catch {
// ignore
}
roots.push(resolve(OUTBOUND_DIR));
return roots;
}
async function resolveSandboxedPath(
input: string,
mode: 'read' | 'write' = 'read',
): Promise<ResolvedSandboxedPath> {
if (!input.trim()) {
throw new Error('outsideSandbox');
}
const expanded = expandPath(input);
const fsP = await import('node:fs/promises');
let real: string;
try {
real = await fsP.realpath(expanded);
} catch {
real = resolve(expanded);
}
const writeRoots = getFilePreviewWriteRoots();
if (writeRoots.some((root) => isPathInside(real, root))) {
return { realPath: real, readOnly: false };
}
if (mode === 'write') {
throw new Error('readOnlyRoot');
}
return { realPath: real, readOnly: true };
}
function looksLikeBinary(buf: Buffer): boolean {
const limit = Math.min(buf.length, 8192);
for (let i = 0; i < limit; i += 1) {
if (buf[i] === 0) return true;
}
return false;
}
function shouldSkipDirEntry(name: string, includeHidden: boolean): boolean {
if (FILE_PREVIEW_DIR_BLACKLIST.has(name)) return true;
if (!includeHidden && name.startsWith('.')) return true;
return false;
}
function shouldSkipFileEntry(name: string, includeHidden: boolean): boolean {
if (!includeHidden && name.startsWith('.')) return true;
return false;
}
function getTreeOptions(opts: unknown): FilePreviewTreeOptions {
return isRecord(opts) ? opts as FilePreviewTreeOptions : {};
}
function getBinaryOptions(opts: unknown): FileReadBinaryOptions {
return isRecord(opts) ? opts as FileReadBinaryOptions : {};
}
export function createFilesApi(): CompleteHostServiceRegistry['files'] {
return {
stagePaths: async (payload) => {
const body = isRecord(payload) ? payload as StagePathsPayload : {};
const filePaths = Array.isArray(body.filePaths)
? body.filePaths.filter((value): value is string => typeof value === 'string')
: [];
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of filePaths) {
const id = crypto.randomUUID();
const fileName = basename(filePath);
const sourceStat = await fsP.stat(filePath);
if (sourceStat.isDirectory()) {
results.push({
id,
fileName,
mimeType: DIRECTORY_MIME_TYPE,
fileSize: 0,
stagedPath: filePath,
preview: null,
});
continue;
}
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
await fsP.copyFile(filePath, stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
return results;
},
stageBuffer: async (payload) => {
const body = isRecord(payload) ? payload as StageBufferPayload : {};
if (typeof body.base64 !== 'string' || typeof body.fileName !== 'string') {
throw new Error('Invalid staged buffer payload');
}
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const payloadMimeType = typeof body.mimeType === 'string' ? body.mimeType : '';
const ext = extname(body.fileName) || mimeToExt(payloadMimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = payloadMimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
return {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
};
},
readText: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
if (stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) return { ok: false, error: 'tooLarge', size: stat.size };
const buf = await fsP.readFile(real);
if (looksLikeBinary(buf)) return { ok: false, error: 'binary', size: stat.size };
return {
ok: true,
content: buf.toString('utf8'),
mimeType: getMimeType(extname(real)),
size: stat.size,
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
readBinary: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getBinaryOptions(body.opts);
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : undefined;
const cap = Math.max(1, Math.min(maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES));
if (stat.size > cap) return { ok: false, error: 'tooLarge', size: stat.size };
const buf = await fsP.readFile(real);
const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
return {
ok: true,
data: view,
mimeType: getMimeType(extname(real)),
size: stat.size,
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
writeText: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
if (typeof body.content !== 'string') return { ok: false, error: 'invalidContent' };
if (Buffer.byteLength(body.content, 'utf8') > FILE_PREVIEW_MAX_TEXT_BYTES) {
return { ok: false, error: 'tooLarge' };
}
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'write');
const fsP = await import('node:fs/promises');
let stat;
try {
stat = await fsP.stat(real);
} catch {
return { ok: false, error: 'notFound' };
}
if (!stat.isFile()) return { ok: false, error: 'notFound' };
await fsP.writeFile(real, body.content, 'utf8');
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message === 'readOnlyRoot') return { ok: false, error: 'readOnlyRoot' };
return { ok: false, error: message };
}
},
stat: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
return {
ok: true,
size: stat.size,
mtime: stat.mtimeMs,
isFile: stat.isFile(),
isDir: stat.isDirectory(),
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
listDir: async (payload) => {
try {
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const dirents = await fsP.readdir(real, { withFileTypes: true });
const entries = await Promise.all(dirents.map(async (entry) => {
const abs = join(real, entry.name);
let size = 0;
try {
if (entry.isFile()) size = (await fsP.stat(abs)).size;
} catch {
// non-fatal
}
return {
name: entry.name,
path: abs,
isDir: entry.isDirectory(),
size,
};
}));
return { ok: true, entries };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
listTree: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getTreeOptions(body.opts);
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' };
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? FILE_PREVIEW_TREE_MAX_DEPTH, 12));
const maxNodes = Math.max(1, Math.min(opts.maxNodes ?? FILE_PREVIEW_TREE_MAX_NODES, 50000));
const includeHidden = !!opts.includeHidden;
let nodeCount = 0;
let truncated = false;
const walk = async (absDir: string, depth: number): Promise<FilePreviewTreeNode[] | undefined> => {
if (depth > maxDepth || truncated) return undefined;
let dirents;
try {
dirents = await fsP.readdir(absDir, { withFileTypes: true });
} catch {
return [];
}
const children: FilePreviewTreeNode[] = [];
for (const entry of dirents) {
if (truncated) break;
const isDir = entry.isDirectory();
const isFile = entry.isFile();
if (!isDir && !isFile) continue;
if (isDir && shouldSkipDirEntry(entry.name, includeHidden)) continue;
if (isFile && shouldSkipFileEntry(entry.name, includeHidden)) continue;
if (nodeCount >= maxNodes) {
truncated = true;
break;
}
nodeCount += 1;
const abs = join(absDir, entry.name);
const node: FilePreviewTreeNode = {
name: entry.name,
relPath: relative(real, abs).split(sep).join('/'),
absPath: abs,
isDir,
};
if (isFile) {
try {
const fstat = await fsP.stat(abs);
node.size = fstat.size;
node.mtime = fstat.mtimeMs;
} catch {
// non-fatal
}
} else {
try {
node.mtime = (await fsP.stat(abs)).mtimeMs;
} catch {
// non-fatal
}
node.children = await walk(abs, depth + 1) ?? [];
}
children.push(node);
}
children.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return children;
};
const root: FilePreviewTreeNode = {
name: basename(real) || real,
relPath: '',
absPath: real,
isDir: true,
mtime: stat.mtimeMs,
children: (await walk(real, 1)) ?? [],
};
return { ok: true, root, truncated };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
};
}
+79
View File
@@ -0,0 +1,79 @@
import type { GatewayManager } from '../gateway/manager';
import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { PORTS } from '../utils/config';
import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing';
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { getSetting } from '../utils/store';
import { isRecord } from './payload-utils';
type HealthPayload = {
probe?: unknown;
};
type ControlUiPayload = {
view?: unknown;
};
type RpcPayload = {
method?: unknown;
params?: unknown;
timeoutMs?: unknown;
};
function parseTimeoutMs(timeoutMs: unknown): number | undefined {
if (timeoutMs === undefined) return undefined;
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error('Invalid gateway RPC timeout');
}
return timeoutMs;
}
export function createGatewayApi(
gatewayManager: GatewayManager,
gatewayRpcBackpressure: GatewayRpcBackpressure,
): CompleteHostServiceRegistry['gateway'] {
return {
status: () => gatewayManager.getStatus(),
start: async () => {
await gatewayManager.start();
return { success: true };
},
stop: async () => {
await gatewayManager.stop();
return { success: true };
},
restart: async () => {
await gatewayManager.restart();
return { success: true };
},
health: async (payload) => {
const body = isRecord(payload) ? payload as HealthPayload : {};
return gatewayManager.checkHealth({ probe: body.probe === true });
},
controlUi: async (payload) => {
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;
const url = buildOpenClawControlUiUrl(port, token, { view });
scheduleControlUiDeviceAutoApproval(gatewayManager);
return { success: true, url, token, port };
},
rpc: async (payload) => {
const body = isRecord(payload) ? payload as RpcPayload : {};
const method = typeof body.method === 'string' ? body.method.trim() : '';
if (!method) {
throw new Error('Invalid gateway RPC method');
}
const timeoutMs = parseTimeoutMs(body.timeoutMs);
return gatewayRpcBackpressure.run(
method,
body.params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
},
};
}
+87
View File
@@ -0,0 +1,87 @@
import { readFile } from 'node:fs/promises';
import { extname, relative, resolve, sep } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { logger } from '../utils/logger';
import { isRecord } from './payload-utils';
type RecentPayload = {
tailLines?: unknown;
};
type ReadFilePayload = RecentPayload & {
path?: unknown;
};
type MemoryPayload = {
count?: unknown;
};
function safePositiveInteger(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.max(1, Math.floor(value));
}
function isPathInside(parentDir: string, childPath: string): boolean {
const relativePath = relative(parentDir, childPath);
return relativePath.length > 0
&& !relativePath.startsWith('..')
&& !relativePath.includes(`..${sep}`);
}
async function validateLogFilePath(path: unknown): Promise<string> {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Invalid log file path');
}
const resolvedPath = resolve(path);
const files = await logger.listLogFiles();
if (files.some((file) => resolve(file.path) === resolvedPath)) {
return resolvedPath;
}
const logDir = logger.getLogDir();
if (!logDir) {
throw new Error('Invalid log file path');
}
const resolvedLogDir = resolve(logDir);
if (!isPathInside(resolvedLogDir, resolvedPath) || extname(resolvedPath) !== '.log') {
throw new Error('Invalid log file path');
}
return resolvedPath;
}
async function readLogFileTail(path: string, tailLines: number): Promise<string> {
const content = await readFile(path, 'utf8');
const lines = content.split('\n');
const hasTrailingNewline = lines.at(-1) === '';
if (hasTrailingNewline) {
lines.pop();
}
if (lines.length <= tailLines) return content;
const tail = lines.slice(-tailLines).join('\n');
return hasTrailingNewline ? `${tail}\n` : tail;
}
export function createLogsApi(): CompleteHostServiceRegistry['logs'] {
return {
recent: async (payload) => {
const body = isRecord(payload) ? payload as RecentPayload : {};
return { content: await logger.readLogFile(safePositiveInteger(body.tailLines, 100)) };
},
memory: (payload) => {
const body = isRecord(payload) ? payload as MemoryPayload : {};
return logger.getRecentLogs(
body.count === undefined ? undefined : safePositiveInteger(body.count, 100),
);
},
dir: () => ({ dir: logger.getLogDir() }),
filePath: () => ({ path: logger.getLogFilePath() }),
listFiles: async () => ({ files: await logger.listLogFiles() }),
readFile: async (payload) => {
const body = isRecord(payload) ? payload as ReadFilePayload : {};
const path = await validateLogFilePath(body.path);
return { content: await readLogFileTail(path, safePositiveInteger(body.tailLines, 200)) };
},
};
}
+221
View File
@@ -0,0 +1,221 @@
import { dialog, nativeImage } from 'electron';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
} from '../utils/openclaw-image-relay-constants';
import {
applyOpenAiImageRelaySettings,
getImageGenerationSettingsSnapshot,
listImageGenerationProvidersFromRuntime,
runImageGenerationTest,
setImageGenerationConfig,
type ImageGenerationModelConfig,
} from '../utils/openclaw-image-generation';
import { isRecord } from './payload-utils';
type ThumbnailEntry = {
filePath?: unknown;
gatewayUrl?: unknown;
mimeType?: unknown;
};
type SaveImagePayload = {
base64?: unknown;
mimeType?: unknown;
filePath?: unknown;
defaultFileName?: unknown;
};
type ImageGenerationSettingsPayload = {
timeoutMs?: unknown;
openAiRelayEnabled?: unknown;
openAiRelayBaseUrl?: unknown;
openAiRelayModel?: unknown;
openAiRelayApiKey?: unknown;
};
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const { readFile } = await import('node:fs/promises');
if (mimeType === 'image/svg+xml') {
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
}
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
async function resolveOutgoingMediaUrl(
gatewayUrl: string,
): Promise<{ path: string; mimeType: string } | null> {
try {
const match = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//);
if (!match) return null;
const attachmentId = decodeURIComponent(match[1]);
if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null;
const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`);
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(recordPath, 'utf8');
const record = JSON.parse(raw) as {
original?: { path?: string; contentType?: string };
};
const original = record?.original;
if (!original?.path) return null;
return {
path: original.path,
mimeType: typeof original.contentType === 'string' && original.contentType
? original.contentType
: 'application/octet-stream',
};
} catch {
return null;
}
}
function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] {
const value = isRecord(payload) ? payload.paths : payload;
return Array.isArray(value) ? value as ThumbnailEntry[] : [];
}
export function createMediaApi(): CompleteHostServiceRegistry['media'] {
return {
thumbnails: async (payload) => {
const entries = normalizeThumbnailEntries(payload);
const fsP = await import('node:fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const entry of entries) {
const mimeType = typeof entry.mimeType === 'string' ? entry.mimeType : 'application/octet-stream';
if (typeof entry.filePath === 'string' && entry.filePath) {
try {
const stat = await fsP.stat(entry.filePath);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(entry.filePath, mimeType)
: null;
results[entry.filePath] = { preview, fileSize: stat.size };
} catch {
results[entry.filePath] = { preview: null, fileSize: 0 };
}
continue;
}
if (typeof entry.gatewayUrl === 'string' && entry.gatewayUrl) {
const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl);
if (!resolved) {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
continue;
}
try {
const stat = await fsP.stat(resolved.path);
const preview = resolved.mimeType.startsWith('image/')
? await generateImagePreview(resolved.path, resolved.mimeType)
: null;
results[entry.gatewayUrl] = { preview, fileSize: stat.size };
} catch {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
}
}
}
return results;
},
saveImage: async (payload) => {
const body = isRecord(payload) ? payload as SaveImagePayload : {};
const defaultFileName = typeof body.defaultFileName === 'string' && body.defaultFileName
? body.defaultFileName
: 'image.png';
const mimeType = typeof body.mimeType === 'string' ? body.mimeType : undefined;
const ext = defaultFileName.includes('.')
? defaultFileName.split('.').pop()!
: (mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) return { success: false };
const fsP = await import('node:fs/promises');
if (typeof body.filePath === 'string' && body.filePath) {
try {
await fsP.access(body.filePath);
await fsP.copyFile(body.filePath, result.filePath);
} catch {
return { success: false, error: 'Source file not found' };
}
} else if (typeof body.base64 === 'string' && body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
return { success: false, error: 'No image data provided' };
}
return { success: true, savedPath: result.filePath };
},
imageGenerationSettings: async () => ({
success: true,
...(await getImageGenerationSettingsSnapshot()),
}),
saveImageGenerationSettings: async (payload) => {
const body = isRecord(payload) ? payload as ImageGenerationSettingsPayload : {};
const current = await getImageGenerationSettingsSnapshot();
const normalizeRelayModel = (value: unknown): string => {
const raw = typeof value === 'string' && value.trim()
? value.trim()
: (current.openAiRelay.model || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
const slash = raw.indexOf('/');
return (slash > 0 ? raw.slice(slash + 1) : raw).trim() || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL;
};
const relayModel = normalizeRelayModel(body.openAiRelayModel);
let nextPrimary = current.config.primary;
if (body.openAiRelayEnabled === true) {
nextPrimary = `${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/${relayModel}`;
} else if (body.openAiRelayEnabled === false) {
nextPrimary = null;
}
const next: ImageGenerationModelConfig = {
primary: nextPrimary,
fallbacks: [],
timeoutMs: body.timeoutMs !== undefined
? (typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? Math.floor(body.timeoutMs) : null)
: current.config.timeoutMs,
};
if (typeof body.openAiRelayEnabled === 'boolean') {
await applyOpenAiImageRelaySettings({
enabled: body.openAiRelayEnabled,
baseUrl: typeof body.openAiRelayBaseUrl === 'string' ? body.openAiRelayBaseUrl : null,
apiKey: typeof body.openAiRelayApiKey === 'string' ? body.openAiRelayApiKey : undefined,
model: relayModel,
});
}
const config = await setImageGenerationConfig(next);
return {
success: true,
...(await getImageGenerationSettingsSnapshot()),
config,
};
},
imageGenerationProviders: async () => ({
success: true,
providers: await listImageGenerationProvidersFromRuntime(),
}),
testImageGeneration: async (payload) => runImageGenerationTest(isRecord(payload) ? payload : {}),
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { getOpenClawCliCommand } from '../utils/openclaw-cli';
import { ensureDir, getOpenClawSkillsDir, getOpenClawStatus } from '../utils/paths';
import { existsSync } from 'node:fs';
export function createOpenClawApi(): CompleteHostServiceRegistry['openclaw'] {
return {
status: () => getOpenClawStatus(),
getSkillsDir: () => {
const dir = getOpenClawSkillsDir();
ensureDir(dir);
return dir;
},
getCliCommand: () => {
const status = getOpenClawStatus();
if (!status.packageExists) {
return { success: false, error: `OpenClaw package not found at: ${status.dir}` };
}
if (!existsSync(status.entryPath)) {
return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` };
}
return { success: true, command: getOpenClawCliCommand() };
},
};
}
+5
View File
@@ -0,0 +1,5 @@
export type UnknownRecord = Record<string, unknown>;
export function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+477
View File
@@ -0,0 +1,477 @@
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 { ProviderConfig } from '../utils/secure-storage';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth';
import { removeProviderFromOpenClaw, saveProviderKeyToOpenClaw } from '../utils/openclaw-auth';
import { getProviderConfig } from '../utils/provider-registry';
import { logger } from '../utils/logger';
import { getProviderService } from './providers/provider-service';
import { providerAccountToConfig } from './providers/provider-store';
import {
getOpenClawProviderKey,
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from './providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from './providers/provider-validation';
import type { ProviderAccount } from '../shared/providers/types';
import { isRecord } from './payload-utils';
type ProvidersApiContext = {
gatewayManager: GatewayManager;
mainWindow: BrowserWindow;
};
type ProviderPayload<Action extends keyof HostApiContract['providers']> =
Parameters<HostApiContract['providers'][Action]>[0];
type ValidationOptions = {
baseUrl?: string;
apiProtocol?: string;
};
function hasObjectChanges<T extends Record<string, unknown>>(
existing: T,
patch: Partial<T> | undefined,
): boolean {
if (!patch) return false;
const keys = Object.keys(patch) as Array<keyof T>;
if (keys.length === 0) return false;
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
}
function payloadString(payload: unknown, key: string): string | undefined {
if (typeof payload === 'string') return payload;
if (!isRecord(payload)) return undefined;
const value = payload[key];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function requireString(payload: unknown, key: string, action: string): string {
const value = payloadString(payload, key);
if (!value) {
throw new Error(`Invalid providers.${action} payload`);
}
return value;
}
function getPayloadRecord(payload: unknown, action: string): Record<string, unknown> {
if (!isRecord(payload)) {
throw new Error(`Invalid providers.${action} payload`);
}
return payload;
}
function getProviderId(payload: unknown, action: string): string {
if (Array.isArray(payload)) {
const [providerId] = payload;
if (typeof providerId === 'string' && providerId.trim()) return providerId.trim();
}
return requireString(payload, 'providerId', action);
}
function getAccountId(payload: unknown, action: string): string {
return requireString(payload, 'accountId', action);
}
function getApiKeyPayload(payload: unknown, action: string): { providerId: string; apiKey: string } {
if (Array.isArray(payload)) {
const [providerId, apiKey] = payload;
if (typeof providerId === 'string' && providerId.trim() && typeof apiKey === 'string') {
return { providerId: providerId.trim(), apiKey };
}
}
const record = getPayloadRecord(payload, action);
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
if (!providerId || typeof record.apiKey !== 'string') {
throw new Error(`Invalid providers.${action} payload`);
}
return { providerId, apiKey: record.apiKey };
}
function getProviderUpdatePayload(payload: unknown): {
providerId: string;
updates: Partial<ProviderConfig>;
apiKey?: string;
} {
if (Array.isArray(payload)) {
const [providerId, updates, apiKey] = payload;
if (typeof providerId === 'string' && providerId.trim() && isRecord(updates)) {
return { providerId: providerId.trim(), updates: updates as Partial<ProviderConfig>, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
}
}
const record = getPayloadRecord(payload, 'updateWithKey');
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
if (!providerId || !isRecord(record.updates)) {
throw new Error('Invalid providers.updateWithKey payload');
}
return {
providerId,
updates: record.updates as Partial<ProviderConfig>,
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
};
}
function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: string } {
if (Array.isArray(payload)) {
const [config, apiKey] = payload;
if (isRecord(config)) {
return { config: config as unknown as ProviderConfig, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
}
}
const record = getPayloadRecord(payload, 'save');
if (!isRecord(record.config)) {
throw new Error('Invalid providers.save payload');
}
return {
config: record.config as unknown as ProviderConfig,
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
};
}
async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> {
try {
const body = getPayloadRecord(payload, 'validateKey');
const accountId = typeof body.accountId === 'string' && body.accountId.trim()
? body.accountId.trim()
: undefined;
const vendorId = typeof body.vendorId === 'string' && body.vendorId.trim()
? body.vendorId.trim()
: undefined;
const providerId = typeof body.providerId === 'string' && body.providerId.trim()
? body.providerId.trim()
: undefined;
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
if (!apiKey) {
return { valid: false, error: 'Invalid providers.validateKey payload' };
}
const providerService = getProviderService();
const lookupId = accountId || vendorId || providerId || '';
const account = lookupId ? await providerService.getAccount(lookupId) : null;
const legacyProvider = !account && providerId ? await providerService._getProviderInternal(providerId) : null;
const providerType = account?.vendorId || legacyProvider?.type || vendorId || providerId || lookupId;
if (!providerType) {
return { valid: false, error: 'Invalid providers.validateKey payload' };
}
const options = isRecord(body.options) ? body.options as ValidationOptions : undefined;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = options?.baseUrl || account?.baseUrl || legacyProvider?.baseUrl || registryBaseUrl;
const resolvedProtocol = options?.apiProtocol || account?.apiProtocol || legacyProvider?.apiProtocol;
return await validateApiKeyWithProvider(providerType, apiKey, {
baseUrl: resolvedBaseUrl,
apiProtocol: resolvedProtocol,
});
} catch (error) {
return { valid: false, error: String(error) };
}
}
async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const { config, apiKey } = getSavePayload(payload);
try {
await providerService._saveProviderInternal(config);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'delete');
try {
const existing = await providerService._getProviderInternal(providerId);
await providerService._deleteProviderInternal(providerId);
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>) {
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);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const { providerId, updates, apiKey } = getProviderUpdatePayload(payload);
const existing = await providerService._getProviderInternal(providerId);
if (!existing) {
return { success: false, error: 'Provider not found' };
}
const previousKey = await providerService._getProviderApiKeyInternal(providerId);
const previousOck = getOpenClawProviderKey(existing.type, providerId);
try {
const nextConfig: ProviderConfig = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
await providerService._saveProviderInternal(nextConfig);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(ock);
}
}
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
return { success: true };
} catch (error) {
try {
await providerService._saveProviderInternal(existing);
if (previousKey) {
await providerService._setProviderApiKeyInternal(providerId, previousKey);
await saveProviderKeyToOpenClaw(previousOck, previousKey);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(previousOck);
}
} catch (rollbackError) {
logger.warn('Failed to rollback provider updateWithKey:', rollbackError);
}
return { success: false, error: String(error) };
}
}
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'deleteApiKey');
try {
await providerService._deleteProviderApiKeyInternal(providerId);
const provider = await providerService._getProviderInternal(providerId);
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'setDefault');
try {
await providerService._setDefaultProviderInternal(providerId);
await syncDefaultProviderToRuntime(providerId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'createAccount');
if (!isRecord(body.account)) {
throw new Error('Invalid providers.createAccount payload');
}
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);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'updateAccount');
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
const updates = isRecord(body.updates) ? body.updates as Partial<ProviderAccount> : undefined;
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
if (!accountId || !updates) {
throw new Error('Invalid providers.updateAccount payload');
}
try {
const existing = await providerService.getAccount(accountId);
if (!existing) {
return { success: false, error: 'Provider account not found' };
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, updates as Record<string, unknown>);
if (!hasPatchChanges && apiKey === undefined) {
return { success: true, noChange: true, account: existing };
}
const account = await providerService.updateAccount(accountId, updates, apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function deleteAccount(
payload: ProviderPayload<'deleteAccount'> & { apiKeyOnly?: boolean },
gatewayManager?: GatewayManager,
) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'deleteAccount');
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
const apiKeyOnly = body.apiKeyOnly === true;
if (!accountId) {
throw new Error('Invalid providers.deleteAccount payload');
}
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai'
? 'openai-codex'
: undefined;
if (apiKeyOnly) {
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
return { success: true };
}
await providerService.deleteAccount(accountId);
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
gatewayManager,
runtimeProviderKey,
);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const accountId = getAccountId(payload, 'setDefaultAccount');
try {
const currentDefault = await providerService.getDefaultAccountId();
if (currentDefault === accountId) {
return { success: true, noChange: true };
}
await providerService.setDefaultAccount(accountId);
await syncDefaultProviderToRuntime(accountId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function requestOAuth(payload: ProviderPayload<'requestOAuth'>) {
const body = getPayloadRecord(payload, 'requestOAuth');
const provider = typeof body.provider === 'string' ? body.provider : undefined;
if (!provider) {
return { success: false, error: 'Invalid providers.requestOAuth payload' };
}
const region = body.region === 'global' || body.region === 'cn' ? body.region : undefined;
const options = {
accountId: typeof body.accountId === 'string' ? body.accountId : undefined,
label: typeof body.label === 'string' ? body.label : undefined,
};
try {
if (provider === 'openai') {
await browserOAuthManager.startFlow(provider as BrowserOAuthProviderType, options);
} else {
await deviceOAuthManager.startFlow(provider as OAuthProviderType, region, options);
}
return { success: true };
} catch (error) {
logger.error('providers.requestOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function cancelOAuth() {
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
return { success: true };
} catch (error) {
logger.error('providers.cancelOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function submitOAuth(payload: ProviderPayload<'submitOAuth'>) {
const body = getPayloadRecord(payload, 'submitOAuth');
const code = typeof body.code === 'string' ? body.code : '';
try {
const accepted = browserOAuthManager.submitManualCode(code);
if (!accepted) {
return { success: false, error: 'No active manual OAuth input pending' };
}
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServiceRegistry['providers'] {
const providerService = getProviderService();
deviceOAuthManager.setWindow(ctx.mainWindow);
browserOAuthManager.setWindow(ctx.mainWindow);
return {
list: async () => providerService._listProvidersWithKeyInfoInternal(),
get: async (payload) => providerService._getProviderInternal(getProviderId(payload, 'get')),
getDefault: async () => providerService._getDefaultProviderInternal(),
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),
accounts: async () => providerService.listAccounts(),
vendors: async () => providerService.listVendors(),
accountKeyInfo: async () => providerService.listAccountsKeyInfo(),
getDefaultAccount: async () => ({ accountId: await providerService.getDefaultAccountId() ?? null }),
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),
requestOAuth,
cancelOAuth,
submitOAuth,
};
}
@@ -1,15 +1,15 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { openSync, closeSync, fstatSync, readSync } from 'node:fs';
import { join } from 'node:path';
import { getOpenClawConfigDir } from '../../utils/paths';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RawMessage } from '@shared/chat/types';
import { getOpenClawConfigDir } from '../utils/paths';
import { logger } from '../utils/logger';
import {
removeSessionEntry,
resolveSessionTranscriptPath,
sweepSessionArtefacts,
} from '../../utils/session-files';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
} from '../utils/session-files';
import { isRecord } from './payload-utils';
const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const RECENT_TRANSCRIPT_INITIAL_READ_BYTES = 256 * 1024;
@@ -22,17 +22,24 @@ type SessionSummary = {
lastTimestamp: number | null;
};
type TranscriptMessage = {
role?: unknown;
content?: unknown;
timestamp?: unknown;
};
type TranscriptMessage = RawMessage;
type ParsedTranscriptLine = {
type?: string;
message?: TranscriptMessage;
};
type SessionPayload = {
id?: unknown;
sessionKey?: unknown;
label?: unknown;
title?: unknown;
agentId?: unknown;
sessionId?: unknown;
limit?: unknown;
sessionKeys?: unknown;
};
function extractMessageText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
@@ -185,6 +192,21 @@ function parseSessionKey(sessionKey: string): { agentId: string; suffix: string
return { agentId, suffix };
}
function getSessionKey(payload: unknown): string {
const body = isRecord(payload) ? payload as SessionPayload : {};
const value = body.sessionKey ?? body.id ?? payload;
if (typeof value !== 'string' || !value.startsWith('agent:')) {
throw new Error(`Invalid sessionKey: ${String(value)}`);
}
return value;
}
function getLimit(payload: unknown, fallback = 200): number {
const value = isRecord(payload) ? (payload as SessionPayload).limit : undefined;
const limitRaw = typeof value === 'number' ? value : fallback;
return Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 1000) : fallback;
}
async function readSessionsJson(agentId: string): Promise<Record<string, unknown>> {
const fsP = await import('node:fs/promises');
const sessionsJsonPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
@@ -263,7 +285,7 @@ async function loadSessionSummary(sessionKey: string): Promise<SessionSummary> {
}
}
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<unknown[] | null> {
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
@@ -279,194 +301,167 @@ async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Pr
}
}
export async function handleSessionRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/sessions/summaries' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKeys?: string[] }>(req);
async function deleteSession(sessionKey: string): Promise<{ success: boolean; error?: string }> {
if (!sessionKey || !sessionKey.startsWith('agent:')) {
return { success: false, error: `Invalid sessionKey: ${sessionKey}` };
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
return { success: false, error: `sessionKey has too few parts: ${sessionKey}` };
}
const agentId = parts[1];
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
return { success: false, error: `Invalid agentId: ${agentId}` };
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`);
logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`);
const fsP = await import('node:fs/promises');
let sessionsJson: Record<string, unknown>;
try {
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
sessionsJson = JSON.parse(raw) as Record<string, unknown>;
} catch (error) {
logger.warn(`[session:delete] Could not read sessions.json: ${String(error)}`);
return { success: false, error: `Could not read sessions.json: ${String(error)}` };
}
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
if (!resolution.ok) {
if (resolution.failure.kind === 'not-found') {
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(sessionsJson[sessionKey])}`);
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
}
logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
return {
success: false,
error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}`,
};
}
const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution;
logger.info(`[session:delete] file: ${resolvedSrcPath}`);
const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId);
for (const removedPath of sweep.removed) {
logger.info(`[session:delete] Unlinked ${removedPath}`);
}
for (const { path: failedPath, error } of sweep.errors) {
logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`);
}
logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`);
try {
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`);
} catch (error) {
logger.warn(`[session:delete] Could not update sessions.json: ${String(error)}`);
}
return { success: true };
}
async function renameSession(sessionKey: string, label: string): Promise<{ success: boolean; error?: string }> {
if (!sessionKey || !sessionKey.startsWith('agent:')) {
return { success: false, error: `Invalid sessionKey: ${sessionKey}` };
}
if (!label || typeof label !== 'string' || !label.trim()) {
return { success: false, error: 'Label cannot be empty' };
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
return { success: false, error: `Malformed sessionKey: ${sessionKey}` };
}
const agentId = parts[1];
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` };
}
const sessionsJsonPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const json = JSON.parse(raw) as Record<string, unknown>;
const trimmedLabel = label.trim();
let found = false;
if (json[sessionKey] && typeof json[sessionKey] === 'object') {
(json[sessionKey] as Record<string, unknown>).label = trimmedLabel;
found = true;
}
if (Array.isArray(json.sessions)) {
for (const entry of json.sessions as Array<Record<string, unknown>>) {
if (entry.key === sessionKey || entry.sessionKey === sessionKey) {
entry.label = trimmedLabel;
found = true;
}
}
}
if (!found) {
return { success: false, error: `Session not found in sessions.json: ${sessionKey}` };
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json, null, 2), 'utf8');
logger.info(`[session:rename] key=${sessionKey} label=${trimmedLabel}`);
return { success: true };
}
export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
return {
delete: async (payload) => deleteSession(getSessionKey(payload)),
rename: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKey = getSessionKey(payload);
const label = body.label ?? body.title;
if (typeof label !== 'string') {
throw new Error('Label cannot be empty');
}
return renameSession(sessionKey, label);
},
summaries: async (payload) => {
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:'))
: [];
if (sessionKeys.length === 0) {
sendJson(res, 200, { success: true, summaries: [] });
return true;
if (sessionKeys.length === 0) return { success: true, summaries: [] };
return {
success: true,
summaries: await Promise.all(sessionKeys.map((sessionKey) => loadSessionSummary(sessionKey))),
};
},
history: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const limit = getLimit(payload);
if (typeof body.sessionKey === 'string' && body.sessionKey.trim()) {
const messages = await loadSessionTranscriptByKey(body.sessionKey.trim(), limit);
if (!messages) return { success: false, error: 'Transcript not found' };
return { success: true, messages };
}
const summaries = await Promise.all(sessionKeys.map((sessionKey) => loadSessionSummary(sessionKey)));
sendJson(res, 200, { success: true, summaries });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/sessions/transcript' && req.method === 'GET') {
try {
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
const limitRaw = Number(url.searchParams.get('limit') ?? '200');
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 1000) : 200;
if (sessionKey) {
const messages = await loadSessionTranscriptByKey(sessionKey, limit);
if (!messages) {
sendJson(res, 404, { success: false, error: 'Transcript not found' });
return true;
}
sendJson(res, 200, { success: true, messages });
return true;
}
const agentId = url.searchParams.get('agentId')?.trim() || '';
const sessionId = url.searchParams.get('sessionId')?.trim() || '';
const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
if (!agentId || !sessionId) {
sendJson(res, 400, { success: false, error: 'agentId and sessionId are required' });
return true;
return { success: false, error: 'agentId and sessionId are required' };
}
if (!SAFE_SESSION_SEGMENT.test(agentId) || !SAFE_SESSION_SEGMENT.test(sessionId)) {
sendJson(res, 400, { success: false, error: 'Invalid transcript identifier' });
return true;
return { success: false, error: 'Invalid transcript identifier' };
}
const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
const messages = readRecentTranscriptMessages(transcriptPath, limit);
sendJson(res, 200, { success: true, messages });
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
sendJson(res, 404, { success: false, error: 'Transcript not found' });
} else {
sendJson(res, 500, { success: false, error: 'Failed to load transcript' });
}
}
return true;
}
// POST /api/sessions/delete — HTTP mirror of the `session:delete` IPC.
// Both surfaces share electron/utils/session-files.ts so they sweep the
// same set of artefacts: the live transcript, legacy `.deleted.jsonl`,
// `.jsonl.reset.*` snapshots, the trajectory sidecar pair
// (`<id>.trajectory.jsonl` + `<id>.trajectory-path.json`) and — when the
// pointer points outside sessions/ (the OPENCLAW_TRAJECTORY_DIR case) —
// the off-disk runtime trajectory it references.
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
const sessionKey = body.sessionKey;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
// Defence-in-depth: agentId becomes a path segment under
// ~/.openclaw/agents/. The sibling /api/sessions/transcript route
// applies the same check to its sessionId; mirror it here so a
// malformed key can never steer the unlink loop into another folder.
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` });
return true;
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
if (!resolution.ok) {
if (resolution.failure.kind === 'not-found') {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
} else {
logger.warn(`[api/sessions/delete] Refusing out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
sendJson(res, 400, { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` });
try {
const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
return { success: true, messages: readRecentTranscriptMessages(transcriptPath, limit) };
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
return { success: false, error: 'Transcript not found' };
}
return true;
return { success: false, error: 'Failed to load transcript' };
}
const sweep = await sweepSessionArtefacts(resolution.sessionsDirAbs, resolution.baseId);
for (const { path: failedPath, error } of sweep.errors) {
logger.warn(`[api/sessions/delete] Failed to unlink ${failedPath}: ${String(error)}`);
}
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
// POST /api/sessions/rename — update session label in sessions.json.
if (url.pathname === '/api/sessions/rename' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string; label: string }>(req);
const { sessionKey, label } = body;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
if (!label || typeof label !== 'string' || !label.trim()) {
sendJson(res, 400, { success: false, error: 'Label cannot be empty' });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` });
return true;
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
const trimmedLabel = label.trim();
let found = false;
// Object-keyed format
if (sessionsJson[sessionKey] && typeof sessionsJson[sessionKey] === 'object') {
(sessionsJson[sessionKey] as Record<string, unknown>).label = trimmedLabel;
found = true;
}
// Array format
if (Array.isArray(sessionsJson.sessions)) {
for (const entry of sessionsJson.sessions as Array<Record<string, unknown>>) {
if (entry.key === sessionKey || entry.sessionKey === sessionKey) {
entry.label = trimmedLabel;
found = true;
}
}
}
if (!found) {
sendJson(res, 404, { success: false, error: `Session not found: ${sessionKey}` });
return true;
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(sessionsJson, null, 2), 'utf8');
logger.info(`[api/sessions/rename] key=${sessionKey} label=${trimmedLabel}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
},
};
}
+133
View File
@@ -0,0 +1,133 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup';
import { createMenu } from '../main/menu';
import { applyProxySettings } from '../main/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import {
type AppSettings,
getAllSettings,
getSetting,
resetSettings,
setSetting,
} from '../utils/store';
import { isRecord } from './payload-utils';
type KeyPayload = {
key?: unknown;
};
type SetPayload = KeyPayload & {
value?: unknown;
};
type SetManyPayload = {
patch?: unknown;
};
const PROXY_SETTING_KEYS = new Set<keyof AppSettings>([
'proxyEnabled',
'proxyServer',
'proxyHttpServer',
'proxyHttpsServer',
'proxyAllServer',
'proxyBypassRules',
]);
async function validateSettingKey(key: unknown): Promise<boolean> {
if (typeof key !== 'string' || key.length === 0) return false;
const settings = await getAllSettings();
return Object.prototype.hasOwnProperty.call(settings, key);
}
async function requireSettingKey(payload: unknown): Promise<keyof AppSettings> {
const key = (payload as KeyPayload | undefined)?.key;
if (!await validateSettingKey(key)) {
throw new Error('Invalid settings key');
}
return key as keyof AppSettings;
}
async function requireSettingsPatch(payload: unknown): Promise<Partial<AppSettings>> {
const patch = (payload as SetManyPayload | undefined)?.patch;
if (!isRecord(patch)) {
throw new Error('Invalid settings patch');
}
const entries = Object.entries(patch);
for (const [key] of entries) {
if (!await validateSettingKey(key)) {
throw new Error('Invalid settings key');
}
}
return Object.fromEntries(entries) as Partial<AppSettings>;
}
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
return Object.keys(patch).some((key) => PROXY_SETTING_KEYS.has(key as keyof AppSettings));
}
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
}
function patchTouchesLanguage(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'language');
}
async function handleProxySettingsChange(gatewayManager: GatewayManager): Promise<void> {
const settings = await getAllSettings();
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
await applyProxySettings(settings);
if (gatewayManager.getStatus().state === 'running') {
await gatewayManager.restart();
}
}
async function runSettingsSideEffects(
gatewayManager: GatewayManager,
patch: Partial<AppSettings>,
): Promise<void> {
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(gatewayManager);
}
if (patchTouchesLaunchAtStartup(patch)) {
await syncLaunchAtStartupSettingFromStore();
}
if (patchTouchesLanguage(patch)) {
await createMenu(typeof patch.language === 'string' ? patch.language : undefined);
}
}
export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['settings'] {
return {
getAll: () => getAllSettings(),
get: async (payload) => {
const key = await requireSettingKey(payload);
return getSetting(key as never);
},
set: async (payload) => {
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>);
return { success: true };
},
setMany: async (payload) => {
const patch = await requireSettingsPatch(payload);
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value as never);
}
await runSettingsSideEffects(gatewayManager, patch);
return { success: true };
},
reset: async () => {
await resetSettings();
await handleProxySettingsChange(gatewayManager);
await syncLaunchAtStartupSettingFromStore();
const settings = await getAllSettings();
await createMenu(settings.language);
return { success: true, settings };
},
};
}
+38
View File
@@ -0,0 +1,38 @@
import { shell } from 'electron';
import { homedir } from 'node:os';
import { join, sep } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
function expandShellPath(input: string): string {
if (input === '~') return homedir();
if (input.startsWith(`~${sep}`) || input.startsWith('~/') || input.startsWith('~\\')) {
return join(homedir(), input.slice(2));
}
return input;
}
function requirePath(path: unknown): string {
if (typeof path !== 'string' || !path.trim()) {
throw new Error('path is required');
}
return path;
}
function requireUrl(url: unknown): string {
if (typeof url !== 'string' || !url.trim()) {
throw new Error('url is required');
}
return url;
}
export function createShellApi(): CompleteHostServiceRegistry['shell'] {
return {
openExternal: async (payload) => {
await shell.openExternal(requireUrl(payload.url));
},
showItemInFolder: (payload) => {
shell.showItemInFolder(expandShellPath(requirePath(payload.path)));
},
openPath: (payload) => shell.openPath(expandShellPath(requirePath(payload.path))),
};
}
+192
View File
@@ -0,0 +1,192 @@
import type { GatewayManager } from '../gateway/manager';
import type { ClawHubService, ClawHubInstallParams, ClawHubSearchParams, ClawHubUninstallParams } from '../gateway/clawhub';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { getAllSkillConfigs, getSkillConfig, updateSkillConfig, updateSkillConfigs } from '../utils/skill-config';
import {
collectQuickAccessSkills,
filterEnabledQuickAccessSkills,
type QuickAccessRuntimeSkillStatus,
} from '../utils/skill-quick-access';
import { listLocalSkills } from './skills/local-skill-service';
import { isRecord } from './payload-utils';
type SkillConfigPayload = {
skillKey?: unknown;
enabled?: unknown;
apiKey?: unknown;
env?: unknown;
};
type SkillConfigsPayload = {
updates?: unknown;
};
type NormalizedSkillConfigUpdate = {
skillKey: string;
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
};
type QuickAccessPayload = {
workspace?: unknown;
};
type SkillOpenPayload = {
slug?: unknown;
skillKey?: unknown;
baseDir?: unknown;
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getSkillKey(payload: unknown): string {
const body = isRecord(payload) ? payload as SkillConfigPayload : {};
if (typeof body.skillKey !== 'string' || !body.skillKey.trim()) {
throw new Error('skillKey is required');
}
return body.skillKey.trim();
}
function getEnv(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
);
}
function getConfigUpdate(payload: unknown): NormalizedSkillConfigUpdate {
const body = isRecord(payload) ? payload as SkillConfigPayload : {};
return {
skillKey: getSkillKey(payload),
enabled: typeof body.enabled === 'boolean' ? body.enabled : undefined,
apiKey: typeof body.apiKey === 'string' ? body.apiKey : undefined,
env: getEnv(body.env),
};
}
function getConfigUpdates(payload: unknown): NormalizedSkillConfigUpdate[] {
const body = isRecord(payload) ? payload as SkillConfigsPayload : {};
if (!Array.isArray(body.updates)) return [];
return body.updates.flatMap((entry) => {
if (!isRecord(entry)) return [];
const skillKey = typeof entry.skillKey === 'string' ? entry.skillKey.trim() : '';
if (!skillKey) return [];
return [{
skillKey,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : undefined,
apiKey: typeof entry.apiKey === 'string' ? entry.apiKey : undefined,
env: getEnv(entry.env),
}];
});
}
export function createSkillsApi({
clawHubService,
gatewayManager,
}: {
clawHubService: ClawHubService;
gatewayManager: GatewayManager;
}): CompleteHostServiceRegistry['skills'] {
return {
local: async () => ({ success: true, skills: await listLocalSkills() }),
configs: async () => getAllSkillConfigs(),
allConfigs: async () => getAllSkillConfigs(),
getConfig: async (payload) => {
const config = await getSkillConfig(getSkillKey(payload));
return config ? { ...config } : undefined;
},
updateConfig: async (payload) => {
const { skillKey, ...updates } = getConfigUpdate(payload);
return updateSkillConfig(skillKey, updates);
},
updateConfigs: async (payload) => updateSkillConfigs(getConfigUpdates(payload)),
status: async () => gatewayManager.rpc('skills.status'),
update: async (payload) => gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {}),
quickAccess: async (payload) => {
const body = isRecord(payload) ? payload as QuickAccessPayload : {};
const [scannedSkills, configs] = await Promise.all([
collectQuickAccessSkills({
workspace: typeof body.workspace === 'string' ? body.workspace : undefined,
}),
getAllSkillConfigs(),
]);
let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined;
if (gatewayManager.getStatus().state === 'running') {
try {
const runtimeStatus = await gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
runtimeSkills = runtimeStatus.skills || [];
} catch {
runtimeSkills = undefined;
}
}
return {
success: true,
skills: filterEnabledQuickAccessSkills(scannedSkills, runtimeSkills, configs),
};
},
clawhubCapability: async () => {
try {
return { success: true, capability: await clawHubService.getMarketplaceCapability() };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubList: async () => {
try {
return { success: true, results: await clawHubService.listInstalled() };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubSearch: async (payload) => {
try {
return { success: true, results: await clawHubService.search((isRecord(payload) ? payload : {}) as ClawHubSearchParams) };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubInstall: async (payload) => {
try {
await clawHubService.install((isRecord(payload) ? payload : {}) as ClawHubInstallParams);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubUninstall: async (payload) => {
try {
await clawHubService.uninstall((isRecord(payload) ? payload : {}) as ClawHubUninstallParams);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubOpenSkillReadme: async (payload) => {
try {
const body = isRecord(payload) ? payload as SkillOpenPayload : {};
const skillKey = typeof body.skillKey === 'string' ? body.skillKey : '';
const slug = typeof body.slug === 'string' ? body.slug : undefined;
const baseDir = typeof body.baseDir === 'string' ? body.baseDir : undefined;
await clawHubService.openSkillReadme(skillKey || slug || '', slug, baseDir);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubOpenSkillPath: async (payload) => {
try {
const body = isRecord(payload) ? payload as SkillOpenPayload : {};
const skillKey = typeof body.skillKey === 'string' ? body.skillKey : '';
const slug = typeof body.slug === 'string' ? body.slug : undefined;
const baseDir = typeof body.baseDir === 'string' ? body.baseDir : undefined;
await clawHubService.openSkillPath(skillKey || slug || '', slug, baseDir);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
};
}
@@ -25,7 +25,7 @@ export interface LocalSkillRecord {
icon?: string;
version?: string;
author?: string;
config?: SkillConfigUpdates;
config?: Record<string, unknown>;
isCore?: boolean;
isBundled?: boolean;
source?: string;
@@ -268,7 +268,8 @@ async function inspectSkillDir(
]);
const skillKey = parsedManifest.id || manifestMeta?.slug || originMeta?.slug || fallbackId;
const config = configs[skillKey] || {};
const rawConfig = configs[skillKey] || {};
const config: Record<string, unknown> = { ...rawConfig };
const version = manifestMeta?.version || parsedManifest.version || originMeta?.installedVersion;
const source = descriptor.source;
const isBundled = source === 'openclaw-bundled' || Boolean(preinstalledMeta);
@@ -287,7 +288,7 @@ async function inspectSkillDir(
slug: originMeta?.slug || manifestMeta?.slug || preinstalledMeta?.slug || fallbackId,
name: parsedManifest.name,
description: parsedManifest.description,
enabled: config.enabled !== false,
enabled: rawConfig.enabled !== false,
icon: parsedManifest.icon || (isBundled ? '🧩' : '📦'),
version,
author: manifestMeta?.author || parsedManifest.author,
+75
View File
@@ -0,0 +1,75 @@
import type {
UpdateInfoSnapshot,
UpdateProgressSnapshot,
UpdateStatusSnapshot,
} from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { AppUpdater, UpdateStatus } from '../main/updater';
function normalizeInfo(info: UpdateStatus['info']): UpdateInfoSnapshot | undefined {
if (!info) return undefined;
return {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: typeof info.releaseNotes === 'string' || info.releaseNotes == null ? info.releaseNotes : String(info.releaseNotes),
};
}
function normalizeProgress(progress: UpdateStatus['progress']): UpdateProgressSnapshot | undefined {
if (!progress) return undefined;
return {
total: progress.total,
delta: progress.delta,
transferred: progress.transferred,
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
};
}
function normalizeStatus(status: UpdateStatus): UpdateStatusSnapshot {
return {
status: status.status,
info: normalizeInfo(status.info),
progress: normalizeProgress(status.progress),
error: status.error,
};
}
export function createUpdatesApi(updater: AppUpdater): CompleteHostServiceRegistry['updates'] {
return {
status: () => normalizeStatus(updater.getStatus()),
version: () => updater.getCurrentVersion(),
check: async () => {
try {
await updater.checkForUpdates();
return { success: true, status: normalizeStatus(updater.getStatus()) };
} catch (error) {
return { success: false, error: String(error), status: normalizeStatus(updater.getStatus()) };
}
},
download: async () => {
try {
await updater.downloadUpdate();
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
install: () => {
updater.quitAndInstall();
return { success: true };
},
setChannel: (payload) => {
updater.setChannel(payload.channel);
return { success: true };
},
setAutoDownload: (payload) => {
updater.setAutoDownload(payload.enable);
return { success: true };
},
cancelAutoInstall: () => {
updater.cancelAutoInstall();
return { success: true };
},
};
}
+27
View File
@@ -0,0 +1,27 @@
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { isRecord } from './payload-utils';
type RecentTokenHistoryPayload = {
limit?: unknown;
};
function getSafeLimit(payload: unknown): number | undefined {
const value = isRecord(payload) ? (payload as RecentTokenHistoryPayload).limit : payload;
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(Math.floor(value), 1);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.max(Math.floor(parsed), 1);
}
}
return undefined;
}
export function createUsageApi(): CompleteHostServiceRegistry['usage'] {
return {
recentTokenHistory: async (payload) => getRecentTokenUsageHistory(getSafeLimit(payload)),
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
export function createUvApi(): CompleteHostServiceRegistry['uv'] {
return {
installAll: async () => {
try {
const isInstalled = await checkUvInstalled();
if (!isInstalled) {
await installUv();
}
await setupManagedPython();
return { success: true };
} catch (error) {
console.error('Failed to setup uv/python:', error);
return { success: false, error: String(error) };
}
},
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { BrowserWindow } from 'electron';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { syncMacTrafficLightPosition } from '../main/traffic-light-layout';
export function createWindowApi(mainWindow: BrowserWindow): CompleteHostServiceRegistry['window'] {
return {
syncTrafficLightPosition: (payload) => {
syncMacTrafficLightPosition(mainWindow, payload.sidebarCollapsed);
},
minimize: () => {
mainWindow.minimize();
},
maximize: () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
},
close: () => {
mainWindow.close();
},
isMaximized: () => mainWindow.isMaximized(),
};
}
+3 -10
View File
@@ -5,6 +5,7 @@ export const PROVIDER_TYPES = [
'openrouter',
'ark',
'moonshot',
'moonshot-global',
'siliconflow',
'deepseek',
'minimax-portal',
@@ -21,6 +22,7 @@ export const BUILTIN_PROVIDER_TYPES = [
'openrouter',
'ark',
'moonshot',
'moonshot-global',
'siliconflow',
'deepseek',
'minimax-portal',
@@ -76,16 +78,7 @@ export function assertValidApiProtocol(
}
}
/**
* UI-selectable subset of api protocols offered to users when configuring
* custom or Ollama providers in Settings. Tightly scoped on purpose so the
* UI dropdown stays simple; built-in providers use the broader
* {@link OpenClawApiProtocol} via {@link ProviderBackendConfig.api}.
*/
export type ProviderProtocol =
| 'openai-completions'
| 'openai-responses'
| 'anthropic-messages';
export type ProviderProtocol = OpenClawApiProtocol;
export type ProviderAuthMode =
| 'api_key'
-5
View File
@@ -21,7 +21,6 @@ const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active = false;
@@ -42,7 +41,6 @@ class BrowserOAuthManager extends EventEmitter {
}
this.active = true;
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
@@ -90,7 +88,6 @@ class BrowserOAuthManager extends EventEmitter {
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
@@ -100,7 +97,6 @@ class BrowserOAuthManager extends EventEmitter {
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
if (this.pendingManualCodeReject) {
@@ -129,7 +125,6 @@ class BrowserOAuthManager extends EventEmitter {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
+8 -5
View File
@@ -754,7 +754,7 @@ function migrateLegacyChannelConfigToAccounts(
const legacyPayload = getLegacyChannelPayload(channelSection);
const legacyKeys = Object.keys(legacyPayload);
const existingAccounts = getChannelAccountsMap(channelSection);
const hasAccounts = Boolean(existingAccounts) && Object.keys(existingAccounts).length > 0;
const hasAccounts = existingAccounts ? Object.keys(existingAccounts).length > 0 : false;
if (legacyKeys.length === 0) {
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
@@ -1360,11 +1360,14 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
if (enabled) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
} else {
if (!currentConfig.plugins) currentConfig.plugins = {};
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
if (!currentConfig.plugins.entries[resolvedChannelType]) currentConfig.plugins.entries[resolvedChannelType] = {};
const plugins = currentConfig.plugins ?? (currentConfig.plugins = {});
const entries = plugins.entries ?? (plugins.entries = {});
entries[resolvedChannelType] ??= {};
}
currentConfig.plugins.entries[resolvedChannelType].enabled = enabled;
const entries = currentConfig.plugins?.entries;
const pluginEntry = entries?.[resolvedChannelType];
if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`);
pluginEntry.enabled = enabled;
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
+4 -4
View File
@@ -60,16 +60,16 @@ async function fileExists(p: string): Promise<boolean> {
/** Generate a new Ed25519 identity (async key generation). */
async function generateIdentity(): Promise<DeviceIdentity> {
const { publicKey, privateKey } = await new Promise<crypto.KeyPairKeyObjectResult>(
const { publicKey, privateKey } = await new Promise<{ publicKey: crypto.KeyObject; privateKey: crypto.KeyObject }>(
(resolve, reject) => {
crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => {
crypto.generateKeyPair('ed25519', {}, (err, publicKey, privateKey) => {
if (err) reject(err);
else resolve({ publicKey, privateKey });
});
},
);
const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString();
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' });
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
return {
deviceId: fingerprintPublicKey(publicKeyPem),
publicKeyPem,
+8
View File
@@ -28,6 +28,7 @@ export interface OpenAICodexOAuthCredentials {
refresh: string;
expires: number;
accountId: string;
email?: string;
}
interface OpenAICodexAuthorizationFlow {
@@ -132,6 +133,12 @@ function getAccountIdFromAccessToken(accessToken: string): string | null {
return null;
}
function getEmailFromAccessToken(accessToken: string): string | undefined {
const payload = decodeJwtPayload(accessToken);
const email = payload?.email;
return typeof email === 'string' && email.trim() ? email.trim() : undefined;
}
async function createAuthorizationFlow(): Promise<OpenAICodexAuthorizationFlow> {
const { verifier, challenge } = createPkce();
const state = createState();
@@ -301,6 +308,7 @@ export async function loginOpenAICodexOAuth(options: {
refresh: token.refresh,
expires: token.expires,
accountId,
email: getEmailFromAccessToken(token.access),
};
} finally {
server?.close();
+10 -7
View File
@@ -708,7 +708,7 @@ async function discoverInstalledExtensionPluginIds(): Promise<Set<string>> {
const ids = new Set<string>();
const extensionRoot = join(homedir(), '.openclaw', 'extensions');
let entries: Awaited<ReturnType<typeof readdir>>;
let entries: Array<{ isDirectory: () => boolean; name: string }>;
try {
entries = await readdir(extensionRoot, { withFileTypes: true });
} catch {
@@ -1601,9 +1601,12 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
}
function removeLegacyMoonshotKimiSearchConfig(config: Record<string, unknown>): boolean {
const tools = isPlainRecord(config.tools) ? config.tools : null;
const web = tools && isPlainRecord(tools.web) ? tools.web : null;
const search = web && isPlainRecord(web.search) ? web.search : null;
if (!isPlainRecord(config.tools) || !isPlainRecord(config.tools.web) || !isPlainRecord(config.tools.web.search)) {
return false;
}
const tools = config.tools as Record<string, unknown>;
const web = tools.web as Record<string, unknown>;
const search = web.search as Record<string, unknown>;
if (!search || !('kimi' in search)) return false;
delete search.kimi;
@@ -2677,9 +2680,9 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
const installs = isPlainRecord(pluginsObj.installs) ? pluginsObj.installs as Record<string, unknown> : null;
const acpxInstall = installs && isPlainRecord(installs.acpx) ? installs.acpx as Record<string, unknown> : null;
if (acpxInstall) {
if (isPlainRecord(pluginsObj.installs) && isPlainRecord(pluginsObj.installs.acpx)) {
const installs = pluginsObj.installs;
const acpxInstall = installs.acpx as Record<string, unknown>;
const currentBundledAcpxDir = join(getOpenClawResolvedDir(), 'dist', 'extensions', 'acpx').replace(/\\/g, '/');
const sourcePath = typeof acpxInstall.sourcePath === 'string' ? acpxInstall.sourcePath : '';
const installPath = typeof acpxInstall.installPath === 'string' ? acpxInstall.installPath : '';
+1 -1
View File
@@ -164,7 +164,7 @@ async function runDoctorCommandWithArgs(
stderrTruncated = next.truncated;
});
child.on('error', (error) => {
child.on('error', (error: unknown) => {
clearTimeout(timeout);
logger.error('Failed to spawn OpenClaw doctor process:', error);
finish({
+12 -11
View File
@@ -90,6 +90,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function getAgentsDefaults(config: unknown): Record<string, unknown> | undefined {
if (!isRecord(config) || !isRecord(config.agents) || !isRecord(config.agents.defaults)) {
return undefined;
}
return config.agents.defaults;
}
function normalizeModelRef(raw: unknown): string | null {
if (typeof raw === 'string' && raw.trim()) {
return raw.trim();
@@ -181,13 +188,11 @@ export async function isImageProviderAuthenticated(
export async function readImageGenerationConfig(): Promise<ImageGenerationModelConfig> {
const config = await readOpenClawConfig();
const defaults = config.agents?.defaults;
if (!defaults || typeof defaults !== 'object') {
const defaults = getAgentsDefaults(config);
if (!defaults) {
return { primary: null, fallbacks: [], timeoutMs: null };
}
return parseImageGenerationModelConfig(
(defaults as Record<string, unknown>).imageGenerationModel,
);
return parseImageGenerationModelConfig(defaults.imageGenerationModel);
}
export async function setImageGenerationConfig(
@@ -314,12 +319,8 @@ export async function getImageGenerationSettingsSnapshot(): Promise<ImageGenerat
const config = await readImageGenerationConfig();
const snapshot = await listAgentsSnapshot();
const openclawConfig = await readOpenClawConfig();
const defaults = openclawConfig.agents?.defaults;
const autoProviderFallback = !(
defaults
&& typeof defaults === 'object'
&& (defaults as Record<string, unknown>).mediaGenerationAutoProviderFallback === false
);
const defaults = getAgentsDefaults(openclawConfig);
const autoProviderFallback = defaults?.mediaGenerationAutoProviderFallback !== false;
const providerKey = config.primary ? parseProviderFromModelRef(config.primary) : null;
const relayState = readOpenAiCompatibleImageRelayState(openclawConfig as Record<string, unknown>);
+1 -1
View File
@@ -11,7 +11,7 @@ export async function proxyAwareFetch(
if (process.versions.electron) {
try {
const { net } = await import('electron');
return await net.fetch(input, init);
return await net.fetch(input instanceof URL ? input.toString() : input, init);
} catch {
// Fall through to the global fetch.
}
+3 -19
View File
@@ -5,7 +5,9 @@
* account-based provider storage and a dedicated secret-store abstraction.
*/
import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry';
import { BUILTIN_PROVIDER_TYPES } from './provider-registry';
import type { ProviderConfig } from '../shared/providers/types';
export type { ProviderConfig } from '../shared/providers/types';
import { getActiveOpenClawProviders } from './openclaw-auth';
import {
deleteProviderAccount,
@@ -25,24 +27,6 @@ import {
} from '../services/secrets/secret-store';
import { getOpenClawProviderKeyForType } from './provider-keys';
/**
* Provider configuration
*/
export interface ProviderConfig {
id: string;
name: string;
type: ProviderType;
baseUrl?: string;
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
}
// ==================== API Key Storage ====================
/**
+1 -1
View File
@@ -5,7 +5,7 @@
import { randomBytes } from 'crypto';
import { app } from 'electron';
import { resolveSupportedLanguage } from '../../shared/language';
import { resolveSupportedLanguage } from '@shared/language';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -8,8 +8,8 @@ requiredTests:
- tests/unit/api-client.test.ts
---
Gateway RPC transport is IPC-only by default. Renderer code must not enable WebSocket or HTTP transport unless `src/lib/api-client.ts` explicitly gates it behind `clawx:gateway-ws-diagnostic`.
Gateway RPC transport is IPC-only. Renderer code must not enable WebSocket or HTTP transports to OpenClaw Gateway.
When diagnostics are enabled, the allowed order is `WS -> HTTP -> IPC`; otherwise the allowed order is `IPC`.
The renderer must call Main through typed host-api or legacy IPC wrappers only; Main owns the Gateway WebSocket.
Failed non-IPC transports must use backoff before retry. `gateway:httpProxy` remains a Main-owned proxy path and must not become direct renderer Gateway HTTP access.
Gateway frame diagnostics belong in Main-process logging, not renderer direct Gateway connections.
@@ -1,15 +1,16 @@
---
id: host-api-fallback-policy
title: Host API Fallback Policy
title: Host API Typed IPC Policy
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
requiredTests:
- tests/unit/host-api.test.ts
- tests/unit/host-api-facade.test.ts
- tests/unit/host-invoke.test.ts
---
Renderer Host API requests must use `hostapi:fetch` IPC proxy by default.
Renderer Host API requests must use the typed `hostApi.<module>.<action>()` facade and `host:invoke` bridge.
Browser fallback to `http://127.0.0.1:13210` is allowed only inside `src/lib/host-api.ts`, and only when `clawx:allow-localhost-fallback` is explicitly enabled.
The local Host API HTTP server and browser fallback to `http://127.0.0.1:13210` are removed and must not be reintroduced.
The Host API token must be obtained through `hostapi:token`; pages and components must not construct Host API localhost requests directly.
Pages and components must not call `window.electron.ipcRenderer.invoke(...)` directly for backend data; expose typed host-api/api-client methods instead.
@@ -10,6 +10,6 @@ requiredTests:
Host event subscriptions must use IPC mappings by default.
Unknown host events must not fall back to SSE unless `clawx:allow-sse-fallback` is explicitly enabled inside `src/lib/host-events.ts`.
Unknown host events must not fall back to SSE/EventSource.
New user-visible gateway, channel, OAuth, or QR events should be added to the host event IPC mapping instead of relying on EventSource fallback.
@@ -8,8 +8,8 @@ ownedPaths:
- src/stores/gateway.ts
- src/stores/chat.ts
- src/stores/chat/**
- electron/api/**
- electron/main/ipc/**
- electron/services/**
- electron/gateway/**
- electron/preload/**
- electron/utils/**
@@ -41,19 +41,19 @@ forbiddenPatterns:
- fetch("http://127.0.0.1:18789 in src/**
- fetch('http://localhost:18789 in src/**
- fetch("http://localhost:18789 in src/**
- clawx:allow-localhost-fallback outside src/lib/host-api.ts and tests
- clawx:allow-sse-fallback outside src/lib/host-events.ts and tests
- clawx:gateway-ws-diagnostic outside src/lib/api-client.ts and tests
- new WebSocket('ws://127.0.0.1:18789 in src/**
- new WebSocket("ws://127.0.0.1:18789 in src/**
- new WebSocket('ws://localhost:18789 in src/**
- new WebSocket("ws://localhost:18789 in src/**
---
Gateway backend communication covers all ClawX paths that move data between the visual desktop UI and OpenClaw runtime/backend services.
Allowed flow:
Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main host route or IPC handler -> gateway proxy / OpenClaw Gateway -> runtime result -> store/UI.
Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main typed host service or IPC handler -> Main-owned OpenClaw Gateway WebSocket -> runtime result -> store/UI.
Renderer code must not own transport selection, direct IPC channels, direct Gateway HTTP calls, retry policy, or protocol fallback.
Explicit local fallback flags are narrow exceptions:
`clawx:allow-localhost-fallback` belongs to Host API browser fallback only, `clawx:allow-sse-fallback` belongs to host event SSE fallback only, and `clawx:gateway-ws-diagnostic` belongs to API client transport diagnostics only.
Renderer code must not create direct Gateway WebSocket connections. Gateway frame diagnostics must be emitted by Main-process Gateway logging.
Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.
@@ -8,7 +8,7 @@ ownedPaths:
- electron/utils/channel-config.ts
- electron/utils/plugin-install.ts
- electron/gateway/skills-symlink-cleanup.ts
- electron/api/routes/skills.ts
- electron/services/skills-api.ts
- src/stores/skills.ts
- resources/skills/**
- tests/unit/openclaw-auth.test.ts
@@ -26,10 +26,10 @@ touchedAreas:
- src/pages/Skills/index.tsx
- src/stores/skills.ts
- src/types/skill.ts
- src/i18n/locales/en/skills.json
- src/i18n/locales/zh/skills.json
- src/i18n/locales/ja/skills.json
- src/i18n/locales/ru/skills.json
- shared/i18n/locales/en/skills.json
- shared/i18n/locales/zh/skills.json
- shared/i18n/locales/ja/skills.json
- shared/i18n/locales/ru/skills.json
- scripts/agent-browser/skills-local-first-smoke.sh
- scripts/bundle-openclaw.mjs
- tests/e2e/skills-gateway-readiness.spec.ts
@@ -6,7 +6,7 @@ taskType: runtime-bridge
intent: Reduce startup chat.history contention so foreground history loads do not time out behind sidebar background hydration.
touchedAreas:
- harness/specs/tasks/fix-chat-history-gateway-timeout.md
- electron/api/routes/sessions.ts
- electron/services/sessions-api.ts
- electron/gateway/rpc-backpressure.ts
- electron/main/ipc-handlers.ts
- src/components/layout/Sidebar.tsx
@@ -26,7 +26,7 @@ touchedAreas:
- tests/unit/gateway-rpc-backpressure.test.ts
- tests/unit/history-startup-retry.test.ts
- tests/unit/session-label-fetch.test.ts
- tests/unit/session-summaries-route.test.ts
- tests/unit/host-services.test.ts
expectedUserBehavior:
- Foreground chat history loading is prioritized during gateway startup and restart.
- Sidebar/session label hydration does not compete with the first visible history load.
@@ -48,7 +48,7 @@ requiredTests:
- tests/unit/chat-store-session-label-fetch.test.ts
- tests/unit/gateway-rpc-backpressure.test.ts
- tests/unit/session-label-fetch.test.ts
- tests/unit/session-summaries-route.test.ts
- tests/unit/host-services.test.ts
acceptance:
- Renderer does not add direct IPC calls.
- Renderer does not fetch Gateway HTTP directly.
@@ -6,11 +6,11 @@ taskType: runtime-bridge
intent: Remove the on-disk session transcript (and its sibling artefacts) when the user deletes a conversation, instead of soft-deleting it via rename.
touchedAreas:
- electron/main/ipc-handlers.ts
- electron/api/routes/sessions.ts
- electron/services/sessions-api.ts
- electron/utils/session-files.ts
- src/stores/chat/session-actions.ts
- src/stores/chat.ts
- tests/unit/session-delete-route.test.ts
- tests/unit/host-services.test.ts
- harness/specs/tasks/hard-delete-session-jsonl.md
- AGENTS.md
expectedUserBehavior:
@@ -25,12 +25,11 @@ requiredProfiles:
- fast
- comms
requiredTests:
- tests/unit/session-delete-route.test.ts
- tests/unit/host-services.test.ts
- tests/unit/chat-session-actions.test.ts
acceptance:
- Renderer continues to use src/lib/host-api.ts and src/lib/api-client.ts; no new direct ipcRenderer or Gateway HTTP calls.
- IPC channel name session:delete and HTTP route POST /api/sessions/delete are unchanged in shape.
- Both the IPC handler in electron/main/ipc-handlers.ts and the HTTP mirror in electron/api/routes/sessions.ts unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
- Typed host session deletion and the legacy session:delete IPC handler unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
- The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry.
- Renderer delete-session paths clear any in-memory pending optimistic user messages for the deleted key before subsequent history loads run.
- agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json).
@@ -49,9 +48,8 @@ that rename with a true `unlink` plus a sibling sweep that also removes
`<id>.deleted.jsonl` (legacy soft-delete leftovers) and `<id>.jsonl.reset.*`
(reset snapshots produced by `sessions.reset`).
Both backends (the IPC handler used by the refactored chat store and the
HTTP route used by the legacy chat store) share the same disk contract via
`electron/utils/session-files.ts`, which centralises:
Both Main surfaces (the typed host session service and the legacy IPC handler)
share the same disk contract via `electron/utils/session-files.ts`, which centralises:
- sessions.json entry resolution across the three observed shapes,
- cross-platform absolute-path detection (POSIX, Windows `C:\...` and
@@ -17,8 +17,7 @@ touchedAreas:
- scripts/bundle-openclaw.mjs
- scripts/patch-openclaw-image-b64-json.mjs
- package.json
- electron/api/routes/media.ts
- electron/api/server.ts
- electron/services/media-api.ts
- electron/utils/store.ts
- electron/services/providers/provider-runtime-sync.ts
- src/lib/image-generation.ts
@@ -27,8 +26,8 @@ touchedAreas:
- src/components/settings/ImageGenerationSettings.tsx
- src/pages/ImageGeneration/index.tsx
- src/pages/Models/index.tsx
- src/i18n/locales/*/common.json
- src/i18n/locales/*/dashboard.json
- shared/i18n/locales/*/common.json
- shared/i18n/locales/*/dashboard.json
- tests/unit/openclaw-image-generation.test.ts
- tests/unit/openclaw-auth.test.ts
- tests/e2e/image-generation-settings.spec.ts
@@ -53,8 +52,8 @@ requiredTests:
- tests/unit/openclaw-image-generation.test.ts
- tests/e2e/image-generation-settings.spec.ts
acceptance:
- Renderer uses hostApiFetch only (src/lib/image-generation.ts); no direct Gateway HTTP or ipcRenderer from pages.
- GET/PUT /api/media/image-generation and POST /api/media/image-generation/test are handled in Main process.
- Renderer uses typed hostApi media methods only (src/lib/image-generation.ts); no direct Gateway HTTP or ipcRenderer from pages.
- Image generation settings and test actions are handled in Main process services.
- Unit tests cover model ref parsing, config read/write, custom endpoint model mapping, private-network endpoint opt-in, and the independent image endpoint not mutating `models.providers.openai`.
- E2E verifies the Image Generation page is hidden until developer mode is enabled, is not embedded in Models, and exposes the custom endpoint controls.
docs:
+1 -1
View File
@@ -9,7 +9,7 @@ touchedAreas:
- electron/utils/openclaw-auth.ts
- electron/utils/channel-config.ts
- electron/utils/plugin-install.ts
- electron/api/routes/skills.ts
- electron/services/skills-api.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
- tests/unit/plugin-install.test.ts
@@ -0,0 +1,63 @@
---
id: prune-host-api-covered-legacy-ipc
title: Prune hostApi-covered legacy direct IPC handlers
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Remove legacy direct IPC channels that are already covered by host:invoke/hostApi and are no longer invoked by renderer code.
touchedAreas:
- README.md
- README.zh-CN.md
- README.ja-JP.md
- docs/superpowers/**
- harness/**
- electron/**
- src/**
- tests/**
- electron/main/ipc-handlers.ts
- electron/preload/index.ts
- src/lib/host-api.ts
- shared/host-api/contract.ts
- tests/unit/host-api-facade.test.ts
- harness/specs/tasks/prune-host-api-covered-legacy-ipc.md
expectedUserBehavior:
- No visible UI or runtime behavior changes.
- Renderer code continues to use hostApi for logs, skills, ClawHub, channel configuration, provider account helpers, and provider OAuth.
- Remaining direct IPC channels are those still intentionally used by renderer code or compatibility tests.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-api-fallback-policy
- docs-sync
requiredTests:
- tests/unit/host-api-facade.test.ts
- pnpm run typecheck
- pnpm test
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- HostApi-covered legacy direct IPC channels are not registered in electron/main/ipc-handlers.ts.
- HostApi-covered legacy direct IPC channels are not exposed through the preload invoke allowlist.
- Legacy Cron actions are not routed through app:request now that Cron uses hostApi.cron.
- Direct IPC channels with no renderer invoke callers are removed from Main and preload.
- Direct IPC channels still used by renderer code remain available.
- Gateway event forwarding and provider/channel OAuth event forwarding continue to work.
docs:
required: false
---
## Scope
Prune direct IPC handlers whose implementation has an equivalent typed host
service and whose old channel is no longer invoked by renderer source code.
Keep event forwarding and still-used direct IPC channels intact.
## Out of scope
- Removing `app:request` or the old provider CRUD fallback in the same change.
- Removing direct IPC channels still used by Setup, file preview, shell/dialog,
update, window controls, or tests that intentionally cover legacy fallback.
- Reworking Gateway event subscription channels.
@@ -0,0 +1,74 @@
---
id: remove-host-api-server-and-renderer-gateway-transports
title: Remove local Host API server and renderer Gateway transports
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Collapse backend communication to typed Electron IPC in the renderer, with OpenClaw Gateway WebSocket ownership kept in Electron Main.
touchedAreas:
- README.md
- README.zh-CN.md
- README.ja-JP.md
- docs/superpowers/**
- harness/**
- src/**
- tests/**
- electron/api/**
- electron/extensions/**
- src/lib/host-api.ts
- src/lib/host-api-client.ts
- src/lib/api-client.ts
- src/lib/host-events.ts
- src/stores/gateway.ts
- src/stores/chat.ts
- src/pages/Settings/index.tsx
- electron/main/index.ts
- electron/main/ipc-handlers.ts
- electron/main/ipc/**
- electron/preload/**
- electron/services/**
- electron/gateway/**
- harness/specs/scenarios/gateway-backend-communication.md
- harness/specs/rules/host-api-fallback-policy.md
- harness/specs/rules/api-client-transport-policy.md
- tests/unit/host-api-facade.test.ts
- tests/unit/host-invoke.test.ts
- tests/unit/host-events.test.ts
- tests/unit/api-client.test.ts
- tests/unit/gateway-ws-trace.test.ts
expectedUserBehavior:
- Settings, channels, agents, providers, skills, cron, chat, sessions, files, media, usage, and diagnostics continue to work through typed hostApi calls.
- Renderer no longer starts or contacts a local Host API HTTP server.
- Renderer no longer opens a direct WebSocket or HTTP proxy transport to OpenClaw Gateway.
- Gateway RPC and Gateway events continue through the Main-owned Gateway manager connection.
- Gateway WebSocket frame diagnostics are available from Main-process logs when CLAWX_GATEWAY_WS_TRACE=1 is set.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-api-fallback-policy
- host-events-fallback-policy
- docs-sync
requiredTests:
- tests/unit/host-api-facade.test.ts
- tests/unit/host-invoke.test.ts
- tests/unit/host-events.test.ts
- tests/unit/api-client.test.ts
- tests/unit/gateway-ws-trace.test.ts
- pnpm run typecheck
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- No production source references the legacy Host API fetch/token IPC names, Gateway HTTP proxy channel, local Host API server startup, Host event bus, or renderer Gateway WS diagnostic toggles.
- src/lib/api-client.ts is IPC-only and does not construct WebSocket or HTTP Gateway transports.
- src/lib/host-api.ts exposes typed hostApi facade methods and does not export a path-based fetch helper.
- src/lib/host-events.ts subscribes through typed IPC events and does not fall back to SSE/EventSource.
- electron/api and local Host API route tests are removed.
- README.md, README.zh-CN.md, and README.ja-JP.md describe typed IPC and Main-owned Gateway WebSocket ownership.
docs:
required: true
---
Use this spec when removing or auditing legacy renderer/Main/backend communication paths.
@@ -0,0 +1,71 @@
---
id: tighten-host-api-contract-types
title: Tighten host API contract types across renderer and Main
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Replace ad-hoc unknown-based host invoke typing with a function-shaped HostApiContract shared by the renderer facade, preload bridge, and Main host service registry.
touchedAreas:
- README.md
- README.zh-CN.md
- README.ja-JP.md
- docs/superpowers/**
- harness/**
- electron/**
- src/**
- tests/**
- harness/specs/tasks/tighten-host-api-contract-types.md
- shared/host-api/contract.ts
- src/lib/host-api-client.ts
- shared/host-api/types.ts
- src/lib/host-api.ts
- src/types/electron.d.ts
- electron/preload/index.ts
- electron/main/ipc/host-contract.ts
- electron/main/ipc/host-invoke.ts
- electron/services/**
- electron/services/payload-utils.ts
- tests/unit/host-api-facade.test.ts
- tests/unit/host-invoke.test.ts
expectedUserBehavior:
- No visible UI or runtime behavior changes.
- Renderer pages and stores continue to call backend operations through hostApi.<module>.<action>().
- Unsupported or malformed host:invoke requests still return validation or unsupported errors from Main.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-api-fallback-policy
- docs-sync
requiredTests:
- tests/unit/host-api-facade.test.ts
- tests/unit/host-invoke.test.ts
- pnpm run typecheck
- pnpm test
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- HostApiContract is expressed as module/action function signatures, not { input, output } descriptors.
- invokeHost infers payload and result types from HostApiContract instead of accepting string/string/unknown plus a caller-supplied output generic.
- src/lib/host-api.ts facade methods expose typed inputs for normal hostApi calls; gateway.rpc keeps a generic result escape hatch for dynamic Gateway RPCs.
- Electron Main host service registration is constrained by the same HostApiContract.
- Host service handlers inherit payload parameter types from HostApiContract instead of annotating payload as unknown.
- Shared payload shape checks live in electron/services/payload-utils.ts instead of being redefined in each service file.
- Runtime host request validation continues to treat untrusted IPC input as unknown at the dispatcher boundary.
docs:
required: false
---
## Scope
This task is a type-safety refactor for the existing typed IPC bridge created by
`remove-host-api-server-and-renderer-gateway-transports`. It does not add a new
backend route, change transport selection, or alter user-visible flows.
## Out of scope
- Reworking legacy direct IPC channels that still exist outside host:invoke.
- Adding runtime schema validation for every payload.
- Removing dynamic typing from Gateway RPC method-specific params/results.
@@ -0,0 +1,64 @@
---
id: tighten-host-events-contract-types
title: Tighten host event contract types
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Move Main-to-renderer host event payload typing into a shared contract so renderer subscribers no longer provide ad-hoc generic payload types.
touchedAreas:
- AGENTS.md
- README.md
- README.zh-CN.md
- README.ja-JP.md
- docs/superpowers/**
- package.json
- tsconfig.json
- tsconfig.node.json
- tsconfig.web.json
- vite.config.ts
- vitest.config.ts
- electron/**
- src/**
- tests/**
- harness/**
- shared/**
- shared/host-events/**
- shared/types/**
- src/lib/host-events.ts
- src/stores/gateway.ts
- src/components/channels/**
- src/components/settings/**
- electron/preload/**
- electron/main/ipc-handlers.ts
- electron/gateway/**
- electron/utils/**
- tests/unit/host-events.test.ts
- tests/unit/gateway-events.test.ts
expectedUserBehavior:
- No visible UI or runtime behavior changes.
- Renderer stores and components continue to receive Gateway, OAuth, and QR channel events through hostEvents.
- Gateway status, chat notifications, channel status refreshes, OAuth login progress, and QR login feedback still update the UI as before.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-events-fallback-policy
- docs-sync
requiredTests:
- tests/unit/host-events.test.ts
- tests/unit/gateway-events.test.ts
- pnpm run typecheck
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- src/lib/host-events.ts derives subscriber payload types from a shared host-events contract.
- Renderer call sites do not pass generic payload types to hostEvents subscribers.
- Known Gateway, OAuth, and QR channel event payloads use concrete shared types instead of caller-side unknown casts where the full chain determines the shape.
- Host events still subscribe through IPC and do not reintroduce SSE/EventSource fallback.
docs:
required: false
---
Use this spec when changing Main-to-renderer event subscription typing or event payload contracts.
+7 -17
View File
@@ -4,19 +4,18 @@ import { ROOT, pathMatchesAny, toArray } from './specs.mjs';
const DIRECT_IPC_PATTERN = /window\.electron\.ipcRenderer\.invoke\s*\(/;
const DIRECT_GATEWAY_HTTP_PATTERN = /fetch\s*\(\s*['"`]http:\/\/(?:127\.0\.0\.1|localhost):18789/;
const DIRECT_GATEWAY_WS_PATTERN = /new\s+WebSocket\s*\(\s*['"`]ws:\/\/(?:127\.0\.0\.1|localhost):18789|ws:\/\/(?:127\.0\.0\.1|localhost):18789/;
const HOST_API_LOCAL_HTTP_PATTERN = /fetch\s*\(\s*['"`]http:\/\/(?:127\.0\.0\.1|localhost):13210|HOST_API_BASE\s*=\s*`?http:\/\/127\.0\.0\.1:\$\{HOST_API_PORT\}`?/;
const LOCALHOST_FALLBACK_FLAG = 'clawx:allow-localhost-fallback';
const SSE_FALLBACK_FLAG = 'clawx:allow-sse-fallback';
const WS_DIAGNOSTIC_FLAG = 'clawx:gateway-ws-diagnostic';
const GATEWAY_READY_MUTATION_PATTERN = /gatewayReady\s*[:=]\s*(?:true|false)|setStatus\s*\([^)]*gatewayReady|setState\s*\([^)]*gatewayReady/s;
const COMMUNICATION_PATHS = [
'src/lib/api-client.ts',
'src/lib/host-api.ts',
'src/lib/host-api-client.ts',
'src/stores/gateway.ts',
'src/stores/chat.ts',
'src/stores/chat/**',
'electron/api/**',
'electron/main/ipc/**',
'electron/services/**',
'electron/gateway/**',
'electron/preload/**',
'electron/utils/**',
@@ -55,21 +54,12 @@ export async function scanBackendCommunicationBoundary(files) {
failures.push(`${file}: renderer must not fetch Gateway HTTP directly`);
}
const isTest = file.startsWith('tests/');
if (!isTest && text.includes(LOCALHOST_FALLBACK_FLAG) && file !== 'src/lib/host-api.ts') {
failures.push(`${file}: ${LOCALHOST_FALLBACK_FLAG} is only allowed in src/lib/host-api.ts`);
if (DIRECT_GATEWAY_WS_PATTERN.test(text)) {
failures.push(`${file}: renderer must not open Gateway WebSocket connections directly`);
}
if (!isTest && HOST_API_LOCAL_HTTP_PATTERN.test(text) && file !== 'src/lib/host-api.ts') {
failures.push(`${file}: direct Host API localhost fallback is only allowed in src/lib/host-api.ts`);
}
if (!isTest && text.includes(SSE_FALLBACK_FLAG) && file !== 'src/lib/host-events.ts') {
failures.push(`${file}: ${SSE_FALLBACK_FLAG} is only allowed in src/lib/host-events.ts`);
}
if (!isTest && text.includes(WS_DIAGNOSTIC_FLAG) && file !== 'src/lib/api-client.ts') {
failures.push(`${file}: ${WS_DIAGNOSTIC_FLAG} is only allowed in src/lib/api-client.ts`);
if (HOST_API_LOCAL_HTTP_PATTERN.test(text)) {
failures.push(`${file}: renderer must not use the removed Host API localhost server`);
}
const isPageOrComponentFile = file.startsWith('src/pages/') || file.startsWith('src/components/');
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.4.8",
"version": "0.4.9-alpha.0",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
@@ -42,7 +42,9 @@
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"typecheck": "tsc --noEmit",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"test": "vitest run",
"test:e2e": "pnpm run build:vite && playwright test",
"test:e2e:headed": "pnpm run build:vite && playwright test --headed",
+169
View File
@@ -0,0 +1,169 @@
import type { ChatRuntimeEvent } from '../chat-runtime-events';
/** Metadata for locally-attached files (not from Gateway) */
export interface AttachedFileMeta {
fileName: string;
mimeType: string;
fileSize: number;
preview: string | null;
previewStatus?: 'unavailable';
filePath?: string;
source?: 'user-upload' | 'tool-result' | 'message-ref' | 'gateway-media';
/**
* For Gateway-injected outgoing media (assistant-media). The Gateway emits
* an `image` content block with a relative URL like
* `/api/chat/media/outgoing/<sessionKey>/<attachmentId>/full`. The renderer
* cannot reach Gateway HTTP directly (CORS / env drift), so this URL is
* resolved through the Main-process proxy in `media:getThumbnails`, which
* looks up `~/.openclaw/media/outgoing/records/<attachmentId>.json` and
* loads the original file off disk.
*/
gatewayUrl?: string;
}
/** Raw message from OpenClaw chat.history */
export interface RawMessage {
role: 'user' | 'assistant' | 'system' | 'toolresult';
content: unknown; // string | ContentBlock[]
timestamp?: number;
id?: string;
toolCallId?: string;
toolName?: string;
details?: unknown;
isError?: boolean;
stopReason?: string;
stop_reason?: string;
errorMessage?: string;
error_message?: string;
/** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */
_attachedFiles?: AttachedFileMeta[];
}
/** Content block inside a message */
export interface ContentBlock {
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
text?: string;
thinking?: string;
source?: { type: string; media_type?: string; data?: string; url?: string };
/** Flat image format from Gateway tool results (no source wrapper) */
data?: string;
mimeType?: string;
/**
* Flat URL on an `image` block. Gateway-injected assistant-media messages
* use this shape: `{ type:'image', url:'/api/chat/media/outgoing/...', mimeType, width, height, alt, openUrl }`.
* Neither nested `source.url` nor flat `data` is set in that case; the
* renderer must read `block.url` directly to surface the artifact.
*/
url?: string;
/** Optional companion of `url` — points at a higher-resolution variant. */
openUrl?: string;
/** Pixel width of the original image, used for layout hints. */
width?: number;
/** Pixel height of the original image, used for layout hints. */
height?: number;
/** Human-readable filename / alt text emitted by the Gateway. */
alt?: string;
id?: string;
name?: string;
input?: unknown;
arguments?: unknown;
content?: unknown;
}
/** Session from sessions.list */
export interface ChatSession {
key: string;
label?: string;
displayName?: string;
derivedTitle?: string;
lastMessagePreview?: string;
thinkingLevel?: string;
model?: string;
updatedAt?: number;
status?: string;
hasActiveRun?: boolean;
}
export interface ToolStatus {
id?: string;
toolCallId?: string;
name: string;
status: 'running' | 'completed' | 'error';
durationMs?: number;
summary?: string;
updatedAt: number;
}
export interface ChatRuntimeRunState {
runId: string;
sessionKey?: string;
status: 'running' | 'completed' | 'error' | 'aborted';
startedAt?: number;
endedAt?: number;
assistantText: string;
thinkingText: string;
events: ChatRuntimeEvent[];
}
export interface ChatState {
// Messages
messages: RawMessage[];
loading: boolean;
loadingMoreHistory: boolean;
hasMoreHistory: boolean;
error: string | null;
runError: string | null;
// Streaming
sending: boolean;
activeRunId: string | null;
streamingText: string;
streamingMessage: unknown | null;
streamingTools: ToolStatus[];
pendingFinal: boolean;
lastUserMessageAt: number | null;
/** Images collected from tool results, attached to the next assistant message */
pendingToolImages: AttachedFileMeta[];
runtimeRuns: Record<string, ChatRuntimeRunState>;
// Sessions
sessions: ChatSession[];
currentSessionKey: string;
currentAgentId: string;
/** First user message text per session key, used as display label */
sessionLabels: Record<string, string>;
/** Last message timestamp (ms) per session key, used for sorting */
sessionLastActivity: Record<string, number>;
// Thinking
thinkingLevel: string | null;
// Actions
loadSessions: () => Promise<void>;
switchSession: (key: string) => void;
newSession: () => void;
deleteSession: (key: string) => Promise<void>;
renameSession: (key: string, label: string) => Promise<void>;
cleanupEmptySession: () => void;
loadHistory: (quiet?: boolean) => Promise<void>;
loadMoreHistory: () => Promise<void>;
sendMessage: (
text: string,
attachments?: Array<{
fileName: string;
mimeType: string;
fileSize: number;
stagedPath: string;
preview: string | null;
}>,
targetAgentId?: string | null,
) => Promise<void>;
abortRun: () => Promise<void>;
handleChatEvent: (event: Record<string, unknown>) => void;
handleRuntimeEvent: (event: ChatRuntimeEvent) => void;
refresh: () => Promise<void>;
clearError: () => void;
}
export const DEFAULT_CANONICAL_PREFIX = 'agent:main';
export const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`;
+874
View File
@@ -0,0 +1,874 @@
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 { MarketplaceSkill, QuickAccessSkill, Skill } from '../types/skill';
export type JsonRecord = Record<string, unknown>;
export type HostSuccess = { success: boolean; error?: string };
export type OptionalHostSuccess = { success?: boolean; error?: string };
export type OpenClawDoctorMode = 'diagnose' | 'fix';
export type OpenClawDoctorResult = HostSuccess & {
mode: OpenClawDoctorMode;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
cwd: string;
durationMs: number;
timedOut?: boolean;
};
export type OpenClawDoctorPayload = { mode: OpenClawDoctorMode };
export type OpenClawStatusResult = {
packageExists: boolean;
isBuilt: boolean;
entryPath: string;
dir: string;
version?: string;
};
export type OpenClawCliCommandResult = HostSuccess & { command?: string };
export type ShellPathPayload = { path: string };
export type ShellOpenExternalPayload = { url: string };
export type DialogOpenPayload = {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: Array<
| 'openFile'
| 'openDirectory'
| 'multiSelections'
| 'showHiddenFiles'
| 'createDirectory'
| 'promptToCreate'
| 'noResolveAliases'
| 'treatPackageAsDirectory'
| 'dontAddToRecent'
>;
message?: string;
securityScopedBookmarks?: boolean;
};
export type DialogOpenResult = {
canceled: boolean;
filePaths: string[];
bookmarks?: string[];
};
export type DialogMessagePayload = {
message: string;
type?: 'none' | 'info' | 'error' | 'question' | 'warning';
buttons?: string[];
defaultId?: number;
cancelId?: number;
detail?: string;
checkboxLabel?: string;
checkboxChecked?: boolean;
noLink?: boolean;
title?: string;
};
export type DialogMessageResult = {
response: number;
checkboxChecked?: boolean;
};
export type WindowSyncTrafficLightPayload = { sidebarCollapsed: boolean };
export type UpdateChannel = 'stable' | 'beta' | 'dev';
export type UpdateInfoSnapshot = {
version: string;
releaseDate?: string;
releaseNotes?: string | null;
};
export type UpdateProgressSnapshot = {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
};
export type UpdateStatusSnapshot = {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: UpdateInfoSnapshot;
progress?: UpdateProgressSnapshot;
error?: string;
};
export type UpdateCheckResult = HostSuccess & { status?: UpdateStatusSnapshot };
export type UpdateSetChannelPayload = { channel: UpdateChannel };
export type UpdateSetAutoDownloadPayload = { enable: boolean };
export type SettingsSnapshot = Partial<{
theme: 'light' | 'dark' | 'system';
language: string;
startMinimized: boolean;
launchAtStartup: boolean;
telemetryEnabled: boolean;
gatewayAutoStart: boolean;
gatewayPort: number;
proxyEnabled: boolean;
proxyServer: string;
proxyHttpServer: string;
proxyHttpsServer: string;
proxyAllServer: string;
proxyBypassRules: string;
updateChannel: 'stable' | 'beta' | 'dev';
autoCheckUpdate: boolean;
sidebarCollapsed: boolean;
sidebarWidth: number;
devModeUnlocked: boolean;
setupComplete: boolean;
}>;
export type SettingsKey = keyof SettingsSnapshot & string;
export type SettingsValue = SettingsSnapshot[SettingsKey];
export type SettingsGetPayload = { key: SettingsKey };
export type SettingsSetPayload = { key: SettingsKey; value: SettingsValue };
export type SettingsSetManyPayload = { patch: Partial<SettingsSnapshot> };
export type SettingsResetResult = HostSuccess & { settings: SettingsSnapshot };
export type GatewayControlUiPayload = { view?: 'dreams' };
export type GatewayControlUiResult = HostSuccess & {
url?: string;
token?: string;
port?: number;
};
export type GatewayHealthPayload = { probe?: boolean };
export type GatewayRpcPayload = {
method: string;
params?: unknown;
timeoutMs?: number;
};
export type LogContentResult = { content: string };
export type LogDirResult = { dir: string | null };
export type LogFilePathResult = { path: string | null };
export type LogRecentPayload = { tailLines?: number };
export type LogMemoryPayload = { count?: number };
export type LogReadFilePayload = { path: string; tailLines?: number };
export type LogFileEntry = {
path: string;
name?: string;
size?: number;
mtime?: number;
};
export type LogFilesResult = { files: LogFileEntry[] };
export type GatewayHealthSummary = {
state: 'healthy' | 'degraded' | 'unresponsive';
reasons: string[];
consecutiveHeartbeatMisses: number;
lastAliveAt?: number;
lastRpcSuccessAt?: number;
lastRpcFailureAt?: number;
lastRpcFailureMethod?: string;
lastChannelsStatusOkAt?: number;
lastChannelsStatusFailureAt?: number;
};
export type ChannelRuntimeStatus = 'connected' | 'connecting' | 'degraded' | 'disconnected' | 'error';
export type ChannelAccountItem = {
accountId: string;
name: string;
configured: boolean;
status: ChannelRuntimeStatus;
statusReason?: string;
lastError?: string;
isDefault: boolean;
agentId?: string;
};
export type ChannelGroupItem = {
channelType: string;
defaultAccountId: string;
status: ChannelRuntimeStatus;
statusReason?: string;
accounts: ChannelAccountItem[];
};
export type ChannelTargetOption = {
value: string;
label: string;
kind: 'user' | 'group' | 'channel';
};
export type ChannelAccountsPayload = {
mode?: 'config' | 'runtime';
configOnly?: boolean;
probe?: boolean;
};
export type ChannelAccountsResult = HostSuccess & {
channels?: ChannelGroupItem[];
gatewayHealth?: GatewayHealthSummary;
};
export type ChannelTargetsPayload = {
channelType: string;
accountId?: string;
query?: string;
};
export type ChannelTargetsResult = HostSuccess & {
channelType?: string;
accountId?: string;
targets?: ChannelTargetOption[];
};
export type ChannelTypePayload = { channelType: string };
export type ChannelAccountPayload = ChannelTypePayload & { accountId?: string };
export type ChannelRequiredAccountPayload = ChannelTypePayload & { accountId: string };
export type ChannelBindingSavePayload = ChannelRequiredAccountPayload & { agentId: string };
export type ChannelBindingDeletePayload = ChannelAccountPayload;
export type ChannelSetEnabledPayload = ChannelTypePayload & { enabled: boolean };
export type ChannelFormValuesResult = HostSuccess & {
values?: Record<string, string>;
};
export type ChannelCredentialValidationPayload = ChannelTypePayload & {
config: Record<string, unknown>;
};
export type ChannelCredentialValidationResult = HostSuccess & {
valid: boolean;
errors?: string[];
warnings?: string[];
details?: {
botUsername?: string;
guildName?: string;
channelName?: string;
};
};
export type ChannelSaveConfigPayload = ChannelTypePayload & {
config: Record<string, unknown>;
accountId?: string;
};
export type ChannelSaveConfigResult = HostSuccess & {
noChange?: boolean;
warning?: string;
};
export type ChannelConfiguredResult = HostSuccess & { channels?: Array<string | JsonRecord> };
export type AgentSnapshotResult = AgentsSnapshot & OptionalHostSuccess;
export type AgentCreatePayload = { name: string; inheritWorkspace?: boolean };
export type AgentUpdatePayload = { id: string; name: string };
export type AgentUpdateModelPayload = { id: string; modelRef: string | null };
export type AgentIdPayload = { id: string };
export type AgentChannelPayload = { id: string; channelType: string };
export type DiagnosticsGatewaySnapshotResult = JsonRecord;
export type ProviderType =
| 'anthropic'
| 'openai'
| 'google'
| 'openrouter'
| 'ark'
| 'moonshot'
| 'moonshot-global'
| 'siliconflow'
| 'deepseek'
| 'minimax-portal'
| 'minimax-portal-cn'
| 'modelstudio'
| 'ollama'
| 'custom';
export type ProviderAuthMode = 'api_key' | 'oauth_device' | 'oauth_browser' | 'local';
export type ProviderVendorCategory = 'official' | 'compatible' | 'local' | 'custom';
export type ProviderProtocol =
| 'openai-completions'
| 'openai-responses'
| 'openai-codex-responses'
| 'anthropic-messages'
| 'google-generative-ai'
| 'github-copilot'
| 'bedrock-converse-stream'
| 'ollama'
| 'azure-openai-responses';
export type ProviderConfig = {
id: string;
name: string;
type: ProviderType;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
};
export type ProviderWithKeyInfo = ProviderConfig & {
hasKey: boolean;
keyMasked: string | null;
};
export type ProviderVendorInfo = {
id: ProviderType;
name: string;
icon: string;
placeholder: string;
model?: string;
requiresApiKey: boolean;
defaultBaseUrl?: string;
showBaseUrl?: boolean;
showModelId?: boolean;
showModelIdInDevModeOnly?: boolean;
modelIdPlaceholder?: string;
defaultModelId?: string;
isOAuth?: boolean;
supportsApiKey?: boolean;
apiKeyUrl?: string;
docsUrl?: string;
docsUrlZh?: string;
codePlanPresetBaseUrl?: string;
codePlanPresetModelId?: string;
codePlanDocsUrl?: string;
hidden?: boolean;
hideOAuthUi?: boolean;
category: ProviderVendorCategory;
envVar?: string;
supportedAuthModes: ProviderAuthMode[];
defaultAuthMode: ProviderAuthMode;
supportsMultipleAccounts: boolean;
};
export type ProviderAccount = {
id: string;
vendorId: ProviderType;
label: string;
authMode: ProviderAuthMode;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackAccountIds?: string[];
enabled: boolean;
isDefault: boolean;
metadata?: {
region?: string;
email?: string;
resourceUrl?: string;
customModels?: string[];
};
createdAt: string;
updatedAt: string;
};
export type ProviderAccountKeyInfo = {
accountId: string;
hasKey: boolean;
keyMasked: string | null;
};
export type ProviderDefaultAccountResult = { accountId: string | null };
export type ProviderValidationOptions = {
baseUrl?: string;
apiProtocol?: string;
};
export type ProviderValidationPayload = {
accountId?: string;
vendorId?: string;
providerId?: string;
apiKey: string;
options?: ProviderValidationOptions;
};
export type ProviderValidationResult = { valid: boolean; error?: string };
export type ProviderIdPayload = { providerId: string };
export type ProviderApiKeyPayload = ProviderIdPayload & { apiKey: string };
export type ProviderSavePayload = { config: ProviderConfig; apiKey?: string };
export type ProviderUpdateWithKeyPayload = {
providerId: string;
updates: Partial<ProviderConfig>;
apiKey?: string;
};
export type ProviderAccountIdPayload = { accountId: string };
export type ProviderCreateAccountPayload = { account: ProviderAccount; apiKey?: string };
export type ProviderUpdateAccountPayload = {
accountId: string;
updates: Partial<ProviderAccount>;
apiKey?: string;
};
export type ProviderOAuthRequestPayload = {
provider: string;
region?: 'global' | 'cn';
accountId?: string;
label?: string;
};
export type ProviderOAuthSubmitPayload = { code: string };
export type StagedFileResult = {
id: string;
fileName: string;
mimeType: string;
fileSize: number;
stagedPath: string;
preview: string | null;
filePath?: string;
};
export type StagePathsPayload = { filePaths: string[] };
export type StageBufferPayload = { base64: string; fileName: string; mimeType?: string };
export type FilePathPayload = { path: string };
export type FileReadBinaryOptions = { maxBytes?: number };
export type FilePreviewTreeOptions = {
maxDepth?: number;
maxNodes?: number;
includeHidden?: boolean;
};
export type FileReadBinaryPayload = FilePathPayload & { opts?: FileReadBinaryOptions };
export type FileWriteTextPayload = FilePathPayload & { content: string };
export type FileListTreePayload = FilePathPayload & { opts?: FilePreviewTreeOptions };
export type FilePreviewError =
| 'outsideSandbox'
| 'readOnlyRoot'
| 'tooLarge'
| 'binary'
| 'notFound'
| 'notDirectory'
| 'invalidContent'
| (string & {});
export type ReadTextFileResult = {
ok: boolean;
content?: string;
mimeType?: string;
size?: number;
readOnly?: boolean;
error?: FilePreviewError;
};
export type ReadBinaryFileResult = {
ok: boolean;
data?: Uint8Array;
mimeType?: string;
size?: number;
readOnly?: boolean;
error?: FilePreviewError;
};
export type WriteTextFileResult = {
ok: boolean;
error?: FilePreviewError;
};
export type StatFileResult = {
ok: boolean;
size?: number;
mtime?: number;
isFile?: boolean;
isDir?: boolean;
readOnly?: boolean;
error?: FilePreviewError;
};
export type FileListDirEntry = {
name: string;
path: string;
isDir: boolean;
size: number;
};
export type FileListDirResult = {
ok: boolean;
entries?: FileListDirEntry[];
error?: FilePreviewError;
};
export type FilePreviewTreeNode = {
name: string;
relPath: string;
absPath: string;
isDir: boolean;
size?: number;
mtime?: number;
children?: FilePreviewTreeNode[];
};
export type FileListTreeResult = {
ok: boolean;
root?: FilePreviewTreeNode;
truncated?: boolean;
error?: FilePreviewError;
};
export type MediaThumbnailEntry = {
filePath?: string;
gatewayUrl?: string;
mimeType?: string;
};
export type MediaThumbnailsPayload = { paths: MediaThumbnailEntry[] };
export type MediaThumbnailResult = Record<string, { preview: string | null; fileSize: number }>;
export type SaveImagePayload = {
base64?: string;
mimeType?: string;
filePath?: string;
defaultFileName?: string;
};
export type ImageGenerationModelConfig = {
primary: string | null;
fallbacks: string[];
timeoutMs: number | null;
};
export type ImageGenerationAgentAuthRow = {
id: string;
name: string;
isDefault: boolean;
provider: string | null;
configured: boolean;
};
export type OpenAiImageRelayConfig = {
enabled: boolean;
baseUrl: string;
model: string;
providerKey?: string;
apiKeyConfigured: boolean;
};
export type ImageGenerationSettingsSnapshot = {
config: ImageGenerationModelConfig;
autoProviderFallback: boolean;
defaultAgentId: string;
agents: ImageGenerationAgentAuthRow[];
openAiRelay: OpenAiImageRelayConfig;
};
export type ImageGenerationProviderRow = {
id: string;
label: string;
defaultModel: string;
configured: boolean;
available: boolean;
selected: boolean;
models: string[];
};
export type ImageGenerationSettingsPayload = {
primary?: string | null;
fallbacks?: string[];
timeoutMs?: number | null;
openAiRelayEnabled?: boolean;
openAiRelayBaseUrl?: string | null;
openAiRelayModel?: string | null;
openAiRelayApiKey?: string;
};
export type ImageGenerationSettingsResult = OptionalHostSuccess & ImageGenerationSettingsSnapshot;
export type ImageGenerationProvidersResult = OptionalHostSuccess & {
providers?: ImageGenerationProviderRow[];
};
export type ImageGenerationTestPayload = {
agentId?: string;
prompt?: string;
model?: string;
};
export type ImageGenerationTestResult = {
success: boolean;
agentId: string;
command: string;
durationMs: number;
error?: string;
stdout?: string;
stderr?: string;
result?: unknown;
};
export type SessionHistoryPayload = {
sessionKey?: string;
agentId?: string;
sessionId?: string;
limit?: number;
};
export type SessionHistoryResult = OptionalHostSuccess & {
messages?: RawMessage[];
};
export type SessionSummariesPayload = { sessionKeys?: string[]; limit?: number };
export type SessionLabelSummary = {
sessionKey: string;
firstUserText: string | null;
lastTimestamp: number | null;
};
export type SessionSummariesResult = HostSuccess & {
summaries?: SessionLabelSummary[];
};
export type SessionDeletePayload = { id: string };
export type SessionRenamePayload = { id: string; title: string };
export type ChatMediaItem = { filePath: string; mimeType?: string; fileName?: string };
export type ChatSendWithMediaPayload = {
sessionKey: string;
message?: string;
deliver?: boolean;
idempotencyKey: string;
media?: ChatMediaItem[];
};
export type ChatSendWithMediaResult = HostSuccess & {
result?: { runId?: string };
};
export type CronUpdatePayload = { id: string; input: CronJobUpdateInput };
export type CronIdPayload = { id: string };
export type CronTogglePayload = CronIdPayload & { enabled: boolean };
export type CronSessionHistoryPayload = { sessionKey: string; limit?: number };
export type CronSessionHistoryResult = {
messages?: RawMessage[];
};
export type SkillsStatusResult = {
skills?: {
skillKey: string;
slug?: string;
name?: string;
description?: string;
disabled?: boolean;
emoji?: string;
version?: string;
author?: string;
config?: Record<string, unknown>;
bundled?: boolean;
always?: boolean;
source?: string;
baseDir?: string;
filePath?: string;
}[];
};
export type LocalSkillsResult = HostSuccess & { skills?: Skill[] };
export type SkillConfigsResult = Record<string, { enabled?: boolean; apiKey?: string; env?: Record<string, string> }>;
export type SkillKeyPayload = { skillKey: string };
export type SkillUpdateConfigPayload = SkillKeyPayload & {
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
};
export type SkillUpdateConfigsPayload = { updates: SkillUpdateConfigPayload[] };
export type SkillUpdatePayload = SkillKeyPayload & { enabled?: boolean };
export type SkillQuickAccessPayload = { workspace?: string };
export type ClawHubInstalledSkill = {
slug: string;
version?: string;
source?: string;
baseDir?: string;
};
export type ClawHubCapabilityResult = HostSuccess & { capability?: JsonRecord };
export type ClawHubListResult = HostSuccess & {
results?: ClawHubInstalledSkill[];
};
export type ClawHubSearchPayload = { query?: string };
export type ClawHubSearchResult = HostSuccess & {
results?: MarketplaceSkill[];
};
export type ClawHubInstallPayload = { slug: string; version?: string };
export type ClawHubUninstallPayload = { slug: string };
export type ClawHubOpenPayload = {
skillKey?: string;
slug?: string;
baseDir?: string;
};
export type UsageHistoryEntry = {
timestamp: string;
sessionId: string;
agentId: string;
model?: string;
provider?: string;
content?: string;
usageStatus?: 'available' | 'missing' | 'error';
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number;
costUsd?: number;
};
export type UsageHistoryPayload = { limit?: number };
export type DeliveryChannelAccount = {
accountId: string;
name: string;
isDefault: boolean;
};
export type DeliveryChannelGroup = {
channelType: string;
defaultAccountId: string;
accounts: DeliveryChannelAccount[];
};
export type DeliveryTargetsResult = HostSuccess & { targets: DeliveryChannelGroup[] };
export type HostApiContract = {
app: {
openClawDoctor: (payload: OpenClawDoctorPayload) => Omit<OpenClawDoctorResult, 'mode'>;
};
openclaw: {
status: () => OpenClawStatusResult;
getSkillsDir: () => string;
getCliCommand: () => OpenClawCliCommandResult;
};
shell: {
openExternal: (payload: ShellOpenExternalPayload) => void;
showItemInFolder: (payload: ShellPathPayload) => void;
openPath: (payload: ShellPathPayload) => string;
};
dialog: {
open: (payload: DialogOpenPayload) => DialogOpenResult;
message: (payload: DialogMessagePayload) => DialogMessageResult;
};
window: {
syncTrafficLightPosition: (payload: WindowSyncTrafficLightPayload) => void;
minimize: () => void;
maximize: () => void;
close: () => void;
isMaximized: () => boolean;
};
updates: {
status: () => UpdateStatusSnapshot;
version: () => string;
check: () => UpdateCheckResult;
download: () => HostSuccess;
install: () => HostSuccess;
setChannel: (payload: UpdateSetChannelPayload) => HostSuccess;
setAutoDownload: (payload: UpdateSetAutoDownloadPayload) => HostSuccess;
cancelAutoInstall: () => HostSuccess;
};
uv: {
installAll: () => HostSuccess;
};
settings: {
getAll: () => SettingsSnapshot;
get: (payload: SettingsGetPayload) => SettingsValue;
set: (payload: SettingsSetPayload) => HostSuccess;
setMany: (payload: SettingsSetManyPayload) => HostSuccess;
reset: () => SettingsResetResult;
};
gateway: {
status: () => GatewayStatus;
start: () => HostSuccess;
stop: () => HostSuccess;
restart: () => HostSuccess;
health: (payload?: GatewayHealthPayload) => GatewayHealth;
controlUi: (payload?: GatewayControlUiPayload) => GatewayControlUiResult;
rpc: (payload: GatewayRpcPayload) => unknown;
};
logs: {
recent: (payload?: LogRecentPayload) => LogContentResult;
memory: (payload?: LogMemoryPayload) => string[];
dir: () => LogDirResult;
filePath: () => LogFilePathResult;
listFiles: () => LogFilesResult;
readFile: (payload: LogReadFilePayload) => LogContentResult;
};
channels: {
configured: () => ChannelConfiguredResult;
accounts: (payload?: ChannelAccountsPayload) => ChannelAccountsResult;
targets: (payload: ChannelTargetsPayload) => ChannelTargetsResult;
setDefaultAccount: (payload: ChannelRequiredAccountPayload) => HostSuccess;
bindingSave: (payload: ChannelBindingSavePayload) => HostSuccess;
bindingDelete: (payload: ChannelBindingDeletePayload) => HostSuccess;
validateConfig: (payload: ChannelTypePayload) => HostSuccess;
validateCredentials: (payload: ChannelCredentialValidationPayload) => ChannelCredentialValidationResult;
saveConfig: (payload: ChannelSaveConfigPayload) => ChannelSaveConfigResult;
setEnabled: (payload: ChannelSetEnabledPayload) => HostSuccess;
formValues: (payload: ChannelAccountPayload) => ChannelFormValuesResult;
deleteConfig: (payload: ChannelAccountPayload) => HostSuccess;
startLogin: (payload: ChannelAccountPayload) => HostSuccess;
cancelLogin: (payload: ChannelAccountPayload) => HostSuccess;
};
agents: {
list: () => AgentSnapshotResult;
create: (payload: AgentCreatePayload) => AgentSnapshotResult;
update: (payload: AgentUpdatePayload) => AgentSnapshotResult;
updateModel: (payload: AgentUpdateModelPayload) => AgentSnapshotResult;
delete: (payload: AgentIdPayload) => AgentSnapshotResult;
assignChannel: (payload: AgentChannelPayload) => AgentSnapshotResult;
removeChannel: (payload: AgentChannelPayload) => AgentSnapshotResult;
};
diagnostics: {
gatewaySnapshot: () => DiagnosticsGatewaySnapshotResult;
};
providers: {
list: () => ProviderWithKeyInfo[];
get: (payload: ProviderIdPayload) => ProviderConfig | null;
getDefault: () => string | undefined;
hasApiKey: (payload: ProviderIdPayload) => boolean;
getApiKey: (payload: ProviderIdPayload) => string | null;
validateKey: (payload: ProviderValidationPayload) => ProviderValidationResult;
save: (payload: ProviderSavePayload) => HostSuccess;
delete: (payload: ProviderIdPayload) => HostSuccess;
setApiKey: (payload: ProviderApiKeyPayload) => HostSuccess;
updateWithKey: (payload: ProviderUpdateWithKeyPayload) => HostSuccess;
deleteApiKey: (payload: ProviderIdPayload) => HostSuccess;
setDefault: (payload: ProviderIdPayload) => HostSuccess;
accounts: () => ProviderAccount[];
vendors: () => ProviderVendorInfo[];
accountKeyInfo: () => ProviderAccountKeyInfo[];
getDefaultAccount: () => ProviderDefaultAccountResult;
getAccount: (payload: ProviderAccountIdPayload) => ProviderAccount | null;
getAccountApiKey: (payload: ProviderAccountIdPayload) => string | null;
hasAccountApiKey: (payload: ProviderAccountIdPayload) => boolean;
createAccount: (payload: ProviderCreateAccountPayload) => HostSuccess;
updateAccount: (payload: ProviderUpdateAccountPayload) => HostSuccess;
deleteAccount: (payload: ProviderAccountIdPayload) => HostSuccess;
deleteAccountApiKey: (payload: ProviderAccountIdPayload) => HostSuccess;
setDefaultAccount: (payload: ProviderAccountIdPayload) => HostSuccess;
requestOAuth: (payload: ProviderOAuthRequestPayload) => HostSuccess;
cancelOAuth: () => HostSuccess;
submitOAuth: (payload: ProviderOAuthSubmitPayload) => HostSuccess;
};
files: {
stagePaths: (payload: StagePathsPayload) => StagedFileResult[];
stageBuffer: (payload: StageBufferPayload) => StagedFileResult;
readText: (payload: FilePathPayload) => ReadTextFileResult;
readBinary: (payload: FileReadBinaryPayload) => ReadBinaryFileResult;
writeText: (payload: FileWriteTextPayload) => WriteTextFileResult;
stat: (payload: FilePathPayload) => StatFileResult;
listDir: (payload: FilePathPayload) => FileListDirResult;
listTree: (payload: FileListTreePayload) => FileListTreeResult;
};
media: {
thumbnails: (payload: MediaThumbnailsPayload) => MediaThumbnailResult;
saveImage: (payload: SaveImagePayload) => JsonRecord;
imageGenerationSettings: () => ImageGenerationSettingsResult;
saveImageGenerationSettings: (payload: ImageGenerationSettingsPayload) => ImageGenerationSettingsResult;
imageGenerationProviders: () => ImageGenerationProvidersResult;
testImageGeneration: (payload: ImageGenerationTestPayload) => ImageGenerationTestResult;
};
sessions: {
delete: (payload: SessionDeletePayload) => HostSuccess;
rename: (payload: SessionRenamePayload) => HostSuccess;
summaries: (payload?: SessionSummariesPayload) => SessionSummariesResult;
history: (payload: SessionHistoryPayload) => SessionHistoryResult;
};
chat: {
sendWithMedia: (payload: ChatSendWithMediaPayload) => ChatSendWithMediaResult;
};
cron: {
list: () => CronJob[];
create: (payload: CronJobCreateInput) => CronJob;
update: (payload: CronUpdatePayload) => CronJob;
delete: (payload: CronIdPayload) => HostSuccess;
toggle: (payload: CronTogglePayload) => HostSuccess;
trigger: (payload: CronIdPayload) => HostSuccess;
sessionHistory: (payload: CronSessionHistoryPayload) => CronSessionHistoryResult;
deliveryTargets: () => DeliveryTargetsResult;
};
skills: {
local: () => LocalSkillsResult;
configs: () => SkillConfigsResult;
allConfigs: () => SkillConfigsResult;
getConfig: (payload: SkillKeyPayload) => JsonRecord | undefined;
updateConfig: (payload: SkillUpdateConfigPayload) => HostSuccess;
updateConfigs: (payload: SkillUpdateConfigsPayload) => HostSuccess;
status: () => SkillsStatusResult;
update: (payload: SkillUpdatePayload) => HostSuccess;
quickAccess: (payload: SkillQuickAccessPayload) => HostSuccess & { skills?: QuickAccessSkill[] };
clawhubCapability: () => ClawHubCapabilityResult;
clawhubList: () => ClawHubListResult;
clawhubSearch: (payload: ClawHubSearchPayload) => ClawHubSearchResult;
clawhubInstall: (payload: ClawHubInstallPayload) => HostSuccess;
clawhubUninstall: (payload: ClawHubUninstallPayload) => HostSuccess;
clawhubOpenSkillReadme: (payload: ClawHubOpenPayload) => HostSuccess;
clawhubOpenSkillPath: (payload: ClawHubOpenPayload) => HostSuccess;
};
usage: {
recentTokenHistory: (payload?: UsageHistoryPayload) => UsageHistoryEntry[];
};
};
export type HostApiModule = keyof HostApiContract & string;
export type HostApiAction<M extends HostApiModule> = keyof HostApiContract[M] & string;
export type HostApiFunction<
M extends HostApiModule,
A extends HostApiAction<M>,
> = HostApiContract[M][A] extends (...args: infer Args) => infer Result
? (...args: Args) => Result
: never;
export type HostApiPayload<
M extends HostApiModule,
A extends HostApiAction<M>,
> = Parameters<HostApiFunction<M, A>> extends []
? undefined
: Parameters<HostApiFunction<M, A>>[0];
export type HostApiResult<
M extends HostApiModule,
A extends HostApiAction<M>,
> = Awaited<ReturnType<HostApiFunction<M, A>>>;
export type HostApiPayloadArgs<
M extends HostApiModule,
A extends HostApiAction<M>,
> = Parameters<HostApiFunction<M, A>> extends []
? []
: undefined extends HostApiPayload<M, A>
? [payload?: HostApiPayload<M, A>]
: [payload: HostApiPayload<M, A>];
+26
View File
@@ -0,0 +1,26 @@
import type {
HostApiAction,
HostApiModule,
HostApiPayload,
} from './contract';
export type HostRequest = {
id: string;
module: string;
action: string;
payload?: unknown;
};
export type TypedHostRequest<
M extends HostApiModule,
A extends HostApiAction<M>,
> = {
id: string;
module: M;
action: A;
payload?: HostApiPayload<M, A>;
};
export type HostResponse<T = unknown> =
| { id?: string; ok: true; data: T }
| { id?: string; ok: false; error?: { code?: string; message?: string; details?: unknown } };
+151
View File
@@ -0,0 +1,151 @@
import type { UpdateStatusSnapshot } from '../host-api/contract';
import type { ChatRuntimeEvent } from '../chat-runtime-events';
import type {
GatewayNotification,
GatewayRuntimePayload,
GatewayRuntimeRecord,
GatewayStatus,
} from '../types/gateway';
export type { GatewayRuntimePayload } from '../types/gateway';
export type JsonRecord = Record<string, unknown>;
export type GatewayErrorEvent = string | { message?: string };
export type GatewayChatMessageEvent = GatewayRuntimeRecord & {
message?: GatewayRuntimePayload;
runId?: GatewayRuntimePayload;
};
export type GatewayChannelStatusEvent = {
channelId: string;
status: string;
};
export type GatewayExitEvent = number | null | { code: number | null };
export type OAuthCodeEvent =
| {
provider: string;
mode: 'manual';
authorizationUrl: string;
message?: string;
}
| {
provider: string;
mode?: 'device';
verificationUri: string;
userCode: string;
expiresIn: number;
};
export type OAuthSuccessEvent = {
provider: string;
accountId: string;
success?: boolean;
};
export type OAuthErrorEvent = {
message: string;
};
export type ChannelQrEvent = {
qr?: string;
raw?: string;
sessionKey?: string;
};
export type ChannelSuccessEvent = {
accountId?: string;
rawAccountId?: string;
message?: string;
};
export type ChannelErrorEvent = string | { message?: string };
export type UpdateAutoInstallCountdownEvent = {
seconds: number;
cancelled?: boolean;
};
export type HostEventContract = {
gateway: {
statusChanged: (payload: GatewayStatus) => void;
message: (payload: unknown) => void;
notification: (payload: GatewayNotification) => void;
healthChanged: (payload: GatewayRuntimePayload) => void;
presenceChanged: (payload: GatewayRuntimePayload) => void;
chatMessage: (payload: GatewayChatMessageEvent) => void;
channelStatus: (payload: GatewayChannelStatusEvent) => void;
exit: (payload: GatewayExitEvent) => void;
error: (payload: GatewayErrorEvent) => void;
};
chat: {
runtimeEvent: (payload: ChatRuntimeEvent) => void;
};
oauth: {
code: (payload: OAuthCodeEvent) => void;
success: (payload: OAuthSuccessEvent) => void;
error: (payload: OAuthErrorEvent) => void;
};
channel: {
qr: (payload: ChannelQrEvent) => void;
success: (payload: ChannelSuccessEvent) => void;
error: (payload: ChannelErrorEvent) => void;
};
updates: {
statusChanged: (payload: UpdateStatusSnapshot) => void;
autoInstallCountdown: (payload: UpdateAutoInstallCountdownEvent) => void;
};
app: {
navigate: (path: string) => void;
newChat: () => void;
openClawCliInstalled: (installedPath: string) => void;
};
};
export type HostEventModule = keyof HostEventContract;
export type HostEventName<M extends HostEventModule> = keyof HostEventContract[M] & string;
export type HostEventHandler<
M extends HostEventModule,
E extends HostEventName<M>,
> = HostEventContract[M][E];
export type HostEventArgs<
M extends HostEventModule,
E extends HostEventName<M>,
> = HostEventHandler<M, E> extends (...args: infer Args) => void ? Args : never;
export const HOST_EVENT_CHANNELS = {
gateway: {
statusChanged: 'gateway:status-changed',
message: 'gateway:message',
notification: 'gateway:notification',
healthChanged: 'gateway:health-changed',
presenceChanged: 'gateway:presence-changed',
chatMessage: 'gateway:chat-message',
channelStatus: 'gateway:channel-status',
exit: 'gateway:exit',
error: 'gateway:error',
},
chat: {
runtimeEvent: 'chat:runtime-event',
},
oauth: {
code: 'oauth:code',
success: 'oauth:success',
error: 'oauth:error',
},
updates: {
statusChanged: 'update:status-changed',
autoInstallCountdown: 'update:auto-install-countdown',
},
app: {
navigate: 'navigate',
newChat: 'new-chat',
openClawCliInstalled: 'openclaw:cli-installed',
},
} as const satisfies {
[M in Exclude<HostEventModule, 'channel'>]: {
[E in HostEventName<M>]: string;
};
};
export function buildHostChannelEventName(
channel: string,
event: HostEventName<'channel'>,
): string {
return `channel:${channel}-${event}`;
}
@@ -61,6 +61,7 @@
"gateway": {
"notRunning": "Gateway Not Running",
"notRunningDesc": "The OpenClaw Gateway needs to be running to use this feature. It will start automatically, or you can start it from Settings.",
"restarting": "Gateway restarting",
"warning": "Gateway is not running."
}
}

Some files were not shown because too many files have changed in this diff Show More