From 57def8564ef8aa494b3638b7e1ae45cefa7afcac Mon Sep 17 00:00:00 2001 From: Souta Date: Tue, 30 Jun 2026 20:24:51 +0900 Subject: [PATCH] feat(shadow): round-trip plugin binaries for BRAT / non-marketplace (#429b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B/2. The enabled-plugins LIST round-trips (#434) and the marketplace installer fetches binaries for registry plugins (#439, Phase B/1) — but a BRAT / sideloaded plugin isn't on the marketplace, so its code never reached another machine. ShadowVaultBootstrap.pull/pushPluginBinaries round-trip the plugin *code* (manifest.json / main.js / styles.css; never data.json) through the remote vault's canonical `.obsidian/plugins//`. Wired into runAutoConnect AFTER the marketplace installer so the plugins it fetched live aren't re-pulled (pull only stages what's still missing = the non-marketplace ones, preserving the installer's live load and acting as a fallback when a marketplace fetch fails). The push makes the remote `.obsidian/plugins/` a complete vault. A pulled binary loads on the next vault open (Obsidian scans the plugins dir at startup). - pull never overwrites a local file (local install authoritative) - push is diff-gated (no churn) and skips remote-ssh (self-managed) - per-file SSH errors are swallowed, never abort the batch Tests: 7 unit (in-memory fakes) + 3 integration (real docker sshd: cross-session round-trip, push→fresh-pull, local-authoritative). Refs #429 #342 Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/src/main.ts | 17 +++ plugin/src/shadow/ShadowVaultBootstrap.ts | 107 +++++++++++++++++ plugin/tests/ShadowVaultBootstrap.test.ts | 109 ++++++++++++++++++ .../config-consistency.e2e.test.ts | 55 +++++++++ 4 files changed, 288 insertions(+) diff --git a/plugin/src/main.ts b/plugin/src/main.ts index c34e4c1..a6801e8 100644 --- a/plugin/src/main.ts +++ b/plugin/src/main.ts @@ -626,6 +626,23 @@ export default class RemoteSshPlugin extends Plugin { logger.warn(`runAutoConnect(${tag}): post-pull plugin install failed: ${errorMessage(e)}`); } + // #429b binary round-trip: the marketplace installer above can't + // fetch a BRAT / sideloaded plugin (it isn't on the registry). Run + // AFTER the installer so the plugins it just fetched are on disk — + // the pull then only stages what's STILL missing (the non-market + // ones), which keeps the installer's live load intact and also + // acts as a fallback if a marketplace fetch failed. The push makes + // the remote `.obsidian/plugins/` a complete vault so every machine + // can pull. A pulled binary loads on the next vault open (Obsidian + // scans the plugins dir at startup). + try { + const enabledIds = ShadowVaultBootstrap.readEnabledPluginIds(localConfigDir); + await ShadowVaultBootstrap.pullPluginBinaries(da, remoteConfigDir, localConfigDir, enabledIds); + await ShadowVaultBootstrap.pushPluginBinaries(da, remoteConfigDir, localConfigDir, enabledIds); + } catch (e) { + logger.warn(`runAutoConnect(${tag}): plugin-binary round-trip failed: ${errorMessage(e)}`); + } + // #342 push half: pull only brought remote→local. Without this, // a settings change made HERE never reaches the remote, so the // next session's pull finds nothing and the change evaporates. diff --git a/plugin/src/shadow/ShadowVaultBootstrap.ts b/plugin/src/shadow/ShadowVaultBootstrap.ts index b32428f..d6f319c 100644 --- a/plugin/src/shadow/ShadowVaultBootstrap.ts +++ b/plugin/src/shadow/ShadowVaultBootstrap.ts @@ -530,6 +530,104 @@ export class ShadowVaultBootstrap { } } + // ─── plugin binary round-trip (#429b — BRAT / non-marketplace) ────────── + // + // The enabled-plugins LIST round-trips (above) and the marketplace + // installer fetches binaries for plugins on Obsidian's registry. But a + // BRAT / sideloaded plugin isn't on the marketplace, so its binary + // would never reach another machine. These methods round-trip the + // plugin *code* through the remote vault's `.obsidian/plugins//` + // (the canonical store) so such plugins load everywhere. Code only — + // the plugin's own `data.json` (settings, sometimes secrets) is left + // alone. + + /** Plugin code files synced cross-machine (text; binary assets are out of scope). */ + static readonly PLUGIN_BINARY_FILES = ['manifest.json', 'main.js', 'styles.css'] as const; + + /** + * Pull each plugin's code from the remote into the local shadow when + * it's MISSING locally — so a plugin enabled on another machine (incl. + * BRAT / non-marketplace) has its code staged here and loads on the + * next vault open. Never overwrites a local file (the local install is + * authoritative). `remote-ssh` is skipped — the plugin manages its own + * install. + */ + static async pullPluginBinaries( + reader: SharedConfigReader, + remoteConfigDir: string, + localConfigDir: string, + pluginIds: readonly string[], + ): Promise<{ pulled: string[] }> { + const pulled: string[] = []; + for (const id of pluginIds) { + if (id === ShadowVaultBootstrap.SELF_PLUGIN_ID) continue; + const localPluginDir = path.join(localConfigDir, 'plugins', id); + for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) { + const localFile = path.join(localPluginDir, file); + if (fs.existsSync(localFile)) continue; // local authoritative + const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`; + try { + if (!(await reader.exists(remoteRel))) continue; + const content = await reader.read(remoteRel); + fs.mkdirSync(localPluginDir, { recursive: true }); + ShadowVaultBootstrap.writeFileAtomic(localFile, content); + if (!pulled.includes(id)) pulled.push(id); + } catch (e) { + logger.warn(`pullPluginBinaries: ${id}/${file} skipped (${errorMessage(e)})`); + } + } + } + if (pulled.length) logger.info(`pullPluginBinaries: staged [${pulled.join(', ')}]`); + return { pulled }; + } + + /** + * Push each plugin's local code to the remote when the remote copy is + * missing or differs — so other machines can pull it. Code only; never + * the plugin's `data.json`. + */ + static async pushPluginBinaries( + rw: SharedConfigReader & SharedConfigWriter, + remoteConfigDir: string, + localConfigDir: string, + pluginIds: readonly string[], + ): Promise<{ pushed: string[] }> { + const pushed: string[] = []; + for (const id of pluginIds) { + if (id === ShadowVaultBootstrap.SELF_PLUGIN_ID) continue; + const localPluginDir = path.join(localConfigDir, 'plugins', id); + for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) { + const localFile = path.join(localPluginDir, file); + if (!fs.existsSync(localFile)) continue; + const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`; + try { + const content = fs.readFileSync(localFile, 'utf-8'); + let remoteContent: string | null = null; + if (await rw.exists(remoteRel)) remoteContent = await rw.read(remoteRel); + if (remoteContent === content) continue; // up to date — avoid churn + await rw.write(remoteRel, content); + if (!pushed.includes(id)) pushed.push(id); + } catch (e) { + logger.warn(`pushPluginBinaries: ${id}/${file} skipped (${errorMessage(e)})`); + } + } + } + if (pushed.length) logger.info(`pushPluginBinaries: pushed [${pushed.join(', ')}]`); + return { pushed }; + } + + /** Atomic (tmp + rename) write of arbitrary file content. */ + private static writeFileAtomic(localFile: string, content: string): void { + const tmp = `${localFile}.${process.pid}.tmp`; + fs.writeFileSync(tmp, content, 'utf-8'); + try { + fs.renameSync(tmp, localFile); + } catch (e) { + try { fs.unlinkSync(tmp); } catch { /* best effort */ } + throw e; + } + } + /** Parse a community-plugins.json body into a string-id array, or null if malformed. */ private static parsePluginIdList(content: string): string[] | null { try { @@ -541,6 +639,15 @@ export class ShadowVaultBootstrap { } } + /** + * Enabled-plugin ids from a local `.obsidian/community-plugins.json` + * ([] when absent/malformed) — the set whose binaries the connect flow + * round-trips via {@link pullPluginBinaries}/{@link pushPluginBinaries}. + */ + static readEnabledPluginIds(localConfigDir: string): string[] { + return ShadowVaultBootstrap.readPluginIdList(path.join(localConfigDir, 'community-plugins.json')); + } + /** Read a local community-plugins.json into an id array ([] when absent/malformed). */ private static readPluginIdList(localPath: string): string[] { try { diff --git a/plugin/tests/ShadowVaultBootstrap.test.ts b/plugin/tests/ShadowVaultBootstrap.test.ts index 40d2668..f0f2931 100644 --- a/plugin/tests/ShadowVaultBootstrap.test.ts +++ b/plugin/tests/ShadowVaultBootstrap.test.ts @@ -1106,3 +1106,112 @@ describe('ShadowVaultBootstrap community-plugins round-trip (#429 / #342)', () = expect(store['.obsidian/community-plugins.json']).toBe('{ not : json'); }); }); + +describe('ShadowVaultBootstrap plugin-binary round-trip (#429b — BRAT / non-marketplace)', () => { + function makeLocalConfigDir(): string { + const dir = path.join( + os.tmpdir(), + `bin-roundtrip-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + '.obsidian', + ); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + const pluginFile = (cfg: string, id: string, file: string) => path.join(cfg, 'plugins', id, file); + function seedLocal(cfg: string, id: string, file: string, content: string): void { + fs.mkdirSync(path.dirname(pluginFile(cfg, id, file)), { recursive: true }); + fs.writeFileSync(pluginFile(cfg, id, file), content, 'utf-8'); + } + + // Reader+writer fake over an in-memory store keyed by remote rel path. + function makeRW(seed: Record = {}): { + rw: SharedConfigReader & SharedConfigWriter; + store: Record; + } { + const store: Record = { ...seed }; + const rw: SharedConfigReader & SharedConfigWriter = { + exists: (p) => Promise.resolve(p in store), + read: (p) => Promise.resolve(store[p]), + write: (p, c) => { store[p] = c; return Promise.resolve(); }, + }; + return { rw, store }; + } + + it('pulls a plugin enabled on another machine into the shadow (code staged on disk)', async () => { + const cfg = makeLocalConfigDir(); + const { rw } = makeRW({ + '.obsidian/plugins/brat-x/manifest.json': '{"id":"brat-x"}', + '.obsidian/plugins/brat-x/main.js': '/* brat code */\n', + }); + + const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['brat-x']); + + expect(pulled).toContain('brat-x'); + expect(fs.readFileSync(pluginFile(cfg, 'brat-x', 'main.js'), 'utf-8')).toBe('/* brat code */\n'); + expect(fs.existsSync(pluginFile(cfg, 'brat-x', 'manifest.json'))).toBe(true); + }); + + it('never overwrites a local plugin file (local install is authoritative)', async () => { + const cfg = makeLocalConfigDir(); + seedLocal(cfg, 'p', 'main.js', '/* LOCAL */\n'); + const { rw } = makeRW({ '.obsidian/plugins/p/main.js': '/* REMOTE */\n' }); + + await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']); + + expect(fs.readFileSync(pluginFile(cfg, 'p', 'main.js'), 'utf-8'), 'local file must win') + .toBe('/* LOCAL */\n'); + }); + + it('skips remote-ssh on pull — the plugin manages its own install', async () => { + const cfg = makeLocalConfigDir(); + const { rw } = makeRW({ '.obsidian/plugins/remote-ssh/main.js': '/* ignore me */\n' }); + + const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['remote-ssh']); + + expect(pulled).not.toContain('remote-ssh'); + expect(fs.existsSync(pluginFile(cfg, 'remote-ssh', 'main.js'))).toBe(false); + }); + + it('pushes a locally-installed plugin so other machines can pull it', async () => { + const cfg = makeLocalConfigDir(); + seedLocal(cfg, 'q', 'main.js', '/* q code */\n'); + seedLocal(cfg, 'q', 'manifest.json', '{"id":"q"}'); + const { rw, store } = makeRW(); + + const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['q']); + + expect(pushed).toContain('q'); + expect(store['.obsidian/plugins/q/main.js']).toBe('/* q code */\n'); + expect(store['.obsidian/plugins/q/manifest.json']).toBe('{"id":"q"}'); + }); + + it('push is a no-op when the remote copy is already identical (avoid churn)', async () => { + const cfg = makeLocalConfigDir(); + seedLocal(cfg, 'r', 'main.js', '/* same */\n'); + const { rw } = makeRW({ '.obsidian/plugins/r/main.js': '/* same */\n' }); + let writes = 0; + const origWrite = rw.write; + rw.write = (p, c) => { writes++; return origWrite(p, c); }; + + const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['r']); + + expect(pushed).not.toContain('r'); + expect(writes, 'identical content must not be re-written').toBe(0); + }); + + it('a per-file SSH error is swallowed and does not abort the rest', async () => { + const cfg = makeLocalConfigDir(); + const { rw } = makeRW({ + '.obsidian/plugins/x/manifest.json': '{"id":"x"}', + '.obsidian/plugins/x/main.js': '/* x */\n', + }); + const origRead = rw.read; + rw.read = (p) => (p.endsWith('manifest.json') ? Promise.reject(new Error('SSH hiccup')) : origRead(p)); + + const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['x']); + + expect(pulled, 'main.js still pulls after manifest read fails').toContain('x'); + expect(fs.readFileSync(pluginFile(cfg, 'x', 'main.js'), 'utf-8')).toBe('/* x */\n'); + expect(fs.existsSync(pluginFile(cfg, 'x', 'manifest.json')), 'failed file is skipped').toBe(false); + }); +}); diff --git a/plugin/tests/integration/config-consistency.e2e.test.ts b/plugin/tests/integration/config-consistency.e2e.test.ts index a896088..0933d35 100644 --- a/plugin/tests/integration/config-consistency.e2e.test.ts +++ b/plugin/tests/integration/config-consistency.e2e.test.ts @@ -150,4 +150,59 @@ describe('Config consistency across connect cycles (#429 / #342)', () => { const local2 = JSON.parse(fs.readFileSync(path.join(s2.layout.configDir, 'app.json'), 'utf-8')); expect(local2, 'the setting changed in session 1 must be consistent in session 2').toEqual(snapshot); }); + + it('a sideloaded plugin binary enabled on another machine is staged into a fresh shadow (#429b)', async () => { + const id = 'sideloaded-plugin'; + await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/manifest.json`, + JSON.stringify({ id, version: '1.0.0', name: 'Sideloaded', minAppVersion: '1.5.0' })); + await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/main.js`, '/* sideloaded code */\n'); + + const profile = profileFor('bin-pull'); + const { layout } = await freshSession().bootstrap(profile, [profile]); + const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries( + remoteClient.adapter, REMOTE_CFG, layout.configDir, [id]); + + expect(pulled).toContain(id); + expect(fs.readFileSync(path.join(layout.configDir, 'plugins', id, 'main.js'), 'utf-8')) + .toBe('/* sideloaded code */\n'); + expect(fs.existsSync(path.join(layout.configDir, 'plugins', id, 'manifest.json'))).toBe(true); + }); + + it('a sideloaded plugin installed in one session round-trips into the next via the remote', async () => { + const id = 'local-only-plugin'; + // Session 1 (machine A): code present locally, pushed to the remote. + const pA = profileFor('bin-push'); + const sA = await freshSession().bootstrap(pA, [pA]); + const dirA = path.join(sA.layout.configDir, 'plugins', id); + fs.mkdirSync(dirA, { recursive: true }); + fs.writeFileSync(path.join(dirA, 'manifest.json'), + JSON.stringify({ id, version: '2.0.0', name: 'LocalOnly', minAppVersion: '1.5.0' })); + fs.writeFileSync(path.join(dirA, 'main.js'), '/* local-only code v2 */\n'); + await ShadowVaultBootstrap.pushPluginBinaries(remoteClient.adapter, REMOTE_CFG, sA.layout.configDir, [id]); + + // Session 2 (machine B): a fresh shadow pulls the pushed binary. + const pB = profileFor('bin-push-2'); + const sB = await freshSession().bootstrap(pB, [pB]); + await ShadowVaultBootstrap.pullPluginBinaries(remoteClient.adapter, REMOTE_CFG, sB.layout.configDir, [id]); + + expect(fs.readFileSync(path.join(sB.layout.configDir, 'plugins', id, 'main.js'), 'utf-8'), + 'a sideloaded plugin installed in session 1 must reach session 2') + .toBe('/* local-only code v2 */\n'); + }); + + it('pullPluginBinaries never overwrites a local plugin file (local is authoritative, real SSH)', async () => { + const id = 'conflict-plugin'; + await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/main.js`, '/* REMOTE code */\n'); + + const profile = profileFor('bin-conflict'); + const { layout } = await freshSession().bootstrap(profile, [profile]); + const dir = path.join(layout.configDir, 'plugins', id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'main.js'), '/* LOCAL code */\n'); + + await ShadowVaultBootstrap.pullPluginBinaries(remoteClient.adapter, REMOTE_CFG, layout.configDir, [id]); + + expect(fs.readFileSync(path.join(dir, 'main.js'), 'utf-8'), 'local file must not be clobbered by pull') + .toBe('/* LOCAL code */\n'); + }); });