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.
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.admitreturns the owner's own peer for every request,authorizeIndexadmits the index, andauthenticatedUrlstops 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 shows401until 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 theHoston — 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.
webRuntimeis gone.dsh-web-appno longer provides it, no package provides it, and a plugin that injects it is heldpending(the boot-log line quoted underprovideWebRuntime).connection.trustedHostsis not gone but changed source: the web bundle's patch now sets it fromwebStartup.trustedHosts(the--trusted-hostvalues), andisBindAddressAuthorityacceptswebServer.hostalongside it. It stays a plainstring[]this plugin can read per request, which is what the snapshot is rebuilt from.webServer.hostis 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.serverandwebServer.registerstill 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-loopbackHost(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 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 only guards requests a browser itself marks as cross-site, so it has no effect on normal navigation or on the WebSocket. WithdisableAuth: true(the default) the gate that calls it is replaced, so it does not run either.- Rewriting
Hostdecides the cookie authority the harness derives from it (dsh-auth-<sha256(authority)>). WithdisableAuth: falsethe 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--portmints 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. 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 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