Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
/**
|
|
* Pure helpers for plugin cloud-sync sidecars.
|
|
*
|
|
* Sidecars hold user-owned encrypted-sync data that must survive missing or
|
|
* uninstalled plugins: non-secret settings marked `sync: true`, plus optional
|
|
* plugin sync account / CRDT baselines. Secret settings never enter sidecars.
|
|
*/
|
|
|
|
export type PluginSyncSidecarKind = 'settings' | 'account_baseline' | 'crdt_baseline';
|
|
|
|
export interface PluginSettingFieldDescriptor {
|
|
id: string;
|
|
secret?: boolean;
|
|
sync?: boolean;
|
|
scope?: string;
|
|
}
|
|
|
|
export interface PluginSettingValueRecord {
|
|
pluginId: string;
|
|
settingId: string;
|
|
scope: string;
|
|
scopeId: string;
|
|
value: unknown;
|
|
updatedAt?: number;
|
|
}
|
|
|
|
export interface PluginSyncSidecarEntry {
|
|
pluginId: string;
|
|
kind: PluginSyncSidecarKind;
|
|
key: string;
|
|
value: unknown;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface PluginSyncSidecarBundle {
|
|
/** Schema version for the sidecar envelope inside the encrypted payload. */
|
|
version: 1;
|
|
entries: PluginSyncSidecarEntry[];
|
|
}
|
|
|
|
export interface CollectPluginSyncSidecarsInput {
|
|
/** Declared setting fields keyed by plugin ID (from installed manifests when available). */
|
|
declaredSettingsByPlugin: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>;
|
|
/** Stored non-secret setting values (from non-cascade plugin_settings rows). */
|
|
storedSettings: readonly PluginSettingValueRecord[];
|
|
/** Existing sidecar rows (account/CRDT baselines and previously synced settings). */
|
|
existingSidecars?: readonly PluginSyncSidecarEntry[];
|
|
now?: number;
|
|
}
|
|
|
|
const SIDECAR_KINDS = new Set<PluginSyncSidecarKind>([
|
|
'settings',
|
|
'account_baseline',
|
|
'crdt_baseline',
|
|
]);
|
|
|
|
export function isPluginSyncSidecarKind(value: unknown): value is PluginSyncSidecarKind {
|
|
return typeof value === 'string' && SIDECAR_KINDS.has(value as PluginSyncSidecarKind);
|
|
}
|
|
|
|
/**
|
|
* Returns true when a setting field may enter cloud sync sidecars.
|
|
* Secrets always fail closed even if a descriptor incorrectly sets sync.
|
|
*/
|
|
export function isCloudSyncablePluginSetting(
|
|
field: Pick<PluginSettingFieldDescriptor, 'secret' | 'sync'>,
|
|
): boolean {
|
|
if (field.secret === true) return false;
|
|
return field.sync === true;
|
|
}
|
|
|
|
function settingsSidecarKey(settingId: string, scope: string, scopeId: string): string {
|
|
return `${settingId}\0${scope}\0${scopeId}`;
|
|
}
|
|
|
|
/**
|
|
* Build the sidecar bundle for the encrypted cloud payload.
|
|
* Missing plugins keep existing sidecar rows; only non-secret sync:true
|
|
* settings from the current store are (re)published under kind "settings".
|
|
*/
|
|
export function collectPluginSyncSidecars(
|
|
input: CollectPluginSyncSidecarsInput,
|
|
): PluginSyncSidecarBundle {
|
|
const now = input.now ?? Date.now();
|
|
const entries: PluginSyncSidecarEntry[] = [];
|
|
const settingsIndex = new Map<string, PluginSyncSidecarEntry>();
|
|
|
|
// Preserve non-settings sidecars and previous settings for unknown plugins.
|
|
for (const existing of input.existingSidecars ?? []) {
|
|
if (!existing || typeof existing.pluginId !== 'string' || !isPluginSyncSidecarKind(existing.kind)) {
|
|
continue;
|
|
}
|
|
if (existing.kind !== 'settings') {
|
|
entries.push({
|
|
pluginId: existing.pluginId,
|
|
kind: existing.kind,
|
|
key: String(existing.key),
|
|
value: existing.value,
|
|
updatedAt: Number(existing.updatedAt) || now,
|
|
});
|
|
continue;
|
|
}
|
|
const copy: PluginSyncSidecarEntry = {
|
|
pluginId: existing.pluginId,
|
|
kind: 'settings',
|
|
key: String(existing.key),
|
|
value: existing.value,
|
|
updatedAt: Number(existing.updatedAt) || now,
|
|
};
|
|
settingsIndex.set(`${copy.pluginId}\0${copy.key}`, copy);
|
|
}
|
|
|
|
for (const stored of input.storedSettings) {
|
|
if (!stored || typeof stored.pluginId !== 'string' || typeof stored.settingId !== 'string') continue;
|
|
const declared = input.declaredSettingsByPlugin.get(stored.pluginId);
|
|
// Missing plugin: keep any prior settings sidecar rows, do not invent new ones
|
|
// from undeclared storage (could include secret-adjacent values).
|
|
if (!declared) continue;
|
|
const field = declared.find((entry) => entry.id === stored.settingId);
|
|
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
|
const key = settingsSidecarKey(stored.settingId, stored.scope, stored.scopeId);
|
|
const entry: PluginSyncSidecarEntry = {
|
|
pluginId: stored.pluginId,
|
|
kind: 'settings',
|
|
key,
|
|
value: stored.value,
|
|
updatedAt: Number(stored.updatedAt) || now,
|
|
};
|
|
const mapKey = `${entry.pluginId}\0${entry.key}`;
|
|
const previous = settingsIndex.get(mapKey);
|
|
// Keep a retained sidecar when it is strictly newer than the stored row
|
|
// (e.g. schema-rejected remote apply left sidecar only).
|
|
if (!previous || entry.updatedAt >= previous.updatedAt) {
|
|
settingsIndex.set(mapKey, entry);
|
|
}
|
|
}
|
|
|
|
entries.push(...settingsIndex.values());
|
|
entries.sort((a, b) => {
|
|
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
|
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
|
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
});
|
|
|
|
return { version: 1, entries };
|
|
}
|
|
|
|
/**
|
|
* Merge a remote sidecar bundle into local non-cascade storage without deleting
|
|
* local rows for plugins that are not present on the remote (partial sync safety).
|
|
* Secret settings are never applied even if a malicious remote marks them as settings.
|
|
*/
|
|
/**
|
|
* Three-way merge for settings sidecars so local resets (absent from local,
|
|
* present in base) propagate instead of being resurrected from base/remote.
|
|
* Non-settings kinds (baselines) still use last-writer-wins union.
|
|
*/
|
|
export function mergePluginSyncSidecarsThreeWay(params: {
|
|
base: readonly PluginSyncSidecarEntry[];
|
|
local: readonly PluginSyncSidecarEntry[];
|
|
remote: readonly PluginSyncSidecarEntry[];
|
|
/**
|
|
* When set, honor cloud/local conflict policy for settings instead of pure
|
|
* timestamp LWW (preferCloud / preferLocal). Default is smart three-way.
|
|
*/
|
|
strategy?: 'smart' | 'preferCloud' | 'preferLocal';
|
|
}): PluginSyncSidecarEntry[] {
|
|
const strategy = params.strategy ?? 'smart';
|
|
const keyOf = (entry: PluginSyncSidecarEntry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
|
|
const toMap = (entries: readonly PluginSyncSidecarEntry[]) => {
|
|
const map = new Map<string, PluginSyncSidecarEntry>();
|
|
for (const entry of entries) {
|
|
if (!entry || typeof entry.pluginId !== 'string' || !isPluginSyncSidecarKind(entry.kind)) continue;
|
|
map.set(keyOf(entry), {
|
|
pluginId: entry.pluginId,
|
|
kind: entry.kind,
|
|
key: String(entry.key),
|
|
value: entry.value,
|
|
updatedAt: Number(entry.updatedAt) || 0,
|
|
});
|
|
}
|
|
return map;
|
|
};
|
|
const baseMap = toMap(params.base);
|
|
const localMap = toMap(params.local);
|
|
const remoteMap = toMap(params.remote);
|
|
const allKeys = new Set([...baseMap.keys(), ...localMap.keys(), ...remoteMap.keys()]);
|
|
const out: PluginSyncSidecarEntry[] = [];
|
|
|
|
for (const mapKey of allKeys) {
|
|
const b = baseMap.get(mapKey);
|
|
const l = localMap.get(mapKey);
|
|
const r = remoteMap.get(mapKey);
|
|
const kind = (l?.kind ?? r?.kind ?? b?.kind) as PluginSyncSidecarKind | undefined;
|
|
if (!kind) continue;
|
|
|
|
if (kind !== 'settings') {
|
|
// 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);
|
|
else out.push(l.updatedAt >= r.updatedAt ? l : r);
|
|
} else if (l) out.push(l);
|
|
else if (r) out.push(r);
|
|
continue;
|
|
}
|
|
|
|
// Settings three-way with optional conflict policy:
|
|
// base+remote, not local → local deleted
|
|
if (b && !l && r) {
|
|
if (strategy === 'preferLocal') continue; // honor local deletion
|
|
if (strategy === 'preferCloud' || r.updatedAt > b.updatedAt) out.push(r);
|
|
continue;
|
|
}
|
|
// base+local, not remote → remote deleted
|
|
if (b && l && !r) {
|
|
if (strategy === 'preferCloud') continue; // honor remote deletion
|
|
if (strategy === 'preferLocal' || l.updatedAt > b.updatedAt) out.push(l);
|
|
continue;
|
|
}
|
|
// both deleted
|
|
if (b && !l && !r) continue;
|
|
// 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;
|
|
}
|
|
if (!b && !l && r) {
|
|
out.push(r);
|
|
continue;
|
|
}
|
|
// both sides present (with or without base)
|
|
if (l && r) {
|
|
if (strategy === 'preferCloud') {
|
|
out.push(r);
|
|
} else if (strategy === 'preferLocal') {
|
|
out.push(l);
|
|
} else if (l.updatedAt !== r.updatedAt) {
|
|
out.push(l.updatedAt > r.updatedAt ? l : r);
|
|
} else {
|
|
// Equal timestamps: stable secondary key so devices converge.
|
|
const lTie = JSON.stringify(l.value);
|
|
const rTie = JSON.stringify(r.value);
|
|
out.push(lTie >= rTie ? l : r);
|
|
}
|
|
continue;
|
|
}
|
|
if (l) out.push(l);
|
|
else if (r) out.push(r);
|
|
}
|
|
|
|
return out.sort((a, b) => {
|
|
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
|
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
|
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
});
|
|
}
|
|
|
|
export function mergePluginSyncSidecars(params: {
|
|
local: readonly PluginSyncSidecarEntry[];
|
|
remote: PluginSyncSidecarBundle | null | undefined;
|
|
declaredSettingsByPlugin?: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>;
|
|
}): PluginSyncSidecarEntry[] {
|
|
const remoteEntries = Array.isArray(params.remote?.entries) ? params.remote.entries : [];
|
|
const merged = new Map<string, PluginSyncSidecarEntry>();
|
|
|
|
for (const entry of params.local) {
|
|
if (!entry || typeof entry.pluginId !== 'string' || !isPluginSyncSidecarKind(entry.kind)) continue;
|
|
merged.set(`${entry.pluginId}\0${entry.kind}\0${entry.key}`, {
|
|
pluginId: entry.pluginId,
|
|
kind: entry.kind,
|
|
key: String(entry.key),
|
|
value: entry.value,
|
|
updatedAt: Number(entry.updatedAt) || 0,
|
|
});
|
|
}
|
|
|
|
for (const raw of remoteEntries) {
|
|
if (!raw || typeof raw.pluginId !== 'string' || !isPluginSyncSidecarKind(raw.kind)) continue;
|
|
if (raw.kind === 'settings') {
|
|
const declared = params.declaredSettingsByPlugin?.get(raw.pluginId);
|
|
if (declared) {
|
|
// key format: settingId\0scope\0scopeId
|
|
const settingId = String(raw.key).split('\0')[0] ?? '';
|
|
const field = declared.find((entry) => entry.id === settingId);
|
|
if (field && !isCloudSyncablePluginSetting(field)) continue;
|
|
}
|
|
}
|
|
const next: PluginSyncSidecarEntry = {
|
|
pluginId: raw.pluginId,
|
|
kind: raw.kind,
|
|
key: String(raw.key),
|
|
value: raw.value,
|
|
updatedAt: Number(raw.updatedAt) || 0,
|
|
};
|
|
const mapKey = `${next.pluginId}\0${next.kind}\0${next.key}`;
|
|
const previous = merged.get(mapKey);
|
|
if (!previous || next.updatedAt >= previous.updatedAt) {
|
|
merged.set(mapKey, next);
|
|
}
|
|
}
|
|
|
|
return [...merged.values()].sort((a, b) => {
|
|
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
|
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
|
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse a settings sidecar key into setting coordinates.
|
|
*/
|
|
export function parseSettingsSidecarKey(key: string): {
|
|
settingId: string;
|
|
scope: string;
|
|
scopeId: string;
|
|
} | null {
|
|
const parts = String(key).split('\0');
|
|
if (parts.length !== 3) return null;
|
|
const [settingId, scope, scopeId] = parts;
|
|
if (!settingId || !scope || !scopeId) return null;
|
|
return { settingId, scope, scopeId };
|
|
}
|
|
|
|
/**
|
|
* Strip secret settings from an arbitrary sidecar entry list (defense in depth).
|
|
*/
|
|
export function excludeSecretPluginSettingsFromSidecars(
|
|
entries: readonly PluginSyncSidecarEntry[],
|
|
declaredSettingsByPlugin: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>,
|
|
): PluginSyncSidecarEntry[] {
|
|
return entries.filter((entry) => {
|
|
if (entry.kind !== 'settings') return true;
|
|
const declared = declaredSettingsByPlugin.get(entry.pluginId);
|
|
if (!declared) return true; // preserve orphaned rows for missing plugins
|
|
const parsed = parseSettingsSidecarKey(entry.key);
|
|
if (!parsed) return false;
|
|
const field = declared.find((item) => item.id === parsed.settingId);
|
|
if (!field) return true;
|
|
return isCloudSyncablePluginSetting(field);
|
|
});
|
|
}
|