fix: persist trusted plugin install records to SQLite for packaged WhatsApp (#1140)

This commit is contained in:
paisley
2026-07-02 11:25:09 +08:00
committed by GitHub
parent 33694efe84
commit f3689894bf
5 changed files with 311 additions and 18 deletions
+1
View File
@@ -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;
}
+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 });
}
}
+36 -18
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;
@@ -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. */
+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);
});
});
+5
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,