mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 09:24:24 +00:00
* 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>
398 lines
15 KiB
JavaScript
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 };
|