Compare commits

...
5 Commits
20 changed files with 1428 additions and 120 deletions
+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);
}
+123 -10
View File
@@ -2733,6 +2733,25 @@ 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';
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 };
}
export async function sanitizeOpenClawConfig(): Promise<void> {
return withConfigLock(async () => {
// Skip sanitization if the config file does not exist yet.
@@ -2916,18 +2935,19 @@ 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;
}
@@ -2951,6 +2971,99 @@ 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;
}
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
+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();
}
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.4.11",
"version": "0.4.13",
"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:
+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();
});
});
});
+16
View File
@@ -378,6 +378,15 @@ describe('sanitizeOpenClawConfig', () => {
const tools = result.tools as Record<string, unknown>;
expect(tools.profile).toBe('full');
expect(tools.deny).toEqual(['skill_workshop']);
const gateway = result.gateway as Record<string, unknown>;
const gatewayTools = gateway.tools as Record<string, unknown>;
expect(gatewayTools.deny).toEqual(['skill_workshop']);
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();
});
@@ -407,6 +416,11 @@ describe('sanitizeOpenClawConfig', () => {
const tools = result.tools as Record<string, unknown>;
expect(tools.profile).toBe('full');
expect(tools.deny).toEqual(['skill_workshop']);
const gateway = result.gateway as Record<string, unknown>;
expect((gateway.tools as Record<string, unknown>).deny).toEqual(['skill_workshop']);
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();
});
@@ -424,6 +438,8 @@ describe('sanitizeOpenClawConfig', () => {
const result = await readOpenClawJson();
const tools = result.tools as Record<string, unknown>;
expect(tools.deny).toEqual(['browser', 'skill_workshop']);
const gateway = result.gateway as Record<string, unknown>;
expect((gateway.tools as Record<string, unknown>).deny).toEqual(['skill_workshop']);
});
it('migrates legacy tools.web.search.kimi into moonshot plugin config', async () => {
+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',
);
});
});
+175 -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>) }
: {};
@@ -48,9 +52,56 @@ function withClawXToolDefaults<T extends Record<string, unknown>>(config: T): T
tools.exec = exec;
tools.deny = deny.includes('skill_workshop') ? deny : [...deny, 'skill_workshop'];
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'];
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',
},
};
}
@@ -376,6 +427,82 @@ 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;
}
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 +582,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 +721,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 +845,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 +1170,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);
});
});