dsh-remote-workspace
Remote workspaces for DeepSeek Harness: run the agent on your server over SSH stdio. Zero file mirroring.
- Stars
- 1
- Language
- JavaScript
- Created
- Sep 10, 2026
- Updated
- Sep 10, 2026
Introduction
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:
initializemust complete beforesession/prompt. The SDK server dispatches stdin lines concurrently; a pipelined prompt comes back asSDK server is not initialized.SessionBridgeserializes it.- Non-interactive SSH does not activate nvm. The remote
dshis a#!/usr/bin/env nodeshim, sossh host dsh …fails withenv: 'node': No such file or directory. The remote command therefore uses absolutenode+bin.jspaths, discovered byremote-detect.js. - The working directory is fixed at
initialize. One transport per remote project. - Remote sessions are durable. Reusing a session id fails with
session "<id>" already exists; a new conversation must mint a fresh id. - The transport is one JSON document per line, and a single line can carry a full session/event envelope (several KB) — parse line-wise.
session.eventcarries complete envelopes, not deltas — dedupe byseq.- Tool names are namespaced
rws_and registered per-tool. The obviousrw_prefix is already owned by thedsh-remoteplugin (rw_exec,rw_read_file, …); a shared name makes the second registration throwtool "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. - 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. Declaretoolsin the plugin'sinjectarray and register once on the plugin context, which is what the shipped SSH plugins do. - A
link:install resolves imports from its real path. With the plugin linked from a checkout, a bareimport('@deepseek-ai/dsh-tools')walks up the checkout and fails withERR_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
Hostcheck). 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
sshclient, so~/.ssh/config,IdentityFile,ProxyJumpandssh-agentall work as-is. - Host-key verification is OpenSSH's job, not ours. Because every connection goes through the system
sshbinary,~/.ssh/known_hosts,StrictHostKeyCheckingandHashKnownHostsall 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.yamlis 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