Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e89edf070 | ||
|
|
042f6539e5 | ||
|
|
8be81b51e1 |
No files matched your search
@@ -43,7 +43,7 @@ identity fence, which is what actually produces the 403.
|
||||
|
||||
## What it changes
|
||||
|
||||
Two things, in two different places.
|
||||
Four things, each one reachable only by a different kind of fence.
|
||||
|
||||
**1. Request identity, at the server.** The plugin installs `request` and
|
||||
`upgrade` listeners with `prependListener` on the raw `node:http` server the
|
||||
@@ -65,6 +65,21 @@ packaged Desktop build publishes, using the harness's own
|
||||
`webserver/index-inject` channel — the same mechanism
|
||||
`@deepseek-ai/dsh-web-app` uses for `__DSH_CONNECTION_RECOVERY__`.
|
||||
|
||||
**3. The paired-device service, for plugins that authorize an identity rather
|
||||
than an origin.** Some plugins accept a request on a loopback peer *or* on a
|
||||
live paired device, and read the second path through a service called
|
||||
`remoteWebUiPairing` rather than through any header — so neither the rewrite nor
|
||||
the browser grant reaches them. The plugin provides that service. See
|
||||
`grantPairing` below.
|
||||
|
||||
**4. Browser APIs a non-secure page is denied.** `crypto.randomUUID` and
|
||||
`navigator.clipboard` exist only in a secure context, so on a plain-HTTP LAN
|
||||
address they are simply absent, and code that assumes them fails — sometimes
|
||||
loudly, sometimes as a button that does nothing. The plugin ships shims for both,
|
||||
plus the metadata that makes the page installable on a phone. These are unrelated
|
||||
to request identity and are here so that one plugin covers the whole "I want to
|
||||
reach this from another machine" problem. See `shimClipboard` below.
|
||||
|
||||
It deliberately does **not** touch `sec-fetch-site`. The harness refuses
|
||||
`cross-site` outright, and that refusal is the last judgement a browser makes
|
||||
on its own behalf. Rewriting it would make a page on another origin look
|
||||
@@ -102,7 +117,11 @@ Override any key in your profile's `cordis.patch.yml`:
|
||||
enabled: true
|
||||
disableAuth: true
|
||||
forceClientLoopback: true
|
||||
grantPairing: true
|
||||
spoofSocketAddress: false
|
||||
shimClipboard: true
|
||||
shimRandomUuid: true
|
||||
shimPwa: true
|
||||
```
|
||||
|
||||
| Key | Default | Effect |
|
||||
@@ -110,23 +129,73 @@ 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. |
|
||||
| `spoofSocketAddress` | `false` | Also reports the peer address as loopback, for plugins with no pairing path. See below. |
|
||||
| `shimClipboard` | `true` | Restores `navigator.clipboard.writeText` on plain HTTP. See below. |
|
||||
| `shimRandomUuid` | `true` | Restores `crypto.randomUUID` on plain HTTP. See below. |
|
||||
| `shimPwa` | `true` | Serves a manifest and adds the installable-app metadata. See below. |
|
||||
|
||||
### `grantPairing` — the Skill Center
|
||||
|
||||
Some plugins authorize a request either by a loopback peer or by a **live paired
|
||||
device**, and they read that second path through a service named
|
||||
`remoteWebUiPairing` rather than through any header. Providing the service is
|
||||
the seam those plugins designed for this situation, and it is what makes
|
||||
`@linxin666/dsh-client-ui-skill-explorer` work from a LAN address or a tunnel.
|
||||
|
||||
It also repairs a defect in that plugin. It reads the service as
|
||||
`ctx.get("remoteWebUiPairing", false)`, intending `false` as a default, but
|
||||
Cordis's signature is `get(name, strict)` — so `false` is read as the strict
|
||||
flag, the call returns `undefined`, and evaluation falls through to
|
||||
`ctx.remoteWebUiPairing`, which Cordis answers by **throwing** for a service
|
||||
nobody provided. The plugin's own `403 forbidden: loopback-only` therefore
|
||||
never reaches the client; the webserver turns the throw into an opaque
|
||||
`400`. Because the throw sits behind the loopback test, it happens only for a
|
||||
remote window — which is why the Skill Center appears to work on the host and
|
||||
fails everywhere else.
|
||||
|
||||
**Set `grantPairing: false` if you install `@linxin666/dsh-remote-web-ui`.** It
|
||||
provides the same service, and two providers for one name is a load error. The
|
||||
plugin detects an existing provider and defers to it, but the reverse order
|
||||
cannot be detected, so leave one of them off.
|
||||
|
||||
### `spoofSocketAddress`
|
||||
|
||||
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-disable-untrusted-origin/manifest.webmanifest`, links it, and widens the
|
||||
viewport tag to `viewport-fit=cover` so Add to Home Screen produces a
|
||||
standalone app rather than a bookmark.
|
||||
|
||||
The harness's own code no longer needs either shim — `@deepseek-ai/dsh-util-crypto`
|
||||
mints UUIDs from `getRandomValues`, and its lint rules point callers there — but
|
||||
third-party plugin bundles are not obliged to, and one that shipped recently
|
||||
(`billion-context`) calls `navigator.clipboard.writeText` **unguarded**. All
|
||||
three are `true` by default; serving over HTTPS makes the first two redundant.
|
||||
|
||||
---
|
||||
|
||||
@@ -139,32 +208,48 @@ them will break it:
|
||||
| Depends on | Where |
|
||||
|---|---|
|
||||
| `webServer.server` — the raw `node:http` server | `@deepseek-ai/dsh-host-webserver` |
|
||||
| `webServer.register` / `webServer.tapIndex` — the route registry and index transforms | `@deepseek-ai/dsh-host-webserver` |
|
||||
| `connection.requestRejection` / `authorizeIndex` / `authenticatedUrl` | `@deepseek-ai/dsh-client-connection` |
|
||||
| the `webserver/index-inject` channel and the `ownsHost` marker | webserver + connection client |
|
||||
|
||||
If `webServer.server` is ever absent the plugin throws at load with a clear
|
||||
message rather than failing quietly.
|
||||
|
||||
### How this differs from `@goodandready/dsh-lanmode`
|
||||
### Relationship to `@goodandready/dsh-lanmode`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -173,12 +258,22 @@ listener. The two are not mutually exclusive, but running both is redundant and
|
||||
```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/shims.test.mjs # executes the generated shim in a context without the APIs
|
||||
node scripts/live-probe.mjs # against the running harness, over a non-loopback peer
|
||||
```
|
||||
|
||||
`test/fence.test.mjs` loads `sameOrigin()` from the installed `dshmarket` when a
|
||||
profile is present and says which source it used, so a run against a bundled
|
||||
copy is never mistaken for a run against the real thing. 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. The suites need no dependency tree beyond a resolvable
|
||||
`@deepseek-ai/schemastery` for the second.
|
||||
|
||||
`scripts/live-probe.mjs` is the one that matters after a change: it connects to a
|
||||
**non-loopback** local address, because the Skill Center fences on the socket
|
||||
address as well as the `Host`, so a probe over `127.0.0.1` can never reproduce a
|
||||
remote client and always looks like it passes.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+13
-4
@@ -4,13 +4,18 @@
|
||||
# (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
|
||||
# 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:
|
||||
@@ -20,4 +25,8 @@
|
||||
enabled: true
|
||||
disableAuth: true
|
||||
forceClientLoopback: true
|
||||
grantPairing: true
|
||||
spoofSocketAddress: false
|
||||
shimClipboard: true
|
||||
shimRandomUuid: true
|
||||
shimPwa: true
|
||||
+145
-7
@@ -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,9 @@
|
||||
* 2. The browser-side `isLoopback` flag is granted over the harness's own
|
||||
* `webserver/index-inject` channel, the same mechanism
|
||||
* `@deepseek-ai/dsh-web-app` uses for `__DSH_CONNECTION_RECOVERY__`.
|
||||
* 3. The index also carries shims for `crypto.randomUUID` and
|
||||
* `navigator.clipboard`, both of which exist only in a secure context, plus
|
||||
* the metadata that makes the page installable on a phone.
|
||||
*
|
||||
* Security: this is a deliberate and total removal of the request-identity
|
||||
* fence across the whole HTTP surface. It defends against DNS rebinding and
|
||||
@@ -38,8 +42,16 @@ import {
|
||||
loopbackise,
|
||||
spoofPeerAddress,
|
||||
} from './authority.js'
|
||||
import {
|
||||
MANIFEST_PATH,
|
||||
manifest,
|
||||
pwaMarkup,
|
||||
shimSource,
|
||||
withCoverViewport,
|
||||
} from './shims.js'
|
||||
|
||||
export { CLIENT_LOOPBACK_GRANT, loopbackAuthority, loopbackise, spoofPeerAddress }
|
||||
export { MANIFEST_PATH, manifest, pwaMarkup, shimSource, withCoverViewport }
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'disable-untrusted-origin'
|
||||
@@ -47,6 +59,13 @@ export const name = 'disable-untrusted-origin'
|
||||
/** 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 +91,49 @@ 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.',
|
||||
),
|
||||
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.',
|
||||
),
|
||||
})
|
||||
|
||||
@@ -153,6 +205,32 @@ export function apply(ctx, config) {
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
if (ctx.get(PAIRING_SERVICE) === undefined) {
|
||||
ctx.provide(PAIRING_SERVICE, { isPairedDevice: () => true })
|
||||
} else {
|
||||
ctx.logger?.info(
|
||||
`disable-untrusted-origin: ${PAIRING_SERVICE} is already provided; leaving that provider in place`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.forceClientLoopback) {
|
||||
ctx.inject(['webServer'], (webCtx) => {
|
||||
// `on` takes options, not a label; the subscription lives with the
|
||||
@@ -163,6 +241,66 @@ 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) {
|
||||
// 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()
|
||||
},
|
||||
'disable-untrusted-origin: browser shims',
|
||||
)
|
||||
}
|
||||
|
||||
ctx.logger?.info(
|
||||
`disable-untrusted-origin: 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-duo-shims'
|
||||
|
||||
/** Attribute marking the injected PWA metadata block. */
|
||||
export const PWA_MARKER = 'data-dsh-duo-pwa'
|
||||
|
||||
/** Where the plugin serves its PWA manifest from. */
|
||||
export const MANIFEST_PATH = '/dsh-disable-untrusted-origin/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' }],
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "0.1.2",
|
||||
"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, optionally removes the browser-session check, and restores the browser APIs a non-secure page is denied.",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./authority": "./lib/authority.js",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./shims": "./lib/shims.js"
|
||||
},
|
||||
"files": [
|
||||
"lib/",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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*, and
|
||||
* 4. the browser shims and the PWA manifest actually reach the page.
|
||||
*
|
||||
* 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}`)
|
||||
|
||||
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.')
|
||||
process.exit(1)
|
||||
}
|
||||
+237
-7
@@ -32,15 +32,42 @@ 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.
|
||||
const exact = new Map()
|
||||
const taps = []
|
||||
const webServer = {
|
||||
port: server.address().port,
|
||||
server,
|
||||
host: '0.0.0.0',
|
||||
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' },
|
||||
webServer,
|
||||
connection,
|
||||
effect(callback) {
|
||||
const dispose = callback()
|
||||
@@ -49,8 +76,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 +102,58 @@ async function mockHost() {
|
||||
connection,
|
||||
originals,
|
||||
hooks,
|
||||
provided,
|
||||
exact,
|
||||
taps,
|
||||
/** 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 +207,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 +247,144 @@ 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-duo-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-duo-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-duo-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))
|
||||
})
|
||||
@@ -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-duo-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-disable-untrusted-origin\/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-duo-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)
|
||||
})
|
||||
Reference in new issue
Block a user