Merge pull request #436 from sotashimozono/next

release: next → main — stage 1.1.6 (do not merge yet)
This commit is contained in:
sotashimozono 2026-07-03 15:12:47 +09:00 committed by GitHub
commit 9ebc520464
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3646 additions and 534 deletions

View file

@ -63,7 +63,7 @@ jobs:
REMOTE_SSH_PERF: '1'
REMOTE_SSH_PERF_BRANCH: ${{ github.head_ref || github.ref_name }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:

View file

@ -19,7 +19,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '20'
@ -51,7 +51,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '20'
@ -79,7 +79,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=6144
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
# Explicit slug avoids fork/PR ambiguity — Codecov's auto-
# detection can mis-route uploads from PRs opened from forks.
@ -118,7 +118,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
@ -144,7 +144,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '20'
@ -215,7 +215,7 @@ jobs:
run:
working-directory: server
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
# Bumped 1.22 → 1.25 to follow `server/go.mod`'s `go 1.25.0`
@ -239,7 +239,7 @@ jobs:
name: Deploy docker image build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- name: Build deploy/docker image
uses: docker/build-push-action@v7

View file

@ -42,7 +42,7 @@ jobs:
run: echo "Sync PR (main → next) — commitlint skipped (commit range includes autogenerated merge titles + already-linted promotion content)."
- if: github.head_ref != 'main'
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# Need full history so commitlint can walk the PR's
# commit range (base..head). Fetch-depth: 0 is overkill

View file

@ -29,7 +29,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@ -66,7 +66,7 @@ jobs:
# which then survived in the cache and broke every later run.
- name: Cache Obsidian AppImage
id: obsidian-cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/obsidian/Obsidian.AppImage
key: obsidian-appimage-1.8.9-v2

View file

@ -23,7 +23,7 @@ jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# Full history so Quartz CreatedModifiedDate plugin can read git log
fetch-depth: 0

View file

@ -68,7 +68,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@ -101,7 +101,7 @@ jobs:
# run that hit the cache.
- name: Cache Obsidian AppImage
id: obsidian-cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/obsidian/Obsidian.AppImage
key: obsidian-appimage-1.8.9-v2

View file

@ -23,7 +23,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:

View file

@ -53,7 +53,7 @@ jobs:
is_beta: ${{ steps.ver.outputs.is_beta }}
should_release: ${{ steps.gate.outputs.should_release }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
@ -120,7 +120,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '20'
@ -153,7 +153,7 @@ jobs:
outputs:
manifest_sha256: ${{ steps.manifest.outputs.sha256 }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: '1.25'
@ -165,6 +165,23 @@ jobs:
- name: Build cross-platform daemon binaries
run: make -C server cross
- name: Assert the linux/amd64 daemon is statically linked (glibc-independent)
# The host-native linux/amd64 target is the only one Go builds with CGO
# auto-ENABLED; a dynamically-linked build dies on older hosts with
# "GLIBC_x.y not found" — the daemon then never writes its token, RPC
# times out, and the client silently drops to SFTP. Fail the release
# loudly if a CGO regression sneaks back in (server/Makefile sets
# CGO_ENABLED=0 to keep it static).
run: |
set -euo pipefail
f=server/dist/obsidian-remote-server-linux-amd64
info=$(file "$f")
echo "$info"
if echo "$info" | grep -q "dynamically linked"; then
echo "::error::$f is dynamically linked — build with CGO_ENABLED=0 (server/Makefile)"
exit 1
fi
- name: Build daemon-manifest.json (sha256 set)
id: manifest
working-directory: server/dist
@ -229,7 +246,7 @@ jobs:
if: needs.precheck.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0 # needed for changelog generation

View file

@ -31,7 +31,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# Need full history + tags so `git describe --tags
# --abbrev=0 origin/main` resolves the latest release.

View file

@ -52,7 +52,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '20'
@ -75,7 +75,7 @@ jobs:
run:
working-directory: server
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: '1.25'
@ -100,7 +100,7 @@ jobs:
name: trivy fs scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Trivy filesystem scan → SARIF
# `@master` is what aquasecurity/trivy-action's own README
# recommends for "always-latest stable"; their tag namespace
@ -132,7 +132,7 @@ jobs:
run:
working-directory: plugin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '20'
@ -165,7 +165,7 @@ jobs:
run:
working-directory: server
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: '1.25'

View file

@ -37,7 +37,7 @@ jobs:
name: Open sync PR (main → next)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

View file

@ -69,7 +69,7 @@ jobs:
run: echo "Auto-generated PR (actor=${{ github.actor }}, head=${{ github.head_ref }}) — version-check is a no-op."
- if: steps.sync_check.outputs.is_sync != 'true'
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # need both branches for `git show`

View file

@ -11,7 +11,7 @@ Design specs for the major subsystems — the **why** behind decisions, not just
| Doc | What it covers |
|---|---|
| [[shadow-vault\|Shadow vault]] | The "local Obsidian vault that mirrors remote" model — shadow lifecycle, file routing, sync events |
| [[shadow-vault\|Shadow vault]] | The shadow-vault model (a local vault whose patched adapter serves the remote virtually) — lifecycle, file routing, change events |
| [[perf\|Performance]] | Sync latency budget, perf bench, the per-merge baseline tracking on `perf-baseline` branch |
| [[collab\|Collaboration]] | Multi-client editing, conflict handling, the per-client `.obsidian/user/<id>/` workspace partition |
| [[release-pipeline\|Release & deploy pipeline]] | Two-channel release model, `release.yml` signing flow, sync workflow, branch-aware lint/version-check, plugin-side deploy lifecycle |
@ -28,9 +28,9 @@ The daemon is the only thing that touches the actual vault files. The plugin nev
### "Shadow vault is a real local vault"
Obsidian's plugin API is opinionated about vault paths. Rather than forking Obsidian's vault layer to be remote-aware (a multi-month investment), the shadow vault model just creates a real vault on your local disk and syncs files. Every Obsidian feature works without modification.
Obsidian's plugin API is opinionated about vault paths. Rather than forking Obsidian's vault layer to be remote-aware (a multi-month investment), the shadow vault model creates a real local vault but patches its adapter so every file op goes to the remote — the notes are never copied to local disk (only `.obsidian/` lives there). Every Obsidian feature works without modification.
The cost: storage on your local machine grows with what you have opened. The plugin keeps a hot cache of recently-touched files; LRU eviction frees space for files not opened in N days.
The cost: file ops need the remote reachable and pay per-op SSH/RPC latency. An in-memory cache (capped) speeds re-reads of recently-touched files.
### v1.x stability promises

View file

@ -8,13 +8,13 @@ description: "What happens when you click Connect: SSH handshake, daemon auto-de
## The shadow vault model
obsidian-remote-ssh does not edit your remote files directly through Obsidian's vault layer. Instead it:
obsidian-remote-ssh doesn't keep a synced copy of your vault — the remote is the single source of truth. Instead it:
1. Creates a **local shadow vault** under `~/.obsidian-remote/vaults/<profile-id>/`. This is a real Obsidian vault on your local disk.
2. Mirrors the remote vault's files into that shadow vault (lazily — large files are fetched on first open).
3. Watches both sides for changes and syncs them through the SSH-tunneled daemon RPC.
1. Creates a **local shadow vault** under `~/.obsidian-remote/vaults/<profile-id>/` — a real Obsidian vault dir, but only `.obsidian/` config lives on disk.
2. Patches that vault's file adapter so every read/write goes **directly to the remote** over the SSH-tunneled daemon (or SFTP). Your notes are served virtually — never copied to local disk.
3. The daemon's `fs.watch` pushes remote changes back, so the file explorer and open editors update live (~1 s).
Result: Obsidian thinks it's editing a local vault. All Obsidian features (Dataview, Templater, Excalidraw, …) work because they ARE working against a local vault. The "remote-ness" lives in the sync layer, invisible to most plugins.
Result: Obsidian thinks it's editing a local vault, so all features (Dataview, Templater, Excalidraw, …) work — but every file op actually hits the remote. (Plugins that bypass the vault API and use Node `fs` directly are the exception — see #429.)
See [[shadow-vault|Shadow vault architecture]] for the full design.

View file

@ -95,7 +95,7 @@ The default-recommended transport mode — plugin auto-deploys the daemon and sp
The current factory default transport — uses SSH's SFTP subsystem directly (no daemon). Higher per-op latency, no `fs.watch`, but works on hosts where you cannot deploy a binary.
**Shadow vault**
A local-disk Obsidian vault under `~/.obsidian-remote/vaults/<profile-id>/` that mirrors the remote vault. Obsidian thinks it's editing local files; the plugin syncs them to the remote via the daemon. See [[en/architecture/shadow-vault|Shadow vault architecture]].
A local Obsidian vault under `~/.obsidian-remote/vaults/<profile-id>/` whose adapter sends every read/write straight to the remote — the single source of truth, **not** a synced copy. Only `.obsidian/` is on disk; notes are virtual. Exception: plugins writing via raw Node `fs` instead of the vault API stay local (#429). See [[en/architecture/shadow-vault|Shadow vault architecture]].
**Sync workflow** — *`.github/workflows/sync-main-to-next.yml`*
After every push to `main`, opens a PR `main → next` and enables auto-merge so the histories rejoin. Prevents drift between channels.

View file

@ -38,8 +38,8 @@ Numbers in the table reflect a `~/work/VaultDev`-class remote
| Plugin | Access pattern | Status | Notes |
|---|---|---|---|
| Dataview | reads every MD file via `app.vault.cachedRead` for the index, then queries against `app.metadataCache` | ✅ verified-by-harness (#124 F11) | Initial index build does N reads on connect. ReadCache absorbs subsequent queries. Watch for noticeable startup delay on bigger vaults. F11 harness scenario (`plugin/tests/compat/dataview.test.ts`) drives `dv.pages()`-shape queries against a 5-page fixture and asserts frontmatter scalars + aggregations round-trip through `metadataCache.getFileCache()`. |
| Templater | reads templates from a configured folder, evaluates JS, writes through `app.vault.modify` / `create`. `tp.file.path(false)` and the `child_process.exec` cwd path read `adapter.basePath`. | ✅ verified-by-harness (#124 F12) | `basePath` now resolves to the shadow-vault local root via #170, so `tp.file.path(false)` returns a path whose `fs.readFileSync` finds mirrored content. JS user functions that import Node `fs` and write under `basePath` land in the shadow dir, which propagates to the remote. F12 harness scenario (`plugin/tests/compat/templater.test.ts`) drives `tp.file.create_new` against 3 fixture templates (date placeholder, frontmatter + title, no-date) and asserts `vault.create` + `vault.modify` round-trip with `metadataCache` reflecting the new frontmatter. |
| Kanban | stores boards as MD files with YAML frontmatter; standard vault read/write on every drag. Clipboard image paste joins `(adapter as any).basePath` with the attachment path and calls `fs.copyFile`. | 🟡 expected (smoke pending after #170) | Each card move = 1 write. Network latency may show as a noticeable lag on big boards; otherwise fine. Clipboard paste is fixed by #170: `basePath` now resolves to the shadow-vault local root, so `fs.copyFile` lands in the shadow dir. |
| Templater | reads templates from a configured folder, evaluates JS, writes through `app.vault.modify` / `create`. `tp.file.path(false)` and the `child_process.exec` cwd path read `adapter.basePath`. | ✅ verified-by-harness (#124 F12) | `vault.create` / `vault.modify` round-trips — F12 harness (`plugin/tests/compat/templater.test.ts`) drives `tp.file.create_new` against 3 fixtures and asserts frontmatter round-trips via `metadataCache`. #170 makes `tp.file.path(false)` / `exec` cwd non-`undefined`, but a JS user script using Node `fs` only sees the local shadow copy — those writes don't reach the remote (#429). |
| Kanban | stores boards as MD files with YAML frontmatter; standard vault read/write on every drag. Clipboard image paste joins `(adapter as any).basePath` with the attachment path and calls `fs.copyFile`. | 🟡 expected (smoke pending after #170) | Each card move = 1 write (vault API → round-trips; latency may lag big boards). Clipboard image paste uses `fs.copyFile` → local shadow copy only, doesn't reach the remote (#429). |
| Thino | reads/writes daily-note-style files; standard vault API | 🟡 expected | Pure vault API user. No special concerns. |
| Commander | UI plugin: ribbon icons, hotkeys, command macros. Doesn't touch vault files for its own state (uses plugin data via `loadData`/`saveData`, which goes through the patched adapter) | 🟡 expected | If a custom command invokes a different plugin, the wrapped plugin's compatibility applies. |
| Emoji Shortcodes | pure UI typing helper. No filesystem access | ✅ verified-by-architecture | Not affected by adapter patching at all. |
@ -47,7 +47,7 @@ Numbers in the table reflect a `~/work/VaultDev`-class remote
| Meta Bind | input bindings on YAML / inline frontmatter; reads / writes via vault API | 🟡 expected | Each input change writes the host note. Latency is noticeable but functional. |
| Omnisearch | full-text indexer: reads every file in the vault on init + on changes | ⚠️ degraded (expected) | This is the most network-bound plugin in the list. Initial index build on a remote vault can take seconds-to-minutes depending on size. After the warm cache, queries are local. Recommendation: only enable Omnisearch when the connection is stable. |
| QuickLatex | renders LaTeX inline. Pure UI, no FS access | ✅ verified-by-architecture | Not affected. |
| Importer | converts external formats (Evernote `.enex`, etc.) to MD using `path.join(getBasePath(), folder.path)` as `outputDir` for the Yarle Evernote converter (Node `fs.writeFile`) | 🟡 expected (smoke pending after #170) | `getBasePath()` now returns the shadow-vault local root via #170, so the converter writes files into the shadow dir; the file-watcher propagates them to the remote. Initial conversion of a large `.enex` may produce many writes; watch for queue lag. |
| Importer | converts external formats (Evernote `.enex`, etc.) to MD using `path.join(getBasePath(), folder.path)` as `outputDir` for the Yarle Evernote converter (Node `fs.writeFile`) | 🟡 expected (smoke pending after #170) | Yarle converter writes via Node `fs` → local shadow copy only, doesn't reach the remote (#429). To import to a remote vault, convert in a local vault and move the result over. |
| Copilot | reads `getBasePath?.()` then falls back to `basePath` for local-context AI indexing (`src/miyo/miyoUtils.ts`) | 🟡 expected (smoke pending after #170) | Either form now resolves to the shadow-vault local root via #170, so Copilot's local-context indexing operates against the synced copy. The remote is the source of truth; the index reflects whatever has been mirrored to the shadow dir. |
| Git (Vinzent03) | desktop hardcodes `SimpleGit` (spawns local `git` against `getBasePath()`); mobile uses `IsomorphicGit` via `MyAdapter(vault.adapter)` (pure JS, no shell-out) | ❌ broken on desktop / fixable upstream | The desktop path silently mis-routes commits to the shadow git repo, not the remote. The isomorphic-git path *would* work transparently against our remote adapter but is gated behind `Platform.isDesktopApp` with no toggle. **We won't ship a workaround** — see [#150](https://github.com/sotashimozono/obsidian-remote-ssh/issues/150) for the rationale. Users wanting git on a remote vault should use the integrated terminal pane ([#149](https://github.com/sotashimozono/obsidian-remote-ssh/issues/149)) or file a feature request at Vinzent03/obsidian-git for a `forceIsomorphicGit` toggle. |
| Excalidraw | drawings stored as `.excalidraw.md` (JSON) or embedded markdown; embedded images go through `getResourcePath`. `pathToFileURL(adapter.basePath)` is used as a vault-membership prefix check (`src/utils/fileUtils.ts:343`). | ✅ verified-by-harness (#124 F13) | The `RPC` transport is required for the ResourceBridge to serve images. On `SFTP` transport, embedded images fall back to a broken `data:` URL. First read of a large `.excalidraw.md` pulls the whole JSON; subsequent edits stream cleanly. After #170 the prefix check stays internally consistent (both sides see the shadow path). F13 harness scenario (`plugin/tests/compat/excalidraw.test.ts`) covers `.excalidraw.md` text round-trip + binary attachment CRUD with byte-exact equality on a 1 KB cyclic blob and a 4 KB PNG-magic + xorshift32 payload. |
@ -130,15 +130,15 @@ Things that aren't a specific plugin but trip plugins in general:
- **`app.vault.adapter.basePath` and `getBasePath()`** resolve to the
**shadow vault's** local root (e.g. `~/.obsidian-remote/vaults/<P-id>/`),
not the remote SSH path. Plugins that join paths against `basePath`
and feed them to Node `fs` directly read mirrored content and write
into the shadow dir; the file-watcher then propagates writes back to
the remote. This is the natural value of `FileSystemAdapter.basePath`
in the shadow window, and #170 patches both forms onto the
replacement adapter explicitly so the contract is stable across
Obsidian version upgrades. See the **basePath compat survey** section
below (#133, 2026-04-29) for the top-20 plugin survey, and #170 for
the implementation. **Exception:** plugins that shell out to a local
not the remote SSH path; #170 patches both so readers don't crash on
`undefined`. But the vault tree is **virtual** — served from the
remote, not mirrored to disk (only `.obsidian/` lives there). So
`basePath` + Node `fs` only touches the local shadow copy: `fs` reads
miss remote notes, `fs` writes don't reach the remote. Only the vault
API round-trips (`.obsidian/` config via a dedicated watcher,
#342/#434). See *Direct-disk / agentic plugins* (#429) and the
**basePath survey** (#133) — its "syncs up to the remote" mitigations
assumed a vault-tree watcher that was never built. **Exception:** plugins that shell out to a local
binary (notably obsidian-Git's `SimpleGit` desktop path) operate on
the shadow git repo rather than the remote one — patching can't fix
this from our side. obsidian-Git's bundled `IsomorphicGit` mode
@ -163,6 +163,13 @@ Things that aren't a specific plugin but trip plugins in general:
image-processing plugins outside the top-20 use this pattern
(see survey section below). No webview-side URL rewriting is
needed at this time.
- **Direct-disk / agentic plugins** (e.g. Claude Code wrappers like
"Claudian") write via Node `fs` / child processes, bypassing the
adapter — so their files stay in the local shadow copy and never
reach the remote ([#429](https://github.com/sotashimozono/obsidian-remote-ssh/issues/429)).
No local→remote reconciler exists for the vault tree (adding one =
the sync model this single-source-of-truth design avoids). Use
plugins that go through the vault API.
## Why we can't auto-test all of this
@ -274,6 +281,14 @@ compare, child-process cwd, etc.).
in a real dev vault remains a manual step; the matrix above keeps
these at `🟡 expected (smoke pending after #170)` until run.
> **⚠️ Correction (2026-06-30, #429).** The "syncs up to the remote"
> mitigations below assumed a shadow→remote file-watcher that was
> **never built** for the vault tree (the only local→remote watcher is
> `SharedConfigWatcher`, `.obsidian/`-only, #342/#434). #170 made
> `basePath` *return* the local root (stops `undefined` crashes) but
> created no mirror or sync path — `fs` writes stay local (#429). Read
> "fixed by #170" as "no longer crashes," not "round-trips."
### Method
- Registry source: `community-plugins.json` +

View file

@ -1,7 +1,7 @@
{
"id": "remote-ssh",
"name": "Remote SSH",
"version": "1.1.5",
"version": "1.1.6",
"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.5",
"version": "1.1.6",
"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.5",
"version": "1.1.6",
"minAppVersion": "1.5.0",
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
"author": "souta shimozono",

616
plugin/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-remote-ssh",
"version": "1.1.5",
"version": "1.1.6",
"description": "VS Code Remote SSH-like experience for Obsidian",
"main": "main.js",
"scripts": {
@ -44,14 +44,14 @@
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
"@playwright/test": "^1.60.0",
"@types/node": "^25.9.3",
"@playwright/test": "^1.61.1",
"@types/node": "^26.0.1",
"@types/ssh2": "^1.11.19",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.59.2",
"@vitest/coverage-v8": "^4.1.8",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"eslint": "^10.5.0",
"eslint": "^10.6.0",
"eslint-plugin-obsidianmd": "^0.3.0",
"fast-check": "^4.8.0",
"jsdom": "^29.1.1",

View file

@ -82,6 +82,13 @@ if (result.status !== 0) {
console.log(`build-server: staged ${path.relative(repoRoot, outPath)} (${prettySize(fs.statSync(outPath).size)})`);
// Mark the staged binary as a DEV build so the plugin's locateDaemonBinary
// trusts it over a download (it now requires this marker). dev-install.mjs and
// the E2E vault-scaffold copy server-bin/ verbatim, so the marker rides along;
// build:server on its own (the E2E path, no dev-install) would otherwise leave
// the daemon unmarked → gated out → RPC unavailable in the Obsidian smoke run.
fs.writeFileSync(path.join(stageDir, '.dev-daemon'), '');
function locateGo() {
const fromPath = which('go') ?? which('go.exe');
if (fromPath) return fromPath;

View file

@ -69,6 +69,13 @@ if (fs.existsSync(serverBinDir)) {
fs.copyFileSync(src, dst);
console.log(`copied server-bin/${f} -> ${dst}`);
}
// Mark this as a DEV-staged daemon so the plugin trusts it over a
// downloaded binary: locateDaemonBinary returns a staged binary only when
// this marker is present. Without it, a *downloaded* binary at the same
// filename would masquerade as a dev build and skip the version/sha
// re-check (so a changed daemon would never get refreshed).
fs.writeFileSync(path.join(binTarget, '.dev-daemon'), '');
console.log('wrote server-bin/.dev-daemon marker');
} else {
console.log('server-bin/ not present — skipping daemon staging (run `npm run build:server` to build it)');
}

View file

@ -119,9 +119,11 @@ export class SftpDataAdapter {
* `getBasePath()` method return this value, so plugins that read
* `adapter.basePath` (Templater's `tp.file.path`, Kanban's clipboard
* paste, Importer, Copilot see `docs/en/user-guide/plugin-compatibility.md`)
* receive a path on the shadow vault. Reads against that path
* succeed against files mirrored locally by the file watcher;
* writes land in the shadow dir and propagate to the remote.
* receive the shadow-vault root (so they don't crash on
* `undefined`). But the vault tree is virtual served from the
* remote, not mirrored to disk so raw Node `fs` here only touches
* the local shadow copy: reads miss remote notes, writes don't
* reach the remote. Only the vault API round-trips (#429).
*
* Defaults to `''` for tests that never exercise these members. The
* production wiring in `main.ts` always passes the real shadow
@ -142,11 +144,11 @@ export class SftpDataAdapter {
) {}
/**
* Mirror of `FileSystemAdapter.basePath`. Returns the absolute path
* of the shadow vault's local root. Plugins that join paths against
* this value and call Node `fs` directly read mirrored content and
* write into the shadow dir, where the file watcher propagates to
* the remote.
* Mirror of `FileSystemAdapter.basePath`: the shadow-vault local
* root. The vault tree is virtual (not mirrored to disk), so raw
* `fs` against this path only touches the local shadow copy writes
* don't reach the remote (#429); only the vault API round-trips.
* #170 returns a defined path so plugins don't crash.
*/
get basePath(): string {
return this.shadowBasePath;

View file

@ -16,6 +16,12 @@ export const DEFAULT_WALK_IGNORE_DIRS: readonly string[] = [
'__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache',
'target', 'dist', 'build', '.next', '.nuxt', '.cache', '.gradle',
'.idea', 'vendor', '.terraform',
// Language / toolchain caches — often enormous (a populated `.julia`
// or `.cargo` is 100k+ files of pure noise). Dot-dirs are hidden from
// the File Explorer anyway (BulkWalker filters them); listing them
// here ALSO prunes them server-side so the daemon never descends into
// or transfers them — the perf half of "hide `.julia` by default".
'.julia', '.cargo', '.rustup', '.npm', '.conda', '.gem',
];
export const DEFAULT_PROFILE: Omit<SshProfile, 'id' | 'name'> = {
@ -39,6 +45,10 @@ export const DEFAULT_SETTINGS: PluginSettings = {
reconnectMaxRetries: 5,
clientId: '',
userName: '',
// Deepen the remote tree one folder level at a time on File-Explorer expand,
// rather than eager-walking the whole (potentially huge, deep) tree at
// connect. Default on; safe to turn off for small vaults.
lazyFolderLoad: true,
// Phase 4 marker for shadow vaults; null on a normal vault. Only
// `ShadowVaultBootstrap` writes a non-null value.
autoConnectProfileId: null,

View file

@ -24,9 +24,11 @@ import { classifyToNotice } from './transport/errorTaxonomy';
import { VaultModelBuilder } from './vault/VaultModelBuilder';
import { FsChangeListener } from './vault/FsChangeListener';
import { BulkWalker } from './vault/BulkWalker';
import { LazyFolderLoader } from './vault/LazyFolderLoader';
import { RenameLeafFollower } from './vault/RenameLeafFollower';
import { ObsidianRegistry } from './shadow/ObsidianRegistry';
import { ShadowVaultBootstrap } from './shadow/ShadowVaultBootstrap';
import type { SharedConfigReader, BootstrapResult } from './shadow/ShadowVaultBootstrap';
import { SharedConfigWatcher } from './shadow/SharedConfigWatcher';
import { ShadowVaultManager } from './shadow/ShadowVaultManager';
import { WindowSpawner } from './shadow/WindowSpawner';
@ -34,6 +36,9 @@ import { ShadowStartupCoordinator } from './shadow/ShadowStartupCoordinator';
import * as os from 'os';
import { ObservabilityInstaller } from './util/ObservabilityInstaller';
import { normalizeRemotePath } from './util/pathUtils';
import { PathMapper } from './path/PathMapper';
import { preSpawnRemotePath } from './shadow/preSpawnPaths';
import { withTimeout } from './util/withTimeout';
import * as path from 'path';
import { errorMessage } from "./util/errorMessage";
import { ConnectionManager, DaemonUnavailableError } from "./ConnectionManager";
@ -42,7 +47,9 @@ import {
ensureDaemonBinary as downloadDaemonBinary,
resolveDaemonConsent,
DaemonVerificationError,
binaryFilename,
} from './transport/DaemonDownloader';
import { createHash } from 'crypto';
import { TransferTracker } from "./util/TransferTracker";
import { LargeTransferBar } from "./ui/LargeTransferBar";
import { OnboardingModal } from "./ui/OnboardingModal";
@ -470,18 +477,21 @@ export default class RemoteSshPlugin extends Plugin {
const ver = this.conn.rpcConnection?.info.version ?? '?';
rpcSummary = ` — daemon ${ver}, ${caps} capabilities`;
} catch (e) {
if (e instanceof DaemonUnavailableError) {
// Unsupported remote arch, or the user declined the daemon
// download → keep the session on SFTP. Vault read/write/sync
// still work; daemon-only features (fast walk, thumbnails, live
// watch, image/PDF rendering) are unavailable.
logger.warn(`RPC unavailable, falling back to SFTP: ${e.message}`);
new Notice('Remote SSH: daemon unavailable — connected via SFTP (reduced features).');
transport = 'sftp';
} else {
// The daemon is an optimization layer, not a requirement. Most
// failures to bring it up degrade to SFTP rather than failing the
// whole connect — a hard failure skips populate and strands the user
// on an EMPTY vault. SFTP shares the same live SSH channel, so
// read/write/sync still work (minus daemon-only features: fast walk,
// thumbnails, live watch, image/PDF rendering).
//
// EXCEPTION: a daemon binary that fails integrity verification
// (checksum mismatch / bad manifest) fails LOUD and does NOT
// downgrade — silently falling back could mask a tampered or corrupt
// binary (supply-chain safety, #406).
if (e instanceof DaemonVerificationError) {
this.setState(SyncState.ERROR);
const { notice, classified } = classifyToNotice(e);
logger.error(`RPC startup failed: ${classified.title}`, {
logger.error(`RPC startup failed (verification): ${classified.title}`, {
category: classified.category, code: classified.code,
original: classified.original.message, profileId: profile.id,
});
@ -489,6 +499,22 @@ export default class RemoteSshPlugin extends Plugin {
try { await this.conn.client.disconnect(); } catch { /* ignore */ }
return;
}
if (e instanceof DaemonUnavailableError) {
// Expected: unsupported remote arch, or the user declined download.
logger.warn(`RPC unavailable, falling back to SFTP: ${e.message}`);
new Notice('Remote SSH: daemon unavailable — connected via SFTP (reduced features).');
} else {
// Unexpected daemon-start failure (deploy / token-write timeout /
// handshake mismatch). Log the classified cause for diagnosis, but
// still degrade to SFTP instead of leaving the vault empty.
const { classified } = classifyToNotice(e);
logger.warn(`RPC startup failed, falling back to SFTP: ${classified.title}`, {
category: classified.category, code: classified.code,
original: classified.original.message, profileId: profile.id,
});
new Notice('Remote SSH: daemon failed to start — connected via SFTP (reduced features). See console.log.');
}
transport = 'sftp';
}
}
@ -558,12 +584,12 @@ export default class RemoteSshPlugin extends Plugin {
return;
}
// Pull the shared Obsidian config (app.json / appearance.json /
// core-plugins.json / hotkeys.json) from the remote onto the
// local shadow disk *before* the populate, so the next time this
// window restarts Obsidian reads fresh settings instead of the
// stale local copy (#342). Best-effort: a failure here must not
// block rendering the vault.
// Pull this device's Obsidian config (app.json / appearance.json /
// core-plugins.json / hotkeys.json — now per-client via PathMapper)
// from the remote onto the local shadow disk *before* the populate, so
// the next time this window restarts Obsidian reads fresh settings
// instead of the stale local copy (#342). Best-effort: a failure here
// must not block rendering the vault.
const da = this.adapterMgr.dataAdapter;
const hostAdapter = this.app.vault.adapter;
if (da && hostAdapter instanceof FileSystemAdapter) {
@ -582,7 +608,7 @@ export default class RemoteSshPlugin extends Plugin {
// settings silently not update — the #342 symptom. Absent
// files are not errored, so a fresh vault stays quiet.
new Notice(
`Remote SSH: ${cfg.errored.length} shared-config file` +
`Remote SSH: ${cfg.errored.length} config file` +
`${cfg.errored.length === 1 ? '' : 's'} (${cfg.errored.join(', ')}) ` +
'could not be synced — settings may be stale until the next connect',
);
@ -593,6 +619,56 @@ export default class RemoteSshPlugin extends Plugin {
);
}
// #429 / #342 residual: round-trip the enabled community-plugins
// list. Pull first so plugins set up on the remote load in the
// shadow vault (the marketplace installer then fetches any missing
// binaries); then push the merged result so a plugin present only
// locally reaches the remote for other machines. Pushing *after*
// the pull is safe — the local list is now the union, so it can
// never drop a plugin the remote already had. Kept out of the
// verbatim shared-config set because `remote-ssh` must be
// force-preserved through the merge (a verbatim copy of a remote
// list omitting it would disable this very plugin).
try {
await ShadowVaultBootstrap.pullCommunityPlugins(da, remoteConfigDir, localConfigDir);
await ShadowVaultBootstrap.pushCommunityPlugins(da, remoteConfigDir, localConfigDir);
} catch (e) {
logger.warn(
`runAutoConnect(${tag}): community-plugins round-trip failed: ${errorMessage(e)}`,
);
}
// #429b: the startup installer (prepareForAutoConnect) ran BEFORE
// the pull above — and not at all on a reconnect — so a plugin the
// pull just added to community-plugins.json has no binary staged yet
// and won't load. Re-run the marketplace installer now the list is
// current; `enablePluginAndSave` loads a marketplace plugin live, no
// restart. Idempotent (already-installed ids are skipped). BRAT /
// non-marketplace plugins still need their binary on the remote.
try {
await new ShadowStartupCoordinator(this.app, this.settings, () => this.saveSettings())
.installMissingShadowPlugins();
} catch (e) {
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.
@ -616,7 +692,7 @@ export default class RemoteSshPlugin extends Plugin {
);
if (r.errored.length > 0) {
new Notice(
`Remote SSH: ${r.errored.length} shared-config file` +
`Remote SSH: ${r.errored.length} config file` +
`${r.errored.length === 1 ? '' : 's'} (${r.errored.join(', ')}) ` +
'could not be pushed — settings change not yet saved remotely',
);
@ -770,6 +846,9 @@ export default class RemoteSshPlugin extends Plugin {
this.sharedConfigWatcher?.stop();
this.sharedConfigWatcher = null;
this.adapterMgr.restore();
// Drop lazy-load state — the walker it captured is now disconnected. A
// stray File-Explorer click after this finds a null loader and no-ops.
this.lazyLoader = null;
await this.conn.disconnectTransport();
this.setState(SyncState.IDLE);
if (this.settings.activeProfileId !== null) {
@ -811,6 +890,43 @@ export default class RemoteSshPlugin extends Plugin {
}
}
/** Lazy per-folder loader (deepen-on-expand); null until a lazy connect. */
private lazyLoader: LazyFolderLoader | null = null;
private lazyExpandHookInstalled = false;
/** A fresh BulkWalker bound to the current session's transport + ignore list. */
private makeWalker(): BulkWalker {
return new BulkWalker({
adapter: this.app.vault.adapter,
rpcConnection: this.conn.rpcConnection ?? undefined,
// Older profiles have no walkIgnoreDirs → fall back to the sensible
// defaults so existing users immediately benefit. An explicit empty
// array (user cleared it) means "ignore nothing" and is respected.
ignoreDirs: this.conn.activeProfile?.walkIgnoreDirs ?? [...DEFAULT_WALK_IGNORE_DIRS],
});
}
/**
* One delegated, capture-phase click listener that deepens a folder the
* first time it's expanded in File Explorer. Obsidian renders folder
* children from the in-memory model, does NOT re-list on expand, and exposes
* no public folder-expand event hence the DOM hook on `.nav-folder-title`
* (which carries the folder's `data-path`; verified in a real-Obsidian
* spike). Idempotent per folder via LazyFolderLoader, so a click on an
* already-loaded folder (or a collapse) is a cheap no-op. Installed once;
* `registerDomEvent` tears it down on unload.
*/
private installLazyExpandHook(): void {
if (this.lazyExpandHookInstalled) return;
this.lazyExpandHookInstalled = true;
this.registerDomEvent(activeDocument, 'click', (evt) => {
const title = (evt.target as HTMLElement | null)?.closest?.('.nav-folder-title');
const path = title?.getAttribute('data-path');
if (path == null) return;
void this.lazyLoader?.loadFolder(path);
}, { capture: true });
}
/**
* POC for the shadow-vault architecture (see
* docs/en/architecture/shadow-vault.md, Phase 1): walk the patched
@ -848,27 +964,37 @@ export default class RemoteSshPlugin extends Plugin {
// mtime+size per entry) when the active session is RPC AND the
// daemon advertises the capability. Otherwise BulkWalker
// transparently runs the legacy BFS via the patched adapter.
const walker = new BulkWalker({
adapter: this.app.vault.adapter,
rpcConnection: this.conn.rpcConnection ?? undefined,
// Older profiles have no walkIgnoreDirs → fall back to the
// sensible defaults so existing users immediately benefit. An
// explicit empty array (user cleared it) means "ignore nothing"
// and is respected (?? only fills null/undefined).
ignoreDirs: this.conn.activeProfile?.walkIgnoreDirs
?? [...DEFAULT_WALK_IGNORE_DIRS],
});
const walk = await walker.walk('');
const walker = this.makeWalker();
// Lazy mode (default): walk only the ROOT level and deepen each folder on
// first expand, so a deep, dir-heavy vault doesn't pull + materialise tens
// of thousands of entries at connect. `walkIgnoreDirs` is applied per level
// either way. Set `lazyFolderLoad: false` to restore the full eager walk.
const lazy = this.settings.lazyFolderLoad !== false;
const walk = await walker.walk('', !lazy);
logger.info(
`populateVaultFromRemote(${label}): ${walk.source}, ${walk.entries.length} entries ` +
`in ${walk.walkMs}ms (pages=${walk.pages})` +
`(${walk.hiddenCount} hidden) in ${walk.walkMs}ms (pages=${walk.pages})${lazy ? ' [lazy: root level]' : ''}` +
(walk.fastPathError ? ` (fast-path fallback: ${walk.fastPathError})` : ''),
);
const builder = new VaultModelBuilder(this.app.vault, { TFile, TFolder });
const result = await builder.build(walk.entries);
// Chunked so even one level (or a full eager walk) fills the File Explorer
// progressively instead of freezing the window while every entry is
// materialised + `create`-triggered in a single JS tick.
const result = await builder.buildChunked(walk.entries);
const totalMs = Date.now() - start;
if (lazy) {
// Obsidian renders folders from the in-memory model and does NOT re-list
// on expand, so deepen-on-expand is driven by a File-Explorer click hook.
this.lazyLoader = new LazyFolderLoader(
() => this.makeWalker(),
() => new VaultModelBuilder(this.app.vault, { TFile, TFolder }),
);
this.lazyLoader.markLoaded('');
this.installLazyExpandHook();
}
const summary =
`${result.filesAdded}f + ${result.foldersAdded}d built, ` +
`${result.skipped} skipped, ${result.errors.length} errors (${totalMs}ms)`;
@ -883,8 +1009,12 @@ export default class RemoteSshPlugin extends Plugin {
// vault (the silent "remote files won't open" symptom). Surface it.
if (walk.entries.length === 0) {
new Notice(
'Remote SSH: 0 files found on the remote. Check the profiles ' +
'remotePath actually points at the vault (see console.log).',
walk.hiddenCount > 0
? `Remote SSH: 0 visible files — all ${walk.hiddenCount} walked ` +
'entries are hidden dot-files (e.g. content under a “.”-prefixed ' +
'folder). Rename them if they should appear in the vault.'
: 'Remote SSH: 0 files found on the remote. Check the profiles ' +
'remotePath actually points at the vault (see console.log).',
10_000,
);
} else if (walk.truncated) {
@ -958,26 +1088,34 @@ export default class RemoteSshPlugin extends Plugin {
logger.warn(`openShadowVaultFor: pre-spawn settings flush failed (${errorMessage(e)}); continuing to spawn`);
}
const result = await manager.openShadowFor(profile, this.settings.profiles);
const result = await manager.openShadowFor(
profile, this.settings.profiles,
// #429b / Phase B-3: pull canonical .obsidian/ before the window
// boots. Best-effort + time-boxed inside preSpawnPull; the manager
// swallows any throw so a slow/failed pull never blocks the spawn.
(r) => this.preSpawnPull(profile, r),
);
const how = result.pluginInstallMethod;
const reg = result.registryCreated ? 'newly registered' : 'reused';
const reg = result.registryCreated ? 'newly registered' : result.migrated ? 'migrated' : 'reused';
logger.info(
`openShadowVaultFor: profile=${profile.name}, vault=${result.layout.vaultDir}, ` +
`registry id=${result.registryId} (${reg}), plugin=${how}`,
);
if (result.registryCreated) {
// First-ever open of this profile: the vault was just added to
// obsidian.json, but the already-running Obsidian only reads that
// file at startup — so the obsidian://open we just fired is a no-op
// and no window appears. Surface a PERSISTENT notice (duration 0)
// telling the user to restart, rather than the misleading "opened in
// new window" that never actually opened. Only happens once per
// profile (subsequent connects hit the `reused` branch).
if (result.registryCreated || result.migrated) {
// The vault's path in obsidian.json is new to the already-running
// Obsidian (it only reads that file at startup), so the
// obsidian://open we just fired is a no-op and no window appears.
// This happens on a profile's first-ever open (registryCreated) and
// the one-time legacy→friendly dir rename (migrated). Surface a
// PERSISTENT notice (duration 0) telling the user to restart, rather
// than the misleading "opened in new window" that never opened.
const why = result.registryCreated
? `"${profile.name}" is a newly registered vault.`
: `"${profile.name}" was renamed to a friendlier vault name.`;
new Notice(
`Remote SSH: "${profile.name}" is a newly registered vault. Obsidian only ` +
'loads new vaults at startup, so it cannot open it this session — fully ' +
'quit and reopen Obsidian, then click Connect again. (Only needed the ' +
'first time you open a profile.)',
`Remote SSH: ${why} Obsidian only loads vaults at startup, so it cannot ` +
'open it this session — fully quit and reopen Obsidian, then click Connect ' +
'again. (One-time.)',
0,
);
} else {
@ -1001,6 +1139,76 @@ export default class RemoteSshPlugin extends Plugin {
}
}
/**
* Pre-spawn pull (#429b / Phase B-3). Before the shadow window opens,
* pull the remote `.obsidian/` shared config (#342), the enabled-
* plugins list, and plugin binaries (#429b) into the freshly
* bootstrapped shadow dir, so the window boots on the CANONICAL remote
* config instead of a stale local copy (no mid-session settings reload).
*
* Strictly best-effort and time-boxed. It builds a STANDALONE
* `SftpClient` that does NOT patch this (source) window's vault adapter,
* so the user's real vault is never hijacked. The kbd-interactive and
* host-key callbacks REJECT rather than prompt: pre-spawn must be
* non-interactive, so a 2FA / unknown-host connect falls through to the
* shadow window (which prompts exactly once) no double prompt, no
* surprise modal in the source window. On ANY error or timeout it logs
* and returns; `ShadowVaultManager` then spawns and the shadow window's
* own connect catches up. Never throws.
*/
private async preSpawnPull(profile: SshProfile, result: BootstrapResult): Promise<void> {
const localConfigDir = result.layout.configDir;
const remoteConfigDir = this.app.vault.configDir; // ".obsidian"
const remoteBase = normalizeRemotePath(profile.remotePath);
// Apply the SAME per-client redirect the shadow window's adapter will use
// (AdapterManager builds `new PathMapper(resolveClientId(settings), configDir)`),
// so the four now-per-device config files (app/appearance/core-plugins/hotkeys)
// are pulled from THIS client's `<configDir>/user/<id>/` subtree — NOT the dead
// shared identity path. Without this, pre-spawn read the shared path and
// clobbered the per-device config on every spawn, undoing the redirect.
// `PathMapper.toRemote` is identity for non-private paths (community-plugins.json,
// plugins/…), so those still round-trip shared, unchanged.
const mapper = new PathMapper(ConnectionManager.resolveClientId(this.settings), remoteConfigDir);
const toRemote = (p: string): string => preSpawnRemotePath(mapper, remoteBase, p);
const client = new SftpClient(
this.authResolver,
this.hostKeyStore,
() => Promise.reject(new Error('pre-spawn: keyboard-interactive deferred to shadow window')),
() => Promise.reject(new Error('pre-spawn: host-key prompt deferred to shadow window')),
);
const reader: SharedConfigReader = {
exists: (p) => client.exists(toRemote(p)),
read: (p) => client.readText(toRemote(p)),
};
// Bound the whole connect+pull so a slow link can't stall the window
// open — beyond the budget we fall back to spawn-and-catch-up.
const budgetMs = (profile.connectTimeoutMs || 15_000) + 8_000;
// Run as a standalone promise: if the budget fires first, `finally`
// disconnects the client and any read still in flight then throws
// "not connected". That settles `pull` a SECOND time, after the race
// already lost — an unhandledrejection in the renderer unless we keep
// a handler on it. The real-error diagnostics still flow through the
// `withTimeout` race into the catch below; this handler only mops up
// the expected post-disconnect noise.
const pull = (async () => {
await client.connect(profile);
await ShadowVaultBootstrap.pullSharedObsidianConfig(reader, remoteConfigDir, localConfigDir);
await ShadowVaultBootstrap.pullCommunityPlugins(reader, remoteConfigDir, localConfigDir);
const enabledIds = ShadowVaultBootstrap.readEnabledPluginIds(localConfigDir);
await ShadowVaultBootstrap.pullPluginBinaries(reader, remoteConfigDir, localConfigDir, enabledIds);
})();
pull.catch(() => { /* post-timeout teardown error — handled via the race */ });
try {
await withTimeout(pull, budgetMs, 'pre-spawn pull');
logger.info(`preSpawnPull: staged canonical .obsidian/ before spawn (${profile.name})`);
} catch (e) {
logger.warn(`preSpawnPull: skipped (${errorMessage(e)}); shadow window will sync after open`);
} finally {
try { await client.disconnect(); } catch { /* best effort */ }
}
}
/**
* Manual command-palette entry point for adapter patching. Used
* during development to inspect pre-patch behaviour or to re-patch
@ -1148,10 +1356,32 @@ export default class RemoteSshPlugin extends Plugin {
private locateDaemonBinary(): string | null {
const pluginDir = this.pluginDir();
if (!pluginDir) return null;
const candidate = path.join(pluginDir, 'server-bin', 'obsidian-remote-server-linux-amd64');
const serverBin = path.join(pluginDir, 'server-bin');
// Only treat a staged binary as a genuine DEV build when dev-install
// marked it (`.dev-daemon`). Without this, a *downloaded* binary at the
// same filename would masquerade as a dev build and bypass the version /
// sha refresh in ensureDaemonBinary — the exact reason a plugin upgrade
// kept re-deploying a stale (dynamically-linked) daemon.
if (!fs.existsSync(path.join(serverBin, '.dev-daemon'))) return null;
const candidate = path.join(serverBin, 'obsidian-remote-server-linux-amd64');
return fs.existsSync(candidate) ? candidate : null;
}
/**
* sha256 (hex) of a cached daemon binary, or null if it can't be read.
* Used by the connect fast-path to re-validate the cached bytes without a
* network round-trip. Reads the whole file (a daemon binary is a few MB)
* cheap next to the SSH connect it gates.
*/
private async sha256File(abs: string): Promise<string | null> {
try {
const buf = await fs.promises.readFile(abs);
return createHash('sha256').update(buf).digest('hex');
} catch {
return null;
}
}
/**
* Acquire a daemon binary for the REMOTE's os/arch when one isn't staged
* locally. Community-store installs don't ship `server-bin/`, so we probe
@ -1189,6 +1419,57 @@ export default class RemoteSshPlugin extends Plugin {
if (!pluginDir) return null;
const cacheDir = path.join(pluginDir, 'server-bin');
// R3: `server-bin` must be a REAL per-shadow dir. Older installs (and dev
// symlink installs) could leave it as a junction to ANOTHER vault's
// server-bin (installPlugin used to propagate it); if that target vault was
// later deleted, the junction dangles and mkdir throws a raw ENOENT — the
// connect then silently degrades to SFTP. Try a plain mkdir first (a valid
// dir/junction is fine); on failure, unlink a stale reparse point (never
// following into / deleting its target) and recreate a real dir. Only give
// up to SFTP if even that fails.
try {
fs.mkdirSync(cacheDir, { recursive: true });
} catch (firstErr) {
let repaired = false;
try {
// lstat succeeds for a dangling junction; unlink drops the link only.
if (fs.lstatSync(cacheDir).isSymbolicLink()) {
fs.unlinkSync(cacheDir);
fs.mkdirSync(cacheDir, { recursive: true });
repaired = true;
}
} catch { /* fall through to the SFTP path below */ }
if (!repaired) {
logger.error(
`ensureDaemonBinary: server-bin unusable (${errorMessage(firstErr)}); staying on SFTP. ` +
'If this persists, delete the shadow vault dir under ~/.obsidian-remote/vaults and reconnect.',
);
new Notice(
'Remote SSH: the daemon cache dir is broken — staying on SFTP. If this persists, ' +
'delete the vault dir under ~/.obsidian-remote/vaults and reconnect.',
);
return null;
}
logger.warn(`ensureDaemonBinary: repaired a stale server-bin link at ${cacheDir}`);
}
// Fast path: reuse the cached binary without a GitHub round-trip when it
// was provisioned for THIS plugin version AND its bytes still hash to the
// recorded sha. The sha re-check upholds the "never deploy an unverified
// binary" invariant even here — a file corrupted/truncated after its
// verified download is caught (network-free) and re-fetched below. A
// version mismatch, missing marker, or sha mismatch falls through to the
// manifest sha re-check / download.
const dest = path.join(cacheDir, binaryFilename(target));
if (
this.settings.daemonBinaryVersion === this.manifest.version &&
this.settings.daemonBinarySha &&
(await this.sha256File(dest)) === this.settings.daemonBinarySha
) {
logger.info(`ensureDaemonBinary: cached daemon validated for ${this.manifest.version}; reusing`);
return dest;
}
// Consent gate (asked once; the decision — accept OR decline — is
// persisted so a decline doesn't re-prompt on every connect / restart).
const consented = await resolveDaemonConsent(
@ -1207,11 +1488,14 @@ export default class RemoteSshPlugin extends Plugin {
fetchBinary: async (url) => new Uint8Array((await requestUrl({ url })).arrayBuffer),
fetchText: async (url) => (await requestUrl({ url })).text,
cacheDir,
cacheHit: (abs) => fs.existsSync(abs),
readCached: async (abs) => {
try { return new Uint8Array(await fs.promises.readFile(abs)); }
catch { return null; }
},
writeExecutable: async (abs, bytes) => {
// Atomic: write a temp sibling, chmod, then rename. A crash
// mid-write can't then leave a torn binary that a later cacheHit
// would hand back unverified (#406 review).
// mid-write can't then leave a torn binary that a later sha
// re-check would hand back unverified (#406 review).
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
const tmp = `${abs}.${process.pid}.tmp`;
await fs.promises.writeFile(tmp, bytes);
@ -1223,7 +1507,22 @@ export default class RemoteSshPlugin extends Plugin {
},
target,
);
new Notice(`Remote SSH: downloaded daemon for ${target.os}/${target.arch}.`);
// Record the version + sha this cached binary is validated for, so the
// next same-version connect takes the network-free fast path above.
// Non-fatal: the binary is verified on disk, so a marker-persist failure
// must NOT be reported as a download failure — we just re-verify on the
// next connect.
try {
this.settings.daemonBinaryVersion = this.manifest.version;
this.settings.daemonBinarySha = (await this.sha256File(local)) ?? undefined;
await this.saveSettings();
} catch (e) {
logger.warn(
`ensureDaemonBinary: daemon ready but failed to persist cache marker ` +
`(${errorMessage(e)}); will re-verify on the next connect`,
);
}
new Notice(`Remote SSH: daemon ready for ${target.os}/${target.arch}.`);
return local;
} catch (e) {
// A sha256 mismatch / malformed manifest (DaemonVerificationError) is a

View file

@ -29,6 +29,18 @@ function defaultObsidianConfigDir(): string {
export const DEFAULT_PRIVATE_PATTERN_BASENAMES: readonly string[] = [
'workspace.json',
'workspace-mobile.json',
// Per-device Obsidian settings. Each machine keeps its OWN
// app/appearance/hotkeys/enabled-core-plugins under its per-client
// subtree instead of fighting over one shared copy on the remote.
// The shared copy was a perpetual write-conflict source: two sessions
// (or the same device across reconnects) both wrote `<configDir>/app.json`
// at the identity path, so every settings save tripped PreconditionFailed
// against the other's mtime. Redirecting them makes a shared path — and
// thus the conflict — impossible by construction.
'app.json',
'appearance.json',
'core-plugins.json',
'hotkeys.json',
'cache',
'cache.zlib',
'types.json',

View file

@ -103,6 +103,18 @@ export class ProfileForm extends Modal {
.addText(t => t.setValue(this.profile.privateKeyPath ?? '')
.onChange(v => { this.profile.privateKeyPath = v || undefined; }));
new Setting(contentEl)
.setName('Proxy command')
.setDesc(
'Optional. OpenSSH ProxyCommand to reach the host through a ' +
'subprocess, e.g. `cloudflared access ssh --hostname %h`. ' +
'`%h`/`%p`/`%r` expand at connect time. Leave blank for a direct ' +
'connection; ignored when a jump host is set. (Desktop only.)',
)
.addText(t => t
.setValue(this.profile.proxyCommand ?? '')
.onChange(v => { this.profile.proxyCommand = v.trim() || undefined; }));
contentEl.createEl('h3', { text: 'Remote vault' });
const pathSetting = new Setting(contentEl)
@ -216,6 +228,17 @@ export class ProfileForm extends Modal {
} else {
this.profile.jumpHost = undefined;
}
// ProxyCommand → subprocess transport (#430). ProxyJump and
// ProxyCommand are mutually exclusive in OpenSSH; mirror the
// SftpClient precedence (jumpHost wins) by only adopting the
// ProxyCommand when no ProxyJump is present.
this.profile.proxyCommand = (e.proxyCommand && !e.proxyJump) ? e.proxyCommand : undefined;
// IdentityAgent → agent socket (#430), e.g. 1Password's agent.sock.
// Don't override an explicit IdentityFile (key) auth choice.
if (e.identityAgent) {
this.profile.agentSocket = e.identityAgent;
if (!e.identityFile) this.profile.authMethod = 'agent';
}
}
private validate(): boolean {

View file

@ -2,6 +2,8 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
import { logger } from '../util/logger';
import { errorMessage } from '../util/errorMessage';
/**
* One entry in `obsidian.json`'s `vaults` map.
@ -104,6 +106,57 @@ export class ObsidianRegistry {
return { id, created: true };
}
/**
* Repoint an existing vault entry from `oldPath` to `newPath`,
* keeping the same Obsidian vault id. Used by the transitional
* shadow-dir rename (legacy UUID name friendly name) so the
* registration follows the moved directory instead of leaving a
* dead entry pointing at the old path. No-op (returns false) when no
* entry matches `oldPath`.
*/
updatePath(oldPath: string, newPath: string): boolean {
const cfg = this.read();
const target = canonicalisePath(oldPath);
for (const [id, entry] of Object.entries(cfg.vaults)) {
if (canonicalisePath(entry.path) === target) {
cfg.vaults[id] = { ...entry, path: newPath };
this.writeAtomic(cfg);
return true;
}
}
return false;
}
/**
* Whether Obsidian currently has this vault open, per the
* `obsidian.json` `open` flag. Used by the shadow-dir migration to
* avoid renaming a live vault's directory on Windows that corrupts
* the open junction/handles (dangling plugin dir daemon ENOENT).
*
* A registered path with `open !== true`, or a path not in the vault
* list at all, is "not open" (safe to migrate). But an UNREADABLE
* `obsidian.json` is treated as OPEN: we can't tell, and a false
* "closed" here would let the caller rename a live vault and cause the
* very corruption this guards against. Deferring a migration by one
* bootstrap is the trivial cost of failing safe.
*/
isOpen(vaultPath: string): boolean {
try {
const cfg = this.read();
const target = canonicalisePath(vaultPath);
for (const entry of Object.values(cfg.vaults)) {
if (canonicalisePath(entry.path) === target) return entry.open === true;
}
return false; // registered nowhere → Obsidian doesn't have it open
} catch (e) {
logger.warn(
`ObsidianRegistry.isOpen: unreadable obsidian.json (${errorMessage(e)}); ` +
'assuming open, deferring rename',
);
return true;
}
}
/**
* Atomic write: serialise to a sibling temp file, then rename.
* Limits the window during which Obsidian could read a half-written

View file

@ -126,12 +126,18 @@ export class ShadowStartupCoordinator {
/**
* Read this shadow vault's community-plugins.json, find ids whose
* binaries aren't yet installed, and download them from Obsidian's
* community marketplace. On first bootstrap this is a no-op (the
* list is just `["remote-ssh"]`); the path matters on re-bootstrap
* where the user has accumulated a real list and a binary went
* missing (vault moved disks, plugin dir purged, ).
* community marketplace.
*
* Called twice: once in `prepareForAutoConnect` (before connect, from
* the local list re-installs a binary that went missing) and again
* by the connect flow right after the remote community-plugins pull,
* so a plugin enabled on another machine gets its binary and loads on
* the first reconnect rather than only after a restart (#429b).
* Idempotent `installMissing` skips ids already on disk.
* (Non-marketplace / BRAT plugins still need their binary on the
* remote out of scope here.)
*/
private async installMissingShadowPlugins(): Promise<void> {
async installMissingShadowPlugins(): Promise<void> {
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
logger.warn('installMissingShadowPlugins: vault is not FileSystemAdapter-backed; skipping');

View file

@ -52,6 +52,14 @@ export interface BootstrapResult {
registryId: string;
/** True if the vault entry was newly added (false = was already registered). */
registryCreated: boolean;
/**
* True if an existing shadow dir (located by profile id, under any
* prior naming scheme) was renamed to the current `<name>--<tail>` on
* this bootstrap. Like a newly-registered vault, the renamed path isn't
* in the running Obsidian's cached vault list, so the connect flow
* surfaces the one-time "restart Obsidian" notice.
*/
migrated: boolean;
/** How the plugin source landed in the shadow vault. */
pluginInstallMethod: 'symlink' | 'copy';
}
@ -84,8 +92,8 @@ export interface SharedConfigWriter {
*
* Layout:
*
* <baseDir>/<sanitised-profile-id>/
* .obsidian/
* <baseDir>/<name>--<remotePath-tail>/ (identity is the profile id
* .obsidian/ in data.json, not the dir name)
* community-plugins.json ["remote-ssh"]
* plugins/
* remote-ssh/ symlink (or copy on Windows
@ -121,7 +129,10 @@ export class ShadowVaultBootstrap {
* is `fs.*Sync` and JSON arithmetic, no I/O actually awaits.
*/
private bootstrapSync(profile: SshProfile, allProfiles: ReadonlyArray<SshProfile>): BootstrapResult {
const layout = this.layoutFor(profile.id);
// Resolve (and, when safe, migrate to) the `<name>--<tail>` shadow
// dir for this profile. Identity is the profile id, not the dir
// name, so a rename/collision never strands config (see resolveLayout).
const { layout, migrated } = this.resolveLayout(profile);
fs.mkdirSync(layout.vaultDir, { recursive: true });
fs.mkdirSync(layout.configDir, { recursive: true });
@ -209,16 +220,21 @@ export class ShadowVaultBootstrap {
`at ${layout.vaultDir} (registry id=${registryId}, plugin=${pluginInstallMethod})`,
);
return { layout, registryId, registryCreated: created, pluginInstallMethod };
return { layout, registryId, registryCreated: created, migrated, pluginInstallMethod };
}
/**
* Compute paths for a given profile id without doing any I/O.
* Useful for callers that need the layout up-front (e.g. the
* spawner needs `vaultDir` for the open URL).
* Compute the shadow paths for a profile without doing any I/O: the
* pure `<name>--<tail>` layout, before any collision ` (n)` suffix
* (which only resolveLayout applies). A thin, side-effect-free path
* builder resolveLayout is the sole caller.
*/
layoutFor(profileId: string): ShadowVaultLayout {
const vaultDir = path.join(this.baseDir, sanitiseProfileId(profileId));
layoutFor(profile: Pick<SshProfile, 'name' | 'remotePath'>): ShadowVaultLayout {
return this.layoutForDir(path.join(this.baseDir, friendlyVaultDirName(profile)));
}
/** Derive the `.obsidian/...` sub-paths for a concrete vault dir. */
private layoutForDir(vaultDir: string): ShadowVaultLayout {
// The shadow vault is freshly created on disk by us before Obsidian
// ever opens it; there's no live `App` instance whose
// `vault.configDir` we could query, so we build the directory name
@ -233,19 +249,179 @@ export class ShadowVaultBootstrap {
return { vaultDir, configDir, pluginDir, pluginDataFile };
}
/**
* Resolve the shadow layout to use for this profile.
*
* Identity is the profile *id* (persisted as `autoConnectProfileId`
* in the shadow's data.json), NOT the directory name so a profile
* rename, a display-name collision, or an older `<uuid>` / `--<id8>`
* naming scheme all still resolve to the same shadow via
* `findShadowByProfileId`. When the existing shadow's dir name no
* longer matches the desired `<name>--<tail>`, we migrate it once by
* renaming, EXCEPT:
*
* - the vault is currently open (obsidian.json `open` flag): renaming
* an open vault's dir on Windows corrupts the live junction/handles
* (the plugin dir goes dangling daemon download ENOENTs SFTP
* fallback). Use it as-is; migrate on the next closed bootstrap.
* - the desired name is already taken by a *different* profile:
* `uniqueVaultDir` appends ` (2)` so two vaults never share one dir
* (which would merge their data.json / secrets / host keys).
*
* The rename and the obsidian.json path update are separate `try`s:
* once the dir physically moves we commit to the new path even if
* updating the registry throws, because falling back would recreate
* the old dir empty and orphan the real config (#438 review).
*/
private resolveLayout(
profile: Pick<SshProfile, 'id' | 'name' | 'remotePath'>,
): { layout: ShadowVaultLayout; migrated: boolean } {
const desired = this.layoutFor(profile);
const found = this.findShadowByProfileId(profile.id);
if (found) {
// Where this profile's shadow SHOULD live (collision-safe): its own
// dir if it already owns one, else the free `<name>--<tail>` or a
// ` (n)` variant. Compare `found` to this resolved target — NOT to
// the bare desired name — otherwise a profile permanently parked in a
// ` (2)` dir (a display-name collision) would "migrate" to itself on
// every reconnect: a no-op same-path rename that falsely reports
// migrated=true and re-shows the one-time "restart Obsidian" notice.
const target = this.uniqueVaultDir(desired.vaultDir, profile.id);
if (path.basename(found) === path.basename(target)) {
return { layout: this.layoutForDir(found), migrated: false };
}
// R2: never rename a dir whose vault is open — Windows corrupts the
// live junction. Use it as-is; migrate on the next closed run.
if (this.registry.isOpen(found)) {
logger.info(`ShadowVaultBootstrap: ${found} is open; deferring rename`);
return { layout: this.layoutForDir(found), migrated: false };
}
// Migrate. A successful rename commits the move; a failing updatePath
// is logged but not fatal (self-heals on register()). A failing
// rename falls back to the found dir.
try {
fs.renameSync(found, target);
} catch (e) {
logger.warn(
`ShadowVaultBootstrap: rename ${found}${target} failed (${errorMessage(e)}); using ${found} this session`,
);
return { layout: this.layoutForDir(found), migrated: false };
}
try {
this.registry.updatePath(found, target);
} catch (e) {
logger.warn(
`ShadowVaultBootstrap: registry path update failed post-migration (${errorMessage(e)}); ` +
`config is at ${target}, registry self-heals on register()`,
);
}
logger.info(`ShadowVaultBootstrap: migrated shadow ${found}${target}`);
return { layout: this.layoutForDir(target), migrated: true };
}
// Brand-new profile — pick a collision-free dir (a different profile
// may already own the desired `<name>--<tail>`).
const target = this.uniqueVaultDir(desired.vaultDir, profile.id);
return { layout: this.layoutForDir(target), migrated: false };
}
/**
* Scan `baseDir` for the shadow whose data.json `autoConnectProfileId`
* matches. Identity is the id, not the dir name, so this is naming-scheme
* agnostic it reuses the existing config whether the dir is a legacy
* `<uuid>`, an older `<name>--<id8>`, or the current `<name>--<tail>`,
* instead of stranding it. Returns the first match in sorted order
* (deterministic) or null.
*/
private findShadowByProfileId(profileId: string): string | null {
let entries: string[];
try {
entries = fs.readdirSync(this.baseDir).sort();
} catch {
return null; // baseDir not created yet
}
for (const entry of entries) {
const dir = path.join(this.baseDir, entry);
let isDir: boolean;
try { isDir = fs.statSync(dir).isDirectory(); } catch { continue; }
if (isDir && this.readShadowProfileId(dir) === profileId) return dir;
}
return null;
}
/**
* The `autoConnectProfileId` recorded in `dir`'s shadow data.json, or
* null if `dir` isn't a bootstrapped shadow. A genuinely absent data.json
* (ENOENT) is the normal "not a shadow dir" case and stays silent; an
* EXISTING-but-unreadable data.json a transient lock (AV / Dropbox sync
* / a mid-write) or malformed JSON is logged, because it can briefly
* hide this profile's OWN shadow and make resolveLayout fork a duplicate
* ` (2)` dir instead of reusing it. A non-string id is treated as no
* match (only a false negative is possible, never a false reuse).
*/
private readShadowProfileId(dir: string): string | null {
const dataFile = this.layoutForDir(dir).pluginDataFile;
let raw: string;
try {
raw = fs.readFileSync(dataFile, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn(`ShadowVaultBootstrap: unreadable ${dataFile} (${errorMessage(e)}); treating as non-matching`);
}
return null;
}
try {
const parsed = JSON.parse(raw) as { autoConnectProfileId?: unknown };
return typeof parsed.autoConnectProfileId === 'string' ? parsed.autoConnectProfileId : null;
} catch (e) {
logger.warn(`ShadowVaultBootstrap: malformed ${dataFile} (${errorMessage(e)}); treating as non-matching`);
return null;
}
}
/**
* A dir the given profile may safely occupy: the desired name if it's
* free or already this profile's, else `desired (2)`, `desired (3)`,
* Two profiles must never share a dir that would merge their
* data.json (secrets, host keys, active profile). Display-name
* collisions are allowed; only the on-disk dir is disambiguated.
*/
private uniqueVaultDir(desiredVaultDir: string, profileId: string): string {
// Free (absent) or already ours → safe to take. An existing dir whose
// data.json belongs to a DIFFERENT profile (or is unreadable, logged by
// readShadowProfileId) is left alone so configs never merge.
const ownsOrFree = (dir: string): boolean =>
!fs.existsSync(dir) || this.readShadowProfileId(dir) === profileId;
if (ownsOrFree(desiredVaultDir)) return desiredVaultDir;
for (let n = 2; n < 100; n++) {
const candidate = `${desiredVaultDir} (${n})`;
if (ownsOrFree(candidate)) return candidate;
}
// 98 collisions on one name is absurd; fall back to an id-suffixed
// dir so we still return something unique rather than loop forever.
return `${desiredVaultDir} (${profileId.slice(0, 8)})`;
}
// ─── shared-config round-trip (#342) ────────────────────────────────────
/**
* Shared (non per-client) Obsidian config files. `PathMapper`
* leaves these unmapped on purpose so every machine on the vault
* sees the same `app.json` / theme / enabled-core-plugins /
* hotkeys. The sharing was one-way though: edits in one session
* push to the remote, but the local shadow disk never pulled them
* back, so the *next* Obsidian startup read a stale local copy and
* the settings appeared to evaporate (#342).
* Obsidian config files this vault round-trips to the remote so a
* settings change survives a shadow-window restart (#342: without the
* pull half, the next startup read a stale local copy and settings
* appeared to evaporate).
*
* `workspace.json` is deliberately NOT here it's per-client UI
* state that `PathMapper` already redirects into a private subtree.
* These are now **per-device**, not shared: `PathMapper` redirects each
* basename into this client's `<configDir>/user/<client-id>/` subtree
* (they were added to `DEFAULT_PRIVATE_PATTERN_BASENAMES`). So the
* round-trip below reads/writes THIS device's own copy giving each
* machine a remote backup + cross-session persistence without two
* devices ever colliding on one shared `<configDir>/app.json` (the
* perpetual write-conflict this round-trip used to cause). The name is
* kept for back-compat; "shared" is historical.
*
* `workspace.json` is deliberately NOT here it's per-client UI state
* `PathMapper` already redirects AND that Obsidian rewrites constantly.
*/
static readonly SHARED_OBSIDIAN_CONFIG_FILES = [
'app.json',
@ -422,6 +598,313 @@ export class ShadowVaultBootstrap {
return { pushed, skipped, errored };
}
// ─── community-plugins list round-trip (#429 / #342) ─────────────────────
//
// `community-plugins.json` is the *enabled community plugins* list.
// It deliberately does NOT join SHARED_OBSIDIAN_CONFIG_FILES (whose
// pull/push copy bytes verbatim): a remote list that omitted
// `remote-ssh` would, written verbatim, disable the very plugin doing
// the sync. Instead these two methods round-trip the list with a
// forced `remote-ssh` union, so a plugin enabled on one machine
// reaches another machine's shadow vault and reopening no longer
// "loses" installed plugins. The marketplace installer re-fetches any
// binaries the list names but that aren't staged locally yet.
/** The plugin's own id — always kept enabled across a round-trip. */
static readonly SELF_PLUGIN_ID = 'remote-ssh';
/**
* Pull the remote enabled-plugin list into the shadow vault, merged
* with the local list and with `remote-ssh` forced on. A remote list
* that's absent or not a valid id array leaves the local list
* untouched (never clobbered).
*/
static async pullCommunityPlugins(
reader: SharedConfigReader,
remoteConfigDir: string,
localConfigDir: string,
): Promise<{ pulled: boolean; merged: string[] }> {
const basename = 'community-plugins.json';
const remoteRel = `${remoteConfigDir}/${basename}`;
const localPath = path.join(localConfigDir, basename);
fs.mkdirSync(localConfigDir, { recursive: true });
const local = ShadowVaultBootstrap.readPluginIdList(localPath);
let remote: string[] | null = null;
try {
if (await reader.exists(remoteRel)) {
remote = ShadowVaultBootstrap.parsePluginIdList(await reader.read(remoteRel));
if (remote === null) {
logger.warn(
`pullCommunityPlugins: remote ${basename} is not a valid id array; keeping local list`,
);
}
}
} catch (e) {
logger.warn(`pullCommunityPlugins: ${basename} skipped (${errorMessage(e)})`);
}
const merged = ShadowVaultBootstrap.mergePluginIds(
local, remote ?? [], ShadowVaultBootstrap.SELF_PLUGIN_ID,
);
const changed =
merged.length !== local.length || merged.some((id, i) => id !== local[i]);
if (changed) ShadowVaultBootstrap.writePluginIdListAtomic(localPath, merged);
logger.info(`pullCommunityPlugins: merged [${merged.join(', ')}] (changed=${changed})`);
return { pulled: remote !== null, merged };
}
/**
* Push the local enabled-plugin list to the remote, unioned with the
* remote's CURRENT list and with `remote-ssh` forced on.
*
* Self-protecting against clobber: it re-reads the remote first and
* unions, so a stale/minimal local list (e.g. after a transient pull
* read-failure earlier in the same connect) can never drop a plugin
* another machine enabled. If the remote HAS the file but it can't be
* read or parsed, it aborts rather than overwrite what it couldn't
* see. A genuinely-absent remote file is seeded from local.
*/
static async pushCommunityPlugins(
rw: SharedConfigReader & SharedConfigWriter,
remoteConfigDir: string,
localConfigDir: string,
): Promise<{ pushed: boolean }> {
const basename = 'community-plugins.json';
const remoteRel = `${remoteConfigDir}/${basename}`;
const local = ShadowVaultBootstrap.readPluginIdList(path.join(localConfigDir, basename));
let remote: string[] = [];
try {
if (await rw.exists(remoteRel)) {
const parsed = ShadowVaultBootstrap.parsePluginIdList(await rw.read(remoteRel));
if (parsed === null) {
logger.warn('pushCommunityPlugins: remote list is not a valid id array; not pushing (avoid clobber)');
return { pushed: false };
}
remote = parsed;
}
} catch (e) {
logger.warn(`pushCommunityPlugins: cannot read remote (${errorMessage(e)}); not pushing (avoid clobber)`);
return { pushed: false };
}
const ids = ShadowVaultBootstrap.mergePluginIds(remote, local, ShadowVaultBootstrap.SELF_PLUGIN_ID);
// No-op when the remote already equals the union — avoid churn.
if (remote.length === ids.length && remote.every((id, i) => id === ids[i])) {
return { pushed: false };
}
try {
await rw.write(remoteRel, JSON.stringify(ids) + '\n');
logger.info(`pushCommunityPlugins: pushed [${ids.join(', ')}]`);
return { pushed: true };
} catch (e) {
logger.warn(`pushCommunityPlugins: push failed (${errorMessage(e)})`);
return { pushed: false };
}
}
// ─── plugin code 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 code 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.
//
// Convergence is VERSION-ORDERED, not last-writer-wins: pull only when
// the remote is strictly newer (or the plugin is absent locally), push
// only when the local copy is strictly newer (or the remote lacks it).
// A plain "content differs" gate would let a machine still on an old
// version downgrade a plugin the rest of the fleet already upgraded,
// and the two sides would ping-pong forever (review: #429b).
// NOTE: synced over a UTF-8 TEXT channel (readText/writeText). Every
// entry MUST be text. Do NOT add binary assets (.png/.woff/…) here —
// the UTF-8 round-trip would corrupt them.
static readonly PLUGIN_BINARY_FILES = ['manifest.json', 'main.js', 'styles.css'] as const;
/**
* Pull a plugin's code from the remote into the local shadow when the
* plugin is ABSENT locally or the remote is a STRICTLY NEWER version
* (by manifest `version`) so a plugin enabled/updated on another
* machine (incl. BRAT / non-marketplace) reaches here and loads on the
* next vault open. Never downgrades a local copy, never touches
* `data.json`. `remote-ssh` is skipped (self-managed).
*/
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);
const remoteManifest = `${remoteConfigDir}/plugins/${id}/manifest.json`;
try {
if (!(await reader.exists(remoteManifest))) continue; // no code on the remote
const remoteVer = ShadowVaultBootstrap.parseManifestVersion(await reader.read(remoteManifest));
const localManifest = path.join(localPluginDir, 'manifest.json');
const localVer = fs.existsSync(localManifest)
? ShadowVaultBootstrap.parseManifestVersion(fs.readFileSync(localManifest, 'utf-8'))
: null;
// Skip unless absent locally, or the remote is strictly newer.
if (localVer !== null && !ShadowVaultBootstrap.versionGt(remoteVer, localVer)) continue;
let staged = false;
for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) {
const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`;
if (!(await reader.exists(remoteRel))) continue;
const content = await reader.read(remoteRel);
fs.mkdirSync(localPluginDir, { recursive: true });
ShadowVaultBootstrap.writeFileAtomic(path.join(localPluginDir, file), content);
staged = true;
}
if (staged) pulled.push(id);
} catch (e) {
logger.warn(`pullPluginBinaries: ${id} skipped (${errorMessage(e)})`);
}
}
if (pulled.length) logger.info(`pullPluginBinaries: staged [${pulled.join(', ')}]`);
return { pulled };
}
/**
* Push a plugin's local code to the remote when the remote LACKS it or
* the local copy is a STRICTLY NEWER version so other machines can
* pull it. Never downgrades the remote, never pushes `data.json`;
* byte-identical files are skipped to avoid churn. `remote-ssh` skipped.
*/
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);
const localManifest = path.join(localPluginDir, 'manifest.json');
if (!fs.existsSync(localManifest)) continue; // nothing to push
const remoteManifest = `${remoteConfigDir}/plugins/${id}/manifest.json`;
try {
const localVer = ShadowVaultBootstrap.parseManifestVersion(fs.readFileSync(localManifest, 'utf-8'));
const remoteVer = (await rw.exists(remoteManifest))
? ShadowVaultBootstrap.parseManifestVersion(await rw.read(remoteManifest))
: null;
// Skip unless the remote lacks it, or the local copy is newer.
if (remoteVer !== null && !ShadowVaultBootstrap.versionGt(localVer, remoteVer)) continue;
let sent = false;
for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) {
const localFile = path.join(localPluginDir, file);
if (!fs.existsSync(localFile)) continue;
const content = fs.readFileSync(localFile, 'utf-8');
const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`;
if ((await rw.exists(remoteRel)) && (await rw.read(remoteRel)) === content) continue; // identical
await rw.write(remoteRel, content);
sent = true;
}
if (sent) pushed.push(id);
} catch (e) {
logger.warn(`pushPluginBinaries: ${id} skipped (${errorMessage(e)})`);
}
}
if (pushed.length) logger.info(`pushPluginBinaries: pushed [${pushed.join(', ')}]`);
return { pushed };
}
/** Parse a manifest.json body's dotted-numeric `version` ([0] when absent/unparseable = oldest). */
private static parseManifestVersion(body: string): number[] {
try {
const v = (JSON.parse(body) as { version?: unknown }).version;
if (typeof v !== 'string') return [0];
const parts = v.split('.').map((s) => parseInt(s, 10)).map((n) => (Number.isFinite(n) ? n : 0));
return parts.length ? parts : [0];
} catch {
return [0];
}
}
/** True when dotted-numeric version `a` is strictly greater than `b` (missing segments = 0). */
private static versionGt(a: number[], b: number[]): boolean {
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x > y;
}
return false;
}
/** 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 {
const parsed: unknown = JSON.parse(content);
if (!Array.isArray(parsed)) return null;
return (parsed as unknown[]).filter((s): s is string => typeof s === 'string');
} catch {
return null;
}
}
/**
* 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 {
return ShadowVaultBootstrap.parsePluginIdList(fs.readFileSync(localPath, 'utf-8')) ?? [];
} catch {
return [];
}
}
/** Order-preserving union of `local` + `remote`, with `required` guaranteed present. */
private static mergePluginIds(local: string[], remote: string[], required: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const id of [...local, ...remote, required]) {
if (!seen.has(id)) { seen.add(id); out.push(id); }
}
return out;
}
/** Atomic (tmp + rename) write of an id array as JSON. */
private static writePluginIdListAtomic(localPath: string, ids: string[]): void {
const tmp = `${localPath}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(ids) + '\n', 'utf-8');
try {
fs.renameSync(tmp, localPath);
} catch (e) {
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
throw e;
}
}
// ─── internals ──────────────────────────────────────────────────────────
/**
@ -722,7 +1205,14 @@ export class ShadowVaultBootstrap {
for (const d of sharedDirs) {
const src = path.join(this.sourcePluginDir, d);
const dst = path.join(pluginDir, d);
if (!fs.existsSync(src)) continue;
// Only mirror a REAL source dir (a local dev build of the daemon). A
// junction/symlink source is NOT propagated: it would chain the shadow
// to another vault's server-bin and dangle if that vault is deleted,
// breaking the per-shadow daemon download (the binary is per-arch,
// fetched at connect time — not a shared build artifact like main.js).
let srcStat: fs.Stats;
try { srcStat = fs.lstatSync(src); } catch { continue; } // absent → skip
if (srcStat.isSymbolicLink() || !srcStat.isDirectory()) continue;
// Use lstat + unlink for symlinks vs rmSync recursive for real
// dirs so we never accidentally recurse through a link.
try {
@ -750,14 +1240,56 @@ export class ShadowVaultBootstrap {
}
/**
* Profile ids should already be uuids, but we sanitise defensively:
* a malicious or unusual id should never escape `baseDir` via `..`
* or surprise the filesystem with separators.
* Filesystem-safe form of a profile *name* for the friendly vault-dir
* name. Obsidian shows a vault by its directory basename, so this is
* what the user sees instead of a raw UUID. Spaces are kept (valid in
* dir names on every OS); anything path-dangerous is collapsed to `_`;
* length-capped; never `.`/`..`/empty.
*/
function sanitiseProfileId(id: string): string {
const cleaned = id.replace(/[^a-zA-Z0-9._-]/g, '_');
// Empty / dot-only ids would resolve to baseDir itself or its
// parent; force them into something benign.
if (!cleaned || cleaned === '.' || cleaned === '..') return '_invalid';
function sanitiseVaultName(name: string): string {
const cleaned = (name ?? '')
.replace(/[^a-zA-Z0-9._ -]/g, '_')
.replace(/_{2,}/g, '_')
.trim()
.slice(0, 40)
.trim();
if (!cleaned || cleaned === '.' || cleaned === '..') return 'vault';
return cleaned;
}
/**
* Filesystem-safe form of the remotePath's LAST segment. One host
* (profile name) often holds several independent vaults under
* different folders, so the folder tail is what disambiguates them
* `/home/souta/work` `work`, `/home/souta/work/dev` `dev`. Same
* sanitising rules as the name; a bare/degenerate path falls back to
* `vault`.
*/
function sanitisePathTail(remotePath: string): string {
const trimmed = (remotePath ?? '').replace(/[/\\]+$/, '');
const tail = trimmed.split(/[/\\]/).pop() ?? '';
// A bare `~` (vault rooted at the remote home dir) has no meaningful
// folder tail — catch it before the charset strip below turns it into `_`.
if (tail === '~') return 'vault';
const cleaned = tail
.replace(/[^a-zA-Z0-9._ -]/g, '_')
.replace(/_{2,}/g, '_')
.trim()
.slice(0, 40)
.trim();
if (!cleaned || cleaned === '.' || cleaned === '..') return 'vault';
return cleaned;
}
/**
* The shadow vault's directory name: `<friendly-name>--<path-tail>`.
* The name identifies the host, the tail the folder together they
* let Obsidian show a recognisable, folder-distinct name (e.g.
* `Panza--work` vs `Panza--dev`) instead of a bare UUID. The id is NOT
* in the name: identity is resolved from `data.json`'s
* `autoConnectProfileId` (see `findShadowByProfileId`), so a profile
* rename or a display-name collision never strands its config.
*/
function friendlyVaultDirName(profile: Pick<SshProfile, 'name' | 'remotePath'>): string {
return `${sanitiseVaultName(profile.name)}--${sanitisePathTail(profile.remotePath)}`;
}

View file

@ -24,8 +24,23 @@ export class ShadowVaultManager {
async openShadowFor(
profile: SshProfile,
allProfiles: ReadonlyArray<SshProfile>,
onBootstrapped?: (result: BootstrapResult) => Promise<void>,
): Promise<BootstrapResult> {
const result = await this.bootstrap.bootstrap(profile, allProfiles);
// #429b / Phase B-3: optional pre-spawn step — e.g. pull the remote
// `.obsidian/` into the freshly-created shadow dir so the window
// boots on canonical config + plugins instead of the stale local
// copy. Best-effort BY CONTRACT: a throw here must never block the
// spawn, so the shadow window's own connect still catches up (the
// pre-B3 behaviour). The hook owns its own timeout.
if (onBootstrapped) {
try {
await onBootstrapped(result);
} catch {
// swallowed — spawn proceeds regardless (fallback to open-now,
// sync-inside-the-window).
}
}
this.spawner.spawn(result.layout.vaultDir);
return result;
}

View file

@ -0,0 +1,26 @@
import type { PathMapper } from '../path/PathMapper';
/**
* Resolve a vault-relative `.obsidian/…` path to the absolute remote path
* the pre-spawn config pull must read, applying the SAME per-client
* redirect the shadow window's adapter uses.
*
* Load-bearing: the four per-device config files (`app.json`,
* `appearance.json`, `core-plugins.json`, `hotkeys.json`) must resolve to
* `<configDir>/user/<clientId>/…`, NOT the dead shared identity path the
* pre-spawn pull previously joined the bare remote base and read the shared
* path, clobbering the redirected per-device config on every spawn (#450
* CRITICAL). `PathMapper.toRemote` is identity for non-private paths, so
* `community-plugins.json` / `plugins/*` stay shared, unchanged.
*
* Extracted as a pure function so this redirect is unit-testable without
* standing up `preSpawnPull`'s standalone `SftpClient`.
*/
export function preSpawnRemotePath(
mapper: PathMapper,
remoteBase: string,
vaultRelPath: string,
): string {
const mapped = mapper.toRemote(vaultRelPath);
return remoteBase === '.' ? mapped : `${remoteBase}/${mapped}`;
}

View file

@ -0,0 +1,106 @@
import { spawn } from 'child_process';
import { Duplex } from 'stream';
import { logger } from '../util/logger';
/**
* The concrete connection target a `ProxyCommand`'s %-tokens expand
* against. `%h` host, `%p` port, `%r` remote user.
*/
export interface ProxyCommandTarget {
host: string;
port: number;
user?: string;
}
export interface ProxyCommandTunnelOptions {
/** Injection seam for tests; defaults to `child_process.spawn`. */
spawnFn?: typeof spawn;
}
/**
* Expand the OpenSSH `ProxyCommand` %-tokens against a concrete target.
* Supports `%h` (host), `%p` (port), `%r` (remote user) and `%%`
* (a literal `%`). Unknown `%x` sequences are left untouched.
*/
export function expandProxyCommandTokens(template: string, target: ProxyCommandTarget): string {
return template.replace(/%[hpr%]/g, (tok) => {
switch (tok) {
case '%h': return target.host;
case '%p': return String(target.port);
case '%r': return target.user ?? '';
case '%%': return '%';
default: return tok;
}
});
}
/**
* Open a `ProxyCommand` transport: spawn the (token-expanded) command
* through the platform shell exactly as OpenSSH runs
* `/bin/sh -c <ProxyCommand>` and bridge its stdio as a single
* `Duplex`. The returned stream is what ssh2 takes as its `sock`
* option: ssh2 writes the SSH protocol into the child's stdin and
* reads the peer's bytes from its stdout.
*
* Desktop-only: `child_process` is unavailable on mobile. Callers must
* gate on `Platform.isDesktop` before reaching this path.
*/
export function createProxyCommandTunnel(
proxyCommand: string,
target: ProxyCommandTarget,
opts: ProxyCommandTunnelOptions = {},
): Duplex {
const spawnFn = opts.spawnFn ?? spawn;
const command = expandProxyCommandTokens(proxyCommand, target);
logger.info(`ProxyCommandTunnel: spawning proxy for ${target.host}:${target.port}`);
// `shell: true` + a full command line mirrors OpenSSH's behaviour
// (the directive is a command line, not an argv vector). With no
// `stdio` option this overload returns a ChildProcessWithoutNullStreams
// so stdin/stdout/stderr are guaranteed non-null.
const child = spawnFn(command, { shell: true });
const duplex = new Duplex({
read() {
// Resume the child's stdout if backpressure had paused it.
child.stdout.resume();
},
write(chunk: Buffer, _enc, cb) {
child.stdin.write(chunk, (err) => cb(err ?? undefined));
},
final(cb) {
child.stdin.end();
cb();
},
});
// proxy → client: push child stdout onto the readable side, honouring
// backpressure so a slow consumer can't make us buffer without bound.
child.stdout.on('data', (chunk: Buffer) => {
if (!duplex.push(chunk)) child.stdout.pause();
});
child.stdout.on('end', () => duplex.push(null));
child.stderr.on('data', (chunk: Buffer) => {
logger.warn(`ProxyCommandTunnel[${target.host}] stderr: ${chunk.toString().trim()}`);
});
// Spawn failure (ENOENT for a missing proxy binary, EACCES, …) surfaces
// on the stream so ssh2's connect rejects with a clear cause.
child.on('error', (err: Error) => {
duplex.destroy(err);
});
child.on('close', (code: number | null) => {
duplex.push(null);
if (code && code !== 0) {
logger.warn(`ProxyCommandTunnel[${target.host}] proxy exited with code ${code}`);
}
});
// When ssh2 (or the caller) closes the tunnel, reap the proxy process
// so it can't linger.
duplex.on('close', () => {
if (!child.killed) child.kill();
});
return duplex;
}

View file

@ -6,6 +6,7 @@ import { TMP_SUFFIX } from '../constants';
import { AuthResolver } from './AuthResolver';
import { HostKeyStore, type HostKeyMismatchHandler } from './HostKeyStore';
import { createJumpTunnel } from './JumpHostTunnel';
import { createProxyCommandTunnel } from './ProxyCommandTunnel';
import { logger } from '../util/logger';
import { asError, errorMessage } from '../util/errorMessage';
@ -179,6 +180,17 @@ export class SftpClient {
keepaliveIntervalMs: profile.keepaliveIntervalMs,
},
);
} else if (profile.proxyCommand) {
// ProxyCommand transport (#430): run the command (e.g.
// `cloudflared access ssh --hostname %h`) and hand ssh2 its
// stdio as the `sock`. Mutually exclusive with jumpHost; if both
// are somehow set, the bastion jump above wins (it already ran).
logger.info(`SftpClient: opening ProxyCommand transport for ${profile.host}`);
sock = createProxyCommandTunnel(profile.proxyCommand, {
host: profile.host,
port: profile.port,
user: profile.username,
});
}
const client = new Client();

View file

@ -14,6 +14,20 @@ export interface SshConfigEntry {
port: number;
identityFile?: string;
proxyJump?: ProxyJumpEntry;
/**
* Raw `ProxyCommand` directive value, with OpenSSH %-tokens (`%h`,
* `%p`, `%r`) left intact they are expanded at connect time once
* the concrete host/port/user are known (see `ProxyCommandTunnel`).
* A command-based proxy (e.g. `cloudflared access ssh --hostname %h`)
* is distinct from `ProxyJump`, which opens an SSH bastion hop.
*/
proxyCommand?: string;
/**
* `IdentityAgent` socket path (tilde-expanded), e.g. 1Password's
* `~/.1password/agent.sock`. Lets auth go through a specific agent
* rather than `$SSH_AUTH_SOCK`.
*/
identityAgent?: string;
}
/**
@ -44,6 +58,8 @@ interface RawFields {
port?: number;
identityFile?: string;
proxyJump?: string;
proxyCommand?: string;
identityAgent?: string;
}
/**
@ -112,11 +128,16 @@ function parseBlocks(content: string): RawHostBlock[] {
if (!current) continue;
switch (key) {
case 'hostname': current.fields.hostname = value; break;
case 'user': current.fields.user = value; break;
case 'port': current.fields.port = parseInt(value, 10) || 22; break;
case 'identityfile': current.fields.identityFile = expandTilde(value); break;
case 'proxyjump': current.fields.proxyJump = value; break;
case 'hostname': current.fields.hostname = value; break;
case 'user': current.fields.user = value; break;
case 'port': current.fields.port = parseInt(value, 10) || 22; break;
case 'identityfile': current.fields.identityFile = expandTilde(value); break;
case 'proxyjump': current.fields.proxyJump = value; break;
// ProxyCommand is kept raw: the %-tokens (%h/%p/%r) are expanded
// at connect time, not here. IdentityAgent is a socket path, so
// it tilde-expands like IdentityFile.
case 'proxycommand': current.fields.proxyCommand = value; break;
case 'identityagent': current.fields.identityAgent = expandTilde(value); break;
}
}
@ -133,11 +154,13 @@ function mergeDefaults(blocks: RawHostBlock[]): RawFields {
const defaults: RawFields = {};
for (const b of blocks) {
if (!b.isDefaults) continue;
if (b.fields.hostname !== undefined) defaults.hostname = b.fields.hostname;
if (b.fields.user !== undefined) defaults.user = b.fields.user;
if (b.fields.port !== undefined) defaults.port = b.fields.port;
if (b.fields.identityFile !== undefined) defaults.identityFile = b.fields.identityFile;
if (b.fields.proxyJump !== undefined) defaults.proxyJump = b.fields.proxyJump;
if (b.fields.hostname !== undefined) defaults.hostname = b.fields.hostname;
if (b.fields.user !== undefined) defaults.user = b.fields.user;
if (b.fields.port !== undefined) defaults.port = b.fields.port;
if (b.fields.identityFile !== undefined) defaults.identityFile = b.fields.identityFile;
if (b.fields.proxyJump !== undefined) defaults.proxyJump = b.fields.proxyJump;
if (b.fields.proxyCommand !== undefined) defaults.proxyCommand = b.fields.proxyCommand;
if (b.fields.identityAgent !== undefined) defaults.identityAgent = b.fields.identityAgent;
}
return defaults;
}
@ -150,8 +173,10 @@ function buildEntry(
const hostname = fields.hostname ?? defaults.hostname ?? alias;
const user = fields.user ?? defaults.user ?? defaultUserName();
const port = fields.port ?? defaults.port ?? 22;
const identityFile = fields.identityFile ?? defaults.identityFile;
const rawJump = fields.proxyJump ?? defaults.proxyJump;
const identityFile = fields.identityFile ?? defaults.identityFile;
const rawJump = fields.proxyJump ?? defaults.proxyJump;
const proxyCommand = fields.proxyCommand ?? defaults.proxyCommand;
const identityAgent = fields.identityAgent ?? defaults.identityAgent;
return {
alias,
@ -164,6 +189,8 @@ function buildEntry(
// from the referenced alias when available, falling back to the
// parent host's user when no alias matched.
proxyJump: rawJump !== undefined ? parseProxyJumpRaw(rawJump) : undefined,
proxyCommand,
identityAgent,
};
}

View file

@ -32,8 +32,12 @@ export interface DaemonDownloaderDeps {
cacheDir: string;
/** Persist downloaded bytes to disk and mark executable (chmod +x). */
writeExecutable: (absPath: string, bytes: Uint8Array) => Promise<void>;
/** True if a cached file already exists. */
cacheHit: (absPath: string) => boolean;
/**
* Read a cached binary's bytes, or null if absent / unreadable. Used for
* sha-based cache validation: a cached file whose bytes still match the
* release manifest's expected sha is reused; anything else is re-downloaded.
*/
readCached: (absPath: string) => Promise<Uint8Array | null>;
/** `owner/repo` for the release URL. */
repo: string;
/**
@ -113,8 +117,6 @@ export async function ensureDaemonBinary(
): Promise<string> {
const filename = binaryFilename(target);
const dest = path.join(deps.cacheDir, filename);
if (deps.cacheHit(dest)) return dest;
const base = `https://github.com/${deps.repo}/releases/download/${deps.version}`;
const manifestRaw = await deps.fetchText(`${base}/${MANIFEST_NAME}`);
@ -142,6 +144,19 @@ export async function ensureDaemonBinary(
);
}
// Reuse the cached binary IFF its bytes already match the expected sha —
// i.e. the daemon didn't change across this bump. A cached binary from a
// prior plugin version (or the pre-static-build era) has the same filename
// but different bytes, so it fails this check and is re-downloaded. This is
// what makes a plugin upgrade refresh a *changed* daemon instead of
// re-deploying the stale one, while skipping the download for an unchanged
// one. (`writeExecutable` overwrites atomically, so the stale file is
// replaced in place — no separate delete.)
const cached = await deps.readCached(dest);
if (cached && sha256Hex(cached).toLowerCase() === expected.toLowerCase()) {
return dest;
}
const bytes = await deps.fetchBinary(`${base}/${filename}`);
const got = sha256Hex(bytes);
if (got.toLowerCase() !== expected.toLowerCase()) {

View file

@ -43,6 +43,15 @@ export interface SshProfile {
keepaliveCountMax: number;
hostKeyFingerprint?: string;
jumpHost?: JumpHostConfig;
/**
* OpenSSH `ProxyCommand` line, e.g. `cloudflared access ssh
* --hostname %h`. When set (and no `jumpHost`), the connection is
* tunnelled through this subprocess's stdio instead of a direct TCP
* socket covers Cloudflare-tunnel / `cloudflared` / `nc`-style
* reachability. `%h`/`%p`/`%r` are expanded at connect time. Desktop
* only (`child_process`). See `ProxyCommandTunnel`.
*/
proxyCommand?: string;
/**
* Picks between the legacy direct-SFTP transport and the α path
@ -125,6 +134,31 @@ export interface PluginSettings {
* is staged locally (community-store installs); remembered thereafter.
*/
daemonDownloadConsented?: boolean;
/**
* Plugin version the currently-cached daemon binary was validated for.
* The connect fast-path reuses the cached binary without hitting GitHub
* when this equals the running `manifest.version`; a mismatch (or absence)
* forces a sha re-check against the release manifest, so a plugin upgrade
* always refreshes a *changed* daemon and never re-downloads an unchanged
* one. Written after each successful (re)provision.
*/
daemonBinaryVersion?: string;
/**
* sha256 (hex) of the currently-cached daemon binary. The connect fast-path
* re-hashes the cached file and reuses it only when this still matches so
* a binary corrupted/truncated after its verified download is detected
* (network-free) and re-fetched instead of deployed. Paired with
* `daemonBinaryVersion`; both are written together after a provision.
*/
daemonBinarySha?: string;
/**
* Load the remote tree ONE folder level at a time (deepen on File-Explorer
* expand) instead of walking + materialising the whole tree at connect.
* Default true essential for large, deep, dir-heavy vaults where the eager
* build freezes the window. Set false to restore the full eager walk (fine
* for small vaults; makes search/graph see the whole tree immediately).
*/
lazyFolderLoad?: boolean;
/**
* #149 terminal pane preferences. All optional; the View applies
* sensible defaults when missing. `terminalShell` overrides the

View file

@ -0,0 +1,23 @@
/**
* Race a promise against a timeout. Rejects with a labelled error if
* `ms` elapses before `promise` settles; the timer is always cleared so
* a fast-settling promise leaves no dangling handle.
*
* The underlying promise is NOT cancelled on timeout callers that need
* teardown (e.g. closing a socket) must do it themselves, typically in a
* `finally`. Used by the pre-spawn config pull (#429b / Phase B-3) to
* bound how long a Connect blocks before falling back to spawning the
* shadow window and letting it catch up.
*/
export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
// `window.setTimeout` returns a number in the Obsidian renderer (DOM),
// not a NodeJS.Timeout — type it as such so @types/node's global
// overload doesn't leak in.
let timer: number | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = window.setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) window.clearTimeout(timer);
});
}

View file

@ -69,6 +69,13 @@ export interface BulkWalkResult {
pages: number;
/** When `fallback-list` because of a fast-path error, the error message; else null. */
fastPathError: string | null;
/**
* Count of entries dropped by the hidden-file (dot-prefix) filter before
* `entries` was returned. Lets the caller tell a genuinely empty remote
* apart from "everything walked was hidden" (e.g. all content nested under
* a dot-dir) instead of firing a misleading "0 files — check remotePath".
*/
hiddenCount: number;
}
/**
@ -111,13 +118,22 @@ export class BulkWalker {
*/
private static readonly MAX_PAGES = 1_000;
async walk(rootPath: string = ''): Promise<BulkWalkResult> {
/**
* Walk `rootPath`. With `recursive` true (default) the whole subtree is
* returned; with `recursive` false only `rootPath`'s IMMEDIATE children are
* returned the primitive the lazy per-folder loader uses to deepen one
* level on demand instead of pulling a huge deep tree at connect.
*/
async walk(rootPath: string = '', recursive: boolean = true): Promise<BulkWalkResult> {
const start = Date.now();
if (this.canUseFastPath()) {
try {
const result = await this.fastPath(rootPath);
const result = await this.fastPath(rootPath, recursive);
const visible = this.visibleEntries(result.entries);
return {
...result,
entries: visible,
hiddenCount: result.entries.length - visible.length,
walkMs: Date.now() - start,
// `truncated` here means we stopped at the page guard on a
// pathological tree — surface it so populate can Notice the
@ -127,23 +143,49 @@ export class BulkWalker {
} catch (e) {
const message = errorMessage(e);
logger.warn(`BulkWalker: fs.walk failed (${message}); falling back to per-folder list`);
const fallback = await this.fallbackPath(rootPath);
const fallback = await this.fallbackPath(rootPath, recursive);
const visible = this.visibleEntries(fallback.entries);
return {
...fallback,
entries: visible,
hiddenCount: fallback.entries.length - visible.length,
walkMs: Date.now() - start,
fastPathError: message,
};
}
}
const fallback = await this.fallbackPath(rootPath);
const fallback = await this.fallbackPath(rootPath, recursive);
const visible = this.visibleEntries(fallback.entries);
return {
...fallback,
entries: visible,
hiddenCount: fallback.entries.length - visible.length,
walkMs: Date.now() - start,
fastPathError: null,
};
}
/**
* Drop dot-prefixed entries so hidden files/dirs never reach the File
* Explorer matching Obsidian's own default of hiding dot-names. An entry
* is hidden when ANY of its path segments starts with `.`, so a dot-DIR
* (`.julia`, `.git`, ) hides its whole subtree. The vault config dir
* (`.obsidian`) is deliberately included: Obsidian loads config directly
* off the local shadow disk, NOT from this walked model, so dropping it
* costs nothing AND keeps every client's per-device `<configDir>/user/<id>/`
* subtree out of the tree (a foreign client's state must never surface as
* editable files). Applies to the full walk AND every lazy per-folder
* deepen (both route through here).
*/
private visibleEntries(entries: RemoteEntry[]): RemoteEntry[] {
return entries.filter((e) => !BulkWalker.isHiddenPath(e.path));
}
private static isHiddenPath(vaultPath: string): boolean {
return vaultPath.split('/').some((seg) => seg.startsWith('.'));
}
// ─── internals ──────────────────────────────────────────────────────────
private canUseFastPath(): boolean {
@ -152,7 +194,7 @@ export class BulkWalker {
return conn.info.capabilities.includes(BulkWalker.FAST_PATH_CAPABILITY);
}
private async fastPath(rootPath: string): Promise<{
private async fastPath(rootPath: string, recursive: boolean): Promise<{
entries: RemoteEntry[];
source: 'rpc-walk';
truncated: boolean;
@ -167,7 +209,7 @@ export class BulkWalker {
let offset = 0;
let pages = 0;
for (;;) {
const params: WalkParams = { path: rootPath, recursive: true };
const params: WalkParams = { path: rootPath, recursive };
if (this.deps.maxEntries != null) params.maxEntries = this.deps.maxEntries;
if (offset > 0) params.offset = offset;
if (this.deps.ignoreDirs?.length) params.ignore = this.deps.ignoreDirs;
@ -206,7 +248,7 @@ export class BulkWalker {
}
}
private async fallbackPath(rootPath: string): Promise<{
private async fallbackPath(rootPath: string, recursive: boolean): Promise<{
entries: RemoteEntry[];
source: 'fallback-list';
truncated: false;
@ -226,7 +268,7 @@ export class BulkWalker {
for (const sub of listing.folders) {
if (!sub) continue;
entries.push({ path: sub, isDirectory: true, ctime: 0, mtime: 0, size: 0 });
queue.push(sub);
if (recursive) queue.push(sub); // one level only when non-recursive
}
for (const file of listing.files) {
if (!file) continue;

View file

@ -0,0 +1,81 @@
import type { BulkWalker } from './BulkWalker';
import type { VaultModelBuilder } from './VaultModelBuilder';
import { logger } from '../util/logger';
import { errorMessage } from '../util/errorMessage';
/**
* Loads the remote vault tree ONE folder level at a time, on demand, instead
* of walking + materialising the whole (deep, dir-heavy) tree at connect.
*
* Connect does a shallow populate (root's immediate children). Every folder is
* materialised as an initially childless `TFolder` Obsidian still renders an
* expand arrow for a childless folder (verified in a real-Obsidian spike:
* `nav-folder-title` gets `mod-collapsible` + a collapse-icon regardless of
* child count), so the user can open it. A File-Explorer folder-expand click
* (wired in `main.ts`, since Obsidian renders from the in-memory model and
* does NOT call `adapter.list` on expand) calls {@link loadFolder}, which
* walks just that folder and materialises its children; their own subfolders
* stay unloaded until expanded in turn.
*
* `walkIgnoreDirs` is honoured per level (the walker carries it), so ignore
* pruning and lazy depth-limiting compose. The live `fs.changed` watch keeps
* working against a partially-loaded tree because inserts `ensureParents`.
*
* Idempotent + deduped: re-expanding a loaded folder is a no-op, and two
* near-simultaneous expands of the same folder share one walk.
*/
export class LazyFolderLoader {
private readonly loaded = new Set<string>();
private readonly inFlight = new Map<string, Promise<void>>();
constructor(
/** Fresh walker per load — mirrors how the connect populate builds one. */
private readonly makeWalker: () => BulkWalker,
/** Fresh (stateless) builder per load. */
private readonly makeBuilder: () => VaultModelBuilder,
) {}
/** Mark a path as already loaded (the root, after the connect shallow populate). */
markLoaded(path: string): void {
this.loaded.add(path);
}
/** Drop all lazy state (on disconnect / re-patch). */
reset(): void {
this.loaded.clear();
this.inFlight.clear();
}
isLoaded(path: string): boolean {
return this.loaded.has(path);
}
/**
* Materialise `folderPath`'s immediate children (one level). Resolves once
* they're in the vault model. No-op if the folder was already loaded; a
* concurrent load of the same folder shares one walk. A failed load leaves
* the folder unloaded so a later expand retries.
*/
loadFolder(folderPath: string): Promise<void> {
if (this.loaded.has(folderPath)) return Promise.resolve();
const existing = this.inFlight.get(folderPath);
if (existing) return existing;
const p = this.doLoad(folderPath);
this.inFlight.set(folderPath, p);
return p.finally(() => this.inFlight.delete(folderPath));
}
private async doLoad(folderPath: string): Promise<void> {
try {
const walk = await this.makeWalker().walk(folderPath, false); // one level only
const result = await this.makeBuilder().buildChunked(walk.entries);
this.loaded.add(folderPath);
logger.info(
`LazyFolderLoader: loaded "${folderPath}" — ${result.filesAdded}f + ${result.foldersAdded}d ` +
`(${result.skipped} already present)`,
);
} catch (e) {
logger.warn(`LazyFolderLoader: load "${folderPath}" failed (${errorMessage(e)}); will retry on next expand`);
}
}
}

View file

@ -103,36 +103,7 @@ export class VaultModelBuilder {
// Folders before files at the same depth, then by depth ascending,
// so parents always exist by the time their children are processed.
const ordered = [...entries].sort(byFoldersFirstThenDepth);
for (const entry of ordered) {
if (!entry.path) {
result.errors.push({ path: entry.path, message: 'empty path is not allowed' });
continue;
}
if (this.vault.getAbstractFileByPath(entry.path)) {
result.skipped++;
continue;
}
const parent = this.resolveParent(entry.path);
if (!parent) {
result.errors.push({
path: entry.path,
message: `parent folder for "${entry.path}" not found in vault`,
});
continue;
}
try {
if (entry.isDirectory) {
this.insertFolder(entry.path, parent);
result.foldersAdded++;
} else {
this.insertFile(entry, parent);
result.filesAdded++;
}
} catch (e) {
result.errors.push({ path: entry.path, message: errorMessage(e) });
}
}
for (const entry of ordered) this.insertBuildEntry(entry, result);
logger.info(
`VaultModelBuilder: built ${result.filesAdded}f + ${result.foldersAdded}d, ` +
@ -141,6 +112,77 @@ export class VaultModelBuilder {
return result;
}
/**
* Chunked, non-blocking variant of `build` for the bulk connect-time
* populate. Inserts the (folders-first) entry list in fixed-size chunks,
* yielding to the event loop between them so Obsidian can paint the File
* Explorer increments and stay responsive.
*
* A synchronous one-tick build of a large vault (tens of thousands of
* entries, each firing a `vault.trigger('create')`) blocks the main thread
* for tens of seconds the tree looks empty and the window freezes.
* Chunking trades that for the tree filling in progressively.
*
* Ordering is preserved (the sorted list is walked start-to-end), so a
* file's parent folder is always inserted in the same or an earlier chunk.
* Inserts are idempotent (an already-present path is skipped), so a live
* `fs.changed` insert landing between chunks can't double-add.
*/
async buildChunked(
entries: ReadonlyArray<RemoteEntry>,
chunkSize = 500,
): Promise<BuildResult> {
const result: BuildResult = {
filesAdded: 0, foldersAdded: 0, skipped: 0, errors: [],
};
const ordered = [...entries].sort(byFoldersFirstThenDepth);
for (let i = 0; i < ordered.length; i += chunkSize) {
const end = Math.min(i + chunkSize, ordered.length);
for (let j = i; j < end; j++) this.insertBuildEntry(ordered[j], result);
if (end < ordered.length) await yieldToEventLoop();
}
logger.info(
`VaultModelBuilder: built ${result.filesAdded}f + ${result.foldersAdded}d (chunked), ` +
`${result.skipped} skipped, ${result.errors.length} errors`,
);
return result;
}
/**
* Insert one walked entry into the vault model, accumulating counters and
* errors. Shared by `buildSync` and `buildChunked` so the two stay in
* lock-step. Never throws a bad entry is recorded in `result.errors`.
*/
private insertBuildEntry(entry: RemoteEntry, result: BuildResult): void {
if (!entry.path) {
result.errors.push({ path: entry.path, message: 'empty path is not allowed' });
return;
}
if (this.vault.getAbstractFileByPath(entry.path)) {
result.skipped++;
return;
}
const parent = this.resolveParent(entry.path);
if (!parent) {
result.errors.push({
path: entry.path,
message: `parent folder for "${entry.path}" not found in vault`,
});
return;
}
try {
if (entry.isDirectory) {
this.insertFolder(entry.path, parent);
result.foldersAdded++;
} else {
this.insertFile(entry, parent);
result.filesAdded++;
}
} catch (e) {
result.errors.push({ path: entry.path, message: errorMessage(e) });
}
}
// ─── internals ───────────────────────────────────────────────────────────
private resolveParent(path: string): TFolder | null {
@ -305,11 +347,20 @@ export class VaultModelBuilder {
* Returns `true` if the rename happened, `false` if the source
* wasn't in the model or the destination's parent is missing.
*/
renameOne(oldPath: string, newPath: string): boolean {
renameOne(oldPath: string, newPath: string, opts: { ensureParents?: boolean } = {}): boolean {
if (!oldPath || !newPath || oldPath === newPath) return false;
const target = this.vault.getAbstractFileByPath(oldPath);
if (!target) return false;
const newParent = this.resolveParent(newPath);
// The destination subtree may not be modelled yet — e.g. a note
// dragged into a freshly-created folder (#423). On the writer-reflect
// path we synthesise the missing parent chain instead of bailing,
// symmetric with `insertOne({ ensureParents: true })`. The bulk
// `build()` path and remote-echo `FsChangeListener` leave
// `ensureParents` off so a missing parent stays a sanity-check error.
let newParent = this.resolveParent(newPath);
if (!newParent && opts.ensureParents) {
newParent = this.ensureParentFolder(newPath);
}
if (!newParent) {
logger.warn(`VaultModelBuilder.renameOne: parent missing for "${newPath}"`);
return false;
@ -436,7 +487,17 @@ export class VaultModelBuilder {
/** Reflect a writer-side `adapter.rename`. */
reflectRename(oldPath: string, newPath: string): void {
if (!this.renameOne(oldPath, newPath)) {
// Obsidian's own `Vault.rename` may have already relocated the entry
// to the new path before this writer-reflect runs. The desired end
// state already holds, so it's a benign no-op — not the #341/#423
// divergence — and must not log a misleading "stale" warning.
if (!this.vault.getAbstractFileByPath(oldPath) && this.vault.getAbstractFileByPath(newPath)) {
return;
}
// `ensureParents`: a move into a not-yet-modelled folder (#423) must
// synthesise the destination subtree rather than drop the file from
// the model (which makes it vanish from File Explorer until reopen).
if (!this.renameOne(oldPath, newPath, { ensureParents: true })) {
// The open editor tab stays bound to the stale TFile — this is
// the core #341 failure. renameOne already warns with the
// specific cause; add the reflect context.
@ -526,6 +587,17 @@ export class VaultModelBuilder {
// ─── helpers ──────────────────────────────────────────────────────────────
/**
* Yield to the macrotask queue so the renderer can paint pending File
* Explorer updates between build chunks. A microtask (`Promise.resolve()`)
* would run before the browser paints; a 0 ms timer lets it paint first.
* Uses `window.setTimeout` per the repo's timer convention (the vitest setup
* polyfills `window`).
*/
function yieldToEventLoop(): Promise<void> {
return new Promise(resolve => { window.setTimeout(resolve, 0); });
}
/**
* Sort key: folders first at any given depth, then by depth ascending.
* Two folders at the same depth, or two files at the same depth, are

View file

@ -69,6 +69,48 @@ describe('BulkWalker', () => {
expect(result.entries[2]).toMatchObject({ path: 'README.md', isDirectory: false, mtime: 3000, size: 42 });
});
it('hides ALL dot-prefixed entries (incl the config dir) and reports hiddenCount', async () => {
const adapter = makeAdapter({});
const { rpc } = makeRpc(['fs.walk'], {
entries: [
{ path: 'Notes', type: 'folder', mtime: 1, size: 0 },
{ path: 'Notes/keep.md', type: 'file', mtime: 2, size: 1 },
{ path: '.julia', type: 'folder', mtime: 3, size: 0 },
{ path: '.julia/registry.md', type: 'file', mtime: 4, size: 1 },
{ path: 'Notes/.secret.md', type: 'file', mtime: 5, size: 1 },
{ path: '.obsidian/app.json', type: 'file', mtime: 6, size: 1 },
{ path: '.obsidian/user/box/app.json', type: 'file', mtime: 7, size: 1 },
],
truncated: false,
});
const walker = new BulkWalker({ adapter, rpcConnection: rpc });
const result = await walker.walk('');
// `.julia` + its subtree, a nested dotfile, AND the whole `.obsidian`
// config dir (incl any client's per-device `user/<id>/` subtree) are
// dropped — config is loaded off the local disk, never from this model.
expect(result.entries.map(e => e.path)).toEqual(['Notes', 'Notes/keep.md']);
expect(result.hiddenCount).toBe(5);
});
it('applies the same dotfile filter on the fallback (adapter.list) path', async () => {
const adapter = makeAdapter({
'': { folders: ['Notes', '.git'], files: ['README.md'] },
'Notes': { folders: [], files: ['Notes/keep.md', 'Notes/.secret.md'] },
'.git': { folders: [], files: ['.git/config'] },
});
const walker = new BulkWalker({ adapter /* no rpcConnection → fallback */ });
const result = await walker.walk('');
expect(result.source).toBe('fallback-list');
// `.git` (+ its file) and the nested `.secret.md` are dropped, exactly
// as on the fast path — the filter lives in walk(), shared by both.
expect(result.entries.map(e => e.path).sort()).toEqual(['Notes', 'Notes/keep.md', 'README.md']);
expect(result.hiddenCount).toBe(3);
});
it('passes through the maxEntries override when set', async () => {
const adapter = makeAdapter({});
const callSpy = vi.fn(async () => ({ entries: [], truncated: false } as WalkResult));

View file

@ -84,7 +84,7 @@ describe('ensureDaemonBinary', () => {
return bytes;
},
cacheDir: '/vault/.obsidian/plugins/remote-ssh/server-bin',
cacheHit: () => false,
readCached: async () => null,
writeExecutable: async (p, b) => {
written.set(p, b);
},
@ -101,18 +101,28 @@ describe('ensureDaemonBinary', () => {
expect(deps.written.get(p)).toEqual(bytes);
});
it('uses the cache and does NOT download on a cache hit', async () => {
it('reuses the cached binary WITHOUT downloading when its bytes match the manifest sha', async () => {
let fetched = false;
const deps = makeDeps({
cacheHit: () => true,
fetchBinary: async () => {
fetched = true;
return bytes;
},
readCached: async () => bytes, // cached bytes already hash to `sha`
fetchBinary: async () => { fetched = true; return bytes; },
});
const p = await ensureDaemonBinary(deps, target);
expect(p).toContain(filename);
expect(fetched).toBe(false);
expect(fetched).toBe(false); // no binary download
expect(deps.written.size).toBe(0); // nothing re-written
});
it('RE-DOWNLOADS when the cached binary sha does NOT match (stale from a prior plugin version)', async () => {
let fetched = false;
const stale = new Uint8Array([0x00, 0x11, 0x22]); // different bytes → different sha
const deps = makeDeps({
readCached: async () => stale,
fetchBinary: async () => { fetched = true; return bytes; },
});
const p = await ensureDaemonBinary(deps, target);
expect(fetched).toBe(true); // stale cache → fetched fresh
expect(deps.written.get(p)).toEqual(bytes); // overwritten with the correct binary
});
it('throws DaemonVerificationError on sha256 mismatch (never caches)', async () => {
@ -183,5 +193,7 @@ describe('resolveDaemonConsent', () => {
describe('deferred hardening (#406 review)', () => {
it.todo('verifies the cosign .bundle (sigstore) against the GitHub OIDC identity before deploy — closes the MITM gap that sha256-from-the-same-channel leaves open');
it.todo('shares the daemon binary cache across vaults/profiles (e.g. ~/.obsidian-remote/server-bin) so a second profile reuses the first download + single consent');
it.todo('re-verifies a cache-hit binary against its sha256 before reuse — defends post-write on-disk corruption (the atomic tmp+rename write already closes the mid-download crash window)');
// Implemented: ensureDaemonBinary now re-checks a cached binary's sha256
// against the release manifest before reuse (see the reuse / re-download
// tests above), which also refreshes a stale binary after a plugin upgrade.
});

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { LazyFolderLoader } from '../src/vault/LazyFolderLoader';
import type { BulkWalker } from '../src/vault/BulkWalker';
import type { VaultModelBuilder } from '../src/vault/VaultModelBuilder';
function fakeWalk(path: string) {
return {
entries: [{ path: `${path}/child.md`, isDirectory: false, ctime: 0, mtime: 0, size: 0 }],
source: 'rpc-walk' as const,
truncated: false,
walkMs: 1,
pages: 1,
fastPathError: null,
};
}
function makeBuilder(): VaultModelBuilder & { buildChunked: ReturnType<typeof vi.fn> } {
return {
buildChunked: vi.fn(async (entries: readonly unknown[]) => ({
filesAdded: entries.length, foldersAdded: 0, skipped: 0, errors: [],
})),
} as unknown as VaultModelBuilder & { buildChunked: ReturnType<typeof vi.fn> };
}
describe('LazyFolderLoader', () => {
it('loadFolder walks ONE level (recursive=false) and builds the children', async () => {
const walk = vi.fn(async (path: string, _recursive: boolean) => fakeWalk(path));
const builder = makeBuilder();
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => builder);
await loader.loadFolder('work/sub');
expect(walk).toHaveBeenCalledExactlyOnceWith('work/sub', false);
expect(builder.buildChunked).toHaveBeenCalledOnce();
expect(loader.isLoaded('work/sub')).toBe(true);
});
it('is idempotent — a second load of the same folder does not re-walk', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await loader.loadFolder('a');
await loader.loadFolder('a');
expect(walk).toHaveBeenCalledTimes(1);
});
it('dedupes concurrent loads of the same folder into one walk', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await Promise.all([loader.loadFolder('b'), loader.loadFolder('b'), loader.loadFolder('b')]);
expect(walk).toHaveBeenCalledTimes(1);
});
it('markLoaded skips the walk entirely', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
loader.markLoaded('c');
await loader.loadFolder('c');
expect(walk).not.toHaveBeenCalled();
});
it('a failed walk leaves the folder unloaded so a later expand retries', async () => {
const walk = vi.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(fakeWalk('x'));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await loader.loadFolder('x');
expect(loader.isLoaded('x')).toBe(false); // failed → not marked loaded
await loader.loadFolder('x'); // retry succeeds
expect(walk).toHaveBeenCalledTimes(2);
expect(loader.isLoaded('x')).toBe(true);
});
it('reset clears loaded state', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await loader.loadFolder('a');
expect(loader.isLoaded('a')).toBe(true);
loader.reset();
expect(loader.isLoaded('a')).toBe(false);
});
});

View file

@ -189,3 +189,50 @@ describe('ObsidianRegistry writeAtomic error propagation', () => {
.toThrow('EXDEV: cross-device link not permitted');
});
});
// ── isOpen (shadow-migration open-safety gate) ───────────────────────────
describe('ObsidianRegistry.isOpen', () => {
let configPath: string;
beforeEach(() => { configPath = makeTmpConfigPath(); });
afterEach(() => { try { fs.unlinkSync(configPath); } catch { /* may not exist */ } });
function writeVaults(vaults: Record<string, { path: string; ts: number; open?: boolean }>): void {
fs.writeFileSync(configPath, JSON.stringify({ vaults }));
}
it('returns true when the matching vault entry has open === true', () => {
writeVaults({ v: { path: '/vault/one', ts: 1, open: true } });
expect(new ObsidianRegistry(configPath).isOpen('/vault/one')).toBe(true);
});
it('returns false when the matching entry has open false or absent', () => {
writeVaults({
closed: { path: '/vault/closed', ts: 1, open: false },
noflag: { path: '/vault/noflag', ts: 2 },
});
const r = new ObsidianRegistry(configPath);
expect(r.isOpen('/vault/closed')).toBe(false);
expect(r.isOpen('/vault/noflag')).toBe(false);
});
it('returns false for a path not registered in the vault list', () => {
writeVaults({ v: { path: '/vault/one', ts: 1, open: true } });
expect(new ObsidianRegistry(configPath).isOpen('/vault/other')).toBe(false);
});
it('canonicalises a trailing separator so the open flag still matches', () => {
const base = path.resolve(os.tmpdir(), 'fake-vault-isopen');
writeVaults({ v: { path: base + path.sep, ts: 1, open: true } });
expect(new ObsidianRegistry(configPath).isOpen(base)).toBe(true);
});
it('assumes OPEN (defers migration) when obsidian.json is missing or corrupt', () => {
// Missing file — never written by this test — must fail safe, not throw.
expect(new ObsidianRegistry(configPath).isOpen('/vault/one')).toBe(true);
// Corrupt JSON: same fail-safe (renaming a possibly-open vault corrupts it).
fs.writeFileSync(configPath, '{ not valid json');
expect(new ObsidianRegistry(configPath).isOpen('/vault/one')).toBe(true);
});
});

View file

@ -49,6 +49,16 @@ describe('PathMapper.isPrivate', () => {
expect(m.isPrivate('.obsidian/types.json')).toBe(true);
});
it('treats per-device settings (app/appearance/core-plugins/hotkeys) as private', () => {
// Moved from shared → per-client so two devices (or one device
// across reconnects) never collide on a single shared copy — the
// perpetual write-conflict this fixes.
expect(m.isPrivate('.obsidian/app.json')).toBe(true);
expect(m.isPrivate('.obsidian/appearance.json')).toBe(true);
expect(m.isPrivate('.obsidian/core-plugins.json')).toBe(true);
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(true);
});
it('matches inside a private directory pattern', () => {
expect(m.isPrivate('.obsidian/cache/index')).toBe(true);
expect(m.isPrivate('.obsidian/cache/sub/x.bin')).toBe(true);
@ -61,7 +71,9 @@ describe('PathMapper.isPrivate', () => {
it('rejects regular vault content', () => {
expect(m.isPrivate('Notes/foo.md')).toBe(false);
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(false);
// community-plugins.json stays shared (round-tripped with a forced
// remote-ssh union), so it is NOT redirected per-client.
expect(m.isPrivate('.obsidian/community-plugins.json')).toBe(false);
expect(m.isPrivate('.obsidian/plugins/myplugin/data.json')).toBe(false);
});
@ -97,11 +109,13 @@ describe('PathMapper.toRemote / toVault', () => {
.toBe('.obsidian/user/host-a/workspace.json');
expect(m.toRemote('.obsidian/cache/foo.bin'))
.toBe('.obsidian/user/host-a/cache/foo.bin');
expect(m.toRemote('.obsidian/app.json'))
.toBe('.obsidian/user/host-a/app.json');
});
it('passes non-private paths through unchanged', () => {
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
expect(m.toRemote('.obsidian/hotkeys.json')).toBe('.obsidian/hotkeys.json');
expect(m.toRemote('.obsidian/community-plugins.json')).toBe('.obsidian/community-plugins.json');
expect(m.toRemote('.obsidian')).toBe('.obsidian');
});
@ -122,6 +136,17 @@ describe('PathMapper.toRemote / toVault', () => {
expect(other.toRemote('.obsidian/workspace.json'))
.toBe('.obsidian/user/SomeBox/workspace.json');
});
it('isolates per-device settings by client id (no cross-machine clobber)', () => {
// The whole point of making app.json per-device: two machines writing
// "the same" config file land on DIFFERENT remote paths, so neither can
// trip the other's write-precondition (the perpetual conflict fixed here).
const a = new PathMapper('host-a');
const b = new PathMapper('host-b');
expect(a.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-a/app.json');
expect(b.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-b/app.json');
expect(a.toRemote('.obsidian/app.json')).not.toBe(b.toRemote('.obsidian/app.json'));
});
});
describe('PathMapper.resolveListing', () => {

View file

@ -0,0 +1,124 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
// #430: a ProxyCommand subprocess transport. None of this exists yet —
// the module import fails today, which is the point: these cases lock
// in the contract the fix must satisfy.
// ProxyCommand cloudflared access ssh --hostname %h
// OpenSSH spawns that command and uses its stdio as the connection
// stream; ssh2 accepts the same via its `sock` option. The tunnel must
// (a) expand the %-tokens, (b) spawn the command, and (c) bridge the
// child's stdio as a single Duplex stream.
import {
createProxyCommandTunnel,
expandProxyCommandTokens,
} from '../src/ssh/ProxyCommandTunnel';
/**
* Minimal fake of a Node ChildProcess. `stdin`/`stdout` are real
* PassThrough streams so the tunnel's piping is exercised for real;
* `kill` records teardown.
*/
class FakeChild extends EventEmitter {
stdin = new PassThrough();
stdout = new PassThrough();
stderr = new PassThrough();
killed = 0;
kill(): boolean { this.killed++; return true; }
}
function fakeSpawn() {
const calls: Array<{ command: string; options: Record<string, unknown> }> = [];
const child = new FakeChild();
const spawnFn = ((command: string, options: Record<string, unknown>) => {
calls.push({ command, options });
return child;
}) as unknown as typeof import('child_process').spawn;
return { spawnFn, calls, child };
}
describe('expandProxyCommandTokens (#430)', () => {
it('substitutes %h (host) and %p (port)', () => {
expect(
expandProxyCommandTokens('ssh -W %h:%p jump.example.com', { host: 'target.example.com', port: 2222 }),
).toBe('ssh -W target.example.com:2222 jump.example.com');
});
it('substitutes %r (remote user) when provided', () => {
expect(
expandProxyCommandTokens('connect --user %r %h', { host: 'h', port: 22, user: 'alice' }),
).toBe('connect --user alice h');
});
it('expands the real-world cloudflared template', () => {
expect(
expandProxyCommandTokens('cloudflared access ssh --hostname %h', { host: 'lab.example.com', port: 22 }),
).toBe('cloudflared access ssh --hostname lab.example.com');
});
it('unescapes %% to a literal % and does not treat it as a token', () => {
expect(
expandProxyCommandTokens('echo 100%% done %h', { host: 'h', port: 22 }),
).toBe('echo 100% done h');
});
});
describe('createProxyCommandTunnel (#430)', () => {
it('spawns the token-expanded command and returns a stream', () => {
const { spawnFn, calls } = fakeSpawn();
const stream = createProxyCommandTunnel(
'cloudflared access ssh --hostname %h',
{ host: 'lab.example.com', port: 22 },
{ spawnFn },
);
expect(calls).toHaveLength(1);
expect(calls[0].command).toContain('cloudflared access ssh --hostname lab.example.com');
expect(typeof (stream as { pipe?: unknown }).pipe).toBe('function'); // a stream
});
it('forwards bytes written to the tunnel into the child stdin (client → proxy)', async () => {
const { spawnFn, child } = fakeSpawn();
const stream = createProxyCommandTunnel('proxy %h %p', { host: 'h', port: 22 }, { spawnFn });
const seen: Buffer[] = [];
child.stdin.on('data', (c: Buffer) => seen.push(Buffer.from(c)));
(stream as NodeJS.WritableStream).write(Buffer.from('SSH-2.0-hello'));
await new Promise((r) => setImmediate(r));
expect(Buffer.concat(seen).toString()).toBe('SSH-2.0-hello');
});
it('surfaces child stdout as readable data on the tunnel (proxy → client)', async () => {
const { spawnFn, child } = fakeSpawn();
const stream = createProxyCommandTunnel('proxy %h %p', { host: 'h', port: 22 }, { spawnFn });
const seen: Buffer[] = [];
(stream as NodeJS.ReadableStream).on('data', (c: Buffer) => seen.push(Buffer.from(c)));
child.stdout.write(Buffer.from('SSH-2.0-server'));
await new Promise((r) => setImmediate(r));
expect(Buffer.concat(seen).toString()).toBe('SSH-2.0-server');
});
it('tears the child process down when the tunnel stream closes', async () => {
const { spawnFn, child } = fakeSpawn();
const stream = createProxyCommandTunnel('proxy %h %p', { host: 'h', port: 22 }, { spawnFn });
expect(child.killed).toBe(0);
(stream as EventEmitter).emit('close');
await new Promise((r) => setImmediate(r));
expect(child.killed).toBe(1);
});
it('emits an error on the tunnel when the proxy command cannot be spawned', async () => {
const child = new FakeChild();
const spawnFn = (() => child) as unknown as typeof import('child_process').spawn;
const stream = createProxyCommandTunnel('does-not-exist %h', { host: 'h', port: 22 }, { spawnFn });
const onError = vi.fn();
(stream as EventEmitter).on('error', onError);
child.emit('error', new Error('spawn ENOENT'));
await new Promise((r) => setImmediate(r));
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringMatching(/ENOENT/) }));
});
});

View file

@ -15,6 +15,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ShadowVaultBootstrap } from '../src/shadow/ShadowVaultBootstrap';
import type { SharedConfigReader, SharedConfigWriter } from '../src/shadow/ShadowVaultBootstrap';
import { ObsidianRegistry } from '../src/shadow/ObsidianRegistry';
import type { SshProfile, PendingPluginSuggestion } from '../src/types';
@ -82,29 +83,113 @@ function makeScratch(): {
}
describe('ShadowVaultBootstrap.layoutFor', () => {
it('returns a layout under baseDir/profileId for a clean id', () => {
it('names the vault dir <friendly-name>--<remotePath-tail> so Obsidian shows a recognisable, folder-distinct name', () => {
const scratch = makeScratch();
try {
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const layout = r.layoutFor('abc-123');
expect(layout.vaultDir).toBe(path.join(scratch.baseDir, 'abc-123'));
expect(layout.configDir).toBe(path.join(scratch.baseDir, 'abc-123', '.obsidian'));
expect(layout.pluginDir).toBe(path.join(scratch.baseDir, 'abc-123', '.obsidian', 'plugins', 'remote-ssh'));
const layout = r.layoutFor({ name: 'My Homelab', remotePath: '/home/souta/work' });
expect(path.basename(layout.vaultDir)).toBe('My Homelab--work');
expect(layout.configDir).toBe(path.join(layout.vaultDir, '.obsidian'));
expect(layout.pluginDir).toBe(path.join(layout.vaultDir, '.obsidian', 'plugins', 'remote-ssh'));
expect(layout.pluginDataFile).toBe(path.join(layout.pluginDir, 'data.json'));
} finally { scratch.cleanup(); }
});
it('sanitises ids that contain path separators or traversal so vaultDir stays inside baseDir', () => {
it('uses the last path segment as the tail, ignoring a trailing slash', () => {
const scratch = makeScratch();
try {
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
// `../etc` would escape baseDir if not sanitised.
const lo = r.layoutFor('../etc');
const layout = r.layoutFor({ name: 'Panza', remotePath: '/home/souta/work/dev/' });
expect(path.basename(layout.vaultDir)).toBe('Panza--dev');
} finally { scratch.cleanup(); }
});
it('falls back to a generic name and tail when both are empty', () => {
const scratch = makeScratch();
try {
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const layout = r.layoutFor({ name: '', remotePath: '' });
expect(path.basename(layout.vaultDir)).toBe('vault--vault');
} finally { scratch.cleanup(); }
});
it('same host name + different remotePath → different dirs (multiple vaults on one host)', () => {
const scratch = makeScratch();
try {
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const a = r.layoutFor({ name: 'Panza', remotePath: '/home/souta/work' });
const b = r.layoutFor({ name: 'Panza', remotePath: '/home/souta/work/dev' });
expect(a.vaultDir).not.toBe(b.vaultDir);
expect(path.basename(a.vaultDir)).toBe('Panza--work');
expect(path.basename(b.vaultDir)).toBe('Panza--dev');
} finally { scratch.cleanup(); }
});
it('sanitises name + tail so the dir never escapes baseDir', () => {
const scratch = makeScratch();
try {
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const lo = r.layoutFor({ name: '../../evil/name', remotePath: '/x/../../etc/' });
expect(path.dirname(lo.vaultDir)).toBe(scratch.baseDir);
expect(path.resolve(lo.vaultDir).startsWith(path.resolve(scratch.baseDir) + path.sep)).toBe(true);
// Slashes inside an id should not introduce nested directories.
const slash = r.layoutFor('a/b/c');
expect(path.dirname(slash.vaultDir)).toBe(scratch.baseDir);
} finally { scratch.cleanup(); }
});
it('falls back to a generic tail for a bare `~` home-dir remotePath', () => {
const scratch = makeScratch();
try {
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
expect(path.basename(r.layoutFor({ name: 'Home', remotePath: '~' }).vaultDir)).toBe('Home--vault');
expect(path.basename(r.layoutFor({ name: 'Home', remotePath: '~/' }).vaultDir)).toBe('Home--vault');
} finally { scratch.cleanup(); }
});
it('splits on backslash separators too (Windows-style remotePath)', () => {
const scratch = makeScratch();
try {
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
expect(path.basename(r.layoutFor({ name: 'Win', remotePath: 'C:\\Users\\a\\notes' }).vaultDir)).toBe('Win--notes');
} finally { scratch.cleanup(); }
});
});
describe('ShadowVaultBootstrap: server-bin (daemon dir) install', () => {
it('mirrors a REAL source server-bin dir into the shadow (local dev build)', async () => {
const scratch = makeScratch();
try {
const srcBin = path.join(scratch.sourceDir, 'server-bin');
fs.mkdirSync(srcBin, { recursive: true });
fs.writeFileSync(path.join(srcBin, 'obsidian-remote-server'), 'ELF\n');
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const profile = makeProfile({ id: 's1', name: 'S', remotePath: '/s1' });
const result = await r.bootstrap(profile, [profile]);
// Real dir → mirrored (symlink or copy); the binary is reachable.
expect(fs.existsSync(path.join(result.layout.pluginDir, 'server-bin', 'obsidian-remote-server'))).toBe(true);
} finally { scratch.cleanup(); }
});
it('does NOT propagate a junction/symlink source server-bin (avoids the dangling-junction daemon breakage)', async () => {
const scratch = makeScratch();
try {
// The corrupted shape: source/server-bin is a LINK to a dir elsewhere.
// Propagating it would chain the shadow to another vault and dangle if
// that vault is deleted (the field bug: mkdir server-bin → ENOENT).
const realElsewhere = path.join(scratch.sourceVaultDir, 'real-bin');
fs.mkdirSync(realElsewhere, { recursive: true });
fs.writeFileSync(path.join(realElsewhere, 'obsidian-remote-server'), 'ELF\n');
const srcBinLink = path.join(scratch.sourceDir, 'server-bin');
try {
fs.symlinkSync(realElsewhere, srcBinLink, process.platform === 'win32' ? 'junction' : 'dir');
} catch (e) {
console.warn(`skipping: cannot create symlink in test env: ${(e as Error).message}`);
return;
}
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const profile = makeProfile({ id: 's2', name: 'S', remotePath: '/s2' });
const result = await r.bootstrap(profile, [profile]);
// Shadow gets NO server-bin — ensureDaemonBinary creates a real per-shadow
// dir at connect time instead of inheriting a link.
expect(fs.existsSync(path.join(result.layout.pluginDir, 'server-bin'))).toBe(false);
} finally { scratch.cleanup(); }
});
});
@ -315,12 +400,17 @@ describe('ShadowVaultBootstrap.bootstrap', () => {
});
it('regression: a stale whole-dir symlink at pluginDir is unlinked, not followed (does not delete source)', async () => {
// Simulate the pre-fix on-disk state: pluginDir is a symlink to
// sourceDir. installPlugin must replace this with a real dir
// without deleting any of the source files.
// Simulate the pre-fix on-disk state: pluginDir is a whole-dir
// symlink to the source plugin dir. installPlugin must replace it
// with a real dir without deleting any of the source files.
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const layout = r.layoutFor('p1');
const profile = makeProfile({ id: 'p1', name: 'P', remotePath: '/p1' });
const layout = r.layoutFor(profile);
fs.mkdirSync(path.dirname(layout.pluginDir), { recursive: true });
// The source plugin dir carries a data.json in the field; give it this
// profile's id so find-by-id resolves the shadow THROUGH the link
// (instead of forking a " (2)" dir) and installPlugin runs on the junction.
fs.writeFileSync(path.join(scratch.sourceDir, 'data.json'), JSON.stringify({ autoConnectProfileId: 'p1' }));
try {
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
fs.symlinkSync(scratch.sourceDir, layout.pluginDir, linkType);
@ -337,7 +427,6 @@ describe('ShadowVaultBootstrap.bootstrap', () => {
const sourceCanary = path.join(scratch.sourceDir, 'CANARY.txt');
fs.writeFileSync(sourceCanary, 'do-not-delete', 'utf-8');
const profile = makeProfile({ id: 'p1' });
await r.bootstrap(profile, [profile]);
// Source file still there.
@ -956,3 +1045,505 @@ describe('ShadowVaultBootstrap — seedObsidianFirstRunState', () => {
expect(fs.readFileSync(corePath, 'utf-8')).toBe(custom);
});
});
// ─── community-plugins round-trip (#429 / #342 residual) ────────────────
//
// app/appearance/core-plugins/hotkeys already round-trip via
// pull/pushSharedObsidianConfig, but the *enabled community-plugins*
// list does NOT: it is only ever seeded locally as ["remote-ssh"]
// (see the bootstrap suite above). So a plugin enabled on machine A
// never reaches machine B's shadow vault, and reopening the vault
// "loses" installed plugins — exactly kazink's #429 follow-up and the
// #342 residual.
//
// community-plugins.json must NOT join the verbatim
// SHARED_OBSIDIAN_CONFIG_FILES set: a remote list that happens to omit
// `remote-ssh` would, written verbatim, disable the very plugin doing
// the sync. The fix is a dedicated pull/push pair that round-trips the
// list while always keeping `remote-ssh` enabled. These fail today —
// the methods do not exist yet.
describe('ShadowVaultBootstrap community-plugins round-trip (#429 / #342)', () => {
function makeLocalConfigDir(seed: string[] = ['remote-ssh']): string {
const dir = path.join(
os.tmpdir(),
`cp-roundtrip-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
'.obsidian',
);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'community-plugins.json'), JSON.stringify(seed), 'utf-8');
return dir;
}
function readLocal(dir: string): string[] {
return JSON.parse(fs.readFileSync(path.join(dir, 'community-plugins.json'), 'utf-8'));
}
it('pulls the remote enabled-plugin list into the shadow vault, keeping remote-ssh enabled', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
const remote: Record<string, string> = {
'.obsidian/community-plugins.json': JSON.stringify(['dataview', 'templater']),
};
const reader: SharedConfigReader = {
exists: (p) => Promise.resolve(p in remote),
read: (p) => Promise.resolve(remote[p]),
};
await ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir);
expect(readLocal(localConfigDir)).toEqual(
expect.arrayContaining(['dataview', 'templater', 'remote-ssh']),
);
});
it('keeps remote-ssh enabled even when the remote list omits it', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
const remote: Record<string, string> = {
'.obsidian/community-plugins.json': JSON.stringify(['dataview']),
};
const reader: SharedConfigReader = {
exists: (p) => Promise.resolve(p in remote),
read: (p) => Promise.resolve(remote[p]),
};
await ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir);
const local = readLocal(localConfigDir);
expect(local, 'remote-ssh must survive a pull that omits it').toContain('remote-ssh');
expect(local).toContain('dataview');
});
it('is a benign no-op when the remote has no community-plugins.json yet', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
const reader: SharedConfigReader = {
exists: () => Promise.resolve(false),
read: () => Promise.reject(new Error('must not read an absent file')),
};
await expect(
ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir),
).resolves.toBeDefined();
expect(readLocal(localConfigDir)).toEqual(['remote-ssh']);
});
it('does not clobber a healthy local list when the remote list is corrupt JSON', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh', 'dataview']);
const remote: Record<string, string> = {
'.obsidian/community-plugins.json': '{ this is : not json',
};
const reader: SharedConfigReader = {
exists: (p) => Promise.resolve(p in remote),
read: (p) => Promise.resolve(remote[p]),
};
await ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir);
expect(readLocal(localConfigDir)).toEqual(
expect.arrayContaining(['remote-ssh', 'dataview']),
);
});
// Reader+writer fake backed by an in-memory store. `read` can be made
// to throw to simulate a transient SSH error mid-read.
function makeRemoteRW(initial?: string[], readThrows = false): {
rw: SharedConfigReader & SharedConfigWriter;
store: Record<string, string>;
} {
const store: Record<string, string> = {};
if (initial) store['.obsidian/community-plugins.json'] = JSON.stringify(initial);
const rw: SharedConfigReader & SharedConfigWriter = {
exists: (p) => Promise.resolve(p in store),
read: (p) => (readThrows ? Promise.reject(new Error('SSH hiccup')) : Promise.resolve(store[p])),
write: (p, c) => { store[p] = c; return Promise.resolve(); },
};
return { rw, store };
}
it('seeds the remote (union with local) when the remote has none yet', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh', 'dataview']);
const { rw, store } = makeRemoteRW();
await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
expect(store['.obsidian/community-plugins.json'], 'a push must seed the remote').toBeDefined();
expect(JSON.parse(store['.obsidian/community-plugins.json'])).toEqual(
expect.arrayContaining(['dataview', 'remote-ssh']),
);
});
it('unions with the remote list and never drops a plugin enabled elsewhere', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh', 'templater']);
const { rw, store } = makeRemoteRW(['remote-ssh', 'dataview']);
await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
const pushed = JSON.parse(store['.obsidian/community-plugins.json']);
expect(pushed, 'remote dataview must survive + local templater added')
.toEqual(expect.arrayContaining(['dataview', 'templater', 'remote-ssh']));
});
it('does NOT clobber the remote when the remote read fails (transient SSH error)', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh']); // minimal local
const { rw, store } = makeRemoteRW(['remote-ssh', 'dataview', 'obsidian-git'], /*readThrows*/ true);
const before = store['.obsidian/community-plugins.json'];
const r = await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
expect(r.pushed).toBe(false);
expect(store['.obsidian/community-plugins.json'], 'remote list must be untouched (no data loss)').toBe(before);
});
it('does NOT clobber the remote when the remote list is corrupt JSON', async () => {
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
const { rw, store } = makeRemoteRW();
store['.obsidian/community-plugins.json'] = '{ not : json';
const r = await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
expect(r.pushed).toBe(false);
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 };
}
// A manifest.json body carrying the version the round-trip orders on.
const manifest = (id: string, version: string) => JSON.stringify({ id, version });
it('pulls a plugin that is ABSENT locally (fresh install from the remote)', async () => {
const cfg = makeLocalConfigDir();
const { rw } = makeRW({
'.obsidian/plugins/brat-x/manifest.json': manifest('brat-x', '1.0.0'),
'.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('UPGRADES a local plugin when the remote is a strictly newer version', async () => {
const cfg = makeLocalConfigDir();
seedLocal(cfg, 'p', 'manifest.json', manifest('p', '1.0.0'));
seedLocal(cfg, 'p', 'main.js', '/* OLD v1 */\n');
const { rw } = makeRW({
'.obsidian/plugins/p/manifest.json': manifest('p', '2.0.0'),
'.obsidian/plugins/p/main.js': '/* NEW v2 */\n',
});
await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']);
expect(fs.readFileSync(pluginFile(cfg, 'p', 'main.js'), 'utf-8'), 'newer remote must upgrade local')
.toBe('/* NEW v2 */\n');
});
it('does NOT downgrade a local plugin when the remote is OLDER (#429b clobber guard)', async () => {
const cfg = makeLocalConfigDir();
seedLocal(cfg, 'p', 'manifest.json', manifest('p', '2.0.0'));
seedLocal(cfg, 'p', 'main.js', '/* NEW v2 */\n');
const { rw } = makeRW({
'.obsidian/plugins/p/manifest.json': manifest('p', '1.0.0'),
'.obsidian/plugins/p/main.js': '/* OLD v1 */\n',
});
await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']);
expect(fs.readFileSync(pluginFile(cfg, 'p', 'main.js'), 'utf-8'), 'older remote must NOT clobber newer local')
.toBe('/* NEW v2 */\n');
});
it('is a no-op when the remote has no manifest (nothing to pull)', async () => {
const cfg = makeLocalConfigDir();
const { rw } = makeRW({ '.obsidian/plugins/p/main.js': '/* orphan main, no manifest */\n' });
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']);
expect(pulled).not.toContain('p');
expect(fs.existsSync(pluginFile(cfg, 'p', 'main.js'))).toBe(false);
});
it('skips remote-ssh on pull — the plugin manages its own install', async () => {
const cfg = makeLocalConfigDir();
const { rw } = makeRW({ '.obsidian/plugins/remote-ssh/manifest.json': manifest('remote-ssh', '9.9.9') });
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['remote-ssh']);
expect(pulled).not.toContain('remote-ssh');
expect(fs.existsSync(pluginFile(cfg, 'remote-ssh', 'manifest.json'))).toBe(false);
});
it('seeds the remote when it LACKS the plugin (push)', async () => {
const cfg = makeLocalConfigDir();
seedLocal(cfg, 'q', 'manifest.json', manifest('q', '1.0.0'));
seedLocal(cfg, 'q', 'main.js', '/* q code */\n');
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(manifest('q', '1.0.0'));
});
it('pushes an UPGRADE when the local copy is a strictly newer version', async () => {
const cfg = makeLocalConfigDir();
seedLocal(cfg, 'q', 'manifest.json', manifest('q', '2.0.0'));
seedLocal(cfg, 'q', 'main.js', '/* NEW v2 */\n');
const { rw, store } = makeRW({
'.obsidian/plugins/q/manifest.json': manifest('q', '1.0.0'),
'.obsidian/plugins/q/main.js': '/* OLD v1 */\n',
});
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['q']);
expect(pushed).toContain('q');
expect(store['.obsidian/plugins/q/main.js'], 'newer local must upgrade the remote').toBe('/* NEW v2 */\n');
});
it('does NOT downgrade the remote when the local copy is OLDER (#429b clobber guard)', async () => {
const cfg = makeLocalConfigDir();
seedLocal(cfg, 'q', 'manifest.json', manifest('q', '1.0.0'));
seedLocal(cfg, 'q', 'main.js', '/* OLD v1 */\n');
const { rw, store } = makeRW({
'.obsidian/plugins/q/manifest.json': manifest('q', '2.0.0'),
'.obsidian/plugins/q/main.js': '/* NEW v2 */\n',
});
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['q']);
expect(pushed).not.toContain('q');
expect(store['.obsidian/plugins/q/main.js'], 'older local must NOT clobber newer remote').toBe('/* NEW v2 */\n');
});
it('push is skipped when the local plugin has no manifest', async () => {
const cfg = makeLocalConfigDir();
seedLocal(cfg, 'r', 'main.js', '/* code, no manifest */\n');
const { rw, store } = makeRW();
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['r']);
expect(pushed).not.toContain('r');
expect(store['.obsidian/plugins/r/main.js']).toBeUndefined();
});
it('CONVERGES without ping-pong: pull an upgrade, then push is a no-op', async () => {
const cfg = makeLocalConfigDir();
seedLocal(cfg, 'c', 'manifest.json', manifest('c', '1.0.0'));
seedLocal(cfg, 'c', 'main.js', '/* v1 */\n');
const { rw, store } = makeRW({
'.obsidian/plugins/c/manifest.json': manifest('c', '2.0.0'),
'.obsidian/plugins/c/main.js': '/* v2 */\n',
});
// Round 1 pull: the newer remote upgrades local to v2.
await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['c']);
expect(fs.readFileSync(pluginFile(cfg, 'c', 'main.js'), 'utf-8')).toBe('/* v2 */\n');
// Then push: local == remote (both v2) → nothing pushed, no oscillation.
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['c']);
expect(pushed, 'after converging on v2 the push must be a no-op').not.toContain('c');
expect(store['.obsidian/plugins/c/main.js']).toBe('/* v2 */\n');
});
it('a per-plugin SSH error is swallowed and does not abort the batch', async () => {
const cfg = makeLocalConfigDir();
const { rw } = makeRW({
'.obsidian/plugins/bad/manifest.json': manifest('bad', '1.0.0'),
'.obsidian/plugins/good/manifest.json': manifest('good', '1.0.0'),
'.obsidian/plugins/good/main.js': '/* good */\n',
});
const origRead = rw.read;
rw.read = (p) => (p.includes('/bad/') ? Promise.reject(new Error('SSH hiccup')) : origRead(p));
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['bad', 'good']);
expect(pulled, 'good pulls even though bad errored').toContain('good');
expect(pulled).not.toContain('bad');
});
});
// ─── shadow-dir naming + migration (identity = profile id) ──────────────
//
// The shadow dir is `<friendly-name>--<remotePath-tail>` so Obsidian
// shows a recognisable, folder-distinct name. Identity is the profile
// *id* (persisted as data.json `autoConnectProfileId`), NOT the dir
// name — so a profile rename, an older `<uuid>` / `--<id8>` name, or a
// display-name collision all still resolve to the same shadow via
// find-by-id. A stale dir name is renamed once, unless the vault is
// open (Windows corruption guard) or the name collides with another
// profile's.
describe('ShadowVaultBootstrap shadow-dir migration (find-by-id)', () => {
let scratch: ReturnType<typeof makeScratch>;
beforeEach(() => { scratch = makeScratch(); });
afterEach(() => { scratch.cleanup(); });
/**
* Seed an existing shadow dir with a data.json carrying the profile
* id (the stable identity find-by-id keys on) plus a
* community-plugins.json so config carry-over is observable.
*/
function seedShadow(dirName: string, profileId: string, cp: string[] = ['remote-ssh']): string {
const dir = path.join(scratch.baseDir, dirName);
const pluginDir = path.join(dir, '.obsidian', 'plugins', 'remote-ssh');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'data.json'), JSON.stringify({ autoConnectProfileId: profileId }));
fs.writeFileSync(path.join(dir, '.obsidian', 'community-plugins.json'), JSON.stringify(cp));
return dir;
}
it('reuses the existing shadow by id and migrates a renamed profile to <name>--<tail>', async () => {
const id = 'dddddddd-1111-2222-3333-444444444444';
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'dataview']);
const registry = new ObsidianRegistry(scratch.configPath);
const { id: regId } = registry.register(oldDir);
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, registry)
.bootstrap(profile, [profile]);
expect(result.migrated).toBe(true);
expect(path.basename(result.layout.vaultDir)).toBe('New Name--work');
expect(fs.existsSync(oldDir)).toBe(false); // old dir moved, not left behind
expect(JSON.parse(fs.readFileSync(path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8')))
.toEqual(expect.arrayContaining(['remote-ssh', 'dataview'])); // config carried over
const cfg = JSON.parse(fs.readFileSync(scratch.configPath, 'utf-8'));
expect(cfg.vaults[regId].path).toBe(result.layout.vaultDir); // same registry id, repointed
});
it('migrates a legacy <uuid>-named dir (data.json carries the id) to <name>--<tail>', async () => {
const id = 'eeeeeeee-1111-2222-3333-444444444444';
const legacyDir = seedShadow(id, id, ['remote-ssh', 'keepme']); // dir named by the raw uuid
const profile = makeProfile({ id, name: 'My Homelab', remotePath: '/home/souta/work' });
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath))
.bootstrap(profile, [profile]);
expect(result.migrated).toBe(true);
expect(path.basename(result.layout.vaultDir)).toBe('My Homelab--work');
expect(fs.existsSync(legacyDir)).toBe(false);
expect(JSON.parse(fs.readFileSync(path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8')))
.toContain('keepme');
});
it('is idempotent: a brand-new bootstrap then a second one does not migrate', async () => {
const id = 'ffffffff-1111-2222-3333-444444444444';
const profile = makeProfile({ id, name: 'Vault', remotePath: '/home/souta/work' });
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const first = await r.bootstrap(profile, [profile]);
expect(first.migrated).toBe(false); // brand-new, not a migration
expect(path.basename(first.layout.vaultDir)).toBe('Vault--work');
const second = await r.bootstrap(profile, [profile]);
expect(second.migrated).toBe(false);
expect(second.layout.vaultDir).toBe(first.layout.vaultDir);
});
it('does not migrate a brand-new profile (no existing shadow)', async () => {
const profile = makeProfile({ id: 'aaaa0000-1111', name: 'Fresh', remotePath: '/srv/notes' });
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath))
.bootstrap(profile, [profile]);
expect(result.migrated).toBe(false);
expect(path.basename(result.layout.vaultDir)).toBe('Fresh--notes');
});
it('open-safety: does NOT rename when the found vault is currently open', async () => {
const id = 'bbbb0000-1111-2222-3333-444444444444';
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'keepme']);
const registry = new ObsidianRegistry(scratch.configPath);
registry.register(oldDir);
const isOpenSpy = vi.spyOn(registry, 'isOpen').mockReturnValue(true);
try {
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, registry)
.bootstrap(profile, [profile]);
expect(result.migrated).toBe(false);
expect(result.layout.vaultDir).toBe(oldDir); // used as-is, rename deferred
expect(fs.existsSync(oldDir)).toBe(true);
expect(fs.existsSync(path.join(scratch.baseDir, 'New Name--work'))).toBe(false);
} finally { isOpenSpy.mockRestore(); }
});
it('collision: a second profile with the same <name>--<tail> gets a " (2)" dir, configs stay separate', async () => {
const a = makeProfile({ id: 'aaaa1111-2222-3333-4444-555555555555', name: 'Panza', remotePath: '/home/a/work' });
const b = makeProfile({ id: 'bbbb2222-3333-4444-5555-666666666666', name: 'Panza', remotePath: '/home/b/work' });
const boot = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
const ra = await boot.bootstrap(a, [a, b]);
const rb = await boot.bootstrap(b, [a, b]);
expect(path.basename(ra.layout.vaultDir)).toBe('Panza--work');
expect(path.basename(rb.layout.vaultDir)).toBe('Panza--work (2)');
expect(ra.layout.vaultDir).not.toBe(rb.layout.vaultDir);
// Distinct data.json → the two vaults never merge into one dir.
expect(JSON.parse(fs.readFileSync(ra.layout.pluginDataFile, 'utf-8')).autoConnectProfileId).toBe(a.id);
expect(JSON.parse(fs.readFileSync(rb.layout.pluginDataFile, 'utf-8')).autoConnectProfileId).toBe(b.id);
// Reconnect: a profile parked in a ` (2)` dir must resolve back to its
// OWN dir with migrated=false — not report a spurious migration (a no-op
// same-path rename) and re-show the one-time "restart Obsidian" notice
// on every connect.
const rb2 = await boot.bootstrap(b, [a, b]);
expect(rb2.migrated).toBe(false);
expect(rb2.layout.vaultDir).toBe(rb.layout.vaultDir);
const ra2 = await boot.bootstrap(a, [a, b]);
expect(ra2.migrated).toBe(false);
expect(ra2.layout.vaultDir).toBe(ra.layout.vaultDir);
});
it('commits the migration even if the registry update throws AFTER a successful rename', async () => {
// #438 regression: rename succeeds (config physically moves) but
// updatePath throws (obsidian.json briefly locked). The session must
// use the migrated dir — NOT fall back to the old dir, which bootstrap
// would recreate empty, opening a blank vault while config sits orphaned.
const id = 'cccc3333-4444-5555-6666-777777777777';
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'keepme']);
const registry = new ObsidianRegistry(scratch.configPath);
registry.register(oldDir);
const updateSpy = vi.spyOn(registry, 'updatePath').mockImplementation(() => { throw new Error('EBUSY'); });
try {
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, registry)
.bootstrap(profile, [profile]);
expect(result.migrated, 'rename succeeded → migration committed').toBe(true);
expect(path.basename(result.layout.vaultDir)).toBe('New Name--work');
expect(fs.existsSync(oldDir), 'old dir was moved, not recreated empty').toBe(false);
expect(
JSON.parse(fs.readFileSync(path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8')),
'config must live at the migrated dir, never orphaned',
).toContain('keepme');
} finally { updateSpy.mockRestore(); }
});
it('falls back to the found dir (no data loss) when the rename fails', async () => {
const id = 'dddd4444-5555-6666-7777-888888888888';
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'keepme']);
// Only the migration rename (the first renameSync) throws; bootstrap's
// own later atomic writes use the real implementation.
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { throw new Error('EBUSY'); });
try {
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath))
.bootstrap(profile, [profile]);
expect(result.migrated).toBe(false);
expect(result.layout.vaultDir).toBe(oldDir); // fell back to found this session
expect(JSON.parse(fs.readFileSync(path.join(oldDir, '.obsidian', 'community-plugins.json'), 'utf-8')))
.toContain('keepme'); // config NOT lost
} finally { renameSpy.mockRestore(); }
});
});

View file

@ -13,6 +13,17 @@ function makeProfile(id: string, name = id): SshProfile {
} 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[] = [];
@ -55,4 +66,43 @@ describe('ShadowVaultManager.openShadowFor', () => {
).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);
});
});

View file

@ -272,3 +272,85 @@ describe('readSshConfig: ProxyJump', () => {
expect(srv.proxyJump?.host).toBe('10.0.0.99');
});
});
// ─── ProxyCommand (#430) ────────────────────────────────────────────────
//
// A user behind a Cloudflare tunnel connects via
// ProxyCommand cloudflared access ssh --hostname %h
// The reader must surface the raw command (OpenSSH %-tokens left
// intact for the transport layer to expand at connect time). None of
// these pass today — the parser has no `proxycommand` case and
// SshConfigEntry has no `proxyCommand` field.
describe('readSshConfig: ProxyCommand (#430)', () => {
it('parses a ProxyCommand and preserves the raw command incl. %-tokens', () => {
const p = write([
'Host homelab',
' HostName lab.example.com',
' User alice',
' ProxyCommand cloudflared access ssh --hostname %h',
].join('\n'));
const entry = readSshConfig(p)[0];
expect(entry.alias).toBe('homelab');
expect(entry.hostname).toBe('lab.example.com');
expect(entry.proxyCommand).toBe('cloudflared access ssh --hostname %h');
});
it('leaves proxyCommand undefined when the host declares none', () => {
const p = write(['Host plain', ' HostName plain.example.com'].join('\n'));
expect(readSshConfig(p)[0].proxyCommand).toBeUndefined();
});
it('applies a ProxyCommand from the Host * defaults block', () => {
const p = write([
'Host *',
' ProxyCommand nc -X connect -x proxy:1080 %h %p',
'Host srv',
' HostName srv.example.com',
].join('\n'));
expect(readSshConfig(p).find(e => e.alias === 'srv')?.proxyCommand)
.toBe('nc -X connect -x proxy:1080 %h %p');
});
it('a host-specific ProxyCommand overrides the Host * default', () => {
const p = write([
'Host *',
' ProxyCommand default-proxy %h',
'Host srv',
' HostName srv.example.com',
' ProxyCommand specific-proxy %h %p',
].join('\n'));
expect(readSshConfig(p).find(e => e.alias === 'srv')?.proxyCommand)
.toBe('specific-proxy %h %p');
});
it('preserves spaces in the ProxyCommand value verbatim', () => {
const p = write([
'Host q',
' HostName q.example.com',
' ProxyCommand ssh -W %h:%p jump.example.com',
].join('\n'));
expect(readSshConfig(p)[0].proxyCommand).toBe('ssh -W %h:%p jump.example.com');
});
});
// ─── IdentityAgent (#430) ───────────────────────────────────────────────
//
// The same user authenticates through 1Password's SSH agent socket
// (`IdentityAgent ~/.1password/agent.sock`). The reader must surface
// the tilde-expanded socket path so AuthResolver can use it. Fails
// today — no `identityagent` case, no `identityAgent` field.
describe('readSshConfig: IdentityAgent (#430)', () => {
it('parses IdentityAgent and tilde-expands the socket path', () => {
const p = write([
'Host onepw',
' HostName host.example.com',
' IdentityAgent ~/.1password/agent.sock',
].join('\n'));
const entry = readSshConfig(p)[0];
expect(entry.identityAgent).toBeDefined();
expect(entry.identityAgent?.startsWith('~')).toBe(false); // tilde expanded
// Compare via path.join so the separator is OS-agnostic (Windows
// expands to backslashes).
expect(entry.identityAgent).toBe(path.join(os.homedir(), '.1password', 'agent.sock'));
});
});

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { VaultModelBuilder, type RemoteEntry, type ObsidianClassDeps } from '../src/vault/VaultModelBuilder';
import { logger } from '../src/util/logger';
/**
* Minimal stubs that mirror the structural shape of TFile / TFolder
@ -72,6 +73,55 @@ function makeFakeVault() {
return { vault, root, fileMap, triggers };
}
describe('VaultModelBuilder.buildChunked', () => {
it('builds every entry across multiple chunks (folders-before-files), no errors', async () => {
const { vault, fileMap } = makeFakeVault();
// One folder + 1200 files under it → spans several chunks at chunkSize 100.
const entries: RemoteEntry[] = [{ path: 'work', isDirectory: true, ctime: 0, mtime: 0, size: 0 }];
for (let i = 0; i < 1200; i++) {
entries.push({ path: `work/n${i}.md`, isDirectory: false, ctime: 0, mtime: 0, size: 0 });
}
// Reverse the input to prove ordering is fixed internally, not by the caller.
const shuffled = [...entries].reverse();
const result = await new VaultModelBuilder(vault as never, deps).buildChunked(shuffled, 100);
expect(result.foldersAdded).toBe(1);
expect(result.filesAdded).toBe(1200);
expect(result.errors).toEqual([]);
expect(Object.keys(fileMap)).toHaveLength(1201);
expect(fileMap['work']).toBeInstanceOf(FakeTFolder);
expect(fileMap['work/n0.md']).toBeInstanceOf(FakeTFile);
expect(fileMap['work/n1199.md']).toBeInstanceOf(FakeTFile);
});
it('is idempotent: entries already in the vault are skipped, not re-added', async () => {
const { vault } = makeFakeVault();
const builder = new VaultModelBuilder(vault as never, deps);
const entries: RemoteEntry[] = [
{ path: 'a', isDirectory: true, ctime: 0, mtime: 0, size: 0 },
{ path: 'a/x.md', isDirectory: false, ctime: 0, mtime: 0, size: 0 },
];
await builder.buildChunked(entries, 1);
const second = await builder.buildChunked(entries, 1);
expect(second).toEqual({ filesAdded: 0, foldersAdded: 0, skipped: 2, errors: [] });
});
it('produces the same model + result as the synchronous build()', async () => {
const entries: RemoteEntry[] = [
{ path: 'd', isDirectory: true, ctime: 0, mtime: 0, size: 0 },
{ path: 'd/a.md', isDirectory: false, ctime: 1, mtime: 2, size: 3 },
{ path: 'top.md', isDirectory: false, ctime: 0, mtime: 0, size: 0 },
];
const a = makeFakeVault();
const sync = await new VaultModelBuilder(a.vault as never, deps).build(entries);
const b = makeFakeVault();
const chunked = await new VaultModelBuilder(b.vault as never, deps).buildChunked(entries, 1);
expect(chunked).toEqual(sync);
expect(Object.keys(b.fileMap).sort()).toEqual(Object.keys(a.fileMap).sort());
});
});
describe('VaultModelBuilder.build', () => {
it('returns zero counts for empty entry list', async () => {
const { vault } = makeFakeVault();
@ -615,3 +665,91 @@ describe('VaultModelBuilder.reflect* (#341 writer self-reflect)', () => {
expect(triggers).toEqual([]);
});
});
// ─── #423: move into a folder the writer model hasn't caught up to ──────────
//
// Field repro (issue #423): a user creates a folder and drags several
// top-level notes into it. The destination folder exists on the remote
// but the writer's vault model is momentarily stale, so `renameOne`'s
// strict `resolveParent` returns null and the reflect bails with
// "writer vault model is stale". The notes then vanish from File
// Explorer (detached from the old path, never attached to the new one)
// until a full reopen re-populates the model.
//
// `reflectRename` is the writer-reflect entry point and MUST tolerate a
// stale destination subtree — exactly as `reflectWrite` already does via
// `insertOne({ ensureParents: true })`. The low-level `renameOne` keeps
// its strict contract for the bulk-build sanity check; only the
// writer-reflect path synthesises. These cases fail today.
describe('VaultModelBuilder.reflectRename — stale destination subtree (#423)', () => {
it('synthesises the destination folder and moves the file when the target folder is not modelled', () => {
const { vault, fileMap, triggers } = makeFakeVault();
const builder = new VaultModelBuilder(vault as never, deps);
builder.reflectWrite('note.md'); // source exists at top level
triggers.length = 0;
// "Folder" exists on the remote but the writer model hasn't caught
// up yet — the #423 drag-into-a-fresh-folder race.
builder.reflectRename('note.md', 'Folder/note.md');
expect(fileMap['note.md']).toBeUndefined();
expect(fileMap['Folder']).toBeInstanceOf(FakeTFolder);
expect(fileMap['Folder/note.md']).toBeDefined();
expect((fileMap['Folder/note.md'] as FakeTFile).path).toBe('Folder/note.md');
const renameEvt = triggers.find((t) => t.event === 'rename');
expect(renameEvt, 'a rename trigger must fire so File Explorer relocates the item').toBeDefined();
expect(renameEvt?.args[1]).toBe('note.md');
});
it('moves several top-level notes into the same fresh folder (exact #423 field-log shape)', () => {
const { vault, fileMap } = makeFakeVault();
const builder = new VaultModelBuilder(vault as never, deps);
builder.reflectMkdir('Zettelkasten');
builder.reflectWrite('Zettelkasten/a.md');
builder.reflectWrite('Zettelkasten/b.md');
builder.reflectWrite('Zettelkasten/c.md');
// "Baza" is created, then the three notes are dragged into it.
builder.reflectRename('Zettelkasten/a.md', 'Zettelkasten/Baza/a.md');
builder.reflectRename('Zettelkasten/b.md', 'Zettelkasten/Baza/b.md');
builder.reflectRename('Zettelkasten/c.md', 'Zettelkasten/Baza/c.md');
expect(fileMap['Zettelkasten/Baza']).toBeInstanceOf(FakeTFolder);
for (const f of ['a', 'b', 'c']) {
expect(fileMap[`Zettelkasten/Baza/${f}.md`], `${f}.md should land in Baza`).toBeDefined();
expect(fileMap[`Zettelkasten/${f}.md`], `${f}.md should leave its old location`).toBeUndefined();
}
});
it('renames into a deeply nested fresh subtree by synthesising the whole chain', () => {
const { vault, fileMap } = makeFakeVault();
const builder = new VaultModelBuilder(vault as never, deps);
builder.reflectWrite('note.md');
builder.reflectRename('note.md', 'a/b/c/note.md');
expect(fileMap['a']).toBeInstanceOf(FakeTFolder);
expect(fileMap['a/b']).toBeInstanceOf(FakeTFolder);
expect(fileMap['a/b/c']).toBeInstanceOf(FakeTFolder);
expect(fileMap['a/b/c/note.md']).toBeDefined();
expect(fileMap['note.md']).toBeUndefined();
});
it('does not log "writer vault model is stale" when Obsidian already moved the file (benign echo)', () => {
const { vault, fileMap } = makeFakeVault();
const builder = new VaultModelBuilder(vault as never, deps);
builder.reflectMkdir('Folder');
builder.reflectWrite('Folder/note.md'); // file already at the destination
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
// Source key already gone — Obsidian's own rename ran first.
builder.reflectRename('note.md', 'Folder/note.md');
// Capture before restoring: mockRestore() also clears mock.calls.
const staleWarnings = warn.mock.calls.filter((c) => String(c[0]).includes('writer vault model is stale'));
warn.mockRestore();
expect(fileMap['Folder/note.md']).toBeDefined();
expect(staleWarnings, 'a no-op move must not be reported as a stale-model divergence').toHaveLength(0);
});
});

View file

@ -60,6 +60,22 @@ describe('connectProfile — RPC daemon failure handling (#399 / #406)', () => {
expect(recordedNotices().some((n) => /SFTP/i.test(n))).toBe(true);
});
it('downgrades to SFTP (not a hard fail) when the daemon deploy/startup fails, e.g. token timeout', async () => {
// Field regression: a daemon that deploys but never writes its token
// used to hard-fail the connect → populate skipped → EMPTY vault. A
// generic (non-verification) daemon-start failure must degrade to SFTP.
const { plugin, conn, disconnect } = makePlugin(
new Error('ServerDeployer: daemon did not write /home/u/.obsidian-remote/token within 5000ms'),
);
await plugin.connectProfile(rpcProfile);
expect(conn.connectSsh).toHaveBeenCalledWith(rpcProfile);
expect(plugin.isConnected()).toBe(true); // reached CONNECTED → populate runs
expect(disconnect).not.toHaveBeenCalled(); // SSH kept for SFTP
expect(recordedNotices().some((n) => /SFTP/i.test(n))).toBe(true);
});
it('fails LOUD (no silent SFTP downgrade) when daemon verification fails', async () => {
const { plugin, conn, disconnect } = makePlugin(new DaemonVerificationError('sha256 mismatch'));

View file

@ -0,0 +1,134 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createHash } from 'crypto';
// main.ts pulls in RemoteTerminalView (extends the obsidian ItemView the unit
// mock doesn't model) — stub it so the import graph resolves. Same shim the
// sibling connectProfile.daemon-downgrade.test.ts uses.
vi.mock('../src/ui/RemoteTerminalView', () => ({
RemoteTerminalView: class {},
VIEW_TYPE_REMOTE_TERMINAL: 'remote-terminal',
}));
import { App } from 'obsidian';
import RemoteSshPlugin from '../src/main';
/** A fake SftpClient whose `exec` answers the daemon's `uname` probe. */
const linuxAmd64Client = {
exec: async (cmd: string) => ({
stdout: cmd === 'uname -s' ? 'Linux' : 'x86_64',
stderr: '',
exitCode: 0,
}),
};
const tmpDirs: string[] = [];
function scratchPluginDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `rs-daemon-${process.pid}-`));
tmpDirs.push(dir);
fs.mkdirSync(path.join(dir, 'server-bin'), { recursive: true });
return dir;
}
afterEach(() => {
while (tmpDirs.length) {
try { fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
const BIN_NAME = 'obsidian-remote-server-linux-amd64';
describe('RemoteSshPlugin.locateDaemonBinary — .dev-daemon marker gate', () => {
it('returns null for an UNMARKED staged binary (a download must not masquerade as a dev build)', () => {
const plugin = new RemoteSshPlugin(new App() as never);
const dir = scratchPluginDir();
fs.writeFileSync(path.join(dir, 'server-bin', BIN_NAME), 'ELF');
const p = plugin as unknown as { pluginDir: () => string; locateDaemonBinary: () => string | null };
p.pluginDir = () => dir;
expect(p.locateDaemonBinary()).toBeNull();
});
it('returns the binary path once dev-install has written the .dev-daemon marker', () => {
const plugin = new RemoteSshPlugin(new App() as never);
const dir = scratchPluginDir();
const bin = path.join(dir, 'server-bin', BIN_NAME);
fs.writeFileSync(bin, 'ELF');
fs.writeFileSync(path.join(dir, 'server-bin', '.dev-daemon'), '');
const p = plugin as unknown as { pluginDir: () => string; locateDaemonBinary: () => string | null };
p.pluginDir = () => dir;
expect(p.locateDaemonBinary()).toBe(bin);
});
});
describe('RemoteSshPlugin.ensureDaemonBinary — version + sha fast path', () => {
interface Internals {
pluginDir: () => string;
manifest: { version: string };
settings: Record<string, unknown>;
saveData: (d: unknown) => Promise<void>;
confirmDaemonDownload: (v: string) => Promise<boolean>;
ensureDaemonBinary: (client: unknown) => Promise<string | null>;
}
function makePlugin(dir: string, settings: Record<string, unknown>): Internals {
const plugin = new RemoteSshPlugin(new App() as never);
const p = plugin as unknown as Internals;
p.pluginDir = () => dir;
p.manifest = { version: '1.2.0' };
p.settings = settings;
p.saveData = async () => { /* no-op persist */ };
return p;
}
it('reuses the cached binary WITHOUT any download when version + sha both match', async () => {
const dir = scratchPluginDir();
const bin = path.join(dir, 'server-bin', BIN_NAME);
const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01]);
fs.writeFileSync(bin, bytes);
const sha = createHash('sha256').update(bytes).digest('hex');
const p = makePlugin(dir, { daemonBinaryVersion: '1.2.0', daemonBinarySha: sha });
// No requestUrl is mocked here: a fall-through to the download path would
// throw, so returning the cached path proves the fast path fired.
const result = await p.ensureDaemonBinary(linuxAmd64Client);
expect(result).toBe(bin);
});
it('does NOT take the fast path when the cached bytes no longer match the sha (post-write corruption)', async () => {
const dir = scratchPluginDir();
// File whose real sha does NOT match the recorded marker → integrity fail.
fs.writeFileSync(path.join(dir, 'server-bin', BIN_NAME), Buffer.from([0x00, 0x11, 0x22]));
const p = makePlugin(dir, {
daemonBinaryVersion: '1.2.0',
daemonBinarySha: 'f'.repeat(64), // deliberately wrong
daemonDownloadConsented: false,
});
// Fall-through reaches the consent gate; decline it to end deterministically
// on the "staying on SFTP" path (no network) rather than the fast path.
p.confirmDaemonDownload = async () => false;
const result = await p.ensureDaemonBinary(linuxAmd64Client);
expect(result).toBeNull(); // fell through (not reused) → declined → SFTP
});
it('does NOT take the fast path when the recorded version differs from the plugin version', async () => {
const dir = scratchPluginDir();
const bin = path.join(dir, 'server-bin', BIN_NAME);
const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
fs.writeFileSync(bin, bytes);
const sha = createHash('sha256').update(bytes).digest('hex');
// sha matches the file, but the marker is for an OLDER plugin version.
const p = makePlugin(dir, {
daemonBinaryVersion: '1.1.0',
daemonBinarySha: sha,
daemonDownloadConsented: false,
});
p.confirmDaemonDownload = async () => false;
const result = await p.ensureDaemonBinary(linuxAmd64Client);
expect(result).toBeNull(); // version mismatch → re-check path → declined → SFTP
});
});

View file

@ -0,0 +1,214 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ShadowVaultBootstrap } from '../../src/shadow/ShadowVaultBootstrap';
import { ObsidianRegistry } from '../../src/shadow/ObsidianRegistry';
import { setupClientPair, TEST_PRIVATE_KEY, type TestClient } from './helpers/makeAdapter';
import type { SshProfile } from '../../src/types';
/**
* Config consistency across connect cycles (#429 / #342).
*
* Layer 1 (integration, vitest + docker sshd, no Obsidian UI). Drives
* the real bootstrap + pull/push round-trip against a real SSH server,
* so the #429/#342 promise "config and the enabled-plugins list stay
* consistent across reconnects, and a push never clobbers a list
* enabled on another machine" is verified end-to-end, not just over
* in-memory fakes. The remote vault's `.obsidian/` is the canonical
* store; a fresh shadow vault is a new "session" / "machine".
*
* Runs only when the test keypair is staged (`npm run sshd:start`).
*/
if (!fs.existsSync(TEST_PRIVATE_KEY)) {
throw new Error(
`Integration test keypair missing at ${TEST_PRIVATE_KEY}. ` +
'Run `npm run sshd:start` from the repo root before `npm run test:integration`.',
);
}
const REMOTE_CFG = '.obsidian';
const CP = `${REMOTE_CFG}/community-plugins.json`;
const APP = `${REMOTE_CFG}/app.json`;
describe('Config consistency across connect cycles (#429 / #342)', () => {
let pair: Awaited<ReturnType<typeof setupClientPair>>;
let remoteClient: TestClient;
let baseDir: string;
let sourcePluginDir: string;
let registryConfigPath: string;
beforeAll(async () => {
pair = await setupClientPair({ testLabel: 'config-consistency' });
remoteClient = pair.a;
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-consistency-base-'));
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-consistency-home-'));
registryConfigPath = path.join(tmpHome, 'obsidian.json');
fs.writeFileSync(registryConfigPath, JSON.stringify({ vaults: {} }) + '\n', 'utf-8');
sourcePluginDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-consistency-source-'));
fs.writeFileSync(path.join(sourcePluginDir, 'main.js'), '/* test stub */\n', 'utf-8');
fs.writeFileSync(path.join(sourcePluginDir, 'manifest.json'),
JSON.stringify({ id: 'remote-ssh', version: '0.0.0-test', name: 'Test', minAppVersion: '1.5.0' }) + '\n', 'utf-8');
fs.writeFileSync(path.join(sourcePluginDir, 'styles.css'), '/* test stub */\n', 'utf-8');
await remoteClient.adapter.mkdir(REMOTE_CFG);
});
afterAll(async () => {
if (pair) await pair.cleanup();
try { fs.rmSync(baseDir, { recursive: true, force: true }); } catch { /* best effort */ }
try { fs.rmSync(sourcePluginDir, { recursive: true, force: true }); } catch { /* best effort */ }
});
/** A fresh shadow-vault session against the shared docker remote. */
function freshSession(): ShadowVaultBootstrap {
return new ShadowVaultBootstrap(baseDir, sourcePluginDir, new ObsidianRegistry(registryConfigPath));
}
function profileFor(caseId: string): SshProfile {
return {
id: `cfg-${caseId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: `Config consistency (${caseId})`,
host: '127.0.0.1', port: 2222, username: 'tester',
authMethod: 'privateKey', privateKeyPath: TEST_PRIVATE_KEY,
remotePath: remoteClient.vaultRoot,
connectTimeoutMs: 10_000, keepaliveIntervalMs: 0, keepaliveCountMax: 0,
};
}
const readLocalCp = (dir: string): string[] =>
JSON.parse(fs.readFileSync(path.join(dir, 'community-plugins.json'), 'utf-8'));
it('community plugins enabled on the remote round-trip into a fresh shadow, keeping remote-ssh (#429/#342)', async () => {
// Remote set up "on another machine": dataview enabled.
await remoteClient.adapter.write(CP, JSON.stringify(['dataview']));
const profile = profileFor('cp-pull');
const { layout } = await freshSession().bootstrap(profile, [profile]);
await ShadowVaultBootstrap.pullCommunityPlugins(remoteClient.adapter, REMOTE_CFG, layout.configDir);
expect(readLocalCp(layout.configDir)).toEqual(expect.arrayContaining(['dataview', 'remote-ssh']));
});
it('a plugin enabled in one session is still enabled in the next (persists via the remote)', async () => {
// Known base so the assertion doesn't depend on case order.
await remoteClient.adapter.write(CP, JSON.stringify(['remote-ssh']));
// Session 1: enable templater locally, push to remote.
const p1 = profileFor('cycle-1');
const s1 = await freshSession().bootstrap(p1, [p1]);
fs.writeFileSync(path.join(s1.layout.configDir, 'community-plugins.json'),
JSON.stringify(['remote-ssh', 'templater']));
await ShadowVaultBootstrap.pushCommunityPlugins(remoteClient.adapter, REMOTE_CFG, s1.layout.configDir);
// Session 2: a brand-new shadow (different id → different local dir) pulls.
const p2 = profileFor('cycle-2');
const s2 = await freshSession().bootstrap(p2, [p2]);
await ShadowVaultBootstrap.pullCommunityPlugins(remoteClient.adapter, REMOTE_CFG, s2.layout.configDir);
expect(readLocalCp(s2.layout.configDir), 'templater enabled in session 1 must survive into session 2')
.toContain('templater');
});
it('push unions with the remote and never drops a plugin enabled elsewhere (#437, real SSH)', async () => {
// Remote (machine B) carries a rich list.
await remoteClient.adapter.write(CP, JSON.stringify(['remote-ssh', 'dataview', 'obsidian-git']));
// Machine A has only a minimal local list, then pushes.
const pA = profileFor('union');
const sA = await freshSession().bootstrap(pA, [pA]);
fs.writeFileSync(path.join(sA.layout.configDir, 'community-plugins.json'),
JSON.stringify(['remote-ssh', 'templater']));
await ShadowVaultBootstrap.pushCommunityPlugins(remoteClient.adapter, REMOTE_CFG, sA.layout.configDir);
const remoteAfter = JSON.parse(await remoteClient.adapter.read(CP));
expect(remoteAfter, 'remote dataview + obsidian-git must survive a push from a minimal local')
.toEqual(expect.arrayContaining(['dataview', 'obsidian-git', 'templater', 'remote-ssh']));
});
it('a settings change stays consistent across a write → push → fresh-pull cycle (#342 reproducer)', async () => {
// Session 1: seed remote app.json, pull into the shadow.
await remoteClient.adapter.write(APP, JSON.stringify({ useMarkdownLinks: false, theme: 'obsidian' }));
const p1 = profileFor('app-1');
const s1 = await freshSession().bootstrap(p1, [p1]);
await ShadowVaultBootstrap.pullSharedObsidianConfig(remoteClient.adapter, REMOTE_CFG, s1.layout.configDir);
// User changes a setting in the shadow, then it's pushed back.
const changed = { useMarkdownLinks: true, theme: 'things' };
fs.writeFileSync(path.join(s1.layout.configDir, 'app.json'), JSON.stringify(changed));
await ShadowVaultBootstrap.pushSharedObsidianConfig(remoteClient.adapter, REMOTE_CFG, s1.layout.configDir);
// The test holds a snapshot of the intended state.
const snapshot = JSON.parse(fs.readFileSync(path.join(s1.layout.configDir, 'app.json'), 'utf-8'));
// Session 2: a fresh shadow pulls — must equal the snapshot.
const p2 = profileFor('app-2');
const s2 = await freshSession().bootstrap(p2, [p2]);
await ShadowVaultBootstrap.pullSharedObsidianConfig(remoteClient.adapter, REMOTE_CFG, s2.layout.configDir);
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 DOWNGRADES a newer local plugin (older remote, real SSH)', async () => {
const id = 'conflict-plugin';
// Remote carries an OLDER version.
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/manifest.json`,
JSON.stringify({ id, version: '1.0.0', name: 'Conflict', minAppVersion: '1.5.0' }));
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/main.js`, '/* REMOTE v1 */\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 });
// Local has a strictly NEWER version — the pull must leave it alone.
fs.writeFileSync(path.join(dir, 'manifest.json'),
JSON.stringify({ id, version: '2.0.0', name: 'Conflict', minAppVersion: '1.5.0' }));
fs.writeFileSync(path.join(dir, 'main.js'), '/* LOCAL v2 */\n');
await ShadowVaultBootstrap.pullPluginBinaries(remoteClient.adapter, REMOTE_CFG, layout.configDir, [id]);
expect(fs.readFileSync(path.join(dir, 'main.js'), 'utf-8'), 'older remote must not downgrade newer local')
.toBe('/* LOCAL v2 */\n');
});
});

View file

@ -38,9 +38,11 @@ import type { SshProfile } from '../../src/types';
* - `core-plugins.json` which built-in plugins are enabled
* - `hotkeys.json` keybinding overrides
*
* Each file is `PathMapper.isPrivate(...)` = **false** today, i.e.
* it's intentionally shared across machines; the gap is that the
* sharing is only one-way (push, no pull).
* Each file is now `PathMapper.isPrivate(...)` = **true** (per-device):
* the round-trip persists THIS device's OWN copy across a restart. The
* seed-write and pull-read go through the same PathMapper, so this still
* exercises the push/pull persistence the #342 fix added it just lands
* on the per-client subtree instead of the old shared identity path.
*
* Runs only when the test keypair is staged (`npm run sshd:start`).
*/

View file

@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { PathMapper } from '../src/path/PathMapper';
import { preSpawnRemotePath } from '../src/shadow/preSpawnPaths';
/**
* Pins the #450 CRITICAL fix: the pre-spawn config pull must resolve the
* four per-device config files to THIS client's `user/<id>/` subtree, not
* the dead shared identity path (reading which clobbered per-device config
* on every shadow-window spawn). A regression that dropped the
* `PathMapper.toRemote` redirect (bare base-join) would flip the config
* assertions below to the shared path and fail here.
*/
describe('preSpawnRemotePath', () => {
const mapper = new PathMapper('host-a', '.obsidian');
const base = '/home/u/vault';
it('redirects the four per-device config files to the per-client subtree', () => {
for (const f of ['app.json', 'appearance.json', 'core-plugins.json', 'hotkeys.json']) {
expect(preSpawnRemotePath(mapper, base, `.obsidian/${f}`))
.toBe(`${base}/.obsidian/user/host-a/${f}`);
}
});
it('leaves shared files (community-plugins.json, plugin binaries) at the identity path', () => {
expect(preSpawnRemotePath(mapper, base, '.obsidian/community-plugins.json'))
.toBe(`${base}/.obsidian/community-plugins.json`);
expect(preSpawnRemotePath(mapper, base, '.obsidian/plugins/dataview/main.js'))
.toBe(`${base}/.obsidian/plugins/dataview/main.js`);
});
it('joins onto a "." remote base (vault root) without a leading slash', () => {
expect(preSpawnRemotePath(mapper, '.', '.obsidian/app.json'))
.toBe('.obsidian/user/host-a/app.json');
});
it('isolates clients — two client ids never resolve to the same config path', () => {
const a = new PathMapper('host-a', '.obsidian');
const b = new PathMapper('host-b', '.obsidian');
expect(preSpawnRemotePath(a, base, '.obsidian/app.json'))
.not.toBe(preSpawnRemotePath(b, base, '.obsidian/app.json'));
});
});

View file

@ -0,0 +1,31 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { withTimeout } from '../src/util/withTimeout';
describe('withTimeout', () => {
afterEach(() => { vi.useRealTimers(); });
it('resolves with the value when the promise settles before the timeout', async () => {
await expect(withTimeout(Promise.resolve('ok'), 1000, 'x')).resolves.toBe('ok');
});
it('propagates the promise rejection when it rejects before the timeout', async () => {
await expect(withTimeout(Promise.reject(new Error('boom')), 1000, 'x')).rejects.toThrow('boom');
});
it('rejects with a labelled timeout error when the promise is too slow', async () => {
vi.useFakeTimers();
const slow = new Promise<string>(() => { /* never settles */ });
const p = withTimeout(slow, 5000, 'pre-spawn pull');
const assertion = expect(p).rejects.toThrow('pre-spawn pull timed out after 5000ms');
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
it('clears the timer on a fast resolve so a later tick raises no timeout', async () => {
vi.useFakeTimers();
const p = withTimeout(Promise.resolve('fast'), 5000, 'x');
await expect(p).resolves.toBe('fast');
// Past the budget the cleared timer must not fire a stray rejection.
await vi.advanceTimersByTimeAsync(10_000);
});
});

View file

@ -190,5 +190,6 @@
"1.1.2": "1.5.0",
"1.1.3": "1.5.0",
"1.1.4": "1.5.0",
"1.1.5": "1.5.0"
"1.1.5": "1.5.0",
"1.1.6": "1.5.0"
}

View file

@ -5,6 +5,17 @@ CMD := ./cmd/obsidian-remote-server
BIN_DIR := bin
DIST_DIR := dist
# Fully static binaries: CGO off so the daemon links no libc and runs on
# ANY Linux glibc version. Without this, the host-native build in `cross`
# (linux/amd64 on a modern ubuntu-latest runner, glibc 2.35+) is the only
# target Go builds with CGO auto-ENABLED — it links dynamically against the
# runner's glibc and then dies on older hosts with
# `libc.so.6: version GLIBC_2.34 not found`. The daemon never writes its
# token, RPC times out, and the client falls back to SFTP. The cross
# (non-host) targets already default to CGO off; exporting it here makes all
# targets consistent and static. Mirrors plugin/scripts/build-server.mjs.
export CGO_ENABLED := 0
.PHONY: build clean cross test fmt
build:

View file

@ -4,7 +4,7 @@ go 1.25.0
require (
github.com/fsnotify/fsnotify v1.10.1
golang.org/x/image v0.42.0
golang.org/x/image v0.43.0
)
require golang.org/x/sys v0.13.0 // indirect

View file

@ -1,6 +1,6 @@
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View file

@ -190,5 +190,6 @@
"1.1.2": "1.5.0",
"1.1.3": "1.5.0",
"1.1.4": "1.5.0",
"1.1.5": "1.5.0"
"1.1.5": "1.5.0",
"1.1.6": "1.5.0"
}