feat(shadow): round-trip plugin binaries for BRAT / non-marketplace (#429b)

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/<id>/`. 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) <noreply@anthropic.com>
This commit is contained in:
Souta 2026-06-30 20:24:51 +09:00
parent 2b7fcd1c87
commit 57def8564e
4 changed files with 288 additions and 0 deletions

View file

@ -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.

View file

@ -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/<id>/`
// (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 {

View file

@ -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<string, string> = {}): {
rw: SharedConfigReader & SharedConfigWriter;
store: Record<string, string>;
} {
const store: Record<string, string> = { ...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);
});
});

View file

@ -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');
});
});