After pass 1 fixed the cross-cutting plugin path / data.json / telemetry claims, pass 2 caught surviving copies of the SAME claims still living in adjacent docs (the fix was correct but didn't grep wide enough), plus a few new findings. Critical internal contradictions removed: - operations/reconnect.md backoff (still said 1s/2s/4s/8s/16s after configuration/advanced.md was corrected to ×1.5 + jitter) - architecture/index.md still claimed "daemon coalesces watcher events server-side" after watch.md was corrected to "no debouncer" - proto/README.md (declared normative for wire shape) still listed the invented `renamed` event + server-side debouncing — fixed both to match the corrected user docs Important: - operations/logs.md reconnect state values: actual is idle/waiting/attempting/recovered/failed/cancelled (was stuck on the made-up started/succeeded/failed) - server/signing.md tag prefix: tags are bare semver `1.0.43` / `1.0.44-beta.1`, no `v` prefix (taken straight from manifest.json) - user-guide/host-keys.md TOFU + mismatch dialog mockups: actual UI has 3 buttons (Reject / Trust this session only / Trust & remember / Replace pinned fingerprint), not 2; fingerprint shown as colon- separated bytes (no `SHA256:` prefix); algorithm labelled as "Key type" Advisory: - api/errors.md: `data` field is OMITTED via Go `omitempty`, not serialised as `null` - getting-started/install.md: store + plugin list show "Remote SSH" (manifest.json `name`), not the slug `obsidian-remote-ssh` |
||
|---|---|---|
| .. | ||
| README.md | ||
proto
Shared JSON-RPC protocol between the obsidian-remote-ssh plugin (TypeScript) and the obsidian-remote-server daemon (Go).
Transport
- Length-prefixed JSON messages (LSP-style framing) over a unix
socket. One message per frame; no WebSocket or HTTP on this channel.
Content-Length: <bytes>\r\n \r\n <JSON body> - The plugin opens a local TCP connection that SSH forwards to the
daemon's unix socket (
ssh -L <port>:<sockpath> …). Nothing is exposed to the network. - The framing handles multi-MB payloads cleanly and lets both sides reject oversized messages up front (future limit, configurable).
- Binary payloads (file bytes) are base64-encoded inside the JSON
body. MVP trade-off: +33% wire overhead for a much simpler client.
Attachment serving for
getResourcePathlives on a separate HTTP channel on a second forwarded port (Phase 5-F); this channel is always framed JSON.
Handshake
Before any fs.* method succeeds, the client must authenticate:
→ { "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": "…" } }
← { "jsonrpc": "2.0", "id": 1, "result": { "ok": true } }
- The server writes
~/.obsidian-remote/token(mode0600) at startup with a fresh 32-byte random token. - The plugin reads that file over SSH (since SSH already authenticates the right user, and POSIX perms forbid other local users from reading it) and presents it here.
- A session is pinned to one authenticated client. Rejecting
authcloses the connection.
After auth succeeds, the plugin should call server.info once to
check protocol compatibility.
Versioning
The protocol version is an integer. The client is responsible for refusing to proceed when the server advertises a version it does not understand. Breaking changes bump the integer; additive changes do not.
Current protocol version: 1.
Path conventions
All paths are vault-relative and use forward slashes.
"note.md","docs/sub/a.md"— valid""or"/"— the vault root itself"../"or any..component — rejected withPathOutsideVault- A leading
/(absolute path) — rejected withPathOutsideVault
The vault root is fixed at server start via --vault-root=<abs>. The
server refuses to open any path that, once resolved, does not live
under that root.
Methods
| Method | Params | Result |
|---|---|---|
auth |
{ token } |
{ ok: true } |
server.info |
{} |
ServerInfo |
fs.stat |
{ path } |
Stat | null |
fs.exists |
{ path } |
{ exists: boolean } |
fs.list |
{ path } |
{ entries: Entry[] } |
fs.readText |
{ path, encoding? } |
ReadTextResult |
fs.readBinary |
{ path } |
ReadBinaryResult |
fs.write |
{ path, content, expectedMtime? } |
{ mtime } |
fs.writeBinary |
{ path, contentBase64, expectedMtime? } |
{ mtime } |
fs.append |
{ path, content } |
{ mtime } |
fs.appendBinary |
{ path, contentBase64 } |
{ mtime } |
fs.mkdir |
{ path, recursive? } |
{} |
fs.remove |
{ path } |
{} |
fs.rmdir |
{ path, recursive? } |
{} |
fs.rename |
{ oldPath, newPath } |
{ mtime } |
fs.copy |
{ srcPath, destPath } |
{ mtime } |
fs.trashLocal |
{ path } |
{} |
fs.watch |
{ path, recursive? } |
{ subscriptionId } |
fs.unwatch |
{ subscriptionId } |
{} |
Shapes:
interface ServerInfo {
version: string; // implementation version, e.g. "0.1.0"
protocolVersion: number; // currently 1
capabilities: string[]; // e.g. ["fs.stat", "fs.watch", …]
vaultRoot: string; // absolute path on the remote host (informational)
}
interface Stat {
type: 'file' | 'folder';
mtime: number; // unix milliseconds
size: number; // bytes (0 for folders)
mode: number; // POSIX mode bits (informational)
}
interface Entry {
name: string; // basename only, no slashes
type: 'file' | 'folder' | 'symlink';
mtime: number;
size: number;
}
interface ReadTextResult { content: string; mtime: number; size: number; encoding: 'utf8'; }
interface ReadBinaryResult { contentBase64: string; mtime: number; size: number; }
Atomicity notes:
fs.writeandfs.writeBinaryare atomic on the remote (tmp file- rename). If
expectedMtimeis set and the current file's mtime does not match, the server rejects withPreconditionFailed.
- rename). If
fs.renamecreates the destination's parent directory if needed.fs.copygoes through the file contents (no server-side reflink).fs.trashLocalmoves the path under<vaultRoot>/.trash/…, creating intermediate dirs as needed.
Notifications (server → client)
The server pushes notifications on subscribed paths:
{
"jsonrpc": "2.0",
"method": "fs.changed",
"params": {
"subscriptionId": "…",
"path": "note.md",
"event": "created" | "modified" | "deleted",
"mtime"?: number
}
}
- The proto reserves a
renamedevent tag for future use, but the current daemon emits onlycreated/modified/deleted. Renames surface as adeleted+createdpair on the affected paths. - The current daemon does NOT debounce or coalesce events server-side;
every inotify event maps to one
fs.changednotification. Clients should debounce on their side if they want UI-friendly throttling. - An
fs.watchsubscription withrecursive: trueemits events for every descendant.
Error codes
| Code | Name | When |
|---|---|---|
-32700 |
ParseError | Not JSON. |
-32600 |
InvalidRequest | JSON-RPC envelope is malformed. |
-32601 |
MethodNotFound | Unknown method. |
-32602 |
InvalidParams | Params don't match the method's shape. |
-32603 |
InternalError | Unexpected server error. |
-32000 |
AuthRequired | A non-auth method was called before auth succeeded. |
-32001 |
AuthInvalid | auth called with a wrong token. |
-32010 |
FileNotFound | Path doesn't exist on the remote. |
-32011 |
NotADirectory | fs.list / fs.rmdir target is a file. |
-32012 |
IsADirectory | A file-only op targeted a directory. |
-32013 |
Exists | Create-like op found the path already present. |
-32014 |
PermissionDenied | OS rejected the operation (mode bits, quota, …). |
-32015 |
PathOutsideVault | Resolved path escapes the vault root. |
-32020 |
PreconditionFailed | expectedMtime did not match the file's current mtime. |
-32021 |
ProtocolVersionTooOld | server.info returned a version the client can't speak. |
Source of truth
- This document is normative for wire shape.
plugin/src/proto/types.tsandserver/internal/proto/types.goare hand-maintained mirrors. When the spec changes, both sides move in the same PR.