DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

tangjunyi1 /

tangjunyi1/dsh-remote-workspace

Verified

Remote workspaces for DeepSeek Harness: run the agent on your server over SSH stdio. Zero file mirroring.

★ 1 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@6451cff0

dsh-remote-workspace

English | 中文

Remote workspaces for DeepSeek Harness — pick a project directory on your server and let the agent work there.

The agent runtime (shell tools, file tools, sandbox, session persistence) executes on the remote host. This machine only drives it over SSH. No file mirroring, ever.


Why not mirror the files?

Because the remote project is not a copy of anything.

Mirroring approach This approach
Pulls the remote tree to local disk Nothing is copied
Local disk grows by the project size Local disk unaffected
.gitignore, permissions, symlinks, hardlinks get lossy Remote semantics untouched
Two sources of truth → sync conflicts One source of truth: the server
Tooling lives remotely, so builds still can't run locally The agent runs where the toolchain is
Usable only for small trees Works for trees that are hundreds of GB

Real numbers from the project this was built for: the remote workspace is 29 GB (apps 15G, dist 17G, deploy 11G). Mirroring it is not an option.

How it works

This is the same architecture Codex uses for its remote projects (codex app-server reached over ssh), mapped onto primitives DSH already ships:

┌─ local DSH ─────────────────────────────┐
│  UI  ⇄  host half  ⇄  SdkTransport      │
└──────────────────────┬──────────────────┘
                       │ ssh -T -o BatchMode=yes <alias> \
                       │   <abs node> <abs dsh>/lib/bin.js --profile sdk
                       │ stdin/stdout = newline-delimited JSON-RPC
                       ▼
┌─ remote server ─────────────────────────┐
│  dsh --profile sdk                      │
│  agent loop · bash/fs tools · sandbox   │
│  cwd = the remote project path          │
└─────────────────────────────────────────┘

DSH's sdk profile is a stdio JSON-RPC server, so no proxy hop into a control socket is needed — one fewer moving part than the Codex design.

Status

Milestone Scope State
M1 transport · handshake sequencing · event projection · remote detection · diagnostic route · CLI probe ✅ done, verified
M2 host half machine registry + ~/.ssh/config import · project registry · pooled transports with reconnect and idle reaping · full HTTP surface incl. SSE ✅ done, verified (20/20)
M2 client half settings section: machine import/test, project creation with remote browse, session panel streaming projected events ✅ done, verified (14/14)
M3 agent tools (rws_list / rws_exec / rws_ls / rws_read / rws_write) · sanitized diagnostics export · type-ahead remote path completion (~ aware) ✅ done, verified (38/38)
Resilience dropped-transport recovery · two projects on one machine · idle reaping · no remote leftovers ✅ done, verified (12/12)

Install

dsh plugin --profile web add link:/path/to/dsh-remote-workspace   # local development
dsh plugin --profile web add dsh-remote-workspace                 # once published

Then restart DSH and open Settings → 远程工作区.

M1 is verified end to end against a Debian 13 remote over SSH:

PASS  serverInfo.name === deepseek-harness-sdk-runtime
PASS  at least one tool call
PASS  at least one tool result with isError=false
PASS  at least one assistant message
PASS  remote shell cwd === remotePath
M1 RESULT: PASS

Requirements

  • DSH >= 0.1.5-rc.1 on both ends (the remote runs dsh --profile sdk)
  • Node.js >= 22.19 locally
  • SSH access to the remote, with the alias defined in ~/.ssh/config
  • DSH installed on the remote: npm i -g @deepseek-ai/dsh

Try it (M1)

No plugin host needed — the probe drives the transport directly:

node scripts/m1-probe.mjs \
  --alias node3 \
  --remote-path /home/sunny/workspace/frontend/rl-studio-frontend \
  --prompt 'Run the shell command `pwd; hostname` and reply with ONLY the raw output.'

The script auto-detects the remote's absolute node and DSH lib/bin.js paths, completes initialize with cwd set to the remote path, sends one prompt, streams the projected events, and asserts the result.

Add --verbose to also see system messages, session titles and the raw SSH stderr.

HTTP surface (M2 host half)

Every route is loopback-only. The smoke test mounts them on a throwaway local server, so no DSH profile has to be touched:

node scripts/m2-smoke.mjs \
  --alias node3 \
  --remote-path /home/sunny/workspace/frontend/rl-studio-frontend \
  --browse-path /home/sunny/workspace
PASS  POST machines/import-ssh-config → 200  (added=7)
PASS  machine "node3" present after import  (7 total)
PASS  POST machines/test → ok  (324ms)
PASS  POST projects → project created  (p-92e7c994)
PASS  GET projects/ls → entries  (11 items)
PASS  ls rejects non-absolute path  (status=400)
PASS  GET projects/complete → matching directories  ("workspac" → 1 match(es))
PASS  complete requires an alias  (status=400)
PASS  POST open → ready  (ready)
PASS  SSE delivered projected events  (22 frames)
PASS  SSE delivered a tool result
PASS  tool ran with cwd === remotePath
PASS  connection removed after close
=== M2 smoke: 20/20 passed ===
Route Methods Purpose
/api/remote-workspace/machines GET · POST · DELETE machine registry
/api/remote-workspace/machines/import-ssh-config POST import hosts from ~/.ssh/config
/api/remote-workspace/machines/test POST connection health check
/api/remote-workspace/projects GET · POST · DELETE project registry
/api/remote-workspace/projects/ls GET browse a remote directory (POSIX-portable, column-parsing free)
/api/remote-workspace/projects/complete GET type-ahead path completion (~ aware, trailing slash lists)
/api/remote-workspace/open POST start (or reuse) a pooled connection
/api/remote-workspace/prompt POST send one user turn
/api/remote-workspace/events GET (SSE) projected events, session status, connection state
/api/remote-workspace/status GET pooled connection snapshot
/api/remote-workspace/close POST shut a connection down and reap the remote process

State lives in $DSH_HOME/remote-workspace/:

machines.json     machine registry
projects.json     project registry (+ cached absolute node/dsh paths)
audit.ndjson      open / prompt / close / registry changes (sanitized)

There is deliberately no known_hosts.json: host-key verification is delegated to OpenSSH, which already does it better than a plugin could (~/.ssh/known_hosts, StrictHostKeyChecking, known_hosts rotation). See Security below.

Transports are pooled per project and reaped after 30 minutes idle, so no dsh --profile sdk process is left behind on the remote. Verified:

$ ssh node3 "ps -eo args | grep 'profile sdk' | grep -v grep"
NONE — no leftovers

Resilience

scripts/resilience-smoke.mjs covers the paths the other suites do not: a dropped transport, two projects on one machine, and the idle reaper.

node scripts/resilience-smoke.mjs --idle-ms 6000
PASS  transport owns an ssh child process  (pid=13820)
PASS  pool reports reconnecting after the transport dies  (reconnecting)
PASS  pool re-establishes the connection automatically
PASS  the recovered transport is a new process  (13820 → 5924)
PASS  the project is usable after recovery  (ddc7407b-…)
PASS  two connections are pooled  (2 connection(s))
PASS  both are independent transports
PASS  the second project is usable independently
PASS  closing one project leaves the other pooled
PASS  the idle reaper closes the last connection  (was 1, now 0)
PASS  no `dsh --profile sdk` process left on the remote  (0 process(es))
=== resilience smoke: 12/12 passed ===

Layout

lib/
  index.js            host half entry: cordis wiring + runProbe()
  client.js           browser half: settings section (CJS `__ModuleLoader__` factory)
  routes.js           loopback-fenced HTTP surface incl. the SSE event stream
  machines.js         machine registry + ~/.ssh/config parser
  projects.js         project registry + remote path normalization
  transport-pool.js   one pooled connection per project: reconnect + idle reaping
  sdk-transport.js    one long-lived ssh child + newline-delimited JSON-RPC
  session-bridge.js   initialize→prompt sequencing, event projection, dedupe
  remote-detect.js    absolute node/dsh path discovery on the remote
  store.js            atomic JSON writes + rolling .bak + audit log
scripts/
  m1-probe.mjs           transport-level verification
  m2-smoke.mjs           full HTTP-surface verification
  client-smoke.mjs       client-half verification without a browser
  m3-smoke.mjs           agent tools, diagnostics, path completion
  resilience-smoke.mjs   reconnect, concurrency, idle reaping

Client half

The browser half is a hand-written CJS factory (no bundler) that registers a single settings.section — deliberately avoiding any optional slot provider such as dsh-better-sidebar.

Because the panel cannot be rendered in a headless environment here, it is verified by scripts/client-smoke.mjs, which stubs __ModuleLoader__ and react, runs the factory, drives apply(mockCtx), and then actually renders the panel through a minimal hooks runtime:

node scripts/client-smoke.mjs
PASS  file calls window.__ModuleLoader__.load
PASS  factory runs without throwing
PASS  exports apply()
PASS  injects the "settings.section" slot
PASS  registers exactly one section
PASS  section label resolves to a non-empty string  (远程工作区)
PASS  panel renders without throwing
PASS  panel produced at least one effect (data load + SSE)  (2 effect(s))
=== client smoke: 14/14 passed ===

Agent tools (M3)

The pooled SDK connection drives a remote agent. These tools are the other direction: the local agent acting on a registered remote project directly — no remote model turn, and still no local mirror.

Tool Purpose
rws_list list registered projects (projectId, machine alias, remote path, label)
rws_exec run a shell command inside the remote project directory
rws_ls list a remote directory (relative paths resolve against the project root)
rws_read read a remote file (base64-safe, size-capped)
rws_write create/overwrite a remote file (parents created, arbitrary bytes via base64)

Relative paths resolve against the project root; absolute paths are honoured as given. Tools register through ctx.inject(['tools'], …), so a profile without the tools service still gets the panel.

Verified against the real defineTool from @deepseek-ai/dsh-tools (schema compilation included) and executed against a live remote:

node scripts/m3-smoke.mjs
PASS  loadDefineTool() resolves even from a checkout (link: install fallback)
PASS  makeRemoteTools() builds against the real defineTool
PASS  compiled schema marks both params required  (["projectId","command"])
PASS  rws_exec ran in the remote project dir  ("/home/sunny/workspace/frontend/rl-studio-frontend")
PASS  rws_ls lists the project root  (36 items)
PASS  rws_write creates a remote file  (45 bytes)
PASS  rws_read round-trips the exact bytes  (43 chars)
PASS  expands ~ and still matches  (/home/sunny)
PASS  unknown projectId fails with a helpful message
=== M3 smoke: 38/38 passed ===

Diagnostics

GET /api/remote-workspace/diagnostics returns one sanitized report: home paths rewritten to ~, no credentials (the plugin stores none), and an audit tail that records event names and identifiers only — never prompt text or command output. The panel exposes it behind a 诊断 button so a bug report is one click away.

Design notes worth knowing

These were all found the hard way, against a real remote:

  1. initialize must complete before session/prompt. The SDK server dispatches stdin lines concurrently; a pipelined prompt comes back as SDK server is not initialized. SessionBridge serializes it.
  2. Non-interactive SSH does not activate nvm. The remote dsh is a #!/usr/bin/env node shim, so ssh host dsh … fails with env: 'node': No such file or directory. The remote command therefore uses absolute node + bin.js paths, discovered by remote-detect.js.
  3. The working directory is fixed at initialize. One transport per remote project.
  4. Remote sessions are durable. Reusing a session id fails with session "<id>" already exists; a new conversation must mint a fresh id.
  5. The transport is one JSON document per line, and a single line can carry a full session/event envelope (several KB) — parse line-wise.
  6. session.event carries complete envelopes, not deltas — dedupe by seq.
  7. Tool names are namespaced rws_ and registered per-tool. The obvious rw_ prefix is already owned by the dsh-remote plugin (rw_exec, rw_read_file, …); a shared name makes the second registration throw tool "rw_exec" is already registered, and because the whole batch was registered in one .map() a single collision silently cost every tool. Registration is now fault tolerant per tool and the diagnostics report lists what was skipped.
  8. Do not register tools via ctx.inject(['tools'], …). That callback fires in every scope where the service appears — including per-agent scopes — so the second firing collides with the first. Declare tools in the plugin's inject array and register once on the plugin context, which is what the shipped SSH plugins do.
  9. A link: install resolves imports from its real path. With the plugin linked from a checkout, a bare import('@deepseek-ai/dsh-tools') walks up the checkout and fails with ERR_MODULE_NOT_FOUND — it never reaches $DSH_HOME/profiles/node_modules. loadDefineTool() therefore falls back to absolute candidates under $DSH_HOME/profiles/*/node_modules.

Deliberately not implemented

SSH_AUTH_SOCK forwarding. Codex forwards the agent socket so remote git / gh can reuse local keys. It was measured on the reference machine and is not viable there — and would be invisible dead config for anyone in the same situation:

SSH_AUTH_SOCK (local)      : <empty>
Get-Service ssh-agent      : Stopped / Disabled
ssh-add -l                 : Error connecting to agent: No such file or directory
ssh -o ForwardAgent=yes …  : SSH_AUTH_SOCK=[]   # nothing to forward

Adding it would mean config surface that cannot be verified and does nothing. Revisit if a target machine actually runs an agent.

Approval passthrough stays out of scope: the SDK wire protocol (@deepseek-ai/dsh-sdk-protocol) has no approval frame, so it needs a protocol extension rather than a plugin change.

Security

  • Every route is loopback-only (socket address plus Host check). These routes execute commands on remote servers; do not expose a DSH web instance running this plugin to a LAN.
  • No credentials are stored. Authentication is delegated entirely to the system ssh client, so ~/.ssh/config, IdentityFile, ProxyJump and ssh-agent all work as-is.
  • Host-key verification is OpenSSH's job, not ours. Because every connection goes through the system ssh binary, ~/.ssh/known_hosts, StrictHostKeyChecking and HashKnownHosts all apply exactly as they do for your interactive shells — better than a plugin-local TOFU store, and one less place for a stale fingerprint to live.
  • The local ~/.dsh/.credentials.yaml is never sent to the remote; the remote needs its own credentials.

Design record

The architecture, alternatives considered, per-milestone plan and every hard-won implementation finding are written up in docs/design.zh.md (Chinese). It doubles as a measurement log: claims marked 实测 were verified against a real Debian 13 remote, and the things that were deliberately not built are recorded there too.

License

MIT

—/ 5

No ratings yet

Verified DSH bundle

Commit 6451cff02d3d

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout