Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ff536687 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* The `webRuntime` seam: the value it publishes, and the wiring that publishes
|
||||
* it only when the harness has not.
|
||||
*
|
||||
* The fence and apply suites cover the request identity; this one covers the
|
||||
* service releases from `0.2.1-alpha.2` on stopped providing, whose absence
|
||||
* leaves an injecting plugin `pending` and its routes unregistered — the 405 a
|
||||
* user sees in the sidebar's Files tab.
|
||||
*
|
||||
* node test/web-runtime.test.mjs
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import test from 'node:test'
|
||||
|
||||
import { Config, apply } from '../lib/index.js'
|
||||
import {
|
||||
WEB_RUNTIME_SERVICE,
|
||||
bindAuthority,
|
||||
connectionAuthorities,
|
||||
trustedAuthorities,
|
||||
webRuntimeShim,
|
||||
} from '../lib/web-runtime.js'
|
||||
|
||||
/** A config with every engagement off but the one under test. */
|
||||
function quietConfig(overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
disableAuth: false,
|
||||
forceClientLoopback: false,
|
||||
grantPairing: false,
|
||||
provideWebRuntime: true,
|
||||
spoofSocketAddress: false,
|
||||
shimClipboard: false,
|
||||
shimRandomUuid: false,
|
||||
shimPwa: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A host context carrying only what `apply` touches, plus the real raw server
|
||||
* the interceptor is installed on.
|
||||
*
|
||||
* @param options - bind host, the authorities `connection` was given, and any
|
||||
* value already registered under the `webRuntime` name.
|
||||
*/
|
||||
async function mockHost({ host = '172.17.0.4', trustedHosts = [], webRuntime } = {}) {
|
||||
const provided = new Map()
|
||||
if (webRuntime !== undefined) provided.set(WEB_RUNTIME_SERVICE, webRuntime)
|
||||
const logs = []
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200)
|
||||
response.end('route handler')
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const ctx = {
|
||||
fiber: { uid: 1 },
|
||||
logger: {
|
||||
info(message) {
|
||||
logs.push(message)
|
||||
},
|
||||
warn(message) {
|
||||
logs.push(message)
|
||||
},
|
||||
},
|
||||
webServer: { port: server.address().port, host, server },
|
||||
connection: { trustedHosts },
|
||||
effect() {},
|
||||
inject(_services, callback) {
|
||||
callback(ctx)
|
||||
},
|
||||
on() {},
|
||||
// Cordis's `get` answers undefined for a name nobody provided, and its
|
||||
// `provide` throws on a second registration of one name.
|
||||
get(service) {
|
||||
return provided.get(service)
|
||||
},
|
||||
provide(service, value) {
|
||||
if (provided.has(service)) throw new Error(`service "${service}" has been registered`)
|
||||
provided.set(service, value)
|
||||
return () => provided.delete(service)
|
||||
},
|
||||
}
|
||||
return { ctx, server, provided, logs }
|
||||
}
|
||||
|
||||
test('declared authorities survive as strings and malformed entries do not', () => {
|
||||
assert.deepEqual(connectionAuthorities({ connection: { trustedHosts: [] } }), [])
|
||||
assert.deepEqual(
|
||||
connectionAuthorities({
|
||||
connection: { trustedHosts: ['ai.milesward.dev', '', 7, null, '192.168.1.49:3080'] },
|
||||
}),
|
||||
['ai.milesward.dev', '192.168.1.49:3080'],
|
||||
)
|
||||
// A harness that never built the list, or a host context without the service.
|
||||
assert.deepEqual(connectionAuthorities({}), [])
|
||||
assert.deepEqual(connectionAuthorities({ connection: {} }), [])
|
||||
})
|
||||
|
||||
test('the bind address is an authority only when it names one interface', () => {
|
||||
assert.equal(bindAuthority({ webServer: { host: '172.17.0.4' } }), '172.17.0.4')
|
||||
assert.equal(bindAuthority({ webServer: { host: '::1' } }), '::1')
|
||||
// A wildcard can be written as an authority but never means one.
|
||||
for (const host of ['0.0.0.0', '::', '::0', '*', '']) {
|
||||
assert.equal(bindAuthority({ webServer: { host } }), undefined, host)
|
||||
}
|
||||
assert.equal(bindAuthority({}), undefined)
|
||||
assert.equal(bindAuthority({ webServer: {} }), undefined)
|
||||
})
|
||||
|
||||
test('the snapshot is the declared authorities plus the bind address, deduplicated', () => {
|
||||
assert.deepEqual(
|
||||
trustedAuthorities({
|
||||
connection: { trustedHosts: ['ai.milesward.dev', '172.17.0.4'] },
|
||||
webServer: { host: '172.17.0.4' },
|
||||
}),
|
||||
['ai.milesward.dev', '172.17.0.4'],
|
||||
)
|
||||
// The wildcard bind adds nothing — alpha.1's resolveLanTrust agreed, and
|
||||
// alpha.2 refuses a wildcard `--host` outright.
|
||||
assert.deepEqual(
|
||||
trustedAuthorities({ connection: { trustedHosts: ['app.internal'] }, webServer: { host: '0.0.0.0' } }),
|
||||
['app.internal'],
|
||||
)
|
||||
})
|
||||
|
||||
test('the snapshot is read per request, not captured once', () => {
|
||||
const connection = { trustedHosts: ['ai.milesward.dev'] }
|
||||
const runtime = webRuntimeShim({ connection, webServer: { host: '10.0.0.7' } })
|
||||
assert.deepEqual(runtime.trustedHosts, ['ai.milesward.dev', '10.0.0.7'])
|
||||
assert.deepEqual(runtime.lanAddresses, [])
|
||||
|
||||
// A fence reads the list inside a request; a value frozen at mount time would
|
||||
// go on refusing a name the `/api` gateway had since admitted.
|
||||
connection.trustedHosts = ['ai.milesward.dev', 'app.internal']
|
||||
assert.deepEqual(runtime.trustedHosts, ['ai.milesward.dev', 'app.internal', '10.0.0.7'])
|
||||
})
|
||||
|
||||
test('the seam is on by default, and `apply` provides it', async (t) => {
|
||||
assert.equal(Config({}).provideWebRuntime, true)
|
||||
|
||||
const { ctx, server, provided, logs } = await mockHost({ trustedHosts: ['ai.milesward.dev'] })
|
||||
t.after(() => server.close())
|
||||
apply(ctx, quietConfig())
|
||||
|
||||
assert.deepEqual(provided.get(WEB_RUNTIME_SERVICE)?.trustedHosts, [
|
||||
'ai.milesward.dev',
|
||||
'172.17.0.4',
|
||||
])
|
||||
assert.ok(logs.some((line) => line.includes('provided the webRuntime service')), logs.join('\n'))
|
||||
})
|
||||
|
||||
test('an existing provider is left alone', async (t) => {
|
||||
const existing = { trustedHosts: ['someone-else.example'] }
|
||||
const { ctx, server, provided, logs } = await mockHost({ webRuntime: existing })
|
||||
t.after(() => server.close())
|
||||
apply(ctx, quietConfig())
|
||||
|
||||
assert.equal(provided.get(WEB_RUNTIME_SERVICE), existing)
|
||||
assert.ok(logs.some((line) => line.includes('already provided')), logs.join('\n'))
|
||||
})
|
||||
|
||||
test('the seam can be switched off, and a suspended plugin provides nothing', async (t) => {
|
||||
const off = await mockHost()
|
||||
t.after(() => off.server.close())
|
||||
apply(off.ctx, quietConfig({ provideWebRuntime: false }))
|
||||
assert.equal(off.provided.has(WEB_RUNTIME_SERVICE), false)
|
||||
|
||||
const suspended = await mockHost()
|
||||
t.after(() => suspended.server.close())
|
||||
apply(suspended.ctx, quietConfig({ enabled: false }))
|
||||
assert.equal(suspended.provided.has(WEB_RUNTIME_SERVICE), false)
|
||||
})
|
||||
Reference in new issue
Block a user