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/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 4c325327de..f0213f0864 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: cfe6fd1028d03056e5ac6da7f014db2ac6db8fab -2026-07-21-serial-cross-platform-ci-reference.zh.md: 8ac7e087c3d015c0ed6bdf71feed9806bd6fb997 +2026-07-21-serial-cross-platform-ci-reference.md: d1ab9590df1252c9c91e7ec53dc1559e221d8f68 +2026-07-21-serial-cross-platform-ci-reference.zh.md: c9ffbac42858a19cca7c5fef6fd8f583030c195d diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index cfe6fd1028..d1ab9590df 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 8ac7e087c3..c9ffbac428 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 3bc9415b40..8ac0ed6dbe 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: b3310988decb2916ac895aaf154dbc106c51ed48 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 2d408173a657c77add750a53eaee4ecb9177919c +2026-07-22-evidence-based-larger-hosted-runners.md: db08a4eb9812a2cb13499718cbd3ceb023960c06 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 1b9b622e929a693d165d23ce256ba4d3c85a0460 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index b3310988de..db08a4eb98 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,7 +14,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests run the three primary Linux jobs on the 16-core Ubuntu 24.04 pool and the independent native Windows signal on the 16-core Windows 2025 pool. The required Wine signal remains on standard hosted Linux. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. -The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. +The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete-aggregate evidence available on `master` through the self-hosted standby pools (no Linux hosted serial reference remains). `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The former gate-level and coarse primary shard jobs are absent from the workflow. Their workflow-facing static, lint, coverage, snapshot, and scenario selectors are also absent, so an unused diagnostic path cannot preserve a second CI architecture. Instrumented coverage may use [process-local partitions inside its existing job](2026-08-18-in-job-partitioned-coverage.md); that coordinator neither selects workflow jobs nor transfers reports between runners. @@ -50,9 +50,9 @@ Inner and outer worker limits are separate controls. An exact-head 32-worker ESL The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. -Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. +The self-hosted serial Linux and Windows standby references and the disabled `serial-macos` job exist. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER_LINUX` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. +The self-hosted serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER_LINUX` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. ## Alternatives considered @@ -70,7 +70,7 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm- **Publish the static job's build to post-build consumers.** A run-scoped artifact preserves one exact build, but the workflow can only consume it by waiting for the entire static job and then requesting another runner. The [independent consumer build](2026-07-30-independent-ci-consumer-build.md) assigns the single Linux build to its actual consumers instead. -**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path. +**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility jobs preserve portable evidence, while the self-hosted serial standby preserves complete-aggregate evidence, without making that slower topology the ordinary primary path. **Keep blocking and observational native Windows checks in separate jobs.** This would preserve their distinction at the workflow level but pay Windows setup twice. `run-gates` preserves the same blocking versus observational result inside one job. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 2d408173a6..1b9b622e92 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,7 +14,7 @@ Status: implemented 企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求在 16 核 Ubuntu 24.04 池上运行 3 个 Linux 主作业,并在 16 核 Windows 2025 池上运行独立的原生 Windows 信号。必需的 Wine 信号仍位于标准托管 Linux。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 -必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 +必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上通过自托管热备池持续提供完整聚合流程证据(不存在托管的 Linux 串行参考)。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 原有的门禁级和粗粒度主流程分片 job 已从工作流中移除。面向工作流的静态、lint、覆盖率、快照和场景选择器也已移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。插桩覆盖率可以在[既有 job 内使用进程本地分区](2026-08-18-in-job-partitioned-coverage.md);该协调器既不选择工作流 job,也不在 runner 之间传输报告。 @@ -50,9 +50,9 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于覆盖率结果能否保持确定性,而非标称核心数。 -只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 +自托管的 Linux 与 Windows 串行热备参考,以及被禁用的 `serial-macos` 任务仍然存在。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER_LINUX` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 +自托管的串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER_LINUX` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 ## 曾考虑的替代方案 @@ -70,7 +70,7 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 **将静态作业的构建发布给构建后消费方。** 仅供本次运行使用的产物能保留同一份构建结果,但工作流要消费它,只能先等待整个静态作业完成,再请求另一台运行器。[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)则转而让实际消费方负责唯一一次 Linux 构建。 -**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 +**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业保留可移植证据,自托管串行热备则保留完整聚合流程证据,无需让这套较慢的拓扑成为普通主路径。 **把阻断性与观测性原生 Windows 检查放在不同 job。** 此方案会在工作流层面保留二者的区别,却要承担两次 Windows 设置开销。`run-gates` 在一个 job 内保留了相同的阻断与观测结果。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 91879fb4d5..5b9f89e609 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 418f996383ebc08e78cb5b061bfde4b90dd89495 -2026-07-23-portable-required-pull-request-ci.zh.md: ada3cc52e3f6436269969a3ff93b7a2616353337 +2026-07-23-portable-required-pull-request-ci.md: 740ef20d6b7a1edc1a010f37bcbcddab2981e8ad +2026-07-23-portable-required-pull-request-ci.zh.md: 5b24af59fb13e32a642e15c79aee3910601ad45b diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 418f996383..740ef20d6b 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -16,7 +16,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei The three Linux primary jobs, Node compatibility, Python SDK unit suite, Python runtime validation, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. -The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. +The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent completeness check, now provided by the self-hosted `vm-backup`/`dsh-win-ci` standby lanes on `master`; the only hosted serial reference is the disabled `serial-macos`. The manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index ada3cc52e3..5b24af59fb 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -16,7 +16,7 @@ Status: implemented 三项 Linux 主作业、Node 兼容性、Python SDK 单元测试套件、Python 运行时验证和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 -当前主拓扑及其测量结果以[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)为准。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 +当前主拓扑及其测量结果以[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)为准。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的完整性检查,现由 `master` 上公司自有 `vm-backup`/`dsh-win-ci` 自托管热备通道提供;仅存的托管串行参考是禁用的 `serial-macos`。手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 392c4f54f2..c75e36f054 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 499141ca6a3703a12d10c195b732441d49419599 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 0b1e280db6e4e04cc4a210203e97026174fc02f6 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 31d1ebf009a6e044722081985546e87b9d4ae0e2 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 84445abfdd42671f2f5c9de8403ed7891f9cc292 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 499141ca6a..31d1ebf009 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -13,7 +13,7 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Restore-only caching** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path. No master job produces these hosted caches, so these restores hit matching archived entries until they evict. The enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. - **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered @@ -31,4 +31,4 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i - The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version. - The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key. +- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need hosted cache restores or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs resolve that stable path and exact key. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 0b1e280db6..84445abfdd 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,7 +13,7 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业策略,保留三种有意采用的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **只恢复不上传**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径。没有任何 master 作业生产这些 hosted 缓存,这些恢复步骤只能命中仍有归档的旧条目,直至其被逐出;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 - **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 @@ -31,4 +31,4 @@ Status: implemented - generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 - 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 的各个矩阵任务共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是利用上一条缓存记录预填充。 -- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。 +- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要 hosted 缓存恢复或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业会解析这一稳定路径及精确键。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index d5bc176adc..419e81704b 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: afe3c937a733ed50073ada5c9c6f40c6c93c6641 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 57a4d3ae5fba7c10211587e36ce9e41e7e985d7e +2026-07-24-web-gui-browser-e2e-lane.md: a477a887e679d455376e69ef0c0fae58dc8dde69 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 1f9504df6d49061a5b4759e65dcbbeeb8d9c7402 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index afe3c937a7..a477a887e6 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 57a4d3ae5f..1f9504df6d 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold:工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态,然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC,并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定,而不会因压缩(compaction)消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状,但时间仍不设阈值,因为机器速度不属于正确性约定。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts` 和 `*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 8557998d06..c0fd339c04 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.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/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: 72a7e33d0e84105f7680429443df41661ced288a -2026-07-30-web-browser-snapshot-ci-gate.zh.md: 161f99ab98984ca1d938f11c5e3de5176ca4da66 +2026-07-30-web-browser-snapshot-ci-gate.md: abb197f2a8e3488e2ae1edc7c2e77cbdcf919556 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: b665dc90c4bdba969b3cd9a561a3f18f388e27dd diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index 72a7e33d0e..abb197f2a8 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,13 +12,13 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. +The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions. Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. CI's `scripts/run-web-snapshots.ts` first runs `hmr-live.e2e.ts` and `cordis-tool-round.e2e.ts` as separate serial Vitest invocations. The HMR scenario mutates built workspace state, while the Cordis scenario owns a lifecycle-sensitive approval and steering sequence whose turn grouping is made deterministic by waiting for the initial turn to settle before approval. After both pass, one six-worker Vitest pool runs every remaining file. Every child inherits stdio, and the enclosing gate streams that output through `run-gates`. -For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. Completed local replays measured the six-worker browser command at about 65–71 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 15–20 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index 161f99ab98..b665dc90c4 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,13 +12,13 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts` 把 `test:web:ci` 登记为 `ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 +消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。 本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。 CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行运行 `hmr-live.e2e.ts` 与 `cordis-tool-round.e2e.ts`。HMR 场景会修改已构建工作区状态;Cordis 场景则拥有一条对生命周期时序敏感的批准与 steering(中途引导)序列,它通过在批准前等待初始轮次结束来确定轮次分组。两者通过后,其余全部文件进入同一个 6-worker Vitest 池。所有子进程都继承 stdio,外围门禁再通过 `run-gates` 流式传递输出。 -对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 +对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 完整本地 replay 中,6-worker 浏览器命令耗时约 65–71 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cdfda7f87..771fe0eb31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,11 @@ env: jobs: - # TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release. - # The self-hosted standby remains active on every master push. + # TODO(hosted-serial-ci): Re-enable the one remaining disabled hosted serial + # reference job (serial-macos) before release. The self-hosted standby lane + # below remains active on every master push. Re-enabling serial-macos does not + # restore a Linux hosted-cache producer: decide whether to add a master seeder + # or remove the restore-only steps if cold starts become a concern. # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The consumer job owns the only Linux build so @@ -211,7 +214,11 @@ jobs: store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) echo "path=$store_path" >> "$GITHUB_OUTPUT" - # Skipped under failover — see the coverage lane's identical rationale. + # Pull requests restore the pnpm store and Playwright caches without paying + # compression and upload on the required path. No master job saves these + # hosted cache keys, so each restore-keys fallback hits the matching archived + # entry until it evicts, after which the store is cold. Skipped under failover + # — the self-hosted VM's persistent store is already warm. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -220,8 +227,7 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - # Pull requests restore the cache normally produced by serial-linux on - # master; they do not pay compression and upload on the required path. + # Skipped under failover: the VM's persistent browser cache is already warm. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -502,76 +508,6 @@ jobs: shell: pwsh run: pnpm run check:ci:windows-complete - # The hosted reference jobs below are temporarily disabled; the self-hosted - # standby remains active. Each enabled host executes the complete, unsharded - # primary Node aggregate with one gate worker, giving reviewers a simple - # cross-platform oracle for completeness and timing. - serial-linux: - if: false - name: serial / linux - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Configure pnpm store path - id: pnpm-store - run: | - store_root="$HOME/.local/share/pnpm/store" - echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" - store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) - echo "path=$store_path" >> "$GITHUB_OUTPUT" - - # Master refreshes the pnpm store cache that pull requests restore without saving. - # The store cache stays a hand-rolled actions/cache step rather than - # setup-node's `cache: pnpm`: the enterprise pull-request jobs above - # restore exactly this key and path, and setup-node's built-in cache - # uses its own key format — converting this producer would silently - # starve their documented restore-only optimization. - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - # Master produces the hosted Chromium cache restored by pull requests. - - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install (immutable) - run: pnpm install --frozen-lockfile - - - name: Install Playwright Chromium and system dependencies - run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium - - - name: Prepare bubblewrap (unrestrict userns) - run: bash scripts/prepare-ci-bubblewrap.sh - - - name: Run complete unsharded primary Node CI serially - env: - DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} - DSH_COVERAGE_MAX_WORKERS: '1' - DSH_E2E_MAX_WORKERS: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_OXLINT_THREADS: '1' - DSH_PUBLINT_CONCURRENCY: '1' - DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci:linux-primary - # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if @@ -590,8 +526,8 @@ jobs: name: serial / linux (self-hosted standby) runs-on: [self-hosted, linux, x64, vm-backup] steps: - # Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive - # comparison as serial-linux. Depth 2 would miss github.event.before + # DSH_ARCHIVE_BASE_REF below compares the frozen-archive gate against + # github.event.before, so full history is required: depth 2 would miss it # on multi-commit or force pushes; full fetch is cheap here because # checkout resolves against the VM's local mirror. - uses: actions/checkout@v6 @@ -631,6 +567,8 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci:linux-primary + # The one remaining disabled hosted serial reference job; see + # TODO(hosted-serial-ci) above. serial-macos: if: false name: serial / macos diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d72e7bfee4..a9c67fca42 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -76,15 +76,11 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - # The with-key escalation e2e self-skips without a usable runner. Install - # bwrap so trusted CI exercises it; the userns knob is best-effort and the - # test's functional probe decides. - - name: Install bubblewrap (unrestrict userns) - run: | - sudo apt-get update -q - sudo apt-get install -yq bubblewrap - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" + # The with-key escalation e2e self-skips without a usable runner. Prepare + # bwrap so trusted CI exercises it; the functional probe remains the + # authority on whether the runner can use the sandbox. + - name: Prepare bubblewrap (unrestrict userns) + run: bash scripts/prepare-ci-bubblewrap.sh # Guard against a false green: the e2e suites self-skip when the key is # absent, so a missing/misconfigured secret would otherwise pass as 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 f336af8fd8..3194d54998 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: dcba2eb562799e8dad84e5ee77e4d0d22144a228 -config-catalog.zh.md: 1e06d0d0ea6aec1f5c669f9780afed9bf685dcda +config-catalog.md: b865806bcc4d3e494a0ebf2a9331928991f9e6d1 +config-catalog.zh.md: 6e4ce4cc4d3bfa856987c0d698fddc1c8c7712a9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dcba2eb562..b865806bcc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3052,6 +3052,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 /** @@ -3066,7 +3068,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 1e06d0d0ea..6e4ce4cc4d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3056,6 +3056,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 /** @@ -3070,7 +3072,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 0388b857a6..be83f2e017 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1434,6 +1434,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 @@ -1458,6 +1461,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 @@ -1470,6 +1476,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:^ @@ -12012,6 +12021,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'} @@ -12328,6 +12341,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==} @@ -12836,6 +12861,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'} @@ -12848,6 +12878,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'} @@ -12870,6 +12909,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==} @@ -13517,6 +13560,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 @@ -13668,6 +13715,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: @@ -13831,6 +13882,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==} @@ -14510,6 +14565,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'} @@ -17155,6 +17214,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: {} @@ -17462,6 +17525,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: @@ -18097,6 +18169,8 @@ snapshots: ipaddr.js@1.9.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -18105,6 +18179,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: {} @@ -18117,6 +18197,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: {} @@ -18941,6 +19025,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 @@ -19124,6 +19217,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: {} @@ -19351,6 +19446,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + rw@1.3.3: {} sade@1.8.1: @@ -20086,6 +20183,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/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 9c0f56c85a..cc43e06f55 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -238,6 +238,20 @@ describe('E2B e2e workflow', () => { }) }) +describe('DeepSeek e2e workflow', () => { + it('prepares bubblewrap from the pinned payload without a package transaction', () => { + const workflow = loadWorkflow('.github/workflows/e2e.yml') + const e2e = workflowJob(workflow, 'e2e') + if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps') + + const steps = e2e.steps.filter(isRecord) + expect(steps.find(step => step.name === 'Prepare bubblewrap (unrestrict userns)')).toMatchObject({ + run: 'bash scripts/prepare-ci-bubblewrap.sh', + }) + expect(JSON.stringify(steps)).not.toContain('apt-get') + }) +}) + describe('Python release workflows', () => { it('keeps complete wheel validation separate from protected public publication', () => { const workflow = loadWorkflow('.github/workflows/python-release.yml') 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/run-oxlint.spec.ts b/scripts/run-oxlint.spec.ts index 25245add32..85628382e8 100644 --- a/scripts/run-oxlint.spec.ts +++ b/scripts/run-oxlint.spec.ts @@ -16,6 +16,18 @@ describe('Oxlint invocation', () => { }) }) + it('uses location-preserving diagnostics in CI', () => { + expect(resolveOxlintInvocation(['.'], { CI: 'true', DSH_OXLINT_THREADS: '4' })).toEqual({ + args: ['.', '--format=unix', '--threads=4'], + env: { CI: 'true', DSH_OXLINT_THREADS: '4', GOMAXPROCS: '4' }, + }) + }) + + it('preserves an explicitly selected CI formatter', () => { + expect(resolveOxlintInvocation(['.', '--format', 'github'], { CI: 'true' }).args) + .toEqual(['.', '--format', 'github']) + }) + it.each(['0', '-1', '1.5', 'auto'])('rejects invalid worker bound %s', (value) => { expect(() => resolveOxlintInvocation(['.'], { DSH_OXLINT_THREADS: value })) .toThrow('DSH_OXLINT_THREADS must be a positive integer') diff --git a/scripts/run-oxlint.ts b/scripts/run-oxlint.ts index 13ed84799f..bcbddb5011 100644 --- a/scripts/run-oxlint.ts +++ b/scripts/run-oxlint.ts @@ -10,6 +10,14 @@ function isFixInvocation(args: readonly string[]): boolean { return args.some(arg => FIX_FLAGS.has(arg)) } +function hasOutputFormat(args: readonly string[]): boolean { + return args.some(arg => + arg === '-f' + || arg.startsWith('-f=') + || arg === '--format' + || arg.startsWith('--format=')) +} + /** Complete Oxlint child-process arguments and environment. */ export interface OxlintInvocation { readonly args: readonly string[] @@ -23,8 +31,10 @@ export interface OxlintInvocation { * @returns the complete CLI arguments and child environment. */ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.ProcessEnv): OxlintInvocation { + const resolvedArgs = [...args] + if (env.CI === 'true' && !hasOutputFormat(args)) resolvedArgs.push('--format=unix') const raw = env.DSH_OXLINT_THREADS - if (raw === undefined || raw === '') return { args: [...args], env: { ...env } } + if (raw === undefined || raw === '') return { args: resolvedArgs, env: { ...env } } const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`run-oxlint: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`) @@ -33,7 +43,7 @@ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.Pro throw new Error('run-oxlint: use DSH_OXLINT_THREADS instead of passing --threads directly.') } return { - args: [...args, `--threads=${raw}`], + args: [...resolvedArgs, `--threads=${raw}`], env: { ...env, GOMAXPROCS: raw }, } } 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",