Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ff536687 | ||
|
|
b7fa429b50 | ||
|
|
80fb6b6f4f | ||
|
|
c6ae15f829 | ||
|
|
3e89edf070 | ||
|
|
042f6539e5 | ||
|
|
8be81b51e1 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# 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.** Every browser
|
||||
window is treated as if it were running on the harness machine itself: no process
|
||||
token, no declared trusted host, and no per-plugin origin fence, because one
|
||||
interceptor answers every request with the loopback authority.
|
||||
|
||||
A window that reaches the harness by a LAN address, a DNS name, or a tunnel
|
||||
authenticates normally and then fails every mutating request with
|
||||
`403 {"error":"untrusted origin"}`. Cards render, but Install, Update, toggle,
|
||||
and "Update all" do nothing that the user can see. This plugin removes the
|
||||
distinction between a local window and a remote one. No token, no
|
||||
`--trusted-host`, no per-plugin configuration.
|
||||
distinction between a local window and a remote one — with no token, no
|
||||
`--trusted-host`, no per-plugin configuration, and no second listener on a
|
||||
second port.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +47,7 @@ identity fence, which is what actually produces the 403.
|
||||
|
||||
## What it changes
|
||||
|
||||
Two things, in two different places.
|
||||
Five things, each one reachable only by a different kind of fence.
|
||||
|
||||
**1. Request identity, at the server.** The plugin installs `request` and
|
||||
`upgrade` listeners with `prependListener` on the raw `node:http` server the
|
||||
@@ -65,6 +69,29 @@ packaged Desktop build publishes, using the harness's own
|
||||
`webserver/index-inject` channel — the same mechanism
|
||||
`@deepseek-ai/dsh-web-app` uses for `__DSH_CONNECTION_RECOVERY__`.
|
||||
|
||||
**3. The paired-device service, for plugins that authorize an identity rather
|
||||
than an origin.** Some plugins accept a request on a loopback peer *or* on a
|
||||
live paired device, and read the second path through a service called
|
||||
`remoteWebUiPairing` rather than through any header — so neither the rewrite nor
|
||||
the browser grant reaches them. The plugin provides that service. See
|
||||
`grantPairing` below.
|
||||
|
||||
**4. Browser APIs a non-secure page is denied.** `crypto.randomUUID` and
|
||||
`navigator.clipboard` exist only in a secure context, so on a plain-HTTP LAN
|
||||
address they are simply absent, and code that assumes them fails — sometimes
|
||||
loudly, sometimes as a button that does nothing. The plugin ships shims for both,
|
||||
plus the metadata that makes the page installable on a phone. These are unrelated
|
||||
to request identity and are here so that one plugin covers the whole "I want to
|
||||
reach this from another machine" problem. See `shimClipboard` below.
|
||||
|
||||
**5. The `webRuntime` service, for plugins that never get to fence at all.** A
|
||||
plugin whose injection list names a service nobody provides is held `pending` by
|
||||
Cordis: its routes are never registered, and the URLs its pane calls answer `405`
|
||||
from the core static handler. Releases from `0.2.1-alpha.2` stopped providing
|
||||
`webRuntime`, which is how the sidebar's Files pane started failing to preview or
|
||||
download anything. The plugin provides it — when nothing else has — built from
|
||||
the trust data the harness still holds. See `provideWebRuntime` below.
|
||||
|
||||
It deliberately does **not** touch `sec-fetch-site`. The harness refuses
|
||||
`cross-site` outright, and that refusal is the last judgement a browser makes
|
||||
on its own behalf. Rewriting it would make a page on another origin look
|
||||
@@ -76,10 +103,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.
|
||||
@@ -87,9 +114,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
|
||||
@@ -97,12 +150,17 @@ 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
|
||||
forceClientLoopback: true
|
||||
grantPairing: true
|
||||
provideWebRuntime: true
|
||||
spoofSocketAddress: false
|
||||
shimClipboard: true
|
||||
shimRandomUuid: true
|
||||
shimPwa: true
|
||||
```
|
||||
|
||||
| Key | Default | Effect |
|
||||
@@ -110,75 +168,400 @@ Override any key in your profile's `cordis.patch.yml`:
|
||||
| `enabled` | `true` | Master switch. `false` loads the plugin and changes nothing. |
|
||||
| `disableAuth` | `true` | Also removes the browser-session check, so no process token is needed. **This is the one that removes access control.** |
|
||||
| `forceClientLoopback` | `true` | Grants the browser-side `isLoopback` flag so the settings and model surfaces act as they do on the host. |
|
||||
| `spoofSocketAddress` | `false` | Also reports the peer address as loopback. See below. |
|
||||
| `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. |
|
||||
|
||||
### `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
|
||||
|
||||
Some plugins authorize a request either by a loopback peer or by a **live paired
|
||||
device**, and they read that second path through a service named
|
||||
`remoteWebUiPairing` rather than through any header. Providing the service is
|
||||
the seam those plugins designed for this situation, and it is what makes
|
||||
`@linxin666/dsh-client-ui-skill-explorer` work from a LAN address or a tunnel.
|
||||
|
||||
It also repairs a defect in that plugin. It reads the service as
|
||||
`ctx.get("remoteWebUiPairing", false)`, intending `false` as a default, but
|
||||
Cordis's signature is `get(name, strict)` — so `false` is read as the strict
|
||||
flag, the call returns `undefined`, and evaluation falls through to
|
||||
`ctx.remoteWebUiPairing`, which Cordis answers by **throwing** for a service
|
||||
nobody provided. The plugin's own `403 forbidden: loopback-only` therefore
|
||||
never reaches the client; the webserver turns the throw into an opaque
|
||||
`400`. Because the throw sits behind the loopback test, it happens only for a
|
||||
remote window — which is why the Skill Center appears to work on the host and
|
||||
fails everywhere else.
|
||||
|
||||
**Set `grantPairing: false` if you install `@linxin666/dsh-remote-web-ui`.** It
|
||||
provides the same service, and two providers for one name is a load error. The
|
||||
plugin detects an existing provider and defers to it, but the reverse order
|
||||
cannot be detected, so leave one of them off.
|
||||
|
||||
### `spoofSocketAddress`
|
||||
|
||||
A minority of plugins fence on `request.socket.remoteAddress` rather than on
|
||||
`Host`. No header rewrite can satisfy those; the plugin has to report the peer
|
||||
address as loopback. The skill explorer (`@linxin666/dsh-client-ui-skill-explorer`)
|
||||
is the one shipped today — over a LAN or a tunnel its routes answer
|
||||
`403 forbidden: loopback-only`, and its skill cards do not work until this is
|
||||
enabled.
|
||||
For plugins that fence on `request.socket.remoteAddress` and offer **no**
|
||||
paired-device path, no header rewrite can reach them and no service can satisfy
|
||||
them; the plugin has to report the peer address as loopback.
|
||||
|
||||
The socket is reused across keep-alive requests, so this is a property of the
|
||||
*connection*, not the request, and it is a lie every consumer of the peer
|
||||
address will believe: a reverse-proxy plugin's client-IP attribution, an access
|
||||
log, or a ban list. If you also run `@goodandready/dsh-lanmode`, leave this
|
||||
off — lanmode's device registry, per-device revocation, and IP bans all read
|
||||
the peer address, and this makes them see every client as the same machine.
|
||||
log, or a ban list. If you run `@goodandready/dsh-lanmode`, leave this off —
|
||||
its device registry, per-device revocation, and IP bans all read the peer
|
||||
address, and this makes them see every client as the same machine. Prefer
|
||||
`grantPairing` where a plugin supports it.
|
||||
|
||||
### `shimClipboard`, `shimRandomUuid`, `shimPwa` — the browser side
|
||||
|
||||
These have nothing to do with request identity. They are here because a page
|
||||
served over plain HTTP from anything but `localhost` is **not a secure
|
||||
context**, and the browser withholds two APIs from it:
|
||||
|
||||
| Withheld | Consequence | Restored by |
|
||||
|---|---|---|
|
||||
| `crypto.randomUUID` | code that mints IDs throws on load | `shimRandomUuid` |
|
||||
| `navigator.clipboard` | every Copy button fails silently | `shimClipboard` |
|
||||
|
||||
The UUID is minted from `crypto.getRandomValues`, which is *not* restricted to
|
||||
secure contexts; the clipboard falls back to a hidden textarea and
|
||||
`document.execCommand('copy')`. `shimPwa` additionally serves a manifest at
|
||||
`/dsh-trustworthy/manifest.webmanifest`, links it, and widens the
|
||||
viewport tag to `viewport-fit=cover` so Add to Home Screen produces a
|
||||
standalone app rather than a bookmark.
|
||||
|
||||
Note that the harness frontend **already ships a manifest** — a static
|
||||
`manifest.webmanifest` with `display: "fullscreen"`, linked relatively from its
|
||||
own `index.html` and served from the fallback seat. So a page with `shimPwa` on
|
||||
carries two `rel="manifest"` links. That is harmless but worth knowing: the
|
||||
browser takes the **first** link in document order, and index injections land at
|
||||
the top of `<head>`, so this plugin's manifest is the one that wins — reporting
|
||||
`display: "standalone"`, which keeps the status bar that `fullscreen` hides.
|
||||
Nothing is string-patched to achieve it; the added link simply precedes the
|
||||
shipped one.
|
||||
|
||||
The harness's own code no longer needs either shim — `@deepseek-ai/dsh-util-crypto`
|
||||
mints UUIDs from `getRandomValues`, and its lint rules point callers there — but
|
||||
third-party plugin bundles are not obliged to, and one that shipped recently
|
||||
(`billion-context`) calls `navigator.clipboard.writeText` **unguarded**. All
|
||||
three are `true` by default; serving over HTTPS makes the first two redundant.
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
Verified against **DSH 0.1.7-rc.2**. 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**, and
|
||||
**0.2.1-alpha.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.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.
|
||||
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:
|
||||
|
||||
### How this differs from `@goodandready/dsh-lanmode`
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
### What was checked on 0.2.1-alpha.2
|
||||
|
||||
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.
|
||||
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 is inert with the default `disableAuth: true`, because the gateway
|
||||
never reaches the fence. It is the one thing that can still refuse a request
|
||||
with `disableAuth: false`.
|
||||
- Rewriting `Host` changes the **cookie authority** the harness derives from it
|
||||
(`dsh-auth-<sha256(authority)>`). With `disableAuth: false` that invalidates
|
||||
sessions minted under the hostname you used before — one re-login — and
|
||||
afterwards a single login covers every entry domain instead of one per domain.
|
||||
Inert with `disableAuth: true`.
|
||||
- `Referer` is read by no layer of the harness, core or plugin. It is rewritten
|
||||
only for third-party code that might read it.
|
||||
|
||||
### Relationship to `@goodandready/dsh-lanmode`
|
||||
|
||||
lanmode approaches the same problem from outside: it opens a **second listener**
|
||||
on its own port and proxies to the harness loopback port, rewriting `Host` and
|
||||
`Origin` on the way through. That indirection is why it never needed to touch
|
||||
harness internals — and why every request that reached the harness arrived as
|
||||
loopback and passed every fence.
|
||||
|
||||
It has three properties worth knowing before you run it alongside this plugin:
|
||||
|
||||
- In `mode: auto` it opens **no listener at all** when something already answers
|
||||
on the harness port — and a `0.0.0.0` bind looks exactly like that from the
|
||||
inside. Its dedicated port then simply never opens, while its health page still
|
||||
reports `mode: proxy` and a list of active shims.
|
||||
- Its settings shim patches a line out of the built client bundle. That line no
|
||||
longer exists in **0.1.7-rc.2**, so the shim is a silent no-op in both 0.8.3
|
||||
and 0.8.7. `forceClientLoopback` here replaces it through a supported channel
|
||||
instead of a string match.
|
||||
- Its `crypto.randomUUID` and clipboard shims are the same ones this plugin now
|
||||
ships, and its PWA work is `shimPwa`.
|
||||
|
||||
So the two overlap almost entirely. Running both is redundant, and
|
||||
`spoofSocketAddress` in particular will confuse lanmode's device tracking — but
|
||||
neither will break the other if you do.
|
||||
|
||||
`shimClipboard`, `shimRandomUuid`, and `shimPwa` exist so that lanmode is not
|
||||
needed for the browser side either; with them on, the only things it still owns
|
||||
are its mobile navigation UI, its QR tool, and its device registry.
|
||||
|
||||
### Relationship to `@mzzsfy/dsh-auto-trust-all`
|
||||
|
||||
The other published approach to this problem, and a good one — it solves it by
|
||||
**declaring** the arriving host, wrapping the webserver's route-registration
|
||||
methods and adding each `Host` it sees to both trusted-host lists, under an LRU
|
||||
cap.
|
||||
|
||||
The difference is reach. A declaration only helps a fence that consults the
|
||||
list, and the fences users actually hit do not: they compare against a literal
|
||||
loopback value, in versions published before the list existed. Declaring the
|
||||
host leaves those untouched; rewriting the request identity satisfies them. This
|
||||
plugin also has nothing to cap, because it declares nothing.
|
||||
|
||||
The reload guards here — a generation-owned carrier, identity-checked teardown,
|
||||
per-request liveness, and a tagged interceptor that retires its predecessor —
|
||||
come from the same problem being solved there. That project's convention is also
|
||||
worth stating, because it predicts what is coming: its own review standard
|
||||
declares *any* audit finding of "missing Host validation / DNS-rebinding defence
|
||||
/ token validation" **invalid** in its packages. Third-party loopback-only
|
||||
fences are therefore a deliberate ecosystem position, not an oversight, and more
|
||||
of them will appear. `sec-fetch-site` and the cookie authority above are the two
|
||||
limits inherited from the harness side.
|
||||
|
||||
### What it does not fix
|
||||
|
||||
- Plugin routes that require a **paired device** rather than a trusted origin
|
||||
(`@linxin666/dsh-remote-web-ui` and anything keying on its pairing service).
|
||||
Those authorize an identity, not an origin, so no header rewrite reaches them.
|
||||
- Anything gating on TLS state, or on a proxy's own authentication.
|
||||
- A plugin that both fences on the peer address *and* offers no pairing path —
|
||||
enable `spoofSocketAddress` for that, with the caveat above.
|
||||
- Whether a remote window is *allowed* is separate from whether it is *safe*.
|
||||
This plugin answers the first question only; see the warning at the top.
|
||||
- lanmode's mobile navigation UI, its QR tool, and its device registry. Those are
|
||||
mobile conveniences rather than capabilities, and none of them is reproduced
|
||||
here.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```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 — **59 tests** (fence 8, apply 20, 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.
|
||||
|
||||
`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.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
@@ -1,23 +1,32 @@
|
||||
# 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-session check is removed, the browser is granted the
|
||||
# host's own isLoopback flag, and the APIs a non-secure page is denied are
|
||||
# restored. Override any key in your profile patch:
|
||||
#
|
||||
# - id: disable-untrusted-origin
|
||||
# - id: trustworthy
|
||||
# config:
|
||||
# disableAuth: false # keep the process token, disarm only the fence
|
||||
# spoofSocketAddress: true # also satisfy socket-based fences
|
||||
# disableAuth: false # keep the process token, disarm only the fence
|
||||
# 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
|
||||
forceClientLoopback: true
|
||||
grantPairing: true
|
||||
spoofSocketAddress: false
|
||||
shimClipboard: true
|
||||
shimRandomUuid: true
|
||||
shimPwa: true
|
||||
+191
@@ -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
|
||||
}
|
||||
+294
-48
@@ -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,8 @@
|
||||
* 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.
|
||||
*
|
||||
* 1. A `request`/`upgrade` listener installed ahead of every route answers
|
||||
* each connection with the loopback authority. One interception covers the
|
||||
@@ -20,6 +21,31 @@
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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: this is a deliberate and total removal of the request-identity
|
||||
* fence across the whole HTTP surface. It defends against DNS rebinding and
|
||||
@@ -28,7 +54,7 @@
|
||||
* turning it on, and keep the listening port behind something that
|
||||
* authenticates on your behalf.
|
||||
*
|
||||
* @module dsh-disable-untrusted-origin
|
||||
* @module dsh-trustworthy
|
||||
*/
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
@@ -38,15 +64,53 @@ 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']
|
||||
|
||||
/**
|
||||
* Service name sibling plugins look up to ask "is this a paired device?".
|
||||
* `@linxin666/dsh-remote-web-ui` provides it; so does this plugin, when
|
||||
* `grantPairing` is on and nothing else has.
|
||||
*/
|
||||
const PAIRING_SERVICE = 'remoteWebUiPairing'
|
||||
|
||||
export const Config = z.object({
|
||||
enabled: z
|
||||
.boolean()
|
||||
@@ -72,16 +136,60 @@ export const Config = z.object({
|
||||
+ 'channel, so the settings and model surfaces act as they do on the '
|
||||
+ 'host instead of falling back to in-memory state.',
|
||||
),
|
||||
grantPairing: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.description(
|
||||
'Provide the paired-device service that plugins such as the skill '
|
||||
+ 'explorer accept as an alternative to a loopback peer, so their '
|
||||
+ 'routes answer a remote window instead of refusing it. Leave this '
|
||||
+ '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)
|
||||
.description(
|
||||
'Also report the peer address as loopback, for the few plugins that '
|
||||
+ 'fence on request.socket.remoteAddress instead of Host (the skill '
|
||||
+ 'explorer is the one shipped today). This is a property of the '
|
||||
+ 'connection, not the request, and every consumer of the peer address '
|
||||
+ 'will believe it — including a reverse proxy plugin\'s client-IP '
|
||||
+ 'attribution and its ban list.',
|
||||
'Also report the peer address as loopback, for plugins that fence on '
|
||||
+ 'request.socket.remoteAddress with no paired-device path of their '
|
||||
+ 'own. This is a property of the connection, not the request, and '
|
||||
+ '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.',
|
||||
),
|
||||
})
|
||||
|
||||
@@ -93,7 +201,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
|
||||
}
|
||||
|
||||
@@ -103,54 +211,119 @@ 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 `/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',
|
||||
)
|
||||
//
|
||||
// `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 restore = installReplacements(connection, {
|
||||
requestRejection: delegate('requestRejection', () => undefined),
|
||||
authorizeIndex: delegate('authorizeIndex', () => true),
|
||||
authenticatedUrl: delegate('authenticatedUrl', (baseUrl) => baseUrl),
|
||||
})
|
||||
ctx.effect(() => restore, 'trustworthy: authentication bypass')
|
||||
}
|
||||
|
||||
if (config.grantPairing) {
|
||||
// Some plugins authorize a request either by a loopback peer or by a live
|
||||
// paired device, and read that second path through a service named
|
||||
// `remoteWebUiPairing` rather than through any header. Providing the
|
||||
// service is the seam those plugins designed for this situation.
|
||||
//
|
||||
// It also repairs a defect they carry. `@linxin666/dsh-client-ui-skill-explorer`
|
||||
// reads the service as `ctx.get("remoteWebUiPairing", false)`, intending
|
||||
// `false` as a default, but Cordis's signature is `get(name, strict)`, so
|
||||
// that call returns `undefined` and falls through to `ctx.remoteWebUiPairing`
|
||||
// — and Cordis answers a read of a service nobody provided by throwing.
|
||||
// The plugin's own 403 never reaches the client; the webserver turns the
|
||||
// throw into an opaque 400. Because the throw sits behind the loopback
|
||||
// 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. 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: () => fiberLive(ctx.fiber) })
|
||||
} else {
|
||||
ctx.logger?.info(
|
||||
`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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.forceClientLoopback) {
|
||||
@@ -163,8 +336,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
@@ -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' }],
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
},
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "dsh-disable-untrusted-origin",
|
||||
"version": "0.1.0",
|
||||
"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.3.0",
|
||||
"description": "Removes the untrusted-origin restriction from DeepSeek Harness. Every browser window is treated as if it were running on the harness machine itself: no process token, no declared trusted host, and no per-plugin origin fence, because one interceptor answers every request with the loopback authority.",
|
||||
"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/",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+367
-9
@@ -17,9 +17,10 @@ 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
|
||||
@@ -32,15 +33,56 @@ async function mockHost() {
|
||||
},
|
||||
}
|
||||
const connection = { ...originals }
|
||||
const provided = new Map()
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200)
|
||||
response.end('route handler')
|
||||
})
|
||||
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()
|
||||
@@ -49,8 +91,24 @@ 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
|
||||
// behaviours the plugin has to respect.
|
||||
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 {
|
||||
@@ -59,13 +117,59 @@ async function mockHost() {
|
||||
connection,
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG = { enabled: true, disableAuth: true, forceClientLoopback: true, spoofSocketAddress: false }
|
||||
const CONFIG = {
|
||||
enabled: true,
|
||||
disableAuth: true,
|
||||
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 () => {
|
||||
const host = await mockHost()
|
||||
@@ -119,16 +223,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))
|
||||
@@ -158,3 +263,256 @@ test('unload removes the interceptor', async () => {
|
||||
assert.equal(host.server.listeners('upgrade').length, 0)
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
})
|
||||
|
||||
test('the pairing grant is provided and answers every request', async () => {
|
||||
const host = await mockHost()
|
||||
const { apply } = await import(PLUGIN)
|
||||
apply(host.ctx, CONFIG)
|
||||
try {
|
||||
const service = host.provided.get('remoteWebUiPairing')
|
||||
assert.notEqual(service, undefined, 'service is provided')
|
||||
assert.equal(typeof service.isPairedDevice, 'function')
|
||||
assert.equal(service.isPairedDevice({ headers: {} }), true, 'any request reads as a paired device')
|
||||
} finally {
|
||||
host.unload()
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
test('an existing pairing provider is never clobbered', async () => {
|
||||
// dsh-remote-web-ui provides the same service; a second registration is a
|
||||
// load error, so the plugin must defer to whatever is already there.
|
||||
const host = await mockHost()
|
||||
const real = { isPairedDevice: () => false }
|
||||
host.provided.set('remoteWebUiPairing', real)
|
||||
const { apply } = await import(PLUGIN)
|
||||
apply(host.ctx, CONFIG)
|
||||
try {
|
||||
assert.equal(host.provided.get('remoteWebUiPairing'), real, 'the existing provider survives')
|
||||
assert.equal(host.provided.get('remoteWebUiPairing').isPairedDevice({}), false)
|
||||
} finally {
|
||||
host.unload()
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
test('grantPairing false provides nothing', async () => {
|
||||
const host = await mockHost()
|
||||
const { apply } = await import(PLUGIN)
|
||||
apply(host.ctx, { ...CONFIG, grantPairing: false })
|
||||
try {
|
||||
assert.equal(host.provided.has('remoteWebUiPairing'), false)
|
||||
} finally {
|
||||
host.unload()
|
||||
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.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.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))
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user