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.
176 lines
6.4 KiB
JavaScript
176 lines
6.4 KiB
JavaScript
/**
|
|
* Live probe for a running harness.
|
|
*
|
|
* Verifies, against the process actually serving your browser, that
|
|
* 1. a non-loopback `Host` is admitted by the plugin market's fence,
|
|
* 2. the shell is served without a process token,
|
|
* 3. the Skill Center answers a non-loopback *peer*,
|
|
* 4. the browser shims and the PWA manifest actually reach the page, and
|
|
* 5. the sidebar's Files API is registered rather than falling through to the
|
|
* static handler as a 405 — the shape a plugin takes when the service it
|
|
* injects (`webRuntime`) is not provided.
|
|
*
|
|
* Point 3 is the one that needs care: the Skill Center fences on the socket
|
|
* address as well as the Host, so a probe over 127.0.0.1 can never reproduce a
|
|
* remote client and always looks like it passes. This connects to a
|
|
* non-loopback local address instead, so the peer address matches what a LAN or
|
|
* tunnel client looks like.
|
|
*
|
|
* node scripts/live-probe.mjs [--port 3080] [--host your.hostname] [--addr 172.17.0.4]
|
|
*
|
|
* Exit code is 0 when every check passes.
|
|
*/
|
|
|
|
import http from 'node:http'
|
|
import { networkInterfaces } from 'node:os'
|
|
import { MANIFEST_PATH, PWA_MARKER, SHIM_MARKER } from '../lib/shims.js'
|
|
|
|
function parseArgs(argv) {
|
|
const options = {}
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const key = argv[index]?.replace(/^--/, '')
|
|
if (key !== undefined && key !== '') options[key] = argv[index + 1]
|
|
}
|
|
return options
|
|
}
|
|
|
|
const args = parseArgs(process.argv.slice(2))
|
|
const PORT = Number(args.port ?? process.env.PROBE_PORT ?? 3080)
|
|
|
|
/** The container's or host's own non-loopback IPv4, which is what a bridge or LAN peer sees. */
|
|
function localAddress() {
|
|
if (args.addr !== undefined) return args.addr
|
|
for (const addresses of Object.values(networkInterfaces())) {
|
|
for (const address of addresses ?? []) {
|
|
if (address.family === 'IPv4' && !address.internal) return address.address
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const ADDR = localAddress()
|
|
if (ADDR === undefined) {
|
|
console.error('live-probe: no non-loopback IPv4 address found; pass --addr')
|
|
process.exit(2)
|
|
}
|
|
|
|
/** Hostnames to test. Defaults to the LAN literal plus whatever --host names. */
|
|
const HOSTS = [
|
|
{ label: 'LAN address', host: `${ADDR}:${PORT}` },
|
|
...(args.host === undefined ? [] : [{ label: 'named host', host: args.host }]),
|
|
]
|
|
|
|
const PATH_MARKET = '/dsh-market/discovery-compatibility'
|
|
const PATH_SKILLS = '/api/dsh-skill-explorer/health'
|
|
|
|
function request({ method, path, host, body }) {
|
|
return new Promise((resolve) => {
|
|
const call = http.request(
|
|
{
|
|
host: ADDR,
|
|
port: PORT,
|
|
path,
|
|
method,
|
|
headers: { host, origin: `http://${host}`, 'content-type': 'application/json' },
|
|
},
|
|
(response) => {
|
|
let text = ''
|
|
response.on('data', (chunk) => (text += chunk))
|
|
response.on('end', () => resolve({ status: response.statusCode, text }))
|
|
},
|
|
)
|
|
call.on('error', (error) => resolve({ status: 0, text: error.message }))
|
|
call.end(body)
|
|
})
|
|
}
|
|
|
|
const checks = []
|
|
const record = (name, ok, detail) => {
|
|
checks.push({ name, ok, detail })
|
|
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name.padEnd(46)} ${detail}`)
|
|
}
|
|
|
|
console.log(`live-probe: connecting to ${ADDR}:${PORT} (non-loopback peer)\n`)
|
|
|
|
for (const identity of HOSTS) {
|
|
const market = await request({
|
|
method: 'POST',
|
|
path: PATH_MARKET,
|
|
host: identity.host,
|
|
body: '{}',
|
|
})
|
|
record(
|
|
`${identity.label}: market POST not refused`,
|
|
market.status !== 403,
|
|
`${market.status}${market.status === 403 ? ' untrusted origin' : ''}`,
|
|
)
|
|
|
|
const shell = await request({ method: 'GET', path: '/', host: identity.host })
|
|
record(`${identity.label}: shell served`, shell.status === 200, String(shell.status))
|
|
|
|
const skills = await request({ method: 'GET', path: PATH_SKILLS, host: identity.host })
|
|
record(
|
|
`${identity.label}: Skill Center answers`,
|
|
skills.status === 200,
|
|
skills.status === 200 ? 'ok' : `${skills.status}${skills.status === 400 ? ' (fence threw)' : ''}`,
|
|
)
|
|
}
|
|
|
|
// The shims and the manifest are properties of the deployment, not of any one
|
|
// identity, so they are checked once rather than per Host.
|
|
const anyIdentity = HOSTS[0].host
|
|
const index = await request({ method: 'GET', path: '/', host: anyIdentity })
|
|
const shims = index.text.includes(SHIM_MARKER)
|
|
record(
|
|
'browser shims reach the page',
|
|
shims && index.text.includes('getRandomValues') && index.text.includes('execCommand'),
|
|
shims ? 'randomUUID + clipboard' : 'absent from index.html',
|
|
)
|
|
record(
|
|
'viewport widened for standalone mode',
|
|
index.text.includes('viewport-fit=cover'),
|
|
index.text.includes('viewport-fit=cover') ? 'cover' : 'not rewritten',
|
|
)
|
|
const pwa = index.text.includes(PWA_MARKER)
|
|
record('PWA metadata reaches the page', pwa, pwa ? 'manifest link + app meta' : 'absent')
|
|
|
|
const manifest = await request({ method: 'GET', path: MANIFEST_PATH, host: anyIdentity })
|
|
let manifestOk = false
|
|
try {
|
|
manifestOk = manifest.status === 200 && JSON.parse(manifest.text).display === 'standalone'
|
|
} catch {
|
|
manifestOk = false
|
|
}
|
|
record('manifest is served', manifestOk, `${manifest.status}`)
|
|
|
|
// A plugin whose injection is unsatisfied never runs `apply`, so its routes are
|
|
// absent and this URL is answered by the core static handler, which allows only
|
|
// GET/HEAD. A registered route answers 400 (no sessionId) or 404 in the plugin's
|
|
// own JSON shape; 405 is the fall-through, and is the whole of "the Files pane
|
|
// stopped previewing anything".
|
|
const sidebar = await request({
|
|
method: 'POST',
|
|
path: '/sidebar/api/fs.tree',
|
|
host: anyIdentity,
|
|
body: '{}',
|
|
})
|
|
const sidebarMounted = sidebar.status !== 405 && sidebar.status !== 0
|
|
record(
|
|
'sidebar Files API is registered',
|
|
sidebarMounted,
|
|
sidebarMounted
|
|
? `${sidebar.status} from a registered route`
|
|
: '405 from the static handler: the plugin never mounted (is webRuntime provided?)',
|
|
)
|
|
|
|
const failed = checks.filter((check) => !check.ok)
|
|
console.log(`\n${checks.length - failed.length}/${checks.length} passed`)
|
|
if (failed.length > 0) {
|
|
console.log('\nA 403 on the market means the origin fence is still in force.')
|
|
console.log('A 400 on the Skill Center means it is still refusing (or throwing for) a remote peer.')
|
|
console.log('A missing shim or manifest usually means the updated bundle is not loaded yet.')
|
|
console.log('A 405 on the Files API means a plugin stayed pending: check the boot log for')
|
|
console.log('"did not activate" and whether the service it injects is provided.')
|
|
process.exit(1)
|
|
}
|