mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
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:
@@ -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
@@ -240,18 +240,17 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
│ Main管理のトランスポート戦略
|
||||
│(WS優先、HTTP次点、IPCフォールバック)
|
||||
│ 型付き IPC リクエスト
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API と Main プロキシ層 │
|
||||
│ Main Host Services と Gateway Manager │
|
||||
│ │
|
||||
│ • hostapi:fetch(Mainプロキシ、CORS回避) │
|
||||
│ • gateway:httpProxy(Rendererは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 スキーマ/定数
|
||||
|
||||
@@ -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
@@ -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/常量
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
+84
-1595
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 : {}),
|
||||
};
|
||||
}
|
||||
@@ -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() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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))),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
@@ -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>];
|
||||
@@ -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 } };
|
||||
@@ -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
Reference in New Issue
Block a user