dsh-remote-ssh is a community plugin. It is not part of the DeepSeek Harness
repository, it does not use DeepSeek's npm scope, and it is not affiliated with or
endorsed by DeepSeek. The upstream project states that it cannot accept external
pull requests at the moment, so nothing here is destined for it: this repository is
the project.
Two guidelines from upstream shape how it presents itself, and both are respected here: descriptive text may say the plugin is built on DeepSeek Harness, and project naming uses the short DSH form.
Thanks for wanting to help. This plugin reaches into a running agent's filesystem and shell, so the bar is not "it works on my machine" — it is "it fails visibly everywhere else". The guide below is mostly about that.
git clone https://github.com/cmukanisa/dsh-remote-ssh
cd dsh-remote-ssh
npm run deps # symlink the harness packages (see below)
npm test # parser check + unit suiteThe plugin imports @deepseek-ai/cordis, @deepseek-ai/dsh-fs, … exactly as the
harness does, but this repository is not a harness checkout, so Node has
nothing to resolve. scripts/link-deps.mjs symlinks those packages out of an
installed harness instead of vendoring a second copy that would let the tests
drift from the runtime they claim to test.
It looks in $DSH_HOME/profiles/node_modules/@deepseek-ai first, then at a global
@deepseek-ai/dsh install. If neither exists:
npm install -g @deepseek-ai/dsh
dsh --profile web --help # creates $DSH_HOME/profiles
npm run depsnpm install (used only for the template check's one dev dependency) prunes those
symlinks, so re-run npm run deps after it.
There is no build step. The plugin is plain ES modules and the browser half is a hand-written bundle, on purpose: a build step is one more thing that can differ between a contributor's machine and a user's.
| Suite | Command | Needs | What it proves |
|---|---|---|---|
| Parser | npm run check |
nothing | every shipped file parses; the browser bundles keep the module-table wrapper |
| Template | npm run test:template |
npm install |
the composition template parses and replaces every shipped provider it must |
| Docs | npm run check:docs |
nothing | the three language pages exist, cross-link, have no dead internal link, and pull no third-party resource |
| Unit | npm run test:unit |
nothing | quoting, path mapping, mirrors, policy containment, the Remote descriptor, the composition template |
| Installer | node test/install.test.mjs |
nothing | the three durable changes, exactly once, and that the plugin arrives switched off |
| Windows client | node test/windows-client.test.mjs |
nothing | no multiplexing promise on Windows; ControlPath shortening holds for Windows-shaped paths |
| Tailscale | node test/tailscale.test.mjs |
nothing | tailnet parsing, address classification, peer lookup, preflight, and the two transports as argv. Synthetic fixtures only: a test file is public, so it must never carry anyone's tailnet |
| Filesystem E2E | npm run test:e2e |
a real SSH host | read/write/edit/list/stat/byte windows/URI parsing/sandbox fences, the shell executor, subprocess routing, and local passthrough |
| Detached work | node test/sessions.test.mjs |
a real SSH host | a command keeps running after the client that started it is gone, a new reader sees its state, and it can be stopped |
| Harness E2E | npm run test:harness |
a running dsh + a real SSH host |
the composed plugin: browser bundle served, sshWorkspace Remote namespace answering, a remote folder becoming a workspace |
| Windows server | node test/windows-server.expect.mjs |
a Windows SSH host | the refusal is explicit and actionable |
npm run ssh:up # builds and starts a disposable OpenSSH container
export DSH_SSH_TEST_HOST=127.0.0.1 DSH_SSH_TEST_PORT=2223 DSH_SSH_TEST_USER=dsh \
DSH_SSH_TEST_KEY=/tmp/dsh-remote-ssh-e2e/id_test \
DSH_SSH_TEST_KNOWN_HOSTS=/tmp/dsh-remote-ssh-e2e/known_hosts \
DSH_SSH_TEST_ROOT=/home/dsh/work
npm run test:e2e
npm run ssh:downssh:up prints the exact environment for your shell. Pick a port nothing else
listens on — a stray local sshd on the same port is the single most confusing
failure mode there is.
The suite creates its own directory per run and never depends on state a previous run left behind, so it is re-runnable.
.github/workflows/ci.yml — parser + unit + installer on Linux, macOS, and
Windows across Node 22 and 24 — the range the harness supports, so a green run
means something about a real deployment. This is the baseline; a red here is a
genuine portability regression.
Two portability traps this matrix has already caught, both worth remembering:
a Windows checkout with core.autocrlf hands over CRLF (so any line-anchored
check must normalise first), and ssh -V prints on stderr.
.github/workflows/e2e.yml — four jobs, because a remote workspace is only
"portable" if it survives all of these:
| Job | Server | Client | Why it exists |
|---|---|---|---|
linux-server |
Alpine and Debian containers | Linux | the two Linux userlands the remote scripts may assume |
harness |
Alpine container | Linux | the full product: boot, browser bundle, Remote namespace, workspace adoption |
macos-server |
the runner's own sshd | macOS | BSD userland (stat -f) and the long /var/folders temp path |
windows-server |
the runner's own sshd | Windows | asserts the documented refusal, and covers the Windows client path |
.github/workflows/portability.yml — POSIX-shell conformance of install.sh,
YAML validity of the composition template, a scan for machine-specific absolute
paths, and the Windows transport expectations.
.github/workflows/release.yml — on a tag: build the tarball, prove it installs
into a pristine harness home, publish it with a checksum.
.github/workflows/codeql.yml — security-and-quality analysis. The interesting
class of bug here is untrusted data (a path, a command fragment) reaching a remote
shell, so a review should look for exactly that.
packages/dsh-remote-ssh/ host: ssh.js tailscale.js registry.js fs.js shell.js subprocess.js sessions.js
packages/dsh-remote-ssh-ui/ browser half (localized) + the Remote namespace
patch/remote-ssh.patch.yml.tpl the composition rows the installer writes
install.mjs, install.sh installation
docs/ the GitHub Pages site, fr/en/zh, no build step
docs/assets/social/ the link-preview and announcement cards
scripts/ the static gates CI runs, plus social-cards.mjs
test/ the suites above
The cards under docs/assets/social/ are generated, not hand-drawn:
node scripts/social-cards.mjs renders them from the wording in that script with a headless
Chrome (--theme light|dark, --out, --scale). Change the copy there and re-run it rather
than editing a PNG.
Client copy is locale-owned: every displayed string is a key in the fr/en/zh dictionaries
registered with ctx.locale, and scripts/check-syntax.mjs fails if a key is missing from a locale,
unused, or if a literal reaches the DOM.
- Every value that reaches a remote shell goes through
shellQuote.ssh host cmd a bjoins its arguments with spaces and the remote login shell parses that one string, so an argument is not a separate word. A path or a command fragment interpolated raw is remote code execution. - Parse
statpermission bits base 8. They are octal digits; reading644base 10 gives420, and every laterchmodcorrupts the file it meant to preserve. This shipped once. - Never let a local path take the remote branch, and never the reverse.
registry.worldOf()is the single decision point; the local passthrough tests exist because a regression there breaks every ordinary session. - Fail visibly. A misconfigured host must not read as an agent bug: name what is wrong and what to do about it (see the non-POSIX refusal).
- Keep side effects reversible. Anything that opens a connection, a timer, a slot, or a settings namespace belongs to the plugin's fiber.
- Put the remote shell source in the layer that owns it (
fs.js,shell.js, …) and quote every interpolation. - Map its failures into the seam's own taxonomy (
FsErrorwith anFS_*code), never a bareError. - Add a unit case for the pure part and an E2E case for the round-trip.
- If it mutates, invalidate the realpath cache and take the per-target lock.
Every remote operation is at least one round trip. Connection multiplexing
(ControlMaster=auto + ControlPersist) makes the second call cheap, so the
rule is: one round trip per user-visible operation, not one per internal step.
listDir is a single NUL-delimited scan for that reason, and realpath results are
cached for two seconds and invalidated on every mutation.
main is protected: it takes pull requests, not pushes, and every required check
must be green first. The flow is
git checkout -b fix/short-description
# ...work...
git push -u origin fix/short-description
gh pr create --fillThree workflows post a stable check name that main requires — ci-gate,
e2e-gate, and portability-gate — each of which aggregates its own matrix so
branch protection needs one context per workflow instead of every expansion.
The PR bot (.github/workflows/pr-bot.yml) then analyzes the pull request,
comments a per-area summary, and arms GitHub's native auto-merge (squash) only
when the author is a trusted actor and the change touches neither
.github/workflows/ nor install.sh — those two run with repository permissions
and deserve a human read. External contributions always wait for a maintainer.
The bot runs on pull_request_target, which is the only trigger that sees fork
PRs with a write token. It therefore never checks out, imports, or executes the
pull request's code: it reads the API only. Anything that needs the PR's files
belongs in .github/workflows/ci.yml, which runs with a read-only token.
- One concern per PR; a behaviour change gets a test in the same PR.
- Conventional commit subjects (
feat:,fix:,test:,docs:,ci:). - Say in the description which suite you ran and against which server, including
which userland (
Alpine,Debian,macOS) when it matters. - If you touched the transport, say which
sshversion you tested with.
Include:
ssh -V,node -v, the harness version (dsh -V), and the OS of both ends;- the exact error text (the plugin tries to make it actionable — please quote it);
- whether
ssh <host>works by hand from the same machine; - the
uname -aof the server, and whetherrg,realpath, andstatexist there.
Do not paste secrets, private keys, or remotes.json.
By contributing you agree your work is released under the MIT License.