diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index 092708a8..11d3f44b 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -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; } diff --git a/electron/utils/plugin-install-index.ts b/electron/utils/plugin-install-index.ts new file mode 100644 index 00000000..ce7192a2 --- /dev/null +++ b/electron/utils/plugin-install-index.ts @@ -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> { + 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> = {}; + for (const [pluginId, record] of Object.entries(parsed as Record)) { + if (record && typeof record === 'object' && !Array.isArray(record)) { + records[pluginId] = record as Record; + } + } + return records; + } catch { + return {}; + } +} + +function installRecordsMatch( + left: Record, + right: Record, +): 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>, +): 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>; + 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 }); + } +} diff --git a/electron/utils/plugin-install.ts b/electron/utils/plugin-install.ts index a24f558a..14234ce7 100644 --- a/electron/utils/plugin-install.ts +++ b/electron/utils/plugin-install.ts @@ -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>, +): boolean { + return upsertPluginInstallRecordsIntoSqlite(records); +} + function trustedInstallRecordMatches( existing: unknown, expected: TrustedOfficialPluginInstallRecord, @@ -311,6 +322,7 @@ function trustedInstallRecordMatches( /** * Write or refresh plugins.installs. 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; - 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; @@ -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. */ diff --git a/tests/unit/plugin-install-index.test.ts b/tests/unit/plugin-install-index.test.ts new file mode 100644 index 00000000..8253174e --- /dev/null +++ b/tests/unit/plugin-install-index.test.ts @@ -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; + 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>; + expect(persisted.whatsapp.installPath).toBe(fresh.installPath); + }); +}); diff --git a/tests/unit/plugin-install.test.ts b/tests/unit/plugin-install.test.ts index eb6fc0ff..f8fafd9f 100644 --- a/tests/unit/plugin-install.test.ts +++ b/tests/unit/plugin-install.test.ts @@ -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,