mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix: persist trusted plugin install records to SQLite for packaged WhatsApp (#1140)
This commit is contained in:
@@ -203,6 +203,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -260,13 +261,16 @@ type TrustedOfficialPluginInstallRecord = {
|
||||
resolvedName: string;
|
||||
resolvedVersion: string;
|
||||
resolvedSpec: string;
|
||||
installedAt: string;
|
||||
};
|
||||
|
||||
function resolveTrustedInstallPath(targetDir: string): string | null {
|
||||
/** Store plain paths for OpenClaw install-record matching (no Windows \\?\ prefix). */
|
||||
function normalizePluginInstallPathForRecord(targetDir: string): string | null {
|
||||
try {
|
||||
return realpathSync(fsPath(targetDir));
|
||||
const resolved = realpathSync(targetDir);
|
||||
return path.normalize(resolved);
|
||||
} catch {
|
||||
return targetDir;
|
||||
return path.normalize(targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +282,7 @@ function buildTrustedOfficialPluginInstallRecord(
|
||||
if (!npmName) return null;
|
||||
|
||||
const version = readPluginVersion(join(targetDir, 'package.json'));
|
||||
const installPath = resolveTrustedInstallPath(targetDir);
|
||||
const installPath = normalizePluginInstallPathForRecord(targetDir);
|
||||
if (!version || !installPath) return null;
|
||||
|
||||
return {
|
||||
@@ -289,9 +293,16 @@ function buildTrustedOfficialPluginInstallRecord(
|
||||
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,
|
||||
@@ -311,6 +322,7 @@ function trustedInstallRecordMatches(
|
||||
|
||||
/**
|
||||
* 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(
|
||||
@@ -324,16 +336,19 @@ export function syncTrustedOfficialPluginInstallRecord(
|
||||
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>;
|
||||
const plugins = config.plugins;
|
||||
let plugins = config.plugins;
|
||||
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
|
||||
return false;
|
||||
plugins = { enabled: true, installs: {} };
|
||||
config.plugins = plugins;
|
||||
}
|
||||
|
||||
const pluginsRecord = plugins as Record<string, unknown>;
|
||||
@@ -343,23 +358,26 @@ export function syncTrustedOfficialPluginInstallRecord(
|
||||
: {};
|
||||
|
||||
const existing = installsRecord[pluginDirName];
|
||||
if (trustedInstallRecordMatches(existing, expected)) {
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
|
||||
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}`);
|
||||
return 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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user