dsh-ssh-agent-workspace
Create DeepSeek Harness projects from remote SSH hosts. When you add a workspace, the source picker gains a remote device lane: pick a configured SSH host, browse its directories, and confirm one.
Features and boundaries (quick view)
What it does
- Remote hosts in the create-project dialog: a "remote devices" lane (with inline add/edit/delete host, jump-host & key passphrase fields) in the workspace source picker; browse remote or local directories (both with inline new-folder creation), choose a cache mode, create the workspace. The host registry is shared with
@linxin666/dsh-ssh. - The remote is the source of truth: shell commands,
Glob, andGrepexecute directly on the remote host; writes upload immediately, reads fetch the freshest copy, and the cache refreshes incrementally after each command. - Resident agent fast path: a single-file stdlib Python agent per host (no listening port, self-terminating on disconnect) brings commands to ~0.2 s; automatic fallback to plain SSH when unavailable.
- Cache & consistency: on-demand / full / tree-only modes; a three-way sync index guarantees no lost bytes and no silent divergence; conflicts resolve remote-wins with a browsable local backup; quota, disk guard, LRU eviction and a cache-manager UI.
- Agent tools:
remote_hosts/test/exec/read/write/sync_push/sync_pull/cache/bindingsfor in-session remote and cache operations (remote_read/remote_writeuse the resident agent fast path and keep bound workspace caches in three-way sync). - Secure defaults: loopback-only HTTP API with origin fencing; trust-on-first-use host-key pinning (with one-click key reset after expected host rebuilds); atomic replace for every write.
Boundaries (unsupported / be aware)
- Remote hosts: Linux only is promised (sshd + bash; the agent additionally needs python3 ≥ 3.6 and inotify). Missing python3/tar degrades automatically to plain SSH (same features, slower refresh). Windows/macOS remotes are untested and unsupported.
- Command shape: remote commands run non-interactively (
bash -lc, or cached login env +--noprofile --norc). Interactive TUIs (vim, top, REPLs), tty-requiring commands, and mid-command password prompts (e.g. sudo) do not work; timeouts/interrupts kill the remote process group. - Network topology: direct SSH reachability or SSH jump-host chains (
ProxyJump/jumpHostreferencing configured host aliases, up to 5 hops viaforwardOut) are supported. No remote GUI or automatic remote-service port mapping. - Sync semantics: deleting locally ≠ deleting remotely (it only evicts the cache); remote deletions do sync down (unsynced local edits are backed up first); simultaneous edits on both sides are a conflict — remote wins, your local version is backed up under
.conflicts/; no content-level merging. - The cache is not a backup: clearing, quota eviction and cache relocation only touch local copies; the remote is never modified by them. Do not treat the local mirror as the only copy of a project.
- Size guards (defaults, all configurable): pre-fetched files ≤ 1 MB in on-demand mode; on-demand reads ≤ 100 MB per file; open-time pre-fetch budget 64 MB (rest filled in background); global quota 5 GB; downloads require 5 GB free disk headroom.
- Credentials: hosts with passwords/key paths live in
~/.dsh/dsh-ssh.json(shared with dsh-ssh, plain YAML) — protect that file's permissions; private key files themselves are never copied. - Concurrency: ≤ 9 SSH channels per host (6 SFTP + 3 exec) to stay under sshd's default
MaxSessions 10; lower remote limits will queue or fail concurrent commands/refreshes. - Key formats: OpenSSH private keys only; PuTTY
.ppkmust be converted (puttygen … -O private-openssh). - Platforms: DSH desktop/web GUI (plugin UI injects into the web client); developed and tested on a Windows host against Linux remotes; other host platforms not individually exercised.
- Remote side effects: writes only under
~/.dsh-remote-dev/(agent files) and sync writes inside your chosen workspace; the agent kills only process groups it started; no system packages installed, no sshd/system configuration changed.
Live remote mode — the remote host is the source of truth
The DSH host requires a workspace to be an existing local directory, so the plugin keeps a local anchor/cache under ~/.dsh/remote-dev/<alias>/…, but sessions operate on the remote host:
- Commands run remotely: Pwsh/Bash tool calls whose workdir is inside a remote workspace are rewritten to run over SSH via
bash -lcin the matching remote directory (output and exit code pass through; timeouts/interrupts kill the remote process group). - Writes go through: Write/Edit upload the file to the remote immediately; edits made in the editor UI are watched and uploaded too.
- Reads are fresh: Read checks the remote copy first and fetches it if it changed.
- Auto refresh: after each foreground command the cache is refreshed incrementally (new, changed and deleted files), so Glob/Grep see remote state.
Extra agent tools: remote_exec, remote_read / remote_write, full manual resync with remote_sync_push / remote_sync_pull, and cache management with remote_cache. Common build/vendor directories (node_modules, .git, dist, build, venvs, …) are never pre-cached, and remote .gitignore / .dsh-remote-dev-ignore entries are skipped by pre-fetching too (reads still fetch on demand).
Hosts live in ~/.dsh/dsh-ssh.json — the same registry (and schema) used by the @linxin666/dsh-ssh plugin, so hosts configured in either place work in both. The create-project dialog can also add a host inline.
Resident remote agent (fast path)
To remove the per-command SSH handshake and remote ~/.bashrc start-up (measured 3–4 s per command), the plugin keeps a lightweight Python service resident on each host:
- Auto-deploy: when the plugin loads (first install / DSH start), a host is added or tested, or a remote workspace is opened, the single stdlib-only file
agent/dsh-agent.py(Python ≥ 3.6) is uploaded over SSH to~/.dsh-remote-dev/agent-<hash>.pyand started. Unchanged versions are not re-uploaded; old versions are cleaned up. - No listening port: the agent speaks length-prefixed frames (JSON control + binary payloads) over one SSH exec channel's stdio; auth is SSH. On disconnect or 120 s without frames it exits and kills the process groups it started.
- Login env loaded once:
bash -lc 'env -0'at start; commands then run inbash --noprofile --norcwith the cached environment. - Methods:
proc.spawn/kill(concurrent, streamed output, kills the whole group),fs.stat/stat_many/list/read/read_many/write/mkdir(atomic writes with mtime; batch reads up to 200 files / 3 MB per RPC),fs.manifest(tree listing for refresh),fs.watch/changes/ack/unwatch(recursive inotify feed),ping. - Command path: Pwsh/Bash calls still become a local runner (keeping DSH timeouts/background/truncation); the runner hands the command to the plugin over a token-protected local named pipe, and the plugin runs it via the agent.
- Heartbeat & status:
pingevery 15 s (and immediately on connect) records RTT, remote load and process count; 3 missed beats restart the agent, reconnecting after 3 s. Status shows as a dot next to each host in the create-project dialog (green = agent + latency, yellow = starting, red = down, grey = unsupported/SSH; hover for details), viaGET /api/dsh-remote-dev/status, and inremote_test. - SSH fallback: no python3, deploy/start failure, agent down, or pipe unavailable → the original plain-SSH paths are used. Read-only/idempotent operations retry once over SSH on agent errors; commands already handed to the agent are never retried (no double execution).
Measured (net-dev, LAN): remote true 44 ms (was ~1 s pooled / 3–4 s via runner); runner end-to-end ~0.2 s; command + refresh ~0.8 s (was 4–5 s); cold workspace open (prepare) 0.5–0.6 s with background fill and warm open 45–93 ms (was 17–25 s); 800-dir incremental refresh 20–48 ms (was 1.2–1.8 s); four concurrent sleep 1 in 1.05 s.
Cache policy, consistency and eviction
Cache location: defaults to the local disk with the most free space (non-system disks get <drive>:\dsh-remote-dev; the system disk and non-Windows fall back to ~/.dsh/remote-dev); the choice persists and can be changed (or reset to auto) in the create-project dialog. Existing workspaces never move. Three ways to change it: type a path, press "Browse…" to open the built-in local directory browser (crumbs + path jump + hidden toggle + new-folder creation, free navigation to any directory), or press "System file manager…" to use the OS-native chooser (the button hides itself with a hint when the deployment's DSH directory-picker backend has no native pick capability). The 此电脑 source list uses the plugin's own local listing routes as well, so local browsing works in every deployment.
Three cache modes (per workspace):
- On demand (default): opening a project creates the directory tree and pre-fetches only small source-like files (text extensions, ≤ 1 MB). Binaries, big files and ignore-file hits are not pre-fetched — Read/Write still download them on first use. For project-wide search, run
rg/grepon the remote (commands already execute there). - Full mirror: everything except the default excludes — for small projects.
- Tree only: directories only, everything on demand — for huge repos.
Consistency (the core): the local cache and the remote never fork silently. Each workspace keeps a three-way sync index in ~/.dsh/remote-dev/state/ (per file: the last-synced version = size + mtime):
- remote-only change → download; local-only change → upload with an optimistic "remote unchanged" check; neither → skip.
- both changed → conflict: the remote version wins and lands in the workspace; your local bytes are backed up intact to
<cache root>/.conflicts/<alias>/<project>/<path>.<timestamp>; the editing tool fails loudly with the backup path. Nothing is ever lost or silently overwritten. - remote delete → local delete (backing up unsynced local edits first); local delete ≠ remote delete: deleting a cached file only evicts it — the remote is always safe.
- writes are temp-file + verify + atomic rename on both sides; interrupted transfers leave no half files.
Legacy mirror migration: pre-index (round ≤ 4) caches migrate on first refresh — stale/differing files are resolved in favour of the remote (with quiet backups), nothing is ever uploaded during migration; the index is then established.
Quota and cleanup: a global cache quota (default 5 GB) evicts least-recently-used clean files when exceeded (evicted files are not auto-fetched back); downloads check free disk space (5 GB reserved by default) and skip pre-fetches with a warning when low. remote_cache / the dialog's cache manager shows per-workspace usage and clears caches (pending edits upload first; the remote is untouched).
Reliability:
- Three-way index + second-granularity mtime compare + optimistic concurrency (
expectmismatch = conflict, never blind overwrite). - Per-file keyed mutex (read/write/refresh never interleave); concurrent refreshes coalesce into one.
- Refresh works from one remote tree manifest (agent
fs.manifestor a singlefind -printfstream); unreadable subtrees are marked out-of-scope, never mistaken for deletions. - Full fallback chain: agent unavailable → plain SSH (SFTP I/O + find walk); failures are counted, kept in status, and retries are idempotent (tests re-verify: second refresh = 0 downloads 0 uploads).
- Interrupted temp files (
.dsh-dl-*/.dsh-tmp-*) are cleaned on the next refresh; the remote agent writes atomically too.
Install
Search for Remote Workspace (SSH) / dsh-ssh-agent-workspace in the DSH Community Plugin Market (dshmarket), or install via CLI from the prebuilt release tarball or GitHub repository:
dsh plugin --profile <name> add "https://github.com/wwwyyyc/dsh-ssh-agent-workspace/releases/latest/download/dsh-ssh-agent-workspace.tgz"
# or directly from GitHub:
dsh plugin --profile <name> add github:wwwyyyc/dsh-ssh-agent-workspace
Offline: plugin manager → install from local path → point at this repository directory (or a .tgz from npm pack).
Note:
dsh-remote-dev,dsh-remote-ssh, anddsh-remote-workspaceon the registries are unrelated plugins by other authors; this project's repository is wwwyyyc/dsh-ssh-agent-workspace.
Agent tools
| Tool | Purpose |
|---|---|
remote_hosts |
List configured hosts (aliases, addresses, auth kinds — never secrets). |
remote_test <alias> |
Connectivity + auth check. |
remote_ls [alias] [path] |
List a remote directory (directories and symlinks, like the local picker). |
remote_exec <command> [alias] [cwd] |
Run a shell command on the host; cwd defaults to the bound remote workspace. |
remote_read <path> / remote_write <path> <content> |
Read or edit remote files (relative to the bound workspace when not absolute). |
remote_sync_push [dry_run] |
Upload changed local files to the remote project. |
remote_sync_pull |
Refresh the local mirror from the remote project. |
remote_bindings |
Show bound remote workspaces. |
When exactly one remote workspace is bound, alias arguments are optional.
HTTP API (loopback only)
The client dialog talks to the host half at /api/dsh-remote-dev/*. Routes are fenced to loopback requests (socket + Host header + Sec-Fetch-Site/Origin checks); anything else gets 403.
GET /api/dsh-remote-dev/hosts— redacted host list.POST /api/dsh-remote-dev/hosts— upsert a host ({alias, host, port, user, auth, jumpHost?, test?}) or delete one ({action: "delete", alias}).POST /api/dsh-remote-dev/test—{alias}connectivity check.POST /api/dsh-remote-dev/ls—{alias, path}directory listing in the same shape as the local browse capability.POST /api/dsh-remote-dev/remote-mkdir—{alias, path, name}create one folder level under a remote directory.POST /api/dsh-remote-dev/prepare—{alias, path, cacheMode?}: prepare the cache per policy, record the binding, return{localPath, cacheMode, files, bytes, uncached, conflicts, warnings, …}.GET/POST /api/dsh-remote-dev/config— read or change cache settings (mirrorRoot,cacheMode,eagerMaxBytes,eagerBudgetBytes,quotaBytes,minFreeBytes,hostKeyPolicy; GET also returns per-disk free space).GET /api/dsh-remote-dev/cache— total cache usage plus per-workspace detail.POST /api/dsh-remote-dev/cache/clear—{alias, remotePath, forget?}clear one workspace's local cache (remote untouched).POST /api/dsh-remote-dev/local-list—{path?}local directory listing (directories only; empty path returns disk roots; crumbs, hidden flags, max 1000 entries per level).POST /api/dsh-remote-dev/local-mkdir—{path, name}create one folder level under a local directory (rejects invalid/escaping names).POST /api/dsh-remote-dev/conflicts—{action: restore|discard, alias, remotePath, backup}restore a conflict backup over the local copy (queued for upload) or delete it; backup paths must live under the workspace's.conflictsdirectory.GET/POST /api/dsh-remote-dev/host-keys— list pinned host-key fingerprints /{action: forget, alias}drop a pin (after an expected host rebuild).
Transfer performance
Opening a remote directory (prepare) and later refreshes (end-to-end on a real 3124-file / 800+-dir Go project: 2+ minutes down to an on-demand open that pre-fetches only small source files, sub-second no-op refreshes):
- On-demand open (default): one remote tree manifest (agent
fs.manifestor a singlefind -printfstream — one exec, zero per-directory round-trips; real 800-dir project 1.2–1.8 s vs 136.7 s for the SFTP readdir walk, ~75×), then only the pre-fetchable source files download; large batches go through onetar -T -bulk stream verified per file. - Incremental refresh: the same manifest stream is diffed against the three-way index and only truly changed files move; measured 0.87 s (agent) / 1.2–1.8 s (plain SSH) with no changes.
- Event-driven refresh (round 7–8): the resident agent keeps a recursive inotify watch on the workspace and reports change events; a refresh then reconciles only the changed paths (plus a throttled local-drift scan, every 5 min / 25 refreshes, or immediately when the local mirror watcher signals edits). Measured on the real project: idle 42–48 ms, one remote change 93 ms, remote dir+file create 96 ms, remote dir delete 16 ms; a forced full walk stays ~0.5–0.8 s. Post-command sync is debounced background work, so remote commands return immediately.
- Compressed bulk pulls: when the remote has pigz/gzip, tar streams are piped through
gzip -1and transparently gunzipped locally (auto-detected by magic bytes; plain tar still works). - Push: parallel
mkdir -planes + multi-channel parallel uploads. - ~45 common build/vendor/toolchain-cache dirs (
node_modules,.git,target, venv,.cargo,.npm,.conda,.gradle,.idea,.vscode-server, …) are excluded by default; remote.gitignoreand.dsh-remote-dev-ignoreadd a second not-pre-fetched layer (reads unaffected).
Channel budget (6 SFTP + ≤3 concurrent exec) stays under sshd's default MaxSessions=10; the tar stream runs on its own connection so it never contends with pooled channels.
Notes & limits
- Host keys use trust-on-first-use pinning: the first-seen fingerprint is stored in
~/.dsh/remote-dev/known-hosts.jsonand a changed key fails the connection with an explicit warning (possible MITM or host rebuild;remote host-keys forgetorhostKeyPolicy: "trust"overrides). Auth supports OpenSSH keys, passwords, and the SSH agent. PuTTY.ppkkeys must be converted (puttygen <file.ppk> -O private-openssh -o <key>). - Conflict backups are browsable in the cache manager (click the conflict count): each backup can be restored over the local copy (then uploaded) or discarded.
- Size guards: on-demand pre-fetch ≤ 1 MB/file, on-demand reads ≤ 100 MB/file, open-time pre-fetch budget
eagerBudgetBytes(default 64 MB), global cache quotaquotaBytes(default 5 GB), and aminFreeBytes(default 5 GB) disk headroom check before downloads — all configurable. - Sync semantics: the three-way index guarantees no lost bytes and no silent divergence; remote deletions sync down (unsynced local edits are backed up first), local deletions only evict the cache; simultaneous edits on both sides are conflicts — remote wins, local version backed up under
<cache root>/.conflicts/.
No comments yet. Be the first to write one.