diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 5db1a9baf8..20edc37d09 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: c90979ff3ade89c11af7fb9a73d536aa2c1daa12 -2026-08-04-configuration-source-ownership.zh.md: 31a813eddf4aefaf4bce1fd9999660abc8032c29 +2026-08-04-configuration-source-ownership.md: 2cd09ae2daca2b15657caa18ff210fa178c2999b +2026-08-04-configuration-source-ownership.zh.md: 76625cade4b1034c2327344945f80e3a4fec5bdc diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index c90979ff3a..2cd09ae2da 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, **The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. -**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. @@ -53,7 +53,7 @@ The line is that these take effect with no user action, before any turn, outside ## Consequences - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. -- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 31a813eddf..76625cade4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -43,7 +43,7 @@ inherited process environment (read-only, wins) **harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 -**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 @@ -54,7 +54,7 @@ inherited process environment (read-only, wins) ## Consequences - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 -- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- 含 `DSH_*`、`PATH`、`BROWSER` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。 diff --git a/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.i18n.yaml new file mode 100644 index 0000000000..42d81db81d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.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 .agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md +2026-08-12-open-ready-web-ui.md: 075082aff6c0b811de3c6750637cb823fe83be61 +2026-08-12-open-ready-web-ui.zh.md: de73ab0c8574ff924229b54c2aedf41899fedee8 diff --git a/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md new file mode 100644 index 0000000000..075082aff6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md @@ -0,0 +1,35 @@ +# Agent Note: `dsh web` opens its ready page + +Status: implemented + +English | [中文](2026-08-12-open-ready-web-ui.zh.md) + +## Problem + +`dsh web` bound the HTTP server and printed its canonical local URL, but left the user to copy that URL into a browser even though the root README described the command as opening the Web UI. A browser handoff also cannot run at the server's bind callback alone: the API routes, browser plugin roster, and static fallback may still be mounting, so the first page request could observe an incomplete application that the process is about to reject. + +## Decision + +The Web app's command provider resolves `openBrowser: true` for an ordinary invocation and `false` for `--no-open`. The bundle passes that value into its `web-runtime` row; deployments may still replace the complete row config explicitly. The runtime samples inherited `SSH_CONNECTION` and `SSH_TTY` once during activation and suppresses browser handoff when either is non-empty, because the process then serves remote host loopback while the SSH client or editor owns the user's local forwarded address. + +The Web runtime treats URL printing and browser opening as separate actions at one readiness point. It waits for the complete Loader tree to settle and confirms that `webServer` is still live, then prints the configured URL line and, outside SSH, prints `dsh web: opening the default browser; pass --no-open to disable` immediately before handing the canonical loopback URL to the operating system's default browser. An SSH launch keeps the host URL line so the operator can identify the remote port, but cannot derive or open the forwarding owner's local address. A deployment that explicitly binds all interfaces still opens loopback locally while the printed LAN URL remains informational; the CLI rejects `--host 0.0.0.0`. `openBrowser` and `printUrl` can be disabled independently. + +The handoff uses the maintained `open` package for macOS, Windows, Linux, containers, and WSL. A short-lived Node helper invokes that package with the canonical scrubbed child environment, so Harness credentials and `DSH_*` state do not reach the operating-system launcher or a newly started browser. `BROWSER` is a launch-only command selector: app boot rejects it in a discovered `.env`, while only an inherited value can reach a compatible opener path that honors the variable. On Windows the helper waits for the short-lived PowerShell launcher to exit because `open` resolves when that process spawns, before it has handed the URL to the shell; other platforms stop after the opener accepts spawn. The runtime never waits for the browser to exit. The parent reads helper stderr so a failure writes one English diagnostic with the specific reason and manual URL to stderr without disposing the already-ready server; a later browser exit is outside the handoff result. + +Unit coverage pins command defaults, `--no-open`, SSH suppression, readiness ordering, teardown and failure suppression, helper outcomes, stderr reason propagation, the Windows launcher lifetime, the scrubbed helper environment, the inherited-only `BROWSER` rule, the pre-handoff opt-out status, and the reason-bearing non-fatal diagnostic. A real Loader composition binds an OS-assigned port, serves the actual static fallback, replaces only the operating-system handoff, and requests the handed-off URL immediately to prove it is already reachable. Assembled keyless snapshots run the built `dsh web` command locally, with a failing opener, with VS Code plus SSH markers, and from a project that declares `BROWSER`: the local case verifies that the handed-off page is the printed, reachable page containing the boot manifest while credential and Harness-state variables are absent from the opener; the failure case verifies the stderr reason and manual URL after readiness; the remote case verifies that the host URL remains visible without a browser launch; the file-layer command case fails before readiness or handoff. Repository browser and packaging tests pass `--no-open` because they own their browser or run unattended. + +## Alternatives considered + +**Open from the CLI launcher** — rejected because the launcher deliberately knows only profile selection and cannot derive the OS-assigned port or the app-owned Loader settlement point without reversing the app-owned command-line decision. + +**Open from `dsh-host-webserver` when its socket binds** — rejected because that package is a generic route carrier with no shell or frontend knowledge, and socket readiness precedes application readiness. + +**Infer whether to open from TTY, CI, editor, display, container, or WSL variables** — rejected because those signals do not establish a host/browser split and misclassify detached terminals and desktop launches. Non-empty `SSH_CONNECTION` or `SSH_TTY` is narrower evidence: it identifies a remote host whose loopback URL is not the forwarding owner's local URL. The default plus explicit `--no-open` remains stable for non-SSH launches. + +**Require Enter before opening the browser** — rejected for the local default because it turns ordinary server startup into a second stdin-owned interaction and excludes desktop or supervised launches with no usable terminal. `--no-open` remains the explicit opt-out for a caller that owns the browser or wants a server only. + +**Hand-roll platform commands** — rejected because URL opening has distinct macOS, Windows, Linux, container, and WSL behavior. The maintained dependency owns those platform branches while this package retains only readiness and failure semantics. + +## Consequences + +An ordinary local `dsh web` invocation announces the automatic handoff and its `--no-open` opt-out, then opens one ready page without making the generic HTTP carrier desktop-aware or exposing its ambient credentials to the desktop launcher. An SSH invocation prints the remote host URL but leaves opening the forwarded local address to the SSH client or editor. A discovered `.env` that sets `BROWSER` fails launch instead of selecting an executable; a platform opener that honors the variable can read it only when the operator exports it in the launching shell. Other unattended consumers must pass `--no-open`; a handoff failure writes its reason and manual URL to stderr while preserving the usable server. The Web app gains the locked `open` dependency, the shared subprocess environment scrubber, and the opener's transitive platform helpers; it does not own, wait for, or terminate the browser after the operating-system handoff succeeds. diff --git a/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md new file mode 100644 index 0000000000..de73ab0c85 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md @@ -0,0 +1,35 @@ +# Agent Note: `dsh web` 打开已就绪页面 + +Status: implemented + +[English](2026-08-12-open-ready-web-ui.md) | 中文 + +## Problem + +`dsh web` 会绑定 HTTP 服务器并打印规范本地 URL,但仍要求用户把 URL 复制到浏览器,尽管根 README 已把该命令描述为会打开 Web UI。浏览器交接也不能只以服务器绑定回调为时机:API 路由、浏览器插件名录和静态回退可能仍在挂载,第一次页面请求可能看到一个尚未完整且即将被进程判定为启动失败的应用。 + +## Decision + +Web 应用的命令提供方为普通调用解析出 `openBrowser: true`,为 `--no-open` 解析出 `false`。组合包把该值传给自己的 `web-runtime` 行;部署仍可显式替换该行的完整配置。运行时在激活期间对继承的 `SSH_CONNECTION` 与 `SSH_TTY` 采样一次,只要其中一项非空就会跳过浏览器交接,因为此时进程提供的是远端宿主机 loopback,而用户的本地转发地址由 SSH 客户端或编辑器持有。 + +Web 运行时把 URL 打印与浏览器打开作为同一就绪点上的两个独立动作。它等待完整 Loader 配置树结算,并确认 `webServer` 仍在线,然后打印已配置的 URL 行;非 SSH 环境下还会在把规范 loopback URL 交给操作系统默认浏览器之前立即打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。SSH 启动会保留宿主机 URL 行,以便操作者识别远端端口,但进程无法推导或打开转发持有方的本地地址。部署显式绑定所有网络接口时,本机仍打开 loopback,打印出的 LAN URL 只用于告知;CLI 会拒绝 `--host 0.0.0.0`。`openBrowser` 与 `printUrl` 可以分别关闭。 + +交接使用维护中的 `open` 包处理 macOS、Windows、Linux、容器和 WSL。一个短生命周期 Node helper 使用规范的脱敏子进程环境调用该包,因此 Harness 凭据和 `DSH_*` 状态不会进入操作系统启动器或新启动的浏览器。`BROWSER` 是只能来自启动环境的命令选择器:应用启动过程会拒绝被发现的 `.env` 中的该变量,只有继承值才能抵达会读取该变量的兼容 opener 路径。在 Windows 上,helper 会等待短生命周期 PowerShell launcher 退出,因为 `open` 会在该进程 spawn 时、尚未把 URL 交给 shell 之前返回;其他平台则在 opener 接受 spawn 后结束。运行时绝不等待浏览器退出。父进程会读取 helper stderr,因此失败时只向 stderr 写入一条包含具体原因和手动访问 URL 的英文诊断,不会 dispose 已就绪的服务器;浏览器之后退出不属于本次交接结果。 + +单元覆盖钉住命令默认值、`--no-open`、SSH 抑制、就绪顺序、资源释放与失败抑制、helper 结果、stderr 原因传播、Windows launcher 生命周期、helper 的脱敏环境、`BROWSER` 仅可继承的规则、交接前 opt-out 提示以及包含原因的非致命诊断。真实 Loader 组合会绑定由操作系统分配的端口、提供实际静态回退,只替换操作系统交接,并立即请求被交接的 URL,以证明页面此时已可访问。无密钥的整体快照会分别在本机环境、opener 失败环境、带 VS Code 与 SSH 标记的环境,以及声明了 `BROWSER` 的项目中运行构建后的 `dsh web` 命令:本机用例验证被交接的页面就是打印出的、已可访问且包含启动清单的页面,同时 opener 中不存在凭据与 Harness 状态变量;失败用例验证就绪后的 stderr 原因和手动 URL;远端用例验证宿主机 URL 仍可见,但不会启动浏览器;文件层命令用例则在就绪或交接前失败。仓库内浏览器与打包测试会传入 `--no-open`,因为它们自行持有浏览器或在无人值守环境运行。 + +## Alternatives considered + +**从 CLI 启动器打开** — 否决,因为启动器刻意只了解 profile 选择,无法取得操作系统分配的端口或应用自有的 Loader 结算点;让它了解这些事实会推翻应用自有命令行决策。 + +**在 `dsh-host-webserver` 绑定 socket 时打开** — 否决,因为该包是不了解 shell 与前端的通用路由载体,而且 socket 就绪早于应用就绪。 + +**根据 TTY、CI、编辑器、显示、容器或 WSL 环境变量推断是否打开** — 否决,因为这些信号不能证明宿主机与浏览器分离,并会误判分离终端和桌面启动。非空的 `SSH_CONNECTION` 或 `SSH_TTY` 是更窄的证据:它表明远端宿主机 loopback URL 并不是转发持有方的本地 URL。非 SSH 启动仍保持默认打开并提供显式 `--no-open`。 + +**打开浏览器前要求按下 Enter** — 不作为本机默认行为,因为它会把普通服务器启动变成由 stdin 持有的第二次交互,并排除没有可用终端的桌面启动或受监督启动。调用方自行持有浏览器或只需要服务器时,仍通过 `--no-open` 显式退出。 + +**手写各平台命令** — 否决,因为 URL 打开在 macOS、Windows、Linux、容器和 WSL 上各有不同。维护中的依赖持有这些平台分支,本包只保留就绪与失败语义。 + +## Consequences + +普通的本机 `dsh web` 调用会先公告自动交接及其 `--no-open` 退出方式,再打开一个已就绪的页面,同时不会让通用 HTTP 载体感知桌面环境,也不会向桌面启动器暴露环境凭据。SSH 调用会打印远端宿主机 URL,但由 SSH 客户端或编辑器负责打开转发后的本地地址。被发现的 `.env` 如果设置 `BROWSER`,启动就会失败,而不是选择一个可执行文件;会读取该变量的平台 opener 只有在操作者从启动 shell 中 export 时才能取得它。其他无人值守消费方必须传入 `--no-open`;交接失败时会向 stderr 写入原因与手动访问 URL,同时保留可用服务器。Web 应用新增锁定的 `open` 依赖、共享子进程环境脱敏器及 opener 的传递平台辅助包;操作系统交接成功后,本应用不持有、不等待也不终止浏览器。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 8daa789977..403f2deb4e 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 README.md -README.md: 8a4bd01332a23ce4144c661784bc549e0ba72d21 -README.zh.md: c507bf884bd426feead6a96adbdb5c136456e3b5 +README.md: 9ccd27b8934449bd0d2311317dc38aee5a5c0cdc +README.zh.md: 7eb9ef1a62afbcf95b235e5b90fa7529869af59d diff --git a/README.md b/README.md index 8a4bd01332..9ccd27b893 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Install `Node.js`, then run: npx @deepseek-ai/dsh web ``` -The command starts the Web UI, served at `http://127.0.0.1:3080` by default. See [Web UI guide](docs/user/guide/index.md). +The command starts the Web UI at `http://127.0.0.1:3080` by default and opens it in the default browser for a local launch. An SSH launch only prints the host URL because the SSH client or editor owns the local forwarded address. Pass `--no-open` to run the server without opening a browser. See [Web UI guide](docs/user/guide/index.md). ### Run from source @@ -34,6 +34,8 @@ pnpm run build pnpm dsh web ``` +`pnpm run build` prepares the repository artifacts. `pnpm dsh web` uses those built artifacts without rebuilding. + ## Community and support - Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions). diff --git a/README.zh.md b/README.zh.md index c507bf884b..7eb9ef1a62 100644 --- a/README.zh.md +++ b/README.zh.md @@ -20,7 +20,7 @@ DeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。** npx @deepseek-ai/dsh web ``` -该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。 +该命令默认会在 `http://127.0.0.1:3080` 启动 Web UI,本机启动时还会用默认浏览器打开页面。通过 SSH 启动时只打印宿主机 URL,因为本地转发地址由 SSH 客户端或编辑器持有。传入 `--no-open` 可仅运行服务器而不打开浏览器。详见 [Web UI 指南](docs/user/guide/index.md)。 ### 从源码运行 @@ -34,6 +34,8 @@ pnpm run build pnpm dsh web ``` +`pnpm run build` 会准备仓库产物。`pnpm dsh web` 会直接使用这些已构建产物,不会重新构建。 + ## 社区与支持 - 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6ad4739c19..295eb1868f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -77,6 +77,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | +| [`open`](https://github.com/sindresorhus/open) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ef63dd2fc6..a5851993dc 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 apps/cli/reference/README.md -README.md: 7828f55a2e4adfd85a0018baada6945ea75aacb0 -README.zh.md: e14e13731c314efd4d39913b91f2e90ba624e55c +README.md: e60f9d9e00dc77c8b2f22edcba67f9e8f3ba2f07 +README.zh.md: a99531a67d447039803a7a8248f2cc30a19d9be9 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 7828f55a2e..e60f9d9e00 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -24,7 +24,7 @@ The shipped apps own these command lines: | Profile | Arguments | |---|---| -| `web` | `--host`, `--port`, repeatable `--trusted-host` | +| `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` | | `headless` | the task text, as the positional argument | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -64,16 +64,17 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, and repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities). The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it 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 a separate `pnpm run dev:web` watcher rebuilds client bundles. ```sh dsh web +dsh web --no-open dsh web --patch ./extra.cordis.yml 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. The CLI intentionally does not support `--host 0.0.0.0` yet and exits with a usage error; `--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 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` yet 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. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index e14e13731c..a99531a67d 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -24,7 +24,7 @@ | Profile | 参数 | |---|---| -| `web` | `--host`、`--port`、可重复的 `--trusted-host` | +| `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` | | `headless` | 任务文本,作为位置参数 | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -64,16 +64,17 @@ dsh --profile tui ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 ```sh dsh web +dsh web --no-open dsh web --patch ./extra.cordis.yml dsh web --dump-config dsh web --help ``` -生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。CLI 目前有意不支持 `--host 0.0.0.0`,并会以用法错误退出;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 +生产 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。 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 611a098b6f..b76326d799 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -36,8 +36,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] }) expect(parse(['--profile', 'web', '-h'])) .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] }) - expect(parse(['web', '--host', '127.0.0.1', '--port', '8080', '--dev'])) - .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '127.0.0.1', '--port', '8080', '--dev'] }) + expect(parse(['web', '--host', '127.0.0.1', '--port', '8080', '--no-open', '--future-web-flag'])) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '127.0.0.1', '--port', '8080', '--no-open', '--future-web-flag'] }) expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] }) // Launcher flags placed after that boundary belong to the app too. diff --git a/apps/cli/tests/fixtures/web-browser-open/open.mjs b/apps/cli/tests/fixtures/web-browser-open/open.mjs new file mode 100644 index 0000000000..0f196f0e3f --- /dev/null +++ b/apps/cli/tests/fixtures/web-browser-open/open.mjs @@ -0,0 +1,42 @@ +import { spawn } from 'node:child_process' +import { join } from 'node:path' + +const handoffProbe = ` +const { writeFileSync } = require('node:fs') +const marker = process.argv[1] +const helperPid = Number(process.argv[2]) +setTimeout(() => { + let helperAlive = true + if (process.platform === 'win32') { + try { + process.kill(helperPid, 0) + } catch { + helperAlive = false + } + } + if (helperAlive) writeFileSync(marker, '') +}, 50) +` + +export default async function open(url) { + if (process.env.BROWSER_OPEN_TEST_FAILURE !== undefined) { + throw new Error(process.env.BROWSER_OPEN_TEST_FAILURE) + } + const response = await fetch(url) + const html = await response.text() + console.log(`dsh browser-open: ${JSON.stringify({ + url, + status: response.status, + bootManifest: html.includes('__DSH_BOOT__'), + apiKeyPresent: process.env.DEEPSEEK_API_KEY !== undefined, + dshHomePresent: process.env.DSH_HOME !== undefined, + })}`) + // The Windows launcher writes the server-exit marker only while its helper + // remains alive, so the assembled test detects an early helper exit. + const launcher = spawn(process.execPath, [ + '--eval', handoffProbe, + '--', join(process.cwd(), `.dsh-browser-open-${process.ppid}`), String(process.pid), + ], { stdio: 'ignore' }) + launcher.unref() + return launcher +} diff --git a/apps/cli/tests/fixtures/web-browser-open/register.mjs b/apps/cli/tests/fixtures/web-browser-open/register.mjs new file mode 100644 index 0000000000..8f3e53c4f3 --- /dev/null +++ b/apps/cli/tests/fixtures/web-browser-open/register.mjs @@ -0,0 +1,41 @@ +import { existsSync, rmSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { join } from 'node:path' + +const openerUrl = new URL('./open.mjs', import.meta.url).href +const exitMarker = join(process.cwd(), `.dsh-browser-open-${process.pid}`) + +const markerPoll = setInterval(() => { + if (!existsSync(exitMarker)) return + rmSync(exitMarker, { force: true }) + process.exit(0) +}, 25) +markerPoll.unref() + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'open') return { shortCircuit: true, url: openerUrl } + return nextResolve(specifier, context) + }, +}) + +// The SSH case has no opener helper to stop the long-lived Web process. +if (process.env.DSH_BROWSER_OPEN_TEST_EXIT_ON_READY === '1') { + const originalLog = console.log + console.log = (...args) => { + originalLog(...args) + if (typeof args[0] === 'string' && args[0].startsWith('dsh web: ')) { + setTimeout(() => process.exit(0), 250) + } + } +} + +if (process.env.DSH_BROWSER_OPEN_TEST_EXIT_ON_FAILURE === '1') { + const originalError = console.error + console.error = (...args) => { + originalError(...args) + if (typeof args[0] === 'string' && args[0].startsWith('web-app: could not open the default browser because ')) { + setTimeout(() => process.exit(0), 0) + } + } +} diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts index c563c850e6..d2172ebe57 100644 --- a/apps/cli/tests/lazy-search-startup.compat.spec.ts +++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts @@ -57,6 +57,7 @@ function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; cod const child = spawn(process.execPath, [ builtBin, 'web', + '--no-open', '--host', '127.0.0.1', '--port', diff --git a/apps/cli/tests/web-browser-open.snapshot.ts b/apps/cli/tests/web-browser-open.snapshot.ts new file mode 100644 index 0000000000..0441ab6e17 --- /dev/null +++ b/apps/cli/tests/web-browser-open.snapshot.ts @@ -0,0 +1,239 @@ +/** Assembled keyless snapshot for the default `dsh web` browser handoff. */ + +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { afterEach, describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const builtBin = join(repoRoot, 'apps/cli/lib/bin.js') +const frontendIndex = join(repoRoot, 'apps/web/dist/index.html') +const openerHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href +const openingMessage = 'dsh web: opening the default browser; pass --no-open to disable' +const tempRoots: string[] = [] +const builtArtifactsExist = existsSync(builtBin) && existsSync(frontendIndex) + +if (process.env.DSH_EXAMPLE_MODE === 'lib' && !builtArtifactsExist) { + throw new Error('dsh web browser-open snapshot requires built CLI and Web artifacts in lib mode') +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +interface BrowserOpenRecord { + url: string + status: number + bootManifest: boolean + apiKeyPresent: boolean + dshHomePresent: boolean +} + +function normalizeLocalUrl(url: string): string { + return url.replace(/:\d+$/, ':{{port}}') +} + +describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', () => { + it('hands the reachable page to the default browser after the shipped tree settles', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-snapshot-')) + tempRoots.push(root) + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '', + SSH_TTY: '', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1] + const openLine = result.stdout.split('\n').find(line => line.startsWith('dsh browser-open: ')) + const opening = result.stdout.includes(openingMessage) + if (readyUrl === undefined || openLine === undefined || !opening) { + throw new Error(`dsh web browser-open evidence missing\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + const opened = JSON.parse(openLine.slice('dsh browser-open: '.length)) as BrowserOpenRecord + + expect({ + exitCode: result.exitCode, + opening, + readyUrl: normalizeLocalUrl(readyUrl), + openedUrl: normalizeLocalUrl(opened.url), + status: opened.status, + bootManifest: opened.bootManifest, + apiKeyPresent: opened.apiKeyPresent, + dshHomePresent: opened.dshHomePresent, + stderr: result.stderr, + }).toMatchInlineSnapshot(` + { + "apiKeyPresent": false, + "bootManifest": true, + "dshHomePresent": false, + "exitCode": 0, + "openedUrl": "http://127.0.0.1:{{port}}", + "opening": true, + "readyUrl": "http://127.0.0.1:{{port}}", + "status": 200, + "stderr": "", + } + `) + }) + + it('prints the launcher reason and manual URL after the Web app is ready', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-failure-snapshot-')) + tempRoots.push(root) + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + BROWSER_OPEN_TEST_FAILURE: 'fixture desktop unavailable', + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_BROWSER_OPEN_TEST_EXIT_ON_FAILURE: '1', + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '', + SSH_TTY: '', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1] + const diagnostic = result.stderr.split(/\r?\n/u) + .find(line => line.startsWith('web-app: could not open the default browser because ')) + ?.replace(/http:\/\/127\.0\.0\.1:\d+/u, 'http://127.0.0.1:{{port}}') + + expect({ + diagnostic, + exitCode: result.exitCode, + opened: result.stdout.includes('dsh browser-open: '), + opening: result.stdout.includes(openingMessage), + readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl), + }).toMatchInlineSnapshot(` + { + "diagnostic": "web-app: could not open the default browser because fixture desktop unavailable; visit http://127.0.0.1:{{port}} manually", + "exitCode": 0, + "opened": false, + "opening": true, + "readyUrl": "http://127.0.0.1:{{port}}", + } + `) + }) + + it('prints the host URL without launching a browser in a VS Code Remote SSH session', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-ssh-snapshot-')) + tempRoots.push(root) + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1', + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22', + SSH_TTY: '', + VSCODE_IPC_HOOK_CLI: '/tmp/vscode-ipc', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1] + + expect({ + exitCode: result.exitCode, + opening: result.stdout.includes(openingMessage), + readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl), + opened: result.stdout.includes('dsh browser-open: '), + stderr: result.stderr, + }).toMatchInlineSnapshot(` + { + "exitCode": 0, + "opened": false, + "opening": false, + "readyUrl": "http://127.0.0.1:{{port}}", + "stderr": "", + } + `) + }) + + it('rejects a project browser command before starting the Web app', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-env-snapshot-')) + tempRoots.push(root) + writeFileSync(join(root, '.env'), 'BROWSER=./project-browser\n') + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '', + SSH_TTY: '', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + + const diagnostic = result.stderr.split(/\r?\n/u) + .find(line => line.startsWith('Error: dsh: ')) + ?.replace(/^Error: dsh: .*[/\\]\.env/u, 'dsh: {{root}}/.env') + + expect({ + diagnostic, + exitCode: result.exitCode, + opening: result.stdout.includes(openingMessage), + opened: result.stdout.includes('dsh browser-open: '), + ready: result.stdout.includes('dsh web: '), + }).toMatchInlineSnapshot(` + { + "diagnostic": "dsh: {{root}}/.env sets "BROWSER", which only the launching environment may set (it decides how this process starts, where its code and instructions load from, or how it reaches the network); export BROWSER instead of putting it in a .env file", + "exitCode": 1, + "opened": false, + "opening": false, + "ready": false, + } + `) + }) +}) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index e15f339d25..99a1105fa6 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -92,7 +92,7 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT)) await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') host = subprocessCtx.subprocess.spawn(spawnSpec( - [process.execPath, binPath, 'web', '--port', '0'], + [process.execPath, binPath, 'web', '--no-open', '--port', '0'], world, { DEEPSEEK_API_KEY: 'keyless-hmr-no-call', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index c6cd58d0a1..ac3a0292ec 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -458,10 +458,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { 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'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: sessionsDir, env: { @@ -226,7 +226,7 @@ describe('dsh web keyless CLI smoke', () => { 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'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: workspace, env: { @@ -339,7 +339,7 @@ describe('dsh web keyless CLI smoke', () => { 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'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: workspace, env: { @@ -421,7 +421,7 @@ describe('dsh web keyless CLI smoke', () => { 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'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: workspace, env: { @@ -491,6 +491,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--no-open', '--port', String(port), ], { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 95205cc6be..de4db72dbd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 09ad73fa708fc526060598421286223d3ef4c955 -config-catalog.zh.md: 38155b91b92902fd0d19e2769e37225f894f2302 +config-catalog.md: 4528cbd248d237d9a9235e2e76aca07c04d0f71d +config-catalog.zh.md: 95f0ff27907884f29d6d86c74427d5a6672c98f9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 09ad73fa70..4528cbd248 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3025,6 +3025,8 @@ Requires: `webServer` ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { + /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */ + openBrowser: boolean /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** @@ -3039,7 +3041,7 @@ export interface Config { } ``` -Source: [`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 38155b91b9..95f0ff2790 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3029,6 +3029,8 @@ export interface WebRuntimeConfig { ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { + /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */ + openBrowser: boolean /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** @@ -3043,7 +3045,7 @@ export interface Config { } ``` -来源:[`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 9be66947bb..f67c375cd0 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -100,11 +100,11 @@ const BOOTSTRAP_NAMES = new Set([ 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', 'PYTHONHOME', - // Version-control command hooks and config redirects. + // Version-control hooks, config redirects, and ambient command selectors. 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', - 'EDITOR', 'VISUAL', 'PAGER', + 'EDITOR', 'VISUAL', 'PAGER', 'BROWSER', // Network reach and trust. 'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL', 'SSL_CERT_FILE', 'SSL_CERT_DIR', diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 9eec620285..8726895c1b 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -133,6 +133,7 @@ describe('loadLayeredEnv', () => { ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'], ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'], ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'], + ['a browser command', 'BROWSER=./script\n'], ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => { const home = tmp() const project = tmp() diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 2a1c5b01db..b23d1fc79c 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/bundle/web-app/README.md -README.md: 28fb5b3dcfc7fbb912493a6b97495e2ed5a3eece -README.zh.md: 92157f05497e53c48506666a3a53d638d98a7c9e +README.md: c8a6874bc01696fc7c9ca65faf772da81ac1e964 +README.zh.md: f36115cd97ccfeabd9bbb1a408be89c4c1df8723 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 28fb5b3dcf..c8a6874bc0 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{openBrowser, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, and registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true. After its Loader tree settles, it prints the `dsh web:` URL line when `printUrl` is true and opens the canonical host URL in the default browser when `openBrowser` is true and the inherited `SSH_CONNECTION` and `SSH_TTY` are blank or absent. An SSH launch keeps the URL line but suppresses browser handoff because the SSH client or editor owns the local forwarded address. Immediately before a handoff, the runtime prints `dsh web: opening the default browser; pass --no-open to disable`. A short-lived Node helper runs the maintained platform opener with the canonical scrubbed child environment. On Windows it stays alive until the short-lived PowerShell launcher exits, because `open` reports spawn before that launcher has handed the URL to the shell; elsewhere the helper stops after the opener accepts spawn. A helper failure writes a diagnostic with its reason and the manual URL to stderr without stopping the server, and no path waits for the browser to exit. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, `--no-open`, and the app's `--help`, then provides `webStartup`; browser opening defaults on for local launches, and `--no-open` turns it off for this invocation. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model retry defaults @@ -28,3 +28,6 @@ The prompt section sits near the system prompt's head and is stable for the life - **The frontend dist must be built** — `require.resolve` of the dist fails loud at activation with a build hint; there is no source-serving fallback. - **`lanAddresses` is a boot-time snapshot** — interface changes after boot are not re-advertised; the printed LAN URL always matches the configured trust fence. +- **Only handoff startup is observable** — observation ends when the platform opener accepts spawn, except that Windows waits for its short-lived PowerShell launcher to exit; a later browser exit is not reported, and the printed URL remains the manual fallback. +- **SSH forwarding owns the browser URL** — the printed canonical URL names the remote host's loopback endpoint; automatic handoff is suppressed, and the SSH client or editor must expose and open its local forwarded address. +- **Browser command overrides are launch-only** — a discovered `.env` may not set `BROWSER`; only an inherited value may reach an opener path that honors the variable, so a checkout cannot choose an executable for automatic handoff. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 92157f0549..f36115cd97 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{openBrowser, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,并在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量。自身 Loader 配置树结算后,它在 `printUrl` 为 true 时打印 `dsh web:` URL 行;`openBrowser` 为 true 且继承的 `SSH_CONNECTION` 与 `SSH_TTY` 均为空或不存在时,才会用默认浏览器打开规范宿主机 URL。SSH 启动仍保留 URL 行,但会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有。交接前,运行时会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。短生命周期 Node helper 使用规范的脱敏子进程环境运行受维护的平台 opener。在 Windows 上,helper 会保持存活,直至短生命周期的 PowerShell launcher 退出,因为 `open` 会在 launcher 把 URL 交给 shell 之前、仅在 spawn 时返回;其他平台则在 opener 接受 spawn 后结束。helper 失败时会向 stderr 写入包含原因和手动访问 URL 的诊断,不会停止服务器,且任何路径都不会等待浏览器退出。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` 以及应用自己的 `--help`,再提供 `webStartup`;本机启动默认会打开浏览器,`--no-open` 则只对本次调用关闭该行为。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型重试默认值 @@ -28,3 +28,6 @@ Web 使用共享的有界 normal 默认值,在首次请求后最多再重试 - **前端 dist 必须已构建**:对 dist 的 `require.resolve` 在激活时明确报错并给出构建提示;没有从源码直接服务的回退路径。 - **`lanAddresses` 是启动期快照**:启动后的网卡变化不会重新公告;打印的 LAN URL 始终与配置的信任栅栏一致。 +- **只观测交接启动**:平台 opener 接受 spawn 后即结束观察,但 Windows 会等待其短生命周期 PowerShell launcher 退出;之后的浏览器退出不会上报,已打印 URL 仍是手动访问的回退路径。 +- **SSH 转发持有浏览器 URL**:打印出的规范 URL 指向远端宿主机 loopback 端点;自动交接会被跳过,SSH 客户端或编辑器必须暴露并打开其本地转发地址。 +- **浏览器命令覆盖只能来自启动环境**:被发现的 `.env` 不得设置 `BROWSER`;只有继承值可以抵达会读取该变量的 opener 路径,避免 checkout 为自动交接选择可执行文件。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d1a9c3f69d..03d04782bb 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -128,15 +128,17 @@ # Web glue owned by this bundle: resolves the built frontend dist (an # 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, and prints the URL line. 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. + # section and the bash runtime variable, prints the URL line, and opens the + # canonical local URL after the full tree settles. 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. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: + openBrowser: !!js ctx.webStartup.openBrowser printUrl: true surfaceContext: true trustedHosts: !!js ctx.webStartup.trustedHosts diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 6180ce7de7..1f6ede4009 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -98,6 +98,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-file-reference-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", @@ -106,9 +107,11 @@ "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "commander": "^15.0.0" + "commander": "^15.0.0", + "open": "^11.0.0" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 9b5207dd02..6965310437 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -5,11 +5,13 @@ * 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, and the URL line. App command-line values arrive through the - * `webStartup` service expressions in the bundle patch. + * variable, the URL line, and the default-browser handoff. App command-line + * values arrive through the `webStartup` service expressions in the bundle + * patch. * @module @deepseek-ai/dsh-web-app */ +import { spawn, type ChildProcess } from 'node:child_process' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' @@ -17,6 +19,8 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +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-system-prompt' @@ -36,6 +40,8 @@ export const inject = ['webServer'] /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { + /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */ + openBrowser: boolean /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** @@ -50,6 +56,7 @@ export interface Config { } export const Config: z = z.object({ + openBrowser: z.boolean().default(true), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), trustedHosts: z.array(String).default([]), @@ -72,6 +79,46 @@ const LOOPBACK_HOST = '127.0.0.1' /** The webserver schema's all-interfaces bind literal. */ const ALL_INTERFACES_HOST = '0.0.0.0' +/** Whether this process was launched through SSH, including a forwarded-port session. */ +function launchedThroughSsh(ctx: Context): boolean { + const environment = launchEnvironmentOf(ctx) + return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => { + const value = environment.getFrom(name, ['process'])?.value + return value !== undefined && value !== '' + }) +} + +const BROWSER_OPENER_MODULE = import.meta.resolve('open') + +const BROWSER_OPENER_PROGRAM = ` +try { + const { default: open } = await import(${JSON.stringify(BROWSER_OPENER_MODULE)}) + const launcher = await open(process.argv[1]) + if (process.platform === 'win32') { + // open resolves at PowerShell spawn; keep it referenced until that launcher hands the URL to Windows. + const code = launcher.exitCode ?? await new Promise((resolve, reject) => { + function onError(error) { + launcher.off('close', onClose) + reject(error) + } + function onClose(code) { + launcher.off('error', onError) + resolve(code) + } + launcher.ref() + launcher.once('error', onError) + launcher.once('close', onClose) + }) + if (code !== 0) throw new Error('browser operating-system launcher exited with code ' + String(code)) + } + process.exitCode = 0 +} catch (error) { + // The parent turns this exit into the manual-URL warning. + console.error(error) + process.exitCode = 1 +} +` + /** * Resolve one LAN-trust snapshot from the active server bind. * @@ -123,17 +170,64 @@ function resolveDistIndex(): string { } } -/** Test hook: hosts with no built frontend dist substitute the resolver; production never touches this. */ -export const internals: { resolveDistIndex: () => string } = { resolveDistIndex } +/** Start the maintained platform opener without forwarding Harness credentials. */ +function spawnBrowserLauncher(url: string): ChildProcess { + return spawn(process.execPath, [ + '--input-type=module', + '--eval', BROWSER_OPENER_PROGRAM, + '--', url, + ], { + env: scrubbedParentEnv(), + stdio: ['ignore', 'inherit', 'pipe'], + }) +} + +/** Hand one URL to the operating system's default browser. */ +async function openBrowser(url: string): Promise { + const launcher = spawnBrowserLauncher(url) + let launcherStderr = '' + launcher.stderr?.setEncoding('utf8') + launcher.stderr?.on('data', (chunk: string) => { launcherStderr += chunk }) + await new Promise((resolve, reject) => { + function onError(error: Error): void { + launcher.off('close', onClose) + reject(error) + } + function onClose(code: number | null): void { + launcher.off('error', onError) + if (code !== 0) { + const firstLine = launcherStderr.trim().split(/\r?\n/u)[0] + const reason = firstLine === undefined || firstLine === '' + ? `browser launcher exited with code ${String(code)}` + : firstLine.replace(/^(?:[A-Za-z]*Error):\s*/u, '') + reject(new Error(reason)) + return + } + if (launcherStderr !== '') process.stderr.write(launcherStderr) + resolve() + } + launcher.once('error', onError) + launcher.once('close', onClose) + }) +} + +/** Test hooks for the built dist and native browser handoff; production never mutates them. */ +export const internals: { + resolveDistIndex: () => string + openBrowser: (url: string) => Promise +} = { resolveDistIndex, openBrowser } /** * Mount the Web runtime: dist serving, surface prompt, the bash runtime - * variable, and the URL line. + * variable, the URL line, and the default-browser handoff. * @param ctx - plugin context carrying the webServer service. * @param config - validated {@link Config}. */ export function apply(ctx: Context, config: Config): void { const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts) + // 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(ctx) // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) @@ -156,28 +250,40 @@ export function apply(ctx: Context, config: Config): void { }) }) } - if (config.printUrl) { - // The URL line is a readiness signal: supervisors (and the keyless CLI - // smoke) RPC as soon as they observe it, so it must not print while - // sibling rows (the /api route owner) are still mounting. Await Loader - // settlement first; a hand-built tree without a Loader prints at once. - const printUrl = (): void => { + if (config.printUrl || handoffBrowser) { + // The URL line and browser handoff are readiness signals: supervisors RPC + // as soon as they observe the line, while a browser requests the page as + // soon as it opens. Neither may run while sibling rows such as the /api + // route owner are still mounting. Await Loader settlement first; a + // hand-built tree without a Loader is already the complete tree. + const announceReady = (): void => { + const webUrl = localWebUrl(ctx) // Reuse the exact LAN snapshot provided to the /api trust fence. const lanCandidate = runtime.lanAddresses[0] const port = ctx.webServer.port - console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) + if (config.printUrl) { + console.log(`dsh web: ${webUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) + } + if (handoffBrowser) { + console.log('dsh web: opening the default browser; pass --no-open to disable') + void internals.openBrowser(webUrl).catch((error: unknown) => { + const reason = error instanceof Error ? error.message : String(error) + console.error(`web-app: could not open the default browser because ${reason}; visit ${webUrl} manually`) + }) + } } // This row's own activation can precede a sibling failure. The app owns - // readiness by waiting for its Loader tree, or prints at once in a + // readiness by waiting for its Loader tree, or announces at once in a // hand-built context without Loader. const settled = ctx.get('loader')?.await() - if (settled === undefined) printUrl() + if (settled === undefined) announceReady() else { void settled.then(() => { // The tree can be disposed while the boot was in flight (early - // SIGTERM); a URL line for a dead server would only mislead, and - // reading the torn-down port would turn a clean shutdown into a crash. - if (ctx.get('webServer') !== undefined) printUrl() + // SIGTERM); a URL line or browser tab for a dead server would only + // mislead, and reading the torn-down port would turn a clean shutdown + // into a crash. + if (ctx.get('webServer') !== undefined) announceReady() // Loader reports a failed boot; this row only stays quiet. }, () => {}) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index af6997cff7..9faad244dd 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,6 +1,6 @@ /** * The web app's command-line provider: it parses the `dsh --profile web` flag - * family (`--host`, `--port`, `--trusted-host`) and its `--help` + * 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. * @module @deepseek-ai/dsh-web-app/startup @@ -21,6 +21,8 @@ export const WEB_STARTUP_SERVICE = 'webStartup' /** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ export interface WebStartupValues { + /** Whether this invocation opens the default browser after startup. */ + openBrowser: boolean /** `--host`, absent when the invocation did not name one. */ host?: string /** `--port`, absent when the invocation did not name one. */ @@ -32,6 +34,7 @@ export interface WebStartupValues { /** The web flag family, as commander parsed it. */ interface WebOptions { host?: string + open: boolean port?: string trustedHost?: string[] } @@ -46,11 +49,13 @@ function webCommand(): Command { .description('Serve the DeepSeek Harness browser UI.') .helpOption('-h, --help', 'show this help') .option('--host ', 'bind host') + .option('--no-open', 'do not open the Web UI in the default browser') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--trusted-host ', '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 `) } @@ -73,6 +78,7 @@ export function apply(ctx: Context): void { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } ctx.provide(WEB_STARTUP_SERVICE, { + openBrowser: options.open, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, trustedHosts: options.trustedHost ?? [], diff --git a/packages/bundle/web-app/tests/browser-open.spec.ts b/packages/bundle/web-app/tests/browser-open.spec.ts new file mode 100644 index 0000000000..0e2e22649d --- /dev/null +++ b/packages/bundle/web-app/tests/browser-open.spec.ts @@ -0,0 +1,101 @@ +/** Default-browser startup over a real Loader tree and listening Web server. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { apply, internals } from '../src/index.ts' + +const contexts: Context[] = [] +const tempRoots: string[] = [] +const originalResolveDistIndex = internals.resolveDistIndex +const originalOpenBrowser = internals.openBrowser + +beforeEach(() => { + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('SSH_TTY', '') +}) + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + internals.resolveDistIndex = originalResolveDistIndex + internals.openBrowser = originalOpenBrowser + vi.unstubAllEnvs() + Reflect.deleteProperty(globalThis, '__dshWebAppApply') + Reflect.deleteProperty(globalThis, '__dshWebServer') +}) + +describe('web app browser startup', () => { + it('opens the canonical URL only after the complete page is reachable', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-')) + tempRoots.push(root) + const dist = join(root, 'dist') + mkdirSync(dist) + const index = join(dist, 'index.html') + writeFileSync(index, 'ready') + internals.resolveDistIndex = () => index + + const webserverModule = join(root, 'webserver.mjs') + const webAppModule = join(root, 'web-app.mjs') + writeFileSync(webserverModule, 'export default globalThis.__dshWebServer\n') + writeFileSync(webAppModule, [ + "export const name = 'fixture-web-app'", + "export const inject = ['webServer']", + 'export const apply = (ctx, config) => globalThis.__dshWebAppApply(ctx, config)', + '', + ].join('\n')) + const config = join(root, 'cordis.yml') + writeFileSync(config, [ + '- id: webserver', + ` name: ${pathToFileURL(webserverModule).href}`, + ' config:', + ' host: 127.0.0.1', + ' port: 0', + '- id: web-app', + ` name: ${pathToFileURL(webAppModule).href}`, + ' config:', + ' openBrowser: true', + ' printUrl: false', + ' surfaceContext: false', + ' trustedHosts: []', + '', + ].join('\n')) + + const globals = globalThis as unknown as { + __dshWebAppApply: typeof apply + __dshWebServer: typeof WebServer + } + globals.__dshWebAppApply = apply + globals.__dshWebServer = WebServer + + let openedUrl: string | undefined + let openedStatus: number | undefined + let resolveOpened!: () => void + const opened = new Promise((resolve) => { resolveOpened = resolve }) + internals.openBrowser = async (url) => { + openedUrl = url + openedStatus = (await fetch(url)).status + resolveOpened() + } + + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(config).href }, + }) + await ctx.loader.await() + await opened + + expect(openedUrl).toBe(`http://127.0.0.1:${String(ctx.webServer.port)}`) + expect(openedStatus).toBe(200) + }) +}) diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 26e347a503..ba3806232c 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -56,6 +56,7 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' config:', " host: !!js ctx.webStartup.host ?? '127.0.0.1'", + ' openBrowser: !!js ctx.webStartup.openBrowser', ' port: !!js ctx.webStartup.port ?? 3080', ' trustedHosts: !!js ctx.webStartup.trustedHosts', '- id: provider', @@ -89,12 +90,14 @@ describe('web command-line provider', () => { it('publishes each flag and releases direct service expressions', async () => { const { values, observed } = await bootProvider([ '--host', '127.0.0.1', + '--no-open', '--port', '8080', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9', ]) expect(values).toEqual({ host: '127.0.0.1', + openBrowser: false, port: 8080, trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], }) @@ -104,9 +107,10 @@ describe('web command-line provider', () => { it('leaves deployment values to each consumer when flags omit them', async () => { const { values, observed } = await bootProvider([]) - expect(values).toEqual({ trustedHosts: [] }) + expect(values).toEqual({ openBrowser: true, trustedHosts: [] }) expect(observed.readerConfig).toEqual({ host: '127.0.0.1', + openBrowser: true, port: 3080, trustedHosts: [], }) @@ -115,6 +119,7 @@ describe('web command-line provider', () => { it('prints its own help and leaves the consumer pending', async () => { const { values, observed } = await bootProvider(['--help']) expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--no-open') expect(observed.out).toContain('--trusted-host') expect(values).toBeUndefined() expect(observed.readerConfig).toBeUndefined() diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index a4e99e8e9b..ee4593cfd0 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -1,19 +1,28 @@ /** * 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 URL-line printing with the - * runtime's bind-dependent LAN snapshot. + * prompt section and bash runtime variables, and readiness publication through + * the URL line and default-browser handoff. */ +import { EventEmitter } from 'node:events' +import { spawn, type ChildProcess } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawn: vi.fn(), +})) + vi.mock('node:os', async importOriginal => ({ ...await importOriginal(), networkInterfaces: () => ({ @@ -24,14 +33,30 @@ vi.mock('node:os', async importOriginal => ({ let dist: string | undefined +beforeEach(() => { + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('SSH_TTY', '') +}) + afterEach(() => { vi.restoreAllMocks() + vi.mocked(spawn).mockReset() + vi.unstubAllEnvs() internals.resolveDistIndex = originalResolve + internals.openBrowser = originalOpenBrowser if (dist !== undefined) rmSync(dist, { recursive: true, force: true }) dist = undefined }) const originalResolve = internals.resolveDistIndex +const originalOpenBrowser = internals.openBrowser + +type BrowserLauncher = ChildProcess & { stderr: PassThrough } + +/** Minimal browser-launcher process for the native handoff adapter. */ +function launcher(): BrowserLauncher { + return Object.assign(new EventEmitter(), { stderr: new PassThrough() }) as unknown as BrowserLauncher +} /** Stage a dist fixture and point the bundle's resolver at it. */ function stageDist(): string { @@ -70,9 +95,14 @@ interface BashContribution { } describe('web-app runtime glue', () => { - it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { + it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => { stageDist() const ctx = new Context() + // Editor markers and a project .env SSH value do not establish a remote launch. + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([ + { source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } }, + { source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } }, + ])) const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('webServer', server) const contributions: BashContribution[] = [] @@ -83,8 +113,11 @@ describe('web-app runtime glue', () => { }, } as never) provideLoader(ctx) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) + const lifecycle: string[] = [] + const log = 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, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) @@ -95,6 +128,13 @@ describe('web-app runtime glue', () => { trustedHosts: ['192.168.1.5', 'lab.internal'], }) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') + expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567') + expect(lifecycle).toEqual([ + 'dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)', + 'dsh web: opening the default browser; pass --no-open to disable', + 'open:http://127.0.0.1:4567', + ]) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') @@ -107,15 +147,18 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('stays quiet with printUrl off', async () => { + it('publishes no readiness side effect when printing and browser opening are disabled', async () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) .toContain('rebuilding the affected Web artifacts') @@ -133,7 +176,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() @@ -148,44 +191,69 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) - it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { + it.each([ + ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'], + ['SSH_TTY', '/dev/pts/3'], + ] as const)('prints the host URL but skips browser handoff when %s marks an SSH launch', async (name, value) => { + vi.stubEnv(name, value) stageDist() - // Settlement path: the line waits for loader.await() so supervisors can - // RPC immediately after observing it. + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(openBrowser).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('defers readiness publication until Loader settlement and drops it on failure or teardown', async () => { + stageDist() + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + // Settlement path: both actions wait for loader.await() so their consumers + // can request the complete app immediately. const settled = new Context() settled.provide('webServer', fakeHttpServer().server) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) provideLoader(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() release!() await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567') await settled.fiber.dispose() // Failed path: Loader reports the sibling failure; the app prints no URL // for a process that is about to exit. log.mockClear() + openBrowser.mockClear() const failed = new Context() failed.provide('webServer', fakeHttpServer().server) provideLoader(failed, async () => { throw new Error('boot failed') }) - apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() await failed.fiber.dispose() // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() + openBrowser.mockClear() const torn = new Context() const child = torn.plugin((childCtx: Context) => { childCtx.provide('webServer', fakeHttpServer().server) @@ -194,11 +262,12 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) provideLoader(torn, () => tornSettlement) - apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the webServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() await torn.fiber.dispose() }) @@ -210,7 +279,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('webServer', server) - apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing') @@ -228,4 +297,82 @@ describe('web-app runtime glue', () => { expect((error as Error).message).toContain('frontend dist not built') } }) + + it.each([ + ['Error', new Error('no desktop'), 'no desktop'], + ['non-Error', 'desktop unavailable', 'desktop unavailable'], + ] as const)('keeps the server running and reports the manual URL when a browser failure is %s', async (_kind, failure, reason) => { + stageDist() + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + internals.openBrowser = vi.fn(async () => { throw failure }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}) + apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') + expect(diagnostic).toHaveBeenCalledWith( + `web-app: could not open the default browser because ${reason}; visit http://127.0.0.1:4567 manually`, + ) + expect(ctx.get('webServer')).toBeDefined() + await ctx.fiber.dispose() + }) + + it('scrubs the helper environment and reports helper spawn or exit failures', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'must-not-reach-browser') + vi.stubEnv('DSH_HOME', '/must-not-reach-browser') + const completed = launcher() + vi.mocked(spawn).mockReturnValueOnce(completed) + const completion = originalOpenBrowser('http://127.0.0.1:4567') + const [command, args, options] = vi.mocked(spawn).mock.calls[0]! + expect(command).toBe(process.execPath) + expect(args).toEqual([ + '--input-type=module', + '--eval', expect.stringContaining('await import('), + '--', 'http://127.0.0.1:4567', + ]) + expect(args?.[2]).toContain("if (process.platform === 'win32')") + expect(args?.[2]).toContain('launcher.ref()') + expect(options?.env).not.toHaveProperty('DEEPSEEK_API_KEY') + expect(options?.env).not.toHaveProperty('DSH_HOME') + expect(options?.env?.PATH).toBe(process.env.PATH) + expect(options?.stdio).toEqual(['ignore', 'inherit', 'pipe']) + completed.emit('close', 0) + await expect(completion).resolves.toBeUndefined() + expect(completed.listenerCount('error')).toBe(0) + + const completedWithStderr = launcher() + vi.mocked(spawn).mockReturnValueOnce(completedWithStderr) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const completionWithStderr = originalOpenBrowser('http://127.0.0.1:4567') + completedWithStderr.stderr?.write('launcher note\n') + completedWithStderr.emit('close', 0) + await expect(completionWithStderr).resolves.toBeUndefined() + expect(stderr).toHaveBeenCalledWith('launcher note\n') + + const failedWithReason = launcher() + vi.mocked(spawn).mockReturnValueOnce(failedWithReason) + const reasonFailure = originalOpenBrowser('http://127.0.0.1:4567') + const reasonAssertion = expect(reasonFailure).rejects.toThrow('desktop unavailable') + failedWithReason.stderr?.write('Error: desktop unavailable\n at fixture') + failedWithReason.emit('close', 1) + await reasonAssertion + + const failed = launcher() + vi.mocked(spawn).mockReturnValueOnce(failed) + const failure = originalOpenBrowser('http://127.0.0.1:4567') + const failureAssertion = expect(failure).rejects.toThrow('exited with code 3') + await Promise.resolve() + failed.emit('close', 3) + await failureAssertion + + const errored = launcher() + vi.mocked(spawn).mockReturnValueOnce(errored) + const error = originalOpenBrowser('http://127.0.0.1:4567') + const errorAssertion = expect(error).rejects.toThrow('spawn failed') + await Promise.resolve() + errored.emit('error', new Error('spawn failed')) + await errorAssertion + expect(errored.listenerCount('close')).toBe(0) + }) }) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 77d823538c..c00b64f5a9 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -29,12 +29,18 @@ { "path": "../../host/webserver" }, + { + "path": "../../util/launch-environment" + }, { "path": "../../core/system-prompt" }, { "path": "../../shell/shell-env" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f577e877..1db9ca29ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1428,6 +1428,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback @@ -1452,6 +1455,9 @@ importers: '@deepseek-ai/dsh-storage-json': specifier: workspace:^ version: link:../../storage/storage-json + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-web-frontend': specifier: workspace:^ version: link:../../../apps/web @@ -1464,6 +1470,9 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 + open: + specifier: ^11.0.0 + version: 11.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -11942,6 +11951,10 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -12258,6 +12271,18 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -12766,6 +12791,11 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -12778,6 +12808,15 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -12800,6 +12839,10 @@ packages: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -13447,6 +13490,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -13598,6 +13645,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + preact@10.29.7: resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} peerDependencies: @@ -13761,6 +13812,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -14440,6 +14495,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -17085,6 +17144,10 @@ snapshots: builtin-modules@3.3.0: {} + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} cac@7.0.0: {} @@ -17392,6 +17455,15 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + defu@6.1.7: {} delaunator@5.1.0: @@ -18027,6 +18099,8 @@ snapshots: ipaddr.js@1.9.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -18035,6 +18109,12 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -18047,6 +18127,10 @@ snapshots: is-what@5.5.0: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isexe@2.0.0: {} @@ -18871,6 +18955,15 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -19054,6 +19147,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + preact@10.29.7: {} prelude-ls@1.2.1: {} @@ -19281,6 +19376,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + rw@1.3.3: {} sade@1.8.1: @@ -20016,6 +20113,11 @@ snapshots: ws@8.21.0: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} xml-naming@0.1.0: {} diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 2d5d391fe9..4e98065719 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -42,7 +42,7 @@ node, bin_path, cwd, timeout_seconds = sys.argv[1:] pid, fd = pty.fork() if pid == 0: os.chdir(cwd) - os.execvpe(node, [node, bin_path, "web", "--host", "127.0.0.1", "--port", "0"], os.environ.copy()) + os.execvpe(node, [node, bin_path, "web", "--no-open", "--host", "127.0.0.1", "--port", "0"], os.environ.copy()) output = bytearray() ready_seen = False diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index ddffa0c246..deff340fa8 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).\n\nIt uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper).\n\n## Developer preview\n\nDeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.**\n\n## Run\n\n### Run from `npm`\n\nInstall `Node.js`, then run:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command starts the Web UI, served at `http://127.0.0.1:3080` by default. See [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run from a repository checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## Community and support\n\n- Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).\n- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.\n- Join DeepSeek Harness Discord community.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Development\n\nStart with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[MIT](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).\n\nIt uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper).\n\n## Developer preview\n\nDeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.**\n\n## Run\n\n### Run from `npm`\n\nInstall `Node.js`, then run:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command starts the Web UI at `http://127.0.0.1:3080` by default and opens it in the default browser for a local launch. An SSH launch only prints the host URL because the SSH client or editor owns the local forwarded address. Pass `--no-open` to run the server without opening a browser. See [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run from a repository checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` prepares the repository artifacts. `pnpm dsh web` uses those built artifacts without rebuilding.\n\n## Community and support\n\n- Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).\n- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.\n- Join DeepSeek Harness Discord community.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Development\n\nStart with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[MIT](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
企微小助手入群问卷微信公众号
\"DeepSeek\"DeepSeek\"DeepSeek
\n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令默认会在 `http://127.0.0.1:3080` 启动 Web UI,本机启动时还会用默认浏览器打开页面。通过 SSH 启动时只打印宿主机 URL,因为本地转发地址由 SSH 客户端或编辑器持有。传入 `--no-open` 可仅运行服务器而不打开浏览器。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` 会准备仓库产物。`pnpm dsh web` 会直接使用这些已构建产物,不会重新构建。\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
企微小助手入群问卷微信公众号
\"DeepSeek\"DeepSeek\"DeepSeek
\n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" }, { "role": "user",