mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Phase B/3. Until now the shadow window booted on its STALE local `.obsidian/` and only pulled the remote config (#342), plugin list (#434) and binaries (B/2) AFTER Obsidian had already read settings at startup — so a setting changed on another machine was a session late. openShadowVaultFor now runs a best-effort, time-boxed pre-spawn pull between bootstrap and spawn (via a new ShadowVaultManager onBootstrapped hook). preSpawnPull builds a STANDALONE SftpClient that does NOT patch the source window's vault adapter (so the user's real vault is never hijacked), pulls shared config + plugin list + binaries into the shadow dir, then disconnects. The window then boots on canonical config — no mid-session reload. Safety: - non-interactive by design: the kbd-interactive + host-key callbacks REJECT instead of prompting, so a 2FA / unknown-host profile falls through to the shadow window (which prompts exactly once) — no double prompt, no surprise modal in the source window - time-boxed (connectTimeoutMs + 8s) via new withTimeout util; a slow link falls back to spawn-and-catch-up - the manager swallows any hook throw, so the spawn NEVER blocks - disconnects in finally; first connect to an unknown host falls back and the next (host now trusted) gets the canonical boot Tests: withTimeout (4) + ShadowVaultManager hook order/fallback (2). The actual SSH connect + canonical-boot effect needs a manual Obsidian smoke (can't run headless) — the fallback path IS unit-tested. Refs #429 #342 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
4.3 KiB
TypeScript
108 lines
4.3 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { ShadowVaultManager } from '../src/shadow/ShadowVaultManager';
|
|
import type { ShadowVaultBootstrap, BootstrapResult } from '../src/shadow/ShadowVaultBootstrap';
|
|
import type { WindowSpawner } from '../src/shadow/WindowSpawner';
|
|
import type { SshProfile } from '../src/types';
|
|
|
|
function makeProfile(id: string, name = id): SshProfile {
|
|
return {
|
|
id, name,
|
|
host: 'h', port: 22, username: 'u', authMethod: 'privateKey',
|
|
remotePath: '~/v/', privateKeyPath: '/dev/null',
|
|
connectTimeoutMs: 5000, keepaliveIntervalMs: 10000, keepaliveCountMax: 3,
|
|
} as SshProfile;
|
|
}
|
|
|
|
function makeResult(): BootstrapResult {
|
|
return {
|
|
layout: {
|
|
vaultDir: '/tmp/v', configDir: '/tmp/v/.obsidian',
|
|
pluginDir: '/tmp/v/.obsidian/plugins/remote-ssh',
|
|
pluginDataFile: '/tmp/v/.obsidian/plugins/remote-ssh/data.json',
|
|
},
|
|
registryId: 'abc', registryCreated: false, pluginInstallMethod: 'symlink',
|
|
};
|
|
}
|
|
|
|
describe('ShadowVaultManager.openShadowFor', () => {
|
|
it('runs bootstrap then spawn, in that order, with the right args', async () => {
|
|
const order: string[] = [];
|
|
const fakeResult: BootstrapResult = {
|
|
layout: {
|
|
vaultDir: '/tmp/v', configDir: '/tmp/v/.obsidian',
|
|
pluginDir: '/tmp/v/.obsidian/plugins/remote-ssh',
|
|
pluginDataFile: '/tmp/v/.obsidian/plugins/remote-ssh/data.json',
|
|
},
|
|
registryId: 'abc', registryCreated: true, pluginInstallMethod: 'symlink',
|
|
};
|
|
const bootstrap = {
|
|
bootstrap: vi.fn(async (..._args: unknown[]) => { order.push('bootstrap'); return fakeResult; }),
|
|
} as unknown as ShadowVaultBootstrap;
|
|
const spawner = {
|
|
spawn: vi.fn((..._args: unknown[]) => { order.push('spawn'); return ''; }),
|
|
} as unknown as WindowSpawner;
|
|
|
|
const profile = makeProfile('p1');
|
|
const all = [profile, makeProfile('p2')];
|
|
const result = await new ShadowVaultManager(bootstrap, spawner).openShadowFor(profile, all);
|
|
|
|
expect(order).toEqual(['bootstrap', 'spawn']);
|
|
expect(bootstrap.bootstrap).toHaveBeenCalledWith(profile, all);
|
|
expect(spawner.spawn).toHaveBeenCalledWith('/tmp/v');
|
|
expect(result).toBe(fakeResult);
|
|
});
|
|
|
|
it('does NOT spawn if bootstrap throws', async () => {
|
|
const bootstrap = {
|
|
bootstrap: vi.fn(async () => { throw new Error('disk full'); }),
|
|
} as unknown as ShadowVaultBootstrap;
|
|
const spawner = {
|
|
spawn: vi.fn(() => ''),
|
|
} as unknown as WindowSpawner;
|
|
|
|
const profile = makeProfile('p1');
|
|
await expect(
|
|
new ShadowVaultManager(bootstrap, spawner).openShadowFor(profile, [profile]),
|
|
).rejects.toThrow(/disk full/);
|
|
expect(spawner.spawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs onBootstrapped AFTER bootstrap and BEFORE spawn, with the bootstrap result', async () => {
|
|
const order: string[] = [];
|
|
const fakeResult = makeResult();
|
|
const bootstrap = {
|
|
bootstrap: vi.fn(async () => { order.push('bootstrap'); return fakeResult; }),
|
|
} as unknown as ShadowVaultBootstrap;
|
|
const spawner = {
|
|
spawn: vi.fn(() => { order.push('spawn'); return ''; }),
|
|
} as unknown as WindowSpawner;
|
|
let seen: BootstrapResult | null = null;
|
|
const hook = vi.fn(async (r: BootstrapResult) => { order.push('hook'); seen = r; });
|
|
|
|
const profile = makeProfile('p1');
|
|
await new ShadowVaultManager(bootstrap, spawner).openShadowFor(profile, [profile], hook);
|
|
|
|
expect(order, 'hook runs between bootstrap and spawn').toEqual(['bootstrap', 'hook', 'spawn']);
|
|
expect(seen).toBe(fakeResult);
|
|
});
|
|
|
|
it('still spawns when onBootstrapped throws (graceful fallback — never blocks the window)', async () => {
|
|
const fakeResult = makeResult();
|
|
const bootstrap = {
|
|
bootstrap: vi.fn(async () => fakeResult),
|
|
} as unknown as ShadowVaultBootstrap;
|
|
const spawner = {
|
|
spawn: vi.fn(() => ''),
|
|
} as unknown as WindowSpawner;
|
|
const hook = vi.fn(async () => { throw new Error('pre-spawn pull failed'); });
|
|
|
|
const profile = makeProfile('p1');
|
|
const result = await new ShadowVaultManager(bootstrap, spawner)
|
|
.openShadowFor(profile, [profile], hook);
|
|
|
|
expect(hook).toHaveBeenCalledOnce();
|
|
expect(spawner.spawn, 'a pre-spawn hook failure must NOT block the spawn')
|
|
.toHaveBeenCalledWith('/tmp/v');
|
|
expect(result).toBe(fakeResult);
|
|
});
|
|
});
|