diff --git a/apps/web/tests/inspector-late-enable.e2e.ts b/apps/web/tests/inspector-late-enable.e2e.ts
new file mode 100644
index 0000000000..9a7cda3eee
--- /dev/null
+++ b/apps/web/tests/inspector-late-enable.e2e.ts
@@ -0,0 +1,56 @@
+/** Enabling the optional Inspector bundle on a running page mounts the bottom DevTools without a reload. */
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser } from 'playwright'
+import { expect, it, onTestFailed, onTestFinished } from 'vitest'
+import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
+import { PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts'
+
+const bundle = fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url))
+const BOOTSTRAP_PATH = '/api/experimental-inspector/bootstrap'
+
+it('opens bottom DevTools after the Developer Tools bundle is enabled on an already loaded page', async () => {
+ const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
+ onTestFinished(async () => {
+ try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
+ })
+ const scaffold = await launchWebScaffold({
+ profile: { packages: [{ dir: bundle, enabled: false }], hmr: true },
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
+ })
+ resources.scaffold = scaffold
+ // Only a running HMR service applies the bundle switch to this page without a restart.
+ expect(scaffold.ctx.get('hmr')).toBeDefined()
+ // Full Chromium matches the other Inspector browser scenarios.
+ const browser = await chromium.launch({ executablePath: chromium.executablePath() })
+ resources.browser = browser
+ const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 900 } })
+ const tripwire = watchConsole(page)
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-inspector-late-enable'))
+ const bootstraps: number[] = []
+ page.on('response', (response) => {
+ if (new URL(response.url()).pathname === BOOTSTRAP_PATH) bootstraps.push(response.status())
+ })
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await page.evaluate(() => { Reflect.set(window, '__inspectorLateEnableMarker', 'same-document') })
+
+ await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
+ const plugins = page.locator('[data-plugin-panel]')
+ const toggle = plugins.getByRole('switch', { name: 'Enable Developer Tools', exact: true })
+ await toggle.waitFor()
+ expect(await toggle.getAttribute('aria-checked')).toBe('false')
+ const panel = page.locator('[data-inspector-panel]')
+ expect(await panel.count()).toBe(0)
+
+ await toggle.click()
+ await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
+ await panel.waitFor({ state: 'attached', timeout: 20_000 }).catch((error: unknown) => {
+ throw new Error(`Bottom panel did not mount; ${BOOTSTRAP_PATH} responses: ${JSON.stringify(bootstraps)}`, { cause: error })
+ })
+ expect(await panel.isVisible()).toBe(false)
+ await page.keyboard.press('ControlOrMeta+Shift+Period')
+ await panel.waitFor({ state: 'visible' })
+ await page.frameLocator('iframe[data-inspector-devtools]').getByRole('tab', { name: 'Console', exact: true }).waitFor()
+ expect(await page.evaluate((): unknown => Reflect.get(window, '__inspectorLateEnableMarker'))).toBe('same-document')
+ expect(new Set(bootstraps)).toEqual(new Set([200]))
+ expect(tripwire.pageErrors).toEqual([])
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 174956d1b9..ff56fc9485 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -85,6 +85,7 @@
"tests/worktree-opt-in.e2e.ts",
"tests/session-inspector.e2e.ts",
"tests/shared-inspector.e2e.ts",
+ "tests/inspector-late-enable.e2e.ts",
"tests/devtools-frontend.e2e.ts",
"tests/inspector-panels.e2e.ts",
"tests/inspector-source-build.e2e.ts",
diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml
index 6c6b7c70d9..e5f1706ff4 100644
--- a/packages/experimental/inspector/README.i18n.yaml
+++ b/packages/experimental/inspector/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 6d290aabc543f878
zh: 7ad283b159ae2c29
/deepseek-ai-dsh-experimental-inspector/runtime-layout:
- en: 30dad7b7fe3b3465
- zh: 2bda395981802f59
+ en: 13f495af2fec307d
+ zh: ec4bebf24a87ec54
/deepseek-ai-dsh-experimental-inspector/configuration:
en: d094842893700446
zh: 7a7f880ba795948f
diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md
index a110302dd3..9856c718e5 100644
--- a/packages/experimental/inspector/README.md
+++ b/packages/experimental/inspector/README.md
@@ -48,7 +48,7 @@ The frontend uses browser runtime adapters and excludes Node-only transports at
## Runtime layout
-The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh.
+The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route, which the Host registers before it starts the Worker: a request waits for Worker readiness and then receives the bootstrap, or `503` when startup fails; an aborted request stops waiting. When the platform cannot run the Worker and reports Node's `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, such as the browser preview's Worker host, the Host logs one warning and the plugin still starts with only that route, which answers `503`; it provides no `ctx.inspector`, DevTools routes, bootstrap injection, or `devtools://` URL. Any other startup error still rejects the plugin. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh.
The Host plugin starts the Worker and connects a dedicated `MessagePort`. The Client uses injected or fetched browser-source parameters to open an authenticated WebSocket directly to the Worker; it receives no DevTools URL. Chrome DevTools connects to the Worker's CDP WebSocket. A private `node:inspector.Session` per DevTools connection attaches from the Worker to the Host main thread, so Host debugging remains available while Host JavaScript is paused.
diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md
index 619a0be665..66b25ec2cc 100644
--- a/packages/experimental/inspector/README.zh.md
+++ b/packages/experimental/inspector/README.zh.md
@@ -48,7 +48,7 @@ Host 在相对于应用根目录的 `inspector/devtools/devtools_app.html` 路
## 运行时布局
-Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。
+Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。Host 在启动 Worker 之前注册该路由,请求会等到 Worker 就绪后返回 bootstrap,启动失败时返回 `503`;请求中止时立即停止等待。当前平台无法运行 Worker 并报告 Node 的 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 时(例如浏览器预览的 Worker host),Host 记录一条警告,插件仍照常启动,只保留该路由并返回 `503`,不提供 `ctx.inspector`、DevTools 路由、bootstrap 注入或 `devtools://` URL;其他任何启动错误仍使插件启动失败。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。
Host 插件启动 Worker 并连接专用 `MessagePort`。Client 使用注入或主动获取的浏览器 source 参数,直接向 Worker 打开带鉴权的 WebSocket,不接收 DevTools URL。Chrome DevTools 连接 Worker 的 CDP WebSocket。每条 DevTools 连接在 Worker 中独占一个连接 Host 主线程的 `node:inspector.Session`,因此 Host JavaScript 暂停时仍可调试 Host。
diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts
index 23306c4cf4..6294edc2ed 100644
--- a/packages/experimental/inspector/src/host/plugin.ts
+++ b/packages/experimental/inspector/src/host/plugin.ts
@@ -4,12 +4,11 @@ import type { Context } from '@deepseek-ai/cordis'
import { fileURLToPath } from 'node:url'
import { connect } from 'node:net'
import type { Duplex } from 'node:stream'
-import serveStatic from 'serve-static'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-cmdline'
-import open, { apps } from 'open'
-import { resolveInspectorOptions, startInspector, type InspectorOptions } from './bridge/controller.ts'
+import { resolveInspectorOptions, startInspector, type InspectorHandle, type InspectorOptions } from './bridge/controller.ts'
+import type { InspectorClientBootstrap } from '../shared/bridge/messages/control.ts'
import { createInspectorService } from '../shared/service.ts'
import { publishCordisTree } from './inspection/cordis.ts'
import { INSPECTOR_BOOTSTRAP_PATH, INSPECTOR_CLIENT_QUERY, readInspectorClientSelection } from '../shared/web.ts'
@@ -17,6 +16,9 @@ import { disposeInspectorResources } from '../shared/dispose.ts'
const DEVTOOLS_PATH = '/inspector/devtools'
+/** Node's standard code for a feature the running platform cannot provide, such as `worker_threads` in a browser Worker host. */
+const FEATURE_UNAVAILABLE_ON_PLATFORM = 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'
+
export { resolveInspectorOptions, startInspector } from './bridge/controller.ts'
export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './bridge/controller.ts'
export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts'
@@ -45,14 +47,36 @@ export interface HostPluginConfig extends Omit {
await ctx.effect(async () => {
const spec = resolveInspectorOptions(config)
- const handle = await startInspector(spec)
+ const ready = Promise.withResolvers()
const disposers: Array<() => unknown> = []
- const dispose = () => disposeInspectorResources(disposers, () => handle.close(), 'experimental-inspector: disposal failed')
+ let worker: InspectorHandle | undefined
+ const dispose = () => disposeInspectorResources(disposers, () => worker?.close(), 'experimental-inspector: disposal failed')
try {
+ disposers.push(ctx.connection.fetch.register({
+ path: INSPECTOR_BOOTSTRAP_PATH, methods: ['GET'], requestBody: 'buffered',
+ fetch: request => bootstrapResponse(ready.promise, request.signal),
+ }))
+ const handle = await startInspector(spec).catch((error: unknown) => {
+ if (!isUnavailableOnPlatform(error)) throw error
+ ctx.logger.warn('experimental-inspector: NodeJS Inspector is unavailable on this platform; Host inspection stays disabled', error)
+ return undefined
+ })
+ if (handle === undefined) {
+ ready.resolve(undefined)
+ return dispose
+ }
+ worker = handle
+ // Node-only frontend and launcher packages load after the Worker starts, so a platform without it never loads them.
+ const { default: serveStatic } = await import('serve-static')
disposers.push(publishCordisTree(ctx, handle.source, {
maxNodes: spec.maxCordisNodes,
maxBytes: spec.maxSourceFrameBytes - 4_096,
@@ -61,12 +85,6 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise {
table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client })
}))
- disposers.push(ctx.connection.fetch.register({
- path: INSPECTOR_BOOTSTRAP_PATH, methods: ['GET'], requestBody: 'buffered',
- fetch: () => Promise.resolve(Response.json(handle.endpoint.client, {
- headers: { 'cache-control': 'no-store' },
- })),
- }))
const assets = serveStatic(fileURLToPath(new URL('./lib/devtools/',
import.meta.resolve('@deepseek-ai/dsh-experimental-inspector/package.json'))), {
index: false, redirect: false, fallthrough: true,
@@ -148,12 +166,15 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise {
ctx.logger.error('experimental-inspector: Chrome could not open DevTools', error)
})
}
} catch (error) {
+ ready.resolve(undefined)
await dispose().catch((cleanupError: unknown) => {
ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError)
})
@@ -162,3 +183,32 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise, signal: AbortSignal): Promise {
+ const headers = { 'cache-control': 'no-store' }
+ const abandoned = Promise.withResolvers()
+ const abandon = () => { abandoned.resolve(undefined) }
+ if (signal.aborted) abandon()
+ else signal.addEventListener('abort', abandon, { once: true })
+ try {
+ const client = await Promise.race([ready, abandoned.promise])
+ return client === undefined ? new Response(null, { status: 503, headers }) : Response.json(client, { headers })
+ } finally {
+ signal.removeEventListener('abort', abandon)
+ }
+}
diff --git a/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts
new file mode 100644
index 0000000000..1a70b1917b
--- /dev/null
+++ b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts
@@ -0,0 +1,179 @@
+/** Host bootstrap route availability while the Inspector Worker is still starting. */
+
+import { Context, FiberState } from '@deepseek-ai/cordis'
+import type { Message } from '@deepseek-ai/cordis'
+import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import open from 'open'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { apply, Config, inject, name } from '../src/index.ts'
+import { startInspector, type InspectorHandle, type InspectorOptions } from '../src/host/bridge/controller.ts'
+import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts'
+
+vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } }))
+
+vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, startInspector: vi.fn(actual.startInspector) }
+})
+
+/** Startup failures the fixture can inject; only the platform code selects the unavailable state. */
+const FAILURES = {
+ fail: () => new Error('fixture Worker startup failed'),
+ unavailable: () => Object.assign(new Error('web-preview: node:worker_threads.MessageChannel is not available in the worker host'), {
+ code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM',
+ }),
+ 'other-code': () => Object.assign(new Error('fixture Worker port is occupied'), { code: 'EADDRINUSE' }),
+ 'message-only': () => new Error('fixture ERR_FEATURE_UNAVAILABLE_ON_PLATFORM: worker host is not available'),
+} satisfies Record Error>
+
+const UNAVAILABLE_WARNING = 'experimental-inspector: NodeJS Inspector is unavailable on this platform; Host inspection stays disabled'
+
+describe('experimental Inspector Host bootstrap route', () => {
+ let context: Context | undefined
+ const gates: Array> = []
+
+ afterEach(async () => {
+ // A failed assertion can leave startup gated; disposal waits for the pending effect.
+ for (const gate of gates.splice(0)) gate.resolve(undefined)
+ await context?.fiber.dispose()
+ context = undefined
+ vi.restoreAllMocks()
+ vi.clearAllMocks()
+ })
+
+ /** Hold Worker startup until the returned gate opens. */
+ function gateStartup(outcome: 'start' | keyof typeof FAILURES): PromiseWithResolvers {
+ const gate = Promise.withResolvers()
+ gates.push(gate)
+ vi.mocked(startInspector).mockImplementationOnce(async (options?: InspectorOptions): Promise => {
+ await gate.promise
+ if (outcome !== 'start') throw FAILURES[outcome]()
+ const actual = await vi.importActual('../src/host/bridge/controller.ts')
+ return actual.startInspector(options)
+ })
+ return gate
+ }
+
+ function mount(args: readonly string[] = []) {
+ context = new Context()
+ const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
+ const register = vi.fn(() => () => {})
+ const registerUpgrade = vi.fn(() => () => {})
+ context.provide('webServer', { register, registerUpgrade } as Pick as WebServer)
+ context.provide('cmdlineArgs', { get: () => args })
+ const connection = new HostConnectionService(context, [], {} as BrowserAuth)
+ const api = connection.createSharedFetchHandler('/api')
+ const messages: Message[] = []
+ context.logger.exporter({ levels: { default: 3 }, export: (message) => { messages.push(message) } })
+ const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false })
+ const bootstrap = (signal?: AbortSignal) => api.fetch(
+ new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`, signal === undefined ? {} : { signal }),
+ connection.browserPeer,
+ )
+ const injected = () => {
+ const rows: IndexInjection[] = []
+ context!.emit('webserver/index-inject', rows)
+ return rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__')
+ }
+ return { fiber, bootstrap, injected, register, registerUpgrade, messages, log }
+ }
+
+ it('accepts bootstrap requests before the Worker is ready and answers once it is', async () => {
+ const gate = gateStartup('start')
+ const { fiber, bootstrap, injected, register, registerUpgrade } = mount()
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const early = bootstrap()
+ expect(await settledWithin(early, 50)).toBe(false)
+ expect(injected()).toBeUndefined()
+ expect(register).not.toHaveBeenCalled()
+ expect(registerUpgrade).not.toHaveBeenCalled()
+
+ gate.resolve(undefined)
+ await fiber.await()
+ const response = await early
+ expect(response.status).toBe(200)
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ const value = injected()
+ expect(value).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' })
+ expect(await response.json()).toEqual(value?.kind === 'global' ? value.value : undefined)
+ expect(register).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools' }))
+ expect(registerUpgrade).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools/cdp' }))
+ })
+
+ for (const outcome of ['fail', 'other-code', 'message-only'] as const) {
+ it(`answers waiting bootstrap requests with 503 and removes the route when Worker startup fails (${outcome})`, async () => {
+ const gate = gateStartup(outcome)
+ const { fiber, bootstrap, injected, messages } = mount()
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const early = bootstrap()
+ expect(await settledWithin(early, 50)).toBe(false)
+ gate.resolve(undefined)
+ await expect(fiber.await()).rejects.toThrow(FAILURES[outcome]().message)
+ expect(fiber.state).toBe(FiberState.FAILED)
+ const response = await early
+ expect(response.status).toBe(503)
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ expect(injected()).toBeUndefined()
+ expect((await bootstrap()).status).toBe(404)
+ expect(messages.filter(message => message.args[0] === UNAVAILABLE_WARNING)).toEqual([])
+ })
+ }
+
+ it('starts without the NodeJS Inspector when the platform cannot run its Worker', async () => {
+ const gate = gateStartup('unavailable')
+ const { fiber, bootstrap, injected, register, registerUpgrade, messages, log } = mount(['--inspect'])
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const early = bootstrap()
+ expect(await settledWithin(early, 50)).toBe(false)
+ gate.resolve(undefined)
+ await fiber.await()
+ expect(fiber.state).toBe(FiberState.ACTIVE)
+ for (const response of [await early, await bootstrap()]) {
+ expect(response.status).toBe(503)
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ }
+ const warnings = messages.filter(message => message.type === 'warn')
+ expect(warnings).toHaveLength(1)
+ expect(warnings[0]!.args[0]).toBe(UNAVAILABLE_WARNING)
+ expect(warnings[0]!.args[1]).toMatchObject({ code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' })
+ expect(messages.filter(message => message.type === 'error')).toEqual([])
+ expect(register).not.toHaveBeenCalled()
+ expect(registerUpgrade).not.toHaveBeenCalled()
+ expect(injected()).toBeUndefined()
+ expect(context!.get('inspector')).toBeUndefined()
+ expect(log).not.toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector:/u))
+ expect(open).not.toHaveBeenCalled()
+
+ await fiber.dispose()
+ expect((await bootstrap()).status).toBe(404)
+ expect(messages.filter(message => message.type === 'error')).toEqual([])
+ })
+
+ it('stops waiting when a bootstrap request is aborted before the Worker is ready', async () => {
+ const gate = gateStartup('start')
+ const { fiber, bootstrap } = mount()
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const abort = new AbortController()
+ const abandoned = bootstrap(abort.signal)
+ expect(await settledWithin(abandoned, 50)).toBe(false)
+ abort.abort()
+ expect((await abandoned).status).toBe(503)
+ expect((await bootstrap(AbortSignal.abort())).status).toBe(503)
+
+ gate.resolve(undefined)
+ await fiber.await()
+ expect((await bootstrap()).status).toBe(200)
+ })
+})
+
+/** Report whether a promise settles before a real-time deadline. */
+async function settledWithin(promise: Promise, ms: number): Promise {
+ const timeout = new Promise((resolve) => { setTimeout(() => { resolve(false) }, ms) })
+ return Promise.race([promise.then(() => true, () => true), timeout])
+}
diff --git a/packages/experimental/inspector/tests/plugin-routes.host.spec.ts b/packages/experimental/inspector/tests/plugin-routes.host.spec.ts
new file mode 100644
index 0000000000..d37d1cbfc2
--- /dev/null
+++ b/packages/experimental/inspector/tests/plugin-routes.host.spec.ts
@@ -0,0 +1,286 @@
+/** Host DevTools frontend and CDP upgrade routes registered by the Inspector plugin. */
+
+import { randomBytes } from 'node:crypto'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createConnection, createServer, type AddressInfo, type Socket } from 'node:net'
+import { Context } from '@deepseek-ai/cordis'
+import type { Message } from '@deepseek-ai/cordis'
+import type { WebRoute, WebServer, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { apply, Config, inject, name } from '../src/index.ts'
+import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
+
+type AssetMiddleware = (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void
+
+const assets = vi.hoisted(() => ({
+ options: undefined as { setHeaders?: (res: ServerResponse) => void } | undefined,
+ middleware: vi.fn(),
+}))
+
+vi.mock('serve-static', () => ({
+ default: (_root: string, options: { setHeaders?: (res: ServerResponse) => void }) => {
+ assets.options = options
+ return assets.middleware
+ },
+}))
+
+vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, startInspector: vi.fn(actual.startInspector) }
+})
+
+interface RecordedResponse {
+ status?: number
+ headers: Record
+ ended: boolean
+ destroyed: boolean
+}
+
+describe('experimental Inspector Host DevTools routes', () => {
+ let context: Context | undefined
+
+ afterEach(async () => {
+ await context?.fiber.dispose()
+ context = undefined
+ assets.middleware.mockReset()
+ vi.restoreAllMocks()
+ vi.clearAllMocks()
+ })
+
+ async function mount(options: { indexAllowed?: () => boolean; before?: (ctx: Context) => void } = {}) {
+ context = new Context()
+ vi.spyOn(console, 'log').mockImplementation(() => undefined)
+ const routes: WebRoute[] = []
+ const upgrades: WebUpgradeRoute[] = []
+ const webServer: Pick = {
+ register: (route) => { routes.push(route); return () => {} },
+ registerUpgrade: (route) => { upgrades.push(route); return () => {} },
+ }
+ context.provide('webServer', webServer as WebServer)
+ const auth: Pick = {
+ isAuthenticated: () => true,
+ authenticateApi: () => undefined,
+ authorizeIndex: (_request, response) => {
+ if (options.indexAllowed?.() !== false) return true
+ response.writeHead(401)
+ response.end()
+ return false
+ },
+ }
+ new HostConnectionService(context, [], auth as BrowserAuth)
+ const messages: Message[] = []
+ context.logger.exporter({ levels: { default: 3 }, export: (message) => { messages.push(message) } })
+ options.before?.(context)
+ const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false })
+ return { fiber, routes, upgrades, messages }
+ }
+
+ it('answers frontend requests by method, index authorization, redirect, and asset outcome', async () => {
+ let indexAllowed = true
+ const { fiber, routes, messages } = await mount({ indexAllowed: () => indexAllowed })
+ await fiber.await()
+ const route = routes.find(row => row.path === '/inspector/devtools')
+ if (route === undefined) throw new Error('DevTools route was not registered')
+ const call = async (method: string | undefined, url: string | undefined, headersSent = false) => {
+ const recorded: RecordedResponse = { headers: {}, ended: false, destroyed: false }
+ const res = {
+ get headersSent() { return headersSent },
+ setHeader(key: string, value: string) { recorded.headers[key.toLowerCase()] = value },
+ writeHead(status: number, headers: Record = {}) {
+ recorded.status = status
+ for (const [key, value] of Object.entries(headers)) recorded.headers[key.toLowerCase()] = value
+ return this
+ },
+ end() { recorded.ended = true; return this },
+ destroy() { recorded.destroyed = true; return this },
+ }
+ const req = { method, url, headers: {} }
+ await route.handler(req as IncomingMessage, res as ServerResponse)
+ return { recorded, req }
+ }
+
+ expect((await call('POST', '/inspector/devtools/devtools_app.html')).recorded).toMatchObject({ status: 405, headers: { allow: 'GET, HEAD' }, ended: true })
+ indexAllowed = false
+ expect((await call('GET', '/inspector/devtools/devtools_app.html')).recorded.status).toBe(401)
+ indexAllowed = true
+ expect(assets.middleware).not.toHaveBeenCalled()
+ expect((await call('GET', '/inspector/devtools?panel=console')).recorded).toMatchObject({
+ status: 302, headers: { location: 'devtools/devtools_app.html?panel=console' },
+ })
+ expect((await call('HEAD', '/inspector/devtools/')).recorded).toMatchObject({ status: 302, headers: { location: 'devtools_app.html' } })
+
+ assets.middleware.mockImplementationOnce((_req, res) => {
+ assets.options?.setHeaders?.(res)
+ res.writeHead(200).end()
+ })
+ const served = await call('GET', '/inspector/devtools/devtools_app.html?ws=1')
+ expect(served.req.url).toBe('/devtools_app.html?ws=1')
+ expect(served.recorded).toMatchObject({ status: 200, headers: { 'x-content-type-options': 'nosniff' } })
+
+ assets.middleware.mockImplementationOnce((_req, _res, next) => { next() })
+ const missing = await call('GET', undefined)
+ expect(missing.req.url).toBe('')
+ expect(missing.recorded).toMatchObject({ status: 404, ended: true })
+
+ const failure = new Error('fixture asset read failed')
+ assets.middleware.mockImplementation((_req, _res, next) => { next(failure) })
+ expect((await call(undefined, '/inspector/devtools/main.js')).recorded).toMatchObject({ status: 405 })
+ expect((await call('GET', '/inspector/devtools/main.js')).recorded).toMatchObject({ status: 500, ended: true, destroyed: false })
+ const streaming = (await call('GET', '/inspector/devtools/main.js', true)).recorded
+ expect(streaming.status).toBeUndefined()
+ expect(streaming.destroyed).toBe(true)
+ expect(messages.filter(message => message.args[0] === 'experimental-inspector: frontend asset request failed')
+ .map((message): unknown => message.args[1])).toEqual([failure, failure])
+ })
+
+ it('proxies authenticated CDP upgrades with their WebSocket headers and early bytes to the Worker', async () => {
+ const { fiber, upgrades } = await mount()
+ await fiber.await()
+ const upgrade = upgrades.find(row => row.path === '/inspector/devtools/cdp')
+ if (upgrade === undefined) throw new Error('CDP upgrade route was not registered')
+
+ const selected = await socketPair()
+ const reply = readUpgrade(selected.client)
+ await upgrade.handler(upgradeRequest('/inspector/devtools/cdp?clientSourceId=client-fixture', {
+ 'sec-websocket-protocol': ['dsh-fixture', 'dsh-other'],
+ 'sec-websocket-extensions': undefined,
+ 'user-agent': 'fixture',
+ }), selected.server, clientTextFrame(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' })))
+ const answered = await reply
+ expect(answered.status).toMatch(/^HTTP\/1\.1 101 /u)
+ expect(answered.headers.toLowerCase()).toContain('sec-websocket-protocol: dsh-fixture')
+ expect(answered.message.id).toBe(1)
+ expect(answered.message.result).toHaveProperty('sources')
+
+ const unselected = await socketPair()
+ const later = readUpgrade(unselected.client)
+ await upgrade.handler(upgradeRequest(undefined, {}), unselected.server, Buffer.alloc(0))
+ await vi.waitFor(() => { expect(unselected.received().toString('latin1')).toMatch(/^HTTP\/1\.1 101 /u) })
+ unselected.client.write(clientTextFrame(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' })))
+ expect((await later).message).toMatchObject({ id: 1 })
+
+ const reset = await socketPair()
+ await upgrade.handler(upgradeRequest(undefined, {}), reset.server, Buffer.alloc(0))
+ reset.server.destroy(new Error('fixture browser reset'))
+ await reset.closed
+
+ // Disposal releases every still-proxied socket before the Worker closes.
+ await fiber.dispose()
+ await Promise.all([selected.closed, unselected.closed])
+ })
+
+ it('closes the browser socket when the Worker endpoint refuses the proxied connection', async () => {
+ const refused = await closedPort()
+ vi.mocked(startInspector).mockImplementationOnce(async (options) => {
+ const actual = await vi.importActual('../src/host/bridge/controller.ts')
+ const handle = await actual.startInspector(options)
+ return redirectedHandle(handle, `ws://127.0.0.1:${String(refused)}/devtools/page/refused`)
+ })
+ const { fiber, upgrades } = await mount()
+ await fiber.await()
+ const upgrade = upgrades.find(row => row.path === '/inspector/devtools/cdp')
+ if (upgrade === undefined) throw new Error('CDP upgrade route was not registered')
+ const pair = await socketPair()
+ await upgrade.handler(upgradeRequest(undefined, {}), pair.server, Buffer.alloc(0))
+ await pair.closed
+ expect(pair.received().length).toBe(0)
+ })
+
+ it('logs a rollback failure and still rejects initialization with the registration error', async () => {
+ const closeFailure = new Error('fixture Worker close failed')
+ vi.mocked(startInspector).mockImplementationOnce(async (options) => {
+ const actual = await vi.importActual('../src/host/bridge/controller.ts')
+ const handle = await actual.startInspector(options)
+ return { endpoint: handle.endpoint, source: handle.source, close: async () => { await handle.close(); throw closeFailure } }
+ })
+ const { fiber, messages } = await mount({
+ before: (ctx) => {
+ ctx.provide('inspector', {
+ publish: () => undefined,
+ cordis: { getTree: () => Promise.reject(new Error('unused test service')) },
+ })
+ },
+ })
+ await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered')
+ const rollback = messages.find(message => message.args[0] === 'experimental-inspector: initialization rollback failed')
+ expect(rollback?.type).toBe('error')
+ expect(rollback?.args[1]).toMatchObject({ errors: [closeFailure] })
+ })
+})
+
+function upgradeRequest(url: string | undefined, headers: Record): IncomingMessage {
+ return {
+ url,
+ headers: {
+ host: '127.0.0.1', origin: 'http://127.0.0.1', connection: 'Upgrade', upgrade: 'websocket',
+ 'sec-websocket-key': randomBytes(16).toString('base64'), 'sec-websocket-version': '13', ...headers,
+ },
+ } as IncomingMessage
+}
+
+/** Encode one masked client-to-server WebSocket text frame. */
+function clientTextFrame(text: string): Buffer {
+ const payload = Buffer.from(text)
+ if (payload.length >= 126) throw new Error('fixture frame must stay below 126 bytes')
+ const mask = randomBytes(4)
+ return Buffer.concat([Buffer.from([0x81, 0x80 | payload.length]), mask, payload.map((byte, index) => byte ^ mask[index % 4]!)])
+}
+
+/** Read the upgrade response and the first server text frame that answers request id 1. */
+async function readUpgrade(socket: Socket): Promise<{ status: string; headers: string; message: Record }> {
+ let buffer = Buffer.alloc(0)
+ return new Promise((resolve, reject) => {
+ socket.on('data', (chunk: Buffer) => {
+ buffer = Buffer.concat([buffer, chunk])
+ const end = buffer.indexOf('\r\n\r\n')
+ if (end < 0) return
+ const head = buffer.subarray(0, end).toString('latin1')
+ let offset = end + 4
+ while (offset + 2 <= buffer.length) {
+ const short = buffer[offset + 1]! & 0x7f
+ const lengthBytes = short === 126 ? 2 : short === 127 ? 8 : 0
+ if (offset + 2 + lengthBytes > buffer.length) return
+ const length = short === 126 ? buffer.readUInt16BE(offset + 2) : short === 127 ? Number(buffer.readBigUInt64BE(offset + 2)) : short
+ const start = offset + 2 + lengthBytes
+ if (start + length > buffer.length) return
+ const message = JSON.parse(buffer.subarray(start, start + length).toString('utf8')) as Record
+ if (message.id === 1) {
+ const [status = '', ...headers] = head.split('\r\n')
+ resolve({ status, headers: headers.join('\n'), message })
+ return
+ }
+ offset = start + length
+ }
+ })
+ socket.once('close', () => { reject(new Error(`socket closed before a CDP reply: ${buffer.toString('latin1')}`)) })
+ })
+}
+
+/** Open one connected TCP pair: `server` stands for the browser socket the Web server hands to the upgrade route. */
+async function socketPair(): Promise<{ client: Socket; server: Socket; closed: Promise; received: () => Buffer }> {
+ const listener = createServer()
+ await new Promise((resolve) => { listener.listen(0, '127.0.0.1', resolve) })
+ const accepted = new Promise((resolve) => { listener.once('connection', resolve) })
+ const client = createConnection((listener.address() as AddressInfo).port, '127.0.0.1')
+ const server = await accepted
+ listener.close()
+ let received = Buffer.alloc(0)
+ client.on('data', (chunk: Buffer) => { received = Buffer.concat([received, chunk]) })
+ client.on('error', (error: unknown) => { void error /* The proxied peer may reset the fixture connection; `closed` observes the outcome. */ })
+ const closed = new Promise((resolve) => { client.once('close', () => { resolve() }) })
+ return { client, server, closed, received: () => received }
+}
+
+async function closedPort(): Promise {
+ const listener = createServer()
+ await new Promise((resolve) => { listener.listen(0, '127.0.0.1', resolve) })
+ const { port } = listener.address() as AddressInfo
+ await new Promise((resolve) => { listener.close(() => { resolve() }) })
+ return port
+}
+
+function redirectedHandle(handle: InspectorHandle, webSocketDebuggerUrl: string): InspectorHandle {
+ return { endpoint: { ...handle.endpoint, webSocketDebuggerUrl }, source: handle.source, close: () => handle.close() }
+}
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 692b5c6d04..1815f14578 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -77,6 +77,7 @@
"apps/web/tests/worktree-opt-in.e2e.ts",
"apps/web/tests/session-inspector.e2e.ts",
"apps/web/tests/shared-inspector.e2e.ts",
+ "apps/web/tests/inspector-late-enable.e2e.ts",
"apps/web/tests/layout-bottom.e2e.ts",
"apps/web/tests/devtools-frontend.e2e.ts",
"apps/web/tests/inspector-panels.e2e.ts",
diff --git a/vitest.config.ts b/vitest.config.ts
index 711733c10e..9c717c0ced 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -306,7 +306,6 @@ export default defineConfig({
// Keep already-complete Inspector modules under the per-file gate and
// enumerate the remaining direct-test debt instead of exempting src/**.
// TODO(inspector): close these branch gaps and remove the entries.
- 'packages/experimental/inspector/src/host/plugin.ts',
'packages/experimental/inspector/src/shared/bridge/{control-codec,rpc}.ts',
'packages/experimental/inspector/src/shared/bridge/messages/observation.ts',
'packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts',