feat(web): advertise a configured public application URL (#3972)

* feat(web): advertise a configured public application URL

* test(session): restore migration verifier prefix coverage

* test(llm): synchronize idle timeout with provider request

* Remove unrelated migration Worker lifecycle changes

---------

Co-authored-by: liukx0205 <harusoda@berkeley.edu>
This commit is contained in:
Yichen Yanandliukx0205 authored and GitHub committed 2026-09-30 17:46:52 +08:00
1 parent f3679c5006
commit a7c3ad99bc
32 files changed
+792 -101

No files matched your search

@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-07-public-web-deployments.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-public-web-deployments.md
/agent-note-advertised-public-http-s-web-roots:
en: 446c76d51a356326
zh: c61302be127d7a01
/agent-note-advertised-public-http-s-web-roots/problem:
en: 982c2131e267c975
zh: 62753dbc8b8e8c92
/agent-note-advertised-public-http-s-web-roots/decision:
en: 1ba95dbdde7c5487
zh: 03c5dcec109c8029
/agent-note-advertised-public-http-s-web-roots/alternatives-considered:
en: 6b3cd8ae0db4e11a
zh: c22b33646178c70e
/agent-note-advertised-public-http-s-web-roots/consequences:
en: 40e1875dab5cc69d
zh: a06aacfc62673f2c
/agent-note-advertised-public-http-s-web-roots/testing:
en: 067cbc57b88ecfd3
zh: cccfe52559e4e1cf
@@ -0,0 +1,37 @@
# Agent Note: Advertised public HTTP(S) Web roots
Status: implemented
English | [中文](2026-09-07-public-web-deployments.zh.md)
## Problem
A forwarding gateway can expose the Web GUI beneath a shared HTTP(S) origin and strip its path prefix before forwarding, so one process answers loopback `/` and an external mount at once — a mount the listener cannot know and an origin only the operator knows. A proxied request carries that external authority in `Host`, which the browser-trust fence refuses until the deployment declares it.
## Decision
`--public-url` is an optional advertised HTTP(S) application root — `http(s)://`, an optional forwarding prefix, and no credentials, query, or fragment — reaching the Web bundle's `publicUrl` config through `src/public-url.ts`. It supplies the printed and opened URL, `DSH_WEB_URL`, and the web-surface orientation, and configures no routing, client transport, or cookie scope. The config field and the CLI flag are both advertisement only: the browser-visible authority must be named with repeatable `--trusted-host`, and the fence still validates `Host` and `Origin` per request, so advertisement grants neither identity nor authentication.
The advertised root is the first production input that can carry a mount prefix, so the launch flow adds the launch token without collapsing that mount. Network listening stays explicit and independent — the shipped CLI refuses a wildcard `--host` — and the proxy owns the external leg, whose required operations the [public deployments guide](../../../../docs/user/guide/public-deployments.md) publishes.
## Alternatives considered
**Let the advertised root select routing and cookies.** One root would supply every browser URL and cookie path, but the advertisement would then decide request routing, so one process could no longer serve loopback and a prefixed mount at once.
**Fold the advertised host into trustedHosts.** Rejected: advertisement must not change the fence, and #4181 keeps trust explicit.
**Have the backend emit mount-aware URLs.** A configured `<base>` or prefixed asset paths would require the webserver to know the external mount and duplicate knowledge the proxy already holds.
**Infer the external leg from requests or forwarded headers.** Request-derived authority is spoofable, and accepting stripped and preserved paths alike leaves the deployment ambiguous.
**Infer network exposure from the advertised root or a trusted host.** A display or authority choice cannot authorize opening every interface, so listener selection stays explicit and a wildcard host stays rejected.
## Consequences
The advertised URL carries no credential until the process adds its launch token, and a misconfigured address can still disclose it. An HTTP root carries that tokenized URL and the session cookie without TLS, and an HTTPS root encrypts only the browser-to-proxy leg.
Advertising an authority admits nothing on its own: a browser reaching the deployment under any non-loopback authority stays outside the fence until `--trusted-host` names it.
## Testing
`tests/public-url.spec.ts` covers URL validation and normalization; `tests/startup.spec.ts` covers the flag over a real Loader tree, including that the advertised host leaves the accepted authorities untouched; `tests/web-app.spec.ts` covers the printed URL, handoff, web-surface prompt, `DSH_WEB_URL`, and a `null` field advertising loopback; `apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` boots the built profile with `--public-url` behind the reference prefix-stripping proxy and authenticates through both the advertised mount and loopback, so the bundle patch's `publicUrl` expression is exercised as shipped.
@@ -0,0 +1,37 @@
# Agent Note: 对外公告的公开 HTTP(S) Web 根
Status: implemented
[English](2026-09-07-public-web-deployments.md) | 中文
## Problem
转发网关可以把 Web GUI 暴露在共享的 HTTP(S) origin 之下,并在转发前剥离其路径前缀,因此同一个进程要同时应答 loopback `/` 与外部挂载——该挂载监听器无从得知,而该 origin 只有运维方知道。经代理转发的请求会在 `Host` 中携带这个外部 authority,浏览器信任栅栏在部署声明它之前会予以拒绝。
## Decision
`--public-url` 是可选的对外公告 HTTP(S) 应用根——`http(s)://`,可带转发前缀,不允许凭据、query 或 fragment——经由 `src/public-url.ts` 抵达 Web bundle 的 `publicUrl` 配置。它提供打印与打开的 URL、`DSH_WEB_URL` 与 web 表层定位,且不配置任何路由、客户端传输或 cookie 作用域。配置字段与 CLI flag 都只是公告:浏览器可见的 authority 必须用可重复的 `--trusted-host` 点名,栅栏才会接受它;栅栏仍逐请求校验 `Host` 与 `Origin`,因此公告既不授予身份,也不代表已认证。
公告根是首个可能携带挂载前缀的生产输入,因此启动流程加上启动 token 时不会压平该挂载。网络监听始终显式且独立——随附 CLI 拒绝通配的 `--host`——外部链路归代理所有,部署必须提供的操作发布在[公开部署指南](../../../../docs/user/guide/public-deployments.zh.md)。
## Alternatives considered
**让公告根同时决定路由与 cookie。** 单一根会提供全部浏览器 URL 与 cookie 路径,但公告随即成为请求路由决策,一个进程再也无法同时服务 loopback 与带前缀的挂载。
**把公告主机并入 trustedHosts。** 否决:公告不得改变栅栏,且 #4181 让信任始终显式。
**让后端产出感知挂载的 URL。** 配置 `<base>` 或带前缀的资源路径会要求 webserver 了解外部挂载,并重复代理已经掌握的信息。
**从请求或转发头推断外部链路。** 请求派生的 authority 可被伪造,而同时接受被剥离与未被剥离的路径会让部署变得含糊。
**从公告根或可信主机推断网络暴露。** 展示或 authority 选择不能授权打开所有网卡,因此监听选择始终显式,通配主机也始终被拒绝。
## Consequences
公告 URL 在进程附带启动 token 之前不含凭据,而配置错误的地址仍可能泄露它。HTTP 根会在无 TLS 的情况下携带带 token 的 URL 与会话 cookie,HTTPS 根则只加密浏览器到代理这一段。
公告本身不接纳任何 authority:浏览器以任何非 loopback authority 访问部署,都会一直处于栅栏之外,直到 `--trusted-host` 点名它。
## Testing
`tests/public-url.spec.ts` 覆盖 URL 校验与归一化;`tests/startup.spec.ts` 在真实 Loader 树上覆盖该 flag,包括公告主机不会改动被接受的 authority;`tests/web-app.spec.ts` 覆盖打印 URL、交接、web 表层提示词、`DSH_WEB_URL`,以及字段为 `null` 时公告 loopback;`apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` 以 `--public-url` 在参考剥前缀代理之后启动已构建的 profile,并同时经公告挂载与 loopback 完成认证,因此 bundle patch 中的 `publicUrl` 表达式按出厂形态得到执行。
+4 -4
View File
@@ -9,8 +9,8 @@
en: e3a159750f5f9ed3
zh: e02d9004fcc239f9
/dsh-cli-behavior-reference/profile-boot/app-arguments:
en: 241e5525aa09a33b
zh: 398f853143097154
en: be06f11a3b585d0f
zh: 28c7ee6e0d86dc3b
/dsh-cli-behavior-reference/profile-boot/config-schema-dump:
en: a356f8a68f3b3685
zh: c718713b5d709295
@@ -21,8 +21,8 @@
en: 9980ff1600fa9460
zh: cc66ba45e27b5540
/dsh-cli-behavior-reference/web-profile:
en: 12bba432c07573bf
zh: ab6b365cfb95003f
en: 42f0b9349cf1574b
zh: a3cc6e352e71badb
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
+4 -2
View File
@@ -33,7 +33,7 @@ The shipped apps own these command lines:
| Profile | Arguments |
|---|---|
| `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` |
| `web` | `--host`, `--port`, `--public-url`, repeatable `--trusted-host`, `--no-open` |
| `headless` | the task text, as the positional argument |
| `sdk` | no options; stdio carries the JSON-RPC protocol |
| `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol |
@@ -104,6 +104,8 @@ Git-hosted plugins that ship sources build during install through their `prepare
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
`--public-url <url>` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's loopback URL. It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide.
```sh
dsh web
dsh web --no-open
@@ -112,7 +114,7 @@ dsh web --dump-config
dsh web --help
```
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL — the advertised root when `--public-url` names one — only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
+4 -2
View File
@@ -35,7 +35,7 @@ dsh rescue
| Profile | 参数 |
|---|---|
| `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` |
| `web` | `--host`、`--port`、`--public-url`、可重复的 `--trusted-host`、`--no-open` |
| `headless` | 任务文本,作为位置参数 |
| `sdk` | 无选项;stdio 携带 JSON-RPC 协议 |
| `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 |
@@ -106,6 +106,8 @@ dsh --profile tui
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
`--public-url <url>` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的 loopback URL。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
```sh
dsh web
dsh web --no-open
@@ -114,7 +116,7 @@ dsh web --dump-config
dsh web --help
```
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL(配置了 `--public-url` 时即为公告根)。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
+1
View File
@@ -372,6 +372,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(web.stderr).toBe('')
expect(web.stdout).toContain('Usage: dsh --profile web')
expect(web.stdout).toContain('--port <port>')
expect(web.stdout).toContain('--public-url <url>')
expect(web.stdout).not.toContain('dsh web: http://')
const wildcardHost = await runBuiltBin(['web', '--host', '0.0.0.0'], {
@@ -0,0 +1,221 @@
/** Built Web-profile acceptance for the advertised public root and browser authentication behind a prefix-stripping proxy. */
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { request as httpRequest, type IncomingMessage } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import { Readable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { startPrefixProxy } from '../../../../../../apps/web/tests/prefix-proxy.ts'
import { PROCESS_SHUTDOWN_TIMEOUT_MS } from '../../../../src/process-shutdown.ts'
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
const builtArtifactsExist = existsSync(dshBin)
/** Browser-facing mount the reference proxy serves and strips. */
const MOUNT = '/tools/dsh/'
/** One live Web child, as `execa` types it. */
type WebChild = ReturnType<typeof spawnWeb>
/** Settled result of a stopped child, carrying the stderr a case asserts on. */
type StoppedWeb = Awaited<WebChild>
/** Start one keyless Web process with an outer lifetime deadline. */
function spawnWeb(root: string, patch: string, interrupt: string, flags: string[]) {
return execa(process.execPath, [
dshBin,
'--profile', 'web',
'--patch', patch,
'--no-open',
'--port', '0',
...flags,
], {
cwd: root,
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-public-url-no-call',
DSH_AGENTS_HOME: join(root, '.agents'),
DSH_HOME: join(root, 'home'),
DSH_TELEMETRY_DISABLED: '1',
NODE_NO_WARNINGS: '1',
WEB_INTERRUPT_FILE: interrupt,
},
input: '',
reject: false,
timeout: 90_000,
killSignal: 'SIGKILL',
})
}
/** One booted Web process: its child, bound port, printed startup URL, and bounded stop. */
interface RunningWeb {
child: WebChild
port: number
startup: URL
/** Stop the child within the shutdown deadline; every caller shares the one settlement. */
stop(): Promise<StoppedWeb>
}
/**
* Boot the built CLI and wait for its `dsh web:` line and bound listener port.
* The marker plugin also polls for the interrupt file and emits `SIGTERM` on
* the child's own process, because Windows has no deliverable SIGTERM.
*/
async function bootWeb(root: string, flags: string[]): Promise<RunningWeb> {
const marker = join(root, 'listen-port.mjs')
const interrupt = join(root, 'interrupt')
writeFileSync(marker, [
"import { existsSync } from 'node:fs'",
"export const name = 'public-url-listen-port'",
"export const inject = ['webServer']",
'export function apply(ctx) {',
" console.log('public-url-listen-port: ' + ctx.webServer.port)",
' const heartbeat = setInterval(() => {',
' if (!existsSync(process.env.WEB_INTERRUPT_FILE)) return',
' clearInterval(heartbeat)',
" process.emit('SIGTERM')",
' }, 20)',
' ctx.effect(() => () => { clearInterval(heartbeat) })',
'}',
'',
].join('\n'))
const patch = join(root, 'extra.yml')
writeFileSync(patch, `- insert:\n - name: ${JSON.stringify(pathToFileURL(marker).href)}\n`)
const child = spawnWeb(root, patch, interrupt, flags)
const lines = createInterface({ input: child.stdout })
const ready = Promise.withResolvers<RunningWeb>()
let stopping: Promise<StoppedWeb> | undefined
let port: number | undefined
let startup: URL | undefined
const announced = (): void => {
if (port === undefined || startup === undefined) return
ready.resolve({ child, port, startup, stop: () => stopping ??= stop(child, interrupt) })
}
lines.on('line', (line) => {
if (line.startsWith('public-url-listen-port: ')) port = Number(line.slice('public-url-listen-port: '.length))
if (line.startsWith('dsh web: http')) startup = new URL(line.slice('dsh web: '.length))
announced()
})
void child.then((result) => {
ready.reject(new Error(`Web exited before readiness (code ${String(result.exitCode)})\n${result.stderr}`))
})
let deadline: NodeJS.Timeout | undefined
const expired = new Promise<never>((_resolve, reject) => {
deadline = setTimeout(() => { reject(new Error('Web profile did not print its URL line and bound port')) }, 60_000)
})
try {
return await Promise.race([ready.promise, expired])
} catch (error) {
child.kill('SIGKILL')
await child
throw error
} finally {
clearTimeout(deadline)
lines.close()
}
}
/** Boot one Web process in a private root, run `scenario`, then always stop it and remove the root. */
async function withWeb(flags: string[], scenario: (web: RunningWeb) => Promise<void>): Promise<void> {
const root = mkdtempSync(join(tmpdir(), 'dsh-web-public-url-'))
let web: RunningWeb | undefined
try {
web = await bootWeb(root, flags)
await scenario(web)
} finally {
try {
if (web !== undefined) await web.stop()
} finally {
rmSync(root, { recursive: true, force: true })
}
}
}
/** Ask the child to shut down through its interrupt marker within a bounded grace period, then force-kill it. */
async function stop(child: WebChild, interrupt: string) {
writeFileSync(interrupt, '')
const deadline = setTimeout(() => { child.kill('SIGKILL') }, PROCESS_SHUTDOWN_TIMEOUT_MS * 2)
try {
return await child
} finally {
clearTimeout(deadline)
}
}
/** Assert the child reached its own clean exit rather than the fixture's deadline. */
function expectCleanExit(stopped: StoppedWeb): void {
expect(stopped.timedOut, stopped.stderr).toBe(false)
expect(stopped.signal, stopped.stderr).toBeUndefined()
expect(stopped.exitCode, stopped.stderr).toBe(0)
}
/**
* One HTTP request that connects to an explicit transport address while
* presenting an explicit browser-facing authority, as a proxy would.
*/
async function request(
dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string,
): Promise<Response> {
const { promise, resolve, reject } = Promise.withResolvers<IncomingMessage>()
const req = httpRequest({
host: dial, port, path, method,
headers: { host, 'content-type': 'application/json', ...cookie === undefined ? {} : { cookie } },
}, resolve)
req.once('error', reject)
req.setTimeout(10_000, () => { req.destroy(new Error('HTTP request timed out')) })
req.end(method === 'POST'
? JSON.stringify({ type: 'client-request', rpcId: 'public-url', method: 'session/create', payload: { args: { request: {} } } })
: undefined)
const response = await promise
const headers = new Headers()
for (let i = 0; i < response.rawHeaders.length; i += 2) {
headers.append(response.rawHeaders[i]!, response.rawHeaders[i + 1]!)
}
return new Response(Readable.toWeb(response) as ReadableStream<Uint8Array>, { status: response.statusCode!, headers })
}
/** Establish the browser session needed by the protected API, keeping the minted cookie's own attributes. */
async function exchange(
dial: string, port: number, path: string, host: string,
): Promise<{ cookie: string; setCookie: string }> {
const response = await request(dial, port, path, host, 'GET')
const setCookie = response.headers.get('set-cookie')
await response.arrayBuffer()
if (setCookie === null) throw new Error(`entry returned ${String(response.status)} without a browser cookie`)
return { cookie: setCookie.split(';', 1)[0]!, setCookie }
}
describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root', () => {
it('serves authenticated requests at the advertised mount and at loopback', async () => {
await withWeb(['--public-url', `http://gateway.example${MOUNT.slice(0, -1)}`, '--trusted-host', 'gateway.example'], async (web) => {
expect(web.startup.origin).toBe('http://gateway.example')
expect(web.startup.pathname).toBe(MOUNT)
expect(web.startup.searchParams.get('token')).not.toBeNull()
const proxy = await startPrefixProxy({ prefix: MOUNT })
try {
proxy.setTarget(web.port)
const entries = [
{ dial: '127.0.0.1', port: proxy.port, prefix: MOUNT, host: web.startup.host },
{ dial: '127.0.0.1', port: web.port, prefix: '/', host: `127.0.0.1:${String(web.port)}` },
]
for (const entry of entries) {
const { cookie } = await exchange(entry.dial, entry.port, `${entry.prefix}${web.startup.search}`, entry.host)
const response = await request(entry.dial, entry.port, `${entry.prefix}api/session/create`, entry.host, 'POST', cookie)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ result: { ok: true } })
}
const foreign = await request('127.0.0.1', proxy.port, `${MOUNT}api/session/create`, 'evil.example', 'POST')
expect(foreign.status).toBe(403)
await foreign.arrayBuffer()
} finally {
await proxy.close()
}
expectCleanExit(await web.stop())
})
})
})
@@ -47,7 +47,7 @@ it('persists Coding Tools and limits the built-in PTC and Minimal choices in ses
await expect.poll(visiblePresets).toEqual(['standard', 'cordis', 'custom'])
await settings.getByRole('button', { name: 'New task default: Standard mode', exact: true }).waitFor()
await expect.poll(async () => (await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
expect(await settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true)
await expect.poll(() => settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true)
await settings.getByRole('button', { name: 'Close', exact: true }).last().click()
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await expect.poll(livePresets).toEqual(['standard'])
+9 -1
View File
@@ -4119,7 +4119,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
- `source`: [`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts)
- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4135,6 +4135,14 @@ export interface Config {
* orientation text would be false.
*/
surfaceContext: boolean
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
* `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
+9 -1
View File
@@ -4121,7 +4121,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
- `source`: [`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts)
- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4137,6 +4137,14 @@ export interface Config {
* orientation text would be false.
*/
surfaceContext: boolean
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
* `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
+2 -2
View File
@@ -15,5 +15,5 @@
en: e1cb5a05bf6e109d
zh: 8b181b0fecbc0517
/use-the-web-ui/continue:
en: 0e74e287e009b401
zh: 932f5475154c202c
en: 6b244134309ca89c
zh: 971fa14939d88c6b
+1
View File
@@ -26,5 +26,6 @@ The agent can read and edit workspace files, run commands, delegate work, and ma
- [Configure models](./providers.md)
- [Use the Python SDK](./python-sdk.md)
- [Publish the Web UI behind a reverse proxy](./public-deployments.md)
- [Use other CLI modes](../../../apps/cli/README.md)
- [Develop a plugin](../develop/basic/index.md)
+1
View File
@@ -26,5 +26,6 @@ Agent(智能体)可以读取和编辑工作区文件、运行命令、委派
- [配置模型](./providers.zh.md)
- [使用 Python SDK](./python-sdk.zh.md)
- [在反向代理之后发布 Web UI](./public-deployments.zh.md)
- [使用其他 CLI 模式](../../../apps/cli/README.zh.md)
- [开发插件](../develop/basic/index.zh.md)
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for public-deployments.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/public-deployments.md
/publish-the-web-ui-behind-a-reverse-proxy:
en: 15b92e92d2526f37
zh: b58079cf6eee78cc
/publish-the-web-ui-behind-a-reverse-proxy/what-public-url-advertises:
en: 576292b93ccf299a
zh: ded5adc55396869c
/publish-the-web-ui-behind-a-reverse-proxy/what-the-proxy-must-do:
en: 8dd9d910a93fe54d
zh: 1e9e21d9949c8ab2
/publish-the-web-ui-behind-a-reverse-proxy/trust-the-authority-browsers-use:
en: 907b5f90c9860733
zh: dcbffd3f00d6c64f
/publish-the-web-ui-behind-a-reverse-proxy/secure-the-external-leg:
en: a833880da5c8bbf4
zh: 6f9bd565b7266241
+33
View File
@@ -0,0 +1,33 @@
# Publish the Web UI behind a reverse proxy
English | [中文](public-deployments.zh.md)
`dsh --profile web` serves the GUI over plain HTTP on a loopback port, so a browser on another machine cannot reach it, and the process knows nothing about the address you do use. A reverse proxy in front of it owns that external leg — the public host name, TLS, and the path prefix it strips before forwarding to the listener — and `--public-url` tells DSH which address browsers use:
```sh
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
```
## What `--public-url` advertises
`--public-url` accepts an `http://` or `https://` root with an optional mount prefix and normalizes it to end in `/`. It supplies the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. The webserver keeps serving origin-root routes and never learns the mount. The `publicUrl` config field publishes the same advertisement.
## What the proxy must do
- **Preserve the browser-facing `Host`.** The fence compares the received `Host` against the accepted authorities, so the proxy forwards it unchanged instead of rewriting it to the listener's address.
- **Strip the mount prefix.** The listener answers origin-root routes, so a request for `/ui/api/...` must arrive as `/api/...`.
- **Forward upgrades.** Every request and WebSocket upgrade the page opens must reach the listener with its `Upgrade` and `Connection` headers intact; TLS terminates on the proxy's external leg when it serves HTTPS.
- **Rewrite cookie scope.** The backend always issues host-only `Path=/` cookies without `Secure`; the proxy rewrites `Path` to the mount (`/ui/`) and adds `Secure` on its HTTPS leg.
- **Redirect the bare mount.** `/ui/` is the only entry: it alone exchanges the launch token for the session cookie, and it or `/ui/index.html` serves the document to a browser that already holds that cookie, because the served document resolves URLs against its own directory. A request to `/ui` must arrive as `/ui/`, and the stripped backend cannot reconstruct that external path.
## Trust the authority browsers use
The fence accepts loopback plus every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it.
Neither the advertised URL nor the fence protects the listening port itself, so restrict the port to the trusted proxy or network.
## Secure the external leg
Terminate TLS at the proxy. An `http://` advertised root sends the launch token and the session cookie unencrypted, and an `https://` root encrypts only the browser-to-proxy leg. The printed URL carries a process credential, so share it only with intended users.
The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) owns the `--public-url` and `--trusted-host` command-line contract and the `publicUrl` and `trustedHosts` fields.
+33
View File
@@ -0,0 +1,33 @@
# 在反向代理之后发布 Web UI
[English](public-deployments.md) | 中文
`dsh --profile web` 在 loopback 端口上以明文 HTTP 提供 GUI,因此其他机器上的浏览器无法访问它,进程也无从得知你实际使用的地址。它前面的反向代理拥有这条外部链路——公开主机名、TLS,以及转发到监听器之前剥离的路径前缀——`--public-url` 则告诉 DSH 浏览器使用的是哪个地址:
```sh
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
```
## `--public-url` 公告什么
`--public-url` 接受可带挂载前缀的 `http://` 或 `https://` 根,并把它归一化为以 `/` 结尾。它提供打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。webserver 继续提供 origin-root 路由,且从不了解挂载。`publicUrl` 配置字段发布同样的公告。
## 代理必须做什么
- **保留浏览器可见的 `Host`。** 栅栏把收到的 `Host` 与被接受的 authority 比对,因此代理应原样转发,而不要改写成监听器的地址。
- **剥离挂载前缀。** 监听器应答的是 origin-root 路由,因此对 `/ui/api/...` 的请求必须以 `/api/...` 抵达。
- **转发升级请求。** 页面发出的每个请求与 WebSocket 升级都必须连同其 `Upgrade` 与 `Connection` 头抵达监听器;代理对外提供 HTTPS 时,TLS 在其外部链路上终止。
- **改写 cookie 作用域。** 后端始终签发不带 `Secure` 的 host-only `Path=/` cookie;代理把 `Path` 改写为挂载(`/ui/`),并仅在其 HTTPS 链路上添加 `Secure`。
- **重定向裸挂载。** `/ui/` 是唯一入口:只有它把启动 token 换成会话 cookie;已持有该 cookie 的浏览器可由它或 `/ui/index.html` 取得文档,因为所提供的文档以自身目录解析 URL。对 `/ui` 的请求必须以 `/ui/` 抵达,而被剥离路径的后端无法重建该外部路径。
## 信任浏览器使用的 authority
栅栏接受 loopback,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。
无论是公告 URL 还是栅栏都不保护监听端口本身,因此请把端口限制在可信代理或网络内。
## 保护外部链路
在代理处终止 TLS。`http://` 公告根会以明文发送启动 token 与会话 cookie,而 `https://` 根只加密浏览器到代理这一段。打印的 URL 携带进程凭据,只应与预期用户分享。
[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)负责 `--public-url` 与 `--trusted-host` 的命令行约定,以及 `publicUrl` 与 `trustedHosts` 字段。
+24 -21
View File
@@ -9,8 +9,8 @@
en: a85684740ecd278a
zh: 7c54099b9edccf01
/deepseek-ai-dsh-web-app/summary:
en: 0ebb6c3ca8c0a84e
zh: 4d85e02c769ddae0
en: fd97ee0c14fb5378
zh: 0e1ccba47bc408d9
/deepseek-ai-dsh-web-app/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -21,23 +21,26 @@
en: b7ab01cfe9fed40f
zh: 23703ab7727c3ab5
/deepseek-ai-dsh-web-app/use-this-package/configuration:
en: 04ae40fa453d2cb5
zh: 51df66e4100f5660
/deepseek-ai-dsh-web-app/use-this-package/lan-access-and-trusted-hosts:
en: 2522c806db384ddc
zh: 7e96f11194af9f9f
en: a9ee366c5af68a6c
zh: 2711d7a3926e1101
/deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments:
en: a79add86cb73d966
zh: 78b58259f98ed063
/deepseek-ai-dsh-web-app/use-this-package/running-over-ssh:
en: 1fe96dde34f21b23
zh: 5689c4f4186805c1
en: 45db677dcc91c5c9
zh: 75b765f7f1e15035
/deepseek-ai-dsh-web-app/use-this-package/per-session-agent-setup:
en: ccd6fc264b51e44b
zh: 3265c68477a89ec9
/deepseek-ai-dsh-web-app/understand-the-implementation:
en: 4eb00d95238e3b92
zh: 0d6ca9e1a3e7db84
en: fb819d10930089a8
zh: f2dee0ff170a4d41
/deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics:
en: f0078127aa8dd273
zh: c954c137599b486f
en: 954a424ea7182c8f
zh: 9339bc21dba44d40
/deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url:
en: ca977d2b2bcf72c5
zh: 2b81b377c133c1a8
/deepseek-ai-dsh-web-app/understand-the-implementation/readiness:
en: 973128fd346869df
zh: 54e6792f9f82a192
@@ -45,8 +48,8 @@
en: b5ed8ff0056ca9da
zh: fdc1fa4f14cb00b3
/deepseek-ai-dsh-web-app/understand-the-implementation/source-map:
en: 0d08e95f0a5543cb
zh: 5a3e8e4102678cb7
en: 9d657e8b57d665f6
zh: c03fad0f89d2a90d
/deepseek-ai-dsh-web-app/understand-the-implementation/invariant-ownership:
en: 34767bb4c0471622
zh: e6d76e7c7a0b9ce8
@@ -60,17 +63,17 @@
en: e6febc53efe0f51d
zh: 9177816a8678d65d
/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/what-the-model-sees:
en: 359b949dd483477f
zh: 1476006614a82282
en: 583592075e04687d
zh: 9defddfd77bab0f7
/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/token-effect:
en: 908fc84d6849a830
zh: 56fe5e418bab60ba
/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/kv-cache-effect:
en: da0f568d6d661c5e
zh: 606919b7d37c95bf
en: 1d7d87f54b20b563
zh: d150a51615d278e8
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work:
en: fead2f7bcd17e1ab
zh: 80949c203c0a0732
en: d6de1d07249ef3b0
zh: 49d01329256e2eb3
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work/dev-note:
en: 3f81b4f3beb74a0d
zh: 2e377ca40822d093
+27 -14
View File
@@ -13,7 +13,7 @@ Desktop analytics schedules partial batches every 30 seconds, with a 15-second e
## Summary
Run `dsh --profile web` to open an interactive browser GUI with chat, model and settings management, and session history. It uses the same model access, tools, and safety defaults as other dsh surfaces. Startup prints an authenticated URL and normally opens it in the default browser; SSH sessions and `--no-open` leave the URL for manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Choose this package for interactive browser work; use `dsh-headless` for one-shot command-line tasks.
Run `dsh --profile web` for browser chat, model and settings management, and session history, using the same model access, tools, and safety defaults as other dsh surfaces. Startup prints a tokenized URL and normally opens the default browser; SSH sessions and `--no-open` require manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Remote access supports an advertised public HTTP(S) URL behind a prefix-stripping proxy. For one-shot command-line tasks, use `dsh-headless`.
## Table of Contents
@@ -46,24 +46,30 @@ Saved model selections override the composition default. The settings card accep
### Configuration
Most users never set these; the command-line flags feed the four settings below — `--host`, `--port`, and `--trusted-host` come from the invocation, and `--no-open` turns the browser handoff off for that invocation:
`--host` and `--port` configure the listener; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments):
| Field | Default | Meaning |
|---|---|---|
| `openBrowser` | `true` | Open the default browser after startup; SSH launches suppress it |
| `printUrl` | `true` | Print the `dsh web:` URL line at startup |
| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands |
| `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the listener's loopback URL |
| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them.
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) lists this runtime plugin's accepted fields and their JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them.
### LAN access and trusted hosts
<a id="public-deployments"></a>
### Listening, trust, and public deployments
By default the GUI accepts connections from this machine only. A deployment that binds all network interfaces also allows browsers from the LAN, and the printed URL then includes a LAN address; `--trusted-host` adds extra hosts in either case. Host and Origin checks control reachability, while the token exchange authenticates every Host API method and WebSocket stream. The LAN addresses are sampled once at startup, so a network change later is not picked up — restart the GUI to re-advertise.
By default the GUI listens on loopback and accepts connections from this machine only; repeatable `--trusted-host` adds the authorities its Host/Origin fence accepts, so a remote browser reaches it behind a prefix-stripping proxy or through a port-forwarding client that presents a trusted hostname.
`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide.
The printed URL contains a process credential; share it only with intended users, and `printUrl: false` suppresses the line with or without `--public-url`.
### Running over SSH
When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Open the forwarded URL on your machine yourself; the printed URL names the remote host's loopback endpoint.
When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's loopback endpoint, which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen.
### Per-session agent setup
@@ -77,11 +83,15 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
<details>
<summary>Implementation internals — click to expand</summary>
The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/<id>.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/<id>.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
### Patch semantics
A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix and suffix templates, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch.
A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix and suffix templates, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver` and `web-runtime` rows inject the `webStartup` provider and read their invocation values directly; the `connection` row instead reads the bind-dependent `webRuntime` values the web-runtime row publishes, which are that provider's authorities plus the LAN literals of an all-interfaces bind. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch.
### Advertised application URL
Startup display and browser handoff receive the advertised root with its launch token; the web-surface prompt and `DSH_WEB_URL` receive it clean. The root itself is defined under [Listening, trust, and public deployments](#public-deployments).
### Readiness
@@ -95,13 +105,15 @@ The URL line and browser handoff are readiness signals: supervisors RPC as soon
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff |
| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--trusted-host`, `--no-open`, `--help` |
| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff |
| [`src/public-url.ts`](src/public-url.ts) | Advertised-root validation and trailing-slash normalization; a leaf module for local imports, not package API |
| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`, `--help` |
| [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, preset registry |
| [`presets/`](presets) | One `@deepseek-ai/dsh-agent-preset` declaration per shipped preset (`standard`, `ptc`, `minimal`, `cordis`), each its own patch file |
| — | No runtime invariant companion is published; every contribution (frontend-static child plugin, prompt section, bashEnv registration) is registry-disposed with the fiber, and each owning registry's package carries that relation's invariant; the package holds no mutable state of its own to audit. |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist resolution, fallback seat, prompt sections, readiness |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist resolution, fallback seat, prompt sections, readiness, advertised URL publication |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree |
| [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | Advertised-root parsing and normalization |
| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN-trust sampling |
| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | Default-browser handoff after the page is reachable |
@@ -133,7 +145,7 @@ Read these pages when you want to go deeper into the shared core, the browser re
#### What the model sees
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (first-party order 10100, after reusable instructions) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered.
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (first-party order 10100, after reusable instructions) orients the model to the GUI: the advertised application URL (defined under Listening, trust, and public deployments above), the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered.
#### Token effect
@@ -141,7 +153,7 @@ One source line and one prompt paragraph per session plus two managed-environmen
#### KV Cache effect
Source and Web sections follow first-party reusable instructions. Different checkout paths or local ports leave that preceding prefix unchanged when tools and configuration match; provider cache reuse is not guaranteed.
Source and Web sections follow first-party reusable instructions. Different checkout paths or application URLs leave that preceding prefix unchanged when tools and configuration match; provider cache reuse is not guaranteed.
## Known Limitations and Deferred Work
@@ -151,9 +163,10 @@ Source and Web sections follow first-party reusable instructions. Different chec
These limits tell you what to expect in unusual setups — a source checkout, SSH sessions, or strict networks. They are current package constraints, not a general browser comparison or a task backlog.
- **The frontend must be built** — a source checkout needs `pnpm run build` first; startup stops with a build hint when the dist is missing, and there is no source-serving fallback.
- **The listener has no TLS** — protect the external leg with a TLS-terminating proxy; an HTTP advertised root sends credentials without encryption.
- **LAN addresses are sampled once at startup** — interface changes after boot are not re-advertised; the printed LAN URL always matches what was sampled.
- **Only the handoff start is observable** — the GUI reports that the browser was asked to open, not that it actually opened; a later browser exit is never reported, and the printed URL is your manual fallback.
- **SSH sessions keep the URL but skip the browser handoff** — the printed URL names the remote host's loopback endpoint; the SSH client or editor must expose and open the local forwarded address.
- **SSH sessions keep the URL but skip the browser handoff** — without an advertised root the printed URL names the remote host's loopback endpoint; the SSH client or editor must expose and open the local forwarded address.
- **`BROWSER` overrides only come from the environment** — a discovered `.env` cannot set `BROWSER`; only an inherited value can choose the executable for the automatic handoff.
- **Binding all network interfaces is not supported** — `--host 0.0.0.0` is rejected at startup for safety; use the default loopback host.
+27 -14
View File
@@ -13,7 +13,7 @@ kind: "package-bundle"
## 概述
运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印带认证信息的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。
运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。跨机访问支持在剥离前缀的代理之后公告一个公开的 HTTP(S) URL。一次性的命令行任务应使用 `dsh-headless`。
## 目录
@@ -46,24 +46,30 @@ dsh --profile web --no-open --port 8080
### 配置
大多数用户不需要设置这些;命令行 flag 会提供给下面四个设置——`--host`、`--port` 与 `--trusted-host` 来自本次调用,`--no-open` 仅对本次调用关闭浏览器交接:
`--host` 与 `--port` 配置监听器;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。两者都在[监听、信任与公开部署](#public-deployments)中说明:
| 字段 | 默认值 | 含义 |
|---|---|---|
| `openBrowser` | `true` | 启动后用默认浏览器打开;SSH 启动会抑制它 |
| `printUrl` | `true` | 启动时打印 `dsh web:` URL 行 |
| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 |
| `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告监听器的 loopback URL |
| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)列出了本运行时插件接受的字段及其 JSDoc。随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。
### LAN 访问与可信主机
<a id="public-deployments"></a>
### 监听、信任与公开部署
默认情况下 GUI 只接受本机的连接。绑定所有网络接口的部署也会允许 LAN 内的浏览器访问,此时打印的 URL 会附带一个 LAN 地址;`--trusted-host` 在两种情况下都能添加额外主机。Host 与 Origin 检查控制可达性,token 交换则认证每个 Host API 方法与 WebSocket 流。LAN 地址只在启动时采样一次,因此之后的网络变化不会被感知——重启 GUI 以重新公告。
默认情况下 GUI 只在 loopback 上监听,只接受本机的连接;可重复的 `--trusted-host` 会添加其 Host/Origin 栅栏接受的 authority,因此远端浏览器要么经由剥离前缀的代理访问,要么通过以可信主机名呈现的端口转发客户端访问。
`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。
打印的 URL 包含进程凭据,只应与预期用户分享;`printUrl: false` 无论是否配置 `--public-url` 都会抑制该行。
### 通过 SSH 运行
通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。请在自己的机器上打开转发后的 URL;打印出的 URL 指向远端宿主机 loopback 端点。
通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机 loopback 端点,你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。
### 按会话的 agent 设置
@@ -77,11 +83,15 @@ dsh --profile web --no-open --port 8080
<details>
<summary>实现细节——点击展开</summary>
此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/<id>.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/<id>.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
### patch 语义
patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀与后缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。
patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀与后缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver` 与 `web-runtime` 行注入 `webStartup` 提供方并直接读取本次调用的取值;`connection` 行则改为读取 web-runtime 行发布的、与绑定相关的 `webRuntime` 值,即该提供方的 authority 加上全接口绑定的 LAN 字面量。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。
### 公告应用 URL
启动显示与浏览器交接接收附带启动 token 的公告根;web 表层提示词与 `DSH_WEB_URL` 接收不含凭据的形式。根本身由[监听、信任与公开部署](#public-deployments)定义。
### 就绪宣告
@@ -95,13 +105,15 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、LAN 信任采样、提示词段落、bash 变量、URL 行、浏览器交接 |
| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--trusted-host`、`--no-open`、`--help` |
| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、LAN 信任采样、提示词段落、bash 变量、URL 行、浏览器交接 |
| [`src/public-url.ts`](src/public-url.ts) | 公告根的校验与尾斜杠归一化;供本地导入的叶子模块,不属于包 API |
| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--public-url`、`--trusted-host`、`--no-open`、`--help` |
| [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 注册表 |
| [`presets/`](presets) | 每个随发行版交付的 preset(`standard`、`ptc`、`minimal`、`cordis`)各一条 `@deepseek-ai/dsh-agent-preset` 声明,各自一个补丁文件 |
| — | 不发布运行时不变式伴生入口;每项贡献(frontend-static 子插件、提示词段落、bashEnv 注册)都会随 fiber 由注册表释放,且每个所属注册表的包负责该关系的不变式;本包不持有需要审计的可变状态。 |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist 解析、回退席位、提示词段落、就绪宣告 |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist 解析、回退席位、提示词段落、就绪宣告、公告 URL 发布 |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 |
| [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | 公告根的解析与归一化 |
| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN 信任采样 |
| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | 页面可达后的默认浏览器交接 |
@@ -133,7 +145,7 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
#### 模型看到什么
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(first-party 顺序 10100,位于可复用指令之后)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(first-party 顺序 10100,位于可复用指令之后)则向模型说明 GUI:公告应用 URL(定义见上文「监听、信任与公开部署」)、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。
#### Token 影响
@@ -141,7 +153,7 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
#### KV Cache 影响
源码与 Web 段落位于第一方可复用指令之后。工具与配置一致时,不同 checkout 路径或本地端口不会改变前置前缀;不保证提供方复用缓存。
源码与 Web 段落位于第一方可复用指令之后。工具与配置一致时,不同 checkout 路径或应用 URL 不会改变前置前缀;不保证提供方复用缓存。
## 已知限制与延期工作
@@ -151,9 +163,10 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
这些限制告诉你在不常见的环境下会遇到什么——源码 checkout、SSH 会话或严格网络。它们是当前包约束,不是通用的浏览器对比或任务积压。
- **前端必须已构建**——源码 checkout 需要先运行 `pnpm run build`;dist 缺失时启动会以构建提示停止,且没有从源码直接服务的回退路径。
- **监听器不提供 TLS**——请用终止 TLS 的代理保护外部链路;HTTP 公告根会以明文发送凭据。
- **LAN 地址只在启动时采样一次**——启动后的网卡变化不会重新公告;打印的 LAN URL 始终与采样结果一致。
- **只能观察到交接的启动**——GUI 只报告浏览器被请求打开,而不是它确实打开了;之后的浏览器退出永远不会上报,打印的 URL 是你的手动回退路径。
- **SSH 会话保留 URL 但跳过浏览器交接**——打印的 URL 指向远端宿主机 loopback 端点;SSH 客户端或编辑器必须暴露并打开本地转发地址。
- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机 loopback 端点;SSH 客户端或编辑器必须暴露并打开本地转发地址。
- **`BROWSER` 覆盖只能来自环境**——被发现的 `.env` 不能设置 `BROWSER`;只有继承值能为自动交接选择可执行文件。
- **不支持绑定所有网络接口**——出于安全考虑,`--host 0.0.0.0` 会在启动时被拒绝;请使用默认 loopback 主机。
+16 -10
View File
@@ -9,7 +9,7 @@
# ordinary Cordis service. Rows configured from flags inject that service, so
# Loader resolves their expressions only after it exists. The web runtime then
# provides bind-dependent `webRuntime` values to the trust fence.
# `dsh --profile web --help` provides neither service, so no server binds.
# `dsh --profile web --help` provides no `webStartup`, so no server binds.
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -171,8 +171,10 @@
# ── layer 2: transport/service ──────────────────────────────────────────────
# Plain route-registration carrier; host and port come from the app's
# webStartup provider, with these deployment fallbacks. The dist is served by
# the web-runtime row below through the fallback seat.
# webStartup provider, with these deployment fallbacks. The dist is served
# by the web-runtime row below through the fallback seat. Routing and
# browser-facing asset paths do not depend on the advertised URL, which the
# web-runtime row owns.
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
@@ -187,18 +189,20 @@
# assembly fact of dsh-web-app, never user config), mounts the
# frontend-static fallback owner, registers the web-surface prompt
# section and the bash runtime variable, prints the URL line, and opens the
# canonical local URL after the full Loader tree settles and the required-
# entry audit passes. The
# webStartup provider supplies invocation-only values; after the server binds, this row samples
# LAN trust once and provides `webRuntime`. A complete agent-preset persona
# suppresses the prompt section for that agent while retaining the host-owned
# shell variable.
# canonical URL after the full Loader tree settles and the required-
# entry audit passes. Invocation-only values come from the webStartup
# provider; the advertised application URL is this row's `publicUrl` (the
# loopback URL when unset). After the server binds, this row samples LAN
# trust once and provides `webRuntime`. A complete agent-preset persona
# suppresses the prompt section for that agent while retaining the
# host-owned shell variable.
- id: web-runtime
name: '@deepseek-ai/dsh-web-app'
inject: [webStartup]
config:
openBrowser: !!js ctx.webStartup.openBrowser
printUrl: true
publicUrl: !!js ctx.webStartup.publicUrl
surfaceContext: true
trustedHosts: !!js ctx.webStartup.trustedHosts
@@ -219,7 +223,9 @@
name: '@deepseek-ai/dsh-client-modules'
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client.
# webserver under /api; browser half is the fetch/SSE client. Every API
# request and upgrade passes the loopback plus trustedHosts policy; an
# advertised public URL contributes nothing to it.
- id: connection
name: '@deepseek-ai/dsh-client-connection'
inject: [webRuntime]
+1
View File
@@ -35,6 +35,7 @@
"presets/ptc.patch.yml",
"presets/minimal.patch.yml",
"presets/cordis.patch.yml",
"lib/public-url-*.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
+27 -8
View File
@@ -5,9 +5,10 @@
* the built frontend dist (workspace knowledge of this bundle, never user
* config), mounts the `frontend-static` fallback owner over it, registers the
* harness-source and web-surface prompt sections, the bash-visible web runtime
* variable, the process-token URL line, and the default-browser handoff. The
* model and shell retain the clean URL. App command-line values arrive through
* the `webStartup` service expressions in the bundle patch.
* variable, the process-token URL line, and the default-browser handoff. An
* advertised `publicUrl` replaces the published root — the loopback URL
* otherwise. App command-line values arrive through the `webStartup` service
* expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app
*/
@@ -26,6 +27,7 @@ import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-shell-env'
import { parsePublicUrl } from './public-url.ts'
/** Stable Cordis plugin name. */
export const name = 'web-app'
@@ -53,6 +55,14 @@ export interface Config {
* orientation text would be false.
*/
surfaceContext: boolean
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
* `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
@@ -61,6 +71,7 @@ export const Config: z<Config> = z.object({
openBrowser: z.boolean().default(true),
printUrl: z.boolean().default(true),
surfaceContext: z.boolean().default(true),
publicUrl: z.transform(z.string(), value => parsePublicUrl(value).href),
trustedHosts: z.array(String).default([]),
})
@@ -72,7 +83,7 @@ export interface WebRuntimeValues {
trustedHosts: string[]
}
/** Environment variable naming the canonical local URL of this Web GUI. */
/** Environment variable naming the advertised URL of this Web GUI. */
const DSH_WEB_URL = 'DSH_WEB_URL' as const
// Display-only mirror of the webserver schema's loopback host: the address the
@@ -152,6 +163,11 @@ function localWebUrl(ctx: Context): string {
return `http://${LOOPBACK_HOST}:${String(port)}`
}
function appRootUrl(ctx: Context, publicUrl: string | undefined): string {
if (publicUrl !== undefined) return publicUrl
return localWebUrl(ctx)
}
/**
* Dist location is workspace knowledge of this bundle: anchored on the
* frontend package manifest, not configured. Existence is a request-time
@@ -224,6 +240,9 @@ export const internals: {
*/
export function apply(ctx: Context, config: Config): void {
const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts)
// The schema validates a present string; an explicit YAML `null` bypasses
// the string transform and reaches here, meaning unset.
const publicUrl = config.publicUrl ?? undefined
// The loopback URL belongs to this host. Under SSH, the operator reaches it
// through a local forwarding address that this process cannot derive.
const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx))
@@ -236,16 +255,16 @@ export function apply(ctx: Context, config: Config): void {
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: promptCtx.systemPrompt.getSectionOrder('WEB_SURFACE'),
text: () => webSurfacePrompt(localWebUrl(promptCtx)),
text: () => webSurfacePrompt(appRootUrl(promptCtx, publicUrl)),
})
})
ctx.inject(['shellEnv'], (runtimeCtx) => {
runtimeCtx.shellEnv.register({
name: 'web-runtime',
variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
[DSH_WEB_URL]: { description: 'Advertised URL of the DeepSeek Harness Web GUI serving this session.' },
},
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx) }),
resolve: () => ({ [DSH_WEB_URL]: appRootUrl(runtimeCtx, publicUrl) }),
})
})
}
@@ -258,7 +277,7 @@ export function apply(ctx: Context, config: Config): void {
// hand-built tree without a Loader is already the complete tree.
const announceReady = (): void => {
if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return
const webUrl = localWebUrl(connectionCtx)
const webUrl = appRootUrl(connectionCtx, publicUrl)
const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl)
// Reuse the exact LAN snapshot provided to the /api trust fence.
const lanCandidate = runtime.lanAddresses[0]
+39
View File
@@ -0,0 +1,39 @@
/**
* Canonical public HTTP(S) root parsing shared by this bundle's `publicUrl`
* config and its CLI startup provider.
* @module @deepseek-ai/dsh-web-app/public-url
*/
/**
* Parse the advertised public HTTP(S) root of a prefix-stripping proxy
* deployment, rejecting the hazards WHATWG URL parsing would accept on its own,
* then normalize the path to end in `/`.
* @param value - advertised public HTTP(S) root.
* @param label - field name or CLI flag included in rejection messages.
* @returns the parsed URL with a trailing-slash pathname.
* @throws when the value is not a usable advertised root, naming the reason.
*/
export function parsePublicUrl(value: string, label = 'publicUrl'): URL {
if (/[\u0000-\u0020\u007f]/u.test(value)) {
throw new Error(`${label} must not contain ASCII whitespace or control characters`)
}
if (!/^https?:\/\//iu.test(value)) {
throw new Error(`${label} must be an absolute http or https URL of the form http(s)://host[/prefix]`)
}
if (value.includes('?') || value.includes('#')) {
throw new Error(`${label} must not include a query or fragment (? or #)`)
}
// Userinfo lives in the raw authority (before the first path slash); the
// empty-authority forms (`https://`, `https:///ui`, `http:///ui`) fail here too.
const [authority = ''] = value.slice(value.indexOf('://') + 3).split(/[/\\]/, 1)
if (authority === '') {
throw new Error(`${label} must name a host after http:// or https://`)
}
if (authority.includes('@')) {
throw new Error(`${label} must not include credentials (a username or password before @, including an empty user)`)
}
const url = URL.parse(value)
if (url === null) throw new Error(`${label} must be an absolute http or https URL`)
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url
}
+25 -6
View File
@@ -1,14 +1,16 @@
/**
* The web app's command-line provider: it parses the `dsh --profile web` flag
* family (`--host`, `--port`, `--trusted-host`, `--no-open`) and its `--help`
* text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
* Ordinary rows inject that service before reading it from lazy config.
* family (`--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`)
* and its `--help` text, then provides the immutable values as
* {@link WEB_STARTUP_SERVICE}. Ordinary rows inject that service before
* reading it from lazy config.
* @module @deepseek-ai/dsh-web-app/startup
*/
import { Command } from 'commander'
import type { Context } from '@deepseek-ai/cordis'
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
import { parsePublicUrl } from './public-url.ts'
/** Stable Cordis plugin name. */
export const name = 'web-startup'
@@ -27,6 +29,11 @@ export interface WebStartupValues {
host?: string
/** `--port`, absent when the invocation did not name one. */
port?: number
/**
* `--public-url`, absent when not specified: the advertised HTTP(S) root.
* See [public deployments](../README.md#public-deployments).
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities, in argument order. */
trustedHosts: string[]
}
@@ -36,6 +43,7 @@ interface WebOptions {
host?: string
open: boolean
port?: string
publicUrl?: string
trustedHost?: string[]
}
@@ -51,20 +59,23 @@ function webCommand(): Command {
.option('--host <host>', 'bind host')
.option('--no-open', 'do not open the Web UI in the default browser')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--public-url <url>', 'advertise this HTTP(S) root in the printed, opened, web-surface, and DSH_WEB_URL forms; grants no trust')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.addHelpText('after', `
Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
`)
}
/**
* Parse and provide the Web invocation as an ordinary Cordis service. The
* command's action publishes the flags this invocation named; `--host 0.0.0.0`
* or a non-numeric `--port` is a usage error, so on rejection (and on `--help`)
* nothing is provided.
* command's action publishes the flags this invocation named; `--host 0.0.0.0`,
* a non-numeric `--port`, or a malformed `--public-url` is a usage error, so on
* rejection (and on `--help`) nothing is provided.
* @param ctx - plugin context carrying the command line.
*/
export function apply(ctx: Context): void {
@@ -77,10 +88,18 @@ export function apply(ctx: Context): void {
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
if (options.publicUrl !== undefined) {
try {
parsePublicUrl(options.publicUrl, '--public-url')
} catch (error) {
program.error(`error: ${(error as Error).message}`)
}
}
ctx.provide(WEB_STARTUP_SERVICE, {
openBrowser: options.open,
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
...options.publicUrl !== undefined && { publicUrl: options.publicUrl },
trustedHosts: options.trustedHost ?? [],
} satisfies WebStartupValues)
})
@@ -0,0 +1,32 @@
/**
* Contract of the Web bundle's advertised-root parser: a canonical HTTP(S)
* root with an optional mount prefix, normalized to a trailing slash. The
* rejections pin the hazards WHATWG URL parsing accepts on its own.
*/
import { describe, expect, it } from 'vitest'
import { parsePublicUrl } from '../src/public-url.ts'
describe('parsePublicUrl', () => {
it('accepts canonical HTTP(S) roots and normalizes a missing trailing slash', () => {
expect(parsePublicUrl('https://app.example').href).toBe('https://app.example/')
expect(parsePublicUrl('https://app.example/ui').href).toBe('https://app.example/ui/')
expect(parsePublicUrl('https://app.example:8443/ui').href).toBe('https://app.example:8443/ui/')
expect(parsePublicUrl('https://[::1]:8443/ui').href).toBe('https://[::1]:8443/ui/')
expect(parsePublicUrl('HTTPS://app.example/UI').href).toBe('https://app.example/UI/')
})
it.each([
// One row per rejection branch; the whitespace check precedes the scheme check.
['relative or non-HTTP(S)', '/web/ui', 'publicUrl must be an absolute http or https URL of the form'],
['whitespace or control', 'https://app.example/ui ', 'publicUrl must not contain ASCII whitespace or control characters'],
['query or fragment', 'https://app.example/ui?x=1', 'publicUrl must not include a query or fragment'],
['host-less', 'https:///ui', 'publicUrl must name a host after http:// or https://'],
['credential-bearing', 'https://user@example.com/', 'publicUrl must not include credentials'],
['unparseable', 'https://[broken/', /^publicUrl must be an absolute http or https URL$/u],
] as const)('rejects a %s publicUrl', (_kind, value, message) => {
expect(() => parsePublicUrl(value)).toThrow(message)
})
})
@@ -63,6 +63,7 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
" host: !!js ctx.webStartup.host ?? '127.0.0.1'",
' openBrowser: !!js ctx.webStartup.openBrowser',
' port: !!js ctx.webStartup.port ?? 3080',
' publicUrl: !!js ctx.webStartup.publicUrl',
' trustedHosts: !!js ctx.webStartup.trustedHosts',
'- id: provider',
` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`,
@@ -125,6 +126,7 @@ describe('web command-line provider', () => {
const { values, observed } = await bootProvider(['--help'])
expect(observed.out).toContain('dsh --profile web')
expect(observed.out).toContain('--no-open')
expect(observed.out).toContain('--public-url')
expect(observed.out).toContain('--trusted-host')
expect(values).toBeUndefined()
expect(observed.readerConfig).toBeUndefined()
@@ -146,4 +148,34 @@ describe('web command-line provider', () => {
expect(observed.readerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('publishes --public-url as advertisement only, leaving the fence to --trusted-host', async () => {
const { values, observed } = await bootProvider([
'--public-url', 'https://web.example/ui',
'--trusted-host', 'lab.internal',
])
expect(values).toEqual({
openBrowser: true,
publicUrl: 'https://web.example/ui',
trustedHosts: ['lab.internal'],
})
expect(observed.readerConfig).toEqual({
host: '127.0.0.1',
openBrowser: true,
port: 3080,
publicUrl: 'https://web.example/ui',
trustedHosts: ['lab.internal'],
})
expect(observed.exits).toEqual([])
})
it('rejects a malformed --public-url before the consumer activates', async () => {
// The parser's own suite owns the exhaustive spellings; the provider only
// has to fail the invocation before any consumer activates.
const { values, observed } = await bootProvider(['--public-url', '/web/ui'])
expect(observed.out).toContain('error: --public-url must be an absolute http or https URL of the form http(s)://host[/prefix]')
expect(values).toBeUndefined()
expect(observed.readerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
})
+58 -2
View File
@@ -2,7 +2,9 @@
* Web runtime glue behavior: dist resolution through the bundle's own hook,
* the frontend-static child claiming the fallback seat, the web-surface
* prompt section and bash runtime variables, and readiness publication through
* the URL line and default-browser handoff.
* the URL line and default-browser handoff. The advertised URL comes from this
* plugin's `publicUrl` config (loopback when unset), never from the bound
* webserver.
*/
import { EventEmitter } from 'node:events'
@@ -88,9 +90,10 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
/** Deterministic Host Connection face for URL publication and frontend injection. */
function provideConnection(ctx: Context): void {
ctx.provide('connection', {
// Adds the process token to the given canonical root, preserving any
// public mount prefix the caller passed.
authenticatedUrl(baseUrl: string) {
const url = new URL(baseUrl)
url.pathname = '/'
url.searchParams.set('token', 'test-token')
return url.href
},
@@ -165,6 +168,48 @@ describe('web-app runtime glue', () => {
await ctx.fiber.dispose()
})
it('advertises and opens the configured public root behind a prefix-stripping proxy', async () => {
stageDist()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const { server } = fakeHttpServer()
ctx.provide('webServer', server)
provideConnection(ctx)
const contributions: BashContribution[] = []
ctx.provide('shellEnv', {
register: (contribution: BashContribution) => {
contributions.push(contribution)
return () => {}
},
} as never)
provideLoader(ctx)
const lifecycle: string[] = []
vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) })
const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) })
internals.openBrowser = openBrowser
apply(ctx, new Config({
openBrowser: true,
printUrl: true,
surfaceContext: true,
publicUrl: 'https://proxy.example:8443/web',
trustedHosts: ['proxy.example:8443'],
}))
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() })
expect(lifecycle).toEqual([
'dsh web: https://proxy.example:8443/web/?token=test-token',
'dsh web: opening the default browser; pass --no-open to disable',
'open:https://proxy.example:8443/web/?token=test-token',
])
const assembly = await ctx.systemPrompt.assemble()
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
expect(section?.text).toContain('https://proxy.example:8443/web/')
expect(section?.text).not.toContain('127.0.0.1')
const urlContribution = contributions.find(contribution => contribution.name === 'web-runtime')
expect(urlContribution?.resolve()).toEqual({ DSH_WEB_URL: 'https://proxy.example:8443/web/' })
})
it('publishes no readiness side effect when printing and browser opening are disabled', async () => {
stageDist()
const ctx = new Context()
@@ -236,6 +281,17 @@ describe('web-app runtime glue', () => {
await ctx.fiber.dispose()
})
it('advertises the loopback URL when the composition sets publicUrl to null', async () => {
stageDist()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
ctx.provide('webServer', fakeHttpServer().server)
provideConnection(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false, publicUrl: null as never, trustedHosts: [] }))
await vi.waitFor(() => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') })
})
it.each([
['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'],
['SSH_TTY', '/dev/pts/3'],
+17 -9
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context, Service } from '@deepseek-ai/cordis'
import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import type {
@@ -20,6 +20,7 @@ import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
afterEach(async () => {
vi.useRealTimers()
vi.unstubAllEnvs()
await closeMockServers()
})
@@ -414,17 +415,24 @@ describe('PiAiAdapter provider routing', () => {
})
it('stops the SDK request when the adapter idle watchdog expires', async () => {
const server = await mockServer([{ events: textEvents, delayMs: 200 }])
const server = await mockServer([{ holdOpen: true }])
const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 })
const controller = new AbortController()
onTestFinished(async () => {
controller.abort()
await ctx.fiber.dispose()
})
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] })
const pending = assemble(ctx, { model: 'deepseek-flash', messages: [], signal: controller.signal })
await server.requestReceived
expect(server.closedResponses).toBe(0)
await vi.advanceTimersByTimeAsync(20)
vi.useRealTimers()
const result = await pending
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } })
await Promise.race([
server.responseClosed,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 1_000)
}),
])
await server.responseClosed
expect(server.paths).toEqual(['/chat/completions'])
expect(server.closedResponses).toBe(1)
+19 -4
View File
@@ -7,14 +7,18 @@ export interface MockServer {
requests: unknown[]
headers: IncomingMessage['headers'][]
readonly closedResponses: number
requestReceived: Promise<void>
responseClosed: Promise<void>
}
const servers: Server[] = []
/** Close every server opened since the last call; run from each spec's afterEach. */
/** Close every listener and connection opened since the last call; run from each spec's afterEach. */
export async function closeMockServers(): Promise<void> {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
await Promise.all(servers.splice(0).map(server => new Promise<void>((resolve) => {
server.close(() => { resolve() })
server.closeAllConnections()
})))
}
/** A minimal complete text generation in pi-ai's chat-completions shape. */
@@ -31,15 +35,20 @@ export async function mockServer(script: {
events?: string[]
body?: string
delayMs?: number
/** Keep the SSE response open after its scripted events until the client disconnects. */
holdOpen?: boolean
headers?: Record<string, string>
}[]): Promise<MockServer> {
const paths: string[] = []
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
let closedResponses = 0
const requestReceived = Promise.withResolvers<undefined>()
const responseClosed = Promise.withResolvers<undefined>()
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let timer: ReturnType<typeof setTimeout> | undefined
response.on('close', () => {
clearTimeout(timer)
closedResponses += 1
responseClosed.resolve(undefined)
})
@@ -49,6 +58,7 @@ export async function mockServer(script: {
paths.push(request.url ?? '')
requests.push(body.length === 0 ? undefined : JSON.parse(body))
headers.push(request.headers)
requestReceived.resolve(undefined)
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
@@ -61,13 +71,17 @@ export async function mockServer(script: {
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.flushHeaders()
let index = 0
const writeNext = (): void => {
const event = behavior.events?.[index++]
if (event === undefined) { response.end(); return }
if (event === undefined) {
if (!behavior.holdOpen) response.end()
return
}
response.write(`data: ${event}\n\n`)
if (behavior.delayMs === undefined) writeNext()
else setTimeout(writeNext, behavior.delayMs)
else timer = setTimeout(writeNext, behavior.delayMs)
}
writeNext()
})
@@ -81,6 +95,7 @@ export async function mockServer(script: {
paths,
requests,
headers,
requestReceived: requestReceived.promise,
responseClosed: responseClosed.promise,
get closedResponses() { return closedResponses },
}
+2
View File
@@ -229,6 +229,8 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// through a hashed chunk. The committed bin.js is the link target pnpm can
// resolve at install time, before the build produces lib/bin.js.
'@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
// Startup and runtime share the advertised URL parser.
'@deepseek-ai/dsh-web-app': ['lib/public-url-*.js'],
// The headless entry and its startup row share the JSON projection code
// through a hashed tsdown chunk; both import it by relative path.
'@deepseek-ai/dsh-headless': ['lib/json-stream-*.js'],
+8
View File
@@ -138,6 +138,14 @@ const homeAndGuide = pairedPages([
section: { root: '入门', en: 'Guide' },
order: 3,
},
{
source: 'docs/user/guide/public-deployments.md',
route: 'guide/public-deployments.md',
label: { root: '公开部署', en: 'Public deployments' },
sidebar: { root: 'zh-guide', en: 'en-guide' },
section: { root: '入门', en: 'Guide' },
order: 4,
},
{
source: 'docs/user/guide/python-sdk.md',
route: 'guide/python-sdk.md',