Back to home

FairyScript

dsh-arbitrary-host

No description

Stars
0
Language
JavaScript
Created
Aug 14, 2026
Updated
Aug 15, 2026

Introduction

dsh web — arbitrary --host (non-invasive bundle)

dsh web originally restricts the bind host:

  1. dsh-web-app/lib/startup.js — rejects --host 0.0.0.0 outright (usage error).
  2. dsh-host-webserver/lib/index.js — the webServer config schema only accepts the literals 127.0.0.1 or 0.0.0.0, so no other host (e.g. a concrete LAN IP) can be configured.
  3. dsh-web-app/lib/index.js (resolveLanTrust) — the /api browser-trust fence auto-derives trusted authorities only for the 0.0.0.0 bind.

This bundle removes those restrictions: any --host value is accepted verbatim and handed to node:http server.listen, and the app refuses to load from a non-secure browser origin instead of booting half-broken.

How it is wired (zero modification of shipped files)

The global dsh installation is untouched (wherever it lives — the installer locates it dynamically, see below). Instead, a single bundle package plugin/ is registered as an extra bundle layer of the web profile through the official mechanism:

dsh plugin --profile web add ./plugin

That runs pnpm add in the profile and reconcilePlugins appends dsh-arbitrary-host to dsh.profile.bundles (now [@deepseek-ai/dsh-base, @deepseek-ai/dsh-web-app, dsh-arbitrary-host]). During local development the dependency is a pnpm link: — the plugin dir stays the single source of truth, so edits are live (no reinstall for code changes).

The bundle's cordis.patch.yml uses the include patch dialect: the shipped web-startup / webserver / web-runtime / connection rows are disabled and replacement rows are inserted under new ids. (The dialect cannot change a row's name — "name mismatch, skipping" — so disable

  • insert is the only non-invasive way to swap an implementation.)

The connection replacement row is named with the bare package name (name: 'dsh-arbitrary-host', like dsh-webui-auth): its main export is the connection implementation and its ./client export is the restamped client bundle — so a single package carries both the server and browser halves.

Update behavior

  • Global dsh updates never touch the profile, and nothing in the bundle edits a shipped file, so an update cannot "lose" these changes.
  • The replacements import the current shipped packages: webserver.js subclasses the shipped WebServer (only the host schema is widened), frontend-static.js reuses the shipped serveStatic, and glue.js reuses the shipped webAppInternals.resolveDistIndex. When the global packages update, the new code flows into the bundle automatically.
  • What can need attention after an update: if a shipped row id or service contract changes, the disabled ids / glue may need small adjustments (visible immediately in dsh web --dump-config). The plugin's node_modules symlink (below) must point at the current global install.

The changes

  • plugin/lib/startup.js — shipped startup minus the --host 0.0.0.0 rejection (only the non-numeric --port check remains).
  • plugin/lib/webserver.jsWebServer subclass with host: z.string().required() (any host string reaches server.listen).
  • plugin/lib/glue.js — web-runtime glue mounting the gated frontend-static, with resolveLanTrust also deriving a concrete non-loopback IP-literal bind host as a trusted /api authority. Hostname binds stay caller-declared (--trusted-host); the fence's loopback and cross-site checks are untouched.
  • plugin/lib/frontend-static.jssecure-context gate: the Web shell depends on crypto.randomUUID, which browsers expose only in secure contexts (HTTPS, or localhost). Over plain HTTP on a LAN IP or hostname the app would boot half-broken (lists fail, "add workspace" throws crypto.randomUUID is not a function), so the fallback returns a self-contained 403 guidance page (Chinese/English) instead, pointing to: loopback on the host, an HTTPS reverse proxy (X-Forwarded-Proto: https passes the gate), or a browser "insecure origins treated as secure" flag. /api and /plugins routes are untouched, so scripts/curl keep working.
  • plugin/lib/connection.jsconnection row replacement (row name dsh-arbitrary-host)
    • The shipped @deepseek-ai/dsh-client-connection pins a set of methods (settings.*, credentials.*, agentPreset.*, host.pickDirectory, host.openPath, llm.discoverModels) to loopback only by design. Over a reverse-proxy domain these returned transport failure for /api/settings.describe: HTTP 403.
    • The replacement is the shipped transport verbatim (reuses the shipped HostConnectionService, toFetchHandler, fence, bridge, and WebSocket downlinks; the browser client bundle is a copy of the shipped one with only the registration id restamped, re-synced by the installer), with the loopback-only gate driven by a new row config privilegedMethods (default [] = none privileged).
    • With privilegedMethods: [] every method still passes the outer DNS-rebinding fence (trustedHosts), so only loopback or a declared --trusted-host authority reaches the configuration plane; un-declared hosts stay 403 (verified). Relaxing the plane is the deployment's deliberate choice — trustedHosts is explicitly not authentication. Re-lock specific methods by listing them in privilegedMethods in cordis.patch.yml.

Files

plugin/                  the single bundle package (pnpm link: dep of the web profile)
  package.json           name, exports map, dsh.bundle.patch + dsh.client manifest
  cordis.patch.yml       disable shipped rows + insert replacements
  lib/startup.js         web-startup replacement
  lib/webserver.js       WebServer subclass (relaxed host schema)
  lib/frontend-static.js gated SPA server (reuses shipped serveStatic)
  lib/glue.js            web-runtime replacement (LAN trust + gate mount)
  lib/connection.js      connection row replacement (row name = the package name)
  lib/client.js          restamped client bundle — GENERATED by install.mjs
                         (see below; deliberately not committed)
  node_modules -> global install   (lets the plugin's own imports resolve;
                                    local link: development only)
install.mjs              installer — registers the package via `dsh plugin` (idempotent, Node.js only)
uninstall.mjs            uninstaller — removes the package again (idempotent, Node.js only)

Why lib/client.js is generated, not committed

The connection row replaces the shipped @deepseek-ai/dsh-client-connection row, so its browser-half bundle must carry a different registration id. The bundle is a copy of the shipped client with only that id rewritten — ~10k lines of vendored shipped code. Committing it would make the repo carry a stale-by-construction copy (it must track every global dsh update), so the installer regenerates it from the CURRENT global install on every run. Consequence: a fully remote one-liner (dsh plugin add github:...) is not possible for the connection row — clone the repo and run node install.mjs instead (it performs the official dsh plugin add under the hood).

Setup

The installer is idempotent (Node.js only, no shell script — and no pnpm requirement):

node install.mjs      # Node.js only — no shell script, pnpm optional
dsh web --dump-config | grep arbitrary-host   # confirm the rows

Registration prefers the official dsh plugin mechanism when pnpm is on PATH (it also maintains the profile's pnpm-lock.yaml); when pnpm is absent the installer falls back to an equivalent pure-Node registration (profile manifest dependency + bundle layer + node_modules link — the same state dsh plugin add produces; the next dsh plugin/pnpm run in the profile reconciles the lockfile from the manifest). uninstall.mjs mirrors this. The loader and the boot never read the pnpm lockfile, so both paths produce a profile that boots identically (verified).

Both honor DSH_PROFILE (default web) and DSH_GLOBAL_NODE_MODULES (override for the global node_modules). No machine-specific path is hardcoded anywhere.

How the global dsh install is located

The installer needs the global node_modules that holds the shipped @deepseek-ai packages (to sync the client bundle and to let the plugins' imports resolve). It is found regardless of how dsh was installed, using only Node built-ins (no sh, no npm/pnpm CLI probes — cross-platform):

  1. DSH_GLOBAL_NODE_MODULES, if set;
  2. the deployment the dsh binary on PATH resolves to — realpath the binary, then walk up probing both each directory and its node_modules (the second probe finds package-own dependency trees, e.g. the .../@deepseek-ai/dsh/node_modules layout mise/pnpm produce);
  3. the global root of the node running the installer (dirname(process.execPath)/../lib/node_modules and friends), which covers mise / nvm / npm layouts even when dsh is not on PATH;
  4. standard layouts: $BUN_INSTALL/install/global/node_modules, then npm-style prefix roots (best effort).

Only roots that actually hold the shipped @deepseek-ai/dsh-client-connection/lib/client.js are accepted, and the chosen root is probe-checked for compatibility: if its own @deepseek-ai/dsh-host-apiproxy calls z.looseObject, its zod must export it, or the installer aborts with a clear message. (This is the failure the probe exists for: a wrong root — e.g. an unrelated tool's global install carrying @deepseek-ai packages as transitive dependencies with a different zod — makes the very first boot die with z$1.looseObject is not a function when the connection row is imported.)

If none matches, it prints where it looked and how to set the override.

Manual install (no scripts at all)

The dsh plugin command is the entire official mechanism; the only extra step is the client-bundle sync, which needs the global path from which dsh:

# .../node_modules/@deepseek-ai/dsh/lib/bin.js -> up four levels to node_modules
GLOBAL="$(dirname "$(dirname "$(dirname "$(dirname "$(readlink -f "$(command -v dsh)")")")")")"
dsh plugin --profile web add ./plugin
cp "$GLOBAL/@deepseek-ai/dsh-client-connection/lib/client.js" plugin/lib/client.js
sed -i 's|id: "@deepseek-ai/dsh-client-connection"|id: "dsh-arbitrary-host"|' plugin/lib/client.js

($GLOBAL resolves to .../node_modules for bun/npm symlinked bins; for other layouts set it to the root that contains @deepseek-ai.)

Uninstall

node uninstall.mjs    # idempotent; restores the shipped rows automatically

uninstall.mjs removes the package from the profile's dependencies (dsh plugin ... remove reconciles dsh.profile.bundles), so the shipped web-startup / webserver / web-runtime / connection rows come back on the next boot, and clean the node_modules symlinks / sync marker that the installer created inside the plugin dir. Your source files are never touched.

Usage

dsh web --host 0.0.0.0            # all interfaces
dsh web --host 192.168.1.5        # one LAN address (auto-trusted for /api)
dsh web --host myhost.local --trusted-host myhost.local   # name bind: declare it
dsh web --port 0                  # OS-assigned port (unchanged)

Reverse proxy: forward the original Host (bare hostname) and set X-Forwarded-Proto: https, and start dsh with --trusted-host <public-hostname> — otherwise the /api browser-trust fence rejects the proxied hostname (transport failure ... HTTP 403). The dsh-arbitrary-host-connection row also relaxes the loopback-only configuration plane (settings.*, credentials.*, agentPreset.*, ...) to declared trusted authorities; un-declared hosts remain 403.

LAN access caveat: the plain-HTTP LAN origin shows the 403 guidance page by design (see frontend-static.js).

License

MIT — see LICENSE in this directory.