Compare commits

..
38 Commits
Author SHA1 Message Date
Haze 6c97df69d2 0.4.5-beta.0 2026-05-20 10:27:46 +08:00
paisley d9f42930d7 Fix false "no response" error during tool-call chains when history poll shows progress (#1047) 2026-05-20 10:11:14 +08:00
paisley 2f7466e2dd fix session rename (#1046) 2026-05-19 19:04:17 +08:00
paisley dbe9d3c9bb fix(chat): dedupe optimistic messages and defer transient terminated errors (#1044) 2026-05-19 18:01:12 +08:00
paisley 1aa4776ea5 fix: OpenAI OAuth login and provider list cleanup (#1043) 2026-05-19 15:51:42 +08:00
paisley 0d5323360c fix: ensure maxTokens for anthropic-messages providers in openclaw.json (#1042) 2026-05-19 14:48:58 +08:00
paisley 3de1fed446 release 0.4.4 (#1037) 2026-05-18 19:21:07 +08:00
Haze 035ee8e32c feat(provider): add option to hide OAuth UI in Add Provider dialog (#1036) 2026-05-18 19:17:27 +08:00
paisley 4136b04c42 fix(chat): keep toolbar background draggable (#1035) 2026-05-18 17:54:38 +08:00
566a2d752d Update sidebar history buckets (#1034)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-18 16:52:29 +08:00
paisley a417d4ec6b feat(chat): add toolbar question directory (#1032) 2026-05-18 16:37:29 +08:00
8af070a7a0 Remove assistant reply bubble styling (#1033)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-18 16:33:38 +08:00
Lingxuan Zuo 8df7eee0c1 [issue follow up] Keep chat runs active across gateway phase end (#1027) 2026-05-18 16:18:14 +08:00
paisley f4d6478f1c feat(chat): add scroll-to-latest button (#1031) 2026-05-18 15:09:08 +08:00
Haze fdc0b0d746 fix(chat): chat bubble loss thinking status error (#1030) 2026-05-18 14:36:53 +08:00
paisley dea38c0c44 feat: add rendered HTML file preview (#1029) 2026-05-18 13:42:38 +08:00
paisley 0ee8f5e202 fix channel degraded misleading info (#1028) 2026-05-18 11:41:04 +08:00
Haze 60b5209cf6 fix(provider): add model provider error (#1025) 2026-05-18 10:21:52 +08:00
Lingxuan Zuo 3edeb8cdfa Reduce packaged app size (#1026) 2026-05-17 23:28:09 +08:00
paisley 34bfae2851 Upgrade openclaw to 5.12 (#1023) 2026-05-15 17:13:43 +08:00
paisley 360b6649ba fix clawx version (#1021) 2026-05-15 11:53:44 +08:00
paisley 75343d58d1 Feat/auto update (#1018) 2026-05-15 11:21:11 +08:00
Kagura 160160ecde feat(chat): add session rename in sidebar (#1016) 2026-05-14 13:12:41 +08:00
paisley 80251a5825 Fix macOS right-side window drag region (#1015) 2026-05-14 11:30:45 +08:00
Haze b0e55692f5 refactor(doc): Update screenshots for English, Japanese, Russian, and Chinese locales (#1014) 2026-05-14 11:02:37 +08:00
Haze ed1525e25b release: v0.4.3 tag commit (#1012) 2026-05-14 11:02:11 +08:00
paisley 4865afc6e6 Fix gateway rpc error (#1010) 2026-05-13 15:29:44 +08:00
Haze 42d62f5002 fix(sytle): windows sidebar and cronjob card front style fix (#1011) 2026-05-13 14:30:36 +08:00
paisley 62c11d2441 fix acpx deps (#1008) 2026-05-13 10:00:21 +08:00
58aa88b3ae feat(session): hard-delete JSONL transcript on conversation delete (#986)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-12 19:09:25 +08:00
2327f0ba6a fix(chat): stop splitting message bubble text mid-word (closes #931) (#1001)
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:05:54 +08:00
Haze 7dc18bb6e7 refact(front): optimize front style (#1007) 2026-05-12 18:37:40 +08:00
paisley 5c1ed5e6ee fix missing deps (#1005) 2026-05-11 19:29:17 +08:00
paisley abce8a8b96 Fix chat load history (#1004) 2026-05-11 19:25:39 +08:00
arianachennn-design 087b38a39a docs: add enterprise inquiry callout to README overview in all locales (#1003) 2026-05-11 17:03:29 +08:00
Tao YipingandHaze cbf70fb299 feat(models): Added a Test button in Provider Settings that validates… (#992)
Co-authored-by: Haze <709547807@qq.com>
2026-05-11 11:51:28 +08:00
Haze 8a8cd73919 release 0.4.2 (#999) 2026-05-11 10:46:59 +08:00
paisley a7f4552be6 fix(openclaw): seed default ClawX identity to avoid bootstrap (#991) 2026-05-11 10:28:02 +08:00
194 changed files with 12119 additions and 3053 deletions
+10
View File
@@ -25,6 +25,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -69,6 +74,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -35,6 +35,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -37,6 +37,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -33,6 +33,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -43,6 +43,11 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
+5
View File
@@ -57,6 +57,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
+5
View File
@@ -31,6 +31,11 @@ jobs:
node-version: "24"
cache: "pnpm"
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
+1
View File
@@ -63,6 +63,7 @@ resources/bin
build/
artifacts/
.delivery/
docs/pr-session-notes-*.md
.cursor/
+1 -1
View File
@@ -33,7 +33,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. Note: "Delete conversation" in the sidebar is a hard delete — the Main process unlinks `<id>.jsonl` plus any leftover `<id>.deleted.jsonl` and `<id>.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`<id>.trajectory.jsonl` flight recorder + `<id>.trajectory-path.json` pointer); when the pointer references a runtime file outside the agent's `sessions/` folder (the `OPENCLAW_TRAJECTORY_DIR` override), that off-disk file is unlinked too. Deleted conversations stop contributing to this chart — use a fresh session if you want history retained.
- **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.
+6
View File
@@ -43,6 +43,8 @@
ClawXはベストプラクティスのモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">完全なエンタープライズ版、専用のサービスサポート、または御社のビジネスシナリオに合わせた導入支援が必要な場合は、<a href="mailto:public@valuecell.ai">public@valuecell.ai</a> までお問い合わせください。</strong></p>
---
## スクリーンショット
@@ -81,6 +83,7 @@ AIエージェントの構築にコマンドラインの習得は不要である
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
| アプリ更新 | 起動時に更新を確認し、ダウンロードやインストール前に通知 |
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
@@ -135,6 +138,9 @@ OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う
### 🚀 自動起動設定
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
### 🔔 更新通知
ClawX は起動時に新しいバージョンを自動確認できます。更新が見つかるとアプリ内通知を表示し、ダウンロードやインストールはユーザーが選択した後にのみ実行されます。
---
## はじめに
+6
View File
@@ -43,6 +43,8 @@ Whether you're automating workflows, managing AI-powered channels, or scheduling
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. Of course, you can also fine-tune advanced configurations via **Settings → Advanced → Developer Mode**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">For a full enterprise edition, dedicated service support, or tailored deployment guidance for your business scenario, contact us at <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
---
## Screenshot
@@ -81,6 +83,7 @@ Building AI agents shouldn't require mastering the command line. ClawX was desig
| Complex CLI setup | One-click installation with guided setup wizard |
| Configuration files | Visual settings with real-time validation |
| Process management | Automatic gateway lifecycle management |
| App updates | Startup update checks with a prompt before downloading or installing |
| Multiple AI providers | Unified provider configuration panel |
| Skill/plugin installation | Built-in skill marketplace and management |
@@ -136,6 +139,9 @@ Light mode, dark mode, or system-synchronized themes. ClawX adapts to your prefe
### 🚀 Startup Launch Control
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
### 🔔 Update Prompts
ClawX can automatically check for new versions on startup. When an update is available, it shows an in-app prompt; downloading and installing only happen after you choose the action.
---
## Getting Started
+2
View File
@@ -43,6 +43,8 @@
ClawX поставляется с предустановленными лучшими практиками для провайдеров моделей и нативно поддерживает Windows, а также многоязычные настройки. Вы можете тонко настроить расширенные параметры через **Настройки → Дополнительно → Режим разработчика**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">Для получения полной корпоративной версии, специализированной поддержки или индивидуального сопровождения внедрения под ваш бизнес-сценарий, свяжитесь с нами по адресу <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
---
## Скриншоты
+6
View File
@@ -43,6 +43,8 @@
ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平台以及多语言设置。当然,你也可以通过 **设置 → 高级 → 开发者模式** 来进行精细的高级配置。
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">如需完整的企业版、专属服务支持或面向您业务场景的定制化落地辅导,请联系 <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>。</strong></p>
---
## 截图预览
@@ -82,6 +84,7 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
| 复杂的命令行配置 | 一键安装,配合引导式设置向导 |
| 手动编辑配置文件 | 可视化设置界面,实时校验 |
| 进程管理繁琐 | 自动管理网关生命周期 |
| 应用更新 | 启动时检查新版本,并在下载或安装前提示确认 |
| 多 AI 供应商切换 | 统一的供应商配置面板 |
| 技能/插件安装复杂 | 内置技能市场与管理界面 |
@@ -136,6 +139,9 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor
### 🚀 开机启动控制
**设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
### 🔔 更新提示
ClawX 可以在启动时自动检查新版本。发现更新后会显示应用内提示;只有在你选择操作后,才会下载或安装更新。
---
## 快速上手
+26 -1
View File
@@ -25,10 +25,13 @@ import {
listAgentsSnapshotFromConfig,
} from '../../utils/agent-config';
import {
ensureDiscordPluginInstalled,
ensureDingTalkPluginInstalled,
ensureFeishuPluginInstalled,
ensureQQBotPluginInstalled,
ensureWeChatPluginInstalled,
ensureWeComPluginInstalled,
ensureWhatsAppPluginInstalled,
} from '../../utils/plugin-install';
import {
computeChannelRuntimeStatus,
@@ -1471,7 +1474,28 @@ export async function handleChannelRoutes(
return true;
}
}
// QQBot is a built-in channel since OpenClaw 3.31 — no plugin install needed
if (storedChannelType === 'discord') {
const installResult = await ensureDiscordPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'Discord plugin install failed' });
return true;
}
}
if (storedChannelType === 'qqbot') {
const installResult = await ensureQQBotPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'QQBot plugin install failed' });
return true;
}
}
if (storedChannelType === 'whatsapp') {
const installResult = await ensureWhatsAppPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'WhatsApp plugin install failed' });
return true;
}
}
// QQBot is installed as an official external channel plugin for this OpenClaw version.
if (storedChannelType === 'feishu') {
const installResult = await ensureFeishuPluginInstalled();
if (!installResult.installed) {
@@ -1489,6 +1513,7 @@ export async function handleChannelRoutes(
const existingValues = await getChannelFormValues(body.channelType, body.accountId);
if (isSameConfigValues(existingValues, body.config)) {
await ensureScopedChannelBinding(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
sendJson(res, 200, { success: true, noChange: true });
return true;
}
+4 -6
View File
@@ -143,7 +143,7 @@ export async function handleProviderRoutes(
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google' || body.provider === 'openai') {
if (body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
@@ -230,10 +230,8 @@ export async function handleProviderRoutes(
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 === 'google'
? 'google-gemini-cli'
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai'
? 'openai-codex'
: undefined;
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService._deleteProviderApiKeyInternal(accountId);
@@ -314,7 +312,7 @@ export async function handleProviderRoutes(
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google' || body.provider === 'openai') {
if (body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
+396 -59
View File
@@ -1,10 +1,283 @@
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 {
removeSessionEntry,
resolveSessionTranscriptPath,
sweepSessionArtefacts,
} from '../../utils/session-files';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const RECENT_TRANSCRIPT_INITIAL_READ_BYTES = 256 * 1024;
const RECENT_TRANSCRIPT_MAX_READ_BYTES = 8 * 1024 * 1024;
const RECENT_TRANSCRIPT_MAX_SCAN_LINES = 5_000;
type SessionSummary = {
sessionKey: string;
firstUserText: string | null;
lastTimestamp: number | null;
};
type TranscriptMessage = {
role?: unknown;
content?: unknown;
timestamp?: unknown;
};
type ParsedTranscriptLine = {
type?: string;
message?: TranscriptMessage;
};
function extractMessageText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return (content as Array<{ type?: unknown; text?: unknown }>)
.filter((block) => block?.type === 'text' && typeof block.text === 'string' && block.text.trim())
.map((block) => String(block.text))
.join('\n')
.trim();
}
function cleanSummaryUserText(text: string): string {
return text
.replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^Sender\s*\([^)]*\)\s*:[^\n]*(?:\n\s*)*/i, '')
.replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^Sender\s*:[^\n]*(?:\n\s*)*/i, '')
.replace(/^```json\n[\s\S]*?```\s*/i, '')
.replace(/^\{[\s\S]*?\}\s*/i, '')
.replace(/\s*\[media attached:[^\]]*\]/g, '')
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
.trim();
}
function isInternalSummaryText(text: string): boolean {
if (!text) return true;
if (/^\s*System\s*\(untrusted\)\s*:/i.test(text)) return true;
if (
/An async command you ran earlier has completed/i.test(text)
&& /Do not relay it to the user unless explicitly requested/i.test(text)
) {
return true;
}
if (
/^\s*Current time\s*:/i.test(text)
&& /^\s*Current time\s*:[^\n]*\/\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+UTC\s*$/i.test(text)
) {
return true;
}
return false;
}
function normalizeTimestamp(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return value < 1e12 ? value * 1000 : value;
}
function parseMessageLine(line: string): TranscriptMessage | null {
try {
const entry = JSON.parse(line) as ParsedTranscriptLine;
if (entry.type !== 'message' || !entry.message || typeof entry.message !== 'object') {
return null;
}
return entry.message;
} catch {
return null;
}
}
function parseRecentMessagesFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessage[] {
const lines = chunk.split(/\r?\n/);
if (readStart > 0) lines.shift();
const collected: TranscriptMessage[] = [];
let scanned = 0;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line?.trim()) continue;
scanned += 1;
if (scanned > RECENT_TRANSCRIPT_MAX_SCAN_LINES) break;
const message = parseMessageLine(line);
if (message) {
collected.push(message);
if (collected.length >= limit) break;
}
}
return collected.reverse();
}
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
const boundedLimit = Math.max(1, Math.min(Math.floor(limit), 1000));
let fd: number | null = null;
try {
fd = openSync(transcriptPath, 'r');
const size = fstatSync(fd).size;
if (size === 0) return [];
let readBytes = Math.min(size, Math.max(RECENT_TRANSCRIPT_INITIAL_READ_BYTES, boundedLimit * 2048));
while (readBytes <= size) {
const readStart = Math.max(0, size - readBytes);
const readLen = size - readStart;
const buffer = Buffer.allocUnsafe(readLen);
readSync(fd, buffer, 0, readLen, readStart);
const messages = parseRecentMessagesFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
if (
messages.length >= boundedLimit
|| readStart === 0
|| readBytes >= RECENT_TRANSCRIPT_MAX_READ_BYTES
) {
return messages;
}
readBytes = Math.min(size, readBytes * 2);
}
return [];
} finally {
if (fd !== null) closeSync(fd);
}
}
async function readAllTranscriptMessages(transcriptPath: string): Promise<TranscriptMessage[]> {
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(transcriptPath, 'utf8');
return raw.split(/\r?\n/).filter(Boolean).flatMap((line) => {
const message = parseMessageLine(line);
return message ? [message] : [];
});
}
function summarizeTranscriptMessages(sessionKey: string, messages: TranscriptMessage[]): SessionSummary {
let firstUserText: string | null = null;
let lastTimestamp: number | null = null;
for (const message of messages) {
const normalizedTs = normalizeTimestamp(message.timestamp);
if (normalizedTs != null) {
lastTimestamp = normalizedTs;
}
if (firstUserText == null && message.role === 'user') {
const text = cleanSummaryUserText(extractMessageText(message.content));
if (text && !isInternalSummaryText(text)) {
firstUserText = text;
}
}
}
return { sessionKey, firstUserText, lastTimestamp };
}
function parseSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 3) return null;
const agentId = parts[1] || '';
const suffix = parts.slice(2).join(':');
if (!SAFE_SESSION_SEGMENT.test(agentId) || !suffix) return null;
return { agentId, suffix };
}
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');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
return JSON.parse(raw) as Record<string, unknown>;
}
function resolveSessionTranscriptPathByKey(
sessionKey: string,
sessionsDir: string,
sessionsJson: Record<string, unknown>,
): string | null {
let resolvedSrcPath: string | undefined;
let fileName: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((session) => session.key === sessionKey || session.sessionKey === sessionKey);
if (entry) {
fileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!fileName && typeof entry.id === 'string') {
fileName = `${entry.id}.jsonl`;
}
const absFile = (entry.sessionFile ?? entry.absolutePath) as string | undefined;
if (absFile && (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/))) {
resolvedSrcPath = absFile;
}
}
}
if (!fileName && !resolvedSrcPath && sessionsJson[sessionKey] != null) {
const value = sessionsJson[sessionKey];
if (typeof value === 'string') {
fileName = value;
} else if (typeof value === 'object' && value !== null) {
const entry = value as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
fileName = absFile;
}
} else {
const id = (entry.id ?? entry.sessionId) as string | undefined;
if (id) fileName = id.endsWith('.jsonl') ? id : `${id}.jsonl`;
}
}
}
if (!resolvedSrcPath && fileName) {
resolvedSrcPath = join(sessionsDir, fileName.endsWith('.jsonl') ? fileName : `${fileName}.jsonl`);
}
return resolvedSrcPath ?? null;
}
async function loadSessionSummary(sessionKey: string): Promise<SessionSummary> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
const messages = await readAllTranscriptMessages(transcriptPath);
return summarizeTranscriptMessages(sessionKey, messages);
} catch {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
}
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<unknown[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) return null;
return readRecentTranscriptMessages(transcriptPath, limit);
} catch {
return null;
}
}
export async function handleSessionRoutes(
req: IncomingMessage,
@@ -12,8 +285,41 @@ export async function handleSessionRoutes(
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/sessions/summaries' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKeys?: string[] }>(req);
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;
}
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() || '';
if (!agentId || !sessionId) {
@@ -26,17 +332,7 @@ export async function handleSessionRoutes(
}
const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(transcriptPath, 'utf8');
const lines = raw.split(/\r?\n/).filter(Boolean);
const messages = lines.flatMap((line) => {
try {
const entry = JSON.parse(line) as { type?: string; message?: unknown };
return entry.type === 'message' && entry.message ? [entry.message] : [];
} catch {
return [];
}
});
const messages = readRecentTranscriptMessages(transcriptPath, limit);
sendJson(res, 200, { success: true, messages });
} catch (error) {
@@ -49,6 +345,13 @@ export async function handleSessionRoutes(
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);
@@ -63,66 +366,39 @@ export async function handleSessionRoutes(
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>;
let uuidFileName: string | undefined;
let resolvedSrcPath: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
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}` });
}
}
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
return true;
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
} catch {
// Non-fatal; still try to update sessions.json.
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>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {
@@ -131,5 +407,66 @@ export async function handleSessionRoutes(
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;
}
+6 -16
View File
@@ -33,7 +33,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources } from '../utils/plugin-install';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
import {
@@ -71,6 +71,9 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
discord: { dirName: 'discord', npmName: '@openclaw/discord' },
qqbot: { dirName: 'qqbot', npmName: '@openclaw/qqbot' },
whatsapp: { dirName: 'whatsapp', npmName: '@openclaw/whatsapp' },
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
};
@@ -107,19 +110,6 @@ function readPluginVersion(pkgJsonPath: string): string | null {
}
}
function buildBundledPluginSources(pluginDirName: string): string[] {
return app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', pluginDirName),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', pluginDirName),
join(process.cwd(), 'build', 'openclaw-plugins', pluginDirName),
];
}
function measureSync<T>(timings: Record<string, number>, key: string, fn: () => T): T {
const startedAt = Date.now();
try {
@@ -164,7 +154,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
const installedVersion = isInstalled ? readPluginVersion(join(targetDir, 'package.json')) : null;
// Try bundled sources first (packaged mode or if bundle-plugins was run)
const bundledSources = buildBundledPluginSources(dirName);
const bundledSources = buildCandidateSources(dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
if (bundledDir) {
@@ -249,7 +239,7 @@ function buildPluginSourceSignatures(configuredChannels: string[]): Record<strin
for (const channelType of [...configuredChannels].sort()) {
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
if (!pluginInfo) continue;
const bundledSources = buildBundledPluginSources(pluginInfo.dirName);
const bundledSources = buildCandidateSources(pluginInfo.dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
const devPkgPath = join(process.cwd(), 'node_modules', ...pluginInfo.npmName.split('/'));
const sourceDir = bundledDir || (!app.isPackaged ? devPkgPath : '');
+27 -7
View File
@@ -184,12 +184,13 @@ export class GatewayManager extends EventEmitter {
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
public static readonly RESTART_COOLDOWN_MS = 5_000;
private static readonly GATEWAY_READY_FALLBACK_MS = 30_000;
private static readonly GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS = [1_500, 3_000, 5_000, 8_000, 12_000, 30_000] as const;
private static readonly INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS = 5 * 60_000;
private lastRestartAt = 0;
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
private isAutoReconnectStart = false;
private gatewayReadyFallbackTimer: NodeJS.Timeout | null = null;
private gatewayReadyFallbackAttempt = 0;
private readonly capabilityMonitor = new GatewayCapabilityMonitor();
private diagnostics: GatewayDiagnosticsSnapshot = {
consecutiveHeartbeatMisses: 0,
@@ -227,7 +228,7 @@ export class GatewayManager extends EventEmitter {
// so that async file I/O and key generation don't block module loading.
this.on('gateway:ready', () => {
this.clearGatewayReadyFallback();
this.resetGatewayReadyFallback();
this.clearInitialReadyHeartbeatRecoveryTimer();
if (this.status.state === 'running' && !this.status.gatewayReady) {
logger.info('Gateway subsystems ready (event received)');
@@ -337,6 +338,7 @@ export class GatewayManager extends EventEmitter {
}
this.isAutoReconnectStart = false; // consume the flag
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts, gatewayReady: false });
this.resetGatewayReadyFallback();
// Check if Python environment is ready (self-healing) asynchronously.
// Fire-and-forget: only needs to run once, not on every retry.
@@ -780,23 +782,40 @@ export class GatewayManager extends EventEmitter {
clearTimeout(this.reloadDebounceTimer);
this.reloadDebounceTimer = null;
}
this.clearGatewayReadyFallback();
this.resetGatewayReadyFallback();
this.clearInitialReadyHeartbeatRecoveryTimer();
}
private clearGatewayReadyFallback(): void {
private clearGatewayReadyFallbackTimer(): void {
if (this.gatewayReadyFallbackTimer) {
clearTimeout(this.gatewayReadyFallbackTimer);
this.gatewayReadyFallbackTimer = null;
}
}
private scheduleGatewayReadyFallback(): void {
this.clearGatewayReadyFallback();
private resetGatewayReadyFallback(): void {
this.clearGatewayReadyFallbackTimer();
this.gatewayReadyFallbackAttempt = 0;
}
private getNextGatewayReadyFallbackDelayMs(): number {
const delays = GatewayManager.GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS;
const index = Math.min(this.gatewayReadyFallbackAttempt, delays.length - 1);
const delayMs = delays[index]!;
this.gatewayReadyFallbackAttempt += 1;
return delayMs;
}
private scheduleGatewayReadyFallback(delayMs?: number): void {
if (this.status.state !== 'running' || this.status.gatewayReady) {
return;
}
this.clearGatewayReadyFallbackTimer();
const effectiveDelayMs = delayMs ?? this.getNextGatewayReadyFallbackDelayMs();
this.gatewayReadyFallbackTimer = setTimeout(() => {
this.gatewayReadyFallbackTimer = null;
void this.probeGatewayReadyFallback();
}, GatewayManager.GATEWAY_READY_FALLBACK_MS);
}, effectiveDelayMs);
}
private async probeGatewayReadyFallback(): Promise<void> {
@@ -815,6 +834,7 @@ export class GatewayManager extends EventEmitter {
});
if (this.status.state === 'running' && !this.status.gatewayReady) {
logger.info('Gateway ready fallback RPC router probe succeeded');
this.resetGatewayReadyFallback();
this.setStatus({ gatewayReady: true });
}
} catch (error) {
+101
View File
@@ -0,0 +1,101 @@
type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
type QueuedRpc = {
run: () => Promise<void>;
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => (
`${JSON.stringify(key)}:${stableStringify(record[key])}`
)).join(',')}}`;
}
export interface GatewayRpcBackpressureOptions {
maxConcurrentHistory?: number;
}
/**
* Prevents renderer fan-out from forwarding an unbounded number of expensive
* chat.history RPCs to OpenClaw. The Gateway still owns the canonical response;
* this class only coalesces duplicate in-flight history calls and runs distinct
* history requests through a small FIFO queue.
*/
export class GatewayRpcBackpressure {
private readonly maxConcurrentHistory: number;
private readonly inFlightHistory = new Map<string, Promise<unknown>>();
private readonly queue: QueuedRpc[] = [];
private activeHistory = 0;
constructor(options: GatewayRpcBackpressureOptions = {}) {
this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2);
}
run(
method: string,
params: unknown,
timeoutMs: number | undefined,
runner: GatewayRpcRunner,
): Promise<unknown> {
if (method !== 'chat.history') {
return runner(method, params, timeoutMs);
}
const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`;
const existing = this.inFlightHistory.get(key);
if (existing) return existing;
const promise = this.enqueueHistory(() => runner(method, params, timeoutMs))
.finally(() => {
if (this.inFlightHistory.get(key) === promise) {
this.inFlightHistory.delete(key);
}
});
this.inFlightHistory.set(key, promise);
return promise;
}
getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } {
return {
activeHistory: this.activeHistory,
queuedHistory: this.queue.length,
inFlightHistory: this.inFlightHistory.size,
};
}
private enqueueHistory(work: () => Promise<unknown>): Promise<unknown> {
return new Promise((resolve, reject) => {
const queued: QueuedRpc = {
run: async () => {
this.activeHistory += 1;
try {
resolve(await work());
} catch (error) {
reject(error);
} finally {
this.activeHistory -= 1;
this.drain();
}
},
};
this.queue.push(queued);
this.drain();
});
}
private drain(): void {
while (this.activeHistory < this.maxConcurrentHistory) {
const next = this.queue.shift();
if (!next) return;
void next.run();
}
}
}
+4 -2
View File
@@ -102,6 +102,8 @@ export async function waitForGatewayReady(options: {
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
}
const GATEWAY_PROTOCOL_VERSION = 4;
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
@@ -145,8 +147,8 @@ export function buildGatewayConnectFrame(options: {
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
minProtocol: GATEWAY_PROTOCOL_VERSION,
maxProtocol: GATEWAY_PROTOCOL_VERSION,
client: {
id: clientId,
displayName: 'ClawX',
+13 -1
View File
@@ -21,7 +21,11 @@ import { extensionRegistry } from '../extensions/registry';
import { loadExtensionsFromManifest } from '../extensions/loader';
import { registerAllBuiltinExtensions } from '../extensions/builtin';
import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated';
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
import {
ensureClawXContext,
ensureClawXDefaultIdentity,
repairClawXOnlyBootstrapFiles,
} from '../utils/openclaw-workspace';
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
import { isQuitting, setQuitting } from './app-state';
import { applyProxySettings } from './proxy';
@@ -366,6 +370,14 @@ async function initialize(): Promise<void> {
// Note: Auto-check for updates is driven by the renderer (update store init)
// so it respects the user's "Auto-check for updates" setting.
// Seed a stable default IDENTITY.md before the Gateway initializes the
// workspace so ClawX desktop sessions skip OpenClaw's chat-first bootstrap.
if (!isE2EMode) {
void ensureClawXDefaultIdentity().catch((error) => {
logger.warn('Failed to seed default ClawX identity:', error);
});
}
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
// template content). This fixes a race condition where ensureClawXContext()
// previously created the file before the gateway could seed the full template.
+122 -77
View File
@@ -24,6 +24,11 @@ import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { logger } from '../utils/logger';
import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
import {
removeSessionEntry,
resolveSessionTranscriptPath,
sweepSessionArtefacts,
} from '../utils/session-files';
import {
saveChannelConfig,
getChannelConfig,
@@ -62,6 +67,7 @@ import {
} from '../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
import { appUpdater } from './updater';
import { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
import { registerHostApiProxyHandlers } from './ipc/host-api-proxy';
import {
isLaunchAtStartupKey,
@@ -71,6 +77,8 @@ import {
type AppResponse,
} from './ipc/request-helpers';
const gatewayRpcBackpressure = new GatewayRpcBackpressure();
/**
* Register all IPC handlers
*/
@@ -1211,7 +1219,12 @@ function registerGatewayHandlers(
// Gateway RPC call
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
try {
const result = await gatewayManager.rpc(method, params, timeoutMs);
const result = await gatewayRpcBackpressure.run(
method,
params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
return { success: true, result };
} catch (error) {
logger.warn(`[gateway:rpc] ${method} failed (timeoutMs=${timeoutMs ?? 30000}): ${String(error)}`);
@@ -1748,7 +1761,7 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
) => {
try {
logger.info(`provider:requestOAuth for ${provider}`);
if (provider === 'google' || provider === 'openai') {
if (provider === 'openai') {
await browserOAuthManager.startFlow(provider, options);
} else {
await deviceOAuthManager.startFlow(provider, region, options);
@@ -2343,6 +2356,7 @@ const EXT_MIME_MAP: Record<string, string> = {
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
@@ -2586,11 +2600,31 @@ async function resolveOutgoingMediaUrl(
/**
* Session IPC handlers
*
* Performs a soft-delete of a session's JSONL transcript on disk.
* Performs a HARD delete of a session's JSONL transcript on disk.
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list.
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<id>.jsonl
* (where <id> is typically a UUID resolved via sessions.json).
*
* For each deleted session we unlink every file that belongs to its on-disk id:
* - <id>.jsonl — the live transcript
* - <id>.deleted.jsonl — leftovers from earlier soft-delete releases
* - <id>.jsonl.reset.* — historical snapshots produced by sessions.reset
* - <id>.trajectory.jsonl — OpenClaw runtime "flight recorder" sidecar
* - <id>.trajectory-path.json — pointer to the runtime trajectory; if it
* points outside the sessions/ folder
* (OPENCLAW_TRAJECTORY_DIR override) the
* referenced file is unlinked too.
*
* The session entry is also removed from sessions.json so sessions.list stops
* surfacing it. Token-usage history reported by the Dashboard reads the same
* transcripts, so deleted conversations stop contributing to the chart.
*
* Path resolution and the sibling sweep are shared with the HTTP mirror at
* `electron/api/routes/sessions.ts` via `electron/utils/session-files.ts`,
* so both surfaces unlink the same set of files for a given session id.
*/
const SAFE_AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
function registerSessionHandlers(): void {
ipcMain.handle('session:delete', async (_, sessionKey: string) => {
try {
@@ -2604,6 +2638,13 @@ function registerSessionHandlers(): void {
}
const agentId = parts[1];
// Defence-in-depth: agentId becomes a path segment under
// ~/.openclaw/agents/. Reject anything that could escape that root
// (".." segments, slashes, NULs, etc.) before touching the FS.
if (!SAFE_AGENT_ID.test(agentId)) {
return { success: false, error: `Invalid agentId: ${agentId}` };
}
const openclawConfigDir = getOpenClawConfigDir();
const sessionsDir = join(openclawConfigDir, 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
@@ -2623,94 +2664,41 @@ function registerSessionHandlers(): void {
return { success: false, error: `Could not read sessions.json: ${String(e)}` };
}
// sessions.json structure: try common shapes used by OpenClaw Gateway:
// Shape A (array): { sessions: [{ key, file, ... }] }
// Shape B (object): { [sessionKey]: { file, ... } }
// Shape C (array): { sessions: [{ key, id, ... }] } — id is the UUID
let uuidFileName: string | undefined;
// Shape A / C — array under "sessions" key
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
// Could be "file", "fileName", "id" + ".jsonl", or "path"
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
if (!resolution.ok) {
if (resolution.failure.kind === 'not-found') {
const rawVal = sessionsJson[sessionKey];
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`);
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}` };
}
// Shape B — flat object keyed by sessionKey; value may be a string or an object.
// Actual Gateway format: { sessionFile: "/abs/path/uuid.jsonl", sessionId: "uuid", ... }
let resolvedSrcPath: string | undefined;
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
// Priority: absolute sessionFile path > relative file/fileName/path > id/sessionId as UUID
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
// Absolute path — use directly
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
// Fall back to UUID fields
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
const rawVal = sessionsJson[sessionKey];
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`);
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
}
// Normalise: if we got a relative filename, resolve it against sessionsDir
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution;
logger.info(`[session:delete] file: ${resolvedSrcPath}`);
// ── Step 2: rename the JSONL file ──
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
logger.info(`[session:delete] Renamed ${resolvedSrcPath}${dstPath}`);
} catch (e) {
logger.warn(`[session:delete] Could not rename file: ${String(e)}`);
// ── Step 2: hard-delete the JSONL transcript and its siblings ──
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}`);
// ── Step 3: remove the entry from sessions.json ──
try {
// Re-read to avoid race conditions
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`);
} catch (e) {
logger.warn(`[session:delete] Could not update sessions.json: ${String(e)}`);
// Non-fatal — JSONL rename already done
// Non-fatal — transcript files were already unlinked.
}
return { success: true };
@@ -2719,6 +2707,63 @@ function registerSessionHandlers(): void {
return { success: false, error: String(err) };
}
});
ipcMain.handle('session:rename', async (_, sessionKey: string, label: string) => {
try {
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_AGENT_ID.test(agentId)) {
return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` };
}
const sessionsJsonPath = join(
getOpenClawConfigDir(),
'agents',
agentId,
'sessions',
'sessions.json',
);
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const json = JSON.parse(raw) as Record<string, unknown>;
// Update label in sessions.json — supports both object-keyed and array formats
let found = false;
if (json[sessionKey] && typeof json[sessionKey] === 'object') {
(json[sessionKey] as Record<string, unknown>).label = label.trim();
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 = label.trim();
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=${label.trim()}`);
return { success: true };
} catch (err) {
logger.error(`[session:rename] Unexpected error for ${sessionKey}:`, err);
return { success: false, error: String(err) };
}
});
}
// ── File preview (sandboxed) ──────────────────────────────────────────
+9 -8
View File
@@ -60,7 +60,7 @@ export class AppUpdater extends EventEmitter {
});
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.logger = {
info: (msg: string) => logger.info('[Updater]', msg),
@@ -131,10 +131,6 @@ export class AppUpdater extends EventEmitter {
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
this.updateStatus({ status: 'downloaded', info: event });
this.emit('update-downloaded', event);
if (autoUpdater.autoDownload) {
this.startAutoInstallCountdown();
}
});
autoUpdater.on('error', (error: Error) => {
@@ -270,10 +266,15 @@ export class AppUpdater extends EventEmitter {
}
/**
* Set auto-download preference
* Set auto-download preference.
*
* ClawX uses a prompt-first update flow: finding an update shows a UI prompt,
* and downloads/installations only start after the user chooses an action.
* Keep this legacy IPC method as a no-op-compatible setter so stale renderer
* settings cannot re-enable electron-updater's implicit auto-download path.
*/
setAutoDownload(enable: boolean): void {
autoUpdater.autoDownload = enable;
setAutoDownload(_enable: boolean): void {
autoUpdater.autoDownload = false;
}
/**
+1
View File
@@ -139,6 +139,7 @@ const electronAPI = {
'chat:sendWithMedia',
// Session management
'session:delete',
'session:rename',
// OpenClaw extras
'openclaw:getDir',
'openclaw:getConfigDir',
@@ -5,6 +5,9 @@ import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
import {
ensureAnthropicMessagesModelMaxTokens,
ensureOpenClawProviderAgentRuntimePins,
pruneInvalidApiProviderEntries,
removeProviderFromOpenClaw,
removeProviderKeyFromOpenClaw,
saveOAuthTokenToOpenClaw,
@@ -22,10 +25,8 @@ import {
import { logger } from '../../utils/logger';
import { listAgentsSnapshot } from '../../utils/agent-config';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.4`;
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`;
/**
* Provider types that are not in the built-in provider registry (no `providerConfig.api`).
@@ -98,13 +99,8 @@ export function getOpenClawProviderKey(type: string, providerId: string): string
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (account?.authMode === 'oauth_browser') {
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
if (account?.authMode === 'oauth_browser' && config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
return getOpenClawProviderKey(config.type, config.id);
}
@@ -120,9 +116,6 @@ async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<s
return null;
}
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
@@ -599,6 +592,48 @@ export async function syncDefaultProviderToRuntime(
return;
}
// Self-heal: opportunistically remove any pre-existing models.providers
// entries with an invalid `api` field so a switch to a healthy provider
// can rescue the user from a previously broken config (e.g. the historical
// openrouter `api: 'openrouter'` bug). Covers both OAuth and non-OAuth
// branches below.
try {
const removed = await pruneInvalidApiProviderEntries();
if (removed.length > 0) {
logger.warn(
`[provider-runtime] Pruned invalid models.providers entries before switch: ${removed.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to prune invalid provider entries before switch:', err);
}
// Self-heal: pin the embedded agent runtime for legacy OpenAI provider entries
// (`openai`, `openai-codex`) that would otherwise be auto-routed to the
// unbundled `codex` harness. Running this before every default-provider switch
// repairs on-disk config written by earlier ClawX builds.
try {
const pinned = await ensureOpenClawProviderAgentRuntimePins();
if (pinned.length > 0) {
logger.warn(
`[provider-runtime] Pinned embedded agent runtime for models.providers entries before switch: ${pinned.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to pin embedded agent runtime for provider entries before switch:', err);
}
try {
const healed = await ensureAnthropicMessagesModelMaxTokens();
if (healed.length > 0) {
logger.warn(
`[provider-runtime] Ensured anthropic-messages maxTokens for models.providers entries before switch: ${healed.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to ensure anthropic-messages maxTokens before switch:', err);
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
@@ -648,9 +683,7 @@ export async function syncDefaultProviderToRuntime(
});
}
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
const defaultModelRef = OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model
@@ -33,7 +33,12 @@ import {
getOpenClawProvidersConfig,
getProviderApiKeyFromOpenClaw,
} from '../../utils/openclaw-auth';
import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys';
import {
filterActiveProviderKeysForUi,
getAliasSourceTypes,
OPENAI_CODEX_RUNTIME_PROVIDER_KEY,
resolveOpenClawProviderKey,
} from '../../utils/provider-keys';
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
@@ -96,7 +101,7 @@ export class ProviderService {
// Index store accounts by their openclaw runtime key for fast lookup.
const storeByKey = new Map<string, ProviderAccount[]>();
for (const account of allStoreAccounts) {
const ock = getOpenClawProviderKeyForType(account.vendorId, account.id);
const ock = resolveOpenClawProviderKey(account);
const group = storeByKey.get(ock) ?? [];
group.push(account);
storeByKey.set(ock, group);
@@ -105,8 +110,27 @@ export class ProviderService {
const result: ProviderAccount[] = [];
const processedKeys = new Set<string>();
let hasConfiguredOpenAiApiKey = false;
if (activeProviders.has('openai')) {
for (const account of storeByKey.get('openai') ?? []) {
if (account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (apiKey || openClawKey) {
hasConfiguredOpenAiApiKey = true;
break;
}
}
}
const activeKeysForUi = filterActiveProviderKeysForUi(activeProviders, {
hasConfiguredOpenAiApiKey,
});
// For each active provider in openclaw.json, produce exactly ONE account.
for (const key of activeProviders) {
for (const key of activeKeysForUi) {
if (processedKeys.has(key)) continue;
processedKeys.add(key);
@@ -150,6 +174,23 @@ export class ProviderService {
}
}
if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) {
const openaiStoreAccounts = storeByKey.get('openai') ?? [];
for (const account of openaiStoreAccounts) {
if (account.authMode !== 'api_key' && account.authMode !== undefined) {
continue;
}
const apiKey = await getApiKey(account.id);
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (!apiKey && !openClawKey) {
logger.info(
`[provider-sync] Removing unconfigured OpenAI API key account "${account.id}" (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`,
);
await deleteProviderAccount(account.id);
}
}
}
return result;
}
@@ -374,7 +415,7 @@ export class ProviderService {
const accounts = await this.listAccounts();
const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = [];
for (const account of accounts) {
const runtimeProviderKey = getOpenClawProviderKeyForType(account.vendorId, account.id);
const runtimeProviderKey = resolveOpenClawProviderKey(account);
const apiKey = (await getProviderApiKeyFromOpenClaw(runtimeProviderKey))
?? (await getApiKey(account.id))
?? (runtimeProviderKey !== account.id ? await getApiKey(runtimeProviderKey) : null);
@@ -396,7 +437,7 @@ export class ProviderService {
async hasAccountApiKey(accountId: string): Promise<boolean> {
const account = await this.getAccount(accountId);
const runtimeProviderKey = account
? getOpenClawProviderKeyForType(account.vendorId, account.id)
? resolveOpenClawProviderKey(account)
: accountId;
if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) {
return true;
+35 -15
View File
@@ -16,6 +16,8 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
category: 'official',
envVar: 'ANTHROPIC_API_KEY',
defaultModelId: 'claude-opus-4-6',
showModelId: true,
modelIdPlaceholder: 'claude-opus-4-6',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
@@ -29,12 +31,11 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
category: 'official',
envVar: 'OPENAI_API_KEY',
defaultModelId: 'gpt-5.4',
defaultModelId: 'gpt-5.5',
isOAuth: true,
supportsApiKey: true,
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'gpt-5.4',
modelIdPlaceholder: 'gpt-5.5',
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
@@ -53,13 +54,10 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
category: 'official',
envVar: 'GEMINI_API_KEY',
defaultModelId: 'gemini-3-pro-preview',
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'gemini-3.1-pro-preview',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'gemini-3-pro-preview',
supportedAuthModes: ['api_key', 'oauth_browser'],
modelIdPlaceholder: 'gemini-3.1-pro-preview',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
@@ -122,6 +120,8 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.moonshot.cn/v1',
defaultModelId: 'kimi-k2.6',
showModelId: true,
modelIdPlaceholder: 'kimi-k2.6',
category: 'official',
envVar: 'MOONSHOT_API_KEY',
supportedAuthModes: ['api_key'],
@@ -153,6 +153,8 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.moonshot.ai/v1',
defaultModelId: 'kimi-k2.6',
showModelId: true,
modelIdPlaceholder: 'kimi-k2.6',
category: 'official',
envVar: 'MOONSHOT_GLOBAL_API_KEY',
supportedAuthModes: ['api_key'],
@@ -184,7 +186,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.siliconflow.cn/v1',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3',
defaultModelId: 'deepseek-ai/DeepSeek-V3',
category: 'compatible',
@@ -207,7 +208,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.deepseek.com/v1',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'deepseek-v4-pro',
defaultModelId: 'deepseek-v4-pro',
apiKeyUrl: 'https://platform.deepseek.com/api_keys',
@@ -233,7 +233,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.7',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimax.io',
category: 'official',
@@ -245,6 +244,17 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_API_KEY',
models: [
{
id: 'MiniMax-M2.7',
name: 'MiniMax M2.7',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 204800,
maxTokens: 131072,
},
],
},
},
{
@@ -258,7 +268,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.7',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimaxi.com/',
category: 'official',
@@ -270,6 +279,17 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_CN_API_KEY',
models: [
{
id: 'MiniMax-M2.7',
name: 'MiniMax M2.7',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 204800,
maxTokens: 131072,
},
],
},
},
{
@@ -281,10 +301,10 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
showBaseUrl: true,
defaultModelId: 'qwen3.5-plus',
defaultModelId: 'qwen3.6-plus',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'qwen3.5-plus',
modelIdPlaceholder: 'qwen3.6-plus',
category: 'official',
envVar: 'MODELSTUDIO_API_KEY',
supportedAuthModes: ['api_key'],
+49 -1
View File
@@ -34,6 +34,54 @@ export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
/**
* Authoritative set of `models.providers.*.api` values accepted by the
* OpenClaw Gateway config schema. Keep in sync with OpenClaw's
* `assertValidGatewayStartupConfigSnapshot`.
*
* Writing any other value into `~/.openclaw/openclaw.json` triggers
* `Invalid config` rejection on next reload/restart and tears down all
* channels. Use `assertValidApiProtocol` at every write site.
*/
export const OPENCLAW_API_PROTOCOLS = [
'openai-completions',
'openai-responses',
'openai-codex-responses',
'anthropic-messages',
'google-generative-ai',
'github-copilot',
'bedrock-converse-stream',
'ollama',
'azure-openai-responses',
] as const;
export type OpenClawApiProtocol = (typeof OPENCLAW_API_PROTOCOLS)[number];
export class InvalidApiProtocolError extends Error {
constructor(public readonly api: unknown, public readonly providerKey?: string) {
super(
`Invalid OpenClaw api protocol${providerKey ? ` for provider "${providerKey}"` : ''}: ` +
`${JSON.stringify(api)}. Expected one of: ${OPENCLAW_API_PROTOCOLS.join(', ')}.`,
);
this.name = 'InvalidApiProtocolError';
}
}
export function assertValidApiProtocol(
api: unknown,
providerKey?: string,
): asserts api is OpenClawApiProtocol {
if (typeof api !== 'string' || !(OPENCLAW_API_PROTOCOLS as readonly string[]).includes(api)) {
throw new InvalidApiProtocolError(api, providerKey);
}
}
/**
* 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'
@@ -99,7 +147,7 @@ export interface ProviderModelEntry extends Record<string, unknown> {
export interface ProviderBackendConfig {
baseUrl: string;
api: ProviderProtocol;
api: OpenClawApiProtocol;
apiKeyEnv: string;
models?: ProviderModelEntry[];
headers?: Record<string, string>;
+5 -2
View File
@@ -7,6 +7,7 @@ import { withConfigLock } from './config-mutex';
import { expandPath, getOpenClawConfigDir } from './paths';
import * as logger from './logger';
import { toUiChannelType } from './channel-alias';
import { ensureClawXIdentityFile } from './openclaw-workspace';
const MAIN_AGENT_ID = 'main';
const MAIN_AGENT_NAME = 'Main Agent';
@@ -421,11 +422,13 @@ async function provisionAgentFilesystem(
// When inheritWorkspace is true, copy the main agent's workspace bootstrap
// files (SOUL.md, AGENTS.md, etc.) so the new agent inherits the same
// personality / instructions. When false (default), leave the workspace
// empty and let OpenClaw Gateway seed the default bootstrap files on startup.
// personality / instructions. Otherwise OpenClaw will seed the missing files
// on first use, but ClawX still pre-seeds IDENTITY.md so desktop workspaces
// skip the chat-first bootstrap flow.
if (options?.inheritWorkspace && targetWorkspace !== sourceWorkspace) {
await copyBootstrapFiles(sourceWorkspace, targetWorkspace);
}
await ensureClawXIdentityFile(targetWorkspace, { createDir: true });
if (targetAgentDir !== sourceAgentDir) {
await copyRuntimeFiles(sourceAgentDir, targetAgentDir);
}
+68 -75
View File
@@ -1,18 +1,24 @@
import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
import {
ensureOpenClawProviderAgentRuntimePins,
saveOAuthTokenToOpenClaw,
setOpenClawDefaultModel,
} from './openclaw-auth';
export type BrowserOAuthProviderType = 'google' | 'openai';
// Google was removed: OpenClaw's `google-gemini-cli` OAuth integration is an
// unofficial third-party flow that requires the `gemini` CLI binary to be on
// PATH and ships with explicit "use at your own risk" warnings about Google
// account suspensions. ClawX does not bundle that binary, so the only
// browser-OAuth provider we currently expose end-to-end is OpenAI Codex.
export type BrowserOAuthProviderType = 'openai';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.4';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
@@ -41,67 +47,40 @@ class BrowserOAuthManager extends EventEmitter {
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
if (provider === 'openai') {
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
void this.executeFlow(provider);
return true;
}
await this.executeFlow(provider);
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
void this.executeFlow(provider);
return true;
}
private async executeFlow(provider: BrowserOAuthProviderType): Promise<void> {
try {
const token = provider === 'google'
? await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
}
},
},
})
: await loginOpenAICodexOAuth({
openUrl: async (url) => {
await shell.openExternal(url);
},
onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`),
onManualCodeRequired: ({ authorizationUrl, reason }) => {
const message = reason === 'port_in_use'
? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.'
: 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.';
const payload = {
provider,
mode: 'manual' as const,
authorizationUrl,
message,
};
this.emit('oauth:code', payload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', payload);
}
},
onManualCodeInput: async () => {
return await new Promise<string>((resolve, reject) => {
this.pendingManualCodeResolve = resolve;
this.pendingManualCodeReject = reject;
});
},
});
const token = await loginOpenAICodexOAuth({
openUrl: async (url) => {
await shell.openExternal(url);
},
onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`),
onManualCodeRequired: ({ authorizationUrl, reason }) => {
const message = reason === 'port_in_use'
? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.'
: 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.';
const payload = {
provider,
mode: 'manual' as const,
authorizationUrl,
message,
};
this.emit('oauth:code', payload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', payload);
}
},
onManualCodeInput: async () => {
return await new Promise<string>((resolve, reject) => {
this.pendingManualCodeResolve = resolve;
this.pendingManualCodeReject = reject;
});
},
});
await this.onSuccess(provider, token);
} catch (error) {
@@ -145,7 +124,7 @@ class BrowserOAuthManager extends EventEmitter {
private async onSuccess(
providerType: BrowserOAuthProviderType,
token: GeminiCliOAuthCredentials | OpenAICodexOAuthCredentials,
token: OpenAICodexOAuthCredentials,
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
@@ -159,22 +138,16 @@ class BrowserOAuthManager extends EventEmitter {
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
const isGoogle = providerType === 'google';
const runtimeProviderId = isGoogle ? GOOGLE_RUNTIME_PROVIDER_ID : OPENAI_RUNTIME_PROVIDER_ID;
const defaultModel = isGoogle ? GOOGLE_OAUTH_DEFAULT_MODEL : OPENAI_OAUTH_DEFAULT_MODEL;
const accountLabelDefault = isGoogle ? 'Google Gemini' : 'OpenAI Codex';
const oauthTokenEmail = 'email' in token && typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = 'projectId' in token && typeof token.projectId === 'string'
? token.projectId
: ('accountId' in token && typeof token.accountId === 'string' ? token.accountId : undefined);
const runtimeProviderId = OPENAI_RUNTIME_PROVIDER_ID;
const defaultModel = OPENAI_OAUTH_DEFAULT_MODEL;
const accountLabelDefault = 'OpenAI Codex';
const oauthTokenEmail = typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = typeof token.accountId === 'string' ? token.accountId : undefined;
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
const normalizedExistingModel = (() => {
const value = existing?.model?.trim();
if (!value) return undefined;
if (isGoogle) {
return value.includes('/') ? value.split('/').pop() : value;
}
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
if (value.startsWith('openai/')) return undefined;
if (value.startsWith('openai-codex/')) return value.split('/').pop();
return value.includes('/') ? value.split('/').pop() : value;
@@ -219,6 +192,26 @@ class BrowserOAuthManager extends EventEmitter {
projectId: oauthTokenSubject,
});
const modelId = normalizedExistingModel || defaultModel;
const modelRef = `${runtimeProviderId}/${modelId}`;
const fallbackModelRefs = (nextAccount.fallbackModels ?? [])
.map((fallback) => fallback.trim())
.filter(Boolean)
.map((fallback) => (
fallback.startsWith(`${runtimeProviderId}/`)
? fallback
: `${runtimeProviderId}/${fallback}`
));
try {
await setOpenClawDefaultModel(runtimeProviderId, modelRef, fallbackModelRefs);
await ensureOpenClawProviderAgentRuntimePins();
logger.info(`[BrowserOAuth] Registered ${runtimeProviderId} in openclaw.json (default model: ${modelRef})`);
} catch (err) {
logger.warn('[BrowserOAuth] Failed to register OpenAI OAuth provider in openclaw.json:', err);
throw err;
}
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', {
+123 -31
View File
@@ -32,6 +32,28 @@ const DEFAULT_ACCOUNT_ID = 'default';
// schema validation errors. ClawX falls back to DEFAULT_ACCOUNT_ID
// when `defaultAccount` is absent.
const CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY = new Set(['dingtalk']);
// Channels whose schema accepts a top-level default account and account map,
// but whose account payload contains nested strict-schema objects that ClawX
// can accidentally make invalid by adding UI convenience fields. Keep this
// sanitization narrowly scoped to known nested maps so local config remains
// OpenClaw-compatible after a save.
const DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP = new Set([
'autoArchiveDuration',
'autoThread',
'autoThreadName',
'enabled',
'ignoreOtherMentions',
'includeThreadStarter',
'requireMention',
'roles',
'skills',
'systemPrompt',
'tools',
'toolsBySender',
'users',
]);
const DISCORD_CHANNEL_ALLOW_FLAG_KEYS = new Set(['allow']);
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID);
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
@@ -40,8 +62,8 @@ const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_P
const LEGACY_WECHAT_SYNC_DIR = join(OPENCLAW_DIR, 'agents', 'default', 'sessions', '.openclaw-weixin-sync');
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
const PLUGIN_CHANNELS: string[] = [];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set(['whatsapp']);
const PLUGIN_CHANNELS: string[] = ['discord', 'qqbot', 'whatsapp'];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set<string>();
const BUILTIN_CHANNEL_IDS = new Set([
'discord',
'telegram',
@@ -78,6 +100,57 @@ const CHANNEL_UNIQUE_CREDENTIAL_KEY: Record<string, string> = {
// ── Helpers ──────────────────────────────────────────────────────
function sanitizeDiscordGuildChannelConfig(channelConfig: unknown): void {
if (!channelConfig || typeof channelConfig !== 'object' || Array.isArray(channelConfig)) {
return;
}
const record = channelConfig as Record<string, unknown>;
// Backward compatibility for the older ClawX-generated shape:
// channels: { "123": { allow: true, requireMention: true } }
// OpenClaw's current DiscordGuildChannelConfig does not include `allow`;
// represent deny/allow using `enabled` instead.
if (record.allow === false && record.enabled === undefined) {
record.enabled = false;
}
for (const key of Object.keys(record)) {
if (DISCORD_CHANNEL_ALLOW_FLAG_KEYS.has(key)) {
delete record[key];
continue;
}
if (!DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP.has(key)) {
delete record[key];
}
}
}
function sanitizeDiscordGuilds(config: unknown): void {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return;
}
const record = config as Record<string, unknown>;
const guilds = record.guilds;
if (!guilds || typeof guilds !== 'object' || Array.isArray(guilds)) {
return;
}
for (const guildConfig of Object.values(guilds as Record<string, unknown>)) {
if (!guildConfig || typeof guildConfig !== 'object' || Array.isArray(guildConfig)) {
continue;
}
const channels = (guildConfig as Record<string, unknown>).channels;
if (!channels || typeof channels !== 'object' || Array.isArray(channels)) {
continue;
}
for (const channelConfig of Object.values(channels as Record<string, unknown>)) {
sanitizeDiscordGuildChannelConfig(channelConfig);
}
}
}
/**
* Strip `defaultAccount` from channel sections whose plugin schema
* declares additionalProperties:false without listing `defaultAccount`.
@@ -92,6 +165,17 @@ function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
delete section.defaultAccount;
}
}
const discordSection = config.channels.discord;
if (discordSection) {
sanitizeDiscordGuilds(discordSection);
const accounts = getChannelAccountsMap(discordSection);
if (accounts) {
for (const accountConfig of Object.values(accounts)) {
sanitizeDiscordGuilds(accountConfig);
}
}
}
}
async function fileExists(p: string): Promise<boolean> {
@@ -421,6 +505,10 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
ensurePluginRegistration(currentConfig, channelType);
}
if (channelType === 'discord' || channelType === 'qqbot' || channelType === 'whatsapp') {
ensurePluginRegistration(currentConfig, channelType);
}
if (channelType === 'feishu') {
const feishuPluginId = await resolveFeishuPluginId();
if (!currentConfig.plugins) {
@@ -429,8 +517,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
enabled: true,
entries: {
[feishuPluginId]: { enabled: true },
// Disable the built-in feishu plugin when using openclaw-lark
...(feishuPluginId !== 'feishu' ? { feishu: { enabled: false } } : {}),
}
};
} else {
@@ -451,15 +537,10 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
// Remove conflicting feishu plugin entries; keep only the resolved plugin id.
// When the resolved plugin id is NOT 'feishu', explicitly disable the
// built-in feishu plugin (OpenClaw ships one in dist/extensions/feishu/)
// to prevent it from conflicting with the official openclaw-lark plugin.
if (feishuPluginId !== 'feishu') {
currentConfig.plugins.entries['feishu'] = { enabled: false };
} else {
delete currentConfig.plugins.entries['feishu'];
}
// Remove conflicting feishu plugin entries; keep only the resolved
// external plugin id. A disabled plugins.entries.feishu record
// blocks openclaw-lark in OpenClaw's gateway startup planner.
delete currentConfig.plugins.entries['feishu'];
for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) {
if (candidateId !== feishuPluginId) {
delete currentConfig.plugins.entries[candidateId];
@@ -578,11 +659,11 @@ function transformChannelConfig(
if (channelId && typeof channelId === 'string' && channelId.trim()) {
guildConfig.channels = {
[channelId.trim()]: { allow: true, requireMention: true }
[channelId.trim()]: { requireMention: true }
};
} else {
guildConfig.channels = {
'*': { allow: true, requireMention: true }
'*': { requireMention: true }
};
}
@@ -623,6 +704,16 @@ function transformChannelConfig(
transformedConfig.allowFrom = allowFrom;
}
if (channelType === 'whatsapp') {
// The WhatsApp plugin stores QR/session state on disk and does not
// require static credentials, but the runtime still needs an enabled
// plugin config entry for the channel account to appear in status.
transformedConfig = {
...transformedConfig,
enabled: transformedConfig.enabled ?? true,
};
}
if (channelType === 'dingtalk') {
// The per-account schema uses additionalProperties:false and does
// NOT include these legacy/obsolete fields. Strip them before
@@ -750,22 +841,8 @@ export async function saveChannelConfig(
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig, [resolvedChannelType]);
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
currentConfig.plugins!.entries![resolvedChannelType] = {
...currentConfig.plugins!.entries![resolvedChannelType],
enabled: config.enabled ?? true,
};
await writeOpenClawConfig(currentConfig);
logger.info('Plugin channel config saved', {
channelType: resolvedChannelType,
configFile: CONFIG_FILE,
path: `plugins.entries.${resolvedChannelType}`,
});
console.log(`Saved plugin channel config for ${resolvedChannelType}`);
return;
}
// Plugin-based channels are mirrored into plugins.entries.<id> below,
// but ClawX still keeps channels.<id> as the local account-list source.
if (!currentConfig.channels) {
currentConfig.channels = {};
@@ -812,6 +889,21 @@ export async function saveChannelConfig(
// read channels.<type>.enabled still work.
channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true;
// Plugin-backed channel packages read their activation/config from
// plugins.entries.<id>. Mirror the enabled flag and account map there
// while preserving channels.<id> for ClawX's account list UI.
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
const pluginEntry = currentConfig.plugins!.entries![resolvedChannelType];
const pluginAccounts = ensureChannelAccountsMap(pluginEntry);
pluginEntry.defaultAccount = channelSection.defaultAccount;
pluginEntry.enabled = channelSection.enabled;
pluginAccounts[resolvedAccountId] = {
...pluginAccounts[resolvedAccountId],
...accounts[resolvedAccountId],
};
}
// Most OpenClaw channel plugins/built-ins also read the default
// account's credentials from the top level of `channels.<type>`
// (e.g. channels.feishu.appId). Mirror them there so the
-739
View File
@@ -1,739 +0,0 @@
import { execFile, execFileSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { delimiter, dirname, join } from 'node:path';
import { getClawXConfigDir } from './paths';
import { proxyAwareFetch } from './proxy-fetch';
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
const CLIENT_SECRET_KEYS = [
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
'GEMINI_CLI_OAUTH_CLIENT_SECRET',
];
const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback';
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
const SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
const TIER_FREE = 'free-tier';
const TIER_LEGACY = 'legacy-tier';
const TIER_STANDARD = 'standard-tier';
const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli');
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
export class DetailedError extends Error {
detail: string;
constructor(message: string, detail: string) {
super(message);
this.name = 'DetailedError';
this.detail = detail;
}
}
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function findInPath(name: string): string | null {
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
if (!dir) continue;
for (const ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) {
return p;
}
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) {
return null;
}
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const next = join(dir, entry.name);
if (entry.isFile() && entry.name === name) {
return next;
}
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const found = findFile(next, name, depth - 1);
if (found) {
return found;
}
}
}
} catch {
return null;
}
return null;
}
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
const resolvedPath = realpathSync(geminiPath);
const geminiCliDir = dirname(dirname(resolvedPath));
const searchPaths = [
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'src',
'code_assist',
'oauth2.js',
),
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'code_assist',
'oauth2.js',
),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(geminiCliDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
return cachedGeminiCliCredentials;
}
} catch {
return null;
}
return null;
}
function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null {
const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
if (!existsSync(coreDir)) {
return null;
}
const searchPaths = [
join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'),
join(coreDir, 'dist', 'code_assist', 'oauth2.js'),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(coreDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
}
return null;
}
async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolean> {
const npmBin = findInPath('npm');
if (!npmBin) {
return false;
}
onProgress?.('Installing Gemini OAuth helper...');
return await new Promise((resolve) => {
const useShell = process.platform === 'win32';
const child = execFile(
npmBin,
['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'],
{ timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } },
(err) => {
if (err) {
onProgress?.(`Gemini helper install failed, falling back to direct download...`);
resolve(false);
} else {
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper installed');
resolve(true);
}
},
);
child.stderr?.on('data', () => {
// Suppress npm noise.
});
});
}
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
try {
onProgress?.('Downloading Gemini OAuth helper...');
const metaRes = await proxyAwareFetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
if (!metaRes.ok) {
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
return false;
}
const meta = (await metaRes.json()) as { dist?: { tarball?: string } };
const tarballUrl = meta.dist?.tarball;
if (!tarballUrl) {
onProgress?.('Gemini package tarball URL missing');
return false;
}
const tarRes = await proxyAwareFetch(tarballUrl);
if (!tarRes.ok) {
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
return false;
}
const buffer = Buffer.from(await tarRes.arrayBuffer());
const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
mkdirSync(targetDir, { recursive: true });
const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz');
writeFileSync(tmpFile, buffer);
try {
execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], {
timeout: 30_000,
});
} finally {
try {
unlinkSync(tmpFile);
} catch {
// ignore
}
}
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper ready');
return true;
} catch (err) {
onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
async function ensureOAuthClientConfig(
onProgress?: (msg: string) => void,
): Promise<{ clientId: string; clientSecret?: string }> {
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
}
const extracted = extractGeminiCliCredentials();
if (extracted) {
return extracted;
}
const localExtracted = extractFromLocalInstall();
if (localExtracted) {
return localExtracted;
}
mkdirSync(LOCAL_GEMINI_DIR, { recursive: true });
const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress);
if (installed) {
const installedExtracted = extractFromLocalInstall();
if (installedExtracted) {
return installedExtracted;
}
}
throw new Error(
'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.',
);
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString('hex');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function buildAuthUrl(clientId: string, challenge: string, verifier: string): string {
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state: verifier,
access_type: 'offline',
prompt: 'consent',
});
return `${AUTH_URL}?${params.toString()}`;
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = '127.0.0.1';
const expectedPath = '/oauth2callback';
return new Promise((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not found');
return;
}
const error = requestUrl.searchParams.get('error');
const code = requestUrl.searchParams.get('code')?.trim();
const state = requestUrl.searchParams.get('state')?.trim();
if (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end('Missing code or state');
finish(new Error('Missing OAuth code or state'));
return;
}
if (state !== params.expectedState) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Session expired</h2><p>This authorization link is from a previous attempt. Please go back to ClawX and try again.</p></body></html>",
);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Gemini CLI OAuth complete</h2><p>You can close this window and return to ClawX.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error('OAuth callback failed'));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) {
clearTimeout(timeout);
}
try {
server.close();
} catch {
// ignore
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once('error', (err) => {
finish(err instanceof Error ? err : new Error('OAuth callback server error'));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`);
});
timeout = setTimeout(() => {
finish(new DetailedError(
'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.',
`Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`,
));
}, params.timeoutMs);
});
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await proxyAwareFetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) {
return { id: TIER_LEGACY };
}
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') {
return false;
}
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== 'object') {
return false;
}
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) {
return false;
}
return details.some(
(item) =>
typeof item === 'object'
&& item
&& (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED',
);
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
if (!response.ok) {
continue;
}
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) {
return data;
}
}
throw new Error('Operation polling timeout');
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'gl-node/clawx',
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: 'POST',
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === 'string' && project) {
return project;
}
if (typeof project === 'object' && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
}
const hasExistingTierButNoProject = !!data.currentTier;
const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new DetailedError(
'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.',
`tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`,
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: 'POST',
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
const respText = await onboardResponse.text().catch(() => '');
throw new DetailedError(
'Google project provisioning failed. Please try again later.',
`onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`,
);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) {
return projectId;
}
if (envProject) {
return envProject;
}
throw new DetailedError(
'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.',
`tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`,
);
}
async function exchangeCodeForTokens(
code: string,
verifier: string,
clientConfig: { clientId: string; clientSecret?: string },
): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = clientConfig;
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set('client_secret', clientSecret);
}
const response = await proxyAwareFetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error('No refresh token received. Please try again.');
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
export async function loginGeminiCliOAuth(
ctx: GeminiCliOAuthContext,
): Promise<GeminiCliOAuthCredentials> {
if (ctx.isRemote) {
throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.');
}
await ctx.note(
[
'Browser will open for Google authentication.',
'Sign in with your Google account for Gemini CLI access.',
'The callback will be captured automatically on 127.0.0.1:8085.',
].join('\n'),
'Gemini CLI OAuth',
);
ctx.progress.update('Preparing Google OAuth...');
const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg));
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier);
ctx.progress.update('Complete sign-in in browser...');
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update('Exchanging authorization code for tokens...');
return await exchangeCodeForTokens(code, verifier, clientConfig);
} catch (err) {
if (
err instanceof Error
&& (err.message.includes('EADDRINUSE')
|| err.message.includes('port')
|| err.message.includes('listen'))
) {
throw new Error(
'Port 8085 is in use by another process. Close the other application using port 8085 and try again.',
{ cause: err },
);
}
throw err;
}
}
// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale.
export function detectGeminiCliVersion(): string | null {
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim();
} catch {
return null;
}
}
+14 -11
View File
@@ -115,16 +115,21 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
function getAccountIdFromAccessToken(accessToken: string): string | null {
const payload = decodeJwtPayload(accessToken);
const authClaims = payload?.[JWT_CLAIM_PATH];
if (!authClaims || typeof authClaims !== 'object') {
return null;
if (authClaims && typeof authClaims === 'object') {
const claims = authClaims as Record<string, unknown>;
for (const key of ['chatgpt_account_id', 'account_id', 'user_id', 'sub']) {
const value = claims[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
}
const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
if (typeof accountId !== 'string' || !accountId.trim()) {
return null;
if (typeof payload?.sub === 'string' && payload.sub.trim()) {
return payload.sub.trim();
}
return accountId;
return null;
}
async function createAuthorizationFlow(): Promise<OpenAICodexAuthorizationFlow> {
@@ -181,8 +186,9 @@ function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer |
});
return new Promise((resolve) => {
// Bind dual-stack loopback so both `localhost` and `127.0.0.1` redirects work.
server
.listen(1455, 'localhost', () => {
.listen(1455, () => {
resolve({
close: () => server.close(),
waitForCode: async () => {
@@ -288,10 +294,7 @@ export async function loginOpenAICodexOAuth(options: {
}
const token = await exchangeAuthorizationCode(code, verifier);
const accountId = getAccountIdFromAccessToken(token.access);
if (!accountId) {
throw new Error('Failed to extract OpenAI accountId from token');
}
const accountId = getAccountIdFromAccessToken(token.access) ?? 'default';
return {
access: token.access,
+392 -19
View File
@@ -28,6 +28,10 @@ import {
} from './provider-keys';
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
import { withConfigLock } from './config-mutex';
import {
OPENCLAW_API_PROTOCOLS,
assertValidApiProtocol,
} from '../shared/providers/types';
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
@@ -516,6 +520,7 @@ const BUNDLED_ALLOWLIST_PRESERVE_IDS = new Set([
'browser',
'acpx',
'memory-core',
'codex',
]);
const AUTH_PROFILE_PROVIDER_KEY_MAP: Record<string, string> = {
'openai-codex': 'openai',
@@ -1058,6 +1063,43 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
}
}
/**
* Self-heal helper: walk `models.providers.*` in openclaw.json and remove
* any entry whose `api` field is not in the OpenClaw allow-list.
*
* Used opportunistically when the user switches default provider, so that
* a legacy invalid entry (e.g. the historical `models.providers.openrouter
* = { api: 'openrouter', ... }` bug) cannot keep the Gateway in
* Invalid-config -> restart-loop hell on the next reload/restart.
*
* Returns the list of pruned provider keys for logging.
*/
export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
const removed: string[] = [];
await withConfigLock(async () => {
const config = await readOpenClawJson();
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
for (const [key, entry] of Object.entries(providers)) {
const api = isPlainRecord(entry) ? (entry as Record<string, unknown>).api : undefined;
if (typeof api !== 'string' || !(OPENCLAW_API_PROTOCOLS as readonly string[]).includes(api)) {
delete providers[key];
removed.push(key);
modified = true;
}
}
if (modified) {
models.providers = providers;
config.models = models;
await writeOpenClawJson(config);
}
});
return removed;
}
/**
* Build environment variables object with all stored API keys
* for passing to the Gateway process
@@ -1108,6 +1150,7 @@ export async function setOpenClawDefaultModel(
// Configure models.providers for providers that need explicit registration.
const providerCfg = getProviderConfig(provider);
if (providerCfg) {
assertValidApiProtocol(providerCfg.api, provider);
upsertOpenClawProviderEntry(config, provider, {
baseUrl: providerCfg.baseUrl,
api: providerCfg.api,
@@ -1118,6 +1161,21 @@ export async function setOpenClawDefaultModel(
mergeExistingModels: true,
});
console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`);
} else if (provider === 'openai-codex') {
// OAuth Codex is not in the UI registry but still needs an explicit provider
// entry with a pinned embedded runtime (see OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME).
upsertOpenClawProviderEntry(config, provider, {
baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl,
api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api,
modelIds: [modelId, ...fallbackModelIds],
mergeExistingModels: true,
});
if (isOpenClawOAuthPluginProviderKey(provider)) {
ensureOAuthPluginEnabled(config, provider);
}
console.log(
`Configured models.providers.${provider} for OAuth (api=${OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api})`,
);
} else {
// Built-in provider: remove any stale models.providers entry
const models = (config.models || {}) as Record<string, unknown>;
@@ -1192,11 +1250,225 @@ function mergeProviderModels(
return merged;
}
/**
* OpenClaw 2026.5+ requires a positive `maxTokens` on each model (and can
* fall back to provider-level `maxTokens`) when `api` is `anthropic-messages`.
* ClawX-written entries historically only included `{ id, name }`.
*
* Generic Anthropic-compatible providers should not be capped at 8k by
* default: OpenClaw's native Anthropic transport caps default requests at 32k
* (`min(model.maxTokens, 32000)`), while high-output providers such as MiniMax
* M2.7 advertise a larger catalog limit.
*/
export const ANTHROPIC_MESSAGES_DEFAULT_MAX_TOKENS = 32768;
export const MINIMAX_M27_MAX_TOKENS = 131072;
function resolvePositiveMaxTokens(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
const floored = Math.floor(value);
return floored > 0 ? floored : undefined;
}
function isMiniMaxM27AnthropicEntry(
providerKey: string | undefined,
entry: Record<string, unknown> | undefined,
model: Record<string, unknown> | undefined,
): boolean {
const normalizedProvider = (providerKey || '').toLowerCase();
if (normalizedProvider === 'minimax' || normalizedProvider.startsWith('minimax-portal')) {
return true;
}
const baseUrl = typeof entry?.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : '';
if (baseUrl.includes('api.minimax.io') || baseUrl.includes('api.minimaxi.com')) {
return true;
}
const modelId = typeof model?.id === 'string' ? model.id.toLowerCase() : '';
return modelId === 'minimax-m2.7' || modelId === 'minimax-m2.7-highspeed';
}
function resolveAnthropicMessagesDefaultMaxTokens(
providerKey?: string,
entry?: Record<string, unknown>,
model?: Record<string, unknown>,
): number {
if (isMiniMaxM27AnthropicEntry(providerKey, entry, model)) {
return MINIMAX_M27_MAX_TOKENS;
}
return ANTHROPIC_MESSAGES_DEFAULT_MAX_TOKENS;
}
function ensureAnthropicMessagesModelEntry(
model: Record<string, unknown>,
providerKey?: string,
entry?: Record<string, unknown>,
): Record<string, unknown> {
const resolved = resolvePositiveMaxTokens(model.maxTokens);
if (resolved !== undefined) {
if (model.maxTokens === resolved) {
return model;
}
return { ...model, maxTokens: resolved };
}
return { ...model, maxTokens: resolveAnthropicMessagesDefaultMaxTokens(providerKey, entry, model) };
}
function resolveAnthropicMessagesProviderDefaultMaxTokens(
providerKey: string | undefined,
entry: Record<string, unknown>,
): number {
if (Array.isArray(entry.models)) {
const modelDefaults = entry.models
.filter(isPlainRecord)
.map((model) => resolveAnthropicMessagesDefaultMaxTokens(providerKey, entry, model));
if (modelDefaults.length > 0) {
return Math.max(...modelDefaults);
}
}
return resolveAnthropicMessagesDefaultMaxTokens(providerKey, entry);
}
/**
* Ensure `models.providers.*` entries using `anthropic-messages` include the
* token limits OpenClaw's transport layer requires. Returns whether `entry`
* was modified.
*/
function ensureAnthropicMessagesProviderDefaults(
entry: Record<string, unknown>,
providerKey?: string,
): boolean {
if (entry.api !== 'anthropic-messages') {
return false;
}
let modified = false;
if (resolvePositiveMaxTokens(entry.maxTokens) === undefined) {
entry.maxTokens = resolveAnthropicMessagesProviderDefaultMaxTokens(providerKey, entry);
modified = true;
}
if (Array.isArray(entry.models)) {
const nextModels = (entry.models as Array<Record<string, unknown>>).map((model) => {
if (!isPlainRecord(model)) {
return model;
}
const next = ensureAnthropicMessagesModelEntry(model, providerKey, entry);
if (next !== model) {
modified = true;
}
return next;
});
entry.models = nextModels;
}
return modified;
}
function healAnthropicMessagesMaxTokensInConfig(config: Record<string, unknown>): boolean {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
for (const [providerKey, entry] of Object.entries(providers)) {
if (!isPlainRecord(entry)) {
continue;
}
if (ensureAnthropicMessagesProviderDefaults(entry, providerKey)) {
providers[providerKey] = entry;
modified = true;
console.log(
`[openclaw-auth] Ensured anthropic-messages maxTokens defaults for models.providers.${providerKey}`,
);
}
}
if (modified) {
models.providers = providers;
config.models = models;
}
return modified;
}
/**
* Self-heal helper: walk `models.providers.*` and ensure every
* `anthropic-messages` entry (and its model rows) has a positive `maxTokens`.
*/
export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]> {
const healed: string[] = [];
await withConfigLock(async () => {
const config = await readOpenClawJson();
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
for (const [providerKey, entry] of Object.entries(providers)) {
if (!isPlainRecord(entry)) {
continue;
}
if (ensureAnthropicMessagesProviderDefaults(entry, providerKey)) {
providers[providerKey] = entry;
healed.push(providerKey);
modified = true;
}
}
if (modified) {
models.providers = providers;
config.models = models;
await writeOpenClawJson(config);
}
});
return healed;
}
/**
* Map of OpenClaw `models.providers.*` keys that must be pinned to a specific
* embedded agent harness so that OpenClaw's auto-routing policy does not
* dispatch the chat to an externally-bundled harness plugin that may not be
* installed.
*
* OpenClaw 2026.5+ auto-routes OpenAI providers (`openai`, `openai-codex`) to the
* external `codex` agent harness, which expects a separate codex plugin install.
* The bundled OpenClaw distribution ClawX ships does not register that harness,
* so without pinning both keys chat fails with
* `Requested agent harness "codex" is not registered.`
*/
const OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME: Record<string, string> = {
openai: 'pi',
'openai-codex': 'pi',
};
/** Runtime models.providers entry for OpenAI Codex OAuth accounts. */
export const OPENAI_CODEX_OAUTH_PROVIDER_CONFIG = {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-codex-responses' as const,
};
function applyPinnedAgentRuntime(
provider: string,
nextProvider: Record<string, unknown>,
): void {
const pinnedRuntimeId = OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME[provider];
if (!pinnedRuntimeId) return;
const existing = nextProvider.agentRuntime;
if (isPlainRecord(existing) && typeof existing.id === 'string' && existing.id.trim()) {
return;
}
nextProvider.agentRuntime = { id: pinnedRuntimeId };
}
function upsertOpenClawProviderEntry(
config: Record<string, unknown>,
provider: string,
options: ProviderEntryBuildOptions,
): void {
assertValidApiProtocol(options.api, provider);
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers);
@@ -1213,13 +1485,20 @@ function upsertOpenClawProviderEntry(
? ((getProviderConfig(provider)?.models ?? []).map((m) => ({ ...m })) as Array<Record<string, unknown>>)
: [];
const runtimeModels = (options.modelIds ?? []).map((id) => ({ id, name: id }));
let mergedModels = mergeProviderModels(registryModels, existingModels, runtimeModels);
if (options.api === 'anthropic-messages') {
mergedModels = mergedModels.map((model) => ensureAnthropicMessagesModelEntry(model, provider, existingProvider));
}
const nextProvider: Record<string, unknown> = {
...existingProvider,
baseUrl: options.baseUrl,
api: options.api,
models: mergeProviderModels(registryModels, existingModels, runtimeModels),
models: mergedModels,
};
if (options.api === 'anthropic-messages') {
ensureAnthropicMessagesProviderDefaults(nextProvider, provider);
}
if (options.apiKeyEnv) nextProvider.apiKey = options.apiKeyEnv;
if (options.headers !== undefined) {
if (Object.keys(options.headers).length > 0) {
@@ -1233,6 +1512,7 @@ function upsertOpenClawProviderEntry(
} else {
delete nextProvider.authHeader;
}
applyPinnedAgentRuntime(provider, nextProvider);
providers[provider] = nextProvider;
models.providers = providers;
@@ -1243,6 +1523,51 @@ function upsertOpenClawProviderEntry(
}
}
/**
* Self-heal helper: walk `models.providers.*` in openclaw.json and, for any
* entry whose key is in {@link OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME} but
* lacks an `agentRuntime.id`, write the pinned runtime id in place.
*
* Mirrors {@link pruneInvalidApiProviderEntries} invoked opportunistically
* before a default-provider switch so that pre-existing on-disk entries
* (written by earlier ClawX builds that did not pin the runtime) get
* repaired before the next Gateway reload picks them up. Without this, users
* who upgrade ClawX while still pointing at an OpenAI provider would keep
* hitting `Requested agent harness "codex" is not registered.` until they
* re-saved the provider manually.
*
* Returns the list of provider keys that received a runtime pin, for logging.
*/
export async function ensureOpenClawProviderAgentRuntimePins(): Promise<string[]> {
const pinned: string[] = [];
await withConfigLock(async () => {
const config = await readOpenClawJson();
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
for (const [provider, runtimeId] of Object.entries(OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME)) {
const entry = providers[provider];
if (!isPlainRecord(entry)) continue;
const existing = (entry as Record<string, unknown>).agentRuntime;
if (isPlainRecord(existing) && typeof existing.id === 'string' && existing.id.trim()) {
continue;
}
(entry as Record<string, unknown>).agentRuntime = { id: runtimeId };
providers[provider] = entry;
pinned.push(provider);
modified = true;
}
if (modified) {
models.providers = providers;
config.models = models;
await writeOpenClawJson(config);
}
});
return pinned;
}
function removeLegacyMoonshotProviderEntry(
_provider: string,
_providers: Record<string, unknown>
@@ -1330,6 +1655,7 @@ export async function syncProviderConfigToOpenClaw(
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
if (override.baseUrl && override.api) {
assertValidApiProtocol(override.api, provider);
upsertOpenClawProviderEntry(config, provider, {
baseUrl: override.baseUrl,
api: override.api,
@@ -1380,6 +1706,7 @@ export async function setOpenClawDefaultModelWithOverride(
config.agents = agents;
if (override.baseUrl && override.api) {
assertValidApiProtocol(override.api, provider);
upsertOpenClawProviderEntry(config, provider, {
baseUrl: override.baseUrl,
api: override.api,
@@ -1439,7 +1766,7 @@ export async function getActiveOpenClawProviders(): Promise<Set<string>> {
}
// 3. agents.defaults.model.primary — the default model reference encodes
// the provider prefix (e.g. "modelstudio/qwen3.5-plus" → "modelstudio").
// the provider prefix (e.g. "modelstudio/qwen3.6-plus" → "modelstudio").
// This covers providers that are active via OAuth or env-key but don't
// have an explicit models.providers entry.
const agents = config.agents as Record<string, unknown> | undefined;
@@ -1754,7 +2081,13 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
type AgentModelProviderEntry = {
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string; cost?: PiAiModelCostRates }>;
models?: Array<{
id: string;
name: string;
cost?: PiAiModelCostRates;
maxTokens?: number;
[key: string]: unknown;
}>;
apiKey?: string;
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
authHeader?: boolean;
@@ -1801,6 +2134,7 @@ async function updateModelsJsonProviderEntriesForAgents(
if (mergedModels.length > 0) existing.models = mergedModels;
if (entry.apiKey !== undefined) existing.apiKey = entry.apiKey;
if (entry.authHeader !== undefined) existing.authHeader = entry.authHeader;
ensureAnthropicMessagesProviderDefaults(existing, providerType);
providers[providerType] = existing;
data.providers = providers;
@@ -2290,32 +2624,25 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
// ── Disable built-in 'feishu' when official openclaw-lark plugin is active ──
// OpenClaw ships a built-in 'feishu' extension in dist/extensions/feishu/
// that conflicts with the official @larksuite/openclaw-lark plugin
// (id: 'openclaw-lark'). When the feishu channel IS configured and the
// canonical plugin is NOT the built-in 'feishu' itself, we must:
// 1. Remove bare 'feishu' from plugins.allow
// 2. Explicitly disable the built-in feishu extension
// ── Remove legacy built-in 'feishu' registration ───────────────
// ClawX bundles Feishu via the official @larksuite/openclaw-lark
// plugin and removes the old built-in dist/extensions/feishu tree.
// Keeping plugins.entries.feishu={enabled:false} looks harmless, but
// OpenClaw's channel startup planner treats it as an explicit blocker
// for the feishu channel owner and skips openclaw-lark at runtime.
const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
if (isFeishuConfigured) {
const hasCanonicalFeishu = allowArr2.includes(canonicalFeishuId) || !!pEntries[canonicalFeishuId];
if (hasCanonicalFeishu && canonicalFeishuId !== 'feishu') {
// Remove bare 'feishu' from plugins.allow
const bareFeishuIdx = allowArr2.indexOf('feishu');
if (bareFeishuIdx !== -1) {
allowArr2.splice(bareFeishuIdx, 1);
console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark plugin is configured)');
modified = true;
}
// Explicitly disable the built-in feishu extension so it doesn't
// conflict with the official openclaw-lark plugin at runtime.
// Simply deleting the entry is NOT sufficient — the built-in
// extension in dist/extensions/feishu/ (enabledByDefault: true) will
// still load unless explicitly marked as disabled.
if (!pEntries.feishu || (pEntries.feishu as Record<string, unknown>).enabled !== false) {
pEntries.feishu = { enabled: false };
console.log('[sanitize] Disabled built-in feishu plugin (openclaw-lark plugin is configured)');
if (pEntries.feishu) {
delete pEntries.feishu;
console.log('[sanitize] Removed legacy plugins.entries.feishu (openclaw-lark plugin is configured)');
modified = true;
}
}
@@ -2485,9 +2812,55 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
modified = true;
console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`);
}
if (channelType === 'discord') {
const sanitizeDiscordGuildChannelConfig = (channelConfig: unknown): boolean => {
if (!channelConfig || typeof channelConfig !== 'object' || Array.isArray(channelConfig)) return false;
const channelRecord = channelConfig as Record<string, unknown>;
let channelModified = false;
if (channelRecord.allow === false && channelRecord.enabled === undefined) {
channelRecord.enabled = false;
channelModified = true;
}
for (const key of ['allow']) {
if (key in channelRecord) {
delete channelRecord[key];
channelModified = true;
}
}
return channelModified;
};
const sanitizeDiscordGuilds = (target: Record<string, unknown>): boolean => {
const guilds = target.guilds;
if (!guilds || typeof guilds !== 'object' || Array.isArray(guilds)) return false;
let guildsModified = false;
for (const guildConfig of Object.values(guilds as Record<string, unknown>)) {
if (!guildConfig || typeof guildConfig !== 'object' || Array.isArray(guildConfig)) continue;
const channels = (guildConfig as Record<string, unknown>).channels;
if (!channels || typeof channels !== 'object' || Array.isArray(channels)) continue;
for (const channelConfig of Object.values(channels as Record<string, unknown>)) {
guildsModified = sanitizeDiscordGuildChannelConfig(channelConfig) || guildsModified;
}
}
return guildsModified;
};
const sanitizedTopLevel = sanitizeDiscordGuilds(section);
const sanitizedAccounts = Object.values(accounts ?? {}).some((accountConfig) => (
accountConfig && typeof accountConfig === 'object' && sanitizeDiscordGuilds(accountConfig)
));
if (sanitizedTopLevel || sanitizedAccounts) {
modified = true;
console.log('[sanitize] Removed incompatible Discord channel allow flags');
}
}
}
}
if (healAnthropicMessagesMaxTokensInConfig(config)) {
modified = true;
}
if (modified) {
await writeOpenClawJson(config);
console.log('[sanitize] openclaw.json sanitized successfully');
+91 -24
View File
@@ -4,7 +4,7 @@
* All file I/O is async (fs/promises) to avoid blocking the Electron
* main thread.
*/
import { access, readFile, writeFile, readdir, unlink } from 'fs/promises';
import { access, mkdir, readFile, writeFile, readdir, unlink } from 'fs/promises';
import { constants } from 'fs';
import { join, resolve, sep } from 'path';
import { homedir } from 'os';
@@ -13,6 +13,8 @@ import { getResourcesDir } from './paths';
const CLAWX_BEGIN = '<!-- clawx:begin -->';
const CLAWX_END = '<!-- clawx:end -->';
const DEFAULT_BOOTSTRAP_FILENAME = 'BOOTSTRAP.md';
const DEFAULT_IDENTITY_FILENAME = 'IDENTITY.md';
// ── Helpers ──────────────────────────────────────────────────────
@@ -26,6 +28,94 @@ function isCurrentOpenClawPath(p: string): boolean {
return workspaceDir === openclawDir || workspaceDir.startsWith(openclawDir + sep);
}
export function buildDefaultClawXIdentityContent(): string {
return [
'# IDENTITY.md - ClawX',
'',
'- **Name:** ClawX',
'- **Creature:** desktop AI assistant',
'- **Vibe:** concise, capable, and practical',
'- **Emoji:** 🐾',
'- **Avatar:**',
'',
'ClawX uses a default desktop identity instead of chat-first bootstrap.',
'',
].join('\n');
}
export function isOpenClawIdentityTemplate(content: string): boolean {
const normalized = content.replace(/\r\n/g, '\n');
return normalized.includes('# IDENTITY.md - Who Am I?')
&& normalized.includes('_(pick something you like)_')
&& normalized.includes('- **Name:**')
&& normalized.includes('- **Emoji:**');
}
async function writeFileIfMissing(path: string, content: string): Promise<boolean> {
try {
await writeFile(path, content, { encoding: 'utf-8', flag: 'wx' });
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return false;
}
throw error;
}
}
/**
* Ensure ClawX-managed workspaces have a non-template IDENTITY.md before the
* Gateway initializes them. Existing custom identities are preserved.
*/
export async function ensureClawXIdentityFile(
workspaceDir: string,
options: { createDir?: boolean } = {},
): Promise<void> {
const resolvedWorkspaceDir = resolve(workspaceDir);
if (options.createDir) {
await mkdir(resolvedWorkspaceDir, { recursive: true });
} else if (!(await fileExists(resolvedWorkspaceDir))) {
return;
}
const identityPath = join(resolvedWorkspaceDir, DEFAULT_IDENTITY_FILENAME);
const defaultIdentity = buildDefaultClawXIdentityContent();
let wroteIdentity = await writeFileIfMissing(identityPath, defaultIdentity);
if (!wroteIdentity) {
let existing: string;
try {
existing = await readFile(identityPath, 'utf-8');
} catch {
return;
}
if (isOpenClawIdentityTemplate(existing) && existing !== defaultIdentity) {
await writeFile(identityPath, defaultIdentity, 'utf-8');
wroteIdentity = true;
}
}
const bootstrapPath = join(resolvedWorkspaceDir, DEFAULT_BOOTSTRAP_FILENAME);
if (await fileExists(bootstrapPath)) {
try {
await unlink(bootstrapPath);
logger.info(`Removed chat-first bootstrap file from ClawX workspace (${resolvedWorkspaceDir})`);
} catch {
logger.warn(`Failed to remove chat-first bootstrap file: ${bootstrapPath}`);
}
} else if (wroteIdentity) {
logger.info(`Seeded default ClawX identity for workspace (${resolvedWorkspaceDir})`);
}
}
export async function ensureClawXDefaultIdentity(): Promise<void> {
const workspaceDirs = await resolveAllWorkspaceDirs();
for (const { dir: workspaceDir, waitForGatewaySeed } of workspaceDirs) {
await ensureClawXIdentityFile(workspaceDir, { createDir: waitForGatewaySeed });
}
}
// ── Pure helpers (no I/O) ────────────────────────────────────────
/**
@@ -214,26 +304,6 @@ export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
}
}
/**
* ClawX ships a default desktop identity and does not need OpenClaw's
* chat-first personalization script. Once the Gateway has seeded the regular
* workspace files, remove BOOTSTRAP.md so sessions start normally.
*/
export async function removeChatFirstBootstrapFiles(): Promise<void> {
const workspaceDirs = await resolveAllWorkspaceDirs();
for (const { dir: workspaceDir } of workspaceDirs) {
const bootstrapPath = join(workspaceDir, 'BOOTSTRAP.md');
if (!(await fileExists(bootstrapPath))) continue;
try {
await unlink(bootstrapPath);
logger.info(`Removed chat-first bootstrap file from ClawX workspace (${workspaceDir})`);
} catch {
logger.warn(`Failed to remove chat-first bootstrap file: ${bootstrapPath}`);
}
}
}
// ── Context merging ──────────────────────────────────────────────
/**
@@ -352,7 +422,6 @@ export async function ensureClawXContext(options: EnsureClawXContextOptions = {}
async function runEnsureClawXContext(options: EnsureClawXContextOptions): Promise<void> {
let result = await mergeClawXContextOnce(options);
if (result.retryableMissing === 0) {
await removeChatFirstBootstrapFiles();
if (result.missing > 0) {
logger.debug(`ClawX context merge skipped ${result.missing} non-ready file(s)`);
}
@@ -363,7 +432,6 @@ async function runEnsureClawXContext(options: EnsureClawXContextOptions): Promis
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
result = await mergeClawXContextOnce(options);
if (result.retryableMissing === 0) {
await removeChatFirstBootstrapFiles();
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
return;
}
@@ -371,5 +439,4 @@ async function runEnsureClawXContext(options: EnsureClawXContextOptions): Promis
}
logger.warn(`ClawX context merge: ${result.retryableMissing} startup file(s) still missing after ${MAX_RETRIES} retries`);
await removeChatFirstBootstrapFiles();
}
+19 -4
View File
@@ -2,11 +2,8 @@
* Shared OpenClaw Plugin Install Utilities
*
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
* (DingTalk, WeCom, Feishu, WeChat). Used both at app startup (to auto-upgrade
* (DingTalk, WeCom, Feishu, WeChat, Discord, QQBot, WhatsApp). Used both at app startup (to auto-upgrade
* stale plugins) and when a user configures a channel.
*
* Note: QQBot was moved to a built-in channel in OpenClaw 3.31 and is no longer
* managed as a plugin.
*/
import { app } from 'electron';
import path from 'node:path';
@@ -234,6 +231,9 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
dingtalk: '@soimy/dingtalk',
wecom: '@wecom/wecom-openclaw-plugin',
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
discord: '@openclaw/discord',
qqbot: '@openclaw/qqbot',
whatsapp: '@openclaw/whatsapp',
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
@@ -517,6 +517,18 @@ export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: s
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
}
export function ensureDiscordPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord');
}
export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot');
}
export function ensureWhatsAppPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp');
}
// ── Bulk startup installer ───────────────────────────────────────────────────
/**
@@ -528,6 +540,9 @@ const ALL_BUNDLED_PLUGINS = [
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
{ fn: ensureDiscordPluginInstalled, label: 'Discord' },
{ fn: ensureQQBotPluginInstalled, label: 'QQBot' },
{ fn: ensureWhatsAppPluginInstalled, label: 'WhatsApp' },
] as const;
/**
+37
View File
@@ -3,9 +3,11 @@ const MULTI_INSTANCE_PROVIDER_TYPES = new Set(['custom', 'ollama']);
export const OPENCLAW_PROVIDER_KEY_MINIMAX = 'minimax-portal';
export const OPENCLAW_PROVIDER_KEY_MOONSHOT = 'moonshot';
export const OPENCLAW_PROVIDER_KEY_MOONSHOT_GLOBAL = 'moonshot-global';
export const OPENAI_CODEX_RUNTIME_PROVIDER_KEY = 'openai-codex';
export const OAUTH_PROVIDER_TYPES = ['minimax-portal', 'minimax-portal-cn'] as const;
export const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS = [
OPENCLAW_PROVIDER_KEY_MINIMAX,
OPENAI_CODEX_RUNTIME_PROVIDER_KEY,
] as const;
const OAUTH_PROVIDER_TYPE_SET = new Set<string>(OAUTH_PROVIDER_TYPES);
@@ -33,6 +35,21 @@ export function getOpenClawProviderKeyForType(type: string, providerId: string):
return PROVIDER_KEY_ALIASES[type] ?? type;
}
/**
* Resolve the OpenClaw runtime provider key for a saved account.
* Browser OAuth for OpenAI is stored under vendorId `openai` but runs as `openai-codex`.
*/
export function resolveOpenClawProviderKey(account: {
vendorId: string;
id: string;
authMode?: string;
}): string {
if (account.authMode === 'oauth_browser' && account.vendorId === 'openai') {
return OPENAI_CODEX_RUNTIME_PROVIDER_KEY;
}
return getOpenClawProviderKeyForType(account.vendorId, account.id);
}
/**
* Get all vendorId values that map to the given openclaw.json key via alias.
* e.g. getAliasSourceTypes('minimax-portal') ['minimax-portal-cn']
@@ -43,6 +60,26 @@ export function getAliasSourceTypes(openClawKey: string): string[] {
.map(([source]) => source);
}
/**
* OpenAI Codex OAuth uses runtime key `openai-codex` while API keys use `openai`.
* When only OAuth is configured, hide the redundant bare `openai` active slot so the
* UI does not show a second "OpenAI • API key (missing)" row.
*/
export function filterActiveProviderKeysForUi(
activeKeys: Iterable<string>,
options?: { hasConfiguredOpenAiApiKey?: boolean },
): string[] {
const keys = Array.from(activeKeys);
const active = new Set(keys);
if (!active.has('openai') || !active.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) {
return keys;
}
if (options?.hasConfiguredOpenAiApiKey) {
return keys;
}
return keys.filter((key) => key !== 'openai');
}
export function isOAuthProviderType(type: string): boolean {
return OAUTH_PROVIDER_TYPE_SET.has(type);
}
+271
View File
@@ -0,0 +1,271 @@
/**
* Session file helpers
*
* Shared between the Main IPC `session:delete` handler and the HTTP mirror
* `POST /api/sessions/delete`. Both surfaces must agree on:
*
* 1. how a `sessionKey` (e.g. `agent:main:session-1234`) is resolved to an
* on-disk transcript path via `sessions.json`, and
* 2. which sibling artefacts are swept when the user hard-deletes a
* conversation:
* - `<baseId>.jsonl` the live transcript
* - `<baseId>.deleted.jsonl` legacy soft-delete leftover
* - `<baseId>.jsonl.reset.*` reset snapshots from sessions.reset
* - `<baseId>.trajectory.jsonl` OpenClaw runtime "flight recorder"
* (default location, beside session)
* - `<baseId>.trajectory-path.json` pointer sidecar; when present we
* follow `runtimeFile` so the actual
* trajectory still gets unlinked even
* if `OPENCLAW_TRAJECTORY_DIR` moved
* it out of the sessions/ folder
*
* The helper enforces that the resolved session path lives inside the
* agent's `sessions/` directory so a corrupt or malicious `sessions.json`
* can never steer the unlink loop into an unrelated folder. The pointer-
* follow path is the only deliberate exception: it walks to whatever
* `runtimeFile` says (defended by schema + extension checks) so ClawX can
* cooperate with OpenClaw's documented `OPENCLAW_TRAJECTORY_DIR` override.
*/
import { promises as fsP } from 'node:fs';
import path from 'node:path';
export type SessionResolutionFailure =
| { kind: 'not-found' }
| { kind: 'path-outside-scope'; resolvedPath: string };
export type SessionResolutionResult =
| { ok: true; resolvedSrcPath: string; sessionsDirAbs: string; baseId: string }
| { ok: false; failure: SessionResolutionFailure };
/**
* `path.isAbsolute` only respects the *current* platform's rules. OpenClaw
* may be running in a context (or have been migrated from a profile) where
* the on-disk `sessionFile` uses Windows-style backslash paths, Windows
* forward-slash paths (`C:/...`) or POSIX paths. Accepting all three keeps
* the IPC handler portable across Win32 + macOS + Linux installs.
*/
function isAnyAbsolute(candidate: string): boolean {
return path.isAbsolute(candidate) || path.win32.isAbsolute(candidate);
}
/**
* Resolve the absolute on-disk transcript path for `sessionKey` from a parsed
* `sessions.json` blob. Returns `{ ok: false, failure: 'not-found' }` when no
* matching entry is found and `{ ok: false, failure: 'path-outside-scope' }`
* when the resolved path escapes `sessionsDir` (defence-in-depth).
*
* Supports the three sessions.json shapes observed in the wild:
* A) `{ sessions: [{ key, file, ... }] }` (array, file field)
* B) `{ [sessionKey]: { sessionFile|file|...|id } }` (object-keyed, current)
* C) `{ sessions: [{ key, id, ... }] }` (array, id field only)
*/
export function resolveSessionTranscriptPath(
sessionsJson: Record<string, unknown>,
sessionsDir: string,
sessionKey: string,
): SessionResolutionResult {
let uuidFileName: string | undefined;
let resolvedSrcPath: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
}
}
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (isAnyAbsolute(absFile)) {
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
return { ok: false, failure: { kind: 'not-found' } };
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = path.join(sessionsDir, uuidFileName!);
}
const sessionsDirAbs = path.dirname(resolvedSrcPath);
// sessionsDir is always built from `getOpenClawConfigDir()` + the validated
// agentId, so anything that doesn't resolve underneath it is suspect. We
// refuse rather than try to "normalise" the entry — the worst case for the
// user is that the sidebar entry stops listing it; the worst case if we
// proceeded is `unlink`s in someone else's directory.
const rel = path.relative(sessionsDir, sessionsDirAbs);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return { ok: false, failure: { kind: 'path-outside-scope', resolvedPath: resolvedSrcPath } };
}
const baseId = path.basename(resolvedSrcPath).replace(/\.jsonl$/, '');
return { ok: true, resolvedSrcPath, sessionsDirAbs, baseId };
}
export interface SweepResult {
removed: string[];
errors: Array<{ path: string; error: NodeJS.ErrnoException }>;
}
/**
* Schema marker written by OpenClaw's `writeTrajectoryPointerBestEffort`.
* Any pointer file that doesn't carry this exact `traceSchema` is treated
* as untrusted and ignored we only follow pointers we know OpenClaw
* authored.
*/
const TRAJECTORY_POINTER_SCHEMA = 'openclaw-trajectory-pointer';
/**
* Best-effort: parse the trajectory pointer sidecar and return the off-disk
* runtime file path it points at. Returns null when the pointer is absent,
* malformed, missing the openclaw-trajectory-pointer schema, or its
* `runtimeFile` is not an absolute `.jsonl` path. Any of those conditions
* means we silently skip the off-disk unlink the local sidecar sweep
* still runs and the worst case is one orphaned file the user can clean up
* manually.
*/
async function readTrajectoryRuntimeFile(pointerPath: string): Promise<string | null> {
let raw: string;
try {
raw = await fsP.readFile(pointerPath, 'utf8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
if (obj.traceSchema !== TRAJECTORY_POINTER_SCHEMA) return null;
const runtimeFile = obj.runtimeFile;
if (typeof runtimeFile !== 'string' || runtimeFile.length === 0) return null;
// OpenClaw always writes a `.jsonl` path; refusing anything else keeps us
// from being weaponised into deleting (say) `/etc/passwd` if a hostile
// sessions.json/pointer combination ever showed up on disk.
if (!runtimeFile.endsWith('.jsonl')) return null;
if (!path.isAbsolute(runtimeFile) && !path.win32.isAbsolute(runtimeFile)) return null;
return runtimeFile;
}
/**
* Hard-delete every artefact that belongs to `baseId` inside
* `sessionsDirAbs`. ENOENT is tolerated for both the directory itself and
* each individual file by the time the sweep runs we've already committed
* to the deletion, so missing files just mean a previous sweep got there
* first. Other errors are accumulated so the caller can log/surface them.
*
* In addition to the local sidecars (`.jsonl`, `.deleted.jsonl`,
* `.jsonl.reset.*`, `.trajectory.jsonl`, `.trajectory-path.json`), the
* sweep follows the `.trajectory-path.json` pointer when it exists and
* unlinks the off-disk runtime file at `runtimeFile`. This keeps ClawX in
* sync with OpenClaw's `OPENCLAW_TRAJECTORY_DIR` override (where the
* actual trajectory is stored outside the sessions/ folder).
*/
export async function sweepSessionArtefacts(
sessionsDirAbs: string,
baseId: string,
): Promise<SweepResult> {
const result: SweepResult = { removed: [], errors: [] };
let dirEntries: string[];
try {
dirEntries = await fsP.readdir(sessionsDirAbs);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return result;
result.errors.push({ path: sessionsDirAbs, error: err as NodeJS.ErrnoException });
return result;
}
const pointerName = `${baseId}.trajectory-path.json`;
const targets = dirEntries.filter((name) =>
name === `${baseId}.jsonl`
|| name === `${baseId}.deleted.jsonl`
|| name === `${baseId}.trajectory.jsonl`
|| name === pointerName
|| name.startsWith(`${baseId}.jsonl.reset.`),
);
// Follow the pointer FIRST so the off-disk runtime file (lives outside
// sessionsDirAbs when OPENCLAW_TRAJECTORY_DIR is set) gets unlinked
// before we delete its only on-disk reference. If the pointer is gone,
// malformed, or carries an in-sessions-dir path, this is a no-op and the
// standard local sweep below covers everything.
if (targets.includes(pointerName)) {
const pointerPath = path.join(sessionsDirAbs, pointerName);
const runtimeFile = await readTrajectoryRuntimeFile(pointerPath);
if (runtimeFile) {
const absRuntime = path.resolve(runtimeFile);
const rel = path.relative(sessionsDirAbs, absRuntime);
// Only chase pointers that escape the sessions dir. Anything pointing
// back inside is already covered by the local-target sweep below
// (and would risk a double-unlink / double-error otherwise).
if (rel.startsWith('..') || path.isAbsolute(rel)) {
try {
await fsP.unlink(absRuntime);
result.removed.push(absRuntime);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
result.errors.push({ path: absRuntime, error: err as NodeJS.ErrnoException });
}
}
}
}
}
await Promise.all(targets.map(async (name) => {
const target = path.join(sessionsDirAbs, name);
try {
await fsP.unlink(target);
result.removed.push(target);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return;
result.errors.push({ path: target, error: err as NodeJS.ErrnoException });
}
}));
return result;
}
/**
* Remove `sessionKey` from a parsed sessions.json object (mutates in place).
* Handles both the array-shape (`{ sessions: [...] }`) and the object-keyed
* shape so the IPC handler and HTTP route share one rewrite path.
*/
export function removeSessionEntry(
sessionsJson: Record<string, unknown>,
sessionKey: string,
): void {
if (Array.isArray(sessionsJson.sessions)) {
sessionsJson.sessions = (sessionsJson.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (sessionsJson[sessionKey]) {
delete sessionsJson[sessionKey];
}
}
@@ -0,0 +1,24 @@
---
id: packaged-runtime-pruning-guards
title: Packaged Runtime Pruning Guards
type: ai-coding-rule
appliesTo:
- plugin-lifecycle-management
requiredProfiles:
- fast
---
Packaged runtime cleanup must remove only files that are not needed by the
target artifact.
For macOS universal builds, architecture pruning must preserve both x64 and
arm64 native payloads for the same platform. This includes scoped optional
packages such as `@openai/codex-darwin-x64` and
`@openai/codex-darwin-arm64`, plus per-arch native prebuild directories such
as `tree-sitter-bash/prebuilds/darwin-x64` and
`tree-sitter-bash/prebuilds/darwin-arm64`.
Any change to `scripts/after-pack.cjs` or `scripts/bundle-openclaw.mjs` that
adds native-package pruning or known-runtime-junk cleanup must include a unit
test covering the target architecture behavior, including `arch: universal`
when the rule can affect macOS packaged builds.
@@ -36,6 +36,7 @@ requiredRules:
- channel-plugin-migration-guards
- capability-owner-resolution
- active-config-guards
- packaged-runtime-pruning-guards
---
Plugin lifecycle management covers bundled and external plugins as one system with different source types. The core model has two layers:
@@ -52,5 +53,6 @@ Lifecycle stages:
- Validate: ClawX verifies package manifests, dependencies, capability config, ownership uniqueness, and startup requirements.
- Activate: only resolved and validated capabilities enter final OpenClaw runtime config.
- Recover: failed upgrades, stale registrations, conflicts, and removed channels converge to a single diagnosable state with rollback or cleanup paths.
- Package: cleanup and pruning keep packaged artifacts small without deleting target runtime assets; macOS universal packages keep both x64 and arm64 native payloads.
First-stage priority is integration safety: single-owner capability resolution, active config guards, direct regression tests for migration cases, and explicit task specs for resolution, validation, and recovery work.
@@ -0,0 +1,54 @@
---
id: fix-chat-history-gateway-timeout
title: Fix chat history timeout through gateway backend communication
scenario: gateway-backend-communication
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/gateway/rpc-backpressure.ts
- electron/main/ipc-handlers.ts
- src/components/layout/Sidebar.tsx
- src/pages/Chat/index.tsx
- src/pages/Chat/message-utils.ts
- src/stores/chat.ts
- src/stores/chat/history-actions.ts
- src/stores/chat/history-startup-retry.ts
- src/stores/chat/session-actions.ts
- src/stores/chat/session-label-hydration.ts
- src/stores/chat/store-api.ts
- src/stores/chat/types.ts
- 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
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.
- Sidebar label hydration uses the existing host API boundary instead of heavy gateway chat.history fan-out.
- Renderer continues to use the existing host/store boundary.
requiredProfiles:
- fast
- comms
requiredRules:
- gateway-readiness-policy
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
requiredTests:
- pnpm run typecheck
- 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
acceptance:
- Renderer does not add direct IPC calls.
- Renderer does not fetch Gateway HTTP directly.
- Startup/restart no longer fans out sidebar label chat.history calls before the visible session history finishes loading.
- Sidebar label hydration no longer depends on gateway chat.history full-session scans.
- Foreground history uses a bounded startup RPC wait and falls back to local transcript reads instead of surfacing transient RPC timeout errors.
- Main-process chat.history RPCs are single-flighted/backpressured before reaching the Gateway.
docs:
required: false
---
@@ -0,0 +1,37 @@
---
id: fix-gateway-agent-phase-end-state
title: Keep chat run active across non-terminal gateway phase end events
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Prevent Gateway agent lifecycle hints from prematurely clearing the chat sending state while tool execution continues.
touchedAreas:
- .gitignore
- harness/specs/tasks/fix-gateway-agent-phase-end-state.md
- src/stores/gateway.ts
- tests/e2e/chat-run-state-events.spec.ts
- tests/unit/gateway-events.test.ts
expectedUserBehavior:
- The chat composer keeps showing the stop control while an agent run continues across tool rounds.
- Intermediate `phase: end` notifications refresh history without making the run look interrupted.
- Progressive streaming delta notifications without sequence numbers continue updating the visible response.
requiredProfiles:
- fast
- comms
requiredRules:
- gateway-readiness-policy
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
requiredTests:
- pnpm exec vitest run tests/unit/gateway-events.test.ts tests/unit/chat-event-dedupe.test.ts
- pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts
- pnpm run typecheck
acceptance:
- Renderer does not add direct IPC calls.
- Renderer does not fetch Gateway HTTP directly.
- `phase: end` no longer clears `sending`, `activeRunId`, `pendingFinal`, or `lastUserMessageAt`.
- Terminal phases such as `completed` still clear chat run state.
- Gateway event dedupe does not suppress same-run delta notifications that do not carry `seq`.
docs:
required: false
---
@@ -0,0 +1,43 @@
---
id: fix-gateway-ready-chat-history-reload
title: Fix delayed sidebar chat history reload after gateway restart
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Align gateway readiness signaling with sidebar history refresh after restart.
touchedAreas:
- harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
- electron/gateway/manager.ts
- src/components/layout/Sidebar.tsx
- src/stores/chat.ts
- src/stores/chat/history-actions.ts
- src/pages/Chat/ChatInput.tsx
- src/pages/Settings/index.tsx
- src/pages/Setup/index.tsx
- tests/unit/gateway-ready-fallback.test.ts
- tests/unit/chat-input.test.tsx
- tests/unit/chat-store-history-retry.test.ts
- tests/e2e/gateway-lifecycle.spec.ts
expectedUserBehavior:
- After gateway restart, sidebar history reloads as soon as the gateway becomes RPC-ready.
- UI does not show a fully healthy green running state while gatewayReady is still false.
requiredProfiles:
- fast
- comms
requiredRules:
- gateway-readiness-policy
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
requiredTests:
- tests/unit/gateway-ready-fallback.test.ts
- tests/unit/chat-input.test.tsx
- tests/unit/chat-store-history-retry.test.ts
- tests/e2e/gateway-lifecycle.spec.ts
acceptance:
- Renderer does not add direct IPC calls.
- Renderer does not fetch Gateway HTTP directly.
- Gateway running-but-not-ready state is surfaced distinctly from fully ready.
- Sidebar reloads sessions/history when gatewayReady becomes true after a restart.
docs:
required: false
---
@@ -0,0 +1,76 @@
---
id: hard-delete-session-jsonl
title: Hard-delete session JSONL on conversation delete
scenario: gateway-backend-communication
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/utils/session-files.ts
- src/stores/chat/session-actions.ts
- src/stores/chat.ts
- tests/unit/session-delete-route.test.ts
- harness/specs/tasks/hard-delete-session-jsonl.md
- AGENTS.md
expectedUserBehavior:
- Confirming "Delete" in the sidebar conversation menu removes <id>.jsonl, <id>.deleted.jsonl and any <id>.jsonl.reset.* siblings from the agent's sessions folder.
- OpenClaw's trajectory artefacts for the same session id are removed too — both the local <id>.trajectory.jsonl flight recorder and the <id>.trajectory-path.json pointer sidecar.
- When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk.
- The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it.
- The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store.
- Any pending optimistic user message cache for the deleted session key is cleared so a later history reload cannot resurrect deleted chat bubbles.
- Token usage history reported by the Dashboard stops including the deleted session.
requiredProfiles:
- fast
- comms
requiredTests:
- tests/unit/session-delete-route.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.
- 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).
- Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS.
- The sweep also unlinks <id>.trajectory.jsonl and <id>.trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer.
- When <id>.trajectory-path.json carries the openclaw-trajectory-pointer schema and an absolute .jsonl runtimeFile that lives outside the sessions/ folder, the off-disk runtime trajectory is unlinked too (covers the OPENCLAW_TRAJECTORY_DIR override). Pointers with the wrong schema, a non-.jsonl runtimeFile, or a non-absolute runtimeFile are ignored and no extra files are touched.
docs:
required: true
---
Conversation deletion in ClawX runs entirely on the Main process — the
OpenClaw Gateway does not expose a `sessions.delete` RPC. Historically the
operation was a soft delete: the live `<id>.jsonl` transcript was renamed to
`<id>.deleted.jsonl` so `sessions.list` would skip it. This task replaces
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:
- sessions.json entry resolution across the three observed shapes,
- cross-platform absolute-path detection (POSIX, Windows `C:\...` and
Windows `C:/...`) using `path.isAbsolute` + `path.win32.isAbsolute`,
- a defence-in-depth scope check that refuses any `sessionFile` whose
resolved directory escapes the agent's `sessions/` folder,
- the sibling sweep (`.jsonl`, `.deleted.jsonl`, `.jsonl.reset.*`,
`.trajectory.jsonl`, `.trajectory-path.json`) with ENOENT tolerance, and
- the trajectory pointer-follow that handles OpenClaw's
`OPENCLAW_TRAJECTORY_DIR` override: when the pointer is well-formed
(`traceSchema === "openclaw-trajectory-pointer"` and `runtimeFile` is
an absolute `.jsonl` outside sessions/), the off-disk runtime file is
unlinked too. Malformed or unsafe pointers are ignored.
Renderer surface is untouched: the confirm dialog in `Sidebar.tsx`, the
`useChatStore.deleteSession` API, and the host-api/api-client boundary all
continue to work without changes. The only user-visible effect is that
deleted conversations no longer leave hidden `.deleted.jsonl` files behind
and no longer contribute to Dashboard token-usage history. Deletion also
clears renderer-only pending optimistic user messages keyed by the deleted
session so the UI cannot rehydrate a locally staged message after the
transcript and sidebar entry have been removed.
@@ -0,0 +1,97 @@
---
id: openai-api-key-pin-pi-agent-runtime
title: Pin the embedded "pi" agent runtime on OpenAI API-key provider entries to avoid the unbundled "codex" harness
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Stop OpenClaw from auto-routing API-key OpenAI provider entries (api.openai.com baseUrl) to the externally-bundled "codex" agent harness, which is not registered in the shipped OpenClaw distribution and causes every chat to fail with `Requested agent harness "codex" is not registered.`. Pin `agentRuntime.id = "pi"` on every `models.providers.openai` entry ClawX writes, and self-heal existing on-disk entries before the next default-provider switch so upgrading users do not have to re-save their provider manually.
touchedAreas:
- harness/specs/tasks/openai-api-key-pin-pi-agent-runtime.md
- electron/utils/openclaw-auth.ts
- electron/services/providers/provider-runtime-sync.ts
- tests/unit/openclaw-auth.test.ts
expectedUserBehavior:
- Configuring OpenAI with an API key (default `https://api.openai.com/v1` baseUrl) and starting a chat succeeds without `Requested agent harness "codex" is not registered.` from the Gateway.
- Upgrading from an earlier ClawX build that wrote an `openai` provider entry without `agentRuntime` and then switching default provider (or back to OpenAI) self-heals the entry so the next Gateway reload boots cleanly.
- OAuth-based OpenAI Codex accounts (which target the separate `openai-codex` runtime key) keep their existing routing — their `models.providers.openai-codex` entry is never auto-pinned.
requiredProfiles:
- fast
- comms
requiredRules:
- active-config-guards
- backend-communication-boundary
- renderer-main-boundary
- api-client-transport-policy
requiredTests:
- tests/unit/openclaw-auth.test.ts
acceptance:
- electron/utils/openclaw-auth.ts exports `ensureOpenClawProviderAgentRuntimePins` and pins `agentRuntime: { id: 'pi' }` on every `models.providers.openai` write via `upsertOpenClawProviderEntry`.
- electron/services/providers/provider-runtime-sync.ts invokes `ensureOpenClawProviderAgentRuntimePins` inside `syncDefaultProviderToRuntime` right after `pruneInvalidApiProviderEntries`, before either the OAuth or non-OAuth branch runs, so a switch to any healthy provider repairs legacy openai entries.
- The pin policy only targets the `openai` provider key (the API-key path). `openai-codex` (the OAuth runtime key) is never auto-pinned.
- A pre-existing user-supplied `agentRuntime.id` on the `openai` entry is preserved on both the write path and the self-heal path — the pin only fills in a missing value.
- Renderer does not add new direct ipcRenderer or Gateway HTTP calls.
- Unit tests cover (a) the write-path pin via `syncProviderConfigToOpenClaw('openai', ...)`, (b) the OAuth-path non-pin via `syncProviderConfigToOpenClaw('openai-codex', ...)`, (c) preservation of a user-supplied override on both paths, and (d) the self-heal helper for legacy on-disk openai entries.
docs:
required: false
---
## Background
OpenClaw 2026.5+ ships a provider-routing policy
([node_modules/openclaw/dist/policy-AKMwD9k5.js](node_modules/openclaw/dist/policy-AKMwD9k5.js),
[node_modules/openclaw/dist/openai-codex-routing-kS7Ub1vB.js](node_modules/openclaw/dist/openai-codex-routing-kS7Ub1vB.js))
that auto-routes every `models.providers.openai` entry whose `baseUrl` is the
official `https://api.openai.com/v1` endpoint through the `codex` agent
harness. The intent is to give OpenAI Codex OAuth accounts a richer
trajectory, but the heuristic does not distinguish API-key vs OAuth setups —
it always picks `codex` when the baseUrl matches.
The bundled OpenClaw distribution we ship does not register any agent harness
with id `"codex"` (only `cliBackends: ["codex-cli"]` is declared by the OpenAI
plugin manifest at
`node_modules/openclaw/dist/extensions/openai/openclaw.plugin.json`). As a
result, every API-key OpenAI chat fails inside
[node_modules/openclaw/dist/selection-61FIEezO.js](node_modules/openclaw/dist/selection-61FIEezO.js)
with:
```
Requested agent harness "codex" is not registered.
```
Provider-side validation passes (the API key is valid; the protocol is in the
allow-list); the failure is purely about agent harness selection.
The fix is to make ClawX write an explicit `agentRuntime: { id: "pi" }` on
every `models.providers.openai` entry. OpenClaw's policy resolver honours an
explicit `agentRuntime.id` before falling into the codex auto-routing
heuristic, so the API-key path is rescued from the unbundled harness without
disturbing OAuth users (whose entry lives under the separate
`models.providers.openai-codex` key).
## Scope
- Add an `OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME` map in
`electron/utils/openclaw-auth.ts` (currently `{ openai: 'pi' }`) and apply
it inside `upsertOpenClawProviderEntry` so every write of a pinned provider
carries `agentRuntime: { id: <runtime> }` when the entry does not already
specify one.
- Export `ensureOpenClawProviderAgentRuntimePins()` as a self-heal helper
that walks existing `models.providers.*` entries on disk and writes the pin
in place when missing — mirroring `pruneInvalidApiProviderEntries`.
- Call the helper in `syncDefaultProviderToRuntime` immediately after
`pruneInvalidApiProviderEntries`, before any OAuth/non-OAuth branching, so
the repair happens once per default-provider switch.
- Cover both the write-path pin and the self-heal path in
`tests/unit/openclaw-auth.test.ts`. Verify that the OAuth `openai-codex`
entry is untouched and that any user-supplied `agentRuntime` override is
preserved on both paths.
## Out of scope
- Upstream changes to OpenClaw's policy resolver so it would only auto-route
to `codex` when an `openai-codex` harness is actually registered.
- Pin policy for any other provider key besides `openai`. Future providers
that need the same defense can be added to the map without further
plumbing.
- Renderer-side UI surface for picking a different agent runtime per
provider — there is no user-visible UI change in this task.
- README updates (no user-visible UI change).
@@ -0,0 +1,40 @@
---
id: packaged-runtime-pruning-guards
title: Guard packaged runtime pruning for universal builds
scenario: plugin-lifecycle-management
taskType: plugin-lifecycle
intent: Keep packaged OpenClaw runtime cleanup size-conscious without deleting native payloads required by macOS universal artifacts.
touchedAreas:
- scripts/after-pack.cjs
- scripts/bundle-openclaw.mjs
- scripts/openclaw-bundle-config.mjs
- tests/unit/after-pack-cleanup.test.ts
- tests/unit/openclaw-bundle-config.test.ts
- harness/specs/rules/packaged-runtime-pruning-guards.md
- harness/specs/tasks/packaged-runtime-pruning-guards.md
expectedUserBehavior:
- Packaged OpenAI Codex support works in macOS arm64, x64 and universal artifacts.
- Packaged tree-sitter-bash runtime loading keeps a usable native prebuild for every architecture in the target artifact.
- Size cleanup still removes non-target platform packages and known runtime junk for single-architecture builds.
requiredProfiles:
- fast
requiredTests:
- tests/unit/after-pack-cleanup.test.ts
- tests/unit/openclaw-bundle-config.test.ts
acceptance:
- `cleanupNativePlatformPackages` keeps same-platform x64 and arm64 native packages when the electron-builder arch resolves to `universal`.
- `cleanupNodeModulesRuntimeJunk` keeps same-platform x64 and arm64 `tree-sitter-bash/prebuilds` directories when the target arch is `universal`.
- Non-target platforms are still pruned from scoped native packages and tree-sitter-bash prebuilds.
- Extra bundled OpenClaw packages still include `@openclaw/codex`, and the bundle script still skips a duplicate nested `openclaw` package.
docs:
required: false
---
This task captures packaged-runtime cleanup invariants for OpenClaw extension
and native payload bundling. The size optimization path may prune unused
platform binaries, generated declarations, source maps and known non-runtime
files, but it must not treat macOS `universal` as a literal architecture.
Universal macOS artifacts contain both x64 and arm64 slices. Cleanup helpers
therefore keep same-platform x64 and arm64 packages/prebuilds while still
removing Linux, Windows and other non-target platform payloads.
@@ -0,0 +1,70 @@
---
id: provider-switch-api-protocol-validation
title: Reject invalid OpenClaw api protocol writes and self-heal stale entries
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Prevent ClawX from writing models.providers.*.api values outside the OpenClaw allowlist into openclaw.json, and opportunistically prune pre-existing invalid entries when the user switches default provider so a clean switch can rescue the Gateway from Invalid-config restart loops.
touchedAreas:
- harness/specs/tasks/provider-switch-api-protocol-validation.md
- electron/shared/providers/registry.ts
- electron/shared/providers/types.ts
- electron/utils/openclaw-auth.ts
- electron/services/providers/provider-runtime-sync.ts
- tests/unit/openclaw-auth.test.ts
expectedUserBehavior:
- Selecting OpenRouter (or any built-in provider) as default writes a Gateway-valid api protocol so Gateway reload stays healthy.
- Attempting to save a provider whose api protocol is not in the OpenClaw allowlist surfaces an actionable error in the UI without polluting openclaw.json or triggering a Gateway restart.
- Switching default to a healthy provider after a prior corrupt openclaw.json (legacy api=openrouter typo) self-heals the file before the next reload signal so the Gateway boots cleanly.
requiredProfiles:
- fast
- comms
requiredRules:
- active-config-guards
- backend-communication-boundary
- renderer-main-boundary
- api-client-transport-policy
requiredTests:
- tests/unit/openclaw-auth.test.ts
acceptance:
- electron/shared/providers/types.ts exports OPENCLAW_API_PROTOCOLS as the single source of truth for openclaw.json api values, plus assertValidApiProtocol and InvalidApiProtocolError.
- setOpenClawDefaultModel, setOpenClawDefaultModelWithOverride, syncProviderConfigToOpenClaw, and upsertOpenClawProviderEntry all invoke assertValidApiProtocol before any write to openclaw.json.
- syncDefaultProviderToRuntime calls pruneInvalidApiProviderEntries before the OAuth and non-OAuth branches so a switch to any healthy provider drops legacy invalid models.providers entries.
- Renderer does not add new direct ipcRenderer or Gateway HTTP calls.
- Unit tests cover both the rejection path (no file write on invalid api) and the self-heal path (legacy entries pruned while valid ones remain).
docs:
required: false
---
## Background
A historical bug in [electron/shared/providers/registry.ts](electron/shared/providers/registry.ts) set the OpenRouter
`providerConfig.api` to the literal string `'openrouter'`, which is not in OpenClaw's allowed
`api` enum (`openai-completions | openai-responses | openai-codex-responses | anthropic-messages |
google-generative-ai | github-copilot | bedrock-converse-stream | ollama | azure-openai-responses`).
When the user selected OpenRouter as their default provider, ClawX wrote that invalid value into
`~/.openclaw/openclaw.json` and then sent SIGUSR1 to the running Gateway. The Gateway's
`assertValidGatewayStartupConfigSnapshot` rejected the config, tore down all channels (close code
1012), entered the `startup_failed` state, but kept the OS process alive. The Main process then
spun for ~8 minutes inside `waitForGatewayReady` before doctor self-heal kicked in.
Switching to MiniMax during the broken window did not help, because the runtime-sync path only
appended the MiniMax block and left the broken OpenRouter entry untouched.
## Scope
- Fix the registry typo so OpenRouter writes `api: 'openai-completions'`, matching every other
OpenAI-compatible built-in provider.
- Add a single source of truth for the allowed api protocols and a runtime guard at every write
site (`setOpenClawDefaultModel`, `setOpenClawDefaultModelWithOverride`,
`syncProviderConfigToOpenClaw`, plus defense-in-depth inside `upsertOpenClawProviderEntry`).
- Add a self-heal helper that prunes any `models.providers.*` entry whose `api` is outside the
allowlist, and invoke it at the top of `syncDefaultProviderToRuntime` so a clean switch rescues
legacy corrupted configs.
## Out of scope
- Gateway-side `waitForGatewayReady` short-circuit when the owned process is stuck in
`startup_failed`.
- Renderer pre-flight validation UI surface beyond the existing route-level error handling.
- README updates (no user-visible UI change in this task).
+26 -22
View File
@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.4.2",
"version": "0.4.5-beta.0",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
@@ -77,27 +77,12 @@
"postversion": "node scripts/post-version-push.mjs"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@sinclair/typebox": "^0.34.48",
"@types/diff": "^8.0.0",
"chokidar": "^5.0.0",
"clawhub": "^0.5.0",
"diff": "^9.0.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.3",
"katex": "^0.16.45",
"lru-cache": "^11.2.6",
"monaco-editor": "^0.55.1",
"ms": "^2.1.3",
"node-machine-id": "^1.1.12",
"pdfjs-dist": "^5.7.284",
"posthog-node": "^5.28.0",
"rehype-katex": "^7.0.1",
"remark-frontmatter": "^5.0.0",
"remark-math": "^6.0.0",
"tar": "^6.2.1",
"ws": "^8.19.0",
"xlsx": "^0.18.5"
"ws": "^8.19.0"
},
"devDependencies": {
"@buape/carbon": "0.16.0",
@@ -106,8 +91,13 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.7",
"@larksuite/openclaw-lark": "2026.4.8",
"@larksuite/openclaw-lark": "2026.5.12",
"@larksuiteoapi/node-sdk": "^1.61.1",
"@monaco-editor/react": "^4.7.0",
"@openclaw/codex": "2026.5.12",
"@openclaw/discord": "2026.5.12",
"@openclaw/qqbot": "2026.5.12",
"@openclaw/whatsapp": "2026.5.12",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -121,11 +111,13 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@soimy/dingtalk": "^3.5.3",
"@sinclair/typebox": "^0.34.48",
"@soimy/dingtalk": "^3.6.2",
"@tencent-connect/qqbot-connector": "^1.1.0",
"@tencent-weixin/openclaw-weixin": "^2.1.9",
"@tencent-weixin/openclaw-weixin": "^2.4.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/diff": "^8.0.0",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -133,12 +125,14 @@
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
"@wecom/wecom-openclaw-plugin": "^2026.4.23",
"@wecom/wecom-openclaw-plugin": "^2026.5.7",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"acpx": "0.5.3",
"autoprefixer": "^10.4.24",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^9.0.0",
"discord-api-types": "^0.38.47",
"electron": "^40.6.0",
"electron-builder": "^26.8.1",
@@ -151,10 +145,15 @@
"https-proxy-agent": "^9.0.0",
"i18next": "^25.8.11",
"jsdom": "^28.1.0",
"katex": "^0.16.45",
"lru-cache": "^11.2.6",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
"mpg123-decoder": "^1.0.3",
"openclaw": "2026.4.23",
"ms": "^2.1.3",
"openclaw": "2026.5.12",
"opusscript": "^0.1.1",
"pdfjs-dist": "^5.7.284",
"playwright-core": "1.59.1",
"png2icons": "^2.0.1",
"postcss": "^8.5.6",
@@ -164,13 +163,17 @@
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"rehype-katex": "^7.0.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sharp": "^0.34.5",
"silk-wasm": "^3.7.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tar": "^6.2.1",
"typescript": "^5.9.3",
"undici": "8.1.0",
"use-stick-to-bottom": "^1.1.3",
@@ -178,6 +181,7 @@
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.0.18",
"xlsx": "^0.18.5",
"zustand": "^5.0.11",
"zx": "^8.8.5"
},
+1252 -960
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 KiB

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

After

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 KiB

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 KiB

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 KiB

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 KiB

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 KiB

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 KiB

After

Width:  |  Height:  |  Size: 919 KiB

+88 -3
View File
@@ -155,6 +155,7 @@ const PLATFORM_NATIVE_SCOPES = {
'@reflink': /^reflink-(darwin|linux|win32)-(x64|arm64|x64-gnu|x64-musl|arm64-gnu|arm64-musl|x64-msvc|arm64-msvc)/,
'@node-llama-cpp': /^(mac|linux|win)-(arm64|x64|armv7l)(-metal|-cuda|-cuda-ext|-vulkan)?$/,
'@esbuild': /^(darwin|linux|win32|android|freebsd|netbsd|openbsd|sunos|aix|openharmony)-(x64|arm64|arm|ia32|loong64|mips64el|ppc64|riscv64|s390x)/,
'@openai': /^codex-(darwin|linux|win32)-(x64|arm64)$/,
};
// Unscoped packages that follow a <name>-<platform>-<arch> convention.
@@ -173,6 +174,13 @@ function baseArch(rawArch) {
return dash > 0 ? rawArch.slice(0, dash) : rawArch;
}
function matchesTargetArch(pkgArch, targetArch) {
if (targetArch === 'universal') {
return pkgArch === 'x64' || pkgArch === 'arm64' || pkgArch === 'universal';
}
return pkgArch === targetArch || pkgArch === 'universal';
}
function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
let removed = 0;
@@ -190,7 +198,7 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
const isMatch =
pkgPlatform === platform &&
(pkgArch === arch || pkgArch === 'universal');
matchesTargetArch(pkgArch, arch);
if (!isMatch) {
try {
@@ -215,7 +223,7 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
const isMatch =
pkgPlatform === platform &&
(pkgArch === arch || pkgArch === 'universal');
matchesTargetArch(pkgArch, arch);
if (!isMatch) {
try {
@@ -229,6 +237,75 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
return removed;
}
function cleanupNodeModulesRuntimeJunk(nodeModulesDir, platform, arch) {
let removed = 0;
const nodeWavDir = join(nodeModulesDir, 'node-wav');
for (const name of ['x.json', 'x.js', 'x.js~', 'file.wav']) {
try {
const target = join(nodeWavDir, name);
if (existsSync(target)) {
rmSync(target, { recursive: true, force: true });
removed++;
}
} catch { /* */ }
}
const treeSitterBashDir = join(nodeModulesDir, 'tree-sitter-bash');
const treeSitterSrc = join(treeSitterBashDir, 'src');
for (const name of ['parser.c', 'scanner.c', 'grammar.json', 'tree_sitter']) {
try {
const target = join(treeSitterSrc, name);
if (existsSync(target)) {
rmSync(target, { recursive: true, force: true });
removed++;
}
} catch { /* */ }
}
const prebuildsDir = join(treeSitterBashDir, 'prebuilds');
if (existsSync(prebuildsDir)) {
for (const entry of readdirSync(prebuildsDir)) {
const [entryPlatform, ...entryArchParts] = entry.split('-');
const entryArch = baseArch(entryArchParts.join('-'));
if (entryPlatform === platform && matchesTargetArch(entryArch, arch)) continue;
try {
rmSync(join(prebuildsDir, entry), { recursive: true, force: true });
removed++;
} catch { /* */ }
}
}
return removed;
}
function cleanupKnownRuntimeJunk(rootDir, platform, arch) {
let removed = 0;
const stack = [rootDir];
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try { entries = readdirSync(normWin(dir), { withFileTypes: true }); } catch { continue; }
if (basename(dir) === 'node_modules') {
removed += cleanupNodeModulesRuntimeJunk(dir, platform, arch);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
stack.push(join(dir, entry.name));
}
}
return removed;
}
exports.__test = {
cleanupNativePlatformPackages,
cleanupNodeModulesRuntimeJunk,
};
// ── Broken module patcher ─────────────────────────────────────────────────────
// Some bundled packages have transpiled CJS that sets `module.exports = exports.default`
// without ever assigning `exports.default`, leaving module.exports === undefined.
@@ -606,6 +683,9 @@ exports.default = async function afterPack(context) {
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
{ npmName: '@openclaw/discord', pluginId: 'discord' },
{ npmName: '@openclaw/qqbot', pluginId: 'qqbot' },
{ npmName: '@openclaw/whatsapp', pluginId: 'whatsapp' },
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
];
@@ -618,6 +698,10 @@ exports.default = async function afterPack(context) {
const pluginNM = join(pluginDestDir, 'node_modules');
cleanupUnnecessaryFiles(pluginDestDir);
if (existsSync(pluginNM)) {
const pluginJunkRemoved = cleanupKnownRuntimeJunk(pluginDestDir, platform, arch);
if (pluginJunkRemoved > 0) {
console.log(`[after-pack] ✅ ${pluginId}: removed ${pluginJunkRemoved} known runtime junk files/directories.`);
}
cleanupKoffi(pluginNM, platform, arch);
cleanupNativePlatformPackages(pluginNM, platform, arch);
}
@@ -758,7 +842,8 @@ exports.default = async function afterPack(context) {
// 2. General cleanup on the full openclaw directory (not just node_modules)
console.log('[after-pack] 🧹 Cleaning up unnecessary files ...');
const removedRoot = cleanupUnnecessaryFiles(openclawRoot);
console.log(`[after-pack] ✅ Removed ${removedRoot} unnecessary files/directories.`);
const removedKnownJunk = cleanupKnownRuntimeJunk(openclawRoot, platform, arch);
console.log(`[after-pack] ✅ Removed ${removedRoot + removedKnownJunk} unnecessary files/directories.`);
// 3. Platform-specific: strip koffi non-target platform binaries
const koffiRemoved = cleanupKoffi(dest, platform, arch);
+6
View File
@@ -7,6 +7,9 @@
* Current plugins:
* - @soimy/dingtalk -> build/openclaw-plugins/dingtalk
* - @wecom/wecom-openclaw-plugin -> build/openclaw-plugins/wecom
* - @openclaw/discord -> build/openclaw-plugins/discord
* - @openclaw/qqbot -> build/openclaw-plugins/qqbot
* - @openclaw/whatsapp -> build/openclaw-plugins/whatsapp
* - @tencent-weixin/openclaw-weixin -> build/openclaw-plugins/openclaw-weixin
*
* The output plugin directory contains:
@@ -39,6 +42,9 @@ const PLUGINS = [
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
{ npmName: '@openclaw/discord', pluginId: 'discord' },
{ npmName: '@openclaw/qqbot', pluginId: 'qqbot' },
{ npmName: '@openclaw/whatsapp', pluginId: 'whatsapp' },
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
];
+66
View File
@@ -177,6 +177,10 @@ echo` Virtual store root: ${openclawVirtualNM}`;
queue.push({ nodeModulesDir: openclawVirtualNM, skipPkg: 'openclaw' });
const SKIP_PACKAGES = new Set([
// Extra bundled extensions such as @openclaw/codex can declare openclaw as a
// peer/optional dependency. The bundle already copies openclaw to OUTPUT root,
// so do not also copy a duplicate into OUTPUT/node_modules/openclaw.
'openclaw',
'typescript',
'@playwright/test',
// @discordjs/opus is a native .node addon compiled for the system Node.js
@@ -447,6 +451,25 @@ function patchBundledExtensionPackageJsons(extensionsRoot) {
patchBundledExtensionPackageJsons(extensionsDir);
function bundleExternalExtension(pkgName, extensionId) {
const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/'));
if (!fs.existsSync(pkgLink)) {
throw new Error(`Missing extension package "${pkgName}". Run pnpm install first.`);
}
const realPath = fs.realpathSync(pkgLink);
const dest = path.join(extensionsDir, extensionId);
fs.rmSync(normWin(dest), { recursive: true, force: true });
fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true });
fs.cpSync(normWin(realPath), normWin(dest), {
recursive: true,
dereference: true,
});
echo` Bundled external extension ${pkgName} -> dist/extensions/${extensionId}`;
}
bundleExternalExtension('@openclaw/codex', 'codex');
// 6. Clean up the bundle to reduce package size
//
// This removes platform-agnostic waste: dev artifacts, docs, source maps,
@@ -482,6 +505,47 @@ function rmSafe(target) {
} catch { return false; }
}
function cleanupNodeModulesRuntimeJunk(nodeModulesDir) {
let removedCount = 0;
const nodeWavDir = path.join(nodeModulesDir, 'node-wav');
for (const name of ['x.json', 'x.js', 'x.js~', 'file.wav']) {
if (rmSafe(path.join(nodeWavDir, name))) removedCount++;
}
// tree-sitter-bash ships C sources for rebuilding its native addon. Packaged
// builds use the prebuilt addon/wasm; keep node-types.json because the CJS
// entry exposes it as optional runtime metadata.
const treeSitterSrc = path.join(nodeModulesDir, 'tree-sitter-bash', 'src');
for (const name of ['parser.c', 'scanner.c', 'grammar.json', 'tree_sitter']) {
if (rmSafe(path.join(treeSitterSrc, name))) removedCount++;
}
return removedCount;
}
function cleanupKnownRuntimeJunk(rootDir) {
let removedCount = 0;
const stack = [rootDir];
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
if (path.basename(dir) === 'node_modules') {
removedCount += cleanupNodeModulesRuntimeJunk(dir);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
stack.push(path.join(dir, entry.name));
}
}
return removedCount;
}
function cleanupBundle(outputDir) {
let removedCount = 0;
const nm = path.join(outputDir, 'node_modules');
@@ -620,6 +684,8 @@ function cleanupBundle(outputDir) {
if (rmSafe(path.join(outputDir, rel))) removedCount++;
}
removedCount += cleanupKnownRuntimeJunk(outputDir);
return removedCount;
}
+16
View File
@@ -17,6 +17,22 @@ export const EXTRA_BUNDLED_PACKAGES = [
'mpg123-decoder',
'silk-wasm',
// The built-in acpx extension resolves the npm "acpx" runtime from the
// bundled OpenClaw context in packaged builds. Package it explicitly so
// the packaged runtime has acpx@0.5.3 available even when extension
// node_modules are flattened or skipped by electron-builder.
'acpx',
// OpenClaw's built-in browser extension resolves playwright-core at runtime.
// Package it explicitly because it is not always present in openclaw's own
// transitive dependency graph from the app bundle context.
'playwright-core',
// The Codex agent harness is published as an OpenClaw extension package.
// Bundle it with the runtime so openai-codex models can register the codex
// harness in packaged builds.
'@openclaw/codex',
// Electron main process QR login flows resolve these files from the
// bundled OpenClaw runtime context in packaged builds.
'qrcode-terminal',
+17 -2
View File
@@ -19,11 +19,13 @@ import { Dreams } from './pages/Dreams';
import { Settings } from './pages/Settings';
import { Setup } from './pages/Setup';
import { useSettingsStore } from './stores/settings';
import { useUpdateStore } from './stores/update';
import { useGatewayStore } from './stores/gateway';
import { useProviderStore } from './stores/providers';
import { applyGatewayTransportPreference } from './lib/api-client';
import { rendererExtensionRegistry } from './extensions/registry';
import { loadExternalRendererExtensions } from './extensions/_ext-bridge.generated';
import { UpdateNotifier } from './components/update/UpdateNotifier';
/**
@@ -101,11 +103,22 @@ function App() {
const setupComplete = useSettingsStore((state) => state.setupComplete);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const initGateway = useGatewayStore((state) => state.init);
const initUpdate = useUpdateStore((state) => state.init);
const initProviders = useProviderStore((state) => state.init);
useEffect(() => {
initSettings();
}, [initSettings]);
let cancelled = false;
void initSettings().finally(() => {
if (!cancelled) {
void initUpdate();
}
});
return () => {
cancelled = true;
};
}, [initSettings, initUpdate]);
// Sync i18n language with persisted settings on mount
useEffect(() => {
@@ -201,6 +214,8 @@ function App() {
</Route>
</Routes>
<UpdateNotifier />
{/* Global toast notifications */}
<Toaster
position="bottom-right"
+9 -23
View File
@@ -62,7 +62,7 @@ interface ChannelConfigModalProps {
onChannelSaved?: (channelType: ChannelType) => void | Promise<void>;
}
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const labelClasses = 'text-sm text-foreground/80 font-bold';
const outlineButtonClasses = 'h-9 text-meta font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground';
const primaryButtonClasses = 'h-9 text-meta font-medium rounded-full px-4 shadow-none';
@@ -81,7 +81,7 @@ export function ChannelConfigModal({
onChannelSaved,
}: ChannelConfigModalProps) {
const { t } = useTranslation('channels');
const { channels, addChannel, fetchChannels } = useChannelsStore();
const { fetchChannels } = useChannelsStore();
const [selectedType, setSelectedType] = useState<ChannelType | null>(initialSelectedType);
const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState('');
@@ -192,23 +192,9 @@ export function ChannelConfigModal({
}, [selectedType, loadingConfig, showChannelName]);
const finishSave = useCallback(async (channelType: ChannelType) => {
const displayName = showChannelName && channelName.trim()
? channelName.trim()
: CHANNEL_NAMES[channelType];
const existingChannel = channels.find((channel) => channel.type === channelType);
if (!existingChannel) {
await addChannel({
type: channelType,
name: displayName,
token: meta?.configFields[0]?.key ? configValues[meta.configFields[0].key] : undefined,
});
} else {
await fetchChannels();
}
await fetchChannels();
await onChannelSaved?.(channelType);
}, [addChannel, channelName, channels, configValues, fetchChannels, meta?.configFields, onChannelSaved, showChannelName]);
}, [fetchChannels, onChannelSaved]);
const finishSaveRef = useRef(finishSave);
const onCloseRef = useRef(onClose);
@@ -539,7 +525,7 @@ export function ChannelConfigModal({
key={type}
onClick={() => setSelectedType(type)}
className={cn(
'group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-surface-input shadow-sm',
'group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent shadow-sm',
isConfigured
? 'border-green-500/40 bg-green-500/5 dark:bg-green-500/10'
: 'border-black/5 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5'
@@ -578,7 +564,7 @@ export function ChannelConfigModal({
</div>
) : qrCode ? (
<div className="text-center space-y-6">
<div className="bg-surface-input p-4 rounded-3xl inline-block shadow-sm border border-black/10 dark:border-white/10">
<div className="bg-transparent p-4 rounded-3xl inline-block shadow-sm border border-black/10 dark:border-white/10">
{qrCode.startsWith('data:image') || qrCode.startsWith('http://') || qrCode.startsWith('https://') ? (
<img src={qrCode} alt="Scan QR Code" className="w-64 h-64 object-contain rounded-2xl" />
) : (
@@ -604,7 +590,7 @@ export function ChannelConfigModal({
</div>
</div>
) : loadingConfig ? (
<div className="flex items-center justify-center py-10 rounded-2xl bg-surface-input border border-black/10 dark:border-white/10">
<div className="flex items-center justify-center py-10 rounded-2xl bg-transparent border border-black/10 dark:border-white/10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">{t('dialog.loadingConfig')}</span>
</div>
@@ -617,7 +603,7 @@ export function ChannelConfigModal({
</div>
)}
<div className="bg-surface-input p-4 rounded-2xl space-y-4 shadow-sm border border-black/10 dark:border-white/10">
<div className="bg-transparent p-4 rounded-2xl space-y-4 shadow-sm border border-black/10 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div>
<p className={labelClasses}>{t('dialog.howToConnect')}</p>
@@ -851,7 +837,7 @@ function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: Con
variant="outline"
size="icon"
onClick={onToggleSecret}
className="h-[44px] w-[44px] rounded-xl bg-surface-input border-black/10 dark:border-white/10 text-muted-foreground hover:text-foreground shrink-0 shadow-sm"
className="h-[44px] w-[44px] rounded-xl bg-transparent border-black/10 dark:border-white/10 text-muted-foreground hover:text-foreground shrink-0 shadow-sm"
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
+38 -17
View File
@@ -16,7 +16,7 @@
* `useArtifactPanel` zustand store so any part of the page (file cards,
* toolbar buttons, "View file changes →" links) can drive it.
*/
import { useLayoutEffect, useMemo } from 'react';
import { useLayoutEffect, useMemo, useRef } from 'react';
import { Eye, FileEdit, FolderOpen, FolderTree, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -62,7 +62,7 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
return (
<div data-testid="artifact-panel" className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-black/5 px-3 py-2 dark:border-white/10">
<div className="relative z-30 flex shrink-0 items-center justify-between gap-2 border-b border-black/5 bg-background px-3 py-2 dark:border-white/10">
<div className="flex min-w-0 items-center gap-1">
{richFocusedFile ? (
<PanelTabButton
@@ -110,22 +110,27 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
</Button>
</div>
<div className="min-h-0 flex-1">
{visibleTab === 'changes' && (
<div className="relative z-0 min-h-0 flex-1 overflow-hidden">
<div className={cn('h-full min-h-0', visibleTab !== 'changes' && 'hidden')}>
<ChangesTab
files={files}
focusedFile={focusedFile}
onFocus={(f) => setFocusedFile(f)}
active={visibleTab === 'changes'}
/>
)}
{visibleTab === 'preview' && <PreviewTab focusedFile={focusedFile} />}
{WORKSPACE_BROWSER_ENABLED && visibleTab === 'browser' && (
<WorkspaceBrowserBody
agent={agent}
runStartedAt={runStartedAt}
refreshSignal={refreshSignal}
compact
/>
</div>
<div className={cn('h-full min-h-0', visibleTab !== 'preview' && 'hidden')}>
<PreviewTab focusedFile={focusedFile} />
</div>
{WORKSPACE_BROWSER_ENABLED && (
<div className={cn('h-full min-h-0', visibleTab !== 'browser' && 'hidden')}>
<WorkspaceBrowserBody
agent={agent}
runStartedAt={runStartedAt}
refreshSignal={refreshSignal}
compact
/>
</div>
)}
</div>
</div>
@@ -141,13 +146,27 @@ interface PanelTabButtonProps {
}
function PanelTabButton({ testId, icon, label, active, onClick }: PanelTabButtonProps) {
const pointerActivated = useRef(false);
return (
<button
data-testid={testId}
type="button"
onClick={onClick}
onPointerDown={(event) => {
if (event.button !== 0) return;
pointerActivated.current = true;
event.preventDefault();
onClick();
}}
onClick={() => {
if (pointerActivated.current) {
pointerActivated.current = false;
return;
}
onClick();
}}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'relative z-40 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
active
? 'bg-foreground/10 text-foreground'
: 'text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
@@ -178,6 +197,7 @@ interface ChangesTabProps {
files: GeneratedFile[];
focusedFile: FilePreviewTarget | null;
onFocus: (file: FilePreviewTarget) => void;
active: boolean;
}
/**
@@ -185,7 +205,7 @@ interface ChangesTabProps {
* the runs file cards or View file changes (auto-first); switching workspace
* tabs does not bring back a sidebar list.
*/
function ChangesTab({ files, focusedFile, onFocus }: ChangesTabProps) {
function ChangesTab({ files, focusedFile, onFocus, active }: ChangesTabProps) {
const { t } = useTranslation('chat');
// De-dup files by path, keep the latest entry (highest lastSeenIndex).
@@ -203,10 +223,11 @@ function ChangesTab({ files, focusedFile, onFocus }: ChangesTabProps) {
// be present in `files`; keep that focus instead of jumping to an unrelated
// generated file.
useLayoutEffect(() => {
if (!active) return;
if (focusedFile) return;
if (uniqueFiles.length === 0) return;
onFocus(generatedFileToTarget(uniqueFiles[0]));
}, [focusedFile, uniqueFiles, onFocus]);
}, [active, focusedFile, uniqueFiles, onFocus]);
if (!focusedFile && uniqueFiles.length === 0) {
return (
@@ -30,6 +30,7 @@ import { cn } from '@/lib/utils';
import { invokeIpc, readTextFile, statFile, writeTextFile } from '@/lib/api-client';
import type { FilePreviewTarget } from './types';
import {
isHtmlPreviewExt,
isPdfPreviewExt,
isSheetPreviewExt,
supportsInlineDiff,
@@ -43,6 +44,7 @@ import {
shouldOfferDirectOpenFallback,
} from './open-file-utils';
import MarkdownPreview from './MarkdownPreview';
import HtmlPreview from './HtmlPreview';
import ImageViewer from './ImageViewer';
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
@@ -103,8 +105,11 @@ function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[]
if (!supportsInlineDocumentPreview(file.ext)) {
return [];
}
// Markdown / plain-text style documents: rendered preview only.
// Markdown / HTML / rich documents: rendered preview first.
tabs.push('preview');
if (isHtmlPreviewExt(file.ext)) {
tabs.push('source');
}
} else if (file.contentType === 'snapshot') {
tabs.push('preview');
} else if (file.contentType === 'video' || file.contentType === 'audio') {
@@ -577,7 +582,15 @@ export function FilePreviewBody({
<SheetViewerLazy filePath={file.filePath} fileName={file.fileName} />
</Suspense>
) : file.contentType === 'document' ? (
<MarkdownPreview source={draft ?? state.content} />
isHtmlPreviewExt(file.ext) ? (
<HtmlPreview
source={draft ?? state.content}
filePath={file.filePath}
fileName={file.fileName}
/>
) : (
<MarkdownPreview source={draft ?? state.content} />
)
) : (
<div className="p-4 text-sm text-muted-foreground">
{t('filePreview.errors.noPreview', 'No preview available for this file')}
@@ -11,6 +11,7 @@ import { invokeIpc } from '@/lib/api-client';
import {
computeLineStats,
supportsInlineDiff,
supportsInlineDocumentPreview,
supportsRichDocumentPreview,
type GeneratedFile,
} from '@/lib/generated-files';
@@ -50,7 +51,7 @@ export function GeneratedFilesPanel({
<div className="flex flex-wrap gap-2">
{files.map((file) => {
const lineStats = computeLineStats(file);
const clickable = supportsInlineDiff(file);
const clickable = supportsInlineDiff(file) || supportsInlineDocumentPreview(file.ext);
const revealOnly = supportsRichDocumentPreview(file.ext);
if (revealOnly) {
return (
@@ -0,0 +1,69 @@
/**
* Sandboxed rendered HTML preview.
*
* HTML emitted by an agent is untrusted, so it is rendered inside an
* iframe with a sandbox instead of being injected into the React tree.
* We allow scripts so simple interactive demo pages still work, but do
* not grant same-origin/top-navigation/popups; the iframe therefore gets
* an opaque origin and cannot reach back into the ClawX renderer.
*/
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export interface HtmlPreviewProps {
source: string;
filePath: string;
fileName?: string;
className?: string;
}
function dirnameOf(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const idx = normalized.lastIndexOf('/');
if (idx < 0) return '';
return normalized.slice(0, idx + 1);
}
function pathToFileUrl(filePath: string): string | null {
if (!filePath) return null;
const normalized = filePath.replace(/\\/g, '/');
const absolutePath = normalized.startsWith('/') ? normalized : `/${normalized}`;
const encoded = absolutePath
.split('/')
.map((segment, index) => (index === 0 ? '' : encodeURIComponent(segment)))
.join('/');
return `file://${encoded}`;
}
function injectBaseHref(source: string, filePath: string): string {
const baseUrl = pathToFileUrl(dirnameOf(filePath));
if (!baseUrl) return source;
const baseTag = `<base href="${baseUrl}">`;
if (/<base\s/i.test(source)) return source;
if (/<head[\s>]/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n${baseTag}`);
}
if (/<html[\s>]/i.test(source)) {
return source.replace(/<html([^>]*)>/i, `<html$1>\n<head>${baseTag}</head>`);
}
return `<!doctype html><html><head>${baseTag}</head><body>${source}</body></html>`;
}
export default function HtmlPreview({ source, filePath, fileName, className }: HtmlPreviewProps) {
const { t } = useTranslation('chat');
const srcDoc = useMemo(() => injectBaseHref(source, filePath), [source, filePath]);
return (
<div className={cn('h-full min-h-0 bg-white', className)}>
<iframe
data-testid="html-preview-frame"
title={fileName ?? t('filePreview.html.title', 'HTML preview')}
srcDoc={srcDoc}
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads"
className="h-full w-full border-0 bg-white"
/>
</div>
);
}
@@ -138,7 +138,7 @@ function SkillFileSection({ title, description, files, onOpen }: SkillFileSectio
type="button"
onClick={() => onOpen(file)}
className={cn(
'flex items-center gap-2 rounded-xl border border-black/5 bg-surface-input px-3 py-2 text-left transition-colors',
'flex items-center gap-2 rounded-xl border border-black/5 bg-transparent px-3 py-2 text-left transition-colors',
'hover:border-primary/40 hover:bg-primary/5',
'dark:border-white/5 dark:hover:bg-white/10',
)}
+30 -6
View File
@@ -1,21 +1,45 @@
/**
* Main Layout Component
* TitleBar at top, then sidebar + content below.
* Platform-aware application shell.
*/
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { TitleBar } from './TitleBar';
import { cn } from '@/lib/utils';
export function MainLayout() {
const platform = window.electron?.platform;
const isMac = platform === 'darwin';
const isWin = platform === 'win32';
return (
<div data-testid="main-layout" className="flex h-screen flex-col overflow-hidden bg-background">
{/* Title bar: drag region on macOS, icon + controls on Windows */}
<div
data-testid="main-layout"
data-platform={platform}
className={cn(
'flex h-screen overflow-hidden',
isWin ? 'bg-surface-sidebar' : 'bg-background',
isMac ? 'flex-row' : 'flex-col',
)}
>
<TitleBar />
{/* Below the title bar: sidebar + content */}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden bg-surface-sidebar">
<Sidebar />
<main data-testid="main-content" className="min-h-0 flex-1 overflow-auto p-6">
<main
data-testid="main-content"
className={cn(
'relative min-h-0 flex-1 overflow-auto rounded-tl-2xl border-l border-border/60 bg-background p-6',
!isWin && 'border-t border-border/60',
)}
>
{isMac && (
<div
data-testid="mac-main-drag-region"
aria-hidden="true"
className="drag-region absolute inset-x-0 top-0 z-10 h-7"
/>
)}
<Outlet />
</main>
</div>
+296 -99
View File
@@ -3,7 +3,7 @@
* Navigation sidebar with menu items.
* No longer fixed - sits inside the flex layout below the title bar.
*/
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Network,
@@ -17,8 +17,12 @@ import {
Terminal,
ExternalLink,
Trash2,
Pencil,
Check,
X,
Cpu,
Moon,
ChevronRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { rendererExtensionRegistry } from '@/extensions/registry';
@@ -28,6 +32,7 @@ import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { getSessionActivityMs, getSessionBucket, type SessionBucketKey } from './session-buckets';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { hostApiFetch } from '@/lib/host-api';
@@ -52,7 +57,7 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItem
data-testid={testId}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
isActive
? 'bg-black/5 dark:bg-white/10 text-foreground'
@@ -61,28 +66,32 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItem
)
}
>
{({ isActive }) => (
<>
<div className={cn("flex shrink-0 items-center justify-center", isActive ? "text-foreground" : "text-muted-foreground")}>
{icon}
</div>
{!collapsed && (
<>
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
{badge && (
<Badge variant="secondary" className="ml-auto shrink-0">
{badge}
</Badge>
)}
</>
)}
</>
)}
<>
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
{icon}
</div>
{!collapsed && (
<>
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
{badge && (
<Badge variant="secondary" className="ml-auto shrink-0">
{badge}
</Badge>
)}
</>
)}
</>
</NavLink>
);
}
const INITIAL_NOW_MS = Date.now();
const DEFAULT_EXPANDED_SESSION_BUCKETS: Record<SessionBucketKey, boolean> = {
today: true,
withinWeek: true,
withinMonth: false,
older: false,
};
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
@@ -91,9 +100,14 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
}
export function Sidebar() {
const isMac = window.electron?.platform === 'darwin';
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const sidebarWidth = useSettingsStore((state) => state.sidebarWidth);
const setSidebarWidth = useSettingsStore((state) => state.setSidebarWidth);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const [isResizing, setIsResizing] = useState(false);
const stopResizeRef = useRef<(() => void) | null>(null);
const sessions = useChatStore((s) => s.sessions);
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
@@ -102,28 +116,35 @@ export function Sidebar() {
const switchSession = useChatStore((s) => s.switchSession);
const newSession = useChatStore((s) => s.newSession);
const deleteSession = useChatStore((s) => s.deleteSession);
const renameSession = useChatStore((s) => s.renameSession);
const loadSessions = useChatStore((s) => s.loadSessions);
const loadHistory = useChatStore((s) => s.loadHistory);
const gatewayStatus = useGatewayStore((s) => s.status);
const isGatewayRunning = gatewayStatus.state === 'running';
const isGatewayReady = isGatewayRunning && gatewayStatus.gatewayReady !== false;
const gatewayRuntimeKey = `${gatewayStatus.pid ?? 'none'}:${gatewayStatus.connectedAt ?? 'none'}:${gatewayStatus.port}`;
const hasLoadedCurrentRuntimeRef = useRef(false);
useEffect(() => {
hasLoadedCurrentRuntimeRef.current = false;
}, [gatewayRuntimeKey]);
useEffect(() => {
if (!isGatewayReady) return;
let cancelled = false;
const hasExistingMessages = useChatStore.getState().messages.length > 0;
(async () => {
await Promise.allSettled([
loadSessions(),
loadHistory(hasExistingMessages),
]);
await loadSessions();
if (cancelled) return;
if (hasLoadedCurrentRuntimeRef.current) return;
hasLoadedCurrentRuntimeRef.current = true;
await loadHistory(false);
})();
return () => {
cancelled = true;
};
}, [isGatewayReady, loadHistory, loadSessions]);
}, [gatewayRuntimeKey, isGatewayReady, loadHistory, loadSessions]);
const agents = useAgentsStore((s) => s.agents);
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
@@ -156,7 +177,12 @@ export function Sidebar() {
const { t } = useTranslation(['common', 'chat']);
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
const [editingLabel, setEditingLabel] = useState('');
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
const [expandedSessionBuckets, setExpandedSessionBuckets] = useState<Record<SessionBucketKey, boolean>>(
() => ({ ...DEFAULT_EXPANDED_SESSION_BUCKETS }),
);
useEffect(() => {
const timer = window.setInterval(() => {
@@ -169,15 +195,90 @@ export function Sidebar() {
void fetchAgents();
}, [fetchAgents]);
const handleStartRename = (key: string, currentLabel: string) => {
setEditingSessionKey(key);
setEditingLabel(currentLabel);
};
const handleRenameSubmit = async () => {
if (!editingSessionKey || !editingLabel.trim()) {
setEditingSessionKey(null);
return;
}
try {
await renameSession(editingSessionKey, editingLabel.trim());
} catch (err) {
console.error('Failed to rename session:', err);
}
setEditingSessionKey(null);
};
const handleRenameCancel = () => {
setEditingSessionKey(null);
};
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleRenameSubmit();
} else if (e.key === 'Escape') {
handleRenameCancel();
}
};
const toggleSessionBucket = (bucketKey: SessionBucketKey) => {
setExpandedSessionBuckets((current) => ({
...current,
[bucketKey]: !current[bucketKey],
}));
};
const stopResizing = useCallback(() => {
stopResizeRef.current?.();
stopResizeRef.current = null;
setIsResizing(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, []);
const handleResizePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (sidebarCollapsed) return;
event.preventDefault();
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Window listeners below keep dragging reliable even if capture is unavailable.
}
const onMove = (moveEvent: PointerEvent) => {
setSidebarWidth(moveEvent.clientX);
};
const onUp = () => stopResizing();
stopResizeRef.current = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
setIsResizing(true);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[setSidebarWidth, sidebarCollapsed, stopResizing],
);
useEffect(() => stopResizing, [stopResizing]);
const agentNameById = useMemo(
() => Object.fromEntries((agents ?? []).map((agent) => [agent.id, agent.name])),
[agents],
);
const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
{ key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] },
{ key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] },
{ key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] },
{ key: 'older', label: t('chat:historyBuckets.older'), sessions: [] },
];
@@ -200,13 +301,13 @@ export function Sidebar() {
const extraNavItems = rendererExtensionRegistry.getExtraNavItems();
const coreNavItems = [
{ to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
{ to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
{ to: '/skills', icon: <Puzzle className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.skills'), testId: 'sidebar-nav-skills' },
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
{ to: '/models', icon: <Cpu className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
{ to: '/agents', icon: <Bot className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
{ to: '/channels', icon: <Network className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
{ to: '/skills', icon: <Puzzle className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.skills'), testId: 'sidebar-nav-skills' },
{ to: '/cron', icon: <Clock className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
...(devModeUnlocked
? [{ to: '/dreams', icon: <Moon className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }]
? [{ to: '/dreams', icon: <Moon className="h-4 w-4" strokeWidth={2} />, label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }]
: []),
];
@@ -214,7 +315,7 @@ export function Sidebar() {
...coreNavItems.filter((item) => !hiddenRoutes.has(item.to)),
...extraNavItems.map((item) => ({
to: item.to,
icon: <item.icon className="h-[18px] w-[18px]" strokeWidth={2} />,
icon: <item.icon className="h-4 w-4" strokeWidth={2} />,
label: item.labelI18nKey ? t(item.labelI18nKey) : item.label,
testId: item.testId,
})),
@@ -224,12 +325,19 @@ export function Sidebar() {
<aside
data-testid="sidebar"
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden border-r bg-surface-sidebar/60 transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64'
'relative flex min-h-0 shrink-0 flex-col overflow-hidden bg-surface-sidebar',
isResizing ? 'transition-none' : 'transition-[width] duration-300',
)}
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
>
{/* Top Header Toggle */}
<div className={cn("flex items-center p-2 h-12", sidebarCollapsed ? "justify-center" : "justify-between")}>
<div
className={cn(
'flex items-center p-2 h-12',
isMac && 'drag-region h-[4.75rem] items-end pt-10',
sidebarCollapsed ? 'justify-center' : 'justify-between',
)}
>
{!sidebarCollapsed && (
<div className="flex items-center gap-2 px-2 overflow-hidden">
<img src={logoSvg} alt="ClawX" className="h-5 w-auto shrink-0" />
@@ -241,7 +349,10 @@ export function Sidebar() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10"
className={cn(
'no-drag h-8 w-8 shrink-0 rounded-lg text-foreground/80',
'hover:bg-black/5 hover:text-foreground/80 dark:hover:bg-white/5',
)}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? (
@@ -253,8 +364,9 @@ export function Sidebar() {
</div>
{/* Navigation */}
<nav className="flex flex-col px-2 gap-0.5">
<nav className="flex flex-col gap-1 px-2">
<button
type="button"
data-testid="sidebar-new-chat"
onClick={() => {
const { messages } = useChatStore.getState();
@@ -262,13 +374,13 @@ export function Sidebar() {
navigate('/');
}}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors mb-2',
'bg-black/5 dark:bg-accent shadow-none border border-transparent text-foreground',
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-2 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed && 'justify-center px-0',
)}
>
<div className="flex shrink-0 items-center justify-center text-foreground/80">
<Plus className="h-[18px] w-[18px]" strokeWidth={2} />
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
<Plus className="h-4 w-4" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.newChat')}</span>}
</button>
@@ -284,83 +396,149 @@ export function Sidebar() {
{/* Session list — below Settings, only when expanded */}
{!sidebarCollapsed && sessions.length > 0 && (
<div className="mt-4 flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2 space-y-0.5">
{sessionBuckets.map((bucket) => (
bucket.sessions.length > 0 ? (
<div className="mt-4 flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2 space-y-1">
{sessionBuckets.map((bucket) => {
const isBucketExpanded = expandedSessionBuckets[bucket.key] ?? false;
return (
<div key={bucket.key} data-testid={`session-bucket-${bucket.key}`} className="pt-2">
<div className="px-2.5 pb-1 text-tiny font-medium text-muted-foreground/60 tracking-tight">
{bucket.label}
</div>
{bucket.sessions.map((s) => {
<button
type="button"
data-testid={`session-bucket-toggle-${bucket.key}`}
aria-expanded={isBucketExpanded}
onClick={() => toggleSessionBucket(bucket.key)}
className={cn(
'flex w-full items-center gap-1 rounded-md px-2.5 py-1 text-left text-tiny font-medium',
'text-muted-foreground/60 tracking-tight transition-colors',
'hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5',
)}
>
<ChevronRight
className={cn(
'h-3 w-3 shrink-0 transition-transform',
isBucketExpanded && 'rotate-90',
)}
/>
<span>{bucket.label}</span>
</button>
{isBucketExpanded && bucket.sessions.map((s) => {
const agentId = getAgentIdFromSessionKey(s.key);
const agentName = agentNameById[agentId] || agentId;
const isEditing = editingSessionKey === s.key;
const sessionLabel = getSessionLabel(s.key, s.displayName, s.label);
return (
<div key={s.key} className="group relative flex items-center">
<button
onClick={() => { switchSession(s.key); navigate('/'); }}
className={cn(
'w-full text-left rounded-lg px-2.5 py-1.5 text-meta transition-colors pr-7',
'hover:bg-black/5 dark:hover:bg-white/5',
isOnChat && currentSessionKey === s.key
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
: 'text-foreground/75',
)}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 rounded-full bg-black/[0.04] px-2 py-0.5 text-2xs font-medium text-foreground/70 dark:bg-white/[0.08]">
{agentName}
</span>
<span className="truncate">{getSessionLabel(s.key, s.displayName, s.label)}</span>
{isEditing ? (
<div className="flex w-full items-center gap-1 px-1.5 py-1">
<Input
autoFocus
value={editingLabel}
onChange={(e) => setEditingLabel(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={() => void handleRenameSubmit()}
className="h-7 min-w-0 flex-1 text-meta"
aria-label={t('common:sidebar.renameSessionPlaceholder')}
/>
<button
aria-label={t('common:sidebar.saveSessionRename')}
onMouseDown={(e) => { e.preventDefault(); void handleRenameSubmit(); }}
className="flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
aria-label={t('common:sidebar.cancelSessionRename')}
onMouseDown={(e) => { e.preventDefault(); handleRenameCancel(); }}
className="flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</button>
<button
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: getSessionLabel(s.key, s.displayName, s.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : (
<>
<button
onClick={() => {
if (currentSessionKey === s.key) {
void loadHistory(false);
} else {
switchSession(s.key);
}
navigate('/');
}}
onDoubleClick={() => handleStartRename(s.key, sessionLabel)}
className={cn(
'w-full text-left rounded-lg px-2.5 py-1.5 text-meta transition-colors pr-16',
'hover:bg-black/5 dark:hover:bg-white/5',
isOnChat && currentSessionKey === s.key
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
: 'text-foreground/75',
)}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 rounded-full bg-black/[0.04] px-2 py-0.5 text-2xs font-medium text-foreground/70 dark:bg-white/[0.08]">
{agentName}
</span>
<span className="truncate">{sessionLabel}</span>
</div>
</button>
<div className={cn(
'absolute right-1 flex items-center gap-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
)}>
<button
aria-label={t('common:sidebar.renameSession')}
onClick={(e) => {
e.stopPropagation();
handleStartRename(s.key, sessionLabel);
}}
className="flex items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
aria-label={t('common:sidebar.deleteSession')}
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: sessionLabel,
});
}}
className="flex items-center justify-center rounded p-0.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
);
})}
</div>
) : null
))}
);
})}
</div>
)}
{/* Footer */}
<div className="p-2 mt-auto">
<div className="mt-auto flex flex-col gap-1 p-2">
<NavLink
to="/settings"
data-testid="sidebar-nav-settings"
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
isActive && 'bg-black/5 dark:bg-white/10 text-foreground',
sidebarCollapsed ? 'justify-center px-0' : ''
)
}
>
{({ isActive }) => (
<>
<div className={cn("flex shrink-0 items-center justify-center", isActive ? "text-foreground" : "text-muted-foreground")}>
<SettingsIcon className="h-[18px] w-[18px]" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.settings')}</span>}
</>
)}
<>
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
<SettingsIcon className="h-4 w-4" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.settings')}</span>}
</>
</NavLink>
{devModeUnlocked && (
@@ -368,25 +546,44 @@ export function Sidebar() {
data-testid="sidebar-open-dev-console"
variant="ghost"
className={cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-sm font-medium transition-colors w-full mt-1',
'sidebar-nav-text flex h-auto w-full items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
)}
onClick={openDevConsole}
>
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
<Terminal className="h-[18px] w-[18px]" strokeWidth={2} />
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
<Terminal className="h-4 w-4" strokeWidth={2} />
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
<ExternalLink className="h-3 w-3 shrink-0 ml-auto opacity-50 text-muted-foreground" />
<ExternalLink className="ml-auto h-3 w-3 shrink-0 opacity-50 text-current" />
</>
)}
</Button>
)}
</div>
{!sidebarCollapsed && (
<div
data-testid="sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={220}
aria-valuemax={420}
aria-valuenow={sidebarWidth}
title="Drag to resize sidebar"
onPointerDown={handleResizePointerDown}
className="no-drag group absolute inset-y-0 right-0 z-20 w-2 translate-x-1/2 cursor-col-resize select-none"
>
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-primary/40"
/>
</div>
)}
<ConfirmDialog
open={!!sessionToDelete}
title={t('common:actions.confirm')}
+8 -5
View File
@@ -1,7 +1,8 @@
/**
* TitleBar Component
* macOS: empty drag region (native traffic lights handled by hiddenInset).
* Windows: drag region with custom minimize/maximize/close controls.
* Windows: drag region with custom minimize/maximize/close controls; uses
* `bg-surface-sidebar` so the frameless strip matches the sidebar rail.
* Linux: use native window chrome (no custom title bar).
*/
import { useState, useEffect } from 'react';
@@ -12,8 +13,8 @@ export function TitleBar() {
const platform = window.electron?.platform;
if (platform === 'darwin') {
// macOS: just a drag region, traffic lights are native
return <div className="drag-region h-10 shrink-0 border-b bg-background" />;
// macOS traffic lights live inside the sidebar area; keep the shell left/right.
return null;
}
// Linux keeps the native frame/title bar for better IME compatibility.
@@ -51,8 +52,10 @@ function WindowsTitleBar() {
};
return (
<div className="drag-region flex h-10 shrink-0 items-center justify-end border-b bg-background">
<div
data-testid="windows-titlebar"
className="drag-region flex h-10 shrink-0 items-center justify-end bg-surface-sidebar"
>
{/* Right: Window Controls */}
<div className="no-drag flex h-full">
<button
+4 -9
View File
@@ -1,10 +1,10 @@
import type { ChatSession } from '@/stores/chat';
const DAY_MS = 24 * 60 * 60 * 1000;
export type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
@@ -13,15 +13,10 @@ export function getSessionBucket(activityMs: number, nowMs: number): SessionBuck
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
if (activityMs >= startOfToday - 7 * DAY_MS) return 'withinWeek';
if (activityMs >= startOfToday - 30 * DAY_MS) return 'withinMonth';
return 'older';
}
+43 -16
View File
@@ -55,7 +55,7 @@ import { useSettingsStore } from '@/stores/settings';
import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events';
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const labelClasses = 'text-sm text-foreground/80 font-bold';
type ArkMode = 'apikey' | 'codeplan';
@@ -401,6 +401,7 @@ function ProviderCard({
const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false);
const [arkMode, setArkMode] = useState<ArkMode>('apikey');
const [validationError, setValidationError] = useState<string | null>(null);
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === account.vendorId);
const providerDocsUrl = getProviderDocsUrl(typeInfo, i18n.language);
@@ -427,6 +428,7 @@ function ProviderCard({
setModelId(account.model || '');
setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n'));
setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds));
setValidationError(null);
setArkMode(
isArkCodePlanMode(
account.vendorId,
@@ -451,6 +453,7 @@ function ProviderCard({
const handleSaveEdits = async () => {
setSaving(true);
setValidationError(null);
try {
const payload: { newApiKey?: string; updates?: Partial<ProviderConfig> } = {};
const normalizedFallbackModels = normalizeFallbackModels(fallbackModelsText.split('\n'));
@@ -464,7 +467,7 @@ function ProviderCard({
});
setValidating(false);
if (!result.valid) {
toast.error(result.error || t('aiProviders.toast.invalidKey'));
setValidationError(result.error || t('aiProviders.toast.invalidKey'));
setSaving(false);
return;
}
@@ -473,7 +476,7 @@ function ProviderCard({
{
if (showModelIdField && !modelId.trim()) {
toast.error(t('aiProviders.toast.modelRequired'));
setValidationError(t('aiProviders.toast.modelRequired'));
setSaving(false);
return;
}
@@ -670,7 +673,10 @@ function ProviderCard({
<Label className={currentLabelClasses}>{t('aiProviders.dialog.modelId')}</Label>
<Input
value={modelId}
onChange={(e) => setModelId(e.target.value)}
onChange={(e) => {
setModelId(e.target.value);
setValidationError(null);
}}
placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'}
className={currentInputClasses}
/>
@@ -784,7 +790,7 @@ function ProviderCard({
placeholder={t('aiProviders.dialog.fallbackModelIdsPlaceholder')}
className={isDefault
? "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card px-3 py-2 text-meta font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm"
: "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-surface-input px-3 py-2 text-meta font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40"}
: "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-meta font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40"}
/>
<p className="text-xs text-muted-foreground">
{t('aiProviders.dialog.fallbackModelIdsHelp')}
@@ -795,7 +801,7 @@ function ProviderCard({
{fallbackOptions.length === 0 ? (
<p className="text-meta text-muted-foreground">{t('aiProviders.dialog.noFallbackOptions')}</p>
) : (
<div className={cn("space-y-2 rounded-xl border border-black/10 dark:border-white/10 p-3 shadow-sm", isDefault ? "bg-white dark:bg-card" : "bg-surface-input")}>
<div className={cn("space-y-2 rounded-xl border border-black/10 dark:border-white/10 p-3 shadow-sm", isDefault ? "bg-white dark:bg-card" : "bg-transparent")}>
{fallbackOptions.map((candidate) => (
<label key={candidate.account.id} className="flex items-center gap-3 text-meta cursor-pointer group/label">
<input
@@ -851,10 +857,14 @@ function ProviderCard({
<div className="flex gap-2">
<div className="relative flex-1">
<Input
data-testid={`provider-edit-key-input-${account.id}`}
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.requiresApiKey ? typeInfo?.placeholder : (typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : t('aiProviders.card.editKey'))}
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
onChange={(e) => {
setNewKey(e.target.value);
setValidationError(null);
}}
className={cn(currentInputClasses, 'pr-10')}
/>
<button
@@ -866,13 +876,14 @@ function ProviderCard({
</button>
</div>
<Button
data-testid={`provider-edit-save-${account.id}`}
variant="outline"
onClick={handleSaveEdits}
className={cn(
"rounded-xl px-4 border-black/10 dark:border-white/10",
isDefault
? "h-[40px] bg-white dark:bg-card hover:bg-black/5 dark:hover:bg-white/10"
: "h-[44px] bg-surface-input hover:bg-black/5 dark:hover:bg-white/10 shadow-sm"
: "h-[44px] bg-transparent hover:bg-black/5 dark:hover:bg-white/10 shadow-sm"
)}
disabled={
validating
@@ -895,18 +906,29 @@ function ProviderCard({
)}
</Button>
<Button
data-testid={`provider-edit-cancel-${account.id}`}
variant="ghost"
onClick={onCancelEdit}
className={cn(
"p-0 rounded-xl",
isDefault
? "h-[40px] w-[40px] hover:bg-black/5 dark:hover:bg-white/10"
: "h-[44px] w-[44px] bg-surface-input border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10 shadow-sm text-muted-foreground hover:text-foreground"
: "h-[44px] w-[44px] bg-transparent border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10 shadow-sm text-muted-foreground hover:text-foreground"
)}
>
<X className="h-4 w-4" />
</Button>
</div>
{validationError && (
<p
data-testid={`provider-edit-validation-error-${account.id}`}
className="text-xs text-red-500 flex items-center gap-1 mt-1"
>
<XCircle className="h-3 w-3 shrink-0" />
<span className="font-medium">{t('aiProviders.dialog.failed')}:</span>
<span>{validationError}</span>
</p>
)}
<p className="text-xs text-muted-foreground">
{t('aiProviders.dialog.replaceApiKeyHelp')}
</p>
@@ -996,6 +1018,7 @@ function AddProviderDialog({
: providerDocsUrl;
const isOAuth = typeInfo?.isOAuth ?? false;
const supportsApiKey = typeInfo?.supportsApiKey ?? false;
const oauthUiHidden = typeInfo?.hideOAuthUi ?? false;
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
const selectedVendor = selectedType ? vendorMap.get(selectedType) : undefined;
const showUserAgentInAddDialog = shouldShowUserAgentFieldForNewProvider(selectedType);
@@ -1003,16 +1026,20 @@ function AddProviderDialog({
? 'oauth_browser'
: (selectedVendor?.supportedAuthModes.includes('oauth_device')
? 'oauth_device'
: (selectedType === 'google' ? 'oauth_browser' : null));
: null);
// Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
const useOAuthFlow = isOAuth && !oauthUiHidden && (!supportsApiKey || authMode === 'oauth');
useEffect(() => {
if (!selectedVendor || !isOAuth || !supportsApiKey) {
return;
}
if (oauthUiHidden) {
setAuthMode('apikey');
return;
}
setAuthMode(selectedVendor.defaultAuthMode === 'api_key' ? 'apikey' : 'oauth');
}, [selectedVendor, isOAuth, supportsApiKey]);
}, [selectedVendor, isOAuth, supportsApiKey, oauthUiHidden]);
useEffect(() => {
if (selectedType !== 'ark') {
@@ -1292,7 +1319,7 @@ function AddProviderDialog({
</div>
) : (
<div className="space-y-6">
<div className="flex items-center gap-3 p-4 rounded-2xl bg-white dark:bg-card border border-black/5 dark:border-white/5 shadow-sm">
<div className="flex items-center gap-3 p-4 rounded-2xl bg-transparent border border-black/5 dark:border-white/5 shadow-sm">
<div className="h-10 w-10 shrink-0 flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-xl">
{getProviderIconUrl(selectedType!) ? (
<img src={getProviderIconUrl(selectedType!)} alt={typeInfo?.name} className={cn('h-6 w-6', shouldInvertInDark(selectedType!) && 'dark:invert')} />
@@ -1347,8 +1374,8 @@ function AddProviderDialog({
</div>
{/* Auth mode toggle for providers supporting both */}
{isOAuth && supportsApiKey && (
<div className="flex rounded-xl border border-black/10 dark:border-white/10 overflow-hidden text-meta font-medium shadow-sm bg-surface-input p-1 gap-1">
{isOAuth && supportsApiKey && !oauthUiHidden && (
<div className="flex rounded-xl border border-black/10 dark:border-white/10 overflow-hidden text-meta font-medium shadow-sm bg-transparent p-1 gap-1">
<button
onClick={() => setAuthMode('oauth')}
className={cn(
@@ -1640,7 +1667,7 @@ function AddProviderDialog({
</div>
</div>
<div className="flex items-center justify-center gap-3 p-4 bg-surface-input border border-black/5 dark:border-white/5 rounded-xl shadow-inner">
<div className="flex items-center justify-center gap-3 p-4 bg-transparent border border-black/5 dark:border-white/5 rounded-xl shadow-inner">
<code className="text-3xl font-mono tracking-[0.2em] font-bold text-foreground">
{oauthData.userCode}
</code>
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { useUpdateStore } from '@/stores/update';
const AVAILABLE_TOAST_ID = 'clawx-update-available';
const DOWNLOADED_TOAST_ID = 'clawx-update-downloaded';
/**
* Shows global update prompts outside the Settings page.
*
* The update store owns IPC communication; this component only reacts to
* store state changes and presents user-facing actions.
*/
export function UpdateNotifier() {
const { t } = useTranslation('settings');
const status = useUpdateStore((state) => state.status);
const updateInfo = useUpdateStore((state) => state.updateInfo);
const downloadUpdate = useUpdateStore((state) => state.downloadUpdate);
const installUpdate = useUpdateStore((state) => state.installUpdate);
const lastAvailableVersionRef = useRef<string | null>(null);
const lastDownloadedVersionRef = useRef<string | null>(null);
useEffect(() => {
const version = updateInfo?.version || t('updates.toast.unknownVersion');
if (status !== 'available') {
toast.dismiss(AVAILABLE_TOAST_ID);
}
if (status !== 'downloaded') {
toast.dismiss(DOWNLOADED_TOAST_ID);
}
if (status === 'available') {
if (lastAvailableVersionRef.current === version) return;
lastAvailableVersionRef.current = version;
toast(t('updates.toast.availableTitle'), {
id: AVAILABLE_TOAST_ID,
description: t('updates.toast.availableDescription', { version }),
duration: Infinity,
action: {
label: t('updates.action.download'),
onClick: () => {
toast.dismiss(AVAILABLE_TOAST_ID);
lastAvailableVersionRef.current = null;
void downloadUpdate();
},
},
});
return;
}
if (status === 'downloaded') {
if (lastDownloadedVersionRef.current === version) return;
lastDownloadedVersionRef.current = version;
toast(t('updates.toast.downloadedTitle'), {
id: DOWNLOADED_TOAST_ID,
description: t('updates.toast.downloadedDescription', { version }),
duration: Infinity,
action: {
label: t('updates.action.install'),
onClick: () => {
toast.dismiss(DOWNLOADED_TOAST_ID);
lastDownloadedVersionRef.current = null;
installUpdate();
},
},
});
}
}, [downloadUpdate, installUpdate, status, t, updateInfo?.version]);
return null;
}
export default UpdateNotifier;
+2 -2
View File
@@ -37,7 +37,7 @@
"modelIdLabel": "Model ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "Preview",
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)",
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.5)",
"modelOverrideDescription": "Select provider and model ID for this agent.",
"unsavedChangesTitle": "Unsaved changes",
"unsavedChangesMessage": "You have unsaved changes. If you close now, your changes will be discarded.",
@@ -74,4 +74,4 @@
"channelRemoved": "{{channel}} removed",
"channelRemoveFailed": "Failed to remove channel: {{error}}"
}
}
}
+9 -5
View File
@@ -14,6 +14,7 @@
"brainstorming": "Multi-Agent Parallel"
},
"noLogs": "(No logs available yet)",
"scrollToLatest": "Jump to latest",
"toolbar": {
"refresh": "Refresh chat",
"currentAgent": "Talking to {{agent}}",
@@ -188,10 +189,13 @@
},
"historyBuckets": {
"today": "Today",
"yesterday": "Yesterday",
"withinWeek": "Within 1 Week",
"withinTwoWeeks": "Within 2 Weeks",
"withinMonth": "Within 1 Month",
"older": "Older than 1 Month"
"withinWeek": "Last 7 days",
"withinMonth": "Last 30 days",
"older": "Older"
},
"questionDirectory": {
"title": "Question directory",
"fallback": "Question {{number}}",
"moreHint": "{{count}} more questions not shown"
}
}
+8 -3
View File
@@ -10,9 +10,14 @@
"settings": "Settings",
"devConsole": "Developer Console",
"models": "Models",
"deleteSessionConfirm": "Are you sure you want to delete session \"{{label}}\"?",
"deleteSessionConfirm": "Delete \"{{label}}\"?",
"openClawPage": "OpenClaw Page",
"openClawDreams": "Dreams"
"openClawDreams": "Dreams",
"renameSession": "Rename",
"deleteSession": "Delete",
"renameSessionPlaceholder": "Session title",
"saveSessionRename": "Save session title",
"cancelSessionRename": "Cancel renaming"
},
"actions": {
"save": "Save",
@@ -57,4 +62,4 @@
"notRunningDesc": "The OpenClaw Gateway needs to be running to use this feature. It will start automatically, or you can start it from Settings.",
"warning": "Gateway is not running."
}
}
}
+12 -4
View File
@@ -72,7 +72,8 @@
"add": "Add Provider",
"save": "Save",
"customDoc": "Documentation",
"validate": "Validate"
"validate": "Validate",
"failed": "Failed"
},
"card": {
"default": "Default",
@@ -158,8 +159,8 @@
"description": "Keep ClawX up to date",
"autoCheck": "Auto-check for updates",
"autoCheckDesc": "Check for updates on startup",
"autoDownload": "Auto-update",
"autoDownloadDesc": "Automatically download and install updates",
"autoDownload": "Update prompts",
"autoDownloadDesc": "Show a notification when a new version is available; download only after you choose to update",
"status": {
"checking": "Checking for updates...",
"downloading": "Downloading update...",
@@ -182,7 +183,14 @@
"currentVersion": "Current Version",
"whatsNew": "What's New:",
"errorDetails": "Error Details:",
"help": "When auto-update is enabled, updates are downloaded and installed automatically."
"toast": {
"availableTitle": "ClawX update available",
"availableDescription": "Version v{{version}} is available. Download it when you're ready.",
"downloadedTitle": "ClawX update ready",
"downloadedDescription": "Version v{{version}} has been downloaded. Restart to install it.",
"unknownVersion": "new"
},
"help": "ClawX checks for updates automatically and prompts you before downloading or installing."
},
"advanced": {
"title": "Advanced",
+2 -2
View File
@@ -37,7 +37,7 @@
"modelIdLabel": "モデル ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "プレビュー",
"modelOverridePlaceholder": "provider/model(例: openrouter/openai/gpt-5.4",
"modelOverridePlaceholder": "provider/model(例: openrouter/openai/gpt-5.5",
"modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。",
"unsavedChangesTitle": "未保存の変更",
"unsavedChangesMessage": "未保存の変更があります。閉じると変更が破棄されます。",
@@ -74,4 +74,4 @@
"channelRemoved": "{{channel}} を削除しました",
"channelRemoveFailed": "Channel の削除に失敗しました: {{error}}"
}
}
}
+9 -5
View File
@@ -14,6 +14,7 @@
"brainstorming": "マルチエージェント並列実行"
},
"noLogs": "(ログはまだありません)",
"scrollToLatest": "最新の会話へ移動",
"toolbar": {
"refresh": "チャットを更新",
"currentAgent": "現在の会話相手: {{agent}}",
@@ -188,10 +189,13 @@
},
"historyBuckets": {
"today": "今日",
"yesterday": "昨日",
"withinWeek": "1週間以内",
"withinTwoWeeks": "2週間以内",
"withinMonth": "1か月以内",
"older": "1か月より前"
"withinWeek": "過去7日間",
"withinMonth": "過去30日間",
"older": "それ以前"
},
"questionDirectory": {
"title": "質問目次",
"fallback": "質問 {{number}}",
"moreHint": "さらに {{count}} 件の質問は表示されていません"
}
}
+8 -3
View File
@@ -10,9 +10,14 @@
"settings": "設定",
"devConsole": "開発者コンソール",
"models": "モデル",
"deleteSessionConfirm": "セッション \"{{label}}\" を削除してもよろしいですか?",
"deleteSessionConfirm": "{{label}}を削除しすか?",
"openClawPage": "OpenClaw ページ",
"openClawDreams": "夢"
"openClawDreams": "夢",
"renameSession": "名前を変更",
"deleteSession": "削除",
"renameSessionPlaceholder": "セッションタイトル",
"saveSessionRename": "セッションタイトルを保存",
"cancelSessionRename": "名前変更をキャンセル"
},
"actions": {
"save": "保存",
@@ -57,4 +62,4 @@
"notRunningDesc": "この機能を使用するには OpenClaw ゲートウェイが実行されている必要があります。自動的に起動するか、設定から起動できます。",
"warning": "ゲートウェイが停止中です。"
}
}
}
+12 -4
View File
@@ -72,7 +72,8 @@
"add": "プロバイダーを追加",
"save": "保存",
"customDoc": "ドキュメント",
"validate": "検証"
"validate": "検証",
"failed": "失敗"
},
"card": {
"default": "デフォルト",
@@ -157,8 +158,8 @@
"description": "ClawX を最新に保つ",
"autoCheck": "自動更新チェック",
"autoCheckDesc": "起動時に更新を確認",
"autoDownload": "自動アップデート",
"autoDownloadDesc": "更新を自動的にダウンロードしてインストール",
"autoDownload": "更新通知",
"autoDownloadDesc": "新しいバージョンが見つかったら通知し、選択後にのみダウンロードします",
"status": {
"checking": "更新を確認中...",
"downloading": "更新をダウンロード中...",
@@ -181,7 +182,14 @@
"currentVersion": "現在のバージョン",
"whatsNew": "更新内容:",
"errorDetails": "エラー詳細:",
"help": "自動アップデートが有効な場合、更新は自動的にダウンロードされインストールされます。"
"toast": {
"availableTitle": "ClawX の更新があります",
"availableDescription": "バージョン v{{version}} を利用できます。準備ができたらダウンロードしてください。",
"downloadedTitle": "ClawX の更新準備ができました",
"downloadedDescription": "バージョン v{{version}} のダウンロードが完了しました。再起動してインストールしてください。",
"unknownVersion": "新しい"
},
"help": "ClawX は自動的に更新を確認し、ダウンロードやインストールの前に通知します。"
},
"advanced": {
"title": "詳細設定",
+2 -2
View File
@@ -37,7 +37,7 @@
"modelIdLabel": "ID модели",
"modelIdPlaceholder": "model-id",
"modelPreview": "Предпросмотр",
"modelOverridePlaceholder": "провайдер/модель (например: openrouter/openai/gpt-5.4)",
"modelOverridePlaceholder": "провайдер/модель (например: openrouter/openai/gpt-5.5)",
"modelOverrideDescription": "Выберите провайдера и ID модели для этого агента.",
"unsavedChangesTitle": "Несохранённые изменения",
"unsavedChangesMessage": "У вас есть несохранённые изменения. Если закроете сейчас, изменения будут потеряны.",
@@ -74,4 +74,4 @@
"channelRemoved": "{{channel}} удалён",
"channelRemoveFailed": "Не удалось удалить канал: {{error}}"
}
}
}
+9 -5
View File
@@ -14,6 +14,7 @@
"brainstorming": "Параллельные агенты"
},
"noLogs": "(Журналы ещё недоступны)",
"scrollToLatest": "К последним сообщениям",
"toolbar": {
"refresh": "Обновить чат",
"currentAgent": "Общение с {{agent}}",
@@ -188,10 +189,13 @@
},
"historyBuckets": {
"today": "Сегодня",
"yesterday": "Вчера",
"withinWeek": "В течение недели",
"withinTwoWeeks": "В течение 2 недель",
"withinMonth": "В течение месяца",
"older": "Старее месяца"
"withinWeek": "За последние 7 дней",
"withinMonth": "За последние 30 дней",
"older": "Ранее"
},
"questionDirectory": {
"title": "Список вопросов",
"fallback": "Вопрос {{number}}",
"moreHint": "Ещё {{count}} вопросов не показано"
}
}

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