diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml new file mode 100644 index 0000000000..10eddece3c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-22-web-bind-address.md: 3332176c0cee940648ad334a44edd30879225503 +2026-07-22-web-bind-address.zh.md: f539fff93628205bf0099d8f23dfd13d14e55ca5 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md new file mode 100644 index 0000000000..3332176c0c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md @@ -0,0 +1,29 @@ +# Agent Note: Explicit web bind address + +Status: implemented + +English | [中文](2026-07-22-web-bind-address.zh.md) + +## Problem + +`dsh web` binds every network interface even when its browser runs on the same machine. Local use therefore exposes an unauthenticated development server without an explicit operator choice, while remote-container and LAN-browser use still needs a supported way to accept non-loopback connections. + +The HTTP carrier also hides the bind address inside `startWebServer()`, so alternate shells cannot state their own network policy at the package boundary. + +## Decision + +`dsh web` binds `127.0.0.1` by default. The CLI accepts `--host 0.0.0.0` as the explicit all-interface mode and rejects other values so its network modes remain a small, deliberate contract. All-interface mode keeps printing the loopback URL and, when available, the first external IPv4 URL. + +`WebServerOptions.host` is required. The HTTP carrier passes that value to `node:http` without supplying a fallback, leaving each shell responsible for its bind policy. Programmatic carrier consumers may select another hostname or address directly. + +## Alternatives considered + +**Keep `0.0.0.0` as the default.** Rejected because ordinary same-machine use does not need network-wide reachability and should not acquire it implicitly. + +**Use a boolean exposure flag.** Rejected because `--host 0.0.0.0` names the resulting socket behavior directly and matches the underlying server option without introducing a second term. + +**Default inside `startWebServer()`.** Rejected because the carrier has multiple possible shells and no basis for choosing their deployment policy. Requiring `host` makes the choice visible at every assembly call. + +## Consequences + +Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`; a browser on another machine must opt in with `dsh web --host 0.0.0.0`. The CLI does not yet expose custom interface addresses or IPv6 modes, while programmatic carrier consumers retain that flexibility. Server tests pin both loopback and all-interface forwarding into the Node listen boundary, and the web smoke continues to exercise the default CLI path. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md new file mode 100644 index 0000000000..f539fff936 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md @@ -0,0 +1,29 @@ +# Agent Note:显式指定 Web 绑定地址 + +Status: implemented + +[English](2026-07-22-web-bind-address.md) | 中文 + +## 问题 + +即便浏览器与服务器运行在同一台机器上,`dsh web` 也会绑定所有网络接口。因此,本地使用会在操作者未明确选择的情况下暴露一个未经身份验证的开发服务器;另一方面,远程容器和局域网浏览器场景仍需要一种受支持的方式来接受非环回连接。 + +HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包(package)边界明确表达自己的网络策略。 + +## 决策 + +`dsh web` 默认绑定 `127.0.0.1`。CLI(命令行界面)接受 `--host 0.0.0.0` 作为显式启用的全接口模式,并拒绝其他取值,使网络模式保持为一份规模小、经过审慎限定的契约。全接口模式仍然输出本机环回 URL,并在可用时输出第一个外部 IPv4 URL。 + +`WebServerOptions.host` 为必填项。HTTP 承载层将该值直接传给 `node:http`,不提供回退值,因此每个壳层负责制定自己的绑定策略。以编程方式使用承载层的消费方可以直接选择其他主机名或地址。 + +## 曾考虑的替代方案 + +**保留以 `0.0.0.0` 作为默认值。** 不予采纳,因为普通的同机使用不需要在全网范围内可达,也不应隐式获得这种可达性。 + +**使用布尔型暴露标志。** 不予采纳,因为 `--host 0.0.0.0` 直接说明最终的套接字行为,并与底层服务器选项一致,无需再引入第二套术语。 + +**在 `startWebServer()` 内设置默认值。** 不予采纳,因为承载层可能由多种壳层调用,没有依据替它们选择部署策略。要求传入 `host`,可使每次装配调用都明确作出这一选择。 + +## 后果 + +`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问;其他机器上的浏览器必须使用 `dsh web --host 0.0.0.0` 显式启用。CLI 尚未开放自定义接口地址或 IPv6 模式,而以编程方式使用承载层的消费方仍保留这种灵活性。服务器测试将环回模式和全接口模式向 Node 监听边界的传递固定为契约,Web 冒烟测试继续覆盖默认 CLI 路径。 diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 5e03f2bb19..02e98e78b5 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -10,14 +10,27 @@ import { createRequire } from 'node:module' import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +const LOOPBACK_HOST = '127.0.0.1' +const ALL_INTERFACES_HOST = '0.0.0.0' + export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ args: argv, - options: { port: { type: 'string', default: '3080' } }, + options: { + host: { type: 'string', default: LOOPBACK_HOST }, + port: { type: 'string', default: '3080' }, + }, allowPositionals: false, }) + if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { + process.stderr.write( + `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, + ) + process.exit(1) + } + const hostAddress = values.host const port = Number(values.port) - if (!Number.isInteger(port) || port <= 0 || port > 65535) { + if (!Number.isInteger(port) || port < 0 || port > 65535) { process.stderr.write(`dsh web: invalid --port ${values.port}\n`) process.exit(1) } @@ -65,7 +78,7 @@ export async function runWeb(argv: string[]): Promise { let server: Awaited> try { server = await startWebServer( - { port, distIndex, apiHandler: host.handler, webPlugins }, + { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, (err: Error) => { process.stderr.write(`dsh web: ${String(err)}\n`) void shutdown(1) @@ -78,11 +91,12 @@ export async function runWeb(argv: string[]): Promise { process.exit(1) } - // The server binds 0.0.0.0 (remote-container + LAN-browser is the primary scenario); - // print the LAN address alongside loopback so the printed URL is copy-usable from outside. - const lan = Object.values(networkInterfaces()).flat() - .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - console.log(`dsh web: http://127.0.0.1:${server.port}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) + const lan = hostAddress === ALL_INTERFACES_HOST + ? Object.values(networkInterfaces()).flat() + .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + : undefined + const localUrl = `http://${LOOPBACK_HOST}:${server.port}` + console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) process.on('SIGTERM', () => { void shutdown(0) }) process.on('SIGINT', () => { void shutdown(130) }) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 291e871e63..b75716c615 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -44,6 +44,7 @@ describe('web boot chain (keyless, real carrier)', () => { const port = await probeFreePort() const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) } server = await startWebServer({ + host: '127.0.0.1', port, distIndex: DIST_INDEX, apiHandler, @@ -110,6 +111,7 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( // ?fixture never opens HTTP streams; /api is a tripwire like the first describe. const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) } server = await startWebServer({ + host: '127.0.0.1', port, distIndex: DIST_INDEX, apiHandler, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 95b293d0e7..a51e0c9e56 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -85,6 +85,39 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => { }) if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`) +describe('dsh web keyless CLI smoke', () => { + it('listens on 127.0.0.1 by default', async () => { + requireDist() + const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-')) + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: sessionsDir, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-no-call', + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const readyUrl = await waitForReadyLine(child) + expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + expect((await fetch(readyUrl)).status).toBe(200) + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + rmSync(sessionsDir, { recursive: true, force: true }) + } + }) +}) + describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { let child: ChildProcess let sessionsDir: string diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 28f7ccd49c..9e59699bd6 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. -The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own. @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No TLS, auth, or origin policy** — the server binds `0.0.0.0` and trusts its network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. +- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. - **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. -- **`port` is the only listen knob** — bind address and socket options are fixed until a deployment needs them. +- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 3d7531caed..074bfe395c 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -10,6 +10,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { readFile } from 'node:fs/promises' +import type { AddressInfo } from 'node:net' import { dirname } from 'node:path' import { serveStatic } from './static.ts' import type { HostWebPluginRegistry } from './web-plugins.ts' @@ -21,7 +22,9 @@ export type { /** Options for startWebServer. */ export interface WebServerOptions { - /** Port to listen on (0.0.0.0). */ + /** Address or hostname to listen on. */ + host: string + /** Port to listen on; zero requests an OS-assigned port. */ port: number /** * Absolute path of index.html inside the static root — the caller resolves @@ -40,7 +43,7 @@ export interface WebServerOptions { /** Listening web server handle. */ export interface RunningWebServer { - /** The listening port (for the shell's URL line; equals options.port). */ + /** The listening port, including the OS-assigned value when options.port is zero. */ port: number /** * Shutdown: close + closeAllConnections (SSE connections never end on their @@ -50,7 +53,7 @@ export interface RunningWebServer { } /** - * Start the web-shape HTTP server: listen(port, '0.0.0.0'). + * Start the web-shape HTTP server on the caller-selected host and port. * Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else → * static with the step1-locked semantics (403 traversal, SPA fallback 200). * A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a @@ -63,7 +66,7 @@ export interface RunningWebServer { * @returns the running server handle once listening. */ export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise { - const { port, distIndex, apiHandler, webPlugins } = options + const { host, port, distIndex, apiHandler, webPlugins } = options const distRoot = dirname(distIndex) const renderIndex = webPlugins === undefined ? undefined : async (): Promise => { const html = await readFile(distIndex, 'utf8') @@ -113,10 +116,10 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) return new Promise((resolveListen, rejectListen) => { server.once('error', rejectListen) - server.listen(port, '0.0.0.0', () => { + server.listen(port, host, () => { server.off('error', rejectListen) server.on('error', onError) - resolveListen({ port, close }) + resolveListen({ port: (server.address() as AddressInfo).port, close }) }) }) } diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 9c7613ee88..0b9d1a8978 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -1,16 +1,16 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { createServer as createNetServer, type AddressInfo } from 'node:net' +import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { startWebServer, type RunningWebServer } from '../src/index.ts' -/** RunningWebServer.port echoes options.port, so tests must pick a concrete free port up front. */ +/** Reserve a loopback port for tests that need to address a second server. */ function freePort(): Promise { return new Promise((resolve, reject) => { const probe = createNetServer() probe.once('error', reject) - probe.listen(0, () => { + probe.listen(0, '127.0.0.1', () => { const port = (probe.address() as AddressInfo).port probe.close(() => { resolve(port) }) }) @@ -107,16 +107,15 @@ afterEach(async () => { async function boot(onError: (err: Error) => void = () => undefined): Promise { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, onError) + server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError) return `http://127.0.0.1:${String(server.port)}` } describe('startWebServer', () => { it('reports the listening port and closes idempotently', async () => { const { distIndex } = makeDist() - const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined) - expect(server.port).toBe(port) + server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined) + expect(server.port).toBeGreaterThan(0) const first = server.close() const second = server.close() expect(second).toBe(first) @@ -124,11 +123,33 @@ describe('startWebServer', () => { server = undefined }) + it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => { + const { distIndex } = makeDist() + const port = 3080 + const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function ( + this: NetServer, ...args: unknown[] + ): NetServer { + const callback = args.at(-1) + if (typeof callback !== 'function') throw new TypeError('listen callback missing') + queueMicrotask(callback as () => void) + return this + }) + const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port }) + try { + const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined) + expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function)) + await inertServer.close() + } finally { + address.mockRestore() + listen.mockRestore() + } + }) + it('rejects when the port is already taken', async () => { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined) - await expect(startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)) + server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined) + await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)) .rejects.toMatchObject({ code: 'EADDRINUSE' }) }) }) @@ -185,7 +206,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined, } const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined) + server = await startWebServer( + { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + ) return `http://127.0.0.1:${String(server.port)}` } @@ -221,7 +244,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti clientPath: () => '/nonexistent/lib/client.js', } const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined) + server = await startWebServer( + { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + ) const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`) expect(res.status).toBe(404) })