fix(plugin): guard shadow bootstrap against self-install when Connect is clicked inside the shadow window (1.1.7-beta.0)

This commit is contained in:
Asep Bagja Priandana 2026-07-11 11:25:41 +03:00
parent f47bf18c77
commit 550740dfd7
7 changed files with 63 additions and 8 deletions

View file

@ -1,7 +1,7 @@
{
"id": "remote-ssh",
"name": "Remote SSH",
"version": "1.1.6",
"version": "1.1.7-beta.0",
"minAppVersion": "1.5.0",
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
"author": "souta shimozono",

View file

@ -1,7 +1,7 @@
{
"id": "remote-ssh",
"name": "Remote SSH",
"version": "1.1.6",
"version": "1.1.7-beta.0",
"minAppVersion": "1.5.0",
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
"author": "souta shimozono",

View file

@ -1,12 +1,12 @@
{
"name": "obsidian-remote-ssh",
"version": "1.1.6",
"version": "1.1.7-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-remote-ssh",
"version": "1.1.6",
"version": "1.1.7-beta.0",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-remote-ssh",
"version": "1.1.6",
"version": "1.1.7-beta.0",
"description": "VS Code Remote SSH-like experience for Obsidian",
"main": "main.js",
"scripts": {

View file

@ -1043,6 +1043,18 @@ export default class RemoteSshPlugin extends Plugin {
* shadow window (Phase 4).
*/
async openShadowVaultFor(profile: SshProfile): Promise<void> {
// Connect clicked from INSIDE the shadow window for this same
// profile (Settings row button / connect modal). This vault IS the
// shadow — there is no second window to spawn, and re-running the
// bootstrap from here would make installPlugin's rm+symlink cycle
// target its own files (src == dst), turning main.js/manifest.json
// into self-referential symlinks that brick the install on the
// next start. Reconnect in place instead.
if (this.settings.autoConnectProfileId === profile.id) {
await this.runAutoConnect('reconnect');
return;
}
// Source dir: where this running plugin lives, so the shadow
// vault's plugin install symlinks the same bundle.
const sourcePluginDir = this.pluginDir();

View file

@ -60,8 +60,12 @@ export interface BootstrapResult {
* surfaces the one-time "restart Obsidian" notice.
*/
migrated: boolean;
/** How the plugin source landed in the shadow vault. */
pluginInstallMethod: 'symlink' | 'copy';
/**
* How the plugin source landed in the shadow vault. `in-place` means
* source and target were the same directory (bootstrap re-run from
* inside the shadow window) so nothing needed installing.
*/
pluginInstallMethod: 'symlink' | 'copy' | 'in-place';
}
/**
@ -1164,7 +1168,7 @@ export class ShadowVaultBootstrap {
* writes the per-vault data.json into pluginDir as a real file,
* leaving the source vault's data.json untouched.
*/
private installPlugin(pluginDir: string): 'symlink' | 'copy' {
private installPlugin(pluginDir: string): 'symlink' | 'copy' | 'in-place' {
// If pluginDir is a stale whole-dir symlink from an older build
// (or a previous run of this same code on an older version),
// unlink it — DO NOT rmSync, that would follow the link and
@ -1178,6 +1182,23 @@ export class ShadowVaultBootstrap {
// Doesn't exist yet, fine.
}
// Self-install guard: when the bootstrap runs from INSIDE the
// shadow window it targets, sourcePluginDir IS pluginDir. The
// rm+symlink cycle below would then delete each real plugin file
// and replace it with a symlink pointing at its own path — a
// self-referential link Obsidian can't resolve, so the plugin
// "disappears" on the next start. The bundle is already here;
// there is nothing to install.
try {
if (fs.realpathSync(this.sourcePluginDir) === fs.realpathSync(pluginDir)) {
return 'in-place';
}
} catch {
// Either side unresolvable (e.g. pluginDir doesn't exist yet, or
// the stale whole-dir symlink was just unlinked) → not the same
// dir; proceed with a normal install.
}
fs.mkdirSync(pluginDir, { recursive: true });
const sharedFiles = ['main.js', 'manifest.json', 'styles.css'];

View file

@ -438,6 +438,28 @@ describe('ShadowVaultBootstrap.bootstrap', () => {
expect(stat.isDirectory()).toBe(true);
});
it('regression: re-bootstrap from INSIDE the shadow window (source == target plugin dir) must not self-symlink the plugin files', async () => {
// First bootstrap from a normal source vault.
const r1 = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const profile = makeProfile({ id: 'p1', name: 'P', remotePath: '/p1' });
const first = await r1.bootstrap(profile, [profile]);
// Simulate the plugin now RUNNING inside that shadow window with
// the user clicking Connect again: the running plugin's own dir
// (the bootstrap source) IS the shadow's plugin dir. Before the
// self-install guard, installPlugin rm'd each real file and left a
// symlink pointing at its own path — the plugin then failed to
// load ("disappeared") on the next Obsidian start.
const r2 = new ShadowVaultBootstrap(scratch.baseDir, first.layout.pluginDir, new ObsidianRegistry(scratch.configPath));
const second = await r2.bootstrap(profile, [profile]);
expect(second.layout.pluginDir).toBe(first.layout.pluginDir);
expect(second.pluginInstallMethod).toBe('in-place');
// main.js must still resolve to the staged bundle content.
const mainJs = path.join(second.layout.pluginDir, 'main.js');
expect(fs.readFileSync(mainJs, 'utf-8')).toContain('fake bundled plugin');
});
it('writes [remote-ssh] only into shadow community-plugins.json on first bootstrap (does not auto-install source plugins)', async () => {
fs.writeFileSync(
path.join(scratch.sourceConfigDir, 'community-plugins.json'),