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:
dsh-web-app/lib/startup.js— rejects--host 0.0.0.0outright (usage error).dsh-host-webserver/lib/index.js— thewebServerconfig schema only accepts the literals127.0.0.1or0.0.0.0, so no other host (e.g. a concrete LAN IP) can be configured.dsh-web-app/lib/index.js(resolveLanTrust) — the/apibrowser-trust fence auto-derives trusted authorities only for the0.0.0.0bind.
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
dshupdates 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.jssubclasses the shippedWebServer(only thehostschema is widened),frontend-static.jsreuses the shippedserveStatic, andglue.jsreuses the shippedwebAppInternals.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'snode_modulessymlink (below) must point at the current global install.
The changes
plugin/lib/startup.js— shipped startup minus the--host 0.0.0.0rejection (only the non-numeric--portcheck remains).plugin/lib/webserver.js—WebServersubclass withhost: z.string().required()(any host string reachesserver.listen).plugin/lib/glue.js— web-runtime glue mounting the gatedfrontend-static, withresolveLanTrustalso deriving a concrete non-loopback IP-literal bind host as a trusted/apiauthority. Hostname binds stay caller-declared (--trusted-host); the fence's loopback and cross-site checks are untouched.plugin/lib/frontend-static.js— secure-context gate: the Web shell depends oncrypto.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" throwscrypto.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: httpspasses the gate), or a browser "insecure origins treated as secure" flag./apiand/pluginsroutes are untouched, so scripts/curl keep working.plugin/lib/connection.js— connection row replacement (row namedsh-arbitrary-host)- The shipped
@deepseek-ai/dsh-client-connectionpins a set of methods (settings.*,credentials.*,agentPreset.*,host.pickDirectory,host.openPath,llm.discoverModels) to loopback only by design. Over a reverse-proxy domain these returnedtransport 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 configprivilegedMethods(default[]= none privileged). - With
privilegedMethods: []every method still passes the outer DNS-rebinding fence (trustedHosts), so only loopback or a declared--trusted-hostauthority reaches the configuration plane; un-declared hosts stay 403 (verified). Relaxing the plane is the deployment's deliberate choice —trustedHostsis explicitly not authentication. Re-lock specific methods by listing them inprivilegedMethodsincordis.patch.yml.
- The shipped
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):
DSH_GLOBAL_NODE_MODULES, if set;- the deployment the
dshbinary onPATHresolves to — realpath the binary, then walk up probing both each directory and itsnode_modules(the second probe finds package-own dependency trees, e.g. the.../@deepseek-ai/dsh/node_moduleslayout mise/pnpm produce); - the global root of the node running the installer
(
dirname(process.execPath)/../lib/node_modulesand friends), which covers mise / nvm / npm layouts even whendshis not onPATH; - 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.