Compare commits

...
14 Commits
32 changed files with 2094 additions and 168 deletions
+2 -4
View File
@@ -121,17 +121,15 @@ AIタスクを自動的に実行するようスケジュール設定できます
### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills``self-improving-agent``tavily-search`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
主な検索スキルで必要な環境変数:
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
開発者モードでは、専用の Image Generation ページで、独立した OpenAI 互換の画像生成エンドポイント(Base URL、API キー、`gpt-image-2` などのモデル名)を設定でき、画像生成だけ専用の `/v1/images/generations` サービスを使い、チャットは通常の OpenAI Provider のまま継続できます。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
プロバイダーの編集や切り替え時、ClawX は `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択した Custom プロバイダーのモデルには OpenClaw onboarding と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが自分で設定したモデル行や圧縮設定が変更されることはありません(`reserveTokensFloor` が未設定の場合のみ補完されることがあります)。
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
### 🌙 アダプティブテーマ
+2 -5
View File
@@ -121,18 +121,15 @@ The Cron page now lets you configure external delivery directly in the task form
### 🧩 Extensible Skill System
Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed/workspace skill directories, lets you enable or disable skills without depending on the Gateway, and can optionally expose an extension-provided marketplace in enterprise builds.
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime.
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install.
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds now ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both dev and packaged startup, and any stale `openclaw.json` entries left behind for those removed bundled skills are pruned.
Environment variables for bundled search skills:
- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime)
- `find-skills` and `self-improving-agent` do not require API keys
### 🔐 Secure Provider Integration
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
In developer mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint (Base URL, API key, and model name such as `gpt-image-2`) so image generation can use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints.
When you edit or switch providers, ClawX preserves existing per-model capability metadata such as `input: ["text", "image"]`. Newly selected Custom-provider models use OpenClaw onboarding-compatible image-input inference, with unknown models defaulting to text-only.
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except a missing `reserveTokensFloor` may be backfilled).
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe during API key validation.
### 🌙 Adaptive Theming
+1 -5
View File
@@ -118,13 +118,9 @@ ClawX также включает официальный плагин лично
### 🧩 Расширяемая система навыков
Расширяйте возможности AI-агентов готовыми навыками. Просматривайте, устанавливайте и управляйте навыками через встроенную панель — менеджеры пакетов не нужны.
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке. Дополнительные упакованные навыки (`find-skills`, `self-improving-agent`, `tavily-search`) также включены по умолчанию; если необходимые API-ключи отсутствуют, OpenClaw покажет ошибки конфигурации во время выполнения.
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке.
На странице Навыки отображаются навыки из нескольких источников OpenClaw (управляемый каталог, workspace и дополнительные каталоги навыков), а также показывается фактическое расположение каждого навыка для прямого открытия папки.
Переменные окружения для встроенных поисковых навыков:
- `TAVILY_API_KEY` для `tavily-search` (OAuth также может поддерживаться вышестоящей средой выполнения)
- `find-skills` и `self-improving-agent` не требуют API-ключей
### 🔐 Безопасная интеграция провайдеров
Подключайтесь к нескольким AI-провайдерам (OpenAI, Anthropic и др.) с учётными данными, безопасно хранящимися в системной связке ключей. OpenAI поддерживает как API-ключи, так и OAuth через браузер (подписка Codex).
Для провайдеров **Custom**, используемых с OpenAI-совместимыми шлюзами, вы можете установить пользовательский `User-Agent` в **Настройки → AI Провайдеры → Редактировать провайдера** для совместимости с чувствительными эндпоинтами.
+2 -4
View File
@@ -122,17 +122,15 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills``self-improving-agent``tavily-search`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
重点搜索技能所需环境变量:
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth
### 🔐 安全的供应商集成
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`)。
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时自动降级为轻量的 `/chat/completions``/responses` 探测。
### 🌙 自适应主题
+58 -24
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, buildCandidateSources } from '../utils/plugin-install';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
@@ -81,15 +81,40 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
};
/**
* OpenClaw 3.22+ ships Discord, Telegram, and other channels as built-in
* extensions. If a previous ClawX version copied one of these into
* ~/.openclaw/extensions/, the broken copy overrides the working built-in
* plugin and must be removed.
* OpenClaw ships some channel plugins as bundled extensions under
* dist/extensions/. If ClawX previously mirrored one of those ids into
* ~/.openclaw/extensions/, the stale copy overrides the bundled plugin.
* Only remove extension copies whose id is actually bundled in the
* currently resolved OpenClaw runtime (e.g. telegram in 2026.6.10).
*/
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram', 'qqbot'];
function listBundledOpenClawExtensionPluginIds(): string[] {
const extensionsDir = join(getOpenClawResolvedDir(), 'dist', 'extensions');
if (!existsSync(fsPath(extensionsDir))) {
return [];
}
const pluginIds: string[] = [];
for (const entry of readdirSync(fsPath(extensionsDir), { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestPath = join(extensionsDir, entry.name, 'openclaw.plugin.json');
if (!existsSync(fsPath(manifestPath))) continue;
try {
const parsed = JSON.parse(readFileSync(fsPath(manifestPath), 'utf-8')) as { id?: unknown };
if (typeof parsed.id === 'string' && parsed.id.trim()) {
pluginIds.push(parsed.id.trim());
}
} catch {
// ignore malformed manifests
}
}
return pluginIds;
}
function cleanupStaleBuiltInExtensions(): void {
for (const ext of BUILTIN_CHANNEL_EXTENSIONS) {
for (const ext of listBundledOpenClawExtensionPluginIds()) {
const extDir = join(homedir(), '.openclaw', 'extensions', ext);
if (existsSync(fsPath(extDir))) {
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
@@ -169,6 +194,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
rmSync(fsPath(targetDir), { recursive: true, force: true });
cpSyncSafe(bundledDir, targetDir);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
succeeded = false;
@@ -177,31 +203,35 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
// Same version already installed — still patch manifest ID in case it was
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
}
continue;
}
// Dev mode fallback: copy from node_modules/ with pnpm dep resolution
if (!app.isPackaged) {
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
if (!existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) continue;
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (!sourceVersion) continue;
// Skip only if installed AND same version — but still patch manifest ID.
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
fixupPluginManifest(targetDir);
continue;
}
const npmPkgPath = resolvePluginNpmPackagePath(npmName);
if (npmPkgPath && existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (!sourceVersion) continue;
// Skip only if installed AND same version — but still patch manifest ID.
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
continue;
}
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
succeeded = false;
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
succeeded = false;
}
}
}
}
@@ -493,6 +523,10 @@ export async function syncGatewayConfigBeforeLaunch(
},
));
maintenance['plugin-maintenance'] = result;
// Always refresh trusted install metadata through ClawX — this must not
// be skipped when plugin-maintenance is cache-hit, otherwise official
// external plugins like WhatsApp fail openKeyedStore at runtime.
measureSync(timingsMs, 'trustedPluginInstallSyncMs', repairTrustedOfficialPluginInstallRecords);
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
@@ -1,5 +1,34 @@
export type ModelInputModality = 'text' | 'image';
/**
* Conservative context-window defaults for well-known model families, applied
* to custom-provider model rows that would otherwise carry no `contextWindow`.
*
* Why this matters: when a model row has neither `contextTokens` nor
* `contextWindow`, OpenClaw's embedded runner skips preemptive compaction and
* context-window guarding entirely, so long sessions only fail at the provider
* with "Context overflow: prompt too large" instead of being compacted early.
*/
const CUSTOM_MODEL_CONTEXT_WINDOW_RULES: Array<{ pattern: RegExp; contextWindow: number }> = [
{ pattern: /\bgpt-5/, contextWindow: 272_000 },
{ pattern: /\b(?:gpt-4\.1|gpt-4o|o[134])\b/, contextWindow: 128_000 },
{ pattern: /\bclaude\b|\bclaude-/, contextWindow: 200_000 },
{ pattern: /\bgemini\b/, contextWindow: 1_048_576 },
{ pattern: /\bkimi\b|moonshot/, contextWindow: 256_000 },
{ pattern: /minimax/, contextWindow: 204_800 },
];
/** Safe floor for unknown custom models: high enough to avoid compaction spam. */
export const DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW = 131_072;
export function inferCustomModelContextWindow(modelId: string): number {
const normalized = modelId.trim().toLowerCase();
for (const rule of CUSTOM_MODEL_CONTEXT_WINDOW_RULES) {
if (rule.pattern.test(normalized)) return rule.contextWindow;
}
return DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW;
}
/**
* Mirrors OpenClaw 2026.5.20 custom-provider onboarding inference.
* Unknown models use the same conservative text-only fallback as non-interactive onboarding.
+305 -12
View File
@@ -29,13 +29,14 @@ import {
} from './provider-keys';
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
import { withConfigLock } from './config-mutex';
import { ensureMemorySearchDisabledDefault, hasUserMemorySearchConfig } from './openclaw-memory-search';
import { PORTS } from './config';
import { getSetting } from './store';
import {
assertValidApiProtocol,
normalizeOpenClawApiProtocol,
} from '../shared/providers/types';
import { inferCustomModelInputModalities } from '../shared/providers/model-capabilities';
import { inferCustomModelContextWindow, inferCustomModelInputModalities } from '../shared/providers/model-capabilities';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
@@ -522,6 +523,8 @@ async function discoverAgentIds(): Promise<string[]> {
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
/** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */
const DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR = 50_000;
const BUILTIN_CHANNEL_IDS = new Set([
'discord',
'telegram',
@@ -847,6 +850,86 @@ function normalizeAgentsDefaultsCompactionMode(config: Record<string, unknown>):
}
}
/**
* Seed `agents.defaults.compaction.mode = "safeguard"` when the user has no
* compaction config at all, so long sessions are compacted before they hit the
* provider's context limit. Never touches an existing compaction object.
*/
function ensureCompactionSafeguardDefault(config: Record<string, unknown>): boolean {
const agents = (config.agents && typeof config.agents === 'object'
? config.agents as Record<string, unknown>
: {});
const defaults = (agents.defaults && typeof agents.defaults === 'object'
? agents.defaults as Record<string, unknown>
: {});
if (defaults.compaction !== undefined) return false;
defaults.compaction = {
mode: 'safeguard',
reserveTokensFloor: DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR,
};
agents.defaults = defaults;
config.agents = agents;
return true;
}
/**
* Backfill `reserveTokensFloor` on compaction configs that ClawX or OpenClaw
* seeded without one. OpenClaw's built-in default (20k) is too low once
* contextWindow backfill activates safeguard compaction on 200k+ models.
*/
function backfillCompactionReserveTokensFloor(config: Record<string, unknown>): boolean {
const agents = (config.agents && typeof config.agents === 'object'
? config.agents as Record<string, unknown>
: null);
if (!agents) return false;
const defaults = (agents.defaults && typeof agents.defaults === 'object'
? agents.defaults as Record<string, unknown>
: null);
if (!defaults) return false;
const compaction = (defaults.compaction && typeof defaults.compaction === 'object'
? defaults.compaction as Record<string, unknown>
: null);
if (!compaction || compaction.reserveTokensFloor !== undefined) return false;
compaction.reserveTokensFloor = DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR;
defaults.compaction = compaction;
agents.defaults = defaults;
config.agents = agents;
return true;
}
/**
* Self-heal helper: walk `models.providers.custom-*` entries and fill in an
* inferred `contextWindow` on model rows that have neither `contextWindow`
* nor `contextTokens`. Rows written by older ClawX versions only carried
* `{ id, name, input }`, which disables OpenClaw's preemptive compaction and
* context-window guard for custom providers.
*
* Deliberately scoped to `custom-` keys: registry providers own their
* metadata, and small local models (ollama) must not inherit a large window.
*/
function backfillCustomProviderModelContextWindows(config: Record<string, unknown>): string[] {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
const backfilled: string[] = [];
for (const [providerKey, entry] of Object.entries(providers)) {
if (!providerKey.startsWith('custom-') || !isPlainRecord(entry)) continue;
const rows = Array.isArray(entry.models) ? entry.models : [];
for (const row of rows) {
if (!isPlainRecord(row) || typeof row.id !== 'string' || !row.id) continue;
if (typeof row.contextWindow === 'number' || typeof row.contextTokens === 'number') continue;
row.contextWindow = inferCustomModelContextWindow(row.id);
backfilled.push(`${providerKey}/${row.id}`);
}
}
return backfilled;
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
normalizeAgentsDefaultsCompactionMode(config);
@@ -1828,7 +1911,12 @@ function upsertOpenClawProviderEntry(
id,
name: id,
...(options.inferRuntimeModelInputs
? { input: inferCustomModelInputModalities(id) }
? {
input: inferCustomModelInputModalities(id),
// Without an explicit contextWindow OpenClaw cannot budget compaction
// for custom providers and long sessions die with context overflow.
contextWindow: inferCustomModelContextWindow(id),
}
: {}),
}));
let mergedModels = mergeProviderModels(registryModels, existingModels, runtimeModels);
@@ -2615,6 +2703,35 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
modified = true;
}
// ── Compaction safeguard default ──
if (ensureCompactionSafeguardDefault(config)) {
modified = true;
console.log(`[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
} else if (backfillCompactionReserveTokensFloor(config)) {
modified = true;
console.log(`[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
}
// ── Memory search default ──
// OpenClaw defaults to the openai embedding provider; without a key that
// yields doctor errors and a broken memory_search tool. Seed enabled=false
// only when the user has no memorySearch config anywhere AND no OpenAI key
// (i.e. the default embedding model is unusable). Existing user config is
// never modified.
if (!hasUserMemorySearchConfig(config)
&& !(await getProviderApiKeyFromOpenClaw('openai'))
&& ensureMemorySearchDisabledDefault(config)) {
modified = true;
console.log('[batch-sync] Seeded agents.defaults.memorySearch.enabled=false (no embedding provider configured)');
}
// ── Custom provider contextWindow backfill ──
const backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
if (backfilledContextWindows.length > 0) {
modified = true;
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
}
if (modified) {
await writeOpenClawJson(config);
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
@@ -2670,6 +2787,15 @@ async function updateModelsJsonProviderEntriesForAgents(
const mergedModels = (entry.models ?? []).map((m) => {
const prev = existingModels.find((e) => e.id === m.id);
const base = prev ? { ...prev, id: m.id, name: m.name } : { ...m };
// Custom-provider rows need an explicit contextWindow so the embedded
// runner can budget compaction (see backfillCustomProviderModelContextWindows).
if (
providerType.startsWith('custom-')
&& typeof base.contextWindow !== 'number'
&& typeof base.contextTokens !== 'number'
) {
base.contextWindow = inferCustomModelContextWindow(m.id);
}
return {
...base,
cost: normalizePiAiModelCost((base as { cost?: unknown }).cost),
@@ -2733,6 +2859,48 @@ export async function updateSingleAgentModelProvider(
* unknown or future config issues, the reactive auto-repair mechanism
* (`runOpenClawDoctorRepair`) runs `openclaw doctor --fix` as a fallback.
*/
const SKILL_WORKSHOP_TOOL_DENY_ENTRY = 'skill_workshop';
const SKILL_CREATOR_SKILL_KEY = 'skill-creator';
// ClawX desktop does not expose the subagent workflow. Deny the spawn entry
// point and its companion tools so the model cannot spawn child agent
// sessions (runtime="subagent") even under tools.profile="full".
// `sessions_spawn` is the only tool that creates a subagent; `sessions_yield`
// and `subagents` are the receive/list companions and are useless without it.
const SUBAGENT_TOOL_DENY_ENTRIES = ['sessions_spawn', 'sessions_yield', 'subagents'] as const;
function normalizeToolDenyList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string')
: [];
}
function ensureToolDenyIncludes(
deny: string[],
entry: string,
): { deny: string[]; modified: boolean } {
if (deny.includes(entry)) {
return { deny, modified: false };
}
return { deny: [...deny, entry], modified: true };
}
function ensureToolDenyIncludesAll(
deny: string[],
entries: readonly string[],
): { deny: string[]; modified: boolean } {
let current = deny;
let modified = false;
for (const entry of entries) {
const result = ensureToolDenyIncludes(current, entry);
if (result.modified) {
current = result.deny;
modified = true;
}
}
return { deny: current, modified };
}
export async function sanitizeOpenClawConfig(): Promise<void> {
return withConfigLock(async () => {
// Skip sanitization if the config file does not exist yet.
@@ -2916,18 +3084,35 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
toolsModified = true;
}
// OpenClaw 6.5 moved Skill Workshop into the core skills surface.
// ClawX does not expose that durable-skill proposal flow yet, so keep the
// built-in tool denied even under tools.profile="full".
const deny = Array.isArray(toolsConfig.deny)
? toolsConfig.deny.filter((value): value is string => typeof value === 'string')
: [];
if (!deny.includes('skill_workshop')) {
toolsConfig.deny = [...deny, 'skill_workshop'];
// OpenClaw 6.5+ routes durable skill edits through the Skill Workshop tool.
// ClawX keeps direct skill-creator authoring instead, so deny the workshop
// tool even under tools.profile="full".
const denyResult = ensureToolDenyIncludes(
normalizeToolDenyList(toolsConfig.deny),
SKILL_WORKSHOP_TOOL_DENY_ENTRY,
);
if (denyResult.modified) {
toolsConfig.deny = denyResult.deny;
toolsModified = true;
console.log('[sanitize] Added "skill_workshop" to tools.deny for ClawX desktop');
} else if (!Array.isArray(toolsConfig.deny) || toolsConfig.deny.length !== deny.length) {
toolsConfig.deny = deny;
} else if (!Array.isArray(toolsConfig.deny) || toolsConfig.deny.length !== denyResult.deny.length) {
toolsConfig.deny = denyResult.deny;
toolsModified = true;
}
// ClawX desktop does not expose the subagent workflow. Deny the spawn
// entry point and its companions even under tools.profile="full" so the
// model cannot spawn child agent sessions (runtime="subagent").
const subagentDenyResult = ensureToolDenyIncludesAll(
normalizeToolDenyList(toolsConfig.deny),
SUBAGENT_TOOL_DENY_ENTRIES,
);
if (subagentDenyResult.modified) {
toolsConfig.deny = subagentDenyResult.deny;
toolsModified = true;
console.log(`[sanitize] Added subagent tool(s) to tools.deny for ClawX desktop: ${SUBAGENT_TOOL_DENY_ENTRIES.join(', ')}`);
} else if (!Array.isArray(toolsConfig.deny) || toolsConfig.deny.length !== subagentDenyResult.deny.length) {
toolsConfig.deny = subagentDenyResult.deny;
toolsModified = true;
}
@@ -2951,6 +3136,114 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
modified = true;
}
// ── session.dmScope ─────────────────────────────────────────────
// OpenClaw defaults DM session routing to "main" (all channels share
// agent:main:main), which makes ClawX sidebar conflate feishu, dingtalk,
// and other channel DMs into one entry. Set "per-channel-peer" so each
// channel+peer gets its own session key (agent:main:feishu:direct:ou_xxx),
// letting the sidebar show them as separate conversations with channel badges.
const sessionConfig = (
config.session && typeof config.session === 'object' && !Array.isArray(config.session)
? { ...(config.session as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (sessionConfig.dmScope !== 'per-channel-peer' && sessionConfig.dmScope !== 'per-account-channel-peer') {
sessionConfig.dmScope = 'per-channel-peer';
config.session = sessionConfig;
modified = true;
console.log('[sanitize] Set session.dmScope="per-channel-peer" so channel DMs appear as separate sessions in ClawX');
}
// ── Skill Workshop hard-disable (OpenClaw 6.10+) ─────────────────
const gateway = (
config.gateway && typeof config.gateway === 'object'
? { ...(config.gateway as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayTools = (
gateway.tools && typeof gateway.tools === 'object'
? { ...(gateway.tools as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayDenyResult = ensureToolDenyIncludes(
normalizeToolDenyList(gatewayTools.deny),
SKILL_WORKSHOP_TOOL_DENY_ENTRY,
);
let gatewayModified = gatewayDenyResult.modified;
if (gatewayDenyResult.modified) {
gatewayTools.deny = gatewayDenyResult.deny;
console.log('[sanitize] Added "skill_workshop" to gateway.tools.deny for ClawX desktop');
} else if (!Array.isArray(gatewayTools.deny) || gatewayTools.deny.length !== gatewayDenyResult.deny.length) {
gatewayTools.deny = gatewayDenyResult.deny;
gatewayModified = true;
}
// Mirror the subagent tool deny into gateway.tools.deny so gateway-side
// sessions cannot spawn subagents either.
const gatewaySubagentDenyResult = ensureToolDenyIncludesAll(
normalizeToolDenyList(gatewayTools.deny),
SUBAGENT_TOOL_DENY_ENTRIES,
);
if (gatewaySubagentDenyResult.modified) {
gatewayTools.deny = gatewaySubagentDenyResult.deny;
gatewayModified = true;
console.log(`[sanitize] Added subagent tool(s) to gateway.tools.deny for ClawX desktop: ${SUBAGENT_TOOL_DENY_ENTRIES.join(', ')}`);
} else if (!Array.isArray(gatewayTools.deny) || gatewayTools.deny.length !== gatewaySubagentDenyResult.deny.length) {
gatewayTools.deny = gatewaySubagentDenyResult.deny;
gatewayModified = true;
}
if (gatewayModified) {
gateway.tools = gatewayTools;
config.gateway = gateway;
modified = true;
}
let skillsObj = (
config.skills && typeof config.skills === 'object' && !Array.isArray(config.skills)
? { ...(config.skills as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
let skillsModified = false;
const workshop = (
skillsObj.workshop && typeof skillsObj.workshop === 'object'
? { ...(skillsObj.workshop as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const autonomous = (
workshop.autonomous && typeof workshop.autonomous === 'object'
? { ...(workshop.autonomous as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (autonomous.enabled !== false) {
autonomous.enabled = false;
workshop.autonomous = autonomous;
skillsObj.workshop = workshop;
skillsModified = true;
console.log('[sanitize] Disabled skills.workshop.autonomous for ClawX desktop');
}
const skillEntries = (
skillsObj.entries && typeof skillsObj.entries === 'object' && !Array.isArray(skillsObj.entries)
? { ...(skillsObj.entries as Record<string, unknown>) }
: {}
) as Record<string, Record<string, unknown>>;
const skillCreatorEntry = skillEntries[SKILL_CREATOR_SKILL_KEY] || {};
if (skillCreatorEntry.enabled !== true) {
skillEntries[SKILL_CREATOR_SKILL_KEY] = {
...skillCreatorEntry,
enabled: true,
};
skillsObj.entries = skillEntries;
skillsModified = true;
console.log('[sanitize] Enabled bundled skill-creator for direct skill authoring in ClawX desktop');
}
if (skillsModified) {
config.skills = skillsObj;
modified = true;
}
// ── plugins.entries.feishu cleanup ──────────────────────────────
// Normalize feishu plugin ids dynamically based on installed manifest.
// Different environments may report either "openclaw-lark" or
+46
View File
@@ -0,0 +1,46 @@
/**
* Memory search default seeding for openclaw.json.
*
* OpenClaw enables semantic memory search by default with the `openai`
* embedding provider, so a user without an OpenAI key gets doctor errors and
* a broken memory_search tool. ClawX seeds `agents.defaults.memorySearch =
* { enabled: false }` at Gateway prelaunch — but only when the user has no
* memorySearch config anywhere (global defaults or per-agent overrides).
* Existing user config is never modified.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
/**
* True when the user manages memorySearch themselves: either
* `agents.defaults.memorySearch` or any `agents.list[].memorySearch` exists.
*/
export function hasUserMemorySearchConfig(config: Record<string, unknown>): boolean {
const agents = isRecord(config.agents) ? config.agents : undefined;
if (!agents) return false;
const defaults = isRecord(agents.defaults) ? agents.defaults : undefined;
if (defaults && defaults.memorySearch !== undefined) return true;
const list = Array.isArray(agents.list) ? agents.list : [];
return list.some((entry) => isRecord(entry) && entry.memorySearch !== undefined);
}
/**
* Seed `agents.defaults.memorySearch = { enabled: false }` when the user has
* no memorySearch config at all. Mutates `config` in place and returns true
* when a change was made. Never touches existing memorySearch objects.
*/
export function ensureMemorySearchDisabledDefault(config: Record<string, unknown>): boolean {
if (hasUserMemorySearchConfig(config)) return false;
const agents = (isRecord(config.agents) ? config.agents : {}) as Record<string, unknown>;
const defaults = (isRecord(agents.defaults) ? agents.defaults : {}) as Record<string, unknown>;
defaults.memorySearch = { enabled: false };
agents.defaults = defaults;
config.agents = agents;
return true;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Persist ClawX-managed plugin install records into OpenClaw's SQLite
* installed_plugin_index store (openclaw.sqlite).
*
* OpenClaw 2026.6+ reads trusted install metadata from SQLite at runtime,
* not from transient plugins.installs in openclaw.json.
*/
import { existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { logger } from './logger';
const INSTALLED_PLUGIN_INDEX_KEY = 'installed-plugin-index';
const INSTALLED_PLUGIN_INDEX_WARNING = 'DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead.';
const INSTALLED_PLUGIN_INDEX_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
updated_at_ms INTEGER NOT NULL
);
`;
function resolveOpenClawStateDir(): string {
return process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw');
}
function resolveOpenClawStateSqlitePath(): string {
return join(resolveOpenClawStateDir(), 'openclaw.sqlite');
}
function parseInstallRecordsJson(raw: unknown): Record<string, Record<string, unknown>> {
if (!raw || typeof raw !== 'string') {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
const records: Record<string, Record<string, unknown>> = {};
for (const [pluginId, record] of Object.entries(parsed as Record<string, unknown>)) {
if (record && typeof record === 'object' && !Array.isArray(record)) {
records[pluginId] = record as Record<string, unknown>;
}
}
return records;
} catch {
return {};
}
}
function installRecordsMatch(
left: Record<string, unknown>,
right: Record<string, unknown>,
): boolean {
const keys = ['source', 'spec', 'installPath', 'version', 'resolvedName', 'resolvedVersion', 'resolvedSpec'] as const;
return keys.every((key) => left[key] === right[key]);
}
function openStateDatabase(sqlitePath: string): DatabaseSync {
const db = new DatabaseSync(sqlitePath);
db.exec(INSTALLED_PLUGIN_INDEX_TABLE_SQL);
return db;
}
/**
* Upsert trusted install records into openclaw.sqlite.
* ClawX-authored records win over stale SQLite entries for the same plugin id.
*/
export function upsertPluginInstallRecordsIntoSqlite(
records: Record<string, Record<string, unknown>>,
): boolean {
if (Object.keys(records).length === 0) {
return false;
}
ensureOpenClawStateDirExists();
const sqlitePath = resolveOpenClawStateSqlitePath();
let db: DatabaseSync | null = null;
try {
db = openStateDatabase(sqlitePath);
const row = db.prepare(`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = ?
`).get(INSTALLED_PLUGIN_INDEX_KEY) as { install_records_json?: string } | undefined;
const now = Date.now();
let merged: Record<string, Record<string, unknown>>;
let changed = false;
if (row) {
const existing = parseInstallRecordsJson(row.install_records_json);
merged = { ...existing };
for (const [pluginId, record] of Object.entries(records)) {
const current = merged[pluginId];
if (current && installRecordsMatch(current, record)) {
continue;
}
merged[pluginId] = record;
changed = true;
}
if (!changed) {
return false;
}
db.prepare(`
UPDATE installed_plugin_index
SET install_records_json = ?,
updated_at_ms = ?,
generated_at_ms = ?
WHERE index_key = ?
`).run(JSON.stringify(merged), now, now, INSTALLED_PLUGIN_INDEX_KEY);
} else {
merged = { ...records };
changed = true;
db.prepare(`
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (
?, 1, 'clawx-managed', 'clawx-managed',
1, 'clawx-managed', ?, 'source-changed',
?, '[]', '[]', ?, ?
)
`).run(
INSTALLED_PLUGIN_INDEX_KEY,
now,
JSON.stringify(merged),
INSTALLED_PLUGIN_INDEX_WARNING,
now,
);
}
if (changed) {
logger.info(`[plugin] Persisted trusted install metadata to SQLite for: ${Object.keys(records).join(', ')}`);
}
return changed;
} catch (error) {
logger.warn('[plugin] Failed to persist trusted install metadata to SQLite:', error);
return false;
} finally {
db?.close();
}
}
/** Ensure ~/.openclaw exists before first config write in fresh installs. */
export function ensureOpenClawStateDirExists(): void {
const stateDir = resolveOpenClawStateDir();
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
}
+178 -4
View File
@@ -12,6 +12,7 @@ import { readdir, stat, copyFile, mkdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { logger } from './logger';
import { upsertPluginInstallRecordsIntoSqlite, ensureOpenClawStateDirExists } from './plugin-install-index';
function normalizeFsPathForWindows(filePath: string): string {
if (process.platform !== 'win32') return filePath;
@@ -238,7 +239,172 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
// ── Version helper ───────────────────────────────────────────────────────────
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
/**
* Official @openclaw/* channel plugins that ClawX mirrors into
* ~/.openclaw/extensions/. OpenClaw 2026.6+ requires matching
* plugins.installs metadata so trustedOfficialInstall is true and
* runtime APIs such as openKeyedStore are available.
*/
const TRUSTED_OFFICIAL_EXTENSION_PLUGINS: Record<string, string> = {
whatsapp: '@openclaw/whatsapp',
discord: '@openclaw/discord',
qqbot: '@openclaw/qqbot',
};
type TrustedOfficialPluginInstallRecord = {
source: 'npm';
spec: string;
installPath: string;
version: string;
resolvedName: string;
resolvedVersion: string;
resolvedSpec: string;
installedAt: string;
};
/** Store plain paths for OpenClaw install-record matching (no Windows \\?\ prefix). */
function normalizePluginInstallPathForRecord(targetDir: string): string | null {
try {
const resolved = realpathSync(targetDir);
return path.normalize(resolved);
} catch {
return path.normalize(targetDir);
}
}
function buildTrustedOfficialPluginInstallRecord(
pluginDirName: string,
targetDir: string,
): TrustedOfficialPluginInstallRecord | null {
const npmName = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
if (!npmName) return null;
const version = readPluginVersion(join(targetDir, 'package.json'));
const installPath = normalizePluginInstallPathForRecord(targetDir);
if (!version || !installPath) return null;
return {
source: 'npm',
spec: npmName,
installPath,
version,
resolvedName: npmName,
resolvedVersion: version,
resolvedSpec: `${npmName}@${version}`,
installedAt: new Date().toISOString(),
};
}
function persistTrustedOfficialPluginInstallRecordsToSqlite(
records: Record<string, Record<string, unknown>>,
): boolean {
return upsertPluginInstallRecordsIntoSqlite(records);
}
function trustedInstallRecordMatches(
existing: unknown,
expected: TrustedOfficialPluginInstallRecord,
): boolean {
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
return false;
}
const record = existing as Record<string, unknown>;
return record.source === expected.source
&& record.spec === expected.spec
&& record.installPath === expected.installPath
&& record.version === expected.version
&& record.resolvedName === expected.resolvedName
&& record.resolvedVersion === expected.resolvedVersion
&& record.resolvedSpec === expected.resolvedSpec;
}
/**
* Write or refresh plugins.installs.<id> for a ClawX-mirrored official plugin.
* Also persists the record into openclaw.sqlite for OpenClaw 2026.6+ trust checks.
* Safe to call repeatedly; no-ops when metadata is already current.
*/
export function syncTrustedOfficialPluginInstallRecord(
pluginDirName: string,
targetDir: string,
): boolean {
const expected = buildTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
if (!expected) return false;
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
return false;
}
let jsonChanged = false;
try {
ensureOpenClawStateDirExists();
if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) {
return false;
}
const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
let plugins = config.plugins;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
plugins = { enabled: true, installs: {} };
config.plugins = plugins;
}
const pluginsRecord = plugins as Record<string, unknown>;
const installs = pluginsRecord.installs;
const installsRecord = installs && typeof installs === 'object' && !Array.isArray(installs)
? installs as Record<string, unknown>
: {};
const existing = installsRecord[pluginDirName];
if (!trustedInstallRecordMatches(existing, expected)) {
installsRecord[pluginDirName] = expected;
pluginsRecord.installs = installsRecord;
writeFileSync(
fsPath(OPENCLAW_CONFIG_PATH),
`${JSON.stringify(config, null, 2)}\n`,
'utf-8',
);
logger.info(`[plugin] Synced trusted install metadata for ${pluginDirName}`);
jsonChanged = true;
}
} catch (error) {
logger.warn(`[plugin] Failed to sync trusted install metadata for ${pluginDirName}:`, error);
return false;
}
const sqliteChanged = persistTrustedOfficialPluginInstallRecordsToSqlite({
[pluginDirName]: expected,
});
return jsonChanged || sqliteChanged;
}
/** Repair trusted install metadata for all mirrored official plugins on disk. */
export function repairTrustedOfficialPluginInstallRecords(): void {
for (const pluginDirName of Object.keys(TRUSTED_OFFICIAL_EXTENSION_PLUGINS)) {
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
continue;
}
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
}
}
export function resolvePluginNpmPackagePath(npmName: string): string | null {
const candidateRoots = app.isPackaged
? [app.getAppPath(), process.resourcesPath]
: [app.getAppPath(), process.cwd(), join(app.getAppPath(), '..')];
for (const root of candidateRoots) {
const npmPkgPath = join(root, 'node_modules', ...npmName.split('/'));
if (existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
return npmPkgPath;
}
}
return null;
}
function readPluginVersion(pkgJsonPath: string): string | null {
try {
@@ -373,10 +539,14 @@ export function ensurePluginInstalled(
// If already installed, check whether an upgrade is available
if (existsSync(fsPath(targetManifest))) {
if (!sourceDir) return { installed: true }; // no bundled source to compare, keep existing
if (!sourceDir) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // no bundled source to compare, keep existing
}
const installedVersion = readPluginVersion(targetPkgJson);
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version or unable to compare
}
// Version differs — fall through to overwrite install
@@ -400,6 +570,7 @@ export function ensurePluginInstalled(
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
}
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
return { installed: true };
} catch (error) {
@@ -434,8 +605,8 @@ export function ensurePluginInstalled(
if (!app.isPackaged) {
const npmName = PLUGIN_NPM_NAMES[pluginDirName];
if (npmName) {
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
if (existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const npmPkgPath = resolvePluginNpmPackagePath(npmName);
if (npmPkgPath && existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const installedVersion = existsSync(fsPath(targetPkgJson)) ? readPluginVersion(targetPkgJson) : null;
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) {
@@ -448,6 +619,7 @@ export function ensurePluginInstalled(
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true };
}
} catch (err) {
@@ -465,6 +637,7 @@ export function ensurePluginInstalled(
);
}
} else if (existsSync(fsPath(targetManifest))) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version, already installed
}
}
@@ -575,4 +748,5 @@ export async function ensureAllBundledPluginsInstalled(): Promise<void> {
logger.warn(`[plugin] Failed to install/upgrade ${label} plugin:`, error);
}
}
repairTrustedOfficialPluginInstallRecords();
}
@@ -16,3 +16,10 @@ explicitly owns that field.
New model IDs may receive deterministic capability defaults, but metadata from a
different model ID must never be copied onto them.
Custom-provider model rows (`models.providers.custom-*`) must carry an explicit
`contextWindow`: new rows receive a deterministic model-family default, and
existing rows missing both `contextWindow` and `contextTokens` may be
backfilled with that default. Rows that already declare either field are
user-owned and must never be modified, and non-`custom-` provider entries are
never backfilled.
@@ -0,0 +1,71 @@
---
id: custom-provider-context-window-defaults
title: Explicit contextWindow defaults and compaction safeguard for custom providers
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Give custom-provider model rows an explicit contextWindow (inferred by model family) and seed agents.defaults.compaction.mode=safeguard with reserveTokensFloor=50000 so OpenClaw can compact long sessions before they fail with "Context overflow: prompt too large for the model".
touchedAreas:
- harness/specs/tasks/custom-provider-context-window-defaults.md
- harness/specs/rules/provider-model-metadata-preservation.md
- electron/shared/providers/model-capabilities.ts
- electron/utils/openclaw-auth.ts
- tests/unit/provider-model-capabilities.test.ts
- tests/unit/openclaw-auth.test.ts
- README.md
- README.zh-CN.md
- README.ja-JP.md
expectedUserBehavior:
- Saving or updating a custom provider writes model rows that include an inferred contextWindow, so OpenClaw's preemptive compaction and context-window guard are active for custom models.
- Existing custom provider model rows that already carry contextWindow or contextTokens are never modified.
- On app start, custom-provider model rows missing both contextWindow and contextTokens are backfilled with an inferred contextWindow, and agents.defaults.compaction is seeded to safeguard mode when the user has no compaction config.
- Long sessions on custom providers are compacted before overflowing instead of surfacing "Context overflow" errors.
requiredProfiles:
- fast
- comms
requiredRules:
- active-config-guards
- backend-communication-boundary
- provider-model-metadata-preservation
- renderer-main-boundary
requiredTests:
- tests/unit/provider-model-capabilities.test.ts
- tests/unit/openclaw-auth.test.ts
acceptance:
- New custom-provider model rows written by explicit provider sync include an inferred contextWindow matching the model-family table in model-capabilities.
- The startup batch sync backfills contextWindow only on models.providers.custom-* rows lacking both contextWindow and contextTokens; registry and non-custom providers are never touched.
- agents.defaults.compaction is seeded to { mode: "safeguard", reserveTokensFloor: 50000 } only when no compaction config exists; explicit compaction config is preserved verbatim except missing reserveTokensFloor may be backfilled.
- Renderer transport boundaries remain unchanged.
- Focused tests, harness validation, communication replay, and communication compare pass.
docs:
required: true
---
## Background
ClawX wrote custom-provider model rows with only `{ id, name, input }`. Without
`contextWindow` (or `contextTokens`), OpenClaw's embedded runner cannot budget
preemptive compaction or the context-window guard, so long sessions on custom
providers only fail at the provider with "Context overflow: prompt too large
for the model. Try /reset (or /new) ...". Users also had no
`agents.defaults.compaction` config, so no safeguard compaction ran.
## Scope
- Infer a conservative `contextWindow` per model family for custom-provider
model rows (new writes in `upsertOpenClawProviderEntry` and per-agent
`models.json` merges).
- Backfill missing `contextWindow` on existing `models.providers.custom-*`
rows during the startup batch config sync.
- Seed `agents.defaults.compaction.mode = "safeguard"` when the user has no
compaction config.
- Seed `agents.defaults.compaction.reserveTokensFloor = 50000` alongside
safeguard mode (and backfill when an existing compaction config lacks a floor)
so auto-compaction has enough buffer for 200k+ context models.
- Add regression tests and translated documentation.
## Out Of Scope
- Renderer UI for editing contextWindow per model.
- Writing `maxTokens` for non-anthropic providers (changes request payloads).
- Backfill for non-custom (registry/ollama) provider entries.
- Image attachment compression in `chat:sendWithMedia`.
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.4.11",
"version": "0.4.16",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
@@ -99,12 +99,12 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.7",
"@larksuite/openclaw-lark": "2026.5.20",
"@larksuite/openclaw-lark": "2026.6.10",
"@larksuiteoapi/node-sdk": "^1.61.1",
"@monaco-editor/react": "^4.7.0",
"@openclaw/discord": "2026.6.5",
"@openclaw/qqbot": "2026.6.5",
"@openclaw/whatsapp": "2026.6.5",
"@openclaw/discord": "2026.6.10",
"@openclaw/qqbot": "2026.6.10",
"@openclaw/whatsapp": "2026.6.10",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -121,7 +121,7 @@
"@sinclair/typebox": "^0.34.48",
"@soimy/dingtalk": "^3.6.3",
"@tencent-connect/qqbot-connector": "^1.1.0",
"@tencent-weixin/openclaw-weixin": "^2.4.3",
"@tencent-weixin/openclaw-weixin": "^2.4.6",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/diff": "^8.0.0",
@@ -132,7 +132,7 @@
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
"@wecom/wecom-openclaw-plugin": "^2026.5.14",
"@wecom/wecom-openclaw-plugin": "^2026.6.23",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"acpx": "0.5.3",
"autoprefixer": "^10.4.24",
@@ -158,7 +158,7 @@
"monaco-editor": "^0.55.1",
"mpg123-decoder": "^1.0.3",
"ms": "^2.1.3",
"openclaw": "2026.6.5",
"openclaw": "2026.6.10",
"opusscript": "^0.1.1",
"pdfjs-dist": "^5.7.284",
"playwright-core": "1.59.1",
+75 -64
View File
@@ -52,8 +52,8 @@ importers:
specifier: ^1.3.7
version: 1.3.7
'@larksuite/openclaw-lark':
specifier: 2026.5.20
version: 2026.5.20(openclaw@2026.6.5(encoding@0.1.13))
specifier: 2026.6.10
version: 2026.6.10(openclaw@2026.6.10(encoding@0.1.13))
'@larksuiteoapi/node-sdk':
specifier: ^1.61.1
version: 1.62.0
@@ -61,14 +61,14 @@ importers:
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@openclaw/discord':
specifier: 2026.6.5
version: 2026.6.5(openclaw@2026.6.5(encoding@0.1.13))
specifier: 2026.6.10
version: 2026.6.10(openclaw@2026.6.10(encoding@0.1.13))
'@openclaw/qqbot':
specifier: 2026.6.5
version: 2026.6.5(openclaw@2026.6.5(encoding@0.1.13))
specifier: 2026.6.10
version: 2026.6.10(openclaw@2026.6.10(encoding@0.1.13))
'@openclaw/whatsapp':
specifier: 2026.6.5
version: 2026.6.5(openclaw@2026.6.5(encoding@0.1.13))
specifier: 2026.6.10
version: 2026.6.10(openclaw@2026.6.10(encoding@0.1.13))
'@playwright/test':
specifier: ^1.56.1
version: 1.59.0
@@ -113,13 +113,13 @@ importers:
version: 0.34.48
'@soimy/dingtalk':
specifier: ^3.6.3
version: 3.6.4(openclaw@2026.6.5(encoding@0.1.13))
version: 3.6.4(openclaw@2026.6.10(encoding@0.1.13))
'@tencent-connect/qqbot-connector':
specifier: ^1.1.0
version: 1.1.0
'@tencent-weixin/openclaw-weixin':
specifier: ^2.4.3
version: 2.4.3(openclaw@2026.6.5(encoding@0.1.13))
specifier: ^2.4.6
version: 2.4.6(openclaw@2026.6.10(encoding@0.1.13))
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -151,8 +151,8 @@ importers:
specifier: ^5.1.4
version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0))
'@wecom/wecom-openclaw-plugin':
specifier: ^2026.5.14
version: 2026.5.14(openclaw@2026.6.5(encoding@0.1.13))
specifier: ^2026.6.23
version: 2026.6.23(openclaw@2026.6.10(encoding@0.1.13))
'@whiskeysockets/baileys':
specifier: 7.0.0-rc.9
version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)
@@ -229,8 +229,8 @@ importers:
specifier: ^2.1.3
version: 2.1.3
openclaw:
specifier: 2026.6.5
version: 2026.6.5(encoding@0.1.13)
specifier: 2026.6.10
version: 2026.6.10(encoding@0.1.13)
opusscript:
specifier: ^0.1.1
version: 0.1.1
@@ -1175,8 +1175,8 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@larksuite/openclaw-lark@2026.5.20':
resolution: {integrity: sha512-4iTK0ZJXtylJFg+kh6gttKDi6vqKjbwTNrWTo/8Zw0VjtbpZQ2wkWIYF5BQNie/Ehqf1S9D5w4pjIwL9bCzaKg==}
'@larksuite/openclaw-lark@2026.6.10':
resolution: {integrity: sha512-OdNePiG88jRIUrRAx0h3bF2o5UxLD4c9zlK5Wfn1xirYLic6koGpx7xoRFAgvCMd15DzLQQtekkwO6+xkDupBw==}
engines: {node: '>=22'}
hasBin: true
peerDependencies:
@@ -1436,10 +1436,10 @@ packages:
resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==}
engines: {node: ^18.17.0 || >=20.5.0}
'@openclaw/discord@2026.6.5':
resolution: {integrity: sha512-Ww/89ODIdZdWZimNzHWoraJbWOrPIJDB+OfVZcQ5fOnsPNyY1p4RAni72wOOiFVkH+3FwLjniCcxA1eUfDkewA==}
'@openclaw/discord@2026.6.10':
resolution: {integrity: sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA==}
peerDependencies:
openclaw: '>=2026.6.5'
openclaw: '>=2026.6.10'
peerDependenciesMeta:
openclaw:
optional: true
@@ -1461,10 +1461,10 @@ packages:
peerDependencies:
undici: '>=8.3.0 <9'
'@openclaw/qqbot@2026.6.5':
resolution: {integrity: sha512-vY/AbrWD271ReS/oXck2HeuCOB2W5NcgrVU5CJAo+BSp+tzqDZDMsCe/GIc/lwDOjWQg1Ez7+KGwfrlCmn4tjA==}
'@openclaw/qqbot@2026.6.10':
resolution: {integrity: sha512-6G1yvO+pzvdO2ByfyuefAFVj2mW4urCpEN5BTxevXvmMuE7+AXhu8F0Z4aeIQOnUvBzzu7ZyDQtQ+gOA1trmkw==}
peerDependencies:
openclaw: '>=2026.6.5'
openclaw: '>=2026.6.10'
peerDependenciesMeta:
openclaw:
optional: true
@@ -1475,10 +1475,10 @@ packages:
- ws
- zod
'@openclaw/whatsapp@2026.6.5':
resolution: {integrity: sha512-YS/JK5By8AeFQDa6AfqdZk7OzPPWF6AoTV0K6zOdwKsQ7BAFTMTRKaHaniBLttVR3sDe5haLqBdJAvg3jrfBoQ==}
'@openclaw/whatsapp@2026.6.10':
resolution: {integrity: sha512-k/XrRdZY77SHrdaRwJOEB7/JRbjp4yVgGD/ZNyakjTMqo32XRVtwPBUnj7726rW8Kl5yyOMQQLKFiD9MDfhmPQ==}
peerDependencies:
openclaw: '>=2026.6.5'
openclaw: '>=2026.6.10'
peerDependenciesMeta:
openclaw:
optional: true
@@ -2260,11 +2260,11 @@ packages:
resolution: {integrity: sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==}
engines: {node: '>=18.0.0'}
'@tencent-weixin/openclaw-weixin@2.4.3':
resolution: {integrity: sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==}
'@tencent-weixin/openclaw-weixin@2.4.6':
resolution: {integrity: sha512-qw9k3PLTiMWGNjjsknHgcTManH1w4j+Ji1ArWIaYLKCq3aFRsVwcqnPi127bvOoVMJGW4dbyJ8NECEMgoO+iRw==}
engines: {node: '>=22'}
peerDependencies:
openclaw: '>=2026.3.22'
openclaw: '>=2026.5.12'
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
@@ -2533,8 +2533,8 @@ packages:
'@wecom/aibot-node-sdk@1.0.6':
resolution: {integrity: sha512-WZJN3Q+s+94Qjc0VW8d5W1cVkA3emYxiqf+mNRO9UEHoF40puHvizreNMtudjFhm7mmkYiK5ue/QzNiCk+xwLA==}
'@wecom/wecom-openclaw-plugin@2026.5.14':
resolution: {integrity: sha512-z5fhanCn0PT3m8lHDMQATljFXzIsML2r2nq0nEu3KM93E163CmgbDzcXr3R766du4B0y3i65ZdIPh+tWAFeA8g==}
'@wecom/wecom-openclaw-plugin@2026.6.23':
resolution: {integrity: sha512-IYxLDLiiYmL/v3oN4WJOvRD+5yis+CS+Rr7mcjGs24UBmRukMr+i0UBghWRW9ub2jnitP6Gs0uqhLMf0kZ9/Tw==}
peerDependencies:
openclaw: '>=2026.3.28'
peerDependenciesMeta:
@@ -4242,8 +4242,8 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7}
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
resolution: {commit: bcea72df9ec34d9d9140ab30619cf479c7c144c7, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git}
version: 6.0.0
lie@3.3.0:
@@ -4802,8 +4802,8 @@ packages:
zod:
optional: true
openclaw@2026.6.5:
resolution: {integrity: sha512-sRgF0TexfRcJX8Eg0lcL6Jj0YdZbSxUbbp8EbG+qo3v6TtVayE6tKPEs3oCKD7YfYe2C/8Qg26HUxTnycd44ZQ==}
openclaw@2026.6.10:
resolution: {integrity: sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug==}
engines: {node: '>=22.19.0'}
hasBin: true
@@ -5740,6 +5740,10 @@ packages:
resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
engines: {node: '>=18'}
tar@7.5.16:
resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==}
engines: {node: '>=18'}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
@@ -5924,8 +5928,8 @@ packages:
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
engines: {node: '>=22.19.0'}
undici@8.3.0:
resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==}
undici@8.5.0:
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
engines: {node: '>=22.19.0'}
unified@11.0.5:
@@ -7374,7 +7378,7 @@ snapshots:
'@keyv/serialize@1.1.1': {}
'@larksuite/openclaw-lark@2026.5.20(openclaw@2026.6.5(encoding@0.1.13))':
'@larksuite/openclaw-lark@2026.6.10(openclaw@2026.6.10(encoding@0.1.13))':
dependencies:
'@larksuiteoapi/node-sdk': 1.66.1
'@sinclair/typebox': 0.34.49
@@ -7382,7 +7386,7 @@ snapshots:
undici-types: 8.3.0
zod: 4.4.3
optionalDependencies:
openclaw: 2026.6.5(encoding@0.1.13)
openclaw: 2026.6.10(encoding@0.1.13)
transitivePeerDependencies:
- bufferutil
- debug
@@ -7410,7 +7414,7 @@ snapshots:
lodash.pickby: 4.6.0
protobufjs: 7.5.8
qs: 6.15.0
ws: 8.20.1
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- debug
@@ -7629,26 +7633,26 @@ snapshots:
dependencies:
semver: 7.7.4
'@openclaw/discord@2026.6.5(openclaw@2026.6.5(encoding@0.1.13))':
'@openclaw/discord@2026.6.10(openclaw@2026.6.10(encoding@0.1.13))':
optionalDependencies:
openclaw: 2026.6.5(encoding@0.1.13)
openclaw: 2026.6.10(encoding@0.1.13)
'@openclaw/fs-safe@0.3.0':
optionalDependencies:
jszip: 3.10.1
tar: 7.5.13
'@openclaw/proxyline@0.3.3(undici@8.3.0)':
'@openclaw/proxyline@0.3.3(undici@8.5.0)':
dependencies:
undici: 8.3.0
undici: 8.5.0
'@openclaw/qqbot@2026.6.5(openclaw@2026.6.5(encoding@0.1.13))':
'@openclaw/qqbot@2026.6.10(openclaw@2026.6.10(encoding@0.1.13))':
optionalDependencies:
openclaw: 2026.6.5(encoding@0.1.13)
openclaw: 2026.6.10(encoding@0.1.13)
'@openclaw/whatsapp@2026.6.5(openclaw@2026.6.5(encoding@0.1.13))':
'@openclaw/whatsapp@2026.6.10(openclaw@2026.6.10(encoding@0.1.13))':
optionalDependencies:
openclaw: 2026.6.5(encoding@0.1.13)
openclaw: 2026.6.10(encoding@0.1.13)
'@pinojs/redact@0.4.0': {}
@@ -8291,7 +8295,7 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
'@soimy/dingtalk@3.6.4(openclaw@2026.6.5(encoding@0.1.13))':
'@soimy/dingtalk@3.6.4(openclaw@2026.6.10(encoding@0.1.13))':
dependencies:
axios: 1.13.6(debug@4.4.3)
dingtalk-stream: 2.1.5
@@ -8300,7 +8304,7 @@ snapshots:
pdf-parse: 2.4.5
zod: 4.4.3
optionalDependencies:
openclaw: 2026.6.5(encoding@0.1.13)
openclaw: 2026.6.10(encoding@0.1.13)
transitivePeerDependencies:
- bufferutil
- debug
@@ -8319,11 +8323,11 @@ snapshots:
dependencies:
qrcode-terminal: 0.12.0
'@tencent-weixin/openclaw-weixin@2.4.3(openclaw@2026.6.5(encoding@0.1.13))':
'@tencent-weixin/openclaw-weixin@2.4.6(openclaw@2026.6.10(encoding@0.1.13))':
dependencies:
openclaw: 2026.6.5(encoding@0.1.13)
openclaw: 2026.6.10(encoding@0.1.13)
qrcode-terminal: 0.12.0
zod: 4.3.6
zod: 4.4.3
'@testing-library/dom@10.4.1':
dependencies:
@@ -8685,13 +8689,13 @@ snapshots:
dependencies:
axios: 1.13.6(debug@4.4.3)
eventemitter3: 5.0.4
ws: 8.20.1
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- debug
- utf-8-validate
'@wecom/wecom-openclaw-plugin@2026.5.14(openclaw@2026.6.5(encoding@0.1.13))':
'@wecom/wecom-openclaw-plugin@2026.6.23(openclaw@2026.6.10(encoding@0.1.13))':
dependencies:
'@wecom/aibot-node-sdk': 1.0.6
fast-xml-parser: 5.7.3
@@ -8699,7 +8703,7 @@ snapshots:
undici: 7.24.6
zod: 4.4.3
optionalDependencies:
openclaw: 2026.6.5(encoding@0.1.13)
openclaw: 2026.6.10(encoding@0.1.13)
transitivePeerDependencies:
- bufferutil
- debug
@@ -8711,7 +8715,7 @@ snapshots:
'@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4
async-mutex: 0.5.0
libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7
libsignal: git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7
lru-cache: 11.2.7
music-metadata: 11.12.3
p-queue: 9.1.0
@@ -10697,7 +10701,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
dependencies:
curve25519-js: 0.0.4
protobufjs: 7.5.8
@@ -11472,7 +11476,7 @@ snapshots:
ws: 8.21.0
zod: 4.4.3
openclaw@2026.6.5(encoding@0.1.13):
openclaw@2026.6.10(encoding@0.1.13):
dependencies:
'@agentclientprotocol/sdk': 0.22.1(zod@4.4.3)
'@anthropic-ai/sdk': 0.100.1(zod@4.4.3)
@@ -11488,13 +11492,12 @@ snapshots:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
'@mozilla/readability': 0.6.0
'@openclaw/fs-safe': 0.3.0
'@openclaw/proxyline': 0.3.3(undici@8.3.0)
'@openclaw/proxyline': 0.3.3(undici@8.5.0)
chalk: 5.6.2
chokidar: 5.0.0
clawpdf: 0.3.0
commander: 14.0.3
croner: 10.0.1
cross-spawn: 7.0.6
diff: 9.0.0
dotenv: 17.4.2
express: 5.2.1
@@ -11518,12 +11521,12 @@ snapshots:
qrcode: 1.5.4
quickjs-wasi: 3.0.0
rastermill: 0.3.1
tar: 7.5.15
tar: 7.5.16
tree-sitter-bash: 0.25.1
tslog: 4.10.2
typebox: 1.1.39
typescript: 6.0.3
undici: 8.3.0
undici: 8.5.0
web-push: 3.6.7
web-tree-sitter: 0.26.9
ws: 8.21.0
@@ -12609,6 +12612,14 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
tar@7.5.16:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.3
minizlib: 3.1.0
yallist: 5.0.0
teex@1.0.1:
dependencies:
streamx: 2.25.0
@@ -12771,7 +12782,7 @@ snapshots:
undici@8.1.0: {}
undici@8.3.0: {}
undici@8.5.0: {}
unified@11.0.5:
dependencies:
+7
View File
@@ -2,4 +2,11 @@
You are ClawX, a desktop AI assistant application based on OpenClaw. See TOOLS.md for ClawX-specific tool notes (uv, browser automation, etc.).
**Python Environment Rule**: ClawX bundles `uv` and exposes it on PATH. When you need Python scripts, Python packages, or Python ecosystem tooling, assume `uv` is available and prefer it by default.
- Prefer `uv run python ...` for Python execution.
- For one-off Python dependencies, prefer `uv run --with <package> python ...`.
- Prefer `uv pip install ...` for Python package installation.
- Do not default to bare `python` or `pip` unless the user explicitly asks for that or `uv` actually fails.
**Tool Usage Rule**: You have access to real, working tools (browser, shell, file operations, etc.). Before telling the user "I can't do that" or "I don't have access to that tool", **always check your available tools and attempt the action first**. Only report inability after receiving an actual error from the tool. Do not refuse based on assumptions from your training data.
@@ -31,30 +31,6 @@
"ref": "main",
"version": "main",
"autoEnable": true
},
{
"slug": "find-skills",
"repo": "vercel-labs/skills",
"repoPath": "skills/find-skills",
"ref": "main",
"version": "main",
"autoEnable": true
},
{
"slug": "self-improving-agent",
"repo": "peterskoett/self-improving-agent",
"repoPath": ".",
"ref": "master",
"version": "main",
"autoEnable": true
},
{
"slug": "tavily-search",
"repo": "tavily-ai/skills",
"repoPath": "skills/tavily-search",
"ref": "main",
"version": "main",
"autoEnable": true
}
]
}
+2
View File
@@ -82,6 +82,8 @@ export interface ChatSession {
updatedAt?: number;
status?: string;
hasActiveRun?: boolean;
/** Channel provider that last delivered to this session (e.g. webchat, feishu, discord). */
channel?: string;
}
export interface ToolStatus {
+12
View File
@@ -34,6 +34,7 @@ import { useChatStore } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { getSessionActivityMs, getSessionBucket, type SessionBucketKey } from './session-buckets';
import { CHANNEL_NAMES } from '@shared/types/channel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -447,6 +448,8 @@ export function Sidebar() {
const agentName = agentNameById[agentId] || agentId;
const isEditing = editingSessionKey === s.key;
const sessionLabel = getSessionLabel(s.key, s.displayName, s.label);
const channelType = s.channel && s.channel !== 'webchat' ? s.channel : null;
const channelName = channelType ? CHANNEL_NAMES[channelType as keyof typeof CHANNEL_NAMES] ?? channelType : null;
return (
<div key={s.key} className="group relative flex items-center">
{isEditing ? (
@@ -500,6 +503,15 @@ export function Sidebar() {
<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>
{channelType && channelName && (
<span
title={channelName}
aria-label={channelName}
className="shrink-0 truncate rounded-full bg-blue-500/10 px-2 py-0.5 text-2xs font-medium text-blue-700 dark:bg-blue-400/10 dark:text-blue-400"
>
{channelName}
</span>
)}
<span className="truncate">{sessionLabel}</span>
</div>
</button>
+4 -1
View File
@@ -10,6 +10,7 @@ import { useAgentsStore } from './agents';
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache';
import { isCronSessionKey, sessionKeysAreEquivalent } from './chat/cron-session-utils';
import { isClawXDesktopSessionKey, shouldIncludeSessionInSidebarList } from './chat/session-key-utils';
import { fetchCronSessionHistory } from '@/lib/cron-session-history';
import { pickStartupSessionFallback } from './chat/session-selection';
import {
@@ -2641,7 +2642,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
status: parseSessionStatus(s.status),
hasActiveRun: typeof s.hasActiveRun === 'boolean' ? s.hasActiveRun : undefined,
})).filter((s: ChatSession) => s.key);
channel: s.lastChannel ? String(s.lastChannel) : undefined,
})).filter((s: ChatSession) => shouldIncludeSessionInSidebarList(s));
const canonicalBySuffix = new Map<string, string>();
for (const session of sessions) {
@@ -2684,6 +2686,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey
&& isClawXDesktopSessionKey(nextSessionKey)
? [
...dedupedSessions,
{ key: nextSessionKey, displayName: nextSessionKey },
+5 -2
View File
@@ -1,6 +1,7 @@
import { hostApi } from '@/lib/host-api';
import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { isClawXDesktopSessionKey, shouldIncludeSessionInSidebarList } from './session-key-utils';
import { pickStartupSessionFallback } from './session-selection';
import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
@@ -131,7 +132,8 @@ export function createSessionActions(
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
status: parseSessionStatus(s.status),
hasActiveRun: typeof s.hasActiveRun === 'boolean' ? s.hasActiveRun : undefined,
})).filter((s: ChatSession) => s.key);
channel: s.lastChannel ? String(s.lastChannel) : undefined,
})).filter((s: ChatSession) => shouldIncludeSessionInSidebarList(s));
const canonicalBySuffix = new Map<string, string>();
for (const session of sessions) {
@@ -172,6 +174,7 @@ export function createSessionActions(
}
const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey
&& isClawXDesktopSessionKey(nextSessionKey)
? [
...dedupedSessions,
{ key: nextSessionKey, displayName: nextSessionKey },
+40
View File
@@ -0,0 +1,40 @@
import { CHANNEL_NAMES } from '@shared/types/channel';
import { isCronSessionKey } from './cron-session-utils';
import type { ChatSession } from './types';
const CHANNEL_SESSION_SEGMENTS = new Set<string>(Object.keys(CHANNEL_NAMES));
/**
* OpenClaw channel sessions use `agent:<id>:<channel>:...` (e.g. feishu DM keys).
*/
export function isChannelSessionKey(sessionKey: string): boolean {
if (!sessionKey.startsWith('agent:')) return false;
const parts = sessionKey.split(':');
if (parts.length < 3) return false;
return CHANNEL_SESSION_SEGMENTS.has(parts[2] ?? '');
}
export function isClawXDesktopSessionKey(sessionKey: string): boolean {
return !isCronSessionKey(sessionKey) && !isChannelSessionKey(sessionKey);
}
/**
* Gateway may register channel sessions before any real user message (e.g. bot
* added to a group, webhook ping). Hide those placeholder entries from ClawX
* sidebar they have no preview text, no derived title, and no display name.
*/
export function isPlaceholderChannelSession(session: ChatSession): boolean {
if (!isChannelSessionKey(session.key)) return false;
if (session.lastMessagePreview?.trim()) return false;
if (session.derivedTitle?.trim()) return false;
if (session.displayName?.trim() && session.displayName !== session.key) return false;
return true;
}
export function shouldIncludeSessionInSidebarList(session: ChatSession): boolean {
if (!session.key) return false;
if (isChannelSessionKey(session.key)) {
return !isPlaceholderChannelSession(session);
}
return true;
}
+7 -2
View File
@@ -1,4 +1,5 @@
import { isCronSessionKey } from './cron-session-utils';
import { isChannelSessionKey } from './session-key-utils';
import type { ChatSession } from './types';
function getAgentIdFromSessionKey(sessionKey: string): string {
@@ -28,11 +29,15 @@ export function pickStartupSessionFallback(
if (agentMain) return agentMain.key;
const agentNonCron = sortByUpdatedAtDesc(
sessions.filter((session) => session.key.startsWith(`agent:${agentId}:`) && !isCronSessionKey(session.key)),
sessions.filter((session) => session.key.startsWith(`agent:${agentId}:`)
&& !isCronSessionKey(session.key)
&& !isChannelSessionKey(session.key)),
);
if (agentNonCron.length > 0) return agentNonCron[0]!.key;
const nonCron = sortByUpdatedAtDesc(sessions.filter((session) => !isCronSessionKey(session.key)));
const nonCron = sortByUpdatedAtDesc(
sessions.filter((session) => !isCronSessionKey(session.key) && !isChannelSessionKey(session.key)),
);
if (nonCron.length > 0) return nonCron[0]!.key;
return null;
@@ -119,6 +119,88 @@ describe('chat store loadSessions startup selection', () => {
expect(useChatStore.getState().messages).toEqual([]);
});
it('hides placeholder feishu sessions but keeps real desktop history', async () => {
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
return {
sessions: [
{
key: 'agent:main:feishu:ou_69c24802fa248625f7965a',
updatedAt: 9_000,
},
{
key: 'agent:main:session-a',
displayName: 'Desktop chat',
updatedAt: 5_000,
},
],
};
}
if (method === 'chat.history') {
return { messages: [] };
}
throw new Error(`Unexpected gateway RPC: ${method}`);
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:main',
currentAgentId: 'main',
sessions: [],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
});
await useChatStore.getState().loadSessions();
expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual(['agent:main:session-a']);
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-a');
});
it('shows feishu sessions when they contain real channel messages', async () => {
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
return {
sessions: [
{
key: 'agent:main:feishu:ou_69c24802fa248625f7965a',
lastMessagePreview: '你好,来自飞书',
updatedAt: 9_000,
},
{
key: 'agent:main:session-a',
displayName: 'Desktop chat',
updatedAt: 5_000,
},
],
};
}
if (method === 'chat.history') {
return { messages: [] };
}
throw new Error(`Unexpected gateway RPC: ${method}`);
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:main',
currentAgentId: 'main',
sessions: [],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
});
await useChatStore.getState().loadSessions();
expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([
'agent:main:feishu:ou_69c24802fa248625f7965a',
'agent:main:session-a',
]);
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-a');
});
it('keeps the default main ghost session when only cron sessions exist', async () => {
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
@@ -30,6 +30,15 @@ describe('pickStartupSessionFallback', () => {
expect(pickStartupSessionFallback('agent:main:main', sessions)).toBeNull();
});
it('does not auto-select feishu channel sessions on startup', () => {
const sessions: ChatSession[] = [
{ key: 'agent:main:feishu:ou_abc', lastMessagePreview: '你好', updatedAt: 9_000 },
{ key: 'agent:main:session-new', updatedAt: 5_000 },
];
expect(pickStartupSessionFallback('agent:main:main', sessions)).toBe('agent:main:session-new');
});
it('falls back to non-cron sessions from other agents before cron', () => {
const sessions: ChatSession[] = [
{ key: 'agent:main:cron:heartbeat', updatedAt: 9_000 },
@@ -0,0 +1,261 @@
/**
* Bisection tests for 0d794cd ("fix per channel per session") vs de3046a.
*
* Part A dmScope effect is simulated by Gateway event sessionKey alignment
* (de3046a production used dmScope=main events on agent:main:main while UI
* showed feishu keys; 0d794cd sets per-channel-peer keys match).
*
* Part B sessions.subscribe adds handleGatewaySessionsChanged loadSessions.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hostApiMock = vi.hoisted(() => ({
gateway: {
status: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
restart: vi.fn(),
health: vi.fn(),
controlUi: vi.fn(),
rpc: vi.fn(),
},
settings: {
getAll: vi.fn(),
get: vi.fn(),
set: vi.fn(),
setMany: vi.fn(),
reset: vi.fn(),
},
logs: {
recent: vi.fn(),
dir: vi.fn(),
listFiles: vi.fn(),
readFile: vi.fn(),
},
}));
const hostEventSubscriptionMock = vi.fn();
function flushAsyncImports(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function captureHandlers() {
const handlers = new Map<string, (payload: unknown) => void>();
hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
handlers.set(eventName, handler);
return () => {};
});
return handlers;
}
vi.mock('@/lib/host-api', () => ({
hostApi: hostApiMock,
}));
vi.mock('@/lib/host-events', () => ({
hostEvents: {
onGatewayStatus: (handler: unknown) => hostEventSubscriptionMock('gateway:status', handler),
onGatewayError: (handler: unknown) => hostEventSubscriptionMock('gateway:error', handler),
onGatewayNotification: (handler: unknown) => hostEventSubscriptionMock('gateway:notification', handler),
onGatewayHealth: (handler: unknown) => hostEventSubscriptionMock('gateway:health', handler),
onGatewayPresence: (handler: unknown) => hostEventSubscriptionMock('gateway:presence', handler),
onGatewayChatMessage: (handler: unknown) => hostEventSubscriptionMock('gateway:chat-message', handler),
onGatewaySessionsChanged: (handler: unknown) => hostEventSubscriptionMock('gateway:sessions-changed', handler),
onChatRuntimeEvent: (handler: unknown) => hostEventSubscriptionMock('chat:runtime-event', handler),
onGatewayChannelStatus: (handler: unknown) => hostEventSubscriptionMock('gateway:channel-status', handler),
},
}));
const FEISHU_KEY = 'agent:main:feishu:direct:ou_test';
const MAIN_KEY = 'agent:main:main';
const OTHER_FEISHU_KEY = 'agent:main:feishu:direct:ou_other';
describe('bisection 0d794cd vs de3046a', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
hostApiMock.gateway.status.mockResolvedValue({ state: 'running', port: 18789, gatewayReady: true });
});
async function initGatewayHandlers() {
const handlers = captureHandlers();
const { useGatewayStore } = await import('@/stores/gateway');
await useGatewayStore.getState().init();
return handlers;
}
function subscribedEvents(): string[] {
return hostEventSubscriptionMock.mock.calls.map(([eventName]) => String(eventName));
}
function hasSessionsChangedWiring(): boolean {
return subscribedEvents().includes('gateway:sessions-changed');
}
describe('Part A — dmScope key alignment (simulated via runtime event sessionKey)', () => {
it('de3046a baseline: run.started on main key does NOT reload history for feishu view', async () => {
const handlers = await initGatewayHandlers();
const { useChatStore } = await import('@/stores/chat');
const loadHistory = vi.fn(async () => {});
useChatStore.setState({
currentSessionKey: FEISHU_KEY,
sessions: [{ key: FEISHU_KEY }],
sending: true,
activeRunId: 'run-user',
lastUserMessageAt: Date.now(),
loadHistory,
});
handlers.get('chat:runtime-event')?.({
type: 'run.started',
runId: 'run-inbound',
sessionKey: MAIN_KEY,
startedAt: Date.now(),
});
await flushAsyncImports();
expect(loadHistory).not.toHaveBeenCalled();
});
it('0d794cd with dmScope: aligned run.started DOES reload history (regression trigger)', async () => {
const handlers = await initGatewayHandlers();
const { useChatStore } = await import('@/stores/chat');
const loadHistory = vi.fn(async () => {});
useChatStore.setState({
currentSessionKey: FEISHU_KEY,
sessions: [{ key: FEISHU_KEY }],
sending: true,
activeRunId: 'run-user',
lastUserMessageAt: Date.now(),
loadHistory,
});
handlers.get('chat:runtime-event')?.({
type: 'run.started',
runId: 'run-user',
sessionKey: FEISHU_KEY,
startedAt: Date.now(),
});
await flushAsyncImports();
expect(loadHistory).toHaveBeenCalled();
});
it('0d794cd with dmScope: aligned run.ended also reloads history', async () => {
const handlers = await initGatewayHandlers();
const { useChatStore } = await import('@/stores/chat');
const loadHistory = vi.fn(async () => {});
useChatStore.setState({
currentSessionKey: FEISHU_KEY,
sessions: [{ key: FEISHU_KEY }],
sending: true,
activeRunId: 'run-user',
lastUserMessageAt: Date.now(),
loadHistory,
});
handlers.get('chat:runtime-event')?.({
type: 'run.ended',
runId: 'run-user',
sessionKey: FEISHU_KEY,
status: 'completed',
endedAt: Date.now(),
});
await flushAsyncImports();
expect(loadHistory).toHaveBeenCalled();
});
});
describe('Part B — sessions.subscribe (sessions.changed handler)', () => {
it('records whether gateway:sessions-changed is wired (de3046a=false, 0d794cd=true)', async () => {
await initGatewayHandlers();
const wired = hasSessionsChangedWiring();
console.log(`[bisect] gateway:sessions-changed wired=${wired}`);
expect([true, false]).toContain(wired);
});
it('other-session sessions.changed triggers loadSessions only when wired', async () => {
const handlers = await initGatewayHandlers();
const wired = hasSessionsChangedWiring();
const { useChatStore } = await import('@/stores/chat');
const loadSessions = vi.fn(async () => {});
useChatStore.setState({
currentSessionKey: FEISHU_KEY,
sessions: [{ key: FEISHU_KEY }],
loadSessions,
});
handlers.get('gateway:sessions-changed')?.({
sessionKey: OTHER_FEISHU_KEY,
phase: 'start',
ts: Date.now(),
});
await flushAsyncImports();
if (wired) {
expect(loadSessions).toHaveBeenCalled();
} else {
expect(handlers.has('gateway:sessions-changed')).toBe(false);
expect(loadSessions).not.toHaveBeenCalled();
}
});
it('current-session sessions.changed skips loadSessions when wired', async () => {
const handlers = await initGatewayHandlers();
if (!hasSessionsChangedWiring()) {
expect(handlers.has('gateway:sessions-changed')).toBe(false);
return;
}
const { useChatStore } = await import('@/stores/chat');
const loadSessions = vi.fn(async () => {});
useChatStore.setState({
currentSessionKey: FEISHU_KEY,
sessions: [{ key: FEISHU_KEY }],
loadSessions,
});
handlers.get('gateway:sessions-changed')?.({
sessionKey: FEISHU_KEY,
phase: 'start',
ts: Date.now(),
});
await flushAsyncImports();
expect(loadSessions).not.toHaveBeenCalled();
});
it('loadSessions reconcile can clear in-flight sending (path exists on both commits; 0d794cd triggers it via sessions.changed)', async () => {
await initGatewayHandlers();
// updatedAt newer than the in-flight send clears run lifecycle.
const lastUserMessageAt = 1_779_693_769_991;
hostApiMock.gateway.rpc.mockResolvedValue({
sessions: [{
key: FEISHU_KEY,
updatedAt: 1_779_694_521_057,
status: 'done',
hasActiveRun: false,
lastMessagePreview: 'hello from feishu',
}],
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: FEISHU_KEY,
sessions: [{ key: FEISHU_KEY }],
sending: true,
activeRunId: 'run-active',
pendingFinal: true,
lastUserMessageAt,
});
await useChatStore.getState().loadSessions();
expect(useChatStore.getState().sending).toBe(false);
expect(useChatStore.getState().activeRunId).toBeNull();
});
});
});
+174 -3
View File
@@ -45,6 +45,17 @@ vi.mock('@electron/utils/paths', async () => {
};
});
// ClawX desktop denies these tools in tools.deny / gateway.tools.deny during
// sanitizeOpenClawConfig(): skill_workshop (ClawX keeps direct skill-creator
// authoring) plus the subagent workflow (sessions_spawn / sessions_yield /
// subagents) so the model cannot spawn child agent sessions.
const CLAWX_DESKTOP_TOOL_DENY = [
'skill_workshop',
'sessions_spawn',
'sessions_yield',
'subagents',
];
async function writeOpenClawJson(config: unknown): Promise<void> {
const openclawDir = join(testHome, '.openclaw');
await mkdir(openclawDir, { recursive: true });
@@ -377,7 +388,16 @@ describe('sanitizeOpenClawConfig', () => {
// Fresh install should get tools settings enforced
const tools = result.tools as Record<string, unknown>;
expect(tools.profile).toBe('full');
expect(tools.deny).toEqual(['skill_workshop']);
expect(tools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
const gateway = result.gateway as Record<string, unknown>;
const gatewayTools = gateway.tools as Record<string, unknown>;
expect(gatewayTools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
const skills = result.skills as Record<string, unknown>;
const workshop = skills.workshop as Record<string, unknown>;
const autonomous = workshop.autonomous as Record<string, unknown>;
expect(autonomous.enabled).toBe(false);
const entries = skills.entries as Record<string, Record<string, unknown>>;
expect(entries['skill-creator'].enabled).toBe(true);
logSpy.mockRestore();
});
@@ -406,7 +426,12 @@ describe('sanitizeOpenClawConfig', () => {
// tools settings should now be enforced
const tools = result.tools as Record<string, unknown>;
expect(tools.profile).toBe('full');
expect(tools.deny).toEqual(['skill_workshop']);
expect(tools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
const gateway = result.gateway as Record<string, unknown>;
expect((gateway.tools as Record<string, unknown>).deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
const skills = result.skills as Record<string, unknown>;
expect(((skills.workshop as Record<string, unknown>).autonomous as Record<string, unknown>).enabled).toBe(false);
expect((skills.entries as Record<string, Record<string, unknown>>)['skill-creator'].enabled).toBe(true);
logSpy.mockRestore();
});
@@ -423,7 +448,9 @@ describe('sanitizeOpenClawConfig', () => {
const result = await readOpenClawJson();
const tools = result.tools as Record<string, unknown>;
expect(tools.deny).toEqual(['browser', 'skill_workshop']);
expect(tools.deny).toEqual(['browser', ...CLAWX_DESKTOP_TOOL_DENY]);
const gateway = result.gateway as Record<string, unknown>;
expect((gateway.tools as Record<string, unknown>).deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
});
it('migrates legacy tools.web.search.kimi into moonshot plugin config', async () => {
@@ -948,6 +975,59 @@ describe('syncProviderConfigToOpenClaw', () => {
]);
});
it('writes an inferred contextWindow for new custom-provider model rows', async () => {
await writeOpenClawJson({ models: { providers: {} } });
const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth');
await syncProviderConfigToOpenClaw('custom-example', 'gpt-5.5', {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
});
const result = await readOpenClawJson();
const providers = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
const entry = providers['custom-example'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(models).toEqual([
expect.objectContaining({
id: 'gpt-5.5',
contextWindow: 272000,
}),
]);
});
it('does not overwrite an existing contextWindow on re-sync', async () => {
await writeOpenClawJson({
models: {
providers: {
'custom-example': {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
models: [
{ id: 'gpt-5.5', name: 'gpt-5.5', input: ['text'], contextWindow: 64000 },
],
},
},
},
});
const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth');
await syncProviderConfigToOpenClaw('custom-example', 'gpt-5.5', {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
});
const result = await readOpenClawJson();
const providers = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
const entry = providers['custom-example'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(models).toEqual([
expect.objectContaining({ id: 'gpt-5.5', contextWindow: 64000 }),
]);
});
it('uses legacy minimax-portal-auth plugin registration when only the legacy plugin exists', async () => {
await writeOpenClawJson({
models: { providers: {} },
@@ -2266,4 +2346,95 @@ describe('batchSyncConfigFields', () => {
expect(ssrfPolicy.allowRfc2544BenchmarkRange).toBe(false);
expect(ssrfPolicy.allowIpv6UniqueLocalRange).toBe(false);
});
it('seeds compaction safeguard default when compaction is unset', async () => {
await writeOpenClawJson({ gateway: { auth: { mode: 'token', token: 'old' } } });
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const defaults = ((config.agents as Record<string, unknown>).defaults as Record<string, unknown>);
expect(defaults.compaction).toEqual({
mode: 'safeguard',
reserveTokensFloor: 50_000,
});
});
it('backfills reserveTokensFloor on safeguard compaction seeded without a floor', async () => {
await writeOpenClawJson({
gateway: { auth: { mode: 'token', token: 'old' } },
agents: {
defaults: {
compaction: { mode: 'safeguard' },
},
},
});
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const defaults = ((config.agents as Record<string, unknown>).defaults as Record<string, unknown>);
expect(defaults.compaction).toEqual({
mode: 'safeguard',
reserveTokensFloor: 50_000,
});
});
it('does not touch an explicit compaction config', async () => {
await writeOpenClawJson({
gateway: { auth: { mode: 'token', token: 'old' } },
agents: {
defaults: {
compaction: { mode: 'default', reserveTokensFloor: 30000 },
},
},
});
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const defaults = ((config.agents as Record<string, unknown>).defaults as Record<string, unknown>);
expect(defaults.compaction).toEqual({ mode: 'default', reserveTokensFloor: 30000 });
});
it('backfills contextWindow on custom provider model rows that lack one', async () => {
await writeOpenClawJson({
gateway: { auth: { mode: 'token', token: 'old' } },
models: {
providers: {
'custom-enterpri': {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
models: [
{ id: 'gpt-5.5', name: 'gpt-5.5', input: ['text', 'image'] },
{ id: 'private-x', name: 'private-x', input: ['text'], contextTokens: 32000 },
],
},
moonshot: {
baseUrl: 'https://api.moonshot.cn/v1',
api: 'openai-completions',
models: [{ id: 'kimi-k2.6', name: 'Kimi K2.6' }],
},
},
},
});
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const providers = (config.models as Record<string, unknown>).providers as Record<string, unknown>;
const custom = (providers['custom-enterpri'] as Record<string, unknown>).models as Array<Record<string, unknown>>;
const moonshot = (providers.moonshot as Record<string, unknown>).models as Array<Record<string, unknown>>;
expect(custom[0]).toEqual(expect.objectContaining({ id: 'gpt-5.5', contextWindow: 272000 }));
// Rows with explicit contextTokens are user-owned — leave untouched.
expect(custom[1].contextWindow).toBeUndefined();
expect(custom[1].contextTokens).toBe(32000);
// Non custom-* providers own their metadata — never backfilled.
expect(moonshot[0].contextWindow).toBeUndefined();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
ensureMemorySearchDisabledDefault,
hasUserMemorySearchConfig,
} from '@electron/utils/openclaw-memory-search';
describe('openclaw-memory-search', () => {
it('seeds memorySearch.enabled=false when no memorySearch config exists', () => {
const config: Record<string, unknown> = {
agents: { defaults: { model: { primary: 'custom-customfc/gpt-5.5' } } },
};
expect(ensureMemorySearchDisabledDefault(config)).toBe(true);
expect(config).toEqual({
agents: {
defaults: {
model: { primary: 'custom-customfc/gpt-5.5' },
memorySearch: { enabled: false },
},
},
});
});
it('seeds on a completely empty config', () => {
const config: Record<string, unknown> = {};
expect(ensureMemorySearchDisabledDefault(config)).toBe(true);
expect(config).toEqual({
agents: { defaults: { memorySearch: { enabled: false } } },
});
});
it('never touches existing defaults.memorySearch config', () => {
const config: Record<string, unknown> = {
agents: {
defaults: {
memorySearch: {
provider: 'custom-customfc',
model: 'text-embedding-3-small',
fallback: 'none',
remote: { baseUrl: 'https://taolat.com/v1' },
},
},
},
};
const before = JSON.parse(JSON.stringify(config));
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
expect(config).toEqual(before);
});
it('never seeds when a per-agent memorySearch override exists', () => {
const config: Record<string, unknown> = {
agents: {
defaults: { model: { primary: 'openai/gpt-4o' } },
list: [
{ id: 'main' },
{ id: 'research', memorySearch: { provider: 'openai' } },
],
},
};
const before = JSON.parse(JSON.stringify(config));
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
expect(config).toEqual(before);
});
it('treats explicit enabled=true as user config', () => {
const config: Record<string, unknown> = {
agents: { defaults: { memorySearch: { enabled: true } } },
};
expect(hasUserMemorySearchConfig(config)).toBe(true);
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
expect((config.agents as { defaults: { memorySearch: { enabled: boolean } } }).defaults.memorySearch.enabled).toBe(true);
});
it('treats an empty memorySearch object as user config', () => {
const config: Record<string, unknown> = {
agents: { defaults: { memorySearch: {} } },
};
expect(hasUserMemorySearchConfig(config)).toBe(true);
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const mockLoggerInfo = vi.fn();
const mockLoggerWarn = vi.fn();
vi.mock('@electron/utils/logger', () => ({
logger: {
info: mockLoggerInfo,
warn: mockLoggerWarn,
},
}));
describe('plugin install index sqlite persistence', () => {
let stateDir: string;
let previousStateDir: string | undefined;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
stateDir = mkdtempSync(join(tmpdir(), 'clawx-openclaw-state-'));
previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = stateDir;
});
afterEach(() => {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
rmSync(stateDir, { recursive: true, force: true });
});
it('creates sqlite and upserts trusted whatsapp install records', async () => {
const { upsertPluginInstallRecordsIntoSqlite } = await import('@electron/utils/plugin-install-index');
const sqlitePath = join(stateDir, 'openclaw.sqlite');
const record = {
source: 'npm',
spec: '@openclaw/whatsapp',
installPath: '/home/test/.openclaw/extensions/whatsapp',
version: '2026.6.10',
resolvedName: '@openclaw/whatsapp',
resolvedVersion: '2026.6.10',
resolvedSpec: '@openclaw/whatsapp@2026.6.10',
installedAt: '2026-01-01T00:00:00.000Z',
};
expect(upsertPluginInstallRecordsIntoSqlite({ whatsapp: record })).toBe(true);
expect(existsSync(sqlitePath)).toBe(true);
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(sqlitePath);
const row = db.prepare(`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'
`).get() as { install_records_json: string };
db.close();
const persisted = JSON.parse(row.install_records_json) as Record<string, unknown>;
expect(persisted.whatsapp).toEqual(record);
expect(mockLoggerInfo).toHaveBeenCalledWith(
'[plugin] Persisted trusted install metadata to SQLite for: whatsapp',
);
});
it('updates stale sqlite records when installPath changes', async () => {
const { upsertPluginInstallRecordsIntoSqlite } = await import('@electron/utils/plugin-install-index');
const sqlitePath = join(stateDir, 'openclaw.sqlite');
const stale = {
source: 'npm',
spec: '@openclaw/whatsapp',
installPath: '/old/path/whatsapp',
version: '2026.6.10',
resolvedName: '@openclaw/whatsapp',
resolvedVersion: '2026.6.10',
resolvedSpec: '@openclaw/whatsapp@2026.6.10',
};
const fresh = {
...stale,
installPath: '/home/test/.openclaw/extensions/whatsapp',
installedAt: '2026-01-02T00:00:00.000Z',
};
expect(upsertPluginInstallRecordsIntoSqlite({ whatsapp: stale })).toBe(true);
expect(upsertPluginInstallRecordsIntoSqlite({ whatsapp: fresh })).toBe(true);
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(sqlitePath);
const row = db.prepare(`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = 'installed-plugin-index'
`).get() as { install_records_json: string };
db.close();
const persisted = JSON.parse(row.install_records_json) as Record<string, Record<string, unknown>>;
expect(persisted.whatsapp.installPath).toBe(fresh.installPath);
});
});
+49
View File
@@ -87,6 +87,11 @@ vi.mock('@electron/utils/logger', () => ({
},
}));
vi.mock('@electron/utils/plugin-install-index', () => ({
upsertPluginInstallRecordsIntoSqlite: vi.fn(() => true),
ensureOpenClawStateDirExists: vi.fn(),
}));
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value: platform,
@@ -217,4 +222,48 @@ describe('plugin installer diagnostics', () => {
}),
);
});
it('writes trusted install metadata for mirrored official whatsapp plugin', async () => {
const configPath = '/home/test/.openclaw/openclaw.json';
const targetDir = '/home/test/.openclaw/extensions/whatsapp';
const sourceDir = '/bundle/whatsapp';
mockExistsSync.mockImplementation((input: string) => {
const value = String(input);
return value.includes('openclaw.plugin.json')
|| value === configPath
|| value.includes('/bundle/whatsapp/package.json')
|| value.includes(`${targetDir}/package.json`);
});
mockReadFileSync.mockImplementation((input: string) => {
if (String(input) === configPath) {
return JSON.stringify({
plugins: {
allow: ['whatsapp'],
enabled: true,
},
});
}
if (String(input).endsWith('package.json')) {
return JSON.stringify({ version: '2026.6.10' });
}
return '{}';
});
mockRealpathSync.mockImplementation((input: string) => input);
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
const result = ensurePluginInstalled('whatsapp', [sourceDir], 'WhatsApp');
expect(result.installed).toBe(true);
expect(mockWriteFileSync).toHaveBeenCalledWith(
configPath,
expect.stringContaining(`"installPath": "${targetDir}"`),
'utf-8',
);
expect(mockWriteFileSync).toHaveBeenCalledWith(
configPath,
expect.stringContaining('"resolvedName": "@openclaw/whatsapp"'),
'utf-8',
);
});
});
+23 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { inferCustomModelInputModalities } from '@electron/shared/providers/model-capabilities';
import {
DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW,
inferCustomModelContextWindow,
inferCustomModelInputModalities,
} from '@electron/shared/providers/model-capabilities';
describe('inferCustomModelInputModalities', () => {
it.each([
@@ -22,3 +26,21 @@ describe('inferCustomModelInputModalities', () => {
expect(inferCustomModelInputModalities(modelId)).toEqual(['text']);
});
});
describe('inferCustomModelContextWindow', () => {
it.each([
['gpt-5.5', 272_000],
['GPT-5.4-Mini', 272_000],
['gpt-4o', 128_000],
['claude-opus-4-6', 200_000],
['gemini-3-flash', 1_048_576],
['kimi-k2.6', 256_000],
['MiniMax-M3', 204_800],
])('maps known family %s to %d tokens', (modelId, expected) => {
expect(inferCustomModelContextWindow(modelId)).toBe(expected);
});
it('falls back to the conservative default for unknown models', () => {
expect(inferCustomModelContextWindow('unknown-private-model')).toBe(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW);
});
});
+231 -5
View File
@@ -26,7 +26,11 @@ async function readConfig(): Promise<Record<string, unknown>> {
return JSON.parse(raw);
}
function withClawXToolDefaults<T extends Record<string, unknown>>(config: T): T & { tools: Record<string, unknown> } {
function withClawXToolDefaults<T extends Record<string, unknown>>(config: T): T & {
tools: Record<string, unknown>;
gateway: Record<string, unknown>;
skills: Record<string, unknown>;
} {
const tools = (config.tools && typeof config.tools === 'object' && !Array.isArray(config.tools))
? { ...(config.tools as Record<string, unknown>) }
: {};
@@ -47,10 +51,69 @@ function withClawXToolDefaults<T extends Record<string, unknown>>(config: T): T
tools.sessions = sessions;
tools.exec = exec;
tools.deny = deny.includes('skill_workshop') ? deny : [...deny, 'skill_workshop'];
// Mirror production: also deny the subagent workflow tools.
for (const entry of ['sessions_spawn', 'sessions_yield', 'subagents']) {
if (!(tools.deny as string[]).includes(entry)) {
(tools.deny as string[]).push(entry);
}
}
const gateway = (config.gateway && typeof config.gateway === 'object' && !Array.isArray(config.gateway))
? { ...(config.gateway as Record<string, unknown>) }
: {};
const gatewayTools = (gateway.tools && typeof gateway.tools === 'object' && !Array.isArray(gateway.tools))
? { ...(gateway.tools as Record<string, unknown>) }
: {};
const gatewayDeny = Array.isArray(gatewayTools.deny)
? (gatewayTools.deny as unknown[]).filter((value): value is string => typeof value === 'string')
: [];
gatewayTools.deny = gatewayDeny.includes('skill_workshop') ? gatewayDeny : [...gatewayDeny, 'skill_workshop'];
// Mirror production: also deny the subagent workflow tools (gateway side).
for (const entry of ['sessions_spawn', 'sessions_yield', 'subagents']) {
if (!(gatewayTools.deny as string[]).includes(entry)) {
(gatewayTools.deny as string[]).push(entry);
}
}
gateway.tools = gatewayTools;
const skills = (config.skills && typeof config.skills === 'object' && !Array.isArray(config.skills))
? { ...(config.skills as Record<string, unknown>) }
: {};
const workshop = (skills.workshop && typeof skills.workshop === 'object' && !Array.isArray(skills.workshop))
? { ...(skills.workshop as Record<string, unknown>) }
: {};
const autonomous = (workshop.autonomous && typeof workshop.autonomous === 'object' && !Array.isArray(workshop.autonomous))
? { ...(workshop.autonomous as Record<string, unknown>) }
: {};
autonomous.enabled = false;
workshop.autonomous = autonomous;
skills.workshop = workshop;
const entries = (skills.entries && typeof skills.entries === 'object' && !Array.isArray(skills.entries))
? { ...(skills.entries as Record<string, unknown>) }
: {};
const skillCreatorEntry = (entries['skill-creator'] && typeof entries['skill-creator'] === 'object' && !Array.isArray(entries['skill-creator']))
? { ...(entries['skill-creator'] as Record<string, unknown>) }
: {};
skillCreatorEntry.enabled = true;
entries['skill-creator'] = skillCreatorEntry;
skills.entries = entries;
return {
...config,
tools,
gateway,
skills,
session: {
...((config.session && typeof config.session === 'object' && !Array.isArray(config.session))
? (config.session as Record<string, unknown>)
: {}),
dmScope: ((config.session && typeof config.session === 'object' && !Array.isArray(config.session))
? (config.session as Record<string, unknown>).dmScope
: undefined) === 'per-account-channel-peer'
? 'per-account-channel-peer'
: 'per-channel-peer',
},
};
}
@@ -363,6 +426,27 @@ async function sanitizeConfig(
toolsModified = true;
}
// Mirror production: deny the subagent workflow tools (sessions_spawn /
// sessions_yield / subagents) so the model cannot spawn child agent sessions.
const subagentEntries = ['sessions_spawn', 'sessions_yield', 'subagents'];
let subagentDeny = Array.isArray(toolsConfig.deny)
? toolsConfig.deny.filter((value): value is string => typeof value === 'string')
: [];
let subagentDenyChanged = false;
for (const entry of subagentEntries) {
if (!subagentDeny.includes(entry)) {
subagentDeny = [...subagentDeny, entry];
subagentDenyChanged = true;
}
}
if (subagentDenyChanged) {
toolsConfig.deny = subagentDeny;
toolsModified = true;
} else if (!Array.isArray(toolsConfig.deny) || toolsConfig.deny.length !== subagentDeny.length) {
toolsConfig.deny = subagentDeny;
toolsModified = true;
}
const execConfig = (toolsConfig.exec as Record<string, unknown> | undefined) || {};
if (execConfig.security !== 'full' || execConfig.ask !== 'off') {
execConfig.security = 'full';
@@ -376,6 +460,105 @@ async function sanitizeConfig(
modified = true;
}
// Mirror: session.dmScope
const sessionConfig = (
config.session && typeof config.session === 'object' && !Array.isArray(config.session)
? { ...(config.session as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (sessionConfig.dmScope !== 'per-channel-peer' && sessionConfig.dmScope !== 'per-account-channel-peer') {
sessionConfig.dmScope = 'per-channel-peer';
config.session = sessionConfig;
modified = true;
}
const gateway = (
config.gateway && typeof config.gateway === 'object'
? { ...(config.gateway as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayTools = (
gateway.tools && typeof gateway.tools === 'object'
? { ...(gateway.tools as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayDeny = Array.isArray(gatewayTools.deny)
? gatewayTools.deny.filter((value): value is string => typeof value === 'string')
: [];
if (!gatewayDeny.includes('skill_workshop')) {
gatewayTools.deny = [...gatewayDeny, 'skill_workshop'];
gateway.tools = gatewayTools;
config.gateway = gateway;
modified = true;
}
// Mirror production: deny the subagent workflow tools on the gateway side too.
let gatewaySubagentDeny = Array.isArray(gatewayTools.deny)
? gatewayTools.deny.filter((value): value is string => typeof value === 'string')
: [];
let gatewaySubagentChanged = false;
for (const entry of ['sessions_spawn', 'sessions_yield', 'subagents']) {
if (!gatewaySubagentDeny.includes(entry)) {
gatewaySubagentDeny = [...gatewaySubagentDeny, entry];
gatewaySubagentChanged = true;
}
}
if (gatewaySubagentChanged) {
gatewayTools.deny = gatewaySubagentDeny;
gateway.tools = gatewayTools;
config.gateway = gateway;
modified = true;
} else if (!Array.isArray(gatewayTools.deny) || gatewayTools.deny.length !== gatewaySubagentDeny.length) {
gatewayTools.deny = gatewaySubagentDeny;
gateway.tools = gatewayTools;
config.gateway = gateway;
modified = true;
}
let skillsConfig = (
config.skills && typeof config.skills === 'object' && !Array.isArray(config.skills)
? { ...(config.skills as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
let skillsConfigModified = false;
const workshop = (
skillsConfig.workshop && typeof skillsConfig.workshop === 'object'
? { ...(skillsConfig.workshop as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const autonomous = (
workshop.autonomous && typeof workshop.autonomous === 'object'
? { ...(workshop.autonomous as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (autonomous.enabled !== false) {
autonomous.enabled = false;
workshop.autonomous = autonomous;
skillsConfig.workshop = workshop;
skillsConfigModified = true;
}
const skillEntries = (
skillsConfig.entries && typeof skillsConfig.entries === 'object' && !Array.isArray(skillsConfig.entries)
? { ...(skillsConfig.entries as Record<string, unknown>) }
: {}
) as Record<string, Record<string, unknown>>;
const skillCreatorEntry = skillEntries['skill-creator'] || {};
if (skillCreatorEntry.enabled !== true) {
skillEntries['skill-creator'] = {
...skillCreatorEntry,
enabled: true,
};
skillsConfig.entries = skillEntries;
skillsConfigModified = true;
}
if (skillsConfigModified) {
config.skills = skillsConfig;
modified = true;
}
// Mirror: remove stale tools.web.search.kimi.apiKey when moonshot provider exists.
const providers = ((config.models as Record<string, unknown> | undefined)?.providers as Record<string, unknown> | undefined) || {};
if (providers.moonshot) {
@@ -455,8 +638,8 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
const entries = skills.entries as Record<string, Record<string, unknown>>;
expect(entries['my-skill'].enabled).toBe(true);
expect(entries['my-skill'].apiKey).toBe('abc');
// Other top-level sections are untouched
expect(result.gateway).toEqual({ mode: 'local' });
// Other top-level sections are untouched (gateway gets Skill Workshop hardening)
expect(result.gateway).toEqual(withClawXToolDefaults({ gateway: { mode: 'local' } }).gateway);
});
it('removes skills.disabled at the root level of skills', async () => {
@@ -594,7 +777,12 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
// All other sections unchanged
expect(result.channels).toEqual({ discord: { token: 'abc', enabled: true } });
expect(result.plugins).toEqual({ entries: { customPlugin: { enabled: true } } });
expect(result.gateway).toEqual({ mode: 'local', auth: { token: 'xyz' } });
expect(result.gateway).toEqual(withClawXToolDefaults({
gateway: {
mode: 'local',
auth: { token: 'xyz' },
},
}).gateway);
expect(result.agents).toEqual({ defaults: { model: { primary: 'gpt-4' } } });
});
@@ -713,7 +901,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
// Other plugin config is preserved
expect(plugins.entries).toEqual({ customPlugin: { enabled: true } });
// Other top-level sections untouched
expect(result.gateway).toEqual({ mode: 'local' });
expect(result.gateway).toEqual(withClawXToolDefaults({ gateway: { mode: 'local' } }).gateway);
});
it('keeps configured built-in channels in plugins.allow when external plugins are enabled', async () => {
@@ -1038,4 +1226,42 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
const modified = await sanitizeConfig(configPath, { all: ['browser'], enabledByDefault: ['browser'] });
expect(modified).toBe(false);
});
it('sets session.dmScope to per-channel-peer when unset', async () => {
await writeConfig(withClawXToolDefaults({}));
const modified = await sanitizeConfig(configPath, { all: ['browser'], enabledByDefault: ['browser'] });
expect(modified).toBe(false);
const result = await readConfig();
expect((result.session as Record<string, unknown>).dmScope).toBe('per-channel-peer');
});
it('preserves session.dmScope when already set to per-account-channel-peer', async () => {
await writeConfig(withClawXToolDefaults({
session: { dmScope: 'per-account-channel-peer' },
}));
const modified = await sanitizeConfig(configPath, { all: ['browser'], enabledByDefault: ['browser'] });
expect(modified).toBe(false);
const result = await readConfig();
expect((result.session as Record<string, unknown>).dmScope).toBe('per-account-channel-peer');
});
it('overrides session.dmScope when set to main', async () => {
// Write config with dmScope: 'main' but otherwise already sanitized,
// so only the session.dmScope change should trigger modified=true.
const base = withClawXToolDefaults({});
await writeConfig({
...base,
session: { dmScope: 'main' },
});
const modified = await sanitizeConfig(configPath, { all: ['browser'], enabledByDefault: ['browser'] });
expect(modified).toBe(true);
const result = await readConfig();
expect((result.session as Record<string, unknown>).dmScope).toBe('per-channel-peer');
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
isChannelSessionKey,
isClawXDesktopSessionKey,
isPlaceholderChannelSession,
shouldIncludeSessionInSidebarList,
} from '@/stores/chat/session-key-utils';
import type { ChatSession } from '@/stores/chat/types';
describe('session-key-utils', () => {
it('detects feishu and other channel session keys', () => {
expect(isChannelSessionKey('agent:main:feishu:ou_abc123')).toBe(true);
expect(isChannelSessionKey('agent:main:telegram:12345')).toBe(true);
expect(isChannelSessionKey('agent:main:whatsapp:dm:abc')).toBe(true);
});
it('treats ClawX desktop session keys as non-channel', () => {
expect(isChannelSessionKey('agent:main:main')).toBe(false);
expect(isChannelSessionKey('agent:main:session-1710000000000')).toBe(false);
expect(isChannelSessionKey('agent:main:cron:heartbeat')).toBe(false);
});
it('excludes cron and channel keys from desktop-only session keys', () => {
expect(isClawXDesktopSessionKey('agent:main:main')).toBe(true);
expect(isClawXDesktopSessionKey('agent:main:session-1710000000000')).toBe(true);
expect(isClawXDesktopSessionKey('agent:main:feishu:ou_abc123')).toBe(false);
expect(isClawXDesktopSessionKey('agent:main:cron:heartbeat')).toBe(false);
});
it('detects placeholder channel sessions without any preview/title', () => {
const placeholder: ChatSession = {
key: 'agent:main:feishu:ou_abc123',
};
expect(isPlaceholderChannelSession(placeholder)).toBe(true);
expect(shouldIncludeSessionInSidebarList(placeholder)).toBe(false);
});
it('includes channel sessions once they have a message preview', () => {
const active: ChatSession = {
key: 'agent:main:feishu:ou_abc123',
lastMessagePreview: 'feishu:ou_abc123',
};
expect(isPlaceholderChannelSession(active)).toBe(false);
expect(shouldIncludeSessionInSidebarList(active)).toBe(true);
});
it('includes channel sessions with a derived title', () => {
const titled: ChatSession = {
key: 'agent:main:feishu:ou_abc123',
derivedTitle: '飞书对话',
};
expect(isPlaceholderChannelSession(titled)).toBe(false);
expect(shouldIncludeSessionInSidebarList(titled)).toBe(true);
});
});