fix(plugins): harden sidecar empty-vault, hydrate LWW, and credential seals

Close remaining Codex/review gaps for PR8: keep empty sidecar shells from
bypassing upload guards, skip startup hydrate when local settings are newer,
seal/validate durable plugin credential refs, and cover schema 1→2 migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bincxz
2026-08-05 12:17:35 +08:00
parent 0cbdcf75dd
commit 8ae52c5458
20 changed files with 624 additions and 61 deletions
+16 -6
View File
@@ -6,16 +6,17 @@
*/
import type { PluginSyncSidecarBundle } from '../domain/pluginSyncSidecar';
import { SYNC_STORAGE_KEYS } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
/** Ordinary upload fallback when collect cannot reach the host. */
const LAST_KNOWN_SIDECARS_KEY = 'netcatty_plugin_sidecars_last_known_v1';
const LAST_KNOWN_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN;
/**
* Remote apply that could not reach the host DB. Distinct from last-known so
* a later collect does not re-apply a stale post-collect snapshot over newer
* local plugin settings.
*/
const PENDING_REMOTE_SIDECARS_KEY = 'netcatty_plugin_sidecars_pending_remote_v1';
const PENDING_REMOTE_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE;
const HOST_UNAVAILABLE_MARKER = 'PLUGIN_SIDECAR_HOST_UNAVAILABLE';
export class PluginSidecarHostUnavailableError extends Error {
@@ -223,12 +224,21 @@ export async function applyPluginSyncSidecarsFromHost(
writeLastKnownSidecars({ version: 1, entries: collected.entries });
return;
}
// Collect returned a non-authoritative shape — leave prior last-known so
// a later offline upload cannot drop preserved missing-plugin rows.
// Apply already committed; collect shape was non-authoritative. Align
// last-known with the applied remote so offline uploads cannot resurrect
// pre-apply rows, then return successfully — throwing here would leave
// the interrupted-vault-apply sentinel after vault/settings already landed.
writeLastKnownSidecars(normalized);
return;
} catch {
// Apply already committed on the host. Do not replace last-known with the
// unmerged remote bundle (that would discard preserved local rows).
// Apply already committed. Keep last-known aligned with the applied remote.
// Trade-off: missing-plugin rows preserved in the host DB but omitted from
// the remote snapshot are not reflected until a later successful collect.
try {
writeLastKnownSidecars(normalized);
} catch {
// last-known write failure is secondary; apply already succeeded.
}
return;
}
}
+60
View File
@@ -52,6 +52,8 @@ const {
SYNCABLE_SETTING_STORAGE_KEYS,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
@@ -839,6 +841,7 @@ test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful"
}),
true,
);
// Empty bundle alone must not bypass the empty-vault upload guard.
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
@@ -851,6 +854,63 @@ test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful"
}),
false,
);
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
}),
false,
);
});
test("hasMeaningfulCloudSyncData treats empty sidecars as meaningful only after a prior non-empty last-known", () => {
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
try {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "k",
value: 1,
updatedAt: 1,
}],
});
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
true,
);
} finally {
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
}
});
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
assert.equal(
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
);
assert.equal(
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
);
});
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
+28 -8
View File
@@ -22,6 +22,7 @@ import type {
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
type SyncPayload,
} from '../domain/sync';
@@ -122,17 +123,36 @@ export interface SyncableVaultData {
}
/**
* Returns true when the payload contains any meaningful user data worth
* protecting or syncing.
* Returns true when the payload carries plugin sidecar data worth syncing.
* Non-empty entries always count. An explicit empty bundle is only meaningful
* when last-known previously held entries (a real reset to push) — otherwise
* ordinary `{entries:[]}` collects from an empty plugin host would bypass the
* empty-vault upload guard.
*/
function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
return Boolean(
payload.pluginSidecars
&& Array.isArray(payload.pluginSidecars.entries)
&& payload.pluginSidecars.entries.length > 0,
);
if (
!payload.pluginSidecars
|| !Array.isArray(payload.pluginSidecars.entries)
) {
return false;
}
if (payload.pluginSidecars.entries.length > 0) return true;
if (!Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) return false;
try {
const lastKnown = localStorageAdapter.read<{ entries?: unknown[] }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
return Array.isArray(lastKnown?.entries) && lastKnown.entries.length > 0;
} catch {
return false;
}
}
/**
* Returns true when a payload contains entities, settings, or meaningful
* plugin sidecars worth syncing / protecting with empty-vault guards.
* Local-only trust records are intentionally ignored by the cloud variant.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
@@ -997,7 +1017,7 @@ export function buildLocalVaultPayload(
// (sync). Callers that can await should use buildLocalVaultPayloadAsync.
try {
const raw = localStorageAdapter.read<{ version?: number; entries?: unknown }>(
'netcatty_plugin_sidecars_last_known_v1',
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
if (raw && Array.isArray(raw.entries)) {
return withPluginSyncSidecars(base, {
+31
View File
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeDurablePluginSyncCredentialRef } from './sync.ts';
test('normalizeDurablePluginSyncCredentialRef accepts durable refs only', () => {
assert.deepEqual(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 's1' }),
{ kind: 'secret', id: 's1' },
);
assert.deepEqual(
normalizeDurablePluginSyncCredentialRef({ kind: 'credential', id: 'c1', key: 'k' }),
{ kind: 'credential', id: 'c1', key: 'k' },
);
assert.equal(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret-lease', id: 'l1' }),
undefined,
);
assert.equal(
normalizeDurablePluginSyncCredentialRef([{ kind: 'secret', id: 's1' }]),
undefined,
);
assert.equal(normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: '' }), undefined);
assert.equal(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 'x', key: 1 }),
undefined,
);
assert.equal(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 'x'.repeat(513) }),
undefined,
);
});
+33
View File
@@ -222,4 +222,37 @@ describe('pluginSyncSidecar', () => {
assert.equal(merged.length, 1);
assert.equal(merged[0].value, 'light');
});
it('preferCloud keeps local-only settings and baseline orphans', () => {
const localOnly: PluginSyncSidecarEntry = {
pluginId,
kind: 'settings',
key: `${pluginId}.theme\0application\0application`,
value: 'local-new',
updatedAt: 50,
};
const baseline: PluginSyncSidecarEntry = {
pluginId,
kind: 'account_baseline',
key: 'account',
value: { id: 'local-acct' },
updatedAt: 40,
};
const remoteBaseline: PluginSyncSidecarEntry = {
pluginId,
kind: 'crdt_baseline',
key: 'crdt',
value: { clock: 1 },
updatedAt: 30,
};
const merged = mergePluginSyncSidecarsThreeWay({
base: [],
local: [localOnly, baseline],
remote: [remoteBaseline],
strategy: 'preferCloud',
});
assert.equal(merged.some((item) => item.key === localOnly.key), true);
assert.equal(merged.some((item) => item.kind === 'account_baseline'), true);
assert.equal(merged.some((item) => item.kind === 'crdt_baseline'), true);
});
});
+4 -3
View File
@@ -195,8 +195,8 @@ export function mergePluginSyncSidecarsThreeWay(params: {
if (!kind) continue;
if (kind !== 'settings') {
// Baselines: LWW between local and remote; preserve orphans.
// preferCloud / preferLocal still apply when both sides present.
// Baselines: LWW between local and remote; always preserve local orphans
// (device-local CRDT/account merge bases must survive preferCloud).
if (l && r) {
if (strategy === 'preferCloud') out.push(r);
else if (strategy === 'preferLocal') out.push(l);
@@ -221,7 +221,8 @@ export function mergePluginSyncSidecarsThreeWay(params: {
}
// both deleted
if (b && !l && !r) continue;
// only local / only remote additions
// only local / only remote additions — keep local-only so preferCloud does
// not delete unsynced local settings on the subsequent local apply path.
if (!b && l && !r) {
out.push(l);
continue;
+53 -2
View File
@@ -162,12 +162,56 @@ export interface ProviderAccount {
/**
* Cloud provider connection state
*/
/**
* Opaque host-owned sync credential reference (SecretRef / CredentialRef shape).
* Never stores plaintext secrets — only the reference the plugin connect path needs.
* Secret leases are one-shot and must not be persisted for reconnect.
*/
export interface PluginSyncCredentialRef {
kind: 'secret' | 'credential';
id: string;
key?: string;
}
/** Reasonable upper bounds for durable opaque ref strings persisted at rest. */
const MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS = 512;
const MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS = 256;
/**
* Normalize a value into a durable PluginSyncCredentialRef for reconnect
* persistence. Rejects arrays, leases, and oversized / malformed shapes.
*/
export function normalizeDurablePluginSyncCredentialRef(
value: unknown,
): PluginSyncCredentialRef | undefined {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const kind = record.kind;
const id = record.id;
if (kind !== 'secret' && kind !== 'credential') return undefined;
if (typeof id !== 'string' || id.length < 1 || id.length > MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS) {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(record, 'key') || record.key === undefined) {
return { kind, id };
}
const key = record.key;
if (typeof key !== 'string' || key.length < 1 || key.length > MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS) {
return undefined;
}
return { kind, id, key };
}
export interface ProviderConnection {
provider: CloudProvider;
status: ProviderConnectionStatus;
account?: ProviderAccount;
tokens?: OAuthTokens;
config?: WebDAVConfig | S3Config;
/** Plugin sync providers: persisted SyncConnectPayload.credential for reconnect. */
credential?: PluginSyncCredentialRef;
lastSync?: number; // Unix timestamp
lastSyncVersion?: number;
resourceId?: string; // gistId / fileId / itemId
@@ -181,10 +225,11 @@ export interface ProviderConnection {
* existence for `config`; do not use truthiness (`||` / `Boolean`).
*/
export const hasProviderConnectionData = (
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
connection: Pick<ProviderConnection, 'tokens' | 'config' | 'credential'>,
): boolean =>
connection.tokens != null
|| Object.prototype.hasOwnProperty.call(connection, 'config');
|| Object.prototype.hasOwnProperty.call(connection, 'config')
|| connection.credential != null;
export const isProviderReadyForSync = (
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
@@ -654,6 +699,12 @@ export const SYNC_STORAGE_KEYS = {
PROVIDER_SMB: 'netcatty_provider_smb_v1',
/** Registry of connected namespaced plugin sync provider IDs. */
PLUGIN_CLOUD_PROVIDERS: 'netcatty_plugin_cloud_providers_v1',
/** Contribution-available plugin sync provider IDs (live catalog membership). */
AVAILABLE_PLUGIN_SYNC_PROVIDERS: 'netcatty_available_plugin_sync_providers_v1',
/** Last successful sidecar collect (upload fallback when host is offline). */
PLUGIN_SIDECARS_LAST_KNOWN: 'netcatty_plugin_sidecars_last_known_v1',
/** Remote sidecar apply queued while the plugin host was unavailable. */
PLUGIN_SIDECARS_PENDING_REMOTE: 'netcatty_plugin_sidecars_pending_remote_v1',
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
CONVERGENT_REPLICA: 'netcatty_convergent_sync_replica_v2',
+4 -3
View File
@@ -5,7 +5,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
@@ -164,12 +164,12 @@ class PluginDatabase {
);
CREATE INDEX plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 1;
PRAGMA user_version = 2;
`);
});
} else if (version === 1) {
// Pre-sidecar schema-1 databases only created the original tables.
// Ensure the non-cascade sidecar table exists in place (AGENTS.md storage migrations).
// Migrate in place to schema 2 with the non-cascade sidecar table.
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
@@ -182,6 +182,7 @@ class PluginDatabase {
);
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
`);
});
}
+115
View File
@@ -59,6 +59,121 @@ test("obsolete unpublished v1 layouts fail with an explicit reset instruction",
);
});
test("complete schema-1 databases migrate in place to schema 2 with sidecar table", (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v1-migrate-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "plugins.sqlite");
const v1 = new DatabaseSync(file);
// Full pre-sidecar schema-1 layout (all tables except plugin_sync_sidecars).
v1.exec(`
PRAGMA foreign_keys = ON;
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 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 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 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 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
);
PRAGMA user_version = 1;
`);
v1.prepare(
"INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at) VALUES (?, 1, ?, 1, 1)",
).run("com.example.v1", "1.0.0");
v1.close();
const database = new PluginDatabase(file);
assert.equal(database.db.prepare("PRAGMA user_version").get().user_version, SCHEMA_VERSION);
assert.deepEqual(
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
["plugin_id", "kind", "key", "value_json", "updated_at"],
);
// Existing rows survive the in-place migration.
assert.equal(database.db.prepare("SELECT id FROM plugins").get().id, "com.example.v1");
database.setSyncSidecar("com.example.v1", "settings", "theme\0application\0application", "dark", 2);
assert.equal(
database.getSyncSidecar("com.example.v1", "settings", "theme\0application\0application")?.value,
"dark",
);
database.close();
});
test("initial schema scopes runtime and crash state to immutable plugin versions", (context) => {
const database = createDatabase(context);
assert.deepEqual(
+14
View File
@@ -229,6 +229,20 @@ function createPluginHostService(options) {
}
return originalOnPluginEnabled(pluginId);
};
// 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.
}
}
return originalInitialize();
};
const terminalDataPipelineService = options.electron.MessageChannelMain
? new PluginTerminalDataPipelineService({
contributionService,
@@ -28,6 +28,31 @@ function parseSettingsSidecarKey(key) {
return { settingId, scope, scopeId };
}
/**
* Map a settings sidecar key onto the currently declared scope coordinates.
* Returns null when the declared scope cannot be remapped safely (e.g. host
* scope without a fresh host id).
*/
function resolveSettingsSidecarTarget(field, parsed) {
const targetScope = typeof field?.scope === "string" && field.scope.length > 0
? field.scope
: parsed.scope;
let targetScopeId = parsed.scopeId;
if (targetScope === "application") {
targetScopeId = "application";
} else if (targetScope === "device") {
// parseSettingsSidecarKey guarantees a non-empty scopeId when parsed is set.
targetScopeId = parsed.scope === "device" ? parsed.scopeId : "device";
} else if (targetScope !== parsed.scope) {
return null;
}
return {
targetScope,
targetScopeId,
nextKey: settingsSidecarKey(parsed.settingId, targetScope, targetScopeId),
};
}
function collectPluginSyncSidecars(input) {
const now = input.now ?? Date.now();
const entries = [];
@@ -156,5 +181,6 @@ module.exports = {
isPluginSyncSidecarKind,
mergePluginSyncSidecars,
parseSettingsSidecarKey,
resolveSettingsSidecarTarget,
settingsSidecarKey,
};
+19 -10
View File
@@ -10,6 +10,7 @@ const {
excludeSecretPluginSettingsFromSidecars,
mergePluginSyncSidecars,
parseSettingsSidecarKey,
resolveSettingsSidecarTarget,
isCloudSyncablePluginSetting,
} = require("./pluginSyncSidecarHelpers.cjs");
@@ -161,11 +162,9 @@ class PluginSyncSidecarService {
// Older sidecars may encode an obsolete scope after a plugin update;
// recreating that scope would leave duplicate rows that collection
// republishes. Application scope always uses the fixed "application" id.
const targetScope = typeof field.scope === "string" && field.scope.length > 0
? field.scope
: parsed.scope;
const targetScopeId = targetScope === "application" ? "application" : parsed.scopeId;
const nextKey = `${parsed.settingId}\0${targetScope}\0${targetScopeId}`;
const target = resolveSettingsSidecarTarget(field, parsed);
if (!target) continue;
const { targetScope, targetScopeId, nextKey } = target;
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
@@ -274,11 +273,21 @@ class PluginSyncSidecarService {
if (!parsed) continue;
const field = declared.find((item) => item.id === parsed.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
const targetScope = typeof field.scope === "string" && field.scope.length > 0
? field.scope
: parsed.scope;
const targetScopeId = targetScope === "application" ? "application" : parsed.scopeId;
const nextKey = `${parsed.settingId}\0${targetScope}\0${targetScopeId}`;
const target = resolveSettingsSidecarTarget(field, parsed);
if (!target) continue;
const { targetScope, targetScopeId, nextKey } = target;
// Do not overwrite a newer local edit that has not been collected yet.
if (typeof this.database.listSettings === "function") {
const localRows = this.database.listSettings(pluginId);
const local = localRows.find((row) => (
row.settingId === parsed.settingId
&& row.scope === targetScope
&& row.scopeId === targetScopeId
));
if (local && Number(local.updatedAt) > Number(entry.updatedAt)) {
continue;
}
}
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
@@ -284,6 +284,50 @@ test("hydrateInstalledPluginSettings applies retained sidecars through contribut
);
});
test("hydrateInstalledPluginSettings skips sidecars older than local settings", async (context) => {
const database = tempDb(context);
database.setSetting(
"com.example.sync",
"com.example.sync.theme",
"application",
"application",
"local-newer",
20,
);
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"from-cloud-stale",
9,
);
const updates = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
async updateSetting(pluginId, settingId, value, scopeId) {
updates.push({ pluginId, settingId, value, scopeId });
},
},
});
await service.hydrateInstalledPluginSettings("com.example.sync");
assert.equal(updates.length, 0);
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
"local-newer",
);
});
test("applyFromSync empty remote bundle wipes missing-plugin retained rows", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
+6
View File
@@ -257,6 +257,12 @@ export const STORAGE_KEY_PF_RECONNECT_CANCEL = '__netcatty_pf_cancel_reconnect';
// Default SSH Key Passphrases (for ~/.ssh keys not managed in the vault)
export const STORAGE_KEY_DEFAULT_KEY_PASSPHRASES = 'netcatty_default_key_passphrases_v1';
// Plugin sync sidecars / availability. Literals MUST match domain/sync
// SYNC_STORAGE_KEYS (PLUGIN_SIDECARS_* / AVAILABLE_PLUGIN_SYNC_PROVIDERS).
export const STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN = 'netcatty_plugin_sidecars_last_known_v1';
export const STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE = 'netcatty_plugin_sidecars_pending_remote_v1';
export const STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS = 'netcatty_available_plugin_sync_providers_v1';
// Debug Flags (no _v1 suffix — developer-only, not persisted data)
export const STORAGE_KEY_DEBUG_HOTKEYS = 'debug.hotkeys';
export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
@@ -158,11 +158,17 @@ export function decryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyPro
*/
const PLUGIN_CONFIG_ENVELOPE_KEY = "__netcatty_plugin_config_v1" as const;
const LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY = "__encryptedPluginConfig" as const;
/** At-rest envelope for ProviderConnection.credential (opaque refs only). */
const PLUGIN_CREDENTIAL_ENVELOPE_KEY = "__netcatty_plugin_credential_v1" as const;
type PluginConfigEnvelope = {
[PLUGIN_CONFIG_ENVELOPE_KEY]: string;
};
type PluginCredentialEnvelope = {
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: string;
};
function isPluginConfigEnvelope(value: unknown): value is PluginConfigEnvelope {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
@@ -182,6 +188,15 @@ function isLegacyPluginConfigEnvelope(value: unknown): value is { __encryptedPlu
&& typeof record[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY] === "string";
}
function isPluginCredentialEnvelope(value: unknown): value is PluginCredentialEnvelope {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
return keys.length === 1
&& keys[0] === PLUGIN_CREDENTIAL_ENVELOPE_KEY
&& typeof record[PLUGIN_CREDENTIAL_ENVELOPE_KEY] === "string";
}
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
const out = { ...conn };
@@ -224,6 +239,34 @@ export async function encryptProviderSecrets(conn: ProviderConnection): Promise<
}
}
// Seal durable plugin credential refs as one opaque blob (same threat model
// as plugin config: do not leave kind/id/key plaintext in localStorage).
if (out.credential != null && typeof out.credential === "object") {
if (isPluginCredentialEnvelope(out.credential)) {
// already sealed
} else {
const kind = (out.credential as { kind?: unknown }).kind;
const id = (out.credential as { id?: unknown }).id;
const key = (out.credential as { key?: unknown }).key;
if ((kind === "secret" || kind === "credential") && typeof id === "string" && id.length > 0) {
const normalized = {
kind,
id,
...(typeof key === "string" ? { key } : {}),
};
const sealed = await encryptField(JSON.stringify(normalized));
if (sealed) {
out.credential = {
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: sealed,
} as unknown as ProviderConnection["credential"];
}
} else {
// Drop leases / malformed shapes — never persist them at rest.
delete out.credential;
}
}
}
return out;
}
@@ -271,6 +314,34 @@ export async function decryptProviderSecrets(conn: ProviderConnection): Promise<
}
}
if (isPluginCredentialEnvelope(out.credential)) {
const plain = await decryptField(out.credential[PLUGIN_CREDENTIAL_ENVELOPE_KEY]);
if (plain != null && plain !== "") {
try {
const parsed = JSON.parse(plain) as {
kind?: unknown;
id?: unknown;
key?: unknown;
};
if (
(parsed.kind === "secret" || parsed.kind === "credential")
&& typeof parsed.id === "string"
&& parsed.id.length > 0
) {
out.credential = {
kind: parsed.kind,
id: parsed.id,
...(typeof parsed.key === "string" ? { key: parsed.key } : {}),
};
} else {
delete out.credential;
}
} catch {
// leave sealed if corrupt
}
}
}
return out;
}
@@ -12,13 +12,17 @@ import type {
EncryptedObjectStorageCapabilities,
EncryptedObjectWriteResult,
} from '../../../domain/encryptedObjectStorage';
import type { PluginSyncCredentialRef as DurablePluginSyncCredentialRef } from '../../../domain/sync';
/** Host-owned secret/credential reference from SyncConnectPayload. */
export type PluginSyncCredentialRef = {
kind: 'secret' | 'credential' | 'secretLease';
id: string;
key?: string;
};
/**
* SyncConnectPayload.credential — includes one-shot leases for live connect.
* Durable reconnect persistence only keeps secret/credential refs (see domain).
*/
export type PluginSyncCredentialRef =
| DurablePluginSyncCredentialRef
| { kind: 'secret-lease'; id: string; key?: string; operationId?: string; expiresAt?: number };
export type { DurablePluginSyncCredentialRef };
export interface PluginSyncProviderHost {
connectSync(
@@ -18,6 +18,7 @@ import type {
SyncPayload,
WebDAVConfig,
} from '../../../domain/sync';
import { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
import type {
ProviderSyncAnchor,
@@ -416,6 +417,10 @@ export async function connectPluginProviderImpl(
provider: providerId,
status: 'connected',
config: configuration as ProviderConnection['config'],
...((() => {
const ref = normalizeDurablePluginSyncCredentialRef(credential);
return ref ? { credential: ref } : {};
})()),
account,
resourceId: resourceId || undefined,
};
@@ -423,10 +428,13 @@ export async function connectPluginProviderImpl(
await this.saveProviderConnection(providerId, this.state.providers[providerId]);
// Preserve merge base / anchors when reconnecting the same account+resource
// and configuration; clear when backend identity or config changes.
const previousCredentialFp = `${previous?.credential?.kind ?? ''}\0${previous?.credential?.id ?? ''}\0${previous?.credential?.key ?? ''}`;
const nextCredentialFp = `${this.state.providers[providerId].credential?.kind ?? ''}\0${this.state.providers[providerId].credential?.id ?? ''}\0${this.state.providers[providerId].credential?.key ?? ''}`;
if (
previousAccountId !== nextAccountId
|| previousResource !== nextResource
|| previousConfigFp !== nextConfigFp
|| previousCredentialFp !== nextCredentialFp
) {
clearProviderMergeStateImpl.call(this, providerId);
}
@@ -517,18 +517,45 @@ export async function syncConvergentProvidersUnlockedImpl(
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
? { version: 1 as const, entries: mergedSidecars }
: inputPayload.pluginSidecars;
const sidecarFingerprint = (bundle: typeof pluginSidecars) =>
JSON.stringify(
(bundle?.entries ?? [])
.map((e) => [e.pluginId, e.kind, e.key, e.updatedAt, e.value])
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
);
const localSidecarFp = sidecarFingerprint(pluginSidecars);
const remoteSidecarMismatch = usable.some((runtime) => {
if (runtime.error) return false;
const sidecarFingerprint = (
bundle: typeof pluginSidecars,
payload?: SyncPayload | null,
) => {
const present = payload
? Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')
: bundle != null && Array.isArray(bundle.entries);
const entries = (bundle?.entries ?? [])
.map((e) => [e.pluginId, e.kind, e.key, e.updatedAt, e.value] as const)
.sort((a, b) => {
if (a[0] !== b[0]) return String(a[0]).localeCompare(String(b[0]));
if (a[1] !== b[1]) return String(a[1]).localeCompare(String(b[1]));
return String(a[2]).localeCompare(String(b[2]));
});
// Empty present and empty absent both fingerprint as empty so devices
// do not ping-pong uploading omitted vs explicit-empty markers.
if (entries.length === 0) {
return JSON.stringify({ present: false, version: null, entries: [] });
}
return JSON.stringify({
present,
version: present ? (bundle?.version ?? 1) : null,
entries,
});
};
const localSidecarFp = sidecarFingerprint(
pluginSidecars,
Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars') || mergedSidecars.length > 0
? { ...inputPayload, pluginSidecars }
: inputPayload,
);
const providerSidecarMismatch = (runtime: { provider: string }) => {
const decoded = preflightVerified.get(runtime.provider);
if (!decoded) return true; // no preflight match → need upload
return sidecarFingerprint(decoded.payload?.pluginSidecars) !== localSidecarFp;
return sidecarFingerprint(decoded.payload?.pluginSidecars, decoded.payload) !== localSidecarFp;
};
const remoteSidecarMismatch = usable.some((runtime) => {
if (runtime.error) return false;
return providerSidecarMismatch(runtime);
});
const needsUpload = usable.some((runtime) => (
!runtime.error && !preflightVerified.has(runtime.provider)
@@ -542,7 +569,7 @@ export async function syncConvergentProvidersUnlockedImpl(
await Promise.all(usable.map(async (runtime) => {
// Upload when CRDT preflight missed OR sidecars diverge for this provider.
if (runtime.error) return;
if (preflightVerified.has(runtime.provider) && !remoteSidecarMismatch) return;
if (preflightVerified.has(runtime.provider) && !providerSidecarMismatch(runtime)) return;
if (!outgoingPayload) return;
try {
const remoteVersion = runtime.latestRemote?.meta.version ?? this.state.localVersion;
@@ -98,6 +98,9 @@ function createManagerHarness(storage: Map<string, unknown>): ManagerHarness {
describe('plugin provider manager boundary', () => {
it('loads registered plugin providers into initial state and keeps them across restart', () => {
const storage = memoryStorage();
// Seed device identity so loadInitialStateImpl never touches browser globals.
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
const manager = createManagerHarness(storage);
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
storage.set('netcatty_provider_plugin_v1:com.example.backup.sync', {
@@ -43,6 +43,16 @@ const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
/** Ensure per-provider sequence counters exist (dynamic plugins arrive late). */
function ensureProviderSeqCounters(manager: any, provider: CloudProvider): void {
// Lightweight test harnesses may omit the maps entirely; initialize before indexing.
if (manager.providerDecryptSeq == null || typeof manager.providerDecryptSeq !== 'object') {
manager.providerDecryptSeq = {};
}
if (manager.providerWriteSeq == null || typeof manager.providerWriteSeq !== 'object') {
manager.providerWriteSeq = {};
}
if (manager.providerDecrypted == null || typeof manager.providerDecrypted !== 'object') {
manager.providerDecrypted = {};
}
if (manager.providerDecryptSeq[provider] == null || Number.isNaN(manager.providerDecryptSeq[provider])) {
manager.providerDecryptSeq[provider] = 0;
}
@@ -163,11 +173,9 @@ export function listRegisteredPluginProviderIdsImpl(this: any): string[] {
.sort();
}
const AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY = 'netcatty_available_plugin_sync_providers_v1';
export function listAvailablePluginSyncProviderIdsImpl(this: any): string[] {
if (typeof this.loadFromStorage !== 'function') return [];
const raw = this.loadFromStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY) as unknown;
const raw = this.loadFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS) as unknown;
if (!Array.isArray(raw)) return [];
return raw
.filter((id: unknown): id is string => typeof id === 'string' && isPluginCloudProviderId(id))
@@ -178,16 +186,16 @@ export function markPluginSyncProviderAvailableImpl(this: any, provider: CloudPr
if (!isPluginCloudProviderId(provider)) return;
const next = new Set(listAvailablePluginSyncProviderIdsImpl.call(this));
next.add(provider);
this.saveToStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY, [...next].sort());
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, [...next].sort());
}
export function markPluginSyncProviderUnavailableImpl(this: any, provider: CloudProvider): void {
if (!isPluginCloudProviderId(provider)) return;
const next = listAvailablePluginSyncProviderIdsImpl.call(this).filter((id) => id !== provider);
if (next.length === 0) {
this.removeFromStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY);
this.removeFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
} else {
this.saveToStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY, next);
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
}
}
@@ -206,9 +214,9 @@ export function setAvailablePluginSyncProviderIdsImpl(
providerIds.filter((id): id is string => typeof id === 'string' && isPluginCloudProviderId(id)),
)].sort();
if (next.length === 0) {
this.removeFromStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY);
this.removeFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
} else {
this.saveToStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY, next);
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
}
if (!this.state?.providers) return;
let changed = false;
@@ -546,8 +554,10 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
const nextTokens = next.tokens;
const nextConfig = next.config;
const adapter = this.adapters.get(provider);
// Config may be a valid falsy scalar — only null/undefined means absent.
if (nextTokens == null && nextConfig == null) {
// Config may be a valid falsy scalar including JSON null — presence is
// property existence (matches hasProviderConnectionData).
const hasConfigProperty = Object.prototype.hasOwnProperty.call(next, 'config');
if (nextTokens == null && !hasConfigProperty && next.credential == null) {
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
@@ -567,12 +577,30 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
const configChanged =
JSON.stringify(prev.config ?? null) !== JSON.stringify(nextConfig ?? null);
const prevCredential = prev.credential;
const nextCredential = next.credential;
const credentialChanged =
(prevCredential?.kind ?? null) !== (nextCredential?.kind ?? null)
|| (prevCredential?.id ?? null) !== (nextCredential?.id ?? null)
|| (prevCredential?.key ?? null) !== (nextCredential?.key ?? null);
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
if (adapter && (tokenChanged || configChanged || credentialChanged || resourceChanged)) {
adapter.signOut();
this.adapters.delete(provider);
}
// Credential identity changes invalidate merge anchors the same way
// config/resource changes do on the connect path (authMethods).
if (credentialChanged) {
try {
this.removeFromStorage(this.syncBaseKey(provider));
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
this.clearSyncAnchor(provider);
} catch {
// Best-effort; adapter eviction above already forces reconnect.
}
}
this.notifyStateChange();
}).catch(() => {
@@ -619,7 +647,7 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
// Config may be a valid scalar including JSON null — presence is property existence.
const hasConfigProperty = connection != null
&& Object.prototype.hasOwnProperty.call(connection, 'config');
if (tokens == null && !hasConfigProperty) {
if (tokens == null && !hasConfigProperty && connection?.credential == null) {
throw new Error('Provider not connected');
}
@@ -649,6 +677,7 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
providerId,
host,
configuration,
credential: connection?.credential,
});
};