Files
Netcatty/electron/plugins/hostService.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

398 lines
15 KiB
JavaScript

"use strict";
const path = require("node:path");
const { PLUGIN_API_VERSION } = require("./constants.cjs");
const { PluginDatabase } = require("./database.cjs");
const { PluginCompanionSupervisor } = require("./companionSupervisor.cjs");
const { PluginCredentialBroker, assertLeaseParams } = require("./credentialBroker.cjs");
const { PluginCredentialCatalog } = require("./credentialCatalog.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
const { PluginContributionIconService } = require("./contributionIconService.cjs");
const { PluginFilesystemBroker } = require("./filesystemBroker.cjs");
const { createDefaultPluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const {
createDefaultPluginModuleResources,
createUtilityModuleMappings,
normalizePluginModuleResources,
} = require("./moduleResources.cjs");
const { PackageStore } = require("./packageStore.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { PluginManager } = require("./pluginManager.cjs");
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
const { PluginViewHost } = require("./pluginViewHost.cjs");
const {
RuntimeSupervisor,
assertStorageParams,
resolveDefaultRuntimeKind,
} = require("./runtimeSupervisor.cjs");
const { PluginQuotaManager } = require("./quotaManager.cjs");
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
const { PluginSecretStore } = require("./secretStore.cjs");
const { SecretLeaseStore } = require("./secretLease.cjs");
const { PluginTerminalProviderService } = require("./terminalProviderService.cjs");
const { PluginTerminalDataPipelineService } = require("./terminalDataPipelineService.cjs");
const { PluginExtensionProviderService } = require("./extensionProviderService.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
function getElectronProcessMetrics(app, pid) {
const metric = app.getAppMetrics?.().find((candidate) => candidate.pid === pid);
if (!metric) return null;
return {
cpuPercent: Number(metric.cpu?.percentCPUUsage ?? 0),
memoryBytes: Number(metric.memory?.workingSetSize ?? 0) * 1024,
};
}
/**
* Full installed-manifest catalog of sync providers (active + inactive versions).
* Shared by startup and enable-time backfill so ambiguity checks always see
* cross-plugin / nested-namespace claims.
* @param {{ listInstalledVersions?: () => Array<{ pluginId?: string, manifest?: { contributes?: { providers?: Array<{ kind?: string, id?: string }> } } }> }} database
* @returns {Array<{ pluginId: string, provider: { id: string } }>}
*/
function collectInstalledSyncProviderCatalog(database) {
const installedSyncProviders = [];
const seen = new Set();
const versions = typeof database.listInstalledVersions === "function"
? database.listInstalledVersions()
: [];
for (const version of versions) {
const pluginId = version?.pluginId;
if (typeof pluginId !== "string" || !version.manifest) 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 key = `${pluginId}\0${provider.id}`;
if (seen.has(key)) continue;
seen.add(key);
installedSyncProviders.push({
pluginId,
provider: { id: provider.id },
});
}
}
return installedSyncProviders;
}
function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const database = new PluginDatabase(paths.database);
try {
const packageStore = new PackageStore({
paths,
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
});
const contributionIconService = new PluginContributionIconService({
database,
packageStore,
BrowserWindow: options.electron.BrowserWindow,
rasterizeIcon: options.rasterizeContributionIcon,
});
const moduleResources = options.moduleResources
? normalizePluginModuleResources(options.moduleResources)
: createDefaultPluginModuleResources(appRoot, options.additionalModuleResources ?? []);
const protocol = new PluginProtocol({
runtimeDirectory,
moduleResources,
});
const permissionEngine = new PluginPermissionEngine({
database,
requestDecision: options.requestPermissionDecision,
});
const quotaManager = new PluginQuotaManager({
getProcessMetrics: options.getProcessMetrics
?? ((pid) => getElectronProcessMetrics(options.app, pid)),
quotas: options.quotas,
});
const secretStore = new PluginSecretStore({
database,
safeStorage: options.safeStorage ?? options.electron.safeStorage,
});
const leaseStore = new SecretLeaseStore({ secretStore });
const credentialResolver = options.credentialResolver ?? new PluginCredentialCatalog({
safeStorage: options.safeStorage ?? options.electron.safeStorage,
});
const credentialBroker = new PluginCredentialBroker({
secretStore,
leaseStore,
credentialResolver,
});
const filesystemBroker = new PluginFilesystemBroker({
quotaManager,
openDirectoryHandle: options.openPluginDirectoryHandle,
});
const networkBroker = new PluginNetworkBroker({
fetch: options.fetch ?? (options.electron.net?.fetch
? (...args) => options.electron.net.fetch(...args)
: undefined),
permissionEngine,
quotaManager,
});
let runtimeSupervisor;
const companionSupervisor = new PluginCompanionSupervisor({
paths,
quotaManager,
spawn: options.spawnCompanion,
onContainmentFailure: (identity, error) => (
runtimeSupervisor?.enforcePolicyViolation(identity, error)
),
});
const rpcRegistry = options.rpcRegistry ?? createDefaultPluginHostRpcRegistry({
assertStorageParams,
database,
});
const runtimeAccess = Object.freeze({
start: (...args) => runtimeSupervisor.start(...args),
request: (...args) => runtimeSupervisor.request(...args),
notify: (...args) => runtimeSupervisor.notify(...args),
getRuntimeIdentity: (...args) => runtimeSupervisor.getRuntimeIdentity(...args),
});
const contributionService = new PluginContributionService({
database,
runtimeSupervisor: runtimeAccess,
secretStore,
getLocale: options.getLocale,
});
contributionService.registerRpcCapabilities(rpcRegistry);
registerSecurePluginCapabilities(rpcRegistry, {
assertLeaseParams,
companionSupervisor,
credentialBroker,
filesystemBroker,
networkBroker,
permissionEngine,
quotaManager,
secretStore,
});
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
if (configuredRegistry && typeof configuredRegistry.then === "function") {
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
}
const requestedRuntimeResolver = options.resolveRuntimeKind ?? resolveDefaultRuntimeKind;
const resolveRuntimeKind = async (context) => {
const kind = await requestedRuntimeResolver(context);
if (
(kind !== "browser" && kind !== "utility")
|| !context.availableKinds.includes(kind)
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
await permissionEngine.authorizeRequired(context.plugin, {
securityPrincipal: context.securityPrincipal,
signal: context.signal,
skipPermissions: ["runtime.advanced"],
});
if (kind === "utility") {
await permissionEngine.authorize({
pluginId: context.plugin.id,
pluginVersion: context.plugin.activeVersion,
runtimeId: null,
manifest: context.plugin.manifest,
securityPrincipal: context.securityPrincipal,
signal: context.signal,
}, {
permission: "runtime.advanced",
resources: ["*"],
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
});
}
return kind;
};
const runtimeMessageGuard = (identity, message) => {
quotaManager.guardMessage(identity, message);
return options.runtimeMessageGuard?.(identity, message);
};
runtimeSupervisor = new RuntimeSupervisor({
electron: options.electron,
database,
packageStore,
protocol,
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
runtimeDirectory,
appRoot,
rpcRegistry,
resolveRuntimeKind,
resolveSecurityPrincipal: options.resolveSecurityPrincipal,
runtimeMessageGuard,
getInitialEnvironment: () => contributionService.getEnvironment(),
runtimeResourceMonitor: quotaManager,
runtimeCleanup: async (identity) => {
leaseStore.revokeRuntime(identity.runtimeId);
await companionSupervisor.releaseRuntime(identity.runtimeId);
},
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
});
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
const terminalProviderService = new PluginTerminalProviderService({
contributionService,
permissionEngine,
runtimeSupervisor,
});
const extensionProviderService = new PluginExtensionProviderService({
contributionService,
leaseStore,
permissionEngine,
rpcRegistry,
runtimeSupervisor,
});
const syncSidecarService = new PluginSyncSidecarService({
database,
contributionService,
});
// Replay retained cloud sidecars into plugin_settings before the plugin
// runtime starts so onStartupFinished sees hydrated values.
const originalOnPluginEnabled = contributionService.onPluginEnabled.bind(contributionService);
contributionService.onPluginEnabled = async (pluginId) => {
try {
await syncSidecarService.hydrateInstalledPluginSettings(pluginId);
} catch {
// Hydration is best-effort; enable must still succeed.
}
const enabled = await originalOnPluginEnabled(pluginId);
try {
// Use the same installed-manifest catalog as startup so cross-plugin /
// inactive-version ambiguity checks still apply when only this plugin
// was just enabled (Codex P2 on d3a9b94c).
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
secretStore.backfillSyncProviderBindingsFromLiveProviders(
collectInstalledSyncProviderCatalog(database),
);
}
} catch {
// Best-effort; enable must still succeed.
}
return enabled;
};
// Live-provider seed must wait until PackageStore.recover() has restored
// missing plugin_versions. Construction-time seed can bind a stale owner
// against an incomplete catalog; post-recovery seed then skips it as
// existing (Codex P2 on f2b1b5d8). syncDeleteSecrets already awaits
// resolveManager() → package initialize, so cleanup sees the post-recovery
// seed without racing an early incomplete bind.
const seedSyncBindings = () => {
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders !== "function") return;
secretStore.backfillSyncProviderBindingsFromLiveProviders(
collectInstalledSyncProviderCatalog(database),
);
};
// PackageStore.recover() repopulates plugin_versions; only then promote
// legacy maps and seed live bindings (Codex P2 on dd7aad70 / f2b1b5d8).
const originalPackageInitialize = packageStore.initialize.bind(packageStore);
packageStore.initialize = async () => {
const result = await originalPackageInitialize();
try {
if (typeof database.backfillSyncProviderBindingsFromLegacySecrets === "function") {
database.backfillSyncProviderBindingsFromLegacySecrets();
}
seedSyncBindings();
} catch {
// Best-effort
}
return result;
};
// Startup activation goes through contributionService.initialize() → #startPlugin
// without onPluginEnabled. Hydrate every enabled plugin before that path runs.
const originalInitialize = contributionService.initialize.bind(contributionService);
contributionService.initialize = async () => {
for (const plugin of database.listPlugins()) {
if (!plugin?.enabled || typeof plugin.id !== "string") continue;
try {
await syncSidecarService.hydrateInstalledPluginSettings(plugin.id);
} catch {
// Best-effort; startup must continue.
}
}
// Re-seed after any package catalog changes during init.
try {
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
secretStore.backfillSyncProviderBindingsFromLiveProviders(
collectInstalledSyncProviderCatalog(database),
);
}
} catch {
// Best-effort; startup must continue.
}
return originalInitialize();
};
const terminalDataPipelineService = options.electron.MessageChannelMain
? new PluginTerminalDataPipelineService({
contributionService,
permissionEngine,
runtimeSupervisor,
MessageChannelMain: options.electron.MessageChannelMain,
requestSelection: options.requestTerminalInterceptorSelection,
showWarning: options.showTerminalInterceptorWarning,
})
: null;
quotaManager.setViolationHandler((identity, error) => (
runtimeSupervisor.enforcePolicyViolation(identity, error)
));
const viewHost = options.electron.WebContentsView && options.electron.ipcMain
? new PluginViewHost({
electron: options.electron,
protocol,
packageStore,
database,
contributionService,
})
: null;
const manager = new PluginManager({
database,
packageStore,
runtimeSupervisor,
contributionService,
beforeClose: async () => {
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
await viewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionEngine.shutdown();
quotaManager.shutdown();
},
});
return {
companionSupervisor,
contributionIconService,
contributionService,
credentialBroker,
credentialResolver,
database,
filesystemBroker,
extensionProviderService,
syncSidecarService,
leaseStore,
manager,
moduleResources,
packageStore,
paths,
protocol,
networkBroker,
permissionEngine,
quotaManager,
rpcRegistry,
runtimeSupervisor,
secretStore,
terminalProviderService,
terminalDataPipelineService,
viewHost,
};
} catch (error) {
database.close();
throw error;
}
}
module.exports = { createPluginHostService, getElectronProcessMetrics };