mward4 c6ae15f829 Rename to dsh-trustworthy, add a generation handover for 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 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.
2026-09-27 05:59:39 -04:00
2026-09-27 05:28:18 -04:00

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 after spoofSocketAddress was turned off.

Known limits

  • sec-fetch-site: cross-site is refused unconditionally by isTrustedApiRequest, before the Origin check and regardless of the trusted-host list. No header rewrite can defeat it, and this plugin does not try. It is inert with the default disableAuth: true, because the gateway never reaches the fence. It is the one thing that can still refuse a request with disableAuth: false.
  • Rewriting Host changes the cookie authority the harness derives from it (dsh-auth-<sha256(authority)>). With disableAuth: false that 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 with disableAuth: true.
  • Referer is 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: auto it opens no listener at all when something already answers on the harness port — and a 0.0.0.0 bind looks exactly like that from the inside. Its dedicated port then simply never opens, while its health page still reports mode: proxy and 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. forceClientLoopback here replaces it through a supported channel instead of a string match.
  • Its crypto.randomUUID and clipboard shims are the same ones this plugin now ships, and its PWA work is shimPwa.

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 spoofSocketAddress for 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

S
Description
Removes the untrusted-origin restriction from DeepSeek Harness: one interceptor answers every request with the loopback authority.
Readme MIT
297 KiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 100%