7 Commits
Author SHA1 Message Date
mward4 a2700854a9 0.4.0: keep the launch-token session check by default
0.2.1-alpha.3 moved the request gate into `connection.admit`: the `/api`
gateway and the WebSocket upgrade call it directly, and `requestRejection`
became a façade over it. The 0.3.0 bypass only replaced `requestRejection`, so
under `disableAuth: true` the index loaded and every socket upgrade answered
401. Replace `admit` too, returning the peer the harness already owns
(`browserPeer` from alpha.3 on, `operator` before).

The default posture now keeps the harness's browser-session check instead of
removing it. The plugin's Host rewrite to the loopback authority is also the
authority the one-time launch token is exchanged for, so the 30-day cookie is
minted for the rewritten authority and the rewrite presents it on every later
request. Measured on 0.2.1-rc.1, 0.2.1-alpha.4 and 0.2.1-alpha.2: 401 without
the cookie, 303 + Set-Cookie on `/?token=`, 200 index and 101 upgrade with it.

Declare `engines.dsh` (`>=0.1.7-rc.2 <0.3.0-0`) as 0.2.1-alpha.4 now prefers.
The removed session check moves to an opt-in `disableAuth: true`, which still
prints a token-free URL. Tests: 60 pass, including the operator-peer fallback
and restoring the real `admit` when the fiber is disposed.
2026-10-17 06:52:12 -04:00
mward4 61ff536687 v0.3.0: restore the webRuntime service 0.2.1-alpha.2 stopped providing
A plugin whose injection list names a service nobody provides is held pending
by Cordis: the module loads, but the fiber never mounts, so its routes are
never registered and the core static handler answers in their place. That
handler allows GET and HEAD only, so every URL the plugin's own pane calls
comes back as HTTP 405. Releases from 0.2.1-alpha.2 no longer provide
`webRuntime`, which is how the sidebar's Files pane stopped previewing and
downloading anything.

lib/web-runtime.js provides that service — only when nothing else does, so a
deployment that still has the real one (or @linxin666/dsh-remote-web-ui) is
untouched. The value is read live from the trust source this harness actually
has now: `connection.trustedHosts`, plus the listener's bind address, which
the alpha.2 /api fence accepts as a separate case and a plugin carrying the
older two-argument fence cannot see at all. `provideWebRuntime` turns it off.

Verified against the installed 0.2.1-alpha.2, end to end: the same boot with
and without the plugin gives 405 and then 400 on POST /sidebar/api/fs.tree,
the 400 being the sidebar's own handler answering. 59/59 unit tests (new
test/web-runtime.test.mjs), and scripts/live-probe.mjs — extended with the
Files-API check — reports 11/11 on the fixed build and 10/11 with the 405 on
the live one, which is the user-visible regression itself.
2026-10-14 22:49:41 -04:00
mward4 b7fa429b50 v0.2.1: validated against DSH 0.2.1-alpha.1
No code change from 0.2.0. This release records what was measured and how to
install so the plugin market can see future releases.

Compatibility
- Verified against 0.1.7-rc.2, 0.2.0-rc.1 and 0.2.1-alpha.1.
- dsh-host-webserver, dsh-client-connection and dsh-settings are byte-identical
  between 0.2.0-rc.1 and 0.2.1-alpha.1, so no surface this plugin uses moved.
- --public-url advertises a URL in the printed/handoff/system-prompt forms and
  grants no trust; resolveLanTrust() is a separate function it never reaches.
- A/B over a non-loopback peer on 0.2.1-alpha.1: 7/7 enabled, 1/7 disabled.
- This plugin declares no @deepseek-ai/dsh-* peers, so the harness peer-range
  gate has nothing to reject it on; an in-place profile upgrade boots with it
  installed and no exemption recorded.

Install
- The market resolves the ref the installed spec names, so a tag pin reports
  "up to date" for ever. Documented the branch install (#main) that makes the
  in-app Update button work, and that gitUpdateTarget() leaves the spec alone.
2026-10-04 19:11:52 -04:00
mward4 80fb6b6f4f Note that the frontend ships its own manifest and this one precedes it 2026-09-27 19:42:17 -04:00
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
mward4 3e89edf070 v0.1.2: restore the browser APIs a non-secure page is denied
A page served over plain HTTP from anything but localhost is not a
secure context, so crypto.randomUUID and navigator.clipboard are simply
absent. Code that assumes them fails, sometimes loudly and sometimes as
a button that does nothing — billion-context calls
navigator.clipboard.writeText unguarded today.

Fold in what lanmode was still doing for us, so two plugins are not
needed to make one page work:

- shimRandomUuid  mints the UUID from crypto.getRandomValues, which is
                  not restricted to secure contexts
- shimClipboard   falls back to a hidden textarea and execCommand
- shimPwa         serves a manifest, links it, and widens the viewport
                  tag to viewport-fit=cover for standalone installs

All three default on. None of them touches request identity; they live
here so that reaching the harness from another machine is one plugin's
job rather than two.

test/shims.test.mjs evaluates the generated source in a node:vm context
that lacks both APIs, so it checks the delivered code rather than the
string that delivers it. live-probe.mjs now also asserts the shims and
the manifest reach the running harness.
2026-09-27 05:50:31 -04:00
mward4 042f6539e5 Add a live probe for a running harness
Checks the three surfaces the plugin is responsible for against the process
actually serving a browser: the market's fence, the shell without a token,
and the Skill Center.

The Skill Center check connects to a non-loopback local address on purpose.
Its fence requires a loopback socket as well as a loopback Host, so a probe
over 127.0.0.1 can never reproduce a remote client and always appears to
pass.
2026-09-27 05:36:16 -04:00
13 changed files with 2405 additions and 121 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 dsh-disable-untrusted-origin contributors
Copyright (c) 2026 dsh-trustworthy contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+521 -42
View File
@@ -1,13 +1,20 @@
# dsh-disable-untrusted-origin
# dsh-trustworthy
Treat every browser window as if it were running on the DeepSeek Harness host itself.
**Removes the untrusted-origin restriction from DeepSeek Harness without
removing its authentication.** Every browser window is treated as if it were
running on the harness machine itself: no declared trusted host and no
per-plugin origin fence, because one interceptor answers every request with the
loopback authority. The harness's own browser-session check stays in place —
open the one-time launch URL from the process log once, and the 30-day cookie it
exchanges covers the rewritten requests too.
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. No token, no
`--trusted-host`, no per-plugin configuration.
distinction between a local window and a remote one — with no declared trusted
host, no `--trusted-host`, and no per-plugin configuration. Removing the session
check as well (`disableAuth: true`) is available, but it is now opt-in.
---
@@ -15,6 +22,7 @@ distinction between a local window and a remote one. No token, no
**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 session check is a separate matter: it stays unless you ask for it to go.
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
@@ -22,29 +30,55 @@ 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.
- **A page on any other origin** that carries a session cookie 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.
The default posture keeps the browser-session check, so the additional exposure
is limited to a page holding a live session cookie. Set `disableAuth: true` and
there is no check either: **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
That second 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.
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.
### The launch token, and why the default changed
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. Open the URL once on each origin you use, and the harness exchanges it
for a **30-day cookie** bound to the authority it was minted under; until then
every page answers `401`.
The exchange, the cookie, and the authority check are all present in
`0.2.1-alpha.2` and later; what changed is that the bypass (0.3.0 and the
`disableAuth: true` default it shipped) discarded all three. The default now
keeps them.
Because this plugin rewrites `Host` to the loopback authority before any route
looks at it, the cookie is minted for the loopback authority too, and the
rewrite presents that same authority on every request afterwards. One exchange
therefore covers every entry domain, and the token never has to be pasted into
the address bar by hand again. That is why the plugin now keeps the check by
default instead of removing it: the token flow is the access control that makes
an internet-facing port defensible.
If you want the errors gone but not the exposure, leave `disableAuth` at its
default `false`. Only set it `true` when something else already authenticates
callers, and accept that the harness then has no second line of defence.
---
## What it changes
Three things, in three different places, each one reachable only by a different
kind of fence.
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
@@ -73,6 +107,22 @@ live paired device, and read the second path through a service called
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
@@ -84,10 +134,10 @@ exactly like a window on this machine.
```bash
# from a checkout or tarball on disk
dsh plugin --profile web add file:/path/to/dsh-disable-untrusted-origin
dsh plugin --profile web add file:/path/to/dsh-trustworthy
# or from a published package
dsh plugin --profile web add dsh-disable-untrusted-origin
dsh plugin --profile web add dsh-trustworthy
```
Then restart the host so the new bundle is loaded.
@@ -95,9 +145,35 @@ Then restart the host so the new bundle is loaded.
The bundle patch inserts the row automatically. Verify it loaded:
```bash
dsh plugin --profile web list | grep disable-untrusted-origin
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:
```bash
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
@@ -105,21 +181,108 @@ dsh plugin --profile web list | grep disable-untrusted-origin
Override any key in your profile's `cordis.patch.yml`:
```yaml
- id: disable-untrusted-origin
- id: trustworthy
config:
enabled: true
disableAuth: true
disableAuth: false
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 process token is needed. **This is the one that removes access control.** |
| `disableAuth` | `false` | **Also** removes the browser-session check, so no launch token is needed at all. The default keeps it: exchange the token once and the 30-day cookie covers every rewritten request. `true` 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. |
| `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.
- **`false` (default).** The request-identity fence is 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. This is the posture to use on a
port that faces anything you do not fully control.
- **`true`.** 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 — anyone who reaches the port controls the agent. Use it only
behind an authenticating proxy or on a network that is already trusted.
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:
```js
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
@@ -159,36 +322,313 @@ 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**. 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:
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**, and **0.2.1-rc.1**. From 0.4.0 the
manifest also declares the range:
```json
"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 as a
separate version because `dsh-client-connection`, `dsh-host-webserver`,
`dsh-web-app`, and the frontend static bundle are byte-identical between rc.1
and rc.2.
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 |
If `webServer.server` is ever absent the plugin throws at load with a clear
message rather than failing quietly.
The only one used conditionally is `connection.admit`: the bypass path replaces
it when `disableAuth: true`. The default posture does not touch it at all.
### How this differs from `@goodandready/dsh-lanmode`
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:
lanmode solves 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 is more robust — it does not depend on
harness internals — but it needs a second port, it only applies to traffic that
goes through it, and in `mode: auto` it declines to open that listener at all
when something already answers on the harness port, which is what a `0.0.0.0`
bind looks like from the inside.
```
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
```
This plugin solves it from inside, on the port you already use, with no second
listener. The two are not mutually exclusive, but running both is redundant and
`spoofSocketAddress` will confuse lanmode's device tracking.
### 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 is now exercised by the default posture.** 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 the same sandbox, 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 the default
no longer needs to remove 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. Set `disableAuth: true`
and the fence is skipped entirely.
- Rewriting `Host` decides the **cookie authority** the harness derives from it
(`dsh-auth-<sha256(authority)>`). The launch token is exchanged for the
rewritten authority, so the resulting 30-day cookie matches every request the
plugin intercepts — that is what makes the default posture work. 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
@@ -197,20 +637,59 @@ listener. The two are not mutually exclusive, but running both is redundant and
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
```bash
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/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 — **60 tests** (fence 8, apply 21, 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. Both files need no
dependency tree beyond a resolvable `@deepseek-ai/schemastery` for the second.
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.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
+17 -8
View File
@@ -1,25 +1,34 @@
# dsh-disable-untrusted-origin — bundle layer
# dsh-trustworthy — bundle layer
#
# Applied automatically when the package is installed as a profile bundle
# (package.json declares dsh.bundle.patch).
#
# The defaults below are the plugin's stated posture: the identity fence is
# disarmed, the browser-session check is removed, and the browser is granted
# the host's own isLoopback flag. Override any key in your profile patch:
# disarmed, the browser is granted the host's own isLoopback flag, and the APIs
# a non-secure page is denied are restored. The browser-session check stays in
# place: the first visit is the one-time launch token printed in the process log
# (the Docker log from 0.2.1-alpha.4 on), exchanged for a 30-day cookie.
# Override any key in your profile patch:
#
# - id: disable-untrusted-origin
# - id: trustworthy
# config:
# disableAuth: false # keep the process token, disarm only the fence
# disableAuth: true # ALSO remove the session check; no token needed
# grantPairing: false # when dsh-remote-web-ui provides the pairing service itself
# spoofSocketAddress: true # also satisfy socket-based fences with no pairing path
# shimClipboard: false # unnecessary when you reach the harness over HTTPS
# shimRandomUuid: false # likewise
# shimPwa: false # skip the manifest and the installable-app metadata
#
# Set `enabled: false` to suspend the plugin without removing the bundle.
- insert:
- id: disable-untrusted-origin
name: 'dsh-disable-untrusted-origin'
- id: trustworthy
name: 'dsh-trustworthy'
config:
enabled: true
disableAuth: true
disableAuth: false
forceClientLoopback: true
grantPairing: true
spoofSocketAddress: false
shimClipboard: true
shimRandomUuid: true
shimPwa: true
+191
View File
@@ -0,0 +1,191 @@
/**
* Handover between generations of the same plugin.
*
* Everything this plugin installs is a mutation of an object somebody else
* owns: methods on the `connection` service, and listeners on the raw HTTP
* server. A reload — the plugin market switching it off and on, a line-level
* config change, a bundle patch reloaded — runs `apply` again and, sooner or
* later, the previous generation's cleanup. Those two events have no
* guaranteed order, and the unordered case is destructive:
*
* generation 1 captures the true originals, installs A
* generation 2 captures A as its "originals", installs B
* generation 1 cleans up, restoring the true originals
*
* Generation 2 is now silently inert while believing it is installed, because
* the value it displaced was itself a replacement. The fix is to record the
* originals once, on the shared object, together with the identity of the
* generation that currently owns them; a cleanup runs only while it still owns
* the installation. That is what {@link installReplacements} does, and the
* interceptor needs the mirror of it — a listener that survives its author
* must not be joined by its successor.
*
* Deliberately free of imports, like `authority.js` and `shims.js`, so the
* tests can load it with no resolvable dependency tree.
*
* The sharing channels are `Symbol.for` keys, not module-local state: a reload
* can arrive as a fresh module instance with a fresh set of module bindings, and
* only the global symbol registry is common to both.
*/
/** Carrier for the recorded originals and the owning generation. */
export const HANDOVER = Symbol.for('dsh-trustworthy.handover')
/** Marks a listener as this plugin's request interceptor. */
export const INTERCEPTOR = Symbol.for('dsh-trustworthy.interceptor')
/** Fallback carrier for a target that refuses a symbol property. */
const carriers = new WeakMap()
/**
* Read the carrier on a shared object.
*
* @param target - the object the installation lives on.
* @param create - whether to make one when it is absent.
* @returns the carrier, or `undefined` when absent and not creating.
*/
function carrierFor(target, create) {
const existing = target[HANDOVER]
if (existing !== undefined) return existing
const mapped = carriers.get(target)
if (mapped !== undefined) return mapped
if (!create) return undefined
const carrier = { originals: new Map(), owner: null }
try {
Object.defineProperty(target, HANDOVER, {
value: carrier,
configurable: true,
writable: true,
enumerable: false,
})
} catch {
// A frozen or exotic host object still gets coordination, just only within
// this module instance rather than across a fresh one.
carriers.set(target, carrier)
}
return carrier
}
/**
* Install replacement properties, with a handover between generations.
*
* The originals are captured exactly once and kept on the target, so a
* successor inherits them rather than treating its predecessor's replacements
* as the state to return to. A cleanup restores only while its own generation
* still owns the installation, and only for keys still holding its own value —
* so a third party that replaced one of them in the meantime keeps it.
*
* @param target - the object whose properties are replaced.
* @param replacements - a map of property name to replacement value.
* @returns a cleanup function, safe to call more than once.
*/
export function installReplacements(target, replacements) {
const carrier = carrierFor(target, true)
const keys = Object.keys(replacements)
// Recorded before the first overwrite, and never again: from the second
// generation on, the live values are this plugin's own replacements.
for (const key of keys) {
if (!carrier.originals.has(key)) carrier.originals.set(key, target[key])
}
const token = {}
carrier.owner = token
for (const key of keys) target[key] = replacements[key]
let restored = false
return () => {
if (restored) return
restored = true
// A newer generation has taken the installation over; its values are the
// live ones and this cleanup must not touch them.
if (carrier.owner !== token) return
for (const key of keys) {
if (target[key] !== replacements[key]) continue
const original = carrier.originals.get(key)
if (original === undefined) delete target[key]
else target[key] = original
}
carrier.owner = null
if (target[HANDOVER] === carrier) {
try {
delete target[HANDOVER]
} catch {
/* Non-configurable on an exotic host; the carrier is inert anyway. */
}
}
}
}
/**
* The value a key held before this plugin first replaced it.
*
* Read from the carrier rather than captured in the caller, so a replacement
* can delegate to the true original even when it was installed by a later
* generation — and can do so lazily, at call time, when the carrier is known to
* exist.
*
* @param target - the object the installation lives on.
* @param key - the property name.
* @returns the original value, or `undefined` when there is no carrier or the
* property did not exist before this plugin was installed.
*/
export function originalOf(target, key) {
return carrierFor(target, false)?.originals.get(key)
}
/**
* Install the request-identity interceptor ahead of every other listener.
*
* `prependListener`, not `on`: the webserver registers its own request handler
* when it constructs the server, so appending would run this after every route
* lookup and change nothing.
*
* Any predecessor bearing {@link INTERCEPTOR} is removed first. A reload can
* add the successor before the predecessor's cleanup runs, and two identical
* interceptors are not merely wasteful — the older one is a live closure over a
* dead generation's config, which would keep spoofing the peer address after
* `spoofSocketAddress` was turned off.
*
* @param server - the raw `node:http` server.
* @param listener - the interceptor, called with the request or the socket.
* @param events - the server events to cover, `request` and `upgrade`.
* @returns a cleanup function, safe to call more than once.
*/
export function installInterceptor(server, listener, events = ['request', 'upgrade']) {
listener[INTERCEPTOR] = true
for (const event of events) {
for (const existing of server.listeners(event)) {
if (existing[INTERCEPTOR] === true) server.removeListener(event, existing)
}
server.prependListener(event, listener)
}
let removed = false
return () => {
if (removed) return
removed = true
for (const event of events) server.removeListener(event, listener)
}
}
/**
* Whether a fiber is still the live installation.
*
* Cordis nulls a fiber's `uid` at the very start of disposal — before its
* disposers run, and before the state machine moves to `UNLOADING` — so a
* closure left behind by a generation whose cleanup never ran can recognise
* that it no longer owns the process. The alternative signal, `fiber.state`, is
* a numeric enum whose members are not part of the plugin-facing API; `uid` is
* documented as "`null` once disposed".
*
* Conservative by construction: only a positive signal of disposal answers
* false. A host that has reshaped the fiber, or a mock without one, keeps the
* installation working rather than silently disarming it.
*
* @param fiber - the plugin fiber, usually `ctx.fiber`.
* @returns false only when the fiber is known to have been disposed.
*/
export function fiberLive(fiber) {
if (fiber === undefined || fiber === null) return true
return fiber.uid !== null
}
+285 -56
View File
@@ -1,5 +1,5 @@
/**
* dsh-disable-untrusted-origin — make every browser window behave like a
* dsh-trustworthy — make every browser window behave like a
* window running on the harness machine itself.
*
* DeepSeek Harness fences its HTTP surface by request identity. The `/api`
@@ -10,7 +10,14 @@
* tunnel therefore authenticates normally and then fails every mutating
* request with `403`, which reads to the user as a button that does nothing.
*
* This plugin removes the distinction in two places and adds nothing else.
* This plugin removes the distinction in two places, and separately restores
* the browser capabilities a non-secure page is denied. It removes only the
* origin fence by default: the harness's own browser-session check stays in
* place, so the first visit is the one-time launch token from the process log
* (`dsh web: …?token=…`; the container entrypoint prints it into the Docker log
* from `0.2.1-alpha.4`), which is exchanged for a 30-day cookie bound to the
* authority it was minted under. Set `disableAuth: true` to remove that check
* as well.
*
* 1. A `request`/`upgrade` listener installed ahead of every route answers
* each connection with the loopback authority. One interception covers the
@@ -20,15 +27,41 @@
* 2. The browser-side `isLoopback` flag is granted over the harness's own
* `webserver/index-inject` channel, the same mechanism
* `@deepseek-ai/dsh-web-app` uses for `__DSH_CONNECTION_RECOVERY__`.
* 3. The index also carries shims for `crypto.randomUUID` and
* `navigator.clipboard`, both of which exist only in a secure context, plus
* the metadata that makes the page installable on a phone.
*
* Security: this is a deliberate and total removal of the request-identity
* fence across the whole HTTP surface. It defends against DNS rebinding and
* against another page in the same browser driving this API, and both
* defences stop applying once this plugin is enabled. Read the README before
* turning it on, and keep the listening port behind something that
* authenticates on your behalf.
* What it deliberately does not do is *declare* the arriving authority. The
* `connection` service's `trustedHosts` is what the `/api` fence reads, and an
* entry there changes which names the gate accepts — a weaker and narrower
* guarantee than rewriting the request, and one no declaration can give a fence
* that compares against a hardcoded loopback literal. Rewriting needs no
* declaration at all.
*
* @module dsh-disable-untrusted-origin
* It does, separately, **restore** the `webRuntime` service that releases from
* `0.2.1-alpha.2` on stopped providing, when nothing else provides it. That
* service was the LAN-trust snapshot a third-party plugin injects to learn the
* authorities its own copy of the fence should accept; without it the row stays
* `pending`, its routes never mount, and its URLs fall through to the core
* handler — which is what a `405` in a plugin's own pane actually is. See
* `lib/web-runtime.js` and `provideWebRuntime`.
*
* Reload safety: everything installed here is a mutation of an object the
* harness owns, and a reload can run a successor's installation before its
* predecessor's cleanup. `handover.js` records the originals once, on the
* shared object, and restores them only while the generation that wrote them
* still owns the installation — so a reload cannot leave the plugin inert while
* it believes it is mounted, nor let a superseded interceptor keep acting.
*
* Security: removing the request-identity fence is deliberate and total for
* every window that reaches the port. It defends against DNS rebinding and
* against another page in the same browser driving this API, and both defences
* stop applying once this plugin is enabled. The default keeps the session
* check, so the exposure is a page that carries a live cookie; `disableAuth:
* true` removes the check too, and then reachability is the only access
* control. Read the README before turning that on.
*
* @module dsh-trustworthy
*/
import z from '@deepseek-ai/schemastery'
@@ -38,11 +71,42 @@ import {
loopbackise,
spoofPeerAddress,
} from './authority.js'
import {
HANDOVER,
INTERCEPTOR,
fiberLive,
installInterceptor,
installReplacements,
originalOf,
} from './handover.js'
import {
MANIFEST_PATH,
manifest,
pwaMarkup,
shimSource,
withCoverViewport,
} from './shims.js'
import {
WEB_RUNTIME_SERVICE,
bindAuthority,
connectionAuthorities,
trustedAuthorities,
webRuntimeShim,
} from './web-runtime.js'
export { CLIENT_LOOPBACK_GRANT, loopbackAuthority, loopbackise, spoofPeerAddress }
export { HANDOVER, INTERCEPTOR, fiberLive, installInterceptor, installReplacements, originalOf }
export { MANIFEST_PATH, manifest, pwaMarkup, shimSource, withCoverViewport }
export {
WEB_RUNTIME_SERVICE,
bindAuthority,
connectionAuthorities,
trustedAuthorities,
webRuntimeShim,
}
/** Stable Cordis plugin name. */
export const name = 'disable-untrusted-origin'
export const name = 'trustworthy'
/** The webserver owns the routes; the connection service owns the fence. */
export const inject = ['webServer', 'connection']
@@ -64,12 +128,15 @@ export const Config = z.object({
),
disableAuth: z
.boolean()
.default(true)
.default(false)
.description(
'Also remove the browser-session check, so no process token is needed. '
+ 'Anyone who can reach the port then has full control of the agent, '
+ 'including code execution. Keep this true only behind a port or '
+ 'tunnel that authenticates on its own.',
+ 'including code execution. The default leaves the check in place: '
+ 'open the launch URL from the process log (in the Docker log on the '
+ 'container image) once, and the 30-day session cookie it exchanges '
+ 'covers this plugin\'s rewritten requests too. Set this true only '
+ 'behind a port or tunnel that authenticates on its own.',
),
forceClientLoopback: z
.boolean()
@@ -89,6 +156,17 @@ export const Config = z.object({
+ 'off when @linxin666/dsh-remote-web-ui is installed: it provides the '
+ 'same service, and two providers for one name is a load error.',
),
provideWebRuntime: z
.boolean()
.default(true)
.description(
'Provide the Web-runtime trust snapshot that releases from 0.2.1-alpha.2 '
+ 'on stopped publishing, when nothing else provides it. Plugins that '
+ 'inject `webRuntime` — the sidebar among them — stay pending without '
+ 'it, and their routes are never registered, which their pane reports '
+ 'as an HTTP 405. Turn this off to leave those plugins unmounted on a '
+ 'harness that removed the service.',
),
spoofSocketAddress: z
.boolean()
.default(false)
@@ -99,6 +177,30 @@ export const Config = z.object({
+ 'every consumer of the peer address will believe it — including a '
+ 'reverse proxy plugin\'s client-IP attribution and its ban list.',
),
shimClipboard: z
.boolean()
.default(true)
.description(
'Restore navigator.clipboard.writeText for plain-HTTP pages, where the '
+ 'browser withholds it. Copy buttons in third-party plugin bundles '
+ 'are not all guarded, and fail silently without this.',
),
shimRandomUuid: z
.boolean()
.default(true)
.description(
'Restore crypto.randomUUID for plain-HTTP pages, minting the UUID from '
+ 'crypto.getRandomValues, which is not restricted to secure contexts.',
),
shimPwa: z
.boolean()
.default(true)
.description(
'Add the installable-app metadata — a manifest, a theme colour, and an '
+ 'apple-mobile-web-app pair — and widen the viewport to cover the '
+ 'notch on a phone. This is what makes Add to Home Screen produce a '
+ 'standalone app rather than a bookmark.',
),
})
/**
@@ -109,7 +211,7 @@ export const Config = z.object({
*/
export function apply(ctx, config) {
if (!config.enabled) {
ctx.logger?.info('disable-untrusted-origin: disabled by config; no fence was changed')
ctx.logger?.info('trustworthy: disabled by config; no fence was changed')
return
}
@@ -119,54 +221,78 @@ export function apply(ctx, config) {
const authorityNow = () => (authority ??= loopbackAuthority(ctx.webServer.port))
const onRequest = (request) => {
// A listener can outlive its own cleanup when a host tears a fiber down
// without running disposers. Cordis nulls the fiber's `uid` at the start of
// disposal, so a superseded interceptor recognises itself and stops acting
// rather than going on spoofing an identity nobody is asking it to spoof.
if (!fiberLive(ctx.fiber)) return
loopbackise(request.headers, authorityNow())
if (config.spoofSocketAddress) spoofPeerAddress(request)
}
const server = ctx.webServer.server
if (server === undefined) {
throw new Error(
'disable-untrusted-origin: the webserver exposes no raw server; the interceptor cannot be installed',
// The host surface is probed rather than assumed. A harness release that
// stops exposing the raw server should cost the user this plugin's effect and
// a log line, not a failed fiber whose error takes the tree down with it.
const server = ctx.webServer?.server
if (server === undefined || typeof server.prependListener !== 'function') {
ctx.logger?.warn(
'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',
)
} else {
// `prependListener`, not `on`: the webserver registers its own request
// handler when it constructs the server, so appending would run this after
// every route lookup and change nothing.
const detach = installInterceptor(server, onRequest)
ctx.effect(() => detach, 'trustworthy: request identity interceptor')
}
// `prependListener`, not `on`: the webserver registers its own request
// handler when it constructs the server, so appending would run this after
// every route lookup and change nothing.
server.prependListener('request', onRequest)
server.prependListener('upgrade', onRequest)
ctx.effect(
() => () => {
server.removeListener('request', onRequest)
server.removeListener('upgrade', onRequest)
},
'disable-untrusted-origin: request identity interceptor',
)
if (config.disableAuth) {
const connection = ctx.connection
const originals = {
requestRejection: connection.requestRejection,
authorizeIndex: connection.authorizeIndex,
authenticatedUrl: connection.authenticatedUrl,
// The gate is one method. Releases up to `0.2.1-alpha.2` asked
// `requestRejection` first and `admit` wrapped its answer; from
// `0.2.1-alpha.3` on `admit` applies the fence and the session check
// itself, and `requestRejection` is a façade over it. Replacing `admit`
// therefore satisfies both shapes, and replacing `requestRejection` keeps
// the older shape from short-circuiting around it.
//
// The peer that comes back is one the harness already owns: `operator` up
// to `0.2.1-alpha.2`, `browserPeer` from `0.2.1-alpha.3` on. There is no
// synthetic identity to invent, and returning the owner's peer is exactly
// what the session check would have produced for a logged-in browser.
//
// `authenticatedUrl` is the startup URL and the remote-access handoff;
// returning the base URL keeps the process token out of the printed link.
//
// Each replacement is live-aware rather than unconditional. A host that
// takes a plugin out of service by editing its patch row does not always
// run the plugin's disposers, and a service method that goes on answering
// "admitted" after the plugin was switched off is the worst possible leak:
// the fence would look restored while the gateway stayed open. When the
// fiber is gone these delegate to the true original instead — read from the
// handover carrier, so a later generation still finds the real one rather
// than a predecessor's replacement.
const live = () => fiberLive(ctx.fiber)
const delegate = (key, admitted) =>
function (...args) {
if (live()) return admitted(...args)
const original = originalOf(connection, key)
return original === undefined ? undefined : original.apply(this, args)
}
const admit = (request) => {
const peer = connection.browserPeer ?? connection.operator
if (peer !== undefined) return { peer }
const original = originalOf(connection, 'admit')
return original === undefined ? undefined : original.call(connection, request)
}
// The `/api` gateway asks one method whether the request may proceed; it
// applies the identity fence and then the session check. Answering
// `undefined` admits both.
connection.requestRejection = () => undefined
// The shell is served by the frontend's fallback seat, which asks the
// connection service before reading index.html. Affirming unconditionally
// serves the application on a bare URL, with no process token and no
// cookie.
connection.authorizeIndex = () => true
// The startup URL and the remote-access handoff both come from here;
// returning the base URL keeps the token out of the printed link.
connection.authenticatedUrl = (baseUrl) => baseUrl
ctx.effect(
() => () => {
for (const [key, value] of Object.entries(originals)) connection[key] = value
},
'disable-untrusted-origin: authentication bypass',
)
const restore = installReplacements(connection, {
admit: delegate('admit', admit),
requestRejection: delegate('requestRejection', () => undefined),
authorizeIndex: delegate('authorizeIndex', () => true),
authenticatedUrl: delegate('authenticatedUrl', (baseUrl) => baseUrl),
})
ctx.effect(() => restore, 'trustworthy: authentication bypass')
}
if (config.grantPairing) {
@@ -185,12 +311,42 @@ export function apply(ctx, config) {
// test, it only happens for a remote window, which is why the Skill Center
// works on the host and fails everywhere else.
//
// Registered on this plugin's own fiber, so unload withdraws it.
// Registered on this plugin's own fiber, so unload withdraws it. The answer
// is gated on the fiber being live as well: if a host switches the plugin
// off without running its disposers, the registration would otherwise go on
// vouching for every device.
if (ctx.get(PAIRING_SERVICE) === undefined) {
ctx.provide(PAIRING_SERVICE, { isPairedDevice: () => true })
ctx.provide(PAIRING_SERVICE, { isPairedDevice: () => fiberLive(ctx.fiber) })
} else {
ctx.logger?.info(
`disable-untrusted-origin: ${PAIRING_SERVICE} is already provided; leaving that provider in place`,
`trustworthy: ${PAIRING_SERVICE} is already provided; leaving that provider in place`,
)
}
}
if (config.provideWebRuntime) {
// Not a fence change, and not a declaration of the arriving authority: this
// restores a service the harness stopped publishing, for plugins that inject
// it and would otherwise never mount at all. The value is read live from
// `connection`, so it follows the same trust source the `/api` fence derives
// its own list from rather than a snapshot frozen at assembly time.
//
// Registered on this plugin's own fiber, so unload withdraws it and a
// consumer that depended on it is released with it. Unlike the pairing
// service below there is no liveness gate on the answer: that one *decides*
// whether a device is paired, so a registration outliving its disposers
// would keep vouching for devices, while this one only republishes the
// authorities `connection` already carries. Probing first matters for the
// same reason it does for pairing: a second registration of one service
// name is a load error.
if (ctx.get(WEB_RUNTIME_SERVICE) === undefined) {
ctx.provide(WEB_RUNTIME_SERVICE, webRuntimeShim(ctx))
ctx.logger?.info(
`trustworthy: provided the ${WEB_RUNTIME_SERVICE} service this harness no longer publishes`,
)
} else {
ctx.logger?.info(
`trustworthy: ${WEB_RUNTIME_SERVICE} is already provided; leaving that provider in place`,
)
}
}
@@ -205,8 +361,81 @@ export function apply(ctx, config) {
})
}
if (config.shimClipboard || config.shimRandomUuid || config.shimPwa) {
// These restore capabilities the browser withholds from a page that is not
// a secure context — which every plain-HTTP LAN address is. They are
// unrelated to request identity; they live here so that one plugin covers
// both, rather than leaving a second plugin installed for the browser's
// sake alone.
const source = shimSource({
clipboard: config.shimClipboard,
randomUuid: config.shimRandomUuid,
})
const dispose = []
if (source !== '') {
ctx.inject(['webServer'], (webCtx) => {
webCtx.on('webserver/index-inject', (table) => {
table.push({ kind: 'script', placement: 'head', text: source })
})
})
}
if (config.shimPwa) {
// Both seats are probed, for the same reason the raw server is: the
// harness reshapes this surface between releases, and a missing method
// should cost the user a log line rather than a failed fiber.
if (
typeof ctx.webServer?.register !== 'function'
|| typeof ctx.webServer?.tapIndex !== 'function'
) {
ctx.logger?.warn(
'trustworthy: the webserver exposes no route registry or index '
+ 'transform, so the installable-app metadata was not added',
)
} else {
// Served from the webserver rather than injected inline, so the browser
// can fetch it under the URL the manifest link advertises.
dispose.push(ctx.webServer.register({
kind: 'exact',
path: MANIFEST_PATH,
handler: (request, response) => {
const body = JSON.stringify(manifest('DeepSeek Harness'))
response.writeHead(200, {
'content-type': 'application/manifest+json; charset=utf-8',
'content-length': Buffer.byteLength(body),
'cache-control': 'no-cache',
})
response.end(body)
},
}))
ctx.inject(['webServer'], (webCtx) => {
webCtx.on('webserver/index-inject', (table) => {
table.push({
kind: 'html',
placement: 'head',
html: pwaMarkup({ manifestPath: MANIFEST_PATH, includeManifestLink: true }),
})
})
})
// The viewport tag already exists, so it is rewritten in place rather
// than joined by a second one whose precedence the browser decides.
dispose.push(ctx.webServer.tapIndex(withCoverViewport))
}
}
ctx.effect(
() => () => {
for (const undo of dispose) undo()
},
'trustworthy: browser shims',
)
}
ctx.logger?.info(
`disable-untrusted-origin: every window is treated as local; authentication is ${
`trustworthy: every window is treated as local; authentication is ${
config.disableAuth ? 'disabled' : 'kept'
}`,
)
+156
View File
@@ -0,0 +1,156 @@
/**
* Browser shims for the capabilities a browser withholds outside a secure
* context, plus the PWA metadata that makes the page installable.
*
* A page served over plain HTTP from anything other than localhost is not a
* secure context, so two Web APIs the UI touches are simply absent. These
* shims restore them. Deliberately import-free so the tests can load them
* without a dependency tree.
*
* Ported from `@goodandready/dsh-lanmode`'s page shim, which did this job
* well; this copy exists so the two concerns — request identity and browser
* capability — live in one plugin instead of two.
*/
/** Attribute marking the injected shim script, so a second injection is a no-op. */
export const SHIM_MARKER = 'data-dsh-trustworthy-shims'
/** Attribute marking the injected PWA metadata block. */
export const PWA_MARKER = 'data-dsh-trustworthy-pwa'
/** Where the plugin serves its PWA manifest from. */
export const MANIFEST_PATH = '/dsh-trustworthy/manifest.webmanifest'
/**
* The page-side shim, as a single IIFE.
*
* `crypto.randomUUID` is restricted to secure contexts while
* `crypto.getRandomValues` is not, so the UUID is minted from the bytes the
* unrestricted call returns. `navigator.clipboard` is withheld the same way;
* the fallback copies through a hidden textarea and `document.execCommand`,
* which still works on plain HTTP.
*
* The harness's own code no longer needs either — `@deepseek-ai/dsh-util-crypto`
* mints UUIDs from `getRandomValues` and the market guards its clipboard call —
* but third-party plugin bundles are not obliged to, and one shipped today
* (`billion-context`) calls `navigator.clipboard.writeText` unguarded.
*
* @param options - which shims to include.
* @param options.clipboard - include the clipboard fallback.
* @param options.randomUuid - include the randomUUID polyfill.
* @returns JavaScript source for one `<script>` body, or '' when both are off.
*/
export function shimSource({ clipboard, randomUuid }) {
if (!clipboard && !randomUuid) return ''
const parts = []
if (randomUuid) {
// `i !== 16` rather than `i < 16` so the body contains no `<` at all:
// the script row is spliced into HTML without escaping.
parts.push(` try {
var c = globalThis.crypto;
if (c && typeof c.randomUUID !== "function" && typeof c.getRandomValues === "function") {
c.randomUUID = function () {
var b = new Uint8Array(16);
c.getRandomValues(b);
b[6] = (b[6] & 15) | 64;
b[8] = (b[8] & 63) | 128;
var h = "";
for (var i = 0; i !== 16; i++) h += b[i].toString(16).padStart(2, "0");
return h.slice(0, 8) + "-" + h.slice(8, 12) + "-" + h.slice(12, 16) + "-" + h.slice(16, 20) + "-" + h.slice(20);
};
}
} catch (ignored) {}`)
}
if (clipboard) {
parts.push(` try {
var nav = navigator;
if (nav && (!nav.clipboard || typeof nav.clipboard.writeText !== "function")) {
var writeText = function (text) {
return new Promise(function (resolve, reject) {
try {
var area = document.createElement("textarea");
area.value = String(text);
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
var copied = document.execCommand("copy");
document.body.removeChild(area);
copied ? resolve() : reject(new Error("copy rejected"));
} catch (failure) { reject(failure); }
});
};
if (nav.clipboard) nav.clipboard.writeText = writeText;
else Object.defineProperty(nav, "clipboard", { configurable: true, value: { writeText: writeText } });
}
} catch (ignored) {}`)
}
// The first statement stamps the element so a probe can confirm the shim was
// actually delivered, rather than inferring it from behaviour that may have
// had another cause.
const stamp = ` try { document.currentScript.setAttribute(${JSON.stringify(SHIM_MARKER)}, "1"); } catch (ignored) {}`
return `(function () {\n${stamp}\n${parts.join('\n')}\n})();`
}
/**
* Head markup for the PWA block, and the viewport rewrite that goes with it.
*
* The existing viewport tag is rewritten rather than duplicated: two viewport
* tags leave the effective one browser-defined, and `viewport-fit=cover` is
* what keeps the layout out of the notch on a phone in standalone mode.
*
* @param options - what to emit.
* @param options.manifestPath - where the manifest is served.
* @param options.includeManifestLink - emit the `<link rel="manifest">`.
* @returns markup to insert at the end of `<head>`.
*/
export function pwaMarkup({ manifestPath, includeManifestLink }) {
const lines = [
`<meta ${PWA_MARKER}="1" name="apple-mobile-web-app-capable" content="yes">`,
`<meta ${PWA_MARKER}="1" name="apple-mobile-web-app-status-bar-style" content="black-translucent">`,
`<meta ${PWA_MARKER}="1" name="theme-color" content="#1e1e2e">`,
]
if (includeManifestLink) {
lines.push(`<link ${PWA_MARKER}="1" rel="manifest" href="${manifestPath}">`)
}
return lines.join('')
}
/**
* Rewrite the document's viewport tag to add `viewport-fit=cover`.
*
* @param html - the rendered index document.
* @returns the document with a cover-aware viewport, unchanged when none is found.
*/
export function withCoverViewport(html) {
return html.replace(
/<meta\s+name=["']viewport["']\s+content=["']([^"']*)["']\s*\/?>/i,
(whole, content) =>
content.includes('viewport-fit')
? whole
: `<meta name="viewport" content="${content}, viewport-fit=cover">`,
)
}
/**
* The PWA manifest this plugin serves.
*
* Kept minimal and unopinionated: it exists so a phone can install the page to
* its home screen, which is the only reason to serve one at all.
*
* @param name - the display name to advertise.
* @returns a manifest object ready to serialise.
*/
export function manifest(name) {
return {
name,
short_name: 'DSH',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#11111b',
theme_color: '#1e1e2e',
icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* dsh-trustworthy — the `webRuntime` seam, for harness releases that stopped
* providing it.
*
* `@deepseek-ai/dsh-web-app` used to provide a `webRuntime` service whose value
* was the LAN-trust snapshot — `resolveLanTrust(ctx.webServer.host, trustedHosts)`,
* that is `{ lanAddresses, trustedHosts }`. Releases from `0.2.1-alpha.2` on no
* longer provide it: the `--trusted-host` authorities are parsed by the
* `webStartup` service and consumed by `@deepseek-ai/dsh-client-connection` into
* `connection.trustedHosts`, and the listener's own bind address is accepted by
* the `/api` fence as a separate case.
*
* Plugins written against the old shape never learn that it moved. They declare
* `inject: ['webRuntime']`, so Cordis holds their row **pending** — the module
* loads and its inject list is read, but the fiber never mounts. Their routes
* are then simply absent, and the core fallback answers in their place: a
* `POST /sidebar/api/fs.list` that matches no route falls through to the
* static-file handler, which allows `GET`/`HEAD` only and answers **405**.
*
* This module fills that seam. Same service name, same value shape, but read
* live from the trust source the harness actually has now, rather than cloned
* at assembly time — so a later `--trusted-host`, or a row that changed
* `connection`'s config, is visible to a plugin's own copy of the fence.
*
* @module dsh-trustworthy/web-runtime
*/
/** Service name sibling plugins inject, and read, for the Web runtime snapshot. */
export const WEB_RUNTIME_SERVICE = 'webRuntime'
/**
* Bind hosts that name every interface rather than one of them. A wildcard can
* be written as an authority but never *means* one, so it is not an entry.
*/
const WILDCARD_BIND_HOSTS = new Set(['0.0.0.0', '::', '::0', '*'])
/**
* The non-loopback authorities this deployment declared — the `--trusted-host`
* values, as `connection` received them. Read per call rather than captured, so
* the snapshot tracks the same list the `/api` fence is using.
*
* @param ctx - host plugin context carrying `connection`.
* @returns declared authorities, in the order they were configured.
*/
export function connectionAuthorities(ctx) {
const declared = ctx.connection?.trustedHosts
if (!Array.isArray(declared)) return []
return declared.filter((entry) => typeof entry === 'string' && entry !== '')
}
/**
* The listener's own bind address, when it names one interface. `0.2.1-alpha.2`'s
* `/api` fence accepts this address on its own (`isBindAddressAuthority`), and
* a plugin carrying a private copy of the older two-argument fence cannot: it
* only ever compares against the list it is handed. Publishing the bind address
* as an entry makes that older fence accept exactly what the core fence accepts
* — the entry is port-less, so like the core check it matches the address on any
* port, and a wildcard is not an authority: `0.2.1-alpha.2` refuses to bind one
* at all (`host "0.0.0.0" is an unspecified (wildcard) address, which is not
* supported`), so that case is unreachable rather than merely unused.
*
* @param ctx - host plugin context carrying `webServer`.
* @returns the bind host, or undefined when the surface is gone or wildcarded.
*/
export function bindAuthority(ctx) {
const host = ctx.webServer?.host
if (typeof host !== 'string' || host === '' || WILDCARD_BIND_HOSTS.has(host)) return undefined
return host
}
/**
* The authorities the removed `webRuntime.trustedHosts` would carry today.
*
* @param ctx - host plugin context carrying `connection` and `webServer`.
* @returns deduplicated authorities: the declared ones, then the bind address.
*/
export function trustedAuthorities(ctx) {
const declared = connectionAuthorities(ctx)
const bind = bindAuthority(ctx)
return bind === undefined ? [...new Set(declared)] : [...new Set([...declared, bind])]
}
/**
* The value of the `webRuntime` service, shaped as `0.2.1-alpha.1` provided it.
*
* Both members are getters: a consumer reads `trustedHosts` inside its fence, per
* request, so a snapshot that could not see a later authority would quietly
* refuse requests the `/api` gateway admits. `lanAddresses` stays empty because
* `0.2.1-alpha.2` rejects a wildcard bind — alpha.1 filled it from the host's
* interfaces only for `0.0.0.0`, so the empty list is that same function's
* answer for every bind this harness allows.
*
* When dsh-trustworthy's interceptor is installed the list is inert for the
* plugin's own routes: every request arrives carrying the loopback authority, so
* a fence written against this value admits it before reading the list. The
* populated list matters to consumers that read it for another reason, and to a
* deployment that installs this shim without the interceptor.
*
* @param ctx - host plugin context carrying `connection` and `webServer`.
* @returns the service value.
*/
export function webRuntimeShim(ctx) {
return {
get trustedHosts() {
return trustedAuthorities(ctx)
},
get lanAddresses() {
return []
},
}
}
+11 -5
View File
@@ -1,13 +1,16 @@
{
"name": "dsh-disable-untrusted-origin",
"version": "0.1.1",
"description": "Treat every browser window as if it were running on the DeepSeek Harness host itself: answers each request with the loopback authority so no Host/Origin fence refuses it, and optionally removes the browser-session check.",
"name": "dsh-trustworthy",
"version": "0.4.0",
"description": "Removes the untrusted-origin restriction from DeepSeek Harness while keeping its own session check: every browser window is treated as if it were running on the harness machine itself, so no declared trusted host and no per-plugin origin fence refuse it. One interceptor answers every request with the loopback authority, and the one-time launch token is exchanged for a 30-day cookie exactly as on the loopback host.",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./authority": "./lib/authority.js",
"./package.json": "./package.json"
"./handover": "./lib/handover.js",
"./package.json": "./package.json",
"./shims": "./lib/shims.js",
"./web-runtime": "./lib/web-runtime.js"
},
"files": [
"lib/",
@@ -16,6 +19,9 @@
"LICENSE"
],
"license": "MIT",
"engines": {
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
},
"keywords": [
"dsh",
"dsh-plugin",
@@ -30,7 +36,7 @@
"patch": "./cordis.patch.yml"
},
"compatibility": {
"dsh": ">=0.1.7-rc.2"
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
}
},
"peerDependencies": {
+175
View File
@@ -0,0 +1,175 @@
/**
* Live probe for a running harness.
*
* Verifies, against the process actually serving your browser, that
* 1. a non-loopback `Host` is admitted by the plugin market's fence,
* 2. the shell is served without a process token,
* 3. the Skill Center answers a non-loopback *peer*,
* 4. the browser shims and the PWA manifest actually reach the page, and
* 5. the sidebar's Files API is registered rather than falling through to the
* static handler as a 405 — the shape a plugin takes when the service it
* injects (`webRuntime`) is not provided.
*
* Point 3 is the one that needs care: 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. This connects to a
* non-loopback local address instead, so the peer address matches what a LAN or
* tunnel client looks like.
*
* node scripts/live-probe.mjs [--port 3080] [--host your.hostname] [--addr 172.17.0.4]
*
* Exit code is 0 when every check passes.
*/
import http from 'node:http'
import { networkInterfaces } from 'node:os'
import { MANIFEST_PATH, PWA_MARKER, SHIM_MARKER } from '../lib/shims.js'
function parseArgs(argv) {
const options = {}
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index]?.replace(/^--/, '')
if (key !== undefined && key !== '') options[key] = argv[index + 1]
}
return options
}
const args = parseArgs(process.argv.slice(2))
const PORT = Number(args.port ?? process.env.PROBE_PORT ?? 3080)
/** The container's or host's own non-loopback IPv4, which is what a bridge or LAN peer sees. */
function localAddress() {
if (args.addr !== undefined) return args.addr
for (const addresses of Object.values(networkInterfaces())) {
for (const address of addresses ?? []) {
if (address.family === 'IPv4' && !address.internal) return address.address
}
}
return undefined
}
const ADDR = localAddress()
if (ADDR === undefined) {
console.error('live-probe: no non-loopback IPv4 address found; pass --addr')
process.exit(2)
}
/** Hostnames to test. Defaults to the LAN literal plus whatever --host names. */
const HOSTS = [
{ label: 'LAN address', host: `${ADDR}:${PORT}` },
...(args.host === undefined ? [] : [{ label: 'named host', host: args.host }]),
]
const PATH_MARKET = '/dsh-market/discovery-compatibility'
const PATH_SKILLS = '/api/dsh-skill-explorer/health'
function request({ method, path, host, body }) {
return new Promise((resolve) => {
const call = http.request(
{
host: ADDR,
port: PORT,
path,
method,
headers: { host, origin: `http://${host}`, 'content-type': 'application/json' },
},
(response) => {
let text = ''
response.on('data', (chunk) => (text += chunk))
response.on('end', () => resolve({ status: response.statusCode, text }))
},
)
call.on('error', (error) => resolve({ status: 0, text: error.message }))
call.end(body)
})
}
const checks = []
const record = (name, ok, detail) => {
checks.push({ name, ok, detail })
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name.padEnd(46)} ${detail}`)
}
console.log(`live-probe: connecting to ${ADDR}:${PORT} (non-loopback peer)\n`)
for (const identity of HOSTS) {
const market = await request({
method: 'POST',
path: PATH_MARKET,
host: identity.host,
body: '{}',
})
record(
`${identity.label}: market POST not refused`,
market.status !== 403,
`${market.status}${market.status === 403 ? ' untrusted origin' : ''}`,
)
const shell = await request({ method: 'GET', path: '/', host: identity.host })
record(`${identity.label}: shell served`, shell.status === 200, String(shell.status))
const skills = await request({ method: 'GET', path: PATH_SKILLS, host: identity.host })
record(
`${identity.label}: Skill Center answers`,
skills.status === 200,
skills.status === 200 ? 'ok' : `${skills.status}${skills.status === 400 ? ' (fence threw)' : ''}`,
)
}
// The shims and the manifest are properties of the deployment, not of any one
// identity, so they are checked once rather than per Host.
const anyIdentity = HOSTS[0].host
const index = await request({ method: 'GET', path: '/', host: anyIdentity })
const shims = index.text.includes(SHIM_MARKER)
record(
'browser shims reach the page',
shims && index.text.includes('getRandomValues') && index.text.includes('execCommand'),
shims ? 'randomUUID + clipboard' : 'absent from index.html',
)
record(
'viewport widened for standalone mode',
index.text.includes('viewport-fit=cover'),
index.text.includes('viewport-fit=cover') ? 'cover' : 'not rewritten',
)
const pwa = index.text.includes(PWA_MARKER)
record('PWA metadata reaches the page', pwa, pwa ? 'manifest link + app meta' : 'absent')
const manifest = await request({ method: 'GET', path: MANIFEST_PATH, host: anyIdentity })
let manifestOk = false
try {
manifestOk = manifest.status === 200 && JSON.parse(manifest.text).display === 'standalone'
} catch {
manifestOk = false
}
record('manifest is served', manifestOk, `${manifest.status}`)
// A plugin whose injection is unsatisfied never runs `apply`, so its routes are
// absent and this URL is answered by the core static handler, which allows only
// GET/HEAD. A registered route answers 400 (no sessionId) or 404 in the plugin's
// own JSON shape; 405 is the fall-through, and is the whole of "the Files pane
// stopped previewing anything".
const sidebar = await request({
method: 'POST',
path: '/sidebar/api/fs.tree',
host: anyIdentity,
body: '{}',
})
const sidebarMounted = sidebar.status !== 405 && sidebar.status !== 0
record(
'sidebar Files API is registered',
sidebarMounted,
sidebarMounted
? `${sidebar.status} from a registered route`
: '405 from the static handler: the plugin never mounted (is webRuntime provided?)',
)
const failed = checks.filter((check) => !check.ok)
console.log(`\n${checks.length - failed.length}/${checks.length} passed`)
if (failed.length > 0) {
console.log('\nA 403 on the market means the origin fence is still in force.')
console.log('A 400 on the Skill Center means it is still refusing (or throwing for) a remote peer.')
console.log('A missing shim or manifest usually means the updated bundle is not loaded yet.')
console.log('A 405 on the Files API means a plugin stayed pending: check the boot log for')
console.log('"did not activate" and whether the service it injects is provided.')
process.exit(1)
}
+332 -9
View File
@@ -17,13 +17,17 @@ import test from 'node:test'
const PLUGIN = '../lib/index.js'
/** A host context carrying only what the plugin touches. */
async function mockHost() {
async function mockHost({ rawServer = true, registry = true } = {}) {
const disposers = []
const hooks = new Map()
const warnings = []
const originals = {
requestRejection: function requestRejection() {
return 403
},
admit: function admit() {
return { rejection: 401 }
},
authorizeIndex: function authorizeIndex() {
return false
},
@@ -31,7 +35,12 @@ async function mockHost() {
return `${baseUrl}?token=launch-secret`
},
}
const connection = { ...originals }
const connection = {
...originals,
// Releases up to 0.2.1-alpha.2 named the owner peer `operator`; from
// 0.2.1-alpha.3 on it is `browserPeer`. A real host has one or the other.
browserPeer: { id: 'browser-peer' },
}
const provided = new Map()
const server = createServer((_request, response) => {
response.writeHead(200)
@@ -39,9 +48,49 @@ async function mockHost() {
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
// The webserver is a route registry plus an index transform list, and both
// refuse duplicates or release cleanly exactly as the real one does. Either
// seat can be withheld, so the tests can prove the plugin degrades to a
// warning rather than to a failed fiber when a release reshapes the surface.
const exact = new Map()
const taps = []
const webServer = {
port: server.address().port,
host: '0.0.0.0',
...(rawServer ? { server } : {}),
...(registry
? {
register(route) {
if (exact.has(route.path)) {
throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
}
exact.set(route.path, route)
return () => {
exact.delete(route.path)
}
},
tapIndex(transform) {
taps.push(transform)
return () => {
const at = taps.indexOf(transform)
if (at !== -1) taps.splice(at, 1)
}
},
}
: {}),
}
const ctx = {
logger: { info() {} },
webServer: { port: server.address().port, server, host: '0.0.0.0' },
// Cordis nulls a fiber's uid at the start of disposal, and leaves it
// non-null for as long as the plugin is mounted.
fiber: { uid: 1 },
logger: {
info() {},
warn(message) {
warnings.push(message)
},
},
webServer,
connection,
effect(callback) {
const dispose = callback()
@@ -50,8 +99,13 @@ async function mockHost() {
inject(_services, callback) {
callback(ctx)
},
// Cordis's `on` is additive: a plugin may subscribe to one channel more
// than once and every listener runs. The mock keeps them all, so a test
// can tell a second subscription from a replacement.
on(name, listener) {
hooks.set(name, listener)
const listeners = hooks.get(name) ?? []
listeners.push(listener)
hooks.set(name, listeners)
},
// Cordis's `get` returns undefined for a service nobody provided, and its
// `provide` throws on a second registration of the same name — both
@@ -72,6 +126,25 @@ async function mockHost() {
originals,
hooks,
provided,
exact,
taps,
warnings,
/** Apply the registered index transforms, as `renderIndex` does. */
renderIndex(html) {
return taps.reduce((current, tap) => tap(current), html)
},
/** Collect every index injection row the plugin pushed, in order. */
rows() {
const table = []
for (const listeners of hooks.values()) {
for (const listener of listeners) listener(table)
}
return table
},
/** Listeners subscribed to one channel. */
listenersFor(name) {
return hooks.get(name) ?? []
},
unload() {
for (const dispose of disposers.reverse()) dispose()
},
@@ -84,6 +157,26 @@ const CONFIG = {
forceClientLoopback: true,
grantPairing: true,
spoofSocketAddress: false,
shimClipboard: true,
shimRandomUuid: true,
shimPwa: true,
}
/** A response that records what a route handler wrote, without a socket. */
function recordingResponse() {
const captured = { status: undefined, headers: undefined, body: '' }
return {
captured,
writeHead(status, headers) {
captured.status = status
captured.headers = headers
return this
},
end(body) {
captured.body = body ?? ''
return this
},
}
}
test('the interceptor is prepended ahead of the route handler', async () => {
@@ -110,21 +203,39 @@ test('disableAuth replaces the session methods and unload restores them', async
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
assert.deepEqual(host.connection.admit({}), { peer: host.connection.browserPeer })
assert.equal(host.connection.requestRejection({}), undefined)
assert.equal(host.connection.authorizeIndex({}, {}), true)
assert.equal(host.connection.authenticatedUrl('http://box:3080'), 'http://box:3080')
host.unload()
assert.deepEqual(host.connection.admit({}), { rejection: 401 })
assert.equal(host.connection.requestRejection({}), 403)
assert.equal(host.connection.authorizeIndex({}, {}), false)
assert.equal(host.connection.authenticatedUrl('http://box:3080'), 'http://box:3080?token=launch-secret')
await new Promise((resolve) => host.server.close(resolve))
})
test('disableAuth admits through the operator peer on 0.2.1-alpha.2 and older', async () => {
const host = await mockHost()
const operator = { id: 'operator-peer' }
delete host.connection.browserPeer
host.connection.operator = operator
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
try {
assert.deepEqual(host.connection.admit({}), { peer: operator })
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})
test('disableAuth false leaves the session methods untouched', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, { ...CONFIG, disableAuth: false })
try {
assert.deepEqual(host.connection.admit({}), { rejection: 401 })
assert.equal(host.connection.requestRejection({}), 403)
assert.equal(host.connection.authorizeIndex({}, {}), false)
} finally {
@@ -138,16 +249,17 @@ test('the browser grant is pushed onto the index injection table', async () => {
const { apply, CLIENT_LOOPBACK_GRANT } = await import(PLUGIN)
apply(host.ctx, CONFIG)
try {
const listener = host.hooks.get('webserver/index-inject')
const listener = host.listenersFor('webserver/index-inject')[0]
assert.equal(typeof listener, 'function', 'subscribed to the index injection channel')
const table = []
listener(table)
assert.equal(table.length, 1)
assert.deepEqual(table[0], { kind: 'script', placement: 'head', text: CLIENT_LOOPBACK_GRANT })
const grant = table.filter((row) => row.text === CLIENT_LOOPBACK_GRANT)
assert.equal(grant.length, 1)
assert.deepEqual(grant[0], { kind: 'script', placement: 'head', text: CLIENT_LOOPBACK_GRANT })
// The table is fresh per emit, so a second gather must not be short-circuited.
const again = []
listener(again)
assert.equal(again.length, 1)
assert.equal(again.filter((row) => row.text === CLIENT_LOOPBACK_GRANT).length, 1)
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
@@ -221,3 +333,214 @@ test('grantPairing false provides nothing', async () => {
await new Promise((resolve) => host.server.close(resolve))
}
})
test('the shims reach the index and the manifest answers', async () => {
const host = await mockHost()
const { apply, MANIFEST_PATH, manifest } = await import(PLUGIN)
apply(host.ctx, CONFIG)
try {
const rows = host.rows()
const script = rows.find((row) => row.kind === 'script' && row.text?.includes('data-dsh-trustworthy-shims'))
assert.ok(script, 'the shim script row is present')
assert.equal(script.placement, 'head', 'the shim runs before any bundle')
assert.match(script.text, /getRandomValues/, 'the UUID polyfill mints from the unrestricted call')
assert.match(script.text, /execCommand\("copy"\)/, 'the clipboard fallback uses execCommand')
assert.doesNotMatch(script.text, /<\/script/i, 'the unescaped row must not close its own tag')
const pwa = rows.find((row) => row.kind === 'html')
assert.ok(pwa, 'the PWA metadata row is present')
assert.equal(pwa.placement, 'head')
assert.match(pwa.html, new RegExp(`rel="manifest" href="${MANIFEST_PATH}"`))
assert.match(pwa.html, /apple-mobile-web-app-capable/)
assert.match(pwa.html, /theme-color/)
const route = host.exact.get(MANIFEST_PATH)
assert.ok(route, 'the manifest route is registered')
assert.equal(route.kind, 'exact')
const response = recordingResponse()
route.handler({}, response)
assert.equal(response.captured.status, 200)
assert.equal(response.captured.headers['content-type'], 'application/manifest+json; charset=utf-8')
assert.deepEqual(JSON.parse(response.captured.body), manifest('DeepSeek Harness'))
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})
test('the viewport tag is widened in place, not duplicated', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
try {
const once = host.renderIndex('<head><meta name="viewport" content="width=device-width"></head>')
assert.equal(
(once.match(/name="viewport"/g) ?? []).length,
1,
'a second viewport tag would leave the effective one browser-defined',
)
assert.match(once, /content="width=device-width, viewport-fit=cover"/)
// Idempotent: a document that already declares cover is left alone.
assert.equal(host.renderIndex(once), once)
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})
test('turning the shims off leaves the index alone', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, { ...CONFIG, shimClipboard: false, shimRandomUuid: false, shimPwa: false })
try {
const rows = host.rows()
assert.equal(rows.filter((row) => row.text?.includes('data-dsh-trustworthy-shims')).length, 0, 'no shim script')
assert.equal(rows.filter((row) => row.kind === 'html').length, 0, 'no PWA metadata')
assert.equal(rows.filter((row) => row.text?.includes('ownsHost')).length, 1, 'the identity grant is unaffected')
assert.equal(host.exact.size, 0, 'no manifest route')
assert.equal(host.taps.length, 0, 'no index transform')
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})
test('the PWA shim alone does not drag the browser shims in', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, { ...CONFIG, shimClipboard: false, shimRandomUuid: false })
try {
const rows = host.rows()
assert.equal(rows.filter((row) => row.kind === 'script' && row.text?.includes('data-dsh-trustworthy-shims')).length, 0)
assert.equal(rows.filter((row) => row.kind === 'html').length, 1)
assert.equal(host.exact.size, 1)
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})
test('unload withdraws the manifest route and the index transform', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
host.unload()
assert.equal(host.exact.size, 0, 'the manifest route is released')
assert.equal(host.taps.length, 0, 'the viewport transform is released')
await new Promise((resolve) => host.server.close(resolve))
})
// The rest of the suite is about being switched off by a host that does not
// run disposers, and about a reload that gives the installation two authors.
test('a disposed fiber stops admitting requests', async () => {
// A host can take a plugin out of service by editing its patch row, and the
// harness does not always run the plugin's disposers when it does. An
// installed-but-switched-off plugin whose service methods still answer
// "admitted" is the worst case: the interface reports the plugin as off while
// the gateway stands open.
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
assert.deepEqual(host.connection.admit({}), { peer: host.connection.browserPeer }, 'admitting while mounted')
assert.equal(host.connection.requestRejection(), undefined, 'admitted while mounted')
assert.equal(host.connection.authorizeIndex(), true)
assert.equal(host.connection.authenticatedUrl('/base'), '/base')
host.ctx.fiber.uid = null
assert.deepEqual(host.connection.admit({}), { rejection: 401 }, 'the real gate answers again')
assert.equal(host.connection.requestRejection(), 403, 'the real fence answers again')
assert.equal(host.connection.authorizeIndex(), false)
assert.equal(
host.connection.authenticatedUrl('/base'),
'/base?token=launch-secret',
'the launch token comes back',
)
await new Promise((resolve) => host.server.close(resolve))
})
test('a disposed fiber stops rewriting request identity', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
const interceptor = host.server.listeners('request')[0]
const mounted = { headers: { host: '192.168.1.49:3080' } }
interceptor(mounted)
assert.equal(mounted.headers.host, `127.0.0.1:${host.ctx.webServer.port}`)
host.ctx.fiber.uid = null
const after = { headers: { host: '192.168.1.49:3080' } }
interceptor(after)
assert.equal(after.headers.host, '192.168.1.49:3080', 'a dead generation rewrites nothing')
await new Promise((resolve) => host.server.close(resolve))
})
test('a disposed fiber stops vouching for paired devices', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
const pairing = host.provided.get('remoteWebUiPairing')
assert.equal(pairing.isPairedDevice(), true)
host.ctx.fiber.uid = null
assert.equal(pairing.isPairedDevice(), false, 'an off plugin vouches for nobody')
await new Promise((resolve) => host.server.close(resolve))
})
test('a late cleanup from the previous generation leaves the reload in charge', async () => {
// The ordering the harness does not promise: on reload the successor applies
// before the predecessor's disposers run. The two generations share one
// connection service, so the predecessor's cleanup is the dangerous one — an
// unconditional restore would put the fence back while the successor still
// believed it was mounted.
const previous = await mockHost()
const current = await mockHost()
previous.ctx.connection = current.ctx.connection
const { apply } = await import(PLUGIN)
apply(previous.ctx, CONFIG)
apply(current.ctx, CONFIG)
previous.unload()
assert.equal(
current.connection.requestRejection(),
undefined,
'the successor is still admitting after the predecessor was cleaned up',
)
current.unload()
assert.equal(current.connection.requestRejection(), 403, 'the original survives both generations')
assert.equal(current.connection.authenticatedUrl('/base'), '/base?token=launch-secret')
await new Promise((resolve) => previous.server.close(resolve))
await new Promise((resolve) => current.server.close(resolve))
})
test('a webserver with no raw server is reported, not fatal', async () => {
// A release may reshape the surface the plugin reaches through. Losing an
// optional capability is a warning; throwing would fail the fiber and take
// the parts that still work down with it.
const host = await mockHost({ rawServer: false })
const { apply } = await import(PLUGIN)
assert.doesNotThrow(() => apply(host.ctx, CONFIG))
assert.equal(host.warnings.length, 1, 'the loss is reported')
assert.match(host.warnings[0], /no raw server/)
assert.equal(host.connection.requestRejection(), undefined, 'the rest still works')
host.unload()
await new Promise((resolve) => host.server.close(resolve))
})
test('a webserver with no route registry is reported, not fatal', async () => {
const host = await mockHost({ registry: false })
const { apply } = await import(PLUGIN)
assert.doesNotThrow(() => apply(host.ctx, CONFIG))
assert.equal(host.warnings.length, 1)
assert.match(host.warnings[0], /no route registry/)
assert.equal(host.connection.requestRejection(), undefined)
host.unload()
await new Promise((resolve) => host.server.close(resolve))
})
+247
View File
@@ -0,0 +1,247 @@
/**
* Behavioural check for the generation handover.
*
* The mechanism this plugin installs is a mutation of objects the harness
* owns, and a reload gives those mutations two authors. These tests are about
* the orderings — which the harness does not promise — and about the two ways
* a reload goes wrong: a predecessor's cleanup tearing down its successor, and
* a superseded installation going on acting after it was replaced.
*
* node test/handover.test.mjs
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import test from 'node:test'
import {
HANDOVER,
INTERCEPTOR,
fiberLive,
installInterceptor,
installReplacements,
originalOf,
} from '../lib/handover.js'
/** A stand-in for a service whose methods the plugin replaces. */
function service() {
return {
requestRejection: () => 403,
authorizeIndex: () => false,
authenticatedUrl: (baseUrl) => `${baseUrl}?token=secret`,
}
}
/** The replacements one generation installs, distinguishable from another's. */
function generation(tag, { live = () => true } = {}) {
const admitted = () => `admitted-by-${tag}`
return {
requestRejection: () => (live() ? admitted() : 'delegated'),
authorizeIndex: () => live(),
authenticatedUrl: (baseUrl) => (live() ? baseUrl : `${baseUrl}?token=secret`),
}
}
test('the originals are recorded once and restored on cleanup', () => {
const target = service()
const first = generation('one')
const restore = installReplacements(target, first)
assert.equal(target.requestRejection(), 'admitted-by-one')
assert.equal(target.authenticatedUrl('/base'), '/base', 'the token is gone while installed')
restore()
assert.equal(target.requestRejection(), 403, 'the fence is back')
assert.equal(target.authorizeIndex(), false)
assert.equal(target.authenticatedUrl('/base'), '/base?token=secret')
})
test('a predecessor cleanup cannot tear down its successor', () => {
// The ordering the harness does not promise: the successor installs first,
// and only then does the predecessor's disposer run. Restoring
// unconditionally here would put the true originals back while the successor
// still believed it was mounted — the plugin inert, reporting itself live.
const target = service()
const first = installReplacements(target, generation('one'))
const second = installReplacements(target, generation('two'))
first()
assert.equal(target.requestRejection(), 'admitted-by-two', 'the successor still owns the service')
assert.equal(target.authenticatedUrl('/base'), '/base')
second()
assert.equal(target.requestRejection(), 403, 'the true original survives both generations')
assert.equal(target.authenticatedUrl('/base'), '/base?token=secret')
})
test('a cleanup before the next install leaves the original intact', () => {
// The ordering the harness usually produces, asserted so that the guard is
// not accidentally relying on the unordered case.
const target = service()
const first = installReplacements(target, generation('one'))
first()
const second = installReplacements(target, generation('two'))
assert.equal(target.requestRejection(), 'admitted-by-two')
second()
assert.equal(target.requestRejection(), 403, 'not a predecessor replacement')
})
test('the original is readable from the carrier across generations', () => {
const target = service()
const first = installReplacements(target, generation('one'))
assert.equal(originalOf(target, 'requestRejection')(), 403)
installReplacements(target, generation('two'))
assert.equal(
originalOf(target, 'requestRejection')(),
403,
'a later generation must still find the true original, not the predecessor',
)
first()
assert.equal(target.requestRejection(), 'admitted-by-two')
})
test('cleanup is idempotent and a third party keeps what it replaced', () => {
const target = service()
const restore = installReplacements(target, generation('one'))
target.authorizeIndex = () => 'someone-else'
restore()
restore()
assert.equal(target.authorizeIndex(), 'someone-else', 'a foreign replacement is left alone')
assert.equal(target.requestRejection(), 403, 'and the untouched keys are restored')
})
test('a property that did not exist is removed rather than set to undefined', () => {
const target = {}
const restore = installReplacements(target, { requestRejection: () => 'admitted' })
assert.equal(target.requestRejection(), 'admitted')
restore()
assert.equal('requestRejection' in target, false)
})
test('the carrier is a shared, non-enumerable symbol property', () => {
const target = service()
const restore = installReplacements(target, generation('one'))
assert.equal(HANDOVER, Symbol.for('dsh-trustworthy.handover'), 'shared across module instances')
const descriptor = Object.getOwnPropertyDescriptor(target, HANDOVER)
assert.equal(descriptor.enumerable, false, 'invisible to a serializer walking the service')
restore()
assert.equal(target[HANDOVER], undefined, 'withdrawn once nothing is installed')
})
test('a target that refuses the carrier still coordinates', () => {
// Some hosts freeze their service objects. Coordination then only covers this
// module instance, which is still the common reload path, because a reload
// that re-imports the module usually reuses the cached one.
const target = Object.preventExtensions(service())
const first = installReplacements(target, generation('one'))
assert.equal(target.requestRejection(), 'admitted-by-one')
first()
assert.equal(target.requestRejection(), 403)
})
test('the interceptor is prepended ahead of an existing handler', () => {
const server = new EventEmitter()
const order = []
server.on('request', () => order.push('route'))
const detach = installInterceptor(server, () => order.push('interceptor'))
server.emit('request')
assert.deepEqual(order, ['interceptor', 'route'], 'the rewrite runs before any route lookup')
detach()
order.length = 0
server.emit('request')
assert.deepEqual(order, ['route'])
})
test('the interceptor covers both request and upgrade', () => {
const server = new EventEmitter()
const seen = []
installInterceptor(server, (event) => seen.push(event))
server.emit('request', 'request')
server.emit('upgrade', 'upgrade')
assert.deepEqual(seen, ['request', 'upgrade'], 'a websocket upgrade is a request too')
})
test('a superseded interceptor is retired rather than joined', () => {
// A reload can add the successor while the predecessor is still attached.
// Two listeners would both run, and the older one is a closure over a dead
// generation's configuration.
const server = new EventEmitter()
const seen = []
const first = () => seen.push('old')
const detachFirst = installInterceptor(server, first)
const second = () => seen.push('new')
const detachSecond = installInterceptor(server, second)
assert.equal(server.listenerCount('request'), 1, 'only the successor remains')
server.emit('request')
assert.deepEqual(seen, ['new'])
detachFirst()
server.emit('request')
assert.deepEqual(seen, ['new', 'new'], "the predecessor's cleanup is a harmless no-op")
detachSecond()
assert.equal(server.listenerCount('request'), 0)
detachSecond()
assert.equal(server.listenerCount('request'), 0, 'cleanup is idempotent')
})
test('the interceptor is marked so a successor can find it', () => {
const server = new EventEmitter()
const listener = () => {}
installInterceptor(server, listener)
assert.equal(listener[INTERCEPTOR], true)
assert.equal(INTERCEPTOR, Symbol.for('dsh-trustworthy.interceptor'))
})
test('a foreign listener is left in place', () => {
const server = new EventEmitter()
const foreign = () => {}
server.on('request', foreign)
const detach = installInterceptor(server, () => {})
detach()
assert.equal(server.listenerCount('request'), 1)
assert.equal(server.listeners('request')[0], foreign)
})
test('liveness answers false only on a positive signal of disposal', () => {
assert.equal(fiberLive(undefined), true, 'a host without a fiber is not a reason to disarm')
assert.equal(fiberLive(null), true)
assert.equal(fiberLive({}), true, 'an unrecognised shape is not a reason to disarm')
assert.equal(fiberLive({ uid: 0 }), true, 'the root fiber is live')
assert.equal(fiberLive({ uid: 12 }), true)
assert.equal(fiberLive({ uid: null }), false, 'cordis nulls uid at the start of disposal')
})
test('a dead generation delegates instead of admitting', () => {
// The replacement a live plugin installs on the connection service. Once its
// fiber is disposed it must behave as if the plugin were never mounted —
// otherwise switching the plugin off would leave the gateway open.
const target = service()
const fiber = { uid: 1 }
const live = () => fiberLive(fiber)
const delegate = (key, admitted) =>
function delegated(...args) {
if (live()) return admitted(...args)
const original = originalOf(target, key)
return original === undefined ? undefined : original.apply(this, args)
}
installReplacements(target, {
requestRejection: delegate('requestRejection', () => undefined),
authorizeIndex: delegate('authorizeIndex', () => true),
})
assert.equal(target.requestRejection(), undefined, 'admitted while the fiber is live')
assert.equal(target.authorizeIndex(), true)
fiber.uid = null
assert.equal(target.requestRejection(), 403, 'the fence is answered by the original again')
assert.equal(target.authorizeIndex(), false)
})
+183
View File
@@ -0,0 +1,183 @@
/**
* Behavioural check for the generated browser shim.
*
* The apply suite proves the shim reaches the page; this one proves the code
* it delivers actually works. The source is evaluated in a `node:vm` context
* standing in for a page that is not a secure context — no
* `crypto.randomUUID`, no `navigator.clipboard` — which is the whole reason
* the shim exists.
*
* node test/shims.test.mjs
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import vm from 'node:vm'
const SHIMS = '../lib/shims.js'
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
/**
* A page context lacking both secure-context APIs.
*
* @param options - how the page should behave.
* @param options.execCommandResult - what `document.execCommand('copy')` returns.
* @param options.existingClipboard - install a partial `navigator.clipboard`.
* @returns the sandbox, with a record of what the shim did to it.
*/
function page({ execCommandResult = true, existingClipboard = false } = {}) {
const calls = { appended: 0, removed: 0, selected: 0, copied: 0, attributes: [], draws: 0 }
const sandbox = {
crypto: {
// The first draw is deterministic so the UUID assertion can be exact;
// later draws shift, so a repeated UUID would prove the shim cached its
// first result rather than asking for fresh bytes each call.
getRandomValues(bytes) {
const base = calls.draws++ === 0 ? 0 : 64
for (let i = 0; i !== bytes.length; i++) bytes[i] = (base + i) & 255
return bytes
},
},
navigator: existingClipboard ? { clipboard: { readText: async () => '' } } : {},
document: {
currentScript: {
setAttribute(name, value) {
calls.attributes.push([name, value])
},
},
createElement() {
return {
style: {},
setAttribute() {},
select() {
calls.selected += 1
},
}
},
execCommand(command) {
assert.equal(command, 'copy')
calls.copied += 1
return execCommandResult
},
body: {
appendChild() {
calls.appended += 1
},
removeChild() {
calls.removed += 1
},
},
},
}
vm.createContext(sandbox)
return { sandbox, calls }
}
test('the UUID polyfill mints a v4 UUID from the unrestricted call', async () => {
const { shimSource } = await import(SHIMS)
const { sandbox, calls } = page()
assert.equal(typeof sandbox.crypto.randomUUID, 'undefined', 'the page starts without it')
vm.runInContext(shimSource({ clipboard: false, randomUuid: true }), sandbox)
assert.equal(typeof sandbox.crypto.randomUUID, 'function')
const minted = sandbox.crypto.randomUUID()
assert.match(minted, UUID_V4)
// Bytes 0..15, so the only edits are the two forced bit fields: byte 6 gains
// the version nibble (`06` -> `46`) and byte 8 the variant bits (`08` -> `88`).
assert.equal(minted, '00010203-0405-4607-8809-0a0b0c0d0e0f')
assert.notEqual(sandbox.crypto.randomUUID(), minted, 'each call reads fresh bytes')
assert.equal(calls.draws, 2, 'one random draw per minted UUID')
assert.deepEqual(calls.attributes, [['data-dsh-trustworthy-shims', '1']], 'the script is stamped')
})
test('the UUID polyfill leaves an existing implementation alone', async () => {
const { shimSource } = await import(SHIMS)
const { sandbox } = page()
const native = () => 'native'
sandbox.crypto.randomUUID = native
vm.runInContext(shimSource({ clipboard: false, randomUuid: true }), sandbox)
assert.equal(sandbox.crypto.randomUUID, native, 'a secure context keeps its own')
})
test('the clipboard fallback copies through a hidden textarea', async () => {
const { shimSource } = await import(SHIMS)
const { sandbox, calls } = page()
assert.equal(sandbox.navigator.clipboard, undefined, 'the page starts without it')
vm.runInContext(shimSource({ clipboard: true, randomUuid: false }), sandbox)
assert.equal(typeof sandbox.navigator.clipboard.writeText, 'function')
await sandbox.navigator.clipboard.writeText('copied text')
assert.equal(calls.appended, 1)
assert.equal(calls.selected, 1)
assert.equal(calls.copied, 1)
assert.equal(calls.removed, 1, 'the textarea never leaks into the document')
assert.equal(sandbox.navigator.clipboard.writeText, sandbox.navigator.clipboard.writeText)
})
test('the clipboard fallback rejects when the browser refuses', async () => {
const { shimSource } = await import(SHIMS)
const { sandbox, calls } = page({ execCommandResult: false })
vm.runInContext(shimSource({ clipboard: true, randomUuid: false }), sandbox)
await assert.rejects(() => sandbox.navigator.clipboard.writeText('nope'), /copy rejected/)
assert.equal(calls.removed, 1, 'the textarea is cleaned up even on failure')
})
test('the clipboard fallback fills a gap without replacing what works', async () => {
const { shimSource } = await import(SHIMS)
const { sandbox } = page({ existingClipboard: true })
const readText = sandbox.navigator.clipboard.readText
vm.runInContext(shimSource({ clipboard: true, randomUuid: false }), sandbox)
assert.equal(typeof sandbox.navigator.clipboard.writeText, 'function', 'the missing method is added')
assert.equal(sandbox.navigator.clipboard.readText, readText, 'the present method is untouched')
})
test('nothing is emitted when every shim is off', async () => {
const { shimSource } = await import(SHIMS)
assert.equal(shimSource({ clipboard: false, randomUuid: false }), '')
})
test('the PWA block carries the manifest link and the app metadata', async () => {
const { pwaMarkup, MANIFEST_PATH } = await import(SHIMS)
const markup = pwaMarkup({ manifestPath: MANIFEST_PATH, includeManifestLink: true })
assert.match(markup, /rel="manifest" href="\/dsh-trustworthy\/manifest\.webmanifest"/)
assert.match(markup, /apple-mobile-web-app-capable/)
assert.match(markup, /apple-mobile-web-app-status-bar-style/)
assert.match(markup, /name="theme-color"/)
// Every tag is marked, so a probe can find the block in the served document.
assert.equal((markup.match(/data-dsh-trustworthy-pwa/g) ?? []).length, 4)
assert.doesNotMatch(
pwaMarkup({ manifestPath: MANIFEST_PATH, includeManifestLink: false }),
/rel="manifest"/,
)
})
test('the viewport rewrite widens cover without duplicating the tag', async () => {
const { withCoverViewport } = await import(SHIMS)
const document = '<html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>t</title></head></html>'
const widened = withCoverViewport(document)
assert.equal((widened.match(/name="viewport"/g) ?? []).length, 1)
assert.match(widened, /content="width=device-width, initial-scale=1, viewport-fit=cover"/)
assert.equal(withCoverViewport(widened), widened, 'running twice changes nothing')
const absent = '<html><head></head></html>'
assert.equal(withCoverViewport(absent), absent, 'a document with no viewport is left alone')
})
test('the manifest is installable-shaped', async () => {
const { manifest, MANIFEST_PATH } = await import(SHIMS)
const value = manifest('DeepSeek Harness')
assert.equal(value.display, 'standalone')
assert.equal(value.start_url, '/')
assert.equal(value.scope, '/')
assert.match(value.theme_color, /^#[0-9a-f]{6}$/i)
assert.ok(value.icons.length > 0, 'a manifest with no icon is not installable')
assert.equal(MANIFEST_PATH.startsWith('/'), true)
})
+175
View File
@@ -0,0 +1,175 @@
/**
* The `webRuntime` seam: the value it publishes, and the wiring that publishes
* it only when the harness has not.
*
* The fence and apply suites cover the request identity; this one covers the
* service releases from `0.2.1-alpha.2` on stopped providing, whose absence
* leaves an injecting plugin `pending` and its routes unregistered — the 405 a
* user sees in the sidebar's Files tab.
*
* node test/web-runtime.test.mjs
*/
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import test from 'node:test'
import { Config, apply } from '../lib/index.js'
import {
WEB_RUNTIME_SERVICE,
bindAuthority,
connectionAuthorities,
trustedAuthorities,
webRuntimeShim,
} from '../lib/web-runtime.js'
/** A config with every engagement off but the one under test. */
function quietConfig(overrides = {}) {
return {
enabled: true,
disableAuth: false,
forceClientLoopback: false,
grantPairing: false,
provideWebRuntime: true,
spoofSocketAddress: false,
shimClipboard: false,
shimRandomUuid: false,
shimPwa: false,
...overrides,
}
}
/**
* A host context carrying only what `apply` touches, plus the real raw server
* the interceptor is installed on.
*
* @param options - bind host, the authorities `connection` was given, and any
* value already registered under the `webRuntime` name.
*/
async function mockHost({ host = '172.17.0.4', trustedHosts = [], webRuntime } = {}) {
const provided = new Map()
if (webRuntime !== undefined) provided.set(WEB_RUNTIME_SERVICE, webRuntime)
const logs = []
const server = createServer((_request, response) => {
response.writeHead(200)
response.end('route handler')
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const ctx = {
fiber: { uid: 1 },
logger: {
info(message) {
logs.push(message)
},
warn(message) {
logs.push(message)
},
},
webServer: { port: server.address().port, host, server },
connection: { trustedHosts },
effect() {},
inject(_services, callback) {
callback(ctx)
},
on() {},
// Cordis's `get` answers undefined for a name nobody provided, and its
// `provide` throws on a second registration of one name.
get(service) {
return provided.get(service)
},
provide(service, value) {
if (provided.has(service)) throw new Error(`service "${service}" has been registered`)
provided.set(service, value)
return () => provided.delete(service)
},
}
return { ctx, server, provided, logs }
}
test('declared authorities survive as strings and malformed entries do not', () => {
assert.deepEqual(connectionAuthorities({ connection: { trustedHosts: [] } }), [])
assert.deepEqual(
connectionAuthorities({
connection: { trustedHosts: ['ai.milesward.dev', '', 7, null, '192.168.1.49:3080'] },
}),
['ai.milesward.dev', '192.168.1.49:3080'],
)
// A harness that never built the list, or a host context without the service.
assert.deepEqual(connectionAuthorities({}), [])
assert.deepEqual(connectionAuthorities({ connection: {} }), [])
})
test('the bind address is an authority only when it names one interface', () => {
assert.equal(bindAuthority({ webServer: { host: '172.17.0.4' } }), '172.17.0.4')
assert.equal(bindAuthority({ webServer: { host: '::1' } }), '::1')
// A wildcard can be written as an authority but never means one.
for (const host of ['0.0.0.0', '::', '::0', '*', '']) {
assert.equal(bindAuthority({ webServer: { host } }), undefined, host)
}
assert.equal(bindAuthority({}), undefined)
assert.equal(bindAuthority({ webServer: {} }), undefined)
})
test('the snapshot is the declared authorities plus the bind address, deduplicated', () => {
assert.deepEqual(
trustedAuthorities({
connection: { trustedHosts: ['ai.milesward.dev', '172.17.0.4'] },
webServer: { host: '172.17.0.4' },
}),
['ai.milesward.dev', '172.17.0.4'],
)
// The wildcard bind adds nothing — alpha.1's resolveLanTrust agreed, and
// alpha.2 refuses a wildcard `--host` outright.
assert.deepEqual(
trustedAuthorities({ connection: { trustedHosts: ['app.internal'] }, webServer: { host: '0.0.0.0' } }),
['app.internal'],
)
})
test('the snapshot is read per request, not captured once', () => {
const connection = { trustedHosts: ['ai.milesward.dev'] }
const runtime = webRuntimeShim({ connection, webServer: { host: '10.0.0.7' } })
assert.deepEqual(runtime.trustedHosts, ['ai.milesward.dev', '10.0.0.7'])
assert.deepEqual(runtime.lanAddresses, [])
// A fence reads the list inside a request; a value frozen at mount time would
// go on refusing a name the `/api` gateway had since admitted.
connection.trustedHosts = ['ai.milesward.dev', 'app.internal']
assert.deepEqual(runtime.trustedHosts, ['ai.milesward.dev', 'app.internal', '10.0.0.7'])
})
test('the seam is on by default, and `apply` provides it', async (t) => {
assert.equal(Config({}).provideWebRuntime, true)
const { ctx, server, provided, logs } = await mockHost({ trustedHosts: ['ai.milesward.dev'] })
t.after(() => server.close())
apply(ctx, quietConfig())
assert.deepEqual(provided.get(WEB_RUNTIME_SERVICE)?.trustedHosts, [
'ai.milesward.dev',
'172.17.0.4',
])
assert.ok(logs.some((line) => line.includes('provided the webRuntime service')), logs.join('\n'))
})
test('an existing provider is left alone', async (t) => {
const existing = { trustedHosts: ['someone-else.example'] }
const { ctx, server, provided, logs } = await mockHost({ webRuntime: existing })
t.after(() => server.close())
apply(ctx, quietConfig())
assert.equal(provided.get(WEB_RUNTIME_SERVICE), existing)
assert.ok(logs.some((line) => line.includes('already provided')), logs.join('\n'))
})
test('the seam can be switched off, and a suspended plugin provides nothing', async (t) => {
const off = await mockHost()
t.after(() => off.server.close())
apply(off.ctx, quietConfig({ provideWebRuntime: false }))
assert.equal(off.provided.has(WEB_RUNTIME_SERVICE), false)
const suspended = await mockHost()
t.after(() => suspended.server.close())
apply(suspended.ctx, quietConfig({ enabled: false }))
assert.equal(suspended.provided.has(WEB_RUNTIME_SERVICE), false)
})