Skip to content

dsh

Recipe card from the charly-tools plugin (Images — the deployable catalog).

The DeepSeek Harness capability is split across three candies by concern (R3):

  • dsh — the CLI only (@deepseek-ai/dsh 0.2.0-rc.2 via npm).
  • dsh-web — the loopback-only, token-authenticated web UI + socat forwarder (composes dsh).
  • dsh-tui — the dsh-TUI terminal-UI plugin + its typed terminal channel (composes dsh).
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)
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.

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:

  1. 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 answers 303 and sets a signed, authority-bound, HttpOnly; SameSite=Strict cookie whose authority is the request Host — so it follows the proxy authority rather than staying 127.0.0.1. A missing or wrong-authority cookie returns 401.

  2. The Host / Origin browser-trust fence (dsh-client-connection’s api-request-trust), which every API and WebSocket request passes before authentication — including every /api/* call the SPA makes after the shell loads. The request Host must be loopback or match a trustedHosts entry (exact on host:port, any port on a port-less entry); an attached Origin must equal that Host; sec-fetch-site: cross-site is refused. A failed Host check returns 403 forbidden; a trusted-but-unauthenticated request returns 401 unauthorized. The root GET / 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.:

Terminal window
# expose the loopback web UI on the tailnet, 1:1, and trust its authority
dsh --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 443

The 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:

Terminal window
# 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.

charly.yml
my-web-box:
candy:
- dsh-web # CLI + web UI
my-tui-box:
candy:
- dsh-tui # CLI + terminal UI

Plugins 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:

Terminal window
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, in dsh-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 dsh does 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’s runtime/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-tui profile 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 the dsh-tui profile makes them FAIL to activate (N entries did not activate). Keep that profile at dsh-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).

  • Composed on demand; requires a builder that declares the npm builder (the mcporter precedent).

Use when the user asks about:

  • The deepseek-harness dsh CLI
  • The dsh-web candy or the dsh: check verb
  • The socat forwarder that exposes the loopback-bound web app
  • /charly-image:layer — candy authoring reference (charly.yml schema, task verbs, service declarations)
  • /charly-check:check — declarative testing (check: block, charly check box, charly check live)