Miles Ward b8001cbebd 0.5.1: validated on dsh 0.2.2-alpha.2
No code change from 0.5.0. The 0.2.2-alpha.2 review found the allow path's
bundles byte-identical, the webserver's new resource route kind orthogonal to
the interceptor, and the frontend static bundle's resource rewrite still
emitting webserver/index-inject. Full probe matrix (11/11) and Files pane
checks pass against a sandbox of the published release; README records the
new dsh web --host wildcard refusal.
2026-10-28 11:06:21 -04:00
2026-09-27 05:28:18 -04:00

dsh-trustworthy

Removes the untrusted-origin restriction from DeepSeek Harness, and the launch-token prompt with it. Every browser window is treated as if it were running on the harness machine itself: no declared trusted host, no per-plugin origin fence, and no process token to paste into a phone — one interceptor answers every request with the loopback authority, and the session check is off by default.

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 declared trusted host, no --trusted-host, and no per-plugin configuration. The launch-token check is removed as well; disableAuth: false puts it back.


⚠️ Read this before enabling it

This plugin is a deliberate and total removal of the harness's request-identity fence, and by default of its browser-session check too. 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.

And with the default disableAuth: true there is no session check either: anyone who can reach the port has full control of the agent, including code execution. Reachability is the only access control.

That posture is reasonable 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. Set disableAuth: false to restore the launch token as a second line of defence.

The launch token, and the disableAuth switch

The Web UI prints a one-time launch token in the process log — dsh web: http://…/?token=… — and from 0.2.1-alpha.4 the container entrypoint writes that line into the Docker log as well. The token rotates on every restart. With the check enabled, every new device or origin must open that URL once; the harness exchanges the token for a 30-day cookie bound to the authority it was minted under, and until then every page answers 401.

0.4.0 made that check the default, on the argument that one exchange covers every domain because the plugin rewrites Host to the same loopback authority the cookie is bound to. In practice it still means producing a token on each new device, which is the friction this plugin exists to remove — 0.5.0 therefore restores disableAuth: true as the default. Set disableAuth: false to get the check, and the cookie, back.


What it changes

Five things, each one reachable only by a different kind of fence. None of them touches the browser-session check; removing that is the separate disableAuth key described at the end of this section.

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.

5. The webRuntime service, for plugins that never get to fence at all. A plugin whose injection list names a service nobody provides is held pending by Cordis: its routes are never registered, and the URLs its pane calls answer 405 from the core static handler. Releases from 0.2.1-alpha.2 stopped providing webRuntime, which is how the sidebar's Files pane started failing to preview or download anything. The plugin provides it — when nothing else has — built from the trust data the harness still holds. See provideWebRuntime 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

Installing so that the market can update it

The plugin market decides whether a git-sourced install has an update by resolving the ref the installed spec names — checkUpdates() in dshmarket calls resolveGitRemoteHead(spec, gitRefOfTarget(spec)). A spec pinned to a tag therefore reports "up to date" for ever, however many releases are published: the check resolves tag v0.2.0 and compares it with the very commit it already installed. Publishing a new tag does not make a button appear.

To receive in-app updates, install from the branch:

dsh plugin --profile web add 'git+https://gitea.milesward.dev/mward4/dsh-trustworthy.git#main'

Releases are still tagged (v0.2.1, v0.2.0, …) so you can pin to one deliberately and move by hand; the branch ref is what makes the market's Update button work. Applying an update leaves the spec alone — gitUpdateTarget() strips a fragment only when it is a 40-hex commit pin — so it stays on the branch and keeps following it.

dshmarket caches its update verdicts in memory for 30 minutes; the market page forces a fresh check when it opens, and GET /dsh-market/updates?force=1 does the same on demand.


Configuration

Override any key in your profile's cordis.patch.yml:

- id: trustworthy
  config:
    enabled: true
    disableAuth: true
    forceClientLoopback: true
    grantPairing: true
    provideWebRuntime: 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 launch token and no per-device pairing are needed. This is the default: reachability is the only access control. Set false to keep the token — exchange it once and the 30-day cookie covers every rewritten request.
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.
provideWebRuntime true Restores the webRuntime trust snapshot the harness stopped publishing in 0.2.1-alpha.2, so plugins that inject it (the sidebar) mount instead of answering 405. 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.

disableAuth — the two postures

Every key above only answers fences. disableAuth is the one that changes who may talk to the harness at all.

  • true (default). The session check is replaced too: connection.admit returns the owner's own peer for every request, authorizeIndex admits the index, and authenticatedUrl stops appending the token to the printed URL. No token, no cookie, no login, nothing to type on a phone — and anyone who reaches the port controls the agent. Use it behind an authenticating proxy, on a network that is already trusted, or when the port is otherwise unreachable from untrusted networks.
  • false. The request-identity fence is still rewritten away, and the browser-session check is left exactly as the harness built it. A new browser shows 401 until the launch URL from the process log is opened once; that exchange sets the 30-day cookie, and every later request — including the ones this plugin rewrote the Host on — carries it. Each new device still has to be given the token, which is why it is not the default.

The implementation replaces admit because that is where every shape of the gate ends up: releases up to 0.2.1-alpha.2 call requestRejection from inside admit and wrap its answer, while 0.2.1-alpha.3 and later apply the fence and session check in admit itself. requestRejection is replaced as well so the older shape cannot short-circuit around the newer one, and every replacement is liveness-aware, so a host that switches the plugin off without running its disposers gets the real gate back.

provideWebRuntime — the Files pane, and HTTP 405

0.2.1-alpha.2 stopped providing a service named webRuntime. Its value was the LAN-trust snapshot — resolveLanTrust(ctx.webServer.host, trustedHosts), that is { lanAddresses, trustedHosts } — and third-party plugins inject it to learn which authorities their own copy of the request fence should accept:

export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
const fence = (req) => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)

Cordis holds a row whose injection is unsatisfied in pending: the module loads, its inject list is read, and no apply runs — so none of its routes are registered, and the boot log names it:

dsh: warning: 1 entry did not activate
better-sidebar (dsh-better-sidebar): pending (waiting for service: webRuntime)

That 405 is the whole of the "opening a file stopped working" symptom: the routes are not refusing the request, they do not exist. The core static handler answers every unmounted POST with an empty 405, while the plugin's own paths answer 404 — measured over a non-loopback Host, on one profile, with the sidebar's own Files requests:

Request provideWebRuntime: false provideWebRuntime: true
POST /sidebar/api/fs.tree 405, 0 bytes 200, {"ok":true,…,"entries":[…]}
POST /sidebar/api/fs.read 405, 0 bytes 200, {"ok":true,"value":{"kind":"text",…}}
GET /sidebar/file?sessionId=…&path=… 404 200 application/octet-stream
GET /sidebar/file?…&download=1 404 200, content-disposition: attachment

The same release moved --trusted-host authorities into connection.trustedHosts (it is trustedHosts: !!js ctx.webStartup.trustedHosts in the web bundle's patch), which is what the /api fence reads. So the service can be rebuilt from what the harness still has, which is what this key does: it provides webRuntime — when nothing else has — with trustedHosts read live from connection rather than cloned at assembly time, plus the listener's own bind address, which the core fence accepts separately (isBindAddressAuthority) and a two-argument plugin fence otherwise cannot. lanAddresses is [], which is what alpha.1 returned for every bind alpha.2 permits.

This does not weaken anything by itself. It is not a declaration of the arriving authority, and it is inert on the request path whenever the interceptor above is installed: every request already carries the loopback authority by the time any fence reads it. It exists so that a plugin mounts at all.

Set provideWebRuntime: false to leave those plugins unmounted — for instance on a harness that removed the service deliberately and where you would rather run plugins without it than republish an interface the harness retired.

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.

Note that the harness frontend already ships a manifest — a static manifest.webmanifest with display: "fullscreen", linked relatively from its own index.html and served from the fallback seat. So a page with shimPwa on carries two rel="manifest" links. That is harmless but worth knowing: the browser takes the first link in document order, and index injections land at the top of <head>, so this plugin's manifest is the one that wins — reporting display: "standalone", which keeps the status bar that fullscreen hides. Nothing is string-patched to achieve it; the added link simply precedes the shipped one.

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, 0.2.0-rc.1, 0.2.1-alpha.1, 0.2.1-alpha.2, 0.2.1-alpha.4, 0.2.1-rc.1, and 0.2.2-alpha.2. From 0.4.0 the manifest also declares the range:

"engines": { "dsh": ">=0.1.7-rc.2 <0.3.0-0" }

0.2.1-alpha.4 made engines.dsh the compatibility declaration a plugin should provide — the harness uses it, when present, in place of guessing from @deepseek-ai/dsh-* peer ranges. This plugin has no dsh peers at all, so the declaration is the only statement of support it makes, and it is bounded above at the next minor release, excluding its prereleases, rather than left open.

0.2.1-rc.2 is not listed separately because all four bundles the plugin hooks are byte-identical between rc.1 and rc.2. 0.2.2-alpha.2 is listed: it is the first release since 0.2.1-rc.1 that moves a hooked bundle, so it was re-checked live rather than inferred. Its dsh-client-connection and dsh-web-app are still byte-identical to 0.2.1-rc.2; dsh-host-webserver and dsh-host-frontend-static changed in ways that leave the surfaces used here intact, and dsh-session's stricter publication does not touch this plugin.

The plugin depends on several 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.host — the listener's own bind address @deepseek-ai/dsh-host-webserver
webServer.register / webServer.tapIndex — the route registry and index transforms @deepseek-ai/dsh-host-webserver
connection.trustedHosts — the authorities the /api fence reads @deepseek-ai/dsh-client-connection
connection.admit — the fence and session check, alone from 0.2.1-alpha.3 @deepseek-ai/dsh-client-connection
connection.requestRejection / authorizeIndex / authenticatedUrl @deepseek-ai/dsh-client-connection
the webserver/index-inject channel and the ownsHost marker webserver + connection client

The only one used conditionally is connection.admit: the bypass path replaces it when disableAuth: true (the default). With disableAuth: false the plugin does not touch it at all.

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

What was checked on 0.2.2-alpha.2

The first release since 0.2.1-rc.1 that moves a bundle this plugin hooks, so it was re-checked end to end instead of inferred from byte-identity.

What moved. dsh-client-connection and dsh-web-app are still byte-identical to 0.2.1-rc.2, so the allow path — admit, trustedHosts, requestRejection, authorizeIndex, authenticatedUrl — is untouched. The two surfaces that did move are dsh-host-webserver and dsh-host-frontend-static. The webserver gained a second route kind ({ resource }: a Fetch-API Request → Response handler, plus a readResource variant) beside the plain { handler } one; server, register, tapIndex, host, port, and protocol are unchanged, and the interceptor wraps the raw server's request emission before routing, so a route's kind does not matter to it. The frontend static bundle still authorizes the index through connection.authorizeIndex(request, shim) and still emits webserver/index-inject, so the browser grant, the shims, the viewport, and the PWA route inject exactly as before. dsh-session tightened store-owned publication (cannot append outside a SessionStore), which this plugin never does. The CLI's new optional bundles do not change a profile's explicit bundle list, so nothing new is inserted next to this one.

Measured on a sandbox of the published @deepseek-ai/[email protected] (the npm alpha tag; latest still points at 0.2.1-rc.2), with a copy of a real profile, over a non-loopback peer:

Check Result
interceptor answers GET / for a foreign Host 200 index, no cookie
WebSocket upgrade for a foreign Host 101
/api/* over the non-loopback peer and over a named host 200, not the 400 trust fence
browser grant, shims, viewport, and PWA markers in the served index all present
PWA manifest 200 application/manifest+json
Files pane over a foreign Host — fs.tree, fs.read, GET /sidebar/file download 200, and the downloaded bytes are the file's
dsh plugin load no skip line, no missing-surface warning

node scripts/live-probe.mjs passes 11/11 against that sandbox, including the sidebar Files API row. A control run with the bundle disabled answers 401 to GET / — the token gate stands — so a remote browser never gets as far as the pane; that is the difference the bypass makes. The installed sidebar bundle (0.28.0) no longer injects webRuntime at all, so on this harness its Files routes register whether or not the service is provided; provideWebRuntime stays for bundles that still inject it (0.26 and older), where the missing service is what left the pane answering 405.

One harness deployment change to know about: since DSH 0.2.1-alpha.2, dsh web --host refuses a wildcard address (0.0.0.0, ::) — "is an unspecified (wildcard) address, which is not supported: binding every interface would expose remote code execution to the network; bind one concrete IPv4 or IPv6 address instead" — so a container that bound wildcard must pass its concrete container IP. The bind address is still accepted as an authority by the trust snapshot, so provideWebRuntime and the no---trusted-host posture keep working.

What was checked on 0.2.1-rc.1

This is the release the 0.4.0 change answers. Two things moved.

The gate moved into admit. requestRejection is now a façade over admit, and the /api route and the WebSocket upgrade call admit directly. A plugin that only answered requestRejection — which is all 0.3.0 did when disableAuth: true — left the index reachable and every WebSocket refused with 401, so the page loaded and then showed a disconnected shell. The bypass path now replaces admit itself and returns the owner's peer (browserPeer on this release, operator before alpha.3); measured on a sandboxed rc.1 with a non-loopback Host, the upgrade answered 101 with no cookie.

The session check, when enabled, survives the rewrite. The plugin rewrites Host/Origin to 127.0.0.1:<port>, which is also the authority the harness mints the session cookie for. Measured on a sandbox, with disableAuth: false, in order:

Request (Host: ai.milesward.dev) Result
GET / with no cookie 401
GET /?token=<launch token> 303, location: ./, Set-Cookie: dsh-auth-…
GET / with that cookie 200 index
Upgrade /api/remote.mux with that cookie 101

The cookie payload's authority is 127.0.0.1:<port> — the rewritten one — so the browser keeps the cookie for the public origin it opened, and the plugin's rewrite presents the matching authority on every request afterwards. This is what lets the 30-day session survive a Host rewrite, and it is why disableAuth: false is a viable posture for anyone who would rather keep the check.

The token rotates whenever the process restarts (dsh web: …?token=… in the process log; the container entrypoint prints that line into the Docker log from 0.2.1-alpha.4). The safety property the old bypass traded away is exactly this one: after disableAuth: true the printed URL has no token and every session is unauthenticated.

Also verified on this release: webServer.server / register / tapIndex and the webserver/index-inject channel are unchanged, so the interceptor, the browser grant, the shims, and the PWA route need no adjustment. The dsh-client-connection, dsh-host-webserver, dsh-web-app, and dsh-host-frontend-static bundles are byte-identical between 0.2.1-rc.1 and 0.2.1-rc.2.

What was checked on 0.2.1-alpha.2

Everything in the table above is still present and still of the shape this plugin reaches for — with one removal, which is what 0.3.0 answers.

  • webRuntime is gone. dsh-web-app no longer provides it, no package provides it, and a plugin that injects it is held pending (the boot-log line quoted under provideWebRuntime).
  • connection.trustedHosts is not gone but changed source: the web bundle's patch now sets it from webStartup.trustedHosts (the --trusted-host values), and isBindAddressAuthority accepts webServer.host alongside it. It stays a plain string[] this plugin can read per request, which is what the snapshot is rebuilt from.
  • webServer.host is a config value, not a socket fact: it is the concrete IPv4/IPv6 literal passed to --host, and the setter now rejects a wildcard ("0.0.0.0" is an unspecified (wildcard) address, which is not supported).
  • webServer.server and webServer.register still exist, so the interceptor and the shims are unaffected. That the interceptor still works is visible in the table's right-hand column: every one of those requests arrived with a non-loopback Host (172.17.0.4:3104) and still reached the plugin's own fence, which only a rewritten request identity can do on an undeclared authority.

Not re-measured on alpha.2: the seven-point fence matrix from alpha.1 below, and the --help/peer-gate notes. The alpha.1 note is kept because it is still what the fence behaviour rests on, and because the resolveLanTrust details in it describe the value this plugin now rebuilds.

What was checked on 0.2.1-alpha.1

The three packages this plugin reaches into are byte-identical between 0.2.0-rc.1 and 0.2.1-alpha.1 — dsh-host-webserver/lib/index.js (48605e6f4fc88362c07569686df1417b), dsh-client-connection/lib/index.js (39d2d6d64f3ffb91ec623932a15a068b), and dsh-settings/lib/index.js (5024d014ad19237fb91fbf8bb4370903) — so nothing in the table above has moved, ownsHost is still read by the client bundle, and the webserver/index-inject channel is still emitted and still typed.

The release does add --public-url, and it is worth being explicit about what it is not: it advertises an HTTP root in the printed URL, the system prompt, and DSH_WEB_URL, and it grants no trust — the CLI's own help says so. The trust surface is a separate function, resolveLanTrust(), and publicUrl never reaches it. --public-url does not replace this plugin.

Measured on 0.2.1-alpha.1 over a non-loopback peer, plugin enabled vs. enabled: false, same harness and same profile: 7/7 with it on, 1/7 with it off. Switched off, the shell answers 401, the Skill Center answers 400 (the fence's own throw), the shims are absent, and the ready URL carries a process token again. That difference is the whole of what this plugin carries.

An in-place upgrade of a real 0.2.0-rc.1 profile to 0.2.1-alpha.1 boots and serves with this plugin installed and no version exemption recorded. Two other plugins are refused by the harness's peer-range gate on that release — @memtensor/memos-local-plugin and @michengai/dsh-btw, both because of an absolute <0.2.0 / … || 0.2.0-rc.2 upper bound in their @deepseek-ai/dsh-* peers. This plugin declares no @deepseek-ai/dsh-* peers at all (peerDependencies is @deepseek-ai/schemastery only), so the gate has nothing to reject it on and it skips nothing at boot.

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 — and that is a narrower statement than it looks, now that provideWebRuntime exists: it publishes a webRuntime value built from that list, and writes nothing into connection.trustedHosts itself.

The reason is the same one that has always applied. A fence that compares against a literal loopback value rather than against a 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. The same argument applies to the restored snapshot: its list is a faithful reproduction of what the harness already believes, not an attempt to talk a fence into accepting something the harness would refuse.

On 0.2.1-alpha.1 there was a second, version-specific reason: the harness assembled two trusted-host arrays — one on webRuntime, one on connection — as separate objects cloned at assembly time, so a host declared into one was invisible to the other and half the fix silently did nothing. 0.2.1-alpha.2 removed the webRuntime array along with the service, leaving connection as the single source, which is what the snapshot reads.

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 only guards requests a browser itself marks as cross-site, so it has no effect on normal navigation or on the WebSocket. With disableAuth: true (the default) the gate that calls it is replaced, so it does not run either.
  • Rewriting Host decides the cookie authority the harness derives from it (dsh-auth-<sha256(authority)>). With disableAuth: false the launch token is exchanged for the rewritten authority, so the resulting 30-day cookie matches every request the plugin intercepts. The authority is the loopback bind (127.0.0.1:<port>), so changing --port mints a new one and requires one new exchange. A session cookie you already hold under the public hostname is not reused; expect one re-login on enabling the plugin.
  • 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 test/web-runtime.test.mjs  # the restored webRuntime value, and when it is provided
node scripts/live-probe.mjs  # against the running harness, over a non-loopback peer

All five suites together — 61 tests (fence 8, apply 22, handover 15, shims 9, web-runtime 7) — are node --test test/*.test.mjs from the plugin directory. The shell has to expand that glob: node --test test/ fails with MODULE_NOT_FOUND on Node 22.

The manifest pins what it supports: engines.dsh is >=0.1.7-rc.2 <0.3.0-0, so a harness from a future minor release skips this plugin instead of loading it against a surface it was never checked on. Bump the range only after re-running the probe matrix above.

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.

Releases

Version What changed
0.5.1 Documentation and compatibility only — no code change from 0.5.0. Records the 0.2.2-alpha.2 review: the allow path's bundles are byte-identical, the webserver's new route kind does not touch the interceptor, and scripts/live-probe.mjs passes 11/11 against a sandbox of the published release (Files pane, PWA, shell, Skill Center, shims). Also notes the new --host wildcard refusal.
0.5.0 Restores disableAuth: true as the default: no launch token and no per-device pairing, which is what the plugin exists to remove. 0.4.0's keep-the-check posture remains available with disableAuth: false. No code change beyond the default and the documentation; validated on DSH 0.2.1-rc.2, where the bypass still replaces connection.admit.
0.4.0 Keeps the browser-session check by default (disableAuth: false) and makes the 0.2.1-alpha.3 launch-token → 30-day-cookie flow survive the Host rewrite, including on a remote origin; the no-check posture moves to disableAuth: true and now replaces connection.admit as well, because the gate moved there. Declares engines.dsh (the 0.2.1-alpha.4 recommendation) and is validated on 0.2.1-alpha.4 and 0.2.1-rc.1.
0.3.0 Provides the webRuntime trust snapshot that 0.2.1-alpha.2 stopped publishing (lib/web-runtime.js, provideWebRuntime), so a plugin that injects it — the sidebar among them — mounts instead of answering 405 on every request. The value is read live from connection.trustedHosts plus the listener's bind address, and an existing provider is left alone.
0.2.1 Documentation and compatibility only — no code change from 0.2.0. Records the 0.2.1-alpha.1 validation above, and how to install so the market can update it.
0.2.0 Renamed from dsh-disable-untrusted-origin. Added lib/handover.js: originals recorded once on a Symbol.for carrier, a liveness check (fiber.uid === null) on every delegated path, and an interceptor that retires a tagged predecessor. Capabilities now degrade with a warning instead of failing the fiber.
0.1.2 Restored crypto.randomUUID and navigator.clipboard on plain HTTP, added the PWA manifest, and widened the viewport for notched screens.
0.1.1 Provided the paired-device service, so the Skill Center works over a remote address instead of answering 400.
0.1.0 First release: one interceptor on the raw server, rewriting request identity to the loopback authority.

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%