Files
Netcatty/electron/plugins/database.cjs
T
陈大猫 583c75b107 fix(plugins): backfill sync provider bindings after schema upgrade (#2752)
* fix(plugins): backfill sync provider bindings after schema upgrade

Promote legacy sync-provider-map secrets into the host binding table on
open, and infer plugin ownership from sync-credential rows so disconnect
still cleans credentials when bindings were never written.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): infer sync provider owners only via pluginId. namespace

Exact pluginId===providerId is not ownership for contribution provider
ids; prefer the longest pluginId. prefix when backfilling disconnect cleanup.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): stop inferring sync owner from credential prefixes

Parent plugin ids can namespace-prefix another plugin's provider id.
Guessing ownership from sync-credential rows would let disconnect wipe
the wrong secrets. Keep host bindings and legacy map backfill as the
only ownership sources when no live contribution is present.

* fix(plugins): do not overwrite existing sync provider bindings on backfill

Legacy sync-provider-map rows from a parent plugin must not steal a
provider that already has a host binding. Skip upsert when a binding
exists; still delete the stale map secret.

* fix(plugins): pick longest owner when legacy map rows conflict

Group sync-provider-map rows by provider before binding so a shorter
parent plugin cannot win just because SQLite returned it first. Still
preserve existing host bindings and delete all leftover map secrets.

* fix(plugins): never delete secrets during sync-provider-map backfill

The map key prefix is not reserved for host internals; plugins may store
ordinary secrets under the same shape. Only seed missing host bindings
from candidate rows and leave plugin_secrets untouched.

* fix(plugins): seed sync bindings from live provider metadata

Schema-2 installs only stored sync-credential* rows. After upgrade, seed
missing provider bindings from the host contribution catalog when those
credentials exist, instead of guessing from secret key prefixes.

* fix(plugins): seed sync bindings from installed manifests, not only enabled

listProviders skips disabled/quarantined plugins. Walk installed plugin
manifests at host init so schema-2 credential rows under disabled plugins
still get provider bindings for disconnect cleanup.

* fix(plugins): seed sync bindings from all installed version manifests

Active-only listPlugins() misses providers removed/renamed on upgrade
while credentials remain under the plugin. Walk every plugin_versions
manifest so disconnect can still resolve ownership after schema upgrade.

* fix(plugins): only auto-bind single-provider plugins from credentials

Plugin-scoped sync-credential* rows are shared across providers. Binding
every historical provider would let disconnect of a stale one wipe the
active provider's secrets. Seed bindings only when exactly one sync
provider is known for that plugin.

* fix: address Codex review on PR #2752

* fix(plugins): stop resurrecting sync bindings after unbind

Consume promoted sync-provider-map markers and leave an unbound
tombstone on disconnect so constructor backfill cannot re-bind a
provider the user already disconnected.

* fix(plugins): prefer credential-backed owners for legacy map backfill

Longest sync-provider-map namespace alone could bind a nested plugin over a
parent that still owns sync-credential* secrets. Only promote candidates with
credentials; pick the longest among those, and skip when none or ambiguous.

* fix(plugins): skip ambiguous multi-credential legacy map backfill

Only auto-bind when exactly one credential-backed map candidate exists.
When parent and nested plugins both hold credentials for the same provider,
longest pluginId is not a reliable owner signal — leave unbound.

* fix(plugins): skip cross-plugin provider conflicts in live backfill

When multiple credential-backed plugins declare the same providerId via
nested namespaces, do not bind the first writer. A later missing-provider
disconnect would otherwise delete the wrong sync-credential rows.

* fix(plugins): use full installed catalog for enable-time sync backfill

Enable-time binding must see the same active+inactive manifest catalog as
startup so cross-plugin nested provider ambiguity is not missed when only
the newly enabled plugin's live providers would be passed.

* fix(plugins): count non-credential manifest claims in backfill conflicts

Live nested plugins without credentials yet must still block binding a
credential-backed legacy parent to the same providerId, or disconnect can
prefer the live owner while the stale binding remains.

* fix(plugins): skip legacy map promote on installed-manifest conflicts

Constructor-time map backfill must not bind a sole credential-backed
parent when another installed package also declares the same sync
provider, or later disconnect cannot clear the stale binding.

* fix(plugins): require single-provider maps for legacy-map promotion

A plugin with one sync-credential row and maps for multiple historical
providers must not bind all of them — disconnecting a stale provider
would delete the live credentials via the shared prefix.

* fix(plugins): count installed manifest providers in legacy-map guard

A sole stale map row must not promote when the credential owner's live
manifest also declares another sync provider under the same credentials.

* fix(plugins): backfill sync bindings before startup activation

Seed installed-manifest bindings before contributionService.initialize so
an early syncDeleteSecrets cannot miss ownership and leave orphan credentials.

* fix(plugins): seed sync bindings at host construction

Run installed-manifest backfill when the plugin host is created, before
async manager.initialize, so early syncDeleteSecrets can resolve ownership.

* fix(plugins): re-seed sync bindings after package store recovery

PackageStore.recover can add installed versions after the constructor
seed; re-run the catalog backfill when package initialize finishes.

* fix(plugins): await manager init before syncDeleteSecrets ownership

Block secret cleanup until package recovery and binding seed finish so
early disconnect cannot miss retained sync-credential rows.

* fix(plugins): re-run legacy-map backfill after package recovery

After PackageStore.recover repopulates versions, re-run legacy-map promote
so recovered manifests participate in ownership conflict checks.

* fix(plugins): defer legacy-map backfill until after package recovery

Constructor no longer promotes sole map candidates before recovered
manifests are visible. hostService re-runs backfill after package init.

* fix(plugins): defer live binding seed until after package recovery

Construction-time live-provider backfill can bind a stale owner against an
incomplete catalog before PackageStore.recover() restores plugin_versions.
Seed only after recovery; syncDeleteSecrets already awaits manager init.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-06 08:53:03 +08:00

1075 lines
41 KiB
JavaScript

"use strict";
const { createHash } = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const SCHEMA_VERSION = 3;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
plugins: ["id", "enabled", "active_version", "installed_at", "updated_at"],
plugin_versions: ["plugin_id", "version", "manifest_json", "archive_sha256", "package_relative_path", "installed_at"],
plugin_runtime_state: ["plugin_id", "plugin_version", "status", "runtime_kind", "last_error", "quarantined_at", "updated_at"],
plugin_crashes: ["plugin_id", "plugin_version", "crashed_at"],
plugin_kv: ["plugin_id", "key", "value_json", "updated_at"],
plugin_settings: ["plugin_id", "setting_id", "scope", "scope_id", "value_json", "updated_at"],
plugin_view_state: ["plugin_id", "view_id", "scope_id", "state_json", "updated_at"],
plugin_permission_grants: ["plugin_id", "permission", "resource", "resource_kind", "declaration_hash", "granted_at"],
plugin_secrets: ["plugin_id", "key", "secret_ref", "ciphertext", "created_at", "updated_at"],
plugin_security_audit: ["id", "plugin_id", "event", "details_json", "created_at"],
// User-owned encrypted-sync sidecars: no FK cascade on package uninstall.
plugin_sync_sidecars: ["plugin_id", "kind", "key", "value_json", "updated_at"],
// Host-owned sync provider→plugin bindings (not plugin-writable secrets).
plugin_sync_provider_bindings: ["provider_id", "plugin_id", "created_at", "updated_at"],
});
function parseJson(text, label) {
try {
return JSON.parse(text);
} catch {
throw new Error(`Corrupt plugin database ${label}`);
}
}
class PluginDatabase {
constructor(databasePath, options = {}) {
if (typeof databasePath !== "string" || !path.isAbsolute(databasePath)) {
throw new TypeError("Plugin database path must be absolute");
}
fs.mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
this.databasePath = databasePath;
this.clock = options.clock ?? (() => Date.now());
const ownsDatabase = !options.database;
this.db = options.database ?? new DatabaseSync(databasePath);
try {
this.#initializeSchema();
} catch (error) {
if (ownsDatabase) {
try { this.db.close(); } catch {}
}
throw error;
}
}
#initializeSchema() {
this.db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;");
const version = Number(this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
if (version > SCHEMA_VERSION) {
throw new Error(`Plugin database schema ${version} is newer than supported ${SCHEMA_VERSION}`);
}
if (version === 0) {
this.transaction(() => {
this.db.exec(`
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
active_version TEXT,
installed_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE plugin_versions (
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
version TEXT NOT NULL,
manifest_json TEXT NOT NULL,
archive_sha256 TEXT NOT NULL,
package_relative_path TEXT NOT NULL,
installed_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, version)
);
CREATE TABLE plugin_runtime_state (
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'stopped',
runtime_kind TEXT,
last_error TEXT,
quarantined_at INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, plugin_version),
FOREIGN KEY (plugin_id, plugin_version)
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
);
CREATE TABLE plugin_crashes (
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
crashed_at INTEGER NOT NULL,
FOREIGN KEY (plugin_id, plugin_version)
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
);
CREATE INDEX plugin_crashes_lookup
ON plugin_crashes(plugin_id, plugin_version, crashed_at);
CREATE TABLE plugin_kv (
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, key)
);
CREATE TABLE plugin_settings (
plugin_id TEXT NOT NULL,
setting_id TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('application', 'workspace', 'host', 'session', 'device')),
scope_id TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, setting_id, scope, scope_id)
);
CREATE INDEX plugin_settings_lookup
ON plugin_settings(plugin_id, scope, scope_id, setting_id);
CREATE TABLE plugin_view_state (
plugin_id TEXT NOT NULL,
view_id TEXT NOT NULL,
scope_id TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, view_id, scope_id)
);
CREATE INDEX plugin_view_state_lookup
ON plugin_view_state(plugin_id, scope_id, view_id);
CREATE TABLE plugin_permission_grants (
plugin_id TEXT NOT NULL,
permission TEXT NOT NULL,
resource TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('exact', 'directory')),
declaration_hash TEXT NOT NULL,
granted_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, permission, resource)
);
CREATE TABLE plugin_secrets (
plugin_id TEXT NOT NULL,
key TEXT NOT NULL,
secret_ref TEXT NOT NULL UNIQUE,
ciphertext BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, key)
);
CREATE INDEX plugin_secrets_ref_lookup
ON plugin_secrets(plugin_id, secret_ref);
CREATE TABLE plugin_security_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
event TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX plugin_security_audit_lookup
ON plugin_security_audit(plugin_id, created_at DESC);
CREATE TABLE plugin_sync_sidecars (
plugin_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('settings', 'account_baseline', 'crdt_baseline')),
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, kind, key)
);
CREATE INDEX plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
CREATE TABLE plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 1) {
// Pre-sidecar schema-1 databases only created the original tables.
// Migrate in place to schema 3 with sidecar + provider binding tables.
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
plugin_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('settings', 'account_baseline', 'crdt_baseline')),
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, kind, key)
);
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 2) {
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
}
this.#assertSchemaLayout();
// Legacy-map backfill is deferred until PackageStore.recover() has finished
// (hostService seeds after package initialize). Running here would promote
// sole map candidates before recovered manifests are visible, then skip
// re-evaluation because bindings already exist (Codex P2 on cded4c8a).
}
#assertSchemaLayout() {
for (const [table, columns] of Object.entries(REQUIRED_SCHEMA_COLUMNS)) {
const actual = this.db.prepare(`PRAGMA table_info(${table})`).all().map(({ name }) => name);
if (JSON.stringify(actual) !== JSON.stringify(columns)) {
throw new Error(
"Pre-release plugin database schema is obsolete; reset userData/plugins/plugins.sqlite",
);
}
}
}
transaction(callback) {
this.db.exec("BEGIN IMMEDIATE");
try {
const result = callback();
if (result && typeof result.then === "function") {
throw new TypeError("Plugin database transactions must be synchronous");
}
this.db.exec("COMMIT");
return result;
} catch (error) {
try { this.db.exec("ROLLBACK"); } catch {}
throw error;
}
}
installVersion(record, options = {}) {
if (options.enable === true && options.forceDisabled === true) {
throw new TypeError("Plugin version cannot be enabled and force-disabled together");
}
const now = this.clock();
const manifestJson = JSON.stringify(record.manifest);
const requestedEnabled = options.enable === true ? 1 : 0;
const overwriteEnabled = options.enable === true || options.forceDisabled === true;
this.transaction(() => {
this.db.prepare(`
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
active_version = excluded.active_version,
enabled = CASE WHEN ? THEN excluded.enabled ELSE plugins.enabled END,
updated_at = excluded.updated_at
`).run(
record.pluginId,
requestedEnabled,
record.version,
now,
now,
overwriteEnabled ? 1 : 0,
);
this.db.prepare(`
INSERT INTO plugin_versions(
plugin_id, version, manifest_json, archive_sha256, package_relative_path, installed_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, version) DO UPDATE SET
manifest_json = excluded.manifest_json,
archive_sha256 = excluded.archive_sha256,
package_relative_path = excluded.package_relative_path,
installed_at = excluded.installed_at
`).run(
record.pluginId,
record.version,
manifestJson,
record.archiveSha256,
record.packageRelativePath,
now,
);
this.db.prepare(`
INSERT INTO plugin_runtime_state(plugin_id, plugin_version, status, updated_at)
VALUES (?, ?, 'stopped', ?)
ON CONFLICT(plugin_id, plugin_version) DO NOTHING
`).run(record.pluginId, record.version, now);
});
}
getVersion(pluginId, version) {
const row = this.db.prepare(`
SELECT plugin_id, version, manifest_json, archive_sha256,
package_relative_path, installed_at
FROM plugin_versions WHERE plugin_id = ? AND version = ?
`).get(pluginId, version);
return row ? this.#mapVersion(row) : null;
}
getActivePlugin(pluginId) {
const row = this.db.prepare(`
SELECT p.id, p.enabled, p.active_version, p.installed_at, p.updated_at,
v.manifest_json, v.archive_sha256, v.package_relative_path,
r.status, r.runtime_kind, r.last_error, r.quarantined_at
FROM plugins p
LEFT JOIN plugin_versions v
ON v.plugin_id = p.id AND v.version = p.active_version
LEFT JOIN plugin_runtime_state r ON r.plugin_id = p.id
AND r.plugin_version = p.active_version
WHERE p.id = ?
`).get(pluginId);
return row ? this.#mapPlugin(row) : null;
}
listPlugins() {
return this.db.prepare(`
SELECT p.id, p.enabled, p.active_version, p.installed_at, p.updated_at,
v.manifest_json, v.archive_sha256, v.package_relative_path,
r.status, r.runtime_kind, r.last_error, r.quarantined_at
FROM plugins p
LEFT JOIN plugin_versions v
ON v.plugin_id = p.id AND v.version = p.active_version
LEFT JOIN plugin_runtime_state r ON r.plugin_id = p.id
AND r.plugin_version = p.active_version
ORDER BY p.id COLLATE BINARY
`).all().map((row) => this.#mapPlugin(row));
}
/**
* Every installed package version (active and inactive), with parsed manifests.
* Used for ownership recovery when an older version still identifies a sync
* provider that the active manifest no longer contributes.
*/
listInstalledVersions() {
return this.db.prepare(`
SELECT plugin_id, version, manifest_json, archive_sha256,
package_relative_path, installed_at
FROM plugin_versions
ORDER BY plugin_id COLLATE BINARY, installed_at DESC, version COLLATE BINARY
`).all().map((row) => this.#mapVersion(row));
}
#mapVersion(row) {
return {
pluginId: row.plugin_id,
version: row.version,
manifest: parseJson(row.manifest_json, "manifest"),
archiveSha256: row.archive_sha256,
packageRelativePath: row.package_relative_path,
installedAt: Number(row.installed_at),
};
}
#mapPlugin(row) {
return {
id: row.id,
enabled: row.enabled === 1,
activeVersion: row.active_version ?? null,
installedAt: Number(row.installed_at),
updatedAt: Number(row.updated_at),
manifest: row.manifest_json ? parseJson(row.manifest_json, "manifest") : null,
archiveSha256: row.archive_sha256 ?? null,
packageRelativePath: row.package_relative_path ?? null,
runtime: {
status: row.status ?? "stopped",
kind: row.runtime_kind ?? null,
lastError: row.last_error ?? null,
quarantinedAt: row.quarantined_at == null ? null : Number(row.quarantined_at),
},
};
}
setEnabled(pluginId, enabled) {
const result = this.db.prepare("UPDATE plugins SET enabled = ?, updated_at = ? WHERE id = ?")
.run(enabled ? 1 : 0, this.clock(), pluginId);
if (Number(result.changes) !== 1) throw new Error(`Plugin is not installed: ${pluginId}`);
}
setActiveVersion(pluginId, version, options = {}) {
this.transaction(() => {
if (!this.getVersion(pluginId, version)) {
throw new Error(`Plugin version is not installed: ${pluginId}@${version}`);
}
const enabled = options.enabled === true ? 1 : 0;
const expectedActiveVersion = options.expectedActiveVersion;
const result = expectedActiveVersion === undefined
? this.db.prepare(`
UPDATE plugins
SET active_version = ?, enabled = ?, updated_at = ?
WHERE id = ?
`).run(version, enabled, this.clock(), pluginId)
: this.db.prepare(`
UPDATE plugins
SET active_version = ?, enabled = ?, updated_at = ?
WHERE id = ? AND active_version = ?
`).run(version, enabled, this.clock(), pluginId, expectedActiveVersion);
if (Number(result.changes) !== 1) {
throw new Error(`Plugin active version changed before it could be restored: ${pluginId}`);
}
});
return this.getActivePlugin(pluginId);
}
setRuntimeState(pluginId, status, options = {}) {
const activeVersion = this.db.prepare(
"SELECT active_version FROM plugins WHERE id = ?",
).get(pluginId)?.active_version;
const pluginVersion = options.pluginVersion ?? activeVersion;
if (!pluginVersion) throw new Error(`Plugin is not installed: ${pluginId}`);
if (!this.getVersion(pluginId, pluginVersion)) {
throw new Error(`Plugin version is not installed: ${pluginId}@${pluginVersion}`);
}
this.db.prepare(`
INSERT INTO plugin_runtime_state(
plugin_id, plugin_version, status, runtime_kind,
last_error, quarantined_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, plugin_version) DO UPDATE SET
status = excluded.status,
runtime_kind = excluded.runtime_kind,
last_error = excluded.last_error,
quarantined_at = COALESCE(excluded.quarantined_at, plugin_runtime_state.quarantined_at),
updated_at = excluded.updated_at
`).run(
pluginId,
pluginVersion,
status,
options.kind ?? null,
options.error == null ? null : String(options.error).slice(0, 4_096),
options.quarantinedAt ?? null,
this.clock(),
);
}
recordCrash(pluginId, pluginVersion, windowMs, threshold) {
const now = this.clock();
return this.transaction(() => {
if (!this.getVersion(pluginId, pluginVersion)) {
throw new Error(`Plugin version is not installed: ${pluginId}@${pluginVersion}`);
}
this.db.prepare(`
DELETE FROM plugin_crashes
WHERE plugin_id = ? AND plugin_version = ? AND crashed_at < ?
`).run(pluginId, pluginVersion, now - windowMs);
this.db.prepare(`
INSERT INTO plugin_crashes(plugin_id, plugin_version, crashed_at)
VALUES (?, ?, ?)
`).run(pluginId, pluginVersion, now);
const count = Number(this.db.prepare(
"SELECT COUNT(*) AS count FROM plugin_crashes WHERE plugin_id = ? AND plugin_version = ?",
).get(pluginId, pluginVersion)?.count ?? 0);
if (count >= threshold) {
this.db.prepare(`
UPDATE plugin_runtime_state
SET status = 'quarantined', quarantined_at = ?, updated_at = ?
WHERE plugin_id = ? AND plugin_version = ?
`).run(now, now, pluginId, pluginVersion);
}
return { count, quarantined: count >= threshold, quarantinedAt: count >= threshold ? now : null };
});
}
clearQuarantine(pluginId, pluginVersion) {
const now = this.clock();
this.transaction(() => {
const activeVersion = this.db.prepare(
"SELECT active_version FROM plugins WHERE id = ?",
).get(pluginId)?.active_version;
const targetVersion = pluginVersion ?? activeVersion;
if (!targetVersion) throw new Error(`Plugin is not installed: ${pluginId}`);
if (!this.getVersion(pluginId, targetVersion)) {
throw new Error(`Plugin version is not installed: ${pluginId}@${targetVersion}`);
}
this.db.prepare(`
DELETE FROM plugin_crashes WHERE plugin_id = ? AND plugin_version = ?
`).run(pluginId, targetVersion);
this.db.prepare(`
UPDATE plugin_runtime_state
SET status = 'stopped', last_error = NULL, quarantined_at = NULL, updated_at = ?
WHERE plugin_id = ? AND plugin_version = ?
`).run(now, pluginId, targetVersion);
});
}
removePlugin(pluginId) {
this.db.prepare("DELETE FROM plugins WHERE id = ?").run(pluginId);
}
getValue(pluginId, key) {
const row = this.db.prepare(
"SELECT value_json FROM plugin_kv WHERE plugin_id = ? AND key = ?",
).get(pluginId, key);
return row ? parseJson(row.value_json, "key/value entry") : undefined;
}
setValue(pluginId, key, value) {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin storage value must be JSON serializable");
this.db.prepare(`
INSERT INTO plugin_kv(plugin_id, key, value_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(plugin_id, key) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
`).run(pluginId, key, serialized, this.clock());
}
deleteValue(pluginId, key) {
this.db.prepare("DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?").run(pluginId, key);
}
listKeys(pluginId) {
return this.db.prepare(
"SELECT key FROM plugin_kv WHERE plugin_id = ? ORDER BY key COLLATE BINARY",
).all(pluginId).map((row) => row.key);
}
getSetting(pluginId, settingId, scope, scopeId) {
const row = this.db.prepare(`
SELECT value_json FROM plugin_settings
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
`).get(pluginId, settingId, scope, scopeId);
return row ? parseJson(row.value_json, "setting value") : undefined;
}
setSetting(pluginId, settingId, scope, scopeId, value, updatedAt = this.clock()) {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin setting value must be JSON serializable");
const stamp = Number.isFinite(Number(updatedAt)) ? Number(updatedAt) : this.clock();
this.db.prepare(`
INSERT INTO plugin_settings(plugin_id, setting_id, scope, scope_id, value_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, setting_id, scope, scope_id) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
`).run(pluginId, settingId, scope, scopeId, serialized, stamp);
}
deleteSetting(pluginId, settingId, scope, scopeId) {
this.db.prepare(`
DELETE FROM plugin_settings
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
`).run(pluginId, settingId, scope, scopeId);
}
listSettings(pluginId) {
return this.db.prepare(`
SELECT setting_id, scope, scope_id, value_json, updated_at
FROM plugin_settings WHERE plugin_id = ?
ORDER BY setting_id COLLATE BINARY, scope COLLATE BINARY, scope_id COLLATE BINARY
`).all(pluginId).map((row) => ({
settingId: row.setting_id,
scope: row.scope,
scopeId: row.scope_id,
value: parseJson(row.value_json, "setting value"),
updatedAt: Number(row.updated_at),
}));
}
listAllSettings() {
return this.db.prepare(`
SELECT plugin_id, setting_id, scope, scope_id, value_json, updated_at
FROM plugin_settings
ORDER BY plugin_id COLLATE BINARY, setting_id COLLATE BINARY, scope COLLATE BINARY, scope_id COLLATE BINARY
`).all().map((row) => ({
pluginId: row.plugin_id,
settingId: row.setting_id,
scope: row.scope,
scopeId: row.scope_id,
value: parseJson(row.value_json, "setting value"),
updatedAt: Number(row.updated_at),
}));
}
getSyncSidecar(pluginId, kind, key) {
const row = this.db.prepare(`
SELECT value_json, updated_at FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
`).get(pluginId, kind, key);
if (!row) return undefined;
return {
pluginId,
kind,
key,
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
};
}
setSyncSidecar(pluginId, kind, key, value, updatedAt = this.clock()) {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin sync sidecar value must be JSON serializable");
this.db.prepare(`
INSERT INTO plugin_sync_sidecars(plugin_id, kind, key, value_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, kind, key) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
`).run(pluginId, kind, key, serialized, updatedAt);
}
deleteSyncSidecar(pluginId, kind, key) {
this.db.prepare(`
DELETE FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
`).run(pluginId, kind, key);
}
listSyncSidecars(pluginId) {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
FROM plugin_sync_sidecars
WHERE plugin_id = ?
ORDER BY kind COLLATE BINARY, key COLLATE BINARY
`).all(pluginId).map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
}
listAllSyncSidecars() {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
FROM plugin_sync_sidecars
ORDER BY plugin_id COLLATE BINARY, kind COLLATE BINARY, key COLLATE BINARY
`).all().map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
}
replaceAllSyncSidecars(entries) {
this.transaction(() => {
this.db.prepare("DELETE FROM plugin_sync_sidecars").run();
const insert = this.db.prepare(`
INSERT INTO plugin_sync_sidecars(plugin_id, kind, key, value_json, updated_at)
VALUES (?, ?, ?, ?, ?)
`);
for (const entry of entries) {
const serialized = JSON.stringify(entry.value);
if (serialized === undefined) {
throw new TypeError("Plugin sync sidecar value must be JSON serializable");
}
insert.run(
entry.pluginId,
entry.kind,
entry.key,
serialized,
Number(entry.updatedAt) || this.clock(),
);
}
});
}
getViewState(pluginId, viewId, scopeId) {
const row = this.db.prepare(`
SELECT state_json FROM plugin_view_state
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
`).get(pluginId, viewId, scopeId);
return row ? parseJson(row.state_json, "view state") : undefined;
}
setViewState(pluginId, viewId, scopeId, state) {
const serialized = JSON.stringify(state);
if (serialized === undefined) throw new TypeError("Plugin view state must be JSON serializable");
this.db.prepare(`
INSERT INTO plugin_view_state(plugin_id, view_id, scope_id, state_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, view_id, scope_id) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`).run(pluginId, viewId, scopeId, serialized, this.clock());
}
deleteViewState(pluginId, viewId, scopeId) {
this.db.prepare(`
DELETE FROM plugin_view_state
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
`).run(pluginId, viewId, scopeId);
}
listPermissionGrants(pluginId) {
return this.db.prepare(`
SELECT plugin_id, permission, resource, resource_kind, declaration_hash, granted_at
FROM plugin_permission_grants
WHERE plugin_id = ?
ORDER BY permission COLLATE BINARY, resource COLLATE BINARY
`).all(pluginId).map((row) => ({
pluginId: row.plugin_id,
permission: row.permission,
resource: row.resource,
resourceKind: row.resource_kind,
declarationHash: row.declaration_hash,
grantedAt: Number(row.granted_at),
}));
}
upsertPermissionGrant(record) {
this.db.prepare(`
INSERT INTO plugin_permission_grants(
plugin_id, permission, resource, resource_kind, declaration_hash, granted_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, permission, resource) DO UPDATE SET
resource_kind = excluded.resource_kind,
declaration_hash = excluded.declaration_hash,
granted_at = excluded.granted_at
`).run(
record.pluginId,
record.permission,
record.resource,
record.resourceKind,
record.declarationHash,
this.clock(),
);
}
deletePermissionGrant(pluginId, permission, resource) {
this.db.prepare(`
DELETE FROM plugin_permission_grants
WHERE plugin_id = ? AND permission = ? AND resource = ?
`).run(pluginId, permission, resource);
}
deleteAllPermissionGrants(pluginId) {
this.db.prepare("DELETE FROM plugin_permission_grants WHERE plugin_id = ?").run(pluginId);
}
getSecretByKey(pluginId, key) {
const row = this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE plugin_id = ? AND key = ?
`).get(pluginId, key);
return row ? this.#mapSecret(row) : null;
}
getSecretByRef(pluginId, secretRef) {
const row = this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE plugin_id = ? AND secret_ref = ?
`).get(pluginId, secretRef);
return row ? this.#mapSecret(row) : null;
}
#mapSecret(row) {
return {
pluginId: row.plugin_id,
key: row.key,
secretRef: row.secret_ref,
ciphertext: Buffer.from(row.ciphertext),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
upsertSecret(record) {
const now = this.clock();
this.db.prepare(`
INSERT INTO plugin_secrets(
plugin_id, key, secret_ref, ciphertext, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, key) DO UPDATE SET
secret_ref = excluded.secret_ref,
ciphertext = excluded.ciphertext,
updated_at = excluded.updated_at
`).run(
record.pluginId,
record.key,
record.secretRef,
record.ciphertext,
now,
now,
);
}
deleteSecret(pluginId, key) {
this.db.prepare("DELETE FROM plugin_secrets WHERE plugin_id = ? AND key = ?")
.run(pluginId, key);
}
/** Delete all secrets whose key equals prefix or starts with `${prefix}:`. */
deleteSecretsByKeyPrefix(pluginId, prefix) {
const result = this.db.prepare(`
DELETE FROM plugin_secrets
WHERE plugin_id = ?
AND (key = ? OR key LIKE ? ESCAPE '\\')
`).run(
pluginId,
prefix,
`${String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`)}:%`,
);
return Number(result?.changes) || 0;
}
/** Find secret rows by exact key across all plugins (sync provider cleanup). */
findSecretsByKey(key) {
return this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE key = ?
`).all(key).map((row) => this.#mapSecret(row));
}
upsertSyncProviderBinding(providerId, pluginId) {
const now = this.clock();
this.db.prepare(`
INSERT INTO plugin_sync_provider_bindings(provider_id, plugin_id, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(provider_id) DO UPDATE SET
plugin_id = excluded.plugin_id,
updated_at = excluded.updated_at
`).run(providerId, pluginId, now, now);
}
getSyncProviderBinding(providerId) {
const row = this.db.prepare(`
SELECT provider_id, plugin_id, created_at, updated_at
FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).get(providerId);
if (!row) return null;
return {
providerId: row.provider_id,
pluginId: row.plugin_id,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
deleteSyncProviderBinding(providerId) {
const result = this.db.prepare(`
DELETE FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).run(providerId);
return Number(result?.changes) || 0;
}
/** Distinct plugin ids that own secrets with key === prefix or key LIKE prefix:%. */
listPluginIdsWithSecretKeyPrefix(prefix) {
const escaped = String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`);
return this.db.prepare(`
SELECT DISTINCT plugin_id
FROM plugin_secrets
WHERE key = ? OR key LIKE ? ESCAPE '\\'
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
}
/**
* Promote leftover sync-provider-map:* secret rows into the host-owned binding
* table. Idempotent.
*
* Host map keys under this prefix are migration markers from an intermediate
* build (pluginId-namespaced provider ids only). After a successful promote,
* those map rows are deleted so constructor-time backfill cannot resurrect a
* binding after the user disconnects/unbinds. Multiple plugins may hold map
* rows for the same provider (parent + nested id): group by provider, never
* overwrite an existing host binding (including empty-plugin_id unbind
* tombstones). Among candidates, only plugins that still hold sync-credential*
* secrets are eligible. Auto-bind only when exactly one distinct
* credential-backed candidate exists; if several credential-backed plugins
* map the same provider (e.g. com.example and com.example.sync both hold
* credentials), skip entirely — longest pluginId is not a reliable owner
* signal (the shorter parent can be the legitimate owner). Map-only leftovers
* never invent ownership (validation only requires
* providerId.startsWith(pluginId + ".")).
*/
backfillSyncProviderBindingsFromLegacySecrets() {
const legacyPrefix = "sync-provider-map:";
const escaped = legacyPrefix.replace(/[%_\\]/g, (ch) => `\\${ch}`);
const legacyRows = this.db.prepare(`
SELECT plugin_id, key
FROM plugin_secrets
WHERE key LIKE ? ESCAPE '\\'
`).all(`${escaped}%`);
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
const byProvider = new Map();
for (const row of legacyRows) {
const pluginId = row.plugin_id;
const key = row.key;
if (typeof pluginId !== "string" || typeof key !== "string") continue;
if (!key.startsWith(legacyPrefix)) continue;
const providerId = key.slice(legacyPrefix.length);
if (!providerId || providerId.includes("\0")) continue;
if (!providerId.startsWith(`${pluginId}.`)) {
continue;
}
const list = byProvider.get(providerId) || [];
list.push({ pluginId, key });
byProvider.set(providerId, list);
}
// Credential presence is the signal for real ownership. A longer nested map
// row without credentials must not steal bind from a parent that still owns
// sync-credential* secrets for the provider namespace.
const credentialOwners = new Set(
this.listPluginIdsWithSecretKeyPrefix("sync-credential")
.filter((id) => typeof id === "string" && id.length > 0),
);
// Same single-provider guard as live backfill: one shared sync-credential*
// row must not seed bindings for every historical map provider under that
// plugin (disconnecting a stale provider would wipe the live credentials).
// Count both legacy map rows and installed-manifest sync providers so a
// stale map cannot promote when the live manifest also declares another
// provider under the same credential owner (Codex P2 on 8ae60205).
/** @type {Map<string, Set<string>>} */
const providersByCredentialOwner = new Map();
for (const [providerId, candidates] of byProvider) {
for (const c of candidates) {
if (!credentialOwners.has(c.pluginId)) continue;
const set = providersByCredentialOwner.get(c.pluginId) || new Set();
set.add(providerId);
providersByCredentialOwner.set(c.pluginId, set);
}
}
try {
const versions = typeof this.listInstalledVersions === "function"
? this.listInstalledVersions()
: [];
for (const version of versions) {
const pluginId = version?.pluginId;
if (typeof pluginId !== "string" || !credentialOwners.has(pluginId)) continue;
for (const provider of version.manifest?.contributes?.providers ?? []) {
if (provider?.kind !== "sync") continue;
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
const set = providersByCredentialOwner.get(pluginId) || new Set();
set.add(provider.id);
providersByCredentialOwner.set(pluginId, set);
}
}
} catch {
/* ignore catalog probe failures */
}
let promoted = 0;
for (const [providerId, candidates] of byProvider) {
// Any host row means a prior decision: active bind or explicit unbind
// tombstone (empty plugin_id). Never resurrect from leftover map secrets.
if (this.getSyncProviderBinding(providerId)) {
continue;
}
// Only credential-backed map candidates may win; map-only leftovers stay
// unbound rather than inventing ownership from namespace length alone.
// Multiple distinct credential-backed candidates is ambiguous (parent vs
// nested can both hold credentials for the same provider namespace) — skip
// entirely rather than picking longest pluginId.
const uniqueCredentialOwners = [
...new Set(
candidates
.filter((c) => credentialOwners.has(c.pluginId))
.map((c) => c.pluginId),
),
];
if (uniqueCredentialOwners.length !== 1) {
continue;
}
const chosenOwner = uniqueCredentialOwners[0];
if ((providersByCredentialOwner.get(chosenOwner)?.size ?? 0) !== 1) {
continue;
}
// Constructor-time map promote runs before hostService's installed-manifest
// conflict check. Skip when another installed package would also claim this
// provider (live nested plugin without credentials yet) so we do not bind
// a legacy parent that later disconnect cannot clear (Codex P2 on 5bc1b60d).
let hasManifestConflict = false;
try {
const versions = typeof this.listInstalledVersions === "function"
? this.listInstalledVersions()
: [];
for (const version of versions) {
const pluginId = version?.pluginId;
if (typeof pluginId !== "string" || pluginId === chosenOwner) continue;
for (const provider of version.manifest?.contributes?.providers ?? []) {
if (provider?.kind !== "sync") continue;
if (provider.id === providerId) {
hasManifestConflict = true;
break;
}
}
if (hasManifestConflict) break;
}
} catch {
/* ignore catalog probe failures */
}
if (hasManifestConflict) {
continue;
}
this.upsertSyncProviderBinding(providerId, chosenOwner);
// Consume legacy map markers for this provider so a later unbind + reopen
// cannot re-promote from leftover secrets. Only delete namespaced map keys
// we accepted as candidates (not arbitrary plugin secrets). Consume all
// candidate maps (including map-only losers) once a credential-backed
// owner is bound, so orphans cannot re-run later.
for (const candidate of candidates) {
this.deleteSecret(candidate.pluginId, candidate.key);
}
promoted += 1;
}
return promoted;
}
/**
* Credential-row prefix inference is intentionally not used.
*
* A shorter parent plugin id (e.g. com.example with sync-credential rows) can
* namespace-prefix a provider owned by a different/removed plugin
* (com.example.backup.sync). Falling back to that parent would let
* syncDeleteSecrets wipe unrelated credentials. Ownership must come from the
* host binding table (including rows promoted by
* backfillSyncProviderBindingsFromLegacySecrets) or a live contribution.
*/
inferPluginIdForSyncProvider(_providerId) {
return undefined;
}
recordSecurityAudit(pluginId, event, details) {
let detailsJson = JSON.stringify(details ?? {});
const originalBytes = Buffer.byteLength(detailsJson);
if (originalBytes > MAX_SECURITY_AUDIT_DETAILS_BYTES) {
detailsJson = JSON.stringify({
truncated: true,
originalBytes,
sha256: createHash("sha256").update(detailsJson).digest("hex"),
});
}
this.transaction(() => {
this.db.prepare(`
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
VALUES (?, ?, ?, ?)
`).run(pluginId, event, detailsJson, this.clock());
this.db.prepare(`
DELETE FROM plugin_security_audit
WHERE plugin_id = ? AND id NOT IN (
SELECT id FROM plugin_security_audit
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
)
`).run(pluginId, pluginId);
});
}
listSecurityAudit(pluginId, limit = 100) {
const requestedLimit = Number(limit);
const normalizedLimit = Math.max(1, Math.min(
1_000,
Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : 100,
));
return this.db.prepare(`
SELECT event, details_json, created_at
FROM plugin_security_audit
WHERE plugin_id = ? ORDER BY id DESC LIMIT ?
`).all(pluginId, normalizedLimit).map((row) => ({
event: row.event,
details: parseJson(row.details_json, "security audit entry"),
createdAt: Number(row.created_at),
}));
}
close() {
this.db.close();
}
}
module.exports = {
MAX_SECURITY_AUDIT_DETAILS_BYTES,
MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN,
PluginDatabase,
REQUIRED_SCHEMA_COLUMNS,
SCHEMA_VERSION,
};