3 Commits
Author SHA1 Message Date
mward4 3e89edf070 v0.1.2: restore the browser APIs a non-secure page is denied
A page served over plain HTTP from anything but localhost is not a
secure context, so crypto.randomUUID and navigator.clipboard are simply
absent. Code that assumes them fails, sometimes loudly and sometimes as
a button that does nothing — billion-context calls
navigator.clipboard.writeText unguarded today.

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

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

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

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

The Skill Center check connects to a non-loopback local address on purpose.
Its fence requires a loopback socket as well as a loopback Host, so a probe
over 127.0.0.1 can never reproduce a remote client and always appears to
pass.
2026-09-27 05:36:16 -04:00
mward4 8be81b51e1 Provide the paired-device service so the Skill Center works remotely
@linxin666/dsh-client-ui-skill-explorer authorizes a request either by a
loopback peer or by a live paired device, reading that second path through
a service named remoteWebUiPairing. Nothing on a normal install provides
it, and the plugin's read of it throws: it calls
ctx.get("remoteWebUiPairing", false) intending a default, but Cordis's
signature is get(name, strict), so that call returns undefined and falls
through to ctx.remoteWebUiPairing, which Cordis answers by throwing for a
service nobody provided. The webserver turns the throw into an opaque 400,
so the plugin's own 403 never reaches the client.

The throw sits behind the loopback test, which is why the Skill Center
works on the host and fails from a LAN address or a tunnel.

grantPairing (default true) provides the service, satisfying the check the
plugin designed for it and removing the throw, without reporting a false
peer address. It defers to an existing provider and must stay off when
@linxin666/dsh-remote-web-ui is installed, since two providers for one
service name is a load error.

spoofSocketAddress remains for plugins that fence on the peer address with
no pairing path of their own.
2026-09-27 05:34:28 -04:00
8 changed files with 1010 additions and 48 deletions

No files matched your search

+122 -27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,156 @@
/**
* Browser shims for the capabilities a browser withholds outside a secure
* context, plus the PWA metadata that makes the page installable.
*
* A page served over plain HTTP from anything other than localhost is not a
* secure context, so two Web APIs the UI touches are simply absent. These
* shims restore them. Deliberately import-free so the tests can load them
* without a dependency tree.
*
* Ported from `@goodandready/dsh-lanmode`'s page shim, which did this job
* well; this copy exists so the two concerns — request identity and browser
* capability — live in one plugin instead of two.
*/
/** Attribute marking the injected shim script, so a second injection is a no-op. */
export const SHIM_MARKER = 'data-dsh-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
View File
@@ -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/",
+150
View File
@@ -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
View File
@@ -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))
})
+183
View File
@@ -0,0 +1,183 @@
/**
* Behavioural check for the generated browser shim.
*
* The apply suite proves the shim reaches the page; this one proves the code
* it delivers actually works. The source is evaluated in a `node:vm` context
* standing in for a page that is not a secure context — no
* `crypto.randomUUID`, no `navigator.clipboard` — which is the whole reason
* the shim exists.
*
* node test/shims.test.mjs
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import vm from 'node:vm'
const SHIMS = '../lib/shims.js'
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
/**
* A page context lacking both secure-context APIs.
*
* @param options - how the page should behave.
* @param options.execCommandResult - what `document.execCommand('copy')` returns.
* @param options.existingClipboard - install a partial `navigator.clipboard`.
* @returns the sandbox, with a record of what the shim did to it.
*/
function page({ execCommandResult = true, existingClipboard = false } = {}) {
const calls = { appended: 0, removed: 0, selected: 0, copied: 0, attributes: [], draws: 0 }
const sandbox = {
crypto: {
// The first draw is deterministic so the UUID assertion can be exact;
// later draws shift, so a repeated UUID would prove the shim cached its
// first result rather than asking for fresh bytes each call.
getRandomValues(bytes) {
const base = calls.draws++ === 0 ? 0 : 64
for (let i = 0; i !== bytes.length; i++) bytes[i] = (base + i) & 255
return bytes
},
},
navigator: existingClipboard ? { clipboard: { readText: async () => '' } } : {},
document: {
currentScript: {
setAttribute(name, value) {
calls.attributes.push([name, value])
},
},
createElement() {
return {
style: {},
setAttribute() {},
select() {
calls.selected += 1
},
}
},
execCommand(command) {
assert.equal(command, 'copy')
calls.copied += 1
return execCommandResult
},
body: {
appendChild() {
calls.appended += 1
},
removeChild() {
calls.removed += 1
},
},
},
}
vm.createContext(sandbox)
return { sandbox, calls }
}
test('the UUID polyfill mints a v4 UUID from the unrestricted call', async () => {
const { shimSource } = await import(SHIMS)
const { sandbox, calls } = page()
assert.equal(typeof sandbox.crypto.randomUUID, 'undefined', 'the page starts without it')
vm.runInContext(shimSource({ clipboard: false, randomUuid: true }), sandbox)
assert.equal(typeof sandbox.crypto.randomUUID, 'function')
const minted = sandbox.crypto.randomUUID()
assert.match(minted, UUID_V4)
// Bytes 0..15, so the only edits are the two forced bit fields: byte 6 gains
// the version nibble (`06` -> `46`) and byte 8 the variant bits (`08` -> `88`).
assert.equal(minted, '00010203-0405-4607-8809-0a0b0c0d0e0f')
assert.notEqual(sandbox.crypto.randomUUID(), minted, 'each call reads fresh bytes')
assert.equal(calls.draws, 2, 'one random draw per minted UUID')
assert.deepEqual(calls.attributes, [['data-dsh-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)
})