dsh
Recipe card from the charly-tools plugin (Images — the deployable catalog).
dsh – DeepSeek Harness CLI and web UI
Section titled “dsh – DeepSeek Harness CLI and web UI”The DeepSeek Harness capability is split across three candies by concern (R3):
dsh— the CLI only (@deepseek-ai/dsh0.2.0-rc.2 via npm).dsh-web— the loopback-only, token-authenticated web UI + socat forwarder (composesdsh).dsh-tui— the dsh-TUI terminal-UI plugin + its typed terminal channel (composesdsh).
Candy Properties (dsh-web)
Section titled “Candy Properties (dsh-web)”| Property | Value |
|---|---|
| Install files | charly.yml, dsh-entrypoint |
| Depends | dsh, nodejs (>=22.19.0), supervisord |
| Port | 3080 (web UI, loopback) / 3081 (socat forwarder) |
| Volume | ~/.dsh (profiles + plugin data + the captured web token) |
Environment Variables
Section titled “Environment Variables”| Variable | Value |
|---|---|
DSH_HOME |
~/.dsh |
DSH_WEB_TRUSTED_HOSTS |
unset/empty by default; a space- or comma-separated list of host:port authorities the entrypoint forwards as repeatable --trusted-host (set it when the pod is reached through a reverse proxy) |
A reverse-proxy deployment (tailscale serve, an SSH -L tunnel) presents a
non-loopback Host, so without this the shell loads while every /api/*
call returns 403.
Packages
Section titled “Packages”RPM/DEB/PAC: socat (the loopback→eth0 forwarder)
The web UI is loopback-only and token-authenticated
Section titled “The web UI is loopback-only and token-authenticated”dsh web binds 127.0.0.1:3080 only: dsh-web-app hard-rejects
--host 0.0.0.0 (RCE safety). A container’s published
port maps to the container’s eth0, not loopback — so the charly-owned
dsh-entrypoint runs dsh web in the background and execs a socat
forwarder (TCP-LISTEN:3081,fork,reuseaddr TCP:127.0.0.1:3080) that
exposes the web app on eth0. socat cannot share port 3080 (Linux
forbids a wildcard + loopback listener pair on one port), so the
published port (auto-allocated host port → container 3081) reaches
the web UI through socat. On the vm substrate the ssh -L forward
targets loopback directly — socat just doesn’t hurt there.
The web UI has TWO independent auth layers
Section titled “The web UI has TWO independent auth layers”dsh web authenticates at two layers, and a probe that only
checks the first will pass while the browser UI is broken:
-
The launch token. Each process mints a random token and prints its readiness URL as
dsh web: http://127.0.0.1:3080/?token=.... There is no flag or config that disables it.GET /?token=…on the root only answers303and sets a signed, authority-bound,HttpOnly; SameSite=Strictcookie whose authority is the request Host — so it follows the proxy authority rather than staying127.0.0.1. A missing or wrong-authority cookie returns 401. -
The Host / Origin browser-trust fence (
dsh-client-connection’sapi-request-trust), which every API and WebSocket request passes before authentication — including every/api/*call the SPA makes after the shell loads. The requestHostmust be loopback or match atrustedHostsentry (exact onhost:port, any port on a port-less entry); an attachedOriginmust equal thatHost;sec-fetch-site: cross-siteis refused. A failed Host check returns 403forbidden; a trusted-but-unauthenticated request returns 401unauthorized. The rootGET /is the token redemption route and is answered by layer 1 first, so a forged Host there reads 401, not 403 — the fence is only observable on/api/*.
The entrypoint parses the token from the readiness line and writes it
to $DSH_HOME/web-token (on the dsh volume, so a fresh
charly update rebuild re-captures it). In-box probes authenticate
with the token, e.g.
curl "http://127.0.0.1:3080/?token=$(cat $DSH_HOME/web-token)".
Reaching the web UI through a reverse proxy (e.g. Tailscale)
Section titled “Reaching the web UI through a reverse proxy (e.g. Tailscale)”Because the app binds loopback only, every off-loopback route is a
reverse proxy in front of it (tailscale serve, an SSH -L tunnel,
an ingress). Such a proxy forwards Host: <proxy-authority> — which
is neither loopback nor in trustedHosts by default — so the
shell loads (/ → 200) but every /api/* call returns 403
forbidden and the SPA renders an “authentication required” / dead
page. GET / returning 200 does NOT prove the web UI works; always
exercise an /api/* request.
The fix is the repeatable --trusted-host <authority> flag
(dsh web --trusted-host <proxy-host>:<port>). Note lanAddresses
is only populated when the bind host is 0.0.0.0, which dsh web
deliberately is not — so when bound to loopback, only the explicit
--trusted-host entries are trusted. Pass the proxy authority, e.g.:
# expose the loopback web UI on the tailnet, 1:1, and trust its authoritydsh --profile web --port 3080 --trusted-host "$(tailscale status --self --json \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))'):3080"tailscale serve --bg --https=3080 http://127.0.0.1:3080 # 1:1; NOT 443The exposed URL contract — https:// and a token. --https=3080
installs an HTTPS-only listener (there is no HTTP one), so a plain
http://<self>.ts.net:3080/ is answered by that TLS server with
400 Client sent an HTTP request to an HTTPS server. Use
https://. And because dsh web authenticates every request, the bare
https://<self>.ts.net:3080/ returns 401 — the URL must carry the
launch token, which redeems it (303) and sets the authority-scoped
cookie. Both together:
# the working URL (token from the durable file the entrypoint writes)https://<self>.ts.net:3080/?token=$(cat ~/.dsh/web-token)# or: ~/.local/bin/dsh-web-url [--local|--tailnet] [--open]On a pod, the socat forwarder is what makes the published port
loopback-reaching, so a browser hitting 127.0.0.1:<published> stays
on the trusted loopback authority. A pod reached through any
off-loopback proxy needs the same --trusted-host treatment: set the
dsh-web candy’s DSH_WEB_TRUSTED_HOSTS to the proxy authority
(host:port, space- or comma-separated) and the entrypoint forwards
each as --trusted-host.
my-web-box: candy: - dsh-web # CLI + web UImy-tui-box: candy: - dsh-tui # CLI + terminal UIPlugins and experimental features (the Plugins page)
Section titled “Plugins and experimental features (the Plugins page)”Agent Teams, Auto Authorization Review, Automation tasks, and Voice
input are package-bundles: each is a name in a profile’s
dsh.profile.bundles ($DSH_HOME/profiles/<name>/package.json). The
Web sidebar’s Plugins page toggles one by writing a disabled:
override into that profile’s cordis.patch.yml — not by editing the
bundle list. Composition is not activation: --dump-config shows
a bundle was composed; only a boot proves it activated. Check both:
dsh --profile web --dump-config | grep -E 'agent-team|auto-review|ui-schedule|ui-voice-input'dsh --profile web --port 13080 --no-open # a failed plugin prints "N entries did not activate: <reason>"| Feature | Bundle | web | dsh-tui |
|---|---|---|---|
| Agent Teams | dsh-experimental-agent-team-profile |
✅ | ❌ |
| Auto review | dsh-experimental-auto-review |
✅ | ❌ |
| Automation tasks | dsh-experimental-schedule-bundle |
✅ | ❌ |
| Voice input | dsh-experimental-voice-input-bundle |
✅ (needs a model) | ❌ |
| git-worktree (community) | dsh-git-worktree |
✅ | ❌ |
| OpenCharly (native) | dsh-opencharly |
✅ | ❌ |
The dsh-web profile ships eight bundles: dsh-base, dsh-web-app,
the four experimental features, the community dsh-git-worktree
plugin, and the native dsh-opencharly plugin.
Both plugins are VENDORED PREBUILT as tarballs under
candy/dsh-web/web-profile/vendor-tgz/ and consumed as file: deps, so
the profile install resolves them OFFLINE (no codeload fetch, no
per-dep npm-install bootstrap — the flake pod-dsh#26 recorded).
Each tarball’s source repo+commit and SHA-256 are recorded in
vendor-tgz/README.md, with the two hashes in
vendor-tgz/SHA256SUMS (verified in the volume by the bed).
dsh-opencharly is server-only (no browser client); the other ships a
lib/client.js browser half. Both are installed at image BUILD time
into ~/.local/share/dsh/profile-home and seeded into the dsh volume
by the entrypoint — because profile bundles resolve from
$DSH_HOME/profiles/web/node_modules, which the named volume shadows.
- Agent Teams runs in
web/headless: the Lead spawns named teammates, exchanges durable messages, and uses a shared task board. It needs durable session storage (dsh-session-persistence-jsonl, indsh-base) and is durable but PROCESS-LOCAL — all members share ONE working directory (no worktree isolation), and there is no cross-process/cross-client team. - Voice input activates but needs a one-time 241 MB local
SenseVoice model that
dshdoes NOT pre-fetch — the mic appears, and recognition fails until it is prepared. In the UI click Download and prepare; to pre-seed (sizes + sha256 are pinned in the package’sruntime/assets.json), place the verified files under$DSH_HOME/speech-to-text/sensevoice/models/:sensevoice-onnx/model.int8.onnx(239233841 B),sensevoice-onnx/tokens.txt(315894 B),silero/silero_vad.onnx(1807522 B). - The
dsh-tuiprofile cannot host these bundles. The dsh-TUI adapter rejects the root capabilities they require —Error: dsh-tui: root.effect is unavailable from a plugin activation— so adding them to thedsh-tuiprofile makes them FAIL to activate (N entries did not activate). Keep that profile atdsh-base+dsh-tui. The dsh-TUI launcher is standalone (it has no remote/attach), so there is no Agent-Teams coordination between the TUI and the web GUI: team coordination is within one Web/headless process, not across the two UIs.
The dsh-web candy stages the enabled profile into the dsh
volume at deploy time (a data: entry → $DSH_HOME/profiles/web), so
a freshly deployed pod has all eight bundles composed without any
in-UI toggling. The dsh-tui candy keeps its profile at base + the
plugin. This is composition, not activation: the web UI’s own boot
proves they activate (a failed one prints N entries did not activate).
Used In Boxes
Section titled “Used In Boxes”- Composed on demand; requires a builder that declares the npm builder (the mcporter precedent).
Related Candies
Section titled “Related Candies”/charly-coder:nodejs– required runtime dependency (node >=22.19.0)/charly-tools:dsh-tui– the terminal UI candy (the dsh-TUI plugin)/charly-tools:mcporter– sibling npm-global CLI candy
When to Use This Skill
Section titled “When to Use This Skill”Use when the user asks about:
- The deepseek-harness
dshCLI - The
dsh-webcandy or thedsh:check verb - The socat forwarder that exposes the loopback-bound web app
Related
Section titled “Related”/charly-image:layer— candy authoring reference (charly.ymlschema, task verbs, service declarations)/charly-check:check— declarative testing (check:block,charly check box,charly check live)

