Files
ClawX/electron/utils/plugin-install-index.ts

231 lines
7.6 KiB
TypeScript

/**
* 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 {
// OpenClaw 2026.7.1 moved the shared state database under state/.
// Writing the legacy root-level database leaves Gateway migrations reading
// stale plugin records from the canonical database.
return join(resolveOpenClawStateDir(), 'state', '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();
mkdirSync(join(resolveOpenClawStateDir(), 'state'), { recursive: true });
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();
}
}
/**
* Remove install records that must remain ClawX-managed rather than updated
* from their raw upstream npm package. Also clean the legacy root-level DB
* previously written by ClawX before OpenClaw 2026.7.1 moved state to state/.
*/
export function removePluginInstallRecordsFromSqlite(pluginIds: string[]): boolean {
if (pluginIds.length === 0) return false;
const stateDir = resolveOpenClawStateDir();
const sqlitePaths = [
resolveOpenClawStateSqlitePath(),
join(stateDir, 'openclaw.sqlite'),
];
let changed = false;
for (const sqlitePath of sqlitePaths) {
if (!existsSync(sqlitePath)) continue;
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;
if (!row) continue;
const records = parseInstallRecordsJson(row.install_records_json);
let databaseChanged = false;
for (const pluginId of pluginIds) {
if (Object.hasOwn(records, pluginId)) {
delete records[pluginId];
databaseChanged = true;
}
}
if (!databaseChanged) continue;
const now = Date.now();
db.prepare(`
UPDATE installed_plugin_index
SET install_records_json = ?,
updated_at_ms = ?,
generated_at_ms = ?
WHERE index_key = ?
`).run(JSON.stringify(records), now, now, INSTALLED_PLUGIN_INDEX_KEY);
changed = true;
} catch (error) {
logger.warn(`[plugin] Failed to remove install metadata from ${sqlitePath}:`, error);
} finally {
db?.close();
}
}
if (changed) {
logger.info(`[plugin] Removed managed install metadata from SQLite for: ${pluginIds.join(', ')}`);
}
return changed;
}
/** Ensure ~/.openclaw exists before first config write in fresh installs. */
export function ensureOpenClawStateDirExists(): void {
const stateDir = resolveOpenClawStateDir();
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
}