1 Commits
Author SHA1 Message Date
mward4 61ff536687 v0.3.0: restore the webRuntime service 0.2.1-alpha.2 stopped providing
A plugin whose injection list names a service nobody provides is held pending
by Cordis: the module loads, but the fiber never mounts, so its routes are
never registered and the core static handler answers in their place. That
handler allows GET and HEAD only, so every URL the plugin's own pane calls
comes back as HTTP 405. Releases from 0.2.1-alpha.2 no longer provide
`webRuntime`, which is how the sidebar's Files pane stopped previewing and
downloading anything.

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

Verified against the installed 0.2.1-alpha.2, end to end: the same boot with
and without the plugin gives 405 and then 400 on POST /sidebar/api/fs.tree,
the 400 being the sidebar's own handler answering. 59/59 unit tests (new
test/web-runtime.test.mjs), and scripts/live-probe.mjs — extended with the
Files-API check — reports 11/11 on the fixed build and 10/11 with the 405 on
the live one, which is the user-visible regression itself.
2026-10-14 22:49:41 -04:00
6 changed files with 505 additions and 30 deletions

No files matched your search

+123 -18
View File
@@ -47,7 +47,7 @@ identity fence, which is what actually produces the 403.
## What it changes
Four things, each one reachable only by a different kind of fence.
Five things, each one reachable only by a different kind of fence.
**1. Request identity, at the server.** The plugin installs `request` and
`upgrade` listeners with `prependListener` on the raw `node:http` server the
@@ -84,6 +84,14 @@ plus the metadata that makes the page installable on a phone. These are unrelate
to request identity and are here so that one plugin covers the whole "I want to
reach this from another machine" problem. See `shimClipboard` below.
**5. The `webRuntime` service, for plugins that never get to fence at all.** A
plugin whose injection list names a service nobody provides is held `pending` by
Cordis: its routes are never registered, and the URLs its pane calls answer `405`
from the core static handler. Releases from `0.2.1-alpha.2` stopped providing
`webRuntime`, which is how the sidebar's Files pane started failing to preview or
download anything. The plugin provides it — when nothing else has — built from
the trust data the harness still holds. See `provideWebRuntime` below.
It deliberately does **not** touch `sec-fetch-site`. The harness refuses
`cross-site` outright, and that refusal is the last judgement a browser makes
on its own behalf. Rewriting it would make a page on another origin look
@@ -148,6 +156,7 @@ Override any key in your profile's `cordis.patch.yml`:
disableAuth: true
forceClientLoopback: true
grantPairing: true
provideWebRuntime: true
spoofSocketAddress: false
shimClipboard: true
shimRandomUuid: true
@@ -160,11 +169,65 @@ Override any key in your profile's `cordis.patch.yml`:
| `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. |
| `grantPairing` | `true` | Provides the paired-device service that plugins like the Skill Center accept instead of a loopback peer. See below. |
| `provideWebRuntime` | `true` | Restores the `webRuntime` trust snapshot the harness stopped publishing in 0.2.1-alpha.2, so plugins that inject it (the sidebar) mount instead of answering 405. See below. |
| `spoofSocketAddress` | `false` | Also reports the peer address as loopback, for plugins with no pairing path. See below. |
| `shimClipboard` | `true` | Restores `navigator.clipboard.writeText` on plain HTTP. See below. |
| `shimRandomUuid` | `true` | Restores `crypto.randomUUID` on plain HTTP. See below. |
| `shimPwa` | `true` | Serves a manifest and adds the installable-app metadata. See below. |
### `provideWebRuntime` — the Files pane, and HTTP 405
`0.2.1-alpha.2` stopped providing a service named `webRuntime`. Its value was the
LAN-trust snapshot — `resolveLanTrust(ctx.webServer.host, trustedHosts)`, that is
`{ lanAddresses, trustedHosts }` — and third-party plugins inject it to learn
which authorities their own copy of the request fence should accept:
```js
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
const fence = (req) => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
```
Cordis holds a row whose injection is unsatisfied in `pending`: the module loads,
its inject list is read, and **no `apply` runs** — so none of its routes are
registered, and the boot log names it:
```
dsh: warning: 1 entry did not activate
better-sidebar (dsh-better-sidebar): pending (waiting for service: webRuntime)
```
That 405 is the whole of the "opening a file stopped working" symptom: the routes
are not refusing the request, they do not exist. The core static handler answers
every unmounted `POST` with an empty `405`, while the plugin's own paths answer
`404` — measured over a non-loopback `Host`, on one profile, with the sidebar's
own Files requests:
| Request | `provideWebRuntime: false` | `provideWebRuntime: true` |
|---|---|---|
| `POST /sidebar/api/fs.tree` | `405`, 0 bytes | `200`, `{"ok":true,…,"entries":[…]}` |
| `POST /sidebar/api/fs.read` | `405`, 0 bytes | `200`, `{"ok":true,"value":{"kind":"text",…}}` |
| `GET /sidebar/file?sessionId=…&path=…` | `404` | `200 application/octet-stream` |
| `GET /sidebar/file?…&download=1` | `404` | `200`, `content-disposition: attachment` |
The same release moved `--trusted-host` authorities into `connection.trustedHosts`
(it is `trustedHosts: !!js ctx.webStartup.trustedHosts` in the web bundle's patch),
which is what the `/api` fence reads. So the service can be rebuilt from what the
harness still has, which is what this key does: it provides `webRuntime` — when
nothing else has — with `trustedHosts` read **live** from `connection` rather than
cloned at assembly time, plus the listener's own bind address, which the core
fence accepts separately (`isBindAddressAuthority`) and a two-argument plugin fence
otherwise cannot. `lanAddresses` is `[]`, which is what alpha.1 returned for every
bind alpha.2 permits.
This does not weaken anything by itself. It is not a declaration of the arriving
authority, and it is inert on the request path whenever the interceptor above is
installed: every request already carries the loopback authority by the time any
fence reads it. It exists so that a plugin **mounts** at all.
Set `provideWebRuntime: false` to leave those plugins unmounted — for instance on
a harness that removed the service deliberately and where you would rather run
plugins without it than republish an interface the harness retired.
### `grantPairing` — the Skill Center
Some plugins authorize a request either by a loopback peer or by a **live paired
@@ -241,14 +304,16 @@ three are `true` by default; serving over HTTPS makes the first two redundant.
## Compatibility
Verified against **DSH 0.1.7-rc.2**, **0.2.0-rc.1**, and **0.2.1-alpha.1**. The
plugin depends on three things that are internal rather than public API, so a
harness release that renames any of them will break it:
Verified against **DSH 0.1.7-rc.2**, **0.2.0-rc.1**, **0.2.1-alpha.1**, and
**0.2.1-alpha.2**. The plugin depends on several things that are internal rather
than public API, so a harness release that renames any of them will break it:
| Depends on | Where |
|---|---|
| `webServer.server` — the raw `node:http` server | `@deepseek-ai/dsh-host-webserver` |
| `webServer.host` — the listener's own bind address | `@deepseek-ai/dsh-host-webserver` |
| `webServer.register` / `webServer.tapIndex` — the route registry and index transforms | `@deepseek-ai/dsh-host-webserver` |
| `connection.trustedHosts` — the authorities the `/api` fence reads | `@deepseek-ai/dsh-client-connection` |
| `connection.requestRejection` / `authorizeIndex` / `authenticatedUrl` | `@deepseek-ai/dsh-client-connection` |
| the `webserver/index-inject` channel and the `ownsHost` marker | webserver + connection client |
@@ -263,6 +328,34 @@ interceptor was not installed and a Host fence will still refuse a remote
window; the harness surface may have changed
```
### What was checked on 0.2.1-alpha.2
Everything in the table above is still present and still of the shape this plugin
reaches for — with one removal, which is what 0.3.0 answers.
* `webRuntime` is **gone**. `dsh-web-app` no longer provides it, no package
provides it, and a plugin that injects it is held `pending` (the boot-log line
quoted under `provideWebRuntime`).
* `connection.trustedHosts` is not gone but **changed source**: the web bundle's
patch now sets it from `webStartup.trustedHosts` (the `--trusted-host` values),
and `isBindAddressAuthority` accepts `webServer.host` alongside it. It stays a
plain `string[]` this plugin can read per request, which is what the snapshot is
rebuilt from.
* `webServer.host` is a **config value, not a socket fact**: it is the concrete
IPv4/IPv6 literal passed to `--host`, and the setter now rejects a wildcard
(`"0.0.0.0" is an unspecified (wildcard) address, which is not supported`).
* `webServer.server` and `webServer.register` still exist, so the interceptor and
the shims are unaffected. That the interceptor still works is visible in the
table's right-hand column: every one of those requests arrived with a
**non-loopback** `Host` (`172.17.0.4:3104`) and still reached the plugin's own
fence, which only a rewritten request identity can do on an undeclared
authority.
Not re-measured on alpha.2: the seven-point fence matrix from alpha.1 below, and
the `--help`/peer-gate notes. The alpha.1 note is kept because it is still what
the fence behaviour rests on, and because the `resolveLanTrust` details in it
describe the value this plugin now rebuilds.
### What was checked on 0.2.1-alpha.1
The three packages this plugin reaches into are **byte-identical** between
@@ -297,17 +390,27 @@ to reject it on and it skips nothing at boot.
### Why it never writes the trusted-host lists
The obvious fix for this problem is to add the arriving host to
`connection.trustedHosts`. This plugin deliberately does not, for two reasons.
`connection.trustedHosts`. This plugin deliberately does not — and that is a
narrower statement than it looks, now that `provideWebRuntime` exists: it
*publishes* a `webRuntime` value built from that list, and writes nothing into
`connection.trustedHosts` itself.
The harness assembles **two** trusted-host arrays — one on `webRuntime`, one on
`connection` — and they are separate objects cloned at assembly time. A host
declared into one is invisible to the other, so half the fix silently does
nothing. More importantly, a fence that compares against a **literal loopback
value** rather than against the list is not moved by any declaration at all.
Plugin markets published before the list existed hardcode exactly that check, and
they are the ones producing the 403 users actually see. Rewriting the request
identity satisfies every fence, of every shape and every age; a list entry
satisfies only the fences that read the list.
The reason is the same one that has always applied. A fence that compares against
a **literal loopback value** rather than against a list is not moved by any
declaration at all; plugin markets published before the list existed hardcode
exactly that check, and they are the ones producing the 403 users actually see.
Rewriting the request identity satisfies every fence, of every shape and every
age; a list entry satisfies only the fences that read the list. The same argument
applies to the restored snapshot: its list is a faithful reproduction of what the
harness already believes, not an attempt to talk a fence into accepting something
the harness would refuse.
On **0.2.1-alpha.1** there was a second, version-specific reason: the harness
assembled two trusted-host arrays — one on `webRuntime`, one on `connection` —
as separate objects cloned at assembly time, so a host declared into one was
invisible to the other and half the fix silently did nothing. `0.2.1-alpha.2`
removed the `webRuntime` array along with the service, leaving `connection` as
the single source, which is what the snapshot reads.
### Reload safety
@@ -425,12 +528,13 @@ node test/fence.test.mjs # drives real requests past the real plugin-market
node test/apply.test.mjs # wiring, including that unload restores every original
node test/handover.test.mjs # the reload orderings: predecessor vs. successor, liveness
node test/shims.test.mjs # executes the generated shim in a context without the APIs
node test/web-runtime.test.mjs # the restored webRuntime value, and when it is provided
node scripts/live-probe.mjs # against the running harness, over a non-loopback peer
```
All four suites together — **52 tests** (fence 8, apply 20, handover 15,
shims 9) — are `node --test test/*.test.mjs` from the plugin directory. The
shell has to expand that glob: `node --test test/` fails with `MODULE_NOT_FOUND`
All five suites together — **59 tests** (fence 8, apply 20, handover 15,
shims 9, web-runtime 7) — are `node --test test/*.test.mjs` from the plugin
directory. The shell has to expand that glob: `node --test test/` fails with `MODULE_NOT_FOUND`
on Node 22.
`test/fence.test.mjs` loads `sameOrigin()` from the installed `dshmarket` when a
@@ -452,7 +556,8 @@ remote client and always looks like it passes.
| Version | What changed |
|---|---|
| **0.2.1** | Documentation and compatibility only — **no code change from 0.2.0**. Records the 0.2.1-alpha.1 validation above, and how to install so the market can update it. |
| **0.3.0** | Provides the `webRuntime` trust snapshot that `0.2.1-alpha.2` stopped publishing (`lib/web-runtime.js`, `provideWebRuntime`), so a plugin that injects it — the sidebar among them — mounts instead of answering 405 on every request. The value is read live from `connection.trustedHosts` plus the listener's bind address, and an existing provider is left alone. |
| 0.2.1 | Documentation and compatibility only — **no code change from 0.2.0**. Records the 0.2.1-alpha.1 validation above, and how to install so the market can update it. |
| 0.2.0 | Renamed from `dsh-disable-untrusted-origin`. Added `lib/handover.js`: originals recorded once on a `Symbol.for` carrier, a liveness check (`fiber.uid === null`) on every delegated path, and an interceptor that retires a tagged predecessor. Capabilities now degrade with a warning instead of failing the fiber. |
| 0.1.2 | Restored `crypto.randomUUID` and `navigator.clipboard` on plain HTTP, added the PWA manifest, and widened the viewport for notched screens. |
| 0.1.1 | Provided the paired-device service, so the Skill Center works over a remote address instead of answering 400. |
+66 -8
View File
@@ -25,14 +25,20 @@
* `navigator.clipboard`, both of which exist only in a secure context, plus
* the metadata that makes the page installable on a phone.
*
* What it deliberately does not do is write the trusted-host lists.
* `webRuntime.trustedHosts` and the `connection` service's own `trustedHosts`
* are separate arrays cloned at assembly time, and only the second is what the
* `/api` fence reads — declaring an authority in the first, or in a config
* patch that reaches neither, produces a name the gate never sees. Rewriting
* the request needs no declaration at all, and reaches fences a declaration
* cannot: a plugin comparing the header against a hardcoded loopback literal is
* unmoved by any entry in a list it never consults.
* What it deliberately does not do is *declare* the arriving authority. The
* `connection` service's `trustedHosts` is what the `/api` fence reads, and an
* entry there changes which names the gate accepts — a weaker and narrower
* guarantee than rewriting the request, and one no declaration can give a fence
* that compares against a hardcoded loopback literal. Rewriting needs no
* declaration at all.
*
* It does, separately, **restore** the `webRuntime` service that releases from
* `0.2.1-alpha.2` on stopped providing, when nothing else provides it. That
* service was the LAN-trust snapshot a third-party plugin injects to learn the
* authorities its own copy of the fence should accept; without it the row stays
* `pending`, its routes never mount, and its URLs fall through to the core
* handler — which is what a `405` in a plugin's own pane actually is. See
* `lib/web-runtime.js` and `provideWebRuntime`.
*
* Reload safety: everything installed here is a mutation of an object the
* harness owns, and a reload can run a successor's installation before its
@@ -73,10 +79,24 @@ import {
shimSource,
withCoverViewport,
} from './shims.js'
import {
WEB_RUNTIME_SERVICE,
bindAuthority,
connectionAuthorities,
trustedAuthorities,
webRuntimeShim,
} from './web-runtime.js'
export { CLIENT_LOOPBACK_GRANT, loopbackAuthority, loopbackise, spoofPeerAddress }
export { HANDOVER, INTERCEPTOR, fiberLive, installInterceptor, installReplacements, originalOf }
export { MANIFEST_PATH, manifest, pwaMarkup, shimSource, withCoverViewport }
export {
WEB_RUNTIME_SERVICE,
bindAuthority,
connectionAuthorities,
trustedAuthorities,
webRuntimeShim,
}
/** Stable Cordis plugin name. */
export const name = 'trustworthy'
@@ -126,6 +146,17 @@ export const Config = z.object({
+ 'off when @linxin666/dsh-remote-web-ui is installed: it provides the '
+ 'same service, and two providers for one name is a load error.',
),
provideWebRuntime: z
.boolean()
.default(true)
.description(
'Provide the Web-runtime trust snapshot that releases from 0.2.1-alpha.2 '
+ 'on stopped publishing, when nothing else provides it. Plugins that '
+ 'inject `webRuntime` — the sidebar among them — stay pending without '
+ 'it, and their routes are never registered, which their pane reports '
+ 'as an HTTP 405. Turn this off to leave those plugins unmounted on a '
+ 'harness that removed the service.',
),
spoofSocketAddress: z
.boolean()
.default(false)
@@ -268,6 +299,33 @@ export function apply(ctx, config) {
}
}
if (config.provideWebRuntime) {
// Not a fence change, and not a declaration of the arriving authority: this
// restores a service the harness stopped publishing, for plugins that inject
// it and would otherwise never mount at all. The value is read live from
// `connection`, so it follows the same trust source the `/api` fence derives
// its own list from rather than a snapshot frozen at assembly time.
//
// Registered on this plugin's own fiber, so unload withdraws it and a
// consumer that depended on it is released with it. Unlike the pairing
// service below there is no liveness gate on the answer: that one *decides*
// whether a device is paired, so a registration outliving its disposers
// would keep vouching for devices, while this one only republishes the
// authorities `connection` already carries. Probing first matters for the
// same reason it does for pairing: a second registration of one service
// name is a load error.
if (ctx.get(WEB_RUNTIME_SERVICE) === undefined) {
ctx.provide(WEB_RUNTIME_SERVICE, webRuntimeShim(ctx))
ctx.logger?.info(
`trustworthy: provided the ${WEB_RUNTIME_SERVICE} service this harness no longer publishes`,
)
} else {
ctx.logger?.info(
`trustworthy: ${WEB_RUNTIME_SERVICE} is already provided; leaving that provider in place`,
)
}
}
if (config.forceClientLoopback) {
ctx.inject(['webServer'], (webCtx) => {
// `on` takes options, not a label; the subscription lives with the
+111
View File
@@ -0,0 +1,111 @@
/**
* dsh-trustworthy — the `webRuntime` seam, for harness releases that stopped
* providing it.
*
* `@deepseek-ai/dsh-web-app` used to provide a `webRuntime` service whose value
* was the LAN-trust snapshot — `resolveLanTrust(ctx.webServer.host, trustedHosts)`,
* that is `{ lanAddresses, trustedHosts }`. Releases from `0.2.1-alpha.2` on no
* longer provide it: the `--trusted-host` authorities are parsed by the
* `webStartup` service and consumed by `@deepseek-ai/dsh-client-connection` into
* `connection.trustedHosts`, and the listener's own bind address is accepted by
* the `/api` fence as a separate case.
*
* Plugins written against the old shape never learn that it moved. They declare
* `inject: ['webRuntime']`, so Cordis holds their row **pending** — the module
* loads and its inject list is read, but the fiber never mounts. Their routes
* are then simply absent, and the core fallback answers in their place: a
* `POST /sidebar/api/fs.list` that matches no route falls through to the
* static-file handler, which allows `GET`/`HEAD` only and answers **405**.
*
* This module fills that seam. Same service name, same value shape, but read
* live from the trust source the harness actually has now, rather than cloned
* at assembly time — so a later `--trusted-host`, or a row that changed
* `connection`'s config, is visible to a plugin's own copy of the fence.
*
* @module dsh-trustworthy/web-runtime
*/
/** Service name sibling plugins inject, and read, for the Web runtime snapshot. */
export const WEB_RUNTIME_SERVICE = 'webRuntime'
/**
* Bind hosts that name every interface rather than one of them. A wildcard can
* be written as an authority but never *means* one, so it is not an entry.
*/
const WILDCARD_BIND_HOSTS = new Set(['0.0.0.0', '::', '::0', '*'])
/**
* The non-loopback authorities this deployment declared — the `--trusted-host`
* values, as `connection` received them. Read per call rather than captured, so
* the snapshot tracks the same list the `/api` fence is using.
*
* @param ctx - host plugin context carrying `connection`.
* @returns declared authorities, in the order they were configured.
*/
export function connectionAuthorities(ctx) {
const declared = ctx.connection?.trustedHosts
if (!Array.isArray(declared)) return []
return declared.filter((entry) => typeof entry === 'string' && entry !== '')
}
/**
* The listener's own bind address, when it names one interface. `0.2.1-alpha.2`'s
* `/api` fence accepts this address on its own (`isBindAddressAuthority`), and
* a plugin carrying a private copy of the older two-argument fence cannot: it
* only ever compares against the list it is handed. Publishing the bind address
* as an entry makes that older fence accept exactly what the core fence accepts
* — the entry is port-less, so like the core check it matches the address on any
* port, and a wildcard is not an authority: `0.2.1-alpha.2` refuses to bind one
* at all (`host "0.0.0.0" is an unspecified (wildcard) address, which is not
* supported`), so that case is unreachable rather than merely unused.
*
* @param ctx - host plugin context carrying `webServer`.
* @returns the bind host, or undefined when the surface is gone or wildcarded.
*/
export function bindAuthority(ctx) {
const host = ctx.webServer?.host
if (typeof host !== 'string' || host === '' || WILDCARD_BIND_HOSTS.has(host)) return undefined
return host
}
/**
* The authorities the removed `webRuntime.trustedHosts` would carry today.
*
* @param ctx - host plugin context carrying `connection` and `webServer`.
* @returns deduplicated authorities: the declared ones, then the bind address.
*/
export function trustedAuthorities(ctx) {
const declared = connectionAuthorities(ctx)
const bind = bindAuthority(ctx)
return bind === undefined ? [...new Set(declared)] : [...new Set([...declared, bind])]
}
/**
* The value of the `webRuntime` service, shaped as `0.2.1-alpha.1` provided it.
*
* Both members are getters: a consumer reads `trustedHosts` inside its fence, per
* request, so a snapshot that could not see a later authority would quietly
* refuse requests the `/api` gateway admits. `lanAddresses` stays empty because
* `0.2.1-alpha.2` rejects a wildcard bind — alpha.1 filled it from the host's
* interfaces only for `0.0.0.0`, so the empty list is that same function's
* answer for every bind this harness allows.
*
* When dsh-trustworthy's interceptor is installed the list is inert for the
* plugin's own routes: every request arrives carrying the loopback authority, so
* a fence written against this value admits it before reading the list. The
* populated list matters to consumers that read it for another reason, and to a
* deployment that installs this shim without the interceptor.
*
* @param ctx - host plugin context carrying `connection` and `webServer`.
* @returns the service value.
*/
export function webRuntimeShim(ctx) {
return {
get trustedHosts() {
return trustedAuthorities(ctx)
},
get lanAddresses() {
return []
},
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-trustworthy",
"version": "0.2.1",
"version": "0.3.0",
"description": "Removes the untrusted-origin restriction from DeepSeek Harness. Every browser window is treated as if it were running on the harness machine itself: no process token, no declared trusted host, and no per-plugin origin fence, because one interceptor answers every request with the loopback authority.",
"type": "module",
"main": "lib/index.js",
@@ -9,7 +9,8 @@
"./authority": "./lib/authority.js",
"./handover": "./lib/handover.js",
"./package.json": "./package.json",
"./shims": "./lib/shims.js"
"./shims": "./lib/shims.js",
"./web-runtime": "./lib/web-runtime.js"
},
"files": [
"lib/",
+27 -2
View File
@@ -4,8 +4,11 @@
* 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.
* 3. the Skill Center answers a non-loopback *peer*,
* 4. the browser shims and the PWA manifest actually reach the page, and
* 5. the sidebar's Files API is registered rather than falling through to the
* static handler as a 405 — the shape a plugin takes when the service it
* injects (`webRuntime`) is not provided.
*
* Point 3 is the one that needs care: the Skill Center fences on the socket
* address as well as the Host, so a probe over 127.0.0.1 can never reproduce a
@@ -140,11 +143,33 @@ try {
}
record('manifest is served', manifestOk, `${manifest.status}`)
// A plugin whose injection is unsatisfied never runs `apply`, so its routes are
// absent and this URL is answered by the core static handler, which allows only
// GET/HEAD. A registered route answers 400 (no sessionId) or 404 in the plugin's
// own JSON shape; 405 is the fall-through, and is the whole of "the Files pane
// stopped previewing anything".
const sidebar = await request({
method: 'POST',
path: '/sidebar/api/fs.tree',
host: anyIdentity,
body: '{}',
})
const sidebarMounted = sidebar.status !== 405 && sidebar.status !== 0
record(
'sidebar Files API is registered',
sidebarMounted,
sidebarMounted
? `${sidebar.status} from a registered route`
: '405 from the static handler: the plugin never mounted (is webRuntime provided?)',
)
const failed = checks.filter((check) => !check.ok)
console.log(`\n${checks.length - failed.length}/${checks.length} passed`)
if (failed.length > 0) {
console.log('\nA 403 on the market means the origin fence is still in force.')
console.log('A 400 on the Skill Center means it is still refusing (or throwing for) a remote peer.')
console.log('A missing shim or manifest usually means the updated bundle is not loaded yet.')
console.log('A 405 on the Files API means a plugin stayed pending: check the boot log for')
console.log('"did not activate" and whether the service it injects is provided.')
process.exit(1)
}
+175
View File
@@ -0,0 +1,175 @@
/**
* The `webRuntime` seam: the value it publishes, and the wiring that publishes
* it only when the harness has not.
*
* The fence and apply suites cover the request identity; this one covers the
* service releases from `0.2.1-alpha.2` on stopped providing, whose absence
* leaves an injecting plugin `pending` and its routes unregistered — the 405 a
* user sees in the sidebar's Files tab.
*
* node test/web-runtime.test.mjs
*/
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import test from 'node:test'
import { Config, apply } from '../lib/index.js'
import {
WEB_RUNTIME_SERVICE,
bindAuthority,
connectionAuthorities,
trustedAuthorities,
webRuntimeShim,
} from '../lib/web-runtime.js'
/** A config with every engagement off but the one under test. */
function quietConfig(overrides = {}) {
return {
enabled: true,
disableAuth: false,
forceClientLoopback: false,
grantPairing: false,
provideWebRuntime: true,
spoofSocketAddress: false,
shimClipboard: false,
shimRandomUuid: false,
shimPwa: false,
...overrides,
}
}
/**
* A host context carrying only what `apply` touches, plus the real raw server
* the interceptor is installed on.
*
* @param options - bind host, the authorities `connection` was given, and any
* value already registered under the `webRuntime` name.
*/
async function mockHost({ host = '172.17.0.4', trustedHosts = [], webRuntime } = {}) {
const provided = new Map()
if (webRuntime !== undefined) provided.set(WEB_RUNTIME_SERVICE, webRuntime)
const logs = []
const server = createServer((_request, response) => {
response.writeHead(200)
response.end('route handler')
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const ctx = {
fiber: { uid: 1 },
logger: {
info(message) {
logs.push(message)
},
warn(message) {
logs.push(message)
},
},
webServer: { port: server.address().port, host, server },
connection: { trustedHosts },
effect() {},
inject(_services, callback) {
callback(ctx)
},
on() {},
// Cordis's `get` answers undefined for a name nobody provided, and its
// `provide` throws on a second registration of one name.
get(service) {
return provided.get(service)
},
provide(service, value) {
if (provided.has(service)) throw new Error(`service "${service}" has been registered`)
provided.set(service, value)
return () => provided.delete(service)
},
}
return { ctx, server, provided, logs }
}
test('declared authorities survive as strings and malformed entries do not', () => {
assert.deepEqual(connectionAuthorities({ connection: { trustedHosts: [] } }), [])
assert.deepEqual(
connectionAuthorities({
connection: { trustedHosts: ['ai.milesward.dev', '', 7, null, '192.168.1.49:3080'] },
}),
['ai.milesward.dev', '192.168.1.49:3080'],
)
// A harness that never built the list, or a host context without the service.
assert.deepEqual(connectionAuthorities({}), [])
assert.deepEqual(connectionAuthorities({ connection: {} }), [])
})
test('the bind address is an authority only when it names one interface', () => {
assert.equal(bindAuthority({ webServer: { host: '172.17.0.4' } }), '172.17.0.4')
assert.equal(bindAuthority({ webServer: { host: '::1' } }), '::1')
// A wildcard can be written as an authority but never means one.
for (const host of ['0.0.0.0', '::', '::0', '*', '']) {
assert.equal(bindAuthority({ webServer: { host } }), undefined, host)
}
assert.equal(bindAuthority({}), undefined)
assert.equal(bindAuthority({ webServer: {} }), undefined)
})
test('the snapshot is the declared authorities plus the bind address, deduplicated', () => {
assert.deepEqual(
trustedAuthorities({
connection: { trustedHosts: ['ai.milesward.dev', '172.17.0.4'] },
webServer: { host: '172.17.0.4' },
}),
['ai.milesward.dev', '172.17.0.4'],
)
// The wildcard bind adds nothing — alpha.1's resolveLanTrust agreed, and
// alpha.2 refuses a wildcard `--host` outright.
assert.deepEqual(
trustedAuthorities({ connection: { trustedHosts: ['app.internal'] }, webServer: { host: '0.0.0.0' } }),
['app.internal'],
)
})
test('the snapshot is read per request, not captured once', () => {
const connection = { trustedHosts: ['ai.milesward.dev'] }
const runtime = webRuntimeShim({ connection, webServer: { host: '10.0.0.7' } })
assert.deepEqual(runtime.trustedHosts, ['ai.milesward.dev', '10.0.0.7'])
assert.deepEqual(runtime.lanAddresses, [])
// A fence reads the list inside a request; a value frozen at mount time would
// go on refusing a name the `/api` gateway had since admitted.
connection.trustedHosts = ['ai.milesward.dev', 'app.internal']
assert.deepEqual(runtime.trustedHosts, ['ai.milesward.dev', 'app.internal', '10.0.0.7'])
})
test('the seam is on by default, and `apply` provides it', async (t) => {
assert.equal(Config({}).provideWebRuntime, true)
const { ctx, server, provided, logs } = await mockHost({ trustedHosts: ['ai.milesward.dev'] })
t.after(() => server.close())
apply(ctx, quietConfig())
assert.deepEqual(provided.get(WEB_RUNTIME_SERVICE)?.trustedHosts, [
'ai.milesward.dev',
'172.17.0.4',
])
assert.ok(logs.some((line) => line.includes('provided the webRuntime service')), logs.join('\n'))
})
test('an existing provider is left alone', async (t) => {
const existing = { trustedHosts: ['someone-else.example'] }
const { ctx, server, provided, logs } = await mockHost({ webRuntime: existing })
t.after(() => server.close())
apply(ctx, quietConfig())
assert.equal(provided.get(WEB_RUNTIME_SERVICE), existing)
assert.ok(logs.some((line) => line.includes('already provided')), logs.join('\n'))
})
test('the seam can be switched off, and a suspended plugin provides nothing', async (t) => {
const off = await mockHost()
t.after(() => off.server.close())
apply(off.ctx, quietConfig({ provideWebRuntime: false }))
assert.equal(off.provided.has(WEB_RUNTIME_SERVICE), false)
const suspended = await mockHost()
t.after(() => suspended.server.close())
apply(suspended.ctx, quietConfig({ enabled: false }))
assert.equal(suspended.provided.has(WEB_RUNTIME_SERVICE), false)
})