The plugin mutates objects the harness owns, and a reload gives those
mutations two authors. The harness does not promise an ordering between
them: in practice a successor applies before its predecessor's cleanup
runs, and a plugin switched off by editing its patch row does not always
have its disposers run at all.
lib/handover.js makes both cases safe:
- replacements are recorded through a carrier keyed on the target and
stamped with the owning generation, so a cleanup whose owner is no
longer current returns without touching anything;
- the connection replacements answer through a liveness check on the
plugin's own fiber and delegate to the true original once it is gone,
so a plugin switched off without its disposers closes rather than
leaving the gateway open;
- the interceptor is tagged, and a new one retires any predecessor
bearing the tag before prepending itself.
Losing webServer.server or the route registry is now a warning naming the
lost capability rather than a failed fiber.
Renamed from dsh-disable-untrusted-origin; the package name, the plugin
id, the manifest route, and the injection markers all follow.
dsh-trustworthy
Removes the untrusted-origin restriction from DeepSeek Harness. Every browser window is treated as if it were running on the harness machine itself: no process token, no declared trusted host, and no per-plugin origin fence, because one interceptor answers every request with the loopback authority.
A window that reaches the harness by a LAN address, a DNS name, or a tunnel
authenticates normally and then fails every mutating request with
403 {"error":"untrusted origin"}. Cards render, but Install, Update, toggle,
and "Update all" do nothing that the user can see. This plugin removes the
distinction between a local window and a remote one — with no token, no
--trusted-host, no per-plugin configuration, and no second listener on a
second port.
⚠️ Read this before enabling it
This plugin is a deliberate and total removal of the harness's request-identity fence. It is not a bug fix and it is not a workaround for one specific plugin.
The harness fences requests by their Host and Origin headers for one
reason: those are the only headers a page on another origin cannot forge, so
they are what stops a web page you happen to visit from driving your agent API
while the harness is reachable. With this plugin enabled:
- DNS rebinding against the harness API is no longer defended.
- A page on any other origin can issue mutating requests to the API.
- Those requests can run commands on your machine through the agent.
With disableAuth: true (the default) there is no session check either, so
anyone who can reach the port has full control of the agent, including code
execution. Reachability becomes the only access control.
That is a reasonable posture when the port is behind something that
authenticates on your behalf, or when it is bound to a network you fully
control. It is a poor posture for a 0.0.0.0 bind fronted by a public tunnel
with no authentication of its own. Decide accordingly, and re-read this
section if you change how the port is exposed.
If you want the errors gone but not the exposure, set disableAuth: false.
That keeps the process token as the access control while still answering the
identity fence, which is what actually produces the 403.
What it changes
Four things, each one reachable only by a different kind of fence.
1. Request identity, at the server. The plugin installs request and
upgrade listeners with prependListener on the raw node:http server the
webserver owns, ahead of the handler that server was constructed with. Every
connection is then answered with the loopback authority and a matching
Origin. Because the rewrite happens before any route is looked up, it covers
the core /api gateway and every plugin that fences on Host — whatever
each one's implementation looks like, and however old it is. That includes
plugin versions published before the harness grew a declared-authority list,
which cannot be fixed by configuration at all.
2. The browser-side isLoopback flag. @deepseek-ai/dsh-client-connection
computes isLoopback in the browser as
transport?.ownsHost === true || isLoopbackHostname(pageLocation.hostname).
The hostname half stays false for any window that arrived by a LAN address or a
tunnel, which drops the settings and model surfaces into in-memory fallback and
leaves their cards empty. The plugin publishes the same ownsHost marker the
packaged Desktop build publishes, using the harness's own
webserver/index-inject channel — the same mechanism
@deepseek-ai/dsh-web-app uses for __DSH_CONNECTION_RECOVERY__.
3. The paired-device service, for plugins that authorize an identity rather
than an origin. Some plugins accept a request on a loopback peer or on a
live paired device, and read the second path through a service called
remoteWebUiPairing rather than through any header — so neither the rewrite nor
the browser grant reaches them. The plugin provides that service. See
grantPairing below.
4. Browser APIs a non-secure page is denied. crypto.randomUUID and
navigator.clipboard exist only in a secure context, so on a plain-HTTP LAN
address they are simply absent, and code that assumes them fails — sometimes
loudly, sometimes as a button that does nothing. The plugin ships shims for both,
plus the metadata that makes the page installable on a phone. These are unrelated
to request identity and are here so that one plugin covers the whole "I want to
reach this from another machine" problem. See shimClipboard below.
It deliberately does not touch sec-fetch-site. The harness refuses
cross-site outright, and that refusal is the last judgement a browser makes
on its own behalf. Rewriting it would make a page on another origin look
exactly like a window on this machine.
Install
# from a checkout or tarball on disk
dsh plugin --profile web add file:/path/to/dsh-trustworthy
# or from a published package
dsh plugin --profile web add dsh-trustworthy
Then restart the host so the new bundle is loaded.
The bundle patch inserts the row automatically. Verify it loaded:
dsh plugin --profile web list | grep trustworthy
Configuration
Override any key in your profile's cordis.patch.yml:
- id: trustworthy
config:
enabled: true
disableAuth: true
forceClientLoopback: true
grantPairing: true
spoofSocketAddress: false
shimClipboard: true
shimRandomUuid: true
shimPwa: true
| Key | Default | Effect |
|---|---|---|
enabled |
true |
Master switch. false loads the plugin and changes nothing. |
disableAuth |
true |
Also removes the browser-session check, so no process token is needed. This is the one that removes access control. |
forceClientLoopback |
true |
Grants the browser-side isLoopback flag so the settings and model surfaces act as they do on the host. |
grantPairing |
true |
Provides the paired-device service that plugins like the Skill Center accept instead of a loopback peer. See below. |
spoofSocketAddress |
false |
Also reports the peer address as loopback, for plugins with no pairing path. See below. |
shimClipboard |
true |
Restores navigator.clipboard.writeText on plain HTTP. See below. |
shimRandomUuid |
true |
Restores crypto.randomUUID on plain HTTP. See below. |
shimPwa |
true |
Serves a manifest and adds the installable-app metadata. See below. |
grantPairing — the Skill Center
Some plugins authorize a request either by a loopback peer or by a live paired
device, and they read that second path through a service named
remoteWebUiPairing rather than through any header. Providing the service is
the seam those plugins designed for this situation, and it is what makes
@linxin666/dsh-client-ui-skill-explorer work from a LAN address or a tunnel.
It also repairs a defect in that plugin. It reads the service as
ctx.get("remoteWebUiPairing", false), intending false as a default, but
Cordis's signature is get(name, strict) — so false is read as the strict
flag, the call returns undefined, and evaluation falls through to
ctx.remoteWebUiPairing, which Cordis answers by throwing for a service
nobody provided. The plugin's own 403 forbidden: loopback-only therefore
never reaches the client; the webserver turns the throw into an opaque
400. Because the throw sits behind the loopback test, it happens only for a
remote window — which is why the Skill Center appears to work on the host and
fails everywhere else.
Set grantPairing: false if you install @linxin666/dsh-remote-web-ui. It
provides the same service, and two providers for one name is a load error. The
plugin detects an existing provider and defers to it, but the reverse order
cannot be detected, so leave one of them off.
spoofSocketAddress
For plugins that fence on request.socket.remoteAddress and offer no
paired-device path, no header rewrite can reach them and no service can satisfy
them; the plugin has to report the peer address as loopback.
The socket is reused across keep-alive requests, so this is a property of the
connection, not the request, and it is a lie every consumer of the peer
address will believe: a reverse-proxy plugin's client-IP attribution, an access
log, or a ban list. If you run @goodandready/dsh-lanmode, leave this off —
its device registry, per-device revocation, and IP bans all read the peer
address, and this makes them see every client as the same machine. Prefer
grantPairing where a plugin supports it.
shimClipboard, shimRandomUuid, shimPwa — the browser side
These have nothing to do with request identity. They are here because a page
served over plain HTTP from anything but localhost is not a secure
context, and the browser withholds two APIs from it:
| Withheld | Consequence | Restored by |
|---|---|---|
crypto.randomUUID |
code that mints IDs throws on load | shimRandomUuid |
navigator.clipboard |
every Copy button fails silently | shimClipboard |
The UUID is minted from crypto.getRandomValues, which is not restricted to
secure contexts; the clipboard falls back to a hidden textarea and
document.execCommand('copy'). shimPwa additionally serves a manifest at
/dsh-trustworthy/manifest.webmanifest, links it, and widens the
viewport tag to viewport-fit=cover so Add to Home Screen produces a
standalone app rather than a bookmark.
The harness's own code no longer needs either shim — @deepseek-ai/dsh-util-crypto
mints UUIDs from getRandomValues, and its lint rules point callers there — but
third-party plugin bundles are not obliged to, and one that shipped recently
(billion-context) calls navigator.clipboard.writeText unguarded. All
three are true by default; serving over HTTPS makes the first two redundant.
Compatibility
Verified against DSH 0.1.7-rc.2. The plugin depends on three things that are internal rather than public API, so a harness release that renames any of them will break it:
| Depends on | Where |
|---|---|
webServer.server — the raw node:http server |
@deepseek-ai/dsh-host-webserver |
webServer.register / webServer.tapIndex — the route registry and index transforms |
@deepseek-ai/dsh-host-webserver |
connection.requestRejection / authorizeIndex / authenticatedUrl |
@deepseek-ai/dsh-client-connection |
the webserver/index-inject channel and the ownsHost marker |
webserver + connection client |
Every one of those is probed before use. If a release reshapes the surface, the plugin logs which capability it lost and carries on with the rest, rather than failing the fiber and taking the parts that still work down with it. Losing the raw server is the one that matters — it is reported as such:
trustworthy: the webserver exposes no raw server, so the request-identity
interceptor was not installed and a Host fence will still refuse a remote
window; the harness surface may have changed
Why it never writes the trusted-host lists
The obvious fix for this problem is to add the arriving host to
connection.trustedHosts. This plugin deliberately does not, for two reasons.
The harness assembles two trusted-host arrays — one on webRuntime, one on
connection — and they are separate objects cloned at assembly time. A host
declared into one is invisible to the other, so half the fix silently does
nothing. More importantly, a fence that compares against a literal loopback
value rather than against the list is not moved by any declaration at all.
Plugin markets published before the list existed hardcode exactly that check, and
they are the ones producing the 403 users actually see. Rewriting the request
identity satisfies every fence, of every shape and every age; a list entry
satisfies only the fences that read the list.
Reload safety
The plugin mutates objects the harness owns, and a reload gives those mutations two authors. The harness does not promise an ordering between them — in practice the successor applies before the predecessor's cleanup runs — and it does not always run disposers at all when a plugin is switched off by editing its patch row.
Three guards follow from that, and each has a test:
- Replacements are recorded through a handover carrier keyed on the target
itself, with the installing generation stamped as its owner. A cleanup whose
owner is no longer current returns without touching anything, so a
predecessor cannot restore the fence underneath a live successor. The carrier
key is
Symbol.for('dsh-trustworthy.handover')— a registry symbol, not a module-local one, so a reload that re-imports this file still shares it. - The service methods answer through a liveness check on the plugin's own fiber. If the fiber is gone the replacement stops admitting and delegates to the true original instead, read from the carrier rather than captured at install time. A plugin switched off without its disposers therefore closes rather than leaving the gateway open — the failure mode is a working fence, not a silent hole.
- The interceptor is tagged
Symbol.for('dsh-trustworthy.interceptor'), and a new one retires any predecessor bearing the tag before prepending itself. Two live interceptors would both run, and the older one is a closure over a dead generation's configuration — it would keep spoofing the peer address afterspoofSocketAddresswas turned off.
Known limits
sec-fetch-site: cross-siteis refused unconditionally byisTrustedApiRequest, before theOrigincheck and regardless of the trusted-host list. No header rewrite can defeat it, and this plugin does not try. It is inert with the defaultdisableAuth: true, because the gateway never reaches the fence. It is the one thing that can still refuse a request withdisableAuth: false.- Rewriting
Hostchanges the cookie authority the harness derives from it (dsh-auth-<sha256(authority)>). WithdisableAuth: falsethat invalidates sessions minted under the hostname you used before — one re-login — and afterwards a single login covers every entry domain instead of one per domain. Inert withdisableAuth: true. Refereris read by no layer of the harness, core or plugin. It is rewritten only for third-party code that might read it.
Relationship to @goodandready/dsh-lanmode
lanmode approaches the same problem from outside: it opens a second listener
on its own port and proxies to the harness loopback port, rewriting Host and
Origin on the way through. That indirection is why it never needed to touch
harness internals — and why every request that reached the harness arrived as
loopback and passed every fence.
It has three properties worth knowing before you run it alongside this plugin:
- In
mode: autoit opens no listener at all when something already answers on the harness port — and a0.0.0.0bind looks exactly like that from the inside. Its dedicated port then simply never opens, while its health page still reportsmode: proxyand a list of active shims. - Its settings shim patches a line out of the built client bundle. That line no
longer exists in 0.1.7-rc.2, so the shim is a silent no-op in both 0.8.3
and 0.8.7.
forceClientLoopbackhere replaces it through a supported channel instead of a string match. - Its
crypto.randomUUIDand clipboard shims are the same ones this plugin now ships, and its PWA work isshimPwa.
So the two overlap almost entirely. Running both is redundant, and
spoofSocketAddress in particular will confuse lanmode's device tracking — but
neither will break the other if you do.
shimClipboard, shimRandomUuid, and shimPwa exist so that lanmode is not
needed for the browser side either; with them on, the only things it still owns
are its mobile navigation UI, its QR tool, and its device registry.
Relationship to @mzzsfy/dsh-auto-trust-all
The other published approach to this problem, and a good one — it solves it by
declaring the arriving host, wrapping the webserver's route-registration
methods and adding each Host it sees to both trusted-host lists, under an LRU
cap.
The difference is reach. A declaration only helps a fence that consults the list, and the fences users actually hit do not: they compare against a literal loopback value, in versions published before the list existed. Declaring the host leaves those untouched; rewriting the request identity satisfies them. This plugin also has nothing to cap, because it declares nothing.
The reload guards here — a generation-owned carrier, identity-checked teardown,
per-request liveness, and a tagged interceptor that retires its predecessor —
come from the same problem being solved there. That project's convention is also
worth stating, because it predicts what is coming: its own review standard
declares any audit finding of "missing Host validation / DNS-rebinding defence
/ token validation" invalid in its packages. Third-party loopback-only
fences are therefore a deliberate ecosystem position, not an oversight, and more
of them will appear. sec-fetch-site and the cookie authority above are the two
limits inherited from the harness side.
What it does not fix
- Anything gating on TLS state, or on a proxy's own authentication.
- A plugin that both fences on the peer address and offers no pairing path —
enable
spoofSocketAddressfor that, with the caveat above. - Whether a remote window is allowed is separate from whether it is safe. This plugin answers the first question only; see the warning at the top.
- lanmode's mobile navigation UI, its QR tool, and its device registry. Those are mobile conveniences rather than capabilities, and none of them is reproduced here.
Verification
node test/fence.test.mjs # drives real requests past the real plugin-market fence
node test/apply.test.mjs # wiring, including that unload restores every original
node test/handover.test.mjs # the reload orderings: predecessor vs. successor, liveness
node test/shims.test.mjs # executes the generated shim in a context without the APIs
node scripts/live-probe.mjs # against the running harness, over a non-loopback peer
test/fence.test.mjs loads sameOrigin() from the installed dshmarket when a
profile is present and says which source it used, so a run against a bundled
copy is never mistaken for a run against the real thing. test/shims.test.mjs
evaluates the shim source in a node:vm context that lacks crypto.randomUUID
and navigator.clipboard, so it checks the delivered code rather than the string
that delivers it. test/handover.test.mjs exercises the orderings directly,
including the one the harness does not promise — a predecessor's cleanup running
after its successor installed. The suites need no dependency tree beyond a
resolvable @deepseek-ai/schemastery for the second.
scripts/live-probe.mjs is the one that matters after a change: it connects to a
non-loopback local address, because the Skill Center fences on the socket
address as well as the Host, so a probe over 127.0.0.1 can never reproduce a
remote client and always looks like it passes.
License
MIT