Merge branch 'worktree/process-plugin-streaming-72f9b3' into worktree/job-kill-delivery

This commit is contained in:
Yichen Jiang committed 2026-09-08 14:39:29 +08:00
commit 9a8ea5eaf1
55 files changed
+1314 -51

No files matched your search

@@ -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/feature/2026-08-25-promote-open-anywhere-plugin.md
2026-08-25-promote-open-anywhere-plugin.md: ee83c424d1454b26c1ce6cf6954105cdbfbb7419
2026-08-25-promote-open-anywhere-plugin.zh.md: f1696cec10a683d44dcaa3db454d343821fc13c9
2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f
2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96
@@ -12,6 +12,8 @@ The community plugin `@dsh-plugins/open-anywhere` (gitlab.deepseek.com/Ciyou/dsh
The first-party feature is named `open-in-app`: it selects the application that opens a workspace directory on the Harness host, not another machine or destination.
The shared `launchedThroughSsh()` predicate in [launch-environment](../../../../packages/util/launch-environment/README.md) reads non-empty `SSH_CONNECTION` or `SSH_TTY` only from the inherited process layer. An SSH launch produces an empty application catalog before any probe. Project and user `.env` values cannot establish an SSH launch; Web browser handoff and the adaptive directory picker use the same predicate. The client hides the action even when it remembers a choice, and the existing availability checks reject icon and launch requests. SSH port forwarding changes HTTP reachability, not which machine owns the workspace or applications.
The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `packages/host/open-in-app/` (the probe, catalog, and launch routes) and `@deepseek-ai/dsh-client-ui-open-in-app` at `packages/client/ui-open-in-app/` (the split button), mounted in the Web profile by the `dsh-web-app` bundle rows `open-in-app` and `ui-open-in-app`. The promotion is a rewrite, not a vendoring:
- **A host/client package pair, following the `directory-picker-browse`/`ui-directory-picker-browse` pairing**: the host package's `src/index.ts` registers the three HTTP routes on `ctx.webServer` (`GET /open-in-app/apps`, `GET /open-in-app/icon/<id>`, `POST /open-in-app/open`); the ui package's `src/client/index.ts` registers the split button into `conversation.session.header.utilities` through the standard slot/inject currency, with copy in a typed `open-in-app` locale namespace and styling in CSS Modules over `--dsw-*` tokens (the original's hand-injected style tag and inline dropdown are replaced by the `Menu` primitive), over an empty-apply node half that keeps the plugin on the host roster. Route paths and wire payload types have one home, the host package's browser-safe `./shared` subpath (constants and types only); the client bundle inlines it through an `INLINE_SAFE` entry in the client tsdown preset, the same channel `dsh-session`'s wire slices use. The host root exports only the Loader-required plugin values and types; catalog, resolver, launcher, and icon helpers remain source-internal.
@@ -28,6 +30,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t
## Alternatives considered
**Offer VS Code's remote CLI during SSH sessions.** Its installed executable does not prove a usable editor connection: the inherited IPC socket belongs to a live VS Code connection and can disappear while Harness keeps running. Browser-side SSH-target configuration and local editor handoff remain outside this host-application feature.
**Vendor the plugin's `lib/` as-is under `packages/`.** Fastest, but the hand-authored JavaScript fails typecheck, coverage, i18n, JSDoc, and invariant gates wholesale; keeping it exempt would create a package class the repository deliberately does not have.
**A Typert Remote instead of raw webServer routes.** The apps/open calls fit the Remote RPC shape, but the icon route serves binary PNGs, which the JSON RPC vocabulary does not carry; splitting icons onto a raw route while apps/open ride Remote gives two transports for one feature. Raw routes also match the original's client, and `webhook-github` establishes the validated-raw-route pattern.
@@ -50,8 +54,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t
## Consequences
- The Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
- Outside SSH sessions, the Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
- The community plugin's install path remains valid but redundant; its original routes and browser choice key are separate from `open-in-app`, so installations using the first-party feature should remove the community plugin to avoid duplicate header controls.
- Resolution and icons run lazily, once per host process, so an application installed while dsh runs appears only after restart — accepted; the uninstall direction self-heals through the `ENOENT` single-entry refresh.
- The catalog is compile-time fixed; extending it means editing `OPEN_IN_APP_CATALOG` and both locale dictionaries together (README Known Limitations). Platform coverage is uneven — several Git GUIs and terminals are macOS-only entries, Windows icons are limited to the 32px stock .NET extraction, Linux follows hicolor rather than the active theme, and CLI-only entries without a desktop record keep the generic icon.
- Coverage: resolver logic (every locator kind over temp filesystems, registry-dump and desktop-entry fixtures, an injected env/home/PATH table), per-platform icon extraction, the three routes (real Loader + real WebServer composition, including the one-pass cache, the `ENOENT` refresh, and HMR-safety disposal), controller wire behavior, and component presentation are unit-tested to the per-file 100% gate; no snapshot is added because the shipped keyless snapshot fixtures assert session-driven output, which this browser-side control never touches. The web ARIA goldens disable the `open-in-app` and `ui-open-in-app` rows, and the Host-only preset e2e composition disables the host row: the button reflects whatever applications the running machine has installed, so its presence and label are host facts no cross-platform golden can pin.
- Resolver, icon, route, controller, and component tests cover platform discovery, launch outcomes, the availability cache, and HMR disposal. The [SSH Web snapshot](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) renders the shared recorded conversation with both Open In rows enabled and a remembered app choice, capturing only the Session header; composer and statistics output belong to their own snapshots. Inherited SSH markers make the empty catalog deterministic across platforms. Ordinary Web snapshots keep host-dependent application discovery disabled.
@@ -12,6 +12,8 @@ Status: implemented
第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。
[launch-environment](../../../../packages/util/launch-environment/README.zh.md) 中共用的 `launchedThroughSsh()` 只从继承的进程层读取非空 `SSH_CONNECTION` 或 `SSH_TTY`。SSH 启动时会在任何探测开始前返回空应用目录。项目与用户 `.env` 中的值不能作为 SSH 启动的依据;Web 浏览器唤起和自适应目录选择器共用此判断。即使客户端记住了应用选择,也会隐藏操作入口;已有的可用性检查会拒绝图标和启动请求。SSH 端口转发只改变 HTTP 可达性,不改变工作区或应用所属的机器。
该功能的第一方归属是一对包:`@deepseek-ai/dsh-host-open-in-app` 位于 `packages/host/open-in-app/`(探测、目录与启动路由),`@deepseek-ai/dsh-client-ui-open-in-app` 位于 `packages/client/ui-open-in-app/`(分体按钮),由 `dsh-web-app` bundle 的 `open-in-app` 与 `ui-open-in-app` 两行挂载进 Web profile。转正是重写,不是 vendoring:
- **一对 host/client 包,沿用 `directory-picker-browse`/`ui-directory-picker-browse` 的配对结构**:host 包的 `src/index.ts` 在 `ctx.webServer` 上注册三条 HTTP 路由(`GET /open-in-app/apps`、`GET /open-in-app/icon/<id>`、`POST /open-in-app/open`);ui 包的 `src/client/index.ts` 经标准 slot/inject 通货把分体按钮注册进 `conversation.session.header.utilities`,文案在类型化的 `open-in-app` locale 命名空间中,样式为 `--dsw-*` token 上的 CSS Modules(原插件手工注入的 style 标签与内联下拉被 `Menu` 原语替代),节点半边是让插件出现在主机名册上的空 apply。路由路径与 wire 载荷类型只有一个家:host 包浏览器安全的 `./shared` 子路径(只有常量与类型);client bundle 经 client tsdown preset 的 `INLINE_SAFE` 条目将其内联,与 `dsh-session` 各 wire 切片同一通道。host 根入口只导出 Loader 所需的插件实体与类型;目录、resolver、launcher 与图标 helper 保持源码内部可见。
@@ -28,6 +30,8 @@ Status: implemented
## 考虑过的替代方案
**在 SSH 会话中提供 VS Code 的远端 CLI。** 已安装的可执行文件不能证明编辑器连接可用:继承的 IPC socket 属于一个仍在运行的 VS Code 连接,Harness 继续运行时它也可能消失。浏览器侧的 SSH 目标配置与本地编辑器唤起不属于这个主机应用功能。
**将插件的 `lib/` 原样 vendor 进 `packages/`。** 最快,但手写 JavaScript 会整体不过 typecheck、覆盖率、i18n、JSDoc 和 invariant 门禁;为其保留豁免会造出仓库刻意不设的包类别。
**用 Typert Remote 而非裸 webServer 路由。** apps/open 调用符合 Remote RPC 形态,但 icon 路由提供二进制 PNG,JSON RPC 词汇承载不了;把 icon 拆去裸路由而 apps/open 走 Remote 会让一个功能有两种传输。裸路由也匹配原插件的客户端,且 `webhook-github` 已确立带校验裸路由的先例。
@@ -50,8 +54,8 @@ Status: implemented
## 后果
- 只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
- 非 SSH 会话中,只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
- 社区插件的安装路径仍然有效但已冗余;其原始路由与浏览器选择键独立于 `open-in-app`,因此使用第一方功能的安装应移除社区插件,避免出现重复的头部控件。
- 解析与图标每主机进程惰性执行一次,dsh 运行期间安装的应用要重启后才出现——接受;卸载方向经 `ENOENT` 单条目刷新自愈。
- 目录在编译期固定;扩展它意味着同时编辑 `OPEN_IN_APP_CATALOG` 与两份 locale 词典(README 已知限制)。平台覆盖不均——若干 Git GUI 与终端仅有 macOS 条目;Windows 图标受限于 .NET 标准接口的 32px 提取,Linux 跟随 hicolor 而非当前主题,没有 desktop 记录的纯 CLI 条目则保留通用图标。
- 覆盖:resolver 逻辑(每种 locator 在临时文件系统上、注册表转储与 desktop 条目 fixture、注入的 env/home/PATH 表)、逐平台图标提取、三条路由(真实 Loader + 真实 WebServer 组合,含单趟缓存、`ENOENT` 刷新与 HMR 安全处置)、controller wire 行为和组件呈现都以逐文件 100% 门禁做了单元测试;不新增 snapshot,因为随仓库发布的免密 snapshot fixture 断言会话驱动的输出,而这个纯浏览器侧控件不触及它。Web ARIA golden 禁用 `open-in-app` 与 `ui-open-in-app` 两行,Host-only 的 preset e2e 组合禁用 host 行:按钮反映运行机器实际安装了哪些应用,其出现与否和标签都是主机事实,跨平台 golden 无法钉住。
- 解析器、图标、路由、控制器与组件测试覆盖平台探测、启动结果、可用性缓存和 HMR 处置。[SSH Web 快照](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) 在启用两个 Open In 配置项并记住应用选择的条件下渲染共享的录制会话,并仅捕获会话头部;输入框和统计栏由各自的快照负责。继承的 SSH 标记使空应用目录在不同平台上保持确定。普通 Web 快照仍禁用依赖主机的应用探测。
@@ -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/process/2026-09-08-trusted-changed-file-review-routing.md
2026-09-08-trusted-changed-file-review-routing.md: 246c223d976e312e5712d7baa150bd3e84f1363e
2026-09-08-trusted-changed-file-review-routing.zh.md: 397d4174340fc9c6002419b21436b5c33542f7e4
@@ -0,0 +1,47 @@
# Agent Note: Route reviews from trusted changed-file policy
Status: implemented
English | [中文](2026-09-08-trusted-changed-file-review-routing.zh.md)
## Problem
GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision.
Review routing needs an observable changed-file input, explicit owner rules, complete test exclusions, and a write-capable workflow that remains safe for pull requests from forks.
## Decision
The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns and individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches.
The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase.
The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on non-draft `pull_request_target` events for opened, synchronized, reopened, and ready-for-review pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets.
The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them.
The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced.
The workflow prints the changed non-test paths, excluded test paths, per-file owner matches, and final reviewer list before any review-request mutation. It requests the union of matched individual owners after removing the pull-request author and users who are already requested. A test-only or wholly unmatched change requests nobody.
## Verification
[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each test convention, production-name negative controls, renames, last-match behavior, unmatched files, complete pagination, the 3,000-file limit, log-before-request ordering, author and existing-reviewer filtering, test-only changes, drafts, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
## Alternatives considered
**Use native CODEOWNERS.** Native routing cannot ignore test-only changes and offers no repository-owned decision log before requesting reviewers.
**Run under `pull_request` and check out the pull-request head.** A fork workflow does not receive a write-capable token, while granting a write token to code from an untrusted head is unsafe.
**Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners.
**Infer semantic source changes from patches or language parsers.** GitHub can truncate patches, and the repository spans TypeScript, JavaScript, Python, Rust, YAML, Markdown, and generated evidence. A cross-language semantic classifier would add ambiguous rules without providing a complete input. The scanner therefore uses the complete non-test changed-file list and does not claim to distinguish formatting, comments, or documentation-only edits inside an eligible file.
## Consequences
Reviewer requests are reproducible from a trusted policy and the file list printed in the workflow log. Test-only changes do not request owners. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
The workflow requests every matched owner rather than choosing one owner nondeterministically. Shared ownership on large directories therefore produces multiple requests. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
Any non-test change under an owned directory remains eligible, including comment-only or formatting-only edits and documentation changes. Unmatched paths are logged and request nobody. Pull requests above the API file limit fail without requesting a partial owner set.
@@ -0,0 +1,47 @@
# Agent Note: 基于受信任的变更文件策略路由评审
Status: implemented
[English](2026-09-08-trusted-changed-file-review-routing.md) | 中文
## 问题
只要匹配路径发生变更,GitHub 原生 CODEOWNERS 就会请求评审者。它无法应用本仓库对需评审的实现或文档文件与纯测试证据的区分。使用原生 CODEOWNERS 文件还会让 GitHub 负责请求决策,而不是由可检查的仓库程序负责。
评审路由需要可观测的变更文件输入、显式 owner 规则、完整的测试排除规则,以及对 fork PR 仍然安全且具备写权限的 workflow。
## 决策
仓库在 GitHub 原生 CODEOWNERS 路径之外的 [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS) 中保存兼容 CODEOWNERS 格式的映射。该映射只接受显式绝对目录模式和 GitHub 个人用户。通配符、隐藏目录模式、团队、重复模式和重复 owner 都会被拒绝。靠后的匹配模式会替换靠前的匹配结果。
策略测试会统计匹配所有权规则的目录中的非测试跟踪文件行数。如果 `@turtle1999` 拥有的有效代码库超过三分之一,测试就会拒绝该映射。
[`request-review` workflow](../../../../.github/workflows/request-review.yml) 在非草稿 PR 的 `pull_request_target` 事件上运行,订阅创建、同步、重新打开和标记为可评审操作。具备写权限的 job 检出默认分支,只执行默认分支上的扫描器和所有权映射。它不会检出 PR 代码,也不会读取仓库 secret。
扫描器在决策之前获取所有变更文件记录。如果 PR 报告的文件数超过 GitHub API 的 3,000 个文件上限,或者分页只返回了部分列表,扫描器就会失败。它会规范化仓库路径,分别检查重命名前后的路径,并在记录文件名之前进行转义。
扫描器会在匹配 owner 之前排除纯测试路径。排除范围包括名为 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 或 `stress-tests` 的目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,以 `.bench.<ext>`、`.corpus.<ext>`、`.e2e.<ext>`、`.perf.<ext>`、`.snapshot.<ext>`、`.spec.<ext>`、`.stress.<ext>` 或 `.test.<ext>` 结尾的文件名,以及 Python 的 `test_*.py`、`*_test.py` 或 `*_tests.py` 文件。`vitest*.config.ts` 和门禁实现等测试基础设施仍需评审,因为它们会改变仓库证据的生成方式。
Workflow 会在发出任何评审请求变更之前,依次打印变更的非测试路径、排除的测试路径、逐文件 owner 匹配结果和最终评审者列表。它合并匹配到的个人 owner,并排除 PR 作者和已经收到评审请求的用户。纯测试变更或全部未匹配的变更不会请求任何人。
## 验证
[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖允许的所有权语法、拒绝的语法、每种测试约定、生产文件名负向对照、重命名、最后匹配规则、未匹配文件、完整分页、3,000 个文件上限、先记录后请求的顺序、作者与现有评审者过滤、纯测试变更、草稿和 API 失败。[Workflow 测试](../../../../scripts/ci-workflow.spec.ts)固定事件集合、最小权限、受信任的默认分支检出、不引用 PR head 和 secret,以及执行的命令。门禁图在静态 CI 和 `check-all` 中包含这两组测试。
## 考虑过的替代方案
**使用原生 CODEOWNERS。** 原生路由无法忽略纯测试变更,也无法在请求评审者之前提供由仓库控制的决策日志。
**在 `pull_request` 下运行并检出 PR head。** Fork workflow 无法获得具备写权限的 token,而向不受信任 head 中的代码授予写权限 token 并不安全。
**在 `pull_request_target` 下执行 PR 中的扫描器或 owner 映射。** 这会让不受信任的 PR 选择自己的写权限行为或 owner。
**根据补丁或语言解析器推断语义源码变更。** GitHub 可能截断补丁,而且仓库包含 TypeScript、JavaScript、Python、Rust、YAML、Markdown 和生成的证据。跨语言语义分类器会增加含义不明确的规则,却无法提供完整输入。因此,扫描器使用完整的非测试变更文件列表,并且不会声称能够区分合格文件中的纯格式、注释或仅文档编辑。
## 后果
评审请求可以根据受信任的策略和 workflow 日志中打印的文件列表复现。纯测试变更不会请求 owner。所有权变更只有合并后才会生效,因此修改策略的 PR 无法对自身应用其中不受信任的策略。
Workflow 会请求所有匹配的 owner,不会随机选择一人。因此,大目录上的共享所有权会产生多个请求。GitHub 使用 `GITHUB_TOKEN` 生成的评审请求事件可能不会启动依赖递归触发事件的其他 workflow;这些 workflow 不得把此请求作为唯一触发条件。
所有已分配目录下的非测试变更仍符合请求条件,其中包括纯注释、纯格式调整和文档变更。未匹配的路径会被记录,但不会请求任何人。超过 API 文件上限的 PR 会失败,并且不会请求不完整的 owner 集合。
+60
View File
@@ -0,0 +1,60 @@
# Custom static-scanner input. Its nested path keeps GitHub from loading it as
# the repository's native CODEOWNERS file.
/apps/cli/ @turtle1999
/apps/web/ @imccyu
/docs/ @turtle1999
/native/ @mektpoy
/patches/ @mektpoy
/python/ @LegGasai
/scripts/ @turtle1999
/vendor/ @turtle1999
/website/ @LegGasai
/packages/acp/ @mektpoy
/packages/api/ @imccyu
/packages/attachment/ @CreatixChu
/packages/boot/ @turtle1999
/packages/bundle/ @turtle1999
/packages/client/ @imccyu
/packages/code-runtime/ @Chinesezjc
/packages/compaction/ @imccyu
/packages/context/ @turtle1999
/packages/core/ @tianyicui @turtle1999 @mektpoy
/packages/credentials/ @mektpoy
/packages/e2b/ @mektpoy
/packages/experimental/ @mektpoy
/packages/extensions/ @mektpoy
/packages/feedback/ @mektpoy
/packages/fs/ @mektpoy
/packages/goal/ @mektpoy
/packages/guard/ @turtle1999
/packages/hooks/ @mektpoy
/packages/host/ @turtle1999
/packages/identity/ @imccyu
/packages/interaction/ @imccyu
/packages/jobs/ @imccyu
/packages/llm/ @LegGasai
/packages/lsp/ @mektpoy
/packages/mcp/ @mektpoy
/packages/plan/ @mektpoy
/packages/preset/ @LegGasai @turtle1999
/packages/runtime-diagnostics/ @mektpoy
/packages/sandbox/ @mektpoy
/packages/schedule/ @imccyu
/packages/sdk/ @mektpoy
/packages/session/ @tianyicui @turtle1999 @mektpoy
/packages/session-query/ @mektpoy
/packages/settings/ @mektpoy
/packages/shell/ @mektpoy
/packages/skill/ @mektpoy
/packages/spill/ @mektpoy
/packages/storage/ @imccyu
/packages/subagent/ @Dudu-0223
/packages/subprocess/ @mektpoy
/packages/terminal/ @imccyu
/packages/todo/ @mektpoy
/packages/typert/ @imccyu
/packages/util/ @mektpoy
/packages/web/ @imccyu
/packages/webhook/ @mektpoy
/packages/workflow/ @mektpoy
/packages/workspace/ @imccyu
@@ -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 .github/review-ownership/README.md
README.md: 95dd52595bacb9b2b00bc475966f328b06098a14
README.zh.md: a20a5f4ae41b5d0418601afa853ac2fbc141cc96
+53
View File
@@ -0,0 +1,53 @@
# Automated review requests
English | [中文](README.zh.md)
## Summary
The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It prints the complete changed non-test file list, matches those files to owners, and then requests the missing reviewers. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly.
## Table of Contents
- [Routing](#routing)
- [Test exclusion](#test-exclusion)
- [Security](#security)
- [Verification](#verification)
- [Dev Note](#dev-note)
<a id="routing"></a>
## Routing
Non-draft pull requests run the workflow when opened, synchronized, reopened, or marked ready for review. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API.
The ownership map accepts explicit absolute directory patterns and individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints `Changed code files`, `Excluded test files`, `Owners by changed file`, and `Reviewers to request` before it sends the review request. Unmatched files remain visible in the log. The pull-request author and users who are already requested are omitted.
The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
<a id="test-exclusion"></a>
## Test exclusion
Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files.
Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name.
<a id="security"></a>
## Security
The write-capable `pull_request_target` job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Pull-request filenames are treated as API data and escaped in logs.
Ownership changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the routing program or its owner assignments for its own run.
<a id="verification"></a>
## Verification
Run `pnpm run test:request-review` for ownership parsing, test classification, pagination, logging order, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
<a id="dev-note"></a>
## Dev Note
The [review-routing decision](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) records the security model, test exclusions, and alternatives.
+53
View File
@@ -0,0 +1,53 @@
# 自动请求代码评审
[English](README.md) | 中文
## 概要
[`request-review` workflow](../workflows/request-review.yml) 从受信任的默认分支读取兼容 CODEOWNERS 格式的[所有权映射](CODEOWNERS)。它先打印完整的非测试变更文件列表,再将这些文件与 owner 匹配,最后请求尚未加入的评审者。所有权映射不在 GitHub 原生 CODEOWNERS 路径中,因此 GitHub 不会直接应用它。
## 目录
- [路由](#routing)
- [排除测试](#test-exclusion)
- [安全性](#security)
- [验证](#verification)
- [开发说明](#dev-note)
<a id="routing"></a>
## 路由
非草稿 PR 在创建、同步、重新打开或标记为可评审时运行该 workflow。扫描器获取完整的 PR 文件列表,分别检查重命名前后的路径;如果只能取得部分列表,则停止执行,不发出评审请求。GitHub 对此 API 最多公开 3,000 个文件。
所有权映射只接受显式绝对目录模式和 GitHub 个人用户。通配符、隐藏目录模式、团队,以及重复的模式或 owner 都会被拒绝。匹配遵循 CODEOWNERS 的最后一条匹配规则。扫描器先打印 `Changed code files`、`Excluded test files`、`Owners by changed file` 和 `Reviewers to request`,再发送评审请求。未匹配的文件仍显示在日志中。PR 作者和已经收到评审请求的用户会被排除。
策略测试会统计已匹配目录下的非测试跟踪文件行数,并要求 `@turtle1999` 拥有的有效代码库不超过三分之一。
<a id="test-exclusion"></a>
## 排除测试
评审路由会排除仓库中的单元测试、端到端测试、预期输出、快照、基准测试、性能测试、压力测试、语料测试、原生测试和 Python 测试约定。其中包括 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 和 `stress-tests` 目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,可识别的测试文件名后缀,以及 Python 的 `test_*.py` 或 `*_test.py` 文件。
能够改变证据生成方式的测试基础设施仍需评审,包括 `vitest*.config.ts` 和 `scripts` 下的门禁实现。生产文件不会仅因名称为 `test.ts`、`spec.ts` 或 `snapshot.ts` 而被排除。
<a id="security"></a>
## 安全性
具备写权限的 `pull_request_target` job 只检出仓库默认分支。它不会检出或执行 PR 代码,也不使用仓库 secret。PR 文件名仅作为 API 数据处理,并在日志中转义。
所有权变更只有合并到默认分支后才会生效。这可以防止不受信任的 PR 为自身的 workflow 运行修改路由程序或 owner 分配。
<a id="verification"></a>
## 验证
运行 `pnpm run test:request-review` 可检查所有权解析、测试分类、分页、日志顺序、评审者过滤和 API 行为。[Workflow 测试](../../scripts/ci-workflow.spec.ts)固定受信任检出、权限、事件和命令。仓库门禁图会在 CI 中运行这两类检查。
<a id="dev-note"></a>
## 开发说明
[评审路由决策](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md)记录了安全模型、测试排除规则和备选方案。
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const API_VERSION = '2026-03-10'
const MAX_PULL_REQUEST_FILES = 3_000
const PAGE_SIZE = 100
const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests'])
const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u
const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u
/**
* Parse the explicit directory subset accepted from the review ownership file.
* @param {string} source CODEOWNERS-compatible source text.
* @returns {Array<{pattern: string, prefix: string, owners: string[]}>} Ordered ownership rules.
*/
export function parseOwnership(source) {
const rules = []
const patterns = new Set()
for (const [index, rawLine] of source.split('\n').entries()) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const [pattern, ...owners] = line.split(/\s+/u)
const location = `ownership line ${index + 1}`
if (!/^\/[^*?[\]#!\\]+\/$/u.test(pattern)) {
throw new Error(`${location}: expected one explicit absolute directory pattern`)
}
if (pattern.startsWith('/.')) throw new Error(`${location}: hidden-directory patterns are not allowed`)
if (patterns.has(pattern)) throw new Error(`${location}: duplicate pattern ${JSON.stringify(pattern)}`)
if (owners.length === 0) throw new Error(`${location}: expected at least one owner`)
const normalizedOwners = []
const seenOwners = new Set()
for (const owner of owners) {
if (!/^@[A-Za-z0-9-]+$/u.test(owner)) {
throw new Error(`${location}: only individual GitHub users are supported`)
}
const key = owner.toLowerCase()
if (seenOwners.has(key)) throw new Error(`${location}: duplicate owner ${owner}`)
seenOwners.add(key)
normalizedOwners.push(owner)
}
patterns.add(pattern)
rules.push({ pattern, prefix: pattern.slice(1), owners: normalizedOwners })
}
if (rules.length === 0) throw new Error('ownership file contains no rules')
return rules
}
/**
* Normalize a repository-relative path received from GitHub.
* @param {unknown} value GitHub file path.
* @returns {string} Slash-normalized repository path.
*/
export function normalizeRepositoryPath(value) {
if (typeof value !== 'string' || value.length === 0) throw new Error('changed file has no path')
const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '')
if (
normalized.startsWith('/')
|| normalized.includes('\0')
|| normalized.split('/').some(segment => !segment || segment === '.' || segment === '..')
) {
throw new Error(`invalid repository path ${JSON.stringify(value)}`)
}
return normalized
}
/**
* Decide whether a repository path belongs only to test evidence or test support.
* @param {string} value Repository-relative path.
* @returns {boolean} Whether reviewer routing must ignore the path.
*/
export function isTestPath(value) {
const file = normalizeRepositoryPath(value)
const segments = file.split('/')
if (segments[0] === 'benchmarks' || segments[0] === 'snapshots') return true
if (segments[0] === 'packages' && segments[1] === 'test-support') return true
if (segments[0] === 'scripts' && (segments[1] === 'fixtures' || segments[1] === 'snapshots')) return true
if (segments.some(segment => TEST_DIRECTORY_NAMES.has(segment))) return true
const basename = segments.at(-1) ?? ''
return TEST_FILE_MARKER.test(basename) || PYTHON_TEST_FILE.test(basename)
}
/**
* Expand changed-file records into reviewable and excluded repository paths.
* @param {unknown[]} files Pull-request file records from GitHub.
* @returns {{changedCodeFiles: string[], excludedTestFiles: string[]}} Classified paths.
*/
export function classifyChangedFiles(files) {
const changedCodeFiles = new Set()
const excludedTestFiles = new Set()
for (const entry of files) {
if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry')
const paths = [normalizeRepositoryPath(entry.filename)]
if (entry.previous_filename !== undefined) {
paths.unshift(normalizeRepositoryPath(entry.previous_filename))
}
for (const file of paths) {
if (isTestPath(file)) excludedTestFiles.add(file)
else changedCodeFiles.add(file)
}
}
return {
changedCodeFiles: [...changedCodeFiles].sort(),
excludedTestFiles: [...excludedTestFiles].sort(),
}
}
/**
* Match changed paths to owners with CODEOWNERS last-match semantics.
* @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules.
* @param {string[]} changedCodeFiles Reviewable repository paths.
* @returns {{matches: Array<{file: string, owners: string[]}>, reviewers: string[]}} Routing plan.
*/
export function planReviewers(rules, changedCodeFiles) {
const matches = []
const reviewers = new Map()
for (const file of changedCodeFiles) {
let owners = []
for (const rule of rules) {
if (file.startsWith(rule.prefix)) owners = rule.owners
}
matches.push({ file, owners })
for (const owner of owners) reviewers.set(owner.toLowerCase(), owner.slice(1))
}
return {
matches,
reviewers: [...reviewers.values()].sort((left, right) => left.localeCompare(right, 'en')),
}
}
/**
* Create a repository-scoped GitHub JSON API caller.
* @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies.
* @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} API caller.
*/
export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) {
if (!token) throw new Error('GITHUB_TOKEN is not set')
if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
const root = apiUrl.replace(/\/+$/u, '')
return async (path, { method = 'GET', body } = {}) => {
const response = await fetchImpl(`${root}${path}`, {
method,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'User-Agent': 'deepseek-harness-request-review',
'X-GitHub-Api-Version': API_VERSION,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
if (!response.ok) {
const responseBody = await response.text()
throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`)
}
if (response.status === 204) return undefined
return response.json()
}
}
/**
* Fetch the complete pull-request file list or fail before routing a partial list.
* @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
* @param {string} repository Owner/name repository identifier.
* @param {number} pullNumber Pull-request number.
* @param {number} expectedCount Pull-request changed-file count.
* @returns {Promise<unknown[]>} Complete changed-file records.
*/
export async function listPullRequestFiles(api, repository, pullNumber, expectedCount) {
if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) {
throw new Error('pull request changed_files must be a non-negative integer')
}
if (expectedCount > MAX_PULL_REQUEST_FILES) {
throw new Error(`pull request has ${expectedCount} files; GitHub exposes at most ${MAX_PULL_REQUEST_FILES}`)
}
const files = []
for (let page = 1; files.length < expectedCount; page++) {
const response = await api(`/repos/${repository}/pulls/${pullNumber}/files?per_page=${PAGE_SIZE}&page=${page}`)
if (!Array.isArray(response) || response.length === 0) {
throw new Error(`GitHub returned ${files.length} of ${expectedCount} changed files`)
}
files.push(...response)
if (files.length > expectedCount) {
throw new Error(`GitHub returned ${files.length} files but the pull request reports ${expectedCount}`)
}
}
return files
}
/**
* Print changed paths, route owners, and request every missing eligible reviewer.
* @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, write?: (line: string) => void}} options Runtime inputs.
* @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], requestedReviewers: string[]}>} Applied routing result.
*/
export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) {
const pull = pullRequestFromEvent(event)
write('This is by automated Angry Turtle Cyborg, not a human')
if (pull.draft) {
write('Draft pull request; reviewer routing is deferred until ready_for_review.')
return { changedCodeFiles: [], excludedTestFiles: [], requestedReviewers: [] }
}
const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount)
const classified = classifyChangedFiles(files)
const plan = planReviewers(parseOwnership(ownershipSource), classified.changedCodeFiles)
writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file)))
writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file)))
writeList(
write,
'Owners by changed file',
plan.matches.map(({ file, owners }) => `${JSON.stringify(file)}: ${owners.length ? owners.join(' ') : '(none)'}`),
)
const candidates = plan.reviewers.filter(login => login.toLowerCase() !== pull.author.toLowerCase())
if (candidates.length === 0) {
writeList(write, 'Reviewers to request', [])
return { ...classified, requestedReviewers: [] }
}
const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
if (!isRecord(existing) || !Array.isArray(existing.users)) {
throw new Error('requested-reviewers response has no users array')
}
const alreadyRequested = new Set(existing.users.map((user) => {
if (!isRecord(user) || typeof user.login !== 'string') {
throw new Error('requested-reviewers response contains an invalid user')
}
return user.login.toLowerCase()
}))
const reviewers = candidates.filter(login => !alreadyRequested.has(login.toLowerCase()))
writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`))
if (reviewers.length === 0) return { ...classified, requestedReviewers: [] }
await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
method: 'POST',
body: { reviewers },
})
write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`)
return { ...classified, requestedReviewers: reviewers }
}
function pullRequestFromEvent(event) {
if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string') {
throw new Error('event has no repository.full_name')
}
if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)) {
throw new Error('event has no pull_request')
}
const { pull_request: pull } = event
if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number')
if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag')
if (typeof pull.user.login !== 'string' || !pull.user.login) throw new Error('pull request has no author login')
return {
repository: event.repository.full_name,
number: pull.number,
draft: pull.draft,
author: pull.user.login,
changedFileCount: pull.changed_files,
}
}
function writeList(write, title, entries) {
write(`${title}:`)
if (entries.length === 0) write('- (none)')
else for (const entry of entries) write(`- ${entry}`)
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function main() {
const eventPath = process.env.GITHUB_EVENT_PATH
if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set')
const event = JSON.parse(readFileSync(eventPath, 'utf8'))
const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
const api = createGitHubApi({
token: process.env.GITHUB_TOKEN ?? '',
apiUrl: process.env.GITHUB_API_URL,
})
await requestReviews({ event, ownershipSource, api })
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`request-review failed: ${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})
}
@@ -0,0 +1,335 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
classifyChangedFiles,
createGitHubApi,
isTestPath,
listPullRequestFiles,
normalizeRepositoryPath,
parseOwnership,
planReviewers,
requestReviews,
} from './request-review.mjs'
const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false } = {}) => ({
repository: { full_name: 'deepseek-harness/deepseek-harness' },
pull_request: {
number: 42,
draft,
changed_files: changedFiles,
user: { login: author },
},
})
test('loads the repository ownership policy without test-only directory rules', () => {
const rules = parseOwnership(ownershipSource)
const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners]))
assert.equal(rules.length, 58)
assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false)
assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false)
assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false)
assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999'])
assert.deepEqual(ownersByPattern.get('/docs/'), ['@turtle1999'])
assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@tianyicui', '@turtle1999', '@mektpoy'])
assert.deepEqual(ownersByPattern.get('/packages/llm/'), ['@LegGasai'])
assert.deepEqual(ownersByPattern.get('/packages/preset/'), ['@LegGasai', '@turtle1999'])
assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@tianyicui', '@turtle1999', '@mektpoy'])
assert.deepEqual(ownersByPattern.get('/packages/subagent/'), ['@Dudu-0223'])
assert.deepEqual(ownersByPattern.get('/packages/web/'), ['@imccyu'])
assert.deepEqual(ownersByPattern.get('/python/'), ['@LegGasai'])
assert.deepEqual(ownersByPattern.get('/website/'), ['@LegGasai'])
assert.deepEqual(
rules.filter(rule => rule.owners.includes('@tianyicui')).map(rule => rule.pattern),
['/packages/core/', '/packages/session/'],
)
for (const excludedOwner of ['@kermeanx', '@pkh-xht']) {
assert.equal(rules.some(rule => rule.owners.some(owner => owner.toLowerCase() === excludedOwner)), false)
}
})
test('keeps turtle below one third of the eligible owned codebase', () => {
const rules = parseOwnership(ownershipSource)
const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' }).split('\0').filter(Boolean)
let ownedLines = 0
let turtleLines = 0
for (const file of trackedFiles) {
if (isTestPath(file)) continue
const owners = planReviewers(rules, [file]).matches[0]?.owners ?? []
if (owners.length === 0) continue
const content = readFileSync(file)
const lines = content.length === 0
? 0
: content.reduce((count, byte) => count + (byte === 10 ? 1 : 0), 0) + (content.at(-1) === 10 ? 0 : 1)
ownedLines += lines
if (owners.includes('@turtle1999')) turtleLines += lines
}
assert.ok(
turtleLines * 3 <= ownedLines,
`@turtle1999 owns ${turtleLines} of ${ownedLines} eligible owned lines`,
)
})
test('rejects ownership forms the requester cannot apply safely', () => {
for (const [source, message] of [
['', /contains no rules/u],
['* @owner\n', /explicit absolute directory/u],
['/.github/ @owner\n', /hidden-directory/u],
['/packages/*/ @owner\n', /explicit absolute directory/u],
['/packages/core/\n', /at least one owner/u],
['/packages/core/ @org/team\n', /individual GitHub users/u],
['/packages/core/ @owner @OWNER\n', /duplicate owner/u],
['/packages/core/ @owner\n/packages/core/ @other\n', /duplicate pattern/u],
]) {
assert.throws(() => parseOwnership(source), message)
}
})
test('recognizes every repository test location and filename convention', () => {
for (const file of [
'apps/cli/tests/args.spec.ts',
'apps/cli/tests/harness.ts',
'apps/web/stress-tests/reasoning-chunks.stress.ts',
'benchmarks/session-open/workload.ts',
'native/landlock-run/test/entry.test.js',
'packages/core/agent/__tests__/agent.ts',
'packages/core/agent/benches/agent.rs',
'packages/core/agent/src/agent.compat.spec.ts',
'packages/core/agent/src/__snapshots__/agent.ts.snap',
'packages/session-query/session-query/tests/test-service.ts',
'packages/test-support/session-snapshot/src/index.ts',
'python/sdk/src/test_client.py',
'python/sdk/src/client_test.py',
'scripts/fixtures/translation-prompt/response.txt',
'scripts/session-snapshot-corpus.corpus.ts',
'scripts/snapshots/translation-prompt-v4/request-response.expected.json',
'snapshots/session/headless.snapshot.ts',
]) {
assert.equal(isTestPath(file), true, file)
}
})
test('does not confuse production names with tests', () => {
for (const file of [
'apps/cli/src/testing.ts',
'packages/core/agent/src/contest.ts',
'packages/session/session-format/src/snapshot.ts',
'packages/session/session-format/src/spec.ts',
'packages/session/session-format/src/test.ts',
'scripts/run-gates.ts',
'vitest.config.ts',
'vitest.bench.config.ts',
'vitest.e2e.config.ts',
'vitest.snapshot.config.ts',
'vitest.web.perf.config.ts',
'website/docs.ts',
]) {
assert.equal(isTestPath(file), false, file)
}
})
test('normalizes separators and rejects paths that are not repository-relative', () => {
assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts')
for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) {
assert.throws(() => normalizeRepositoryPath(file), /path/u, file)
}
})
test('classifies both sides of a rename independently', () => {
assert.deepEqual(
classifyChangedFiles([
{
filename: 'packages/core/agent/tests/moved.spec.ts',
previous_filename: 'packages/core/agent/src/moved.ts',
},
{
filename: 'packages/client/store/src/restored.ts',
previous_filename: 'packages/client/store/tests/restored.spec.ts',
},
]),
{
changedCodeFiles: [
'packages/client/store/src/restored.ts',
'packages/core/agent/src/moved.ts',
],
excludedTestFiles: [
'packages/client/store/tests/restored.spec.ts',
'packages/core/agent/tests/moved.spec.ts',
],
},
)
})
test('uses the last matching ownership rule and keeps unmatched files visible', () => {
const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n')
assert.deepEqual(
planReviewers(rules, ['AGENTS.md', 'packages/core/agent/src/index.ts', 'packages/fs/fs/src/index.ts']),
{
matches: [
{ file: 'AGENTS.md', owners: [] },
{ file: 'packages/core/agent/src/index.ts', owners: ['@core', '@second'] },
{ file: 'packages/fs/fs/src/index.ts', owners: ['@broad'] },
],
reviewers: ['broad', 'core', 'second'],
},
)
})
test('fetches every declared changed file across pages', async () => {
const calls = []
const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` }))
const pageTwo = [{ filename: 'packages/core/file-100.ts' }]
const api = async (path) => {
calls.push(path)
return calls.length === 1 ? pageOne : pageTwo
}
const files = await listPullRequestFiles(api, 'owner/repo', 42, 101)
assert.equal(files.length, 101)
assert.deepEqual(calls, [
'/repos/owner/repo/pulls/42/files?per_page=100&page=1',
'/repos/owner/repo/pulls/42/files?per_page=100&page=2',
])
})
test('fails closed when GitHub cannot provide the complete file list', async () => {
let calls = 0
await assert.rejects(
listPullRequestFiles(async () => {
calls++
return calls === 1 ? [{ filename: 'one.ts' }] : []
}, 'owner/repo', 42, 2),
/returned 1 of 2/u,
)
await assert.rejects(
listPullRequestFiles(async () => [], 'owner/repo', 42, 3_001),
/at most 3000/u,
)
})
test('prints changed code files before requesting missing owners', async () => {
const trace = []
const files = [
{ filename: 'packages/core/agent/src/index.ts' },
{ filename: 'packages/preset/agent-presets/src/index.ts' },
{ filename: 'packages/client/store/src/index.ts' },
{ filename: 'packages/subagent/subagent/src/index.ts' },
{ filename: 'packages/core/agent/tests/index.spec.ts' },
{ filename: 'AGENTS.md' },
]
const api = async (path, options = {}) => {
trace.push({ type: 'api', path, options })
if (path.endsWith('/files?per_page=100&page=1')) return files
if (path.endsWith('/requested_reviewers') && options.method !== 'POST') {
return { users: [{ login: 'imccyu' }], teams: [] }
}
if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
throw new Error(`unexpected API path ${path}`)
}
const result = await requestReviews({
event: pullRequestEvent({ author: 'turtle1999', changedFiles: files.length }),
ownershipSource,
api,
write: line => trace.push({ type: 'log', line }),
})
assert.deepEqual(result, {
changedCodeFiles: [
'AGENTS.md',
'packages/client/store/src/index.ts',
'packages/core/agent/src/index.ts',
'packages/preset/agent-presets/src/index.ts',
'packages/subagent/subagent/src/index.ts',
],
excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'],
requestedReviewers: ['Dudu-0223', 'LegGasai', 'mektpoy', 'tianyicui'],
})
assert.equal(trace[0].type, 'log')
assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human')
const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:')
const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST')
assert.ok(changedHeading >= 0 && changedHeading < post)
assert.deepEqual(trace[post], {
type: 'api',
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
options: {
method: 'POST',
body: { reviewers: ['Dudu-0223', 'LegGasai', 'mektpoy', 'tianyicui'] },
},
})
})
test('does not request reviewers for a test-only change', async () => {
const calls = []
const output = []
const files = [
{ filename: 'apps/web/tests/chat.e2e.ts' },
{ filename: 'packages/core/agent/tests/agent.spec.ts' },
]
const result = await requestReviews({
event: pullRequestEvent({ changedFiles: files.length }),
ownershipSource,
api: async (path) => {
calls.push(path)
return files
},
write: line => output.push(line),
})
assert.deepEqual(result, {
changedCodeFiles: [],
excludedTestFiles: files.map(file => file.filename),
requestedReviewers: [],
})
assert.equal(calls.length, 1)
assert.deepEqual(output.slice(0, 4), [
'This is by automated Angry Turtle Cyborg, not a human',
'Changed code files:',
'- (none)',
'Excluded test files:',
])
})
test('defers draft pull requests without reading changed files', async () => {
const output = []
const result = await requestReviews({
event: pullRequestEvent({ draft: true }),
ownershipSource,
api: async () => assert.fail('draft routing must not call GitHub'),
write: line => output.push(line),
})
assert.deepEqual(result, { changedCodeFiles: [], excludedTestFiles: [], requestedReviewers: [] })
assert.deepEqual(output, [
'This is by automated Angry Turtle Cyborg, not a human',
'Draft pull request; reviewer routing is deferred until ready_for_review.',
])
})
test('sends authenticated JSON and escapes an API error body', async () => {
const requests = []
const api = createGitHubApi({
token: 'secret',
apiUrl: 'https://github.example/api/v3/',
fetchImpl: async (url, options) => {
requests.push({ url, options })
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
},
})
assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true })
assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo')
assert.equal(requests[0].options.headers.Authorization, 'Bearer secret')
assert.equal(requests[0].options.headers['X-GitHub-Api-Version'], '2026-03-10')
assert.equal(requests[0].options.body, '{"value":1}')
const failing = createGitHubApi({
token: 'secret',
fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }),
})
await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
})
+32
View File
@@ -0,0 +1,32 @@
name: request-review
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: write
concurrency:
group: request-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
request-review:
name: request-review
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# SECURITY: the write-capable job executes policy from the trusted default
# branch and reads pull-request filenames only as API data.
- name: Check out trusted review policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Request reviewers
env:
GITHUB_TOKEN: ${{ github.token }}
run: node .github/review-ownership/request-review.mjs
+2 -1
View File
@@ -54,6 +54,7 @@
"typescript": "^6.0.3",
"vite": "^6.0.0",
"vitest": "^4.1.8",
"ws": "8.21.0"
"ws": "8.21.0",
"@deepseek-ai/dsh-launch-environment": "workspace:^"
}
}
+73
View File
@@ -0,0 +1,73 @@
/** SSH launch behavior over a recorded conversation and the shipped Web plugin rows. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/open-in-app-ssh', import.meta.url))
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
const SEED_ID = 'open-in-app-ssh-web-e2e'
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
openInAppEnvironment: createLaunchEnvironmentSnapshot([
{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } },
]),
})
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.addInitScript(() => {
localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode'))
})
})
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length > 0) throw new AggregateError(failures, 'Open In SSH scenario teardown failed')
})
it('hides a remembered app after the real host returns an empty catalog', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-open-in-app-ssh'))
const [response] = await Promise.all([
page.waitForResponse(response => new URL(response.url()).pathname === '/open-in-app/apps'),
(async () => {
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const group = page.getByRole('treeitem').first()
await group.waitFor()
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
await page.getByRole('treeitem').nth(1).click()
await page.getByText('DONE', { exact: true }).waitFor()
})(),
])
expect(response.status()).toBe(200)
expect(await response.json()).toEqual({ apps: [] })
expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0)
expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0)
expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"')
const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md'])
})
})
+8 -7
View File
@@ -31,6 +31,7 @@ import { pathToFileURL } from 'node:url'
import type { Page } from 'playwright'
import { expect } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import Group from '@deepseek-ai/cordis-plugin-group'
@@ -284,6 +285,8 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/** Enable the real Open In rows with deterministic launch-environment facts. */
openInAppEnvironment?: LaunchEnvironmentSnapshot
/** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */
compareReplaySession?: boolean
/**
@@ -600,13 +603,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
{ id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
] },
// The open-in-app header button reflects the host application probe —
// whatever editors and terminals the RUNNING machine has installed — so
// its presence and label would vary per host and platform. Pin both rows
// off (routes and surface); the packages' own composition and jsdom tests
// cover the button.
{ id: 'open-in-app', disabled: true },
{ id: 'ui-open-in-app', disabled: true },
// Ordinary scenarios exclude host-dependent application discovery. The
// Open In scenario supplies launch facts that suppress every native probe.
{ id: 'open-in-app', disabled: options.openInAppEnvironment === undefined },
{ id: 'ui-open-in-app', disabled: options.openInAppEnvironment === undefined },
...options.agentPresets === undefined
? []
// Never the derived harness-home root: a developer's own presets must not
@@ -638,6 +638,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// the temp workspace so tool cwd, session cwd, and fixtures agree.
const originalCwd = process.cwd()
const ctx = new Context()
if (options.openInAppEnvironment !== undefined) ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.openInAppEnvironment)
const observedSessions = new Map<SessionId, Session>()
const stopObservingSessions = ctx.on('session/created', (session) => {
observedSessions.set(session.id, session)
+1
View File
@@ -65,6 +65,7 @@
"tests/web-search-round.e2e.ts",
"tests/file-upload-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/open-in-app-ssh.e2e.ts",
"tests/message-feedback.e2e.ts",
"tests/message-feedback-layout.e2e.ts",
"tests/markdown-images.e2e.ts",
+2 -2
View File
@@ -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: 1c503fd265a2c7632e33d9c660cdf640741e3ffe
config-catalog.zh.md: 0baa379383ea6238193f7b88e7ed929614e5270e
config-catalog.md: 650e9cccb957c0cc1be99b9be0b9adc0e914fd03
config-catalog.zh.md: 77afdf9118eb4030cf4f66b6b39429bf5c75a85e
+1 -1
View File
@@ -966,7 +966,7 @@ export interface Config {
}
```
Source: [`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts)
Source: [`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
+1 -1
View File
@@ -970,7 +970,7 @@ export interface Config {
}
```
来源:[`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts)
来源:[`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
+1
View File
@@ -57,6 +57,7 @@
"test:expected": "vitest run --config vitest.expected.config.ts",
"test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
"test:issue-management": "node .github/issue-management/policy.test.mjs",
"test:request-review": "node --test .github/review-ownership/request-review.test.mjs",
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
"test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
+2 -11
View File
@@ -21,7 +21,7 @@ import z from '@deepseek-ai/schemastery'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import { launchedThroughSsh, 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'
@@ -81,15 +81,6 @@ 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 = `
@@ -235,7 +226,7 @@ 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)
const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(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() })
@@ -17,7 +17,9 @@ function latestLine(text: string): string {
}
/**
* Render one assistant reasoning block as the Think disclosure row.
* Render one assistant reasoning block as the Think disclosure row. The
* collapsed summary omits double-asterisk markers; expanded content preserves
* the complete text.
* @param props.text - complete or streaming reasoning text.
* @param props.running - whether this block is the streaming tail.
* @param props.t - conversation locale seat for the running status.
@@ -25,7 +27,7 @@ function latestLine(text: string): string {
*/
export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
const [expanded, setExpanded] = useState(false)
const summary = running ? latestLine(text) : firstLine(text)
const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
return (
<div
@@ -70,6 +70,34 @@ describe('ReasoningRow', () => {
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it.each([
{
label: 'settled',
text: '**Comparing checkout and merge bases**\nKeep **reviewing**',
streaming: false,
},
{
label: 'streaming',
text: 'Inspect the session\n**Comparing checkout and merge bases**',
streaming: true,
},
])('strips double-asterisk markers from the $label summary without changing the reasoning body', ({ text, streaming }) => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text }]}
streaming={streaming}
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy()
expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull()
fireEvent.click(view.getByText('思考'))
expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text)
})
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
const view = render(
<AssistantMarkdown
@@ -65,6 +65,7 @@ describe('OpenInAppAction visibility', () => {
for (const over of [
{ apps: null, cwd: '/w' },
{ apps: [], cwd: '/w' },
{ apps: [], choice: 'vscode', cwd: '/w' },
{ apps: ['finder'] },
{ apps: ['finder'], cwd: '' },
{ apps: ['someday-an-app'], cwd: '/w' },
@@ -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/host/directory-picker-auto/README.md
README.md: b4d1c44c84c555f92a42e277571e2f01fc6ae7ee
README.zh.md: 39eeeb0ff8ebec8757be52c9029e6d05688be8f2
README.md: 49079bba2b0e3597b9a560dd39b394b081b2f904
README.zh.md: c09dd158db40d697453cd06f48e327d627f4b3fe
@@ -29,7 +29,7 @@ Compose this plugin instead of a concrete backend when the same composition must
### How the choice is made
`native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `webServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank), and a servable display session — assumed on darwin and win32; on linux, `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH`; never on any other platform. Anything ambiguous resolves to `browse`, which works everywhere.
`native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `webServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (the shared [launch-environment](../../util/launch-environment/README.md) predicate ignores project/user `.env` values and checks only inherited non-empty `SSH_CONNECTION`/`SSH_TTY`), and a servable display session — assumed on darwin and win32; on linux, `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH`; never on any other platform. Anything ambiguous resolves to `browse`, which works everywhere.
### What you get
@@ -29,7 +29,7 @@ kind: "package-reference"
### 选择是如何作出的
`native` 要求「操作者看得到宿主屏幕、且原生后端能服务它」的全部信号:仅回环的绑定(从注入的 `webServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空);以及可服务的显示会话——darwin 与 win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制;其余任何平台上都不成立。任何含糊情形都判定为处处可用的 `browse`。
`native` 要求「操作者看得到宿主屏幕、且原生后端能服务它」的全部信号:仅回环的绑定(从注入的 `webServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(共用的 [launch-environment](../../util/launch-environment/README.zh.md) 判断忽略项目与用户 `.env` 中的值,只检查继承的非空 `SSH_CONNECTION`/`SSH_TTY`);以及可服务的显示会话——darwin 与 win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制;其余任何平台上都不成立。任何含糊情形都判定为处处可用的 `browse`。
### 你会得到什么
@@ -45,5 +45,8 @@
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^"
},
"dependencies": {
"@deepseek-ai/dsh-launch-environment": "workspace:^"
}
}
@@ -15,6 +15,7 @@ import type { Context } from '@deepseek-ai/cordis'
// Empty type imports carry the `loader` and `webServer` Context merges for the reads below.
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
import type { DirectoryPickerBackendKind } from './resolve.ts'
import { resolveDirectoryPickerBackend } from './resolve.ts'
@@ -62,6 +63,7 @@ export async function apply(ctx: Context): Promise<void> {
const backend = resolveDirectoryPickerBackend({
bindHost: ctx.webServer.host,
platform: process.platform,
ssh: launchedThroughSsh(launchEnvironmentOf(ctx)),
env: process.env,
linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute),
})
@@ -13,7 +13,7 @@ export type DirectoryPickerBackendKind = 'native' | 'browse'
/** Environment keys the resolution reads (a `process.env` subset). */
export type DirectoryPickerEnv = Readonly<
Partial<Record<'SSH_CONNECTION' | 'SSH_TTY' | 'DISPLAY' | 'WAYLAND_DISPLAY', string>>
Partial<Record<'DISPLAY' | 'WAYLAND_DISPLAY', string>>
>
/** Host facts the backend choice is a pure function of, sampled once at boot. */
@@ -22,7 +22,9 @@ export interface DirectoryPickerHostFacts {
bindHost: HttpServerConfig['host']
/** Host process platform. */
platform: NodeJS.Platform
/** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */
/** SSH launch fact from the inherited process layer, independent of `.env` values. */
ssh: boolean
/** Environment sample; DISPLAY/WAYLAND_DISPLAY marks a Linux display. */
env: DirectoryPickerEnv
/** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */
linuxChooser: boolean
@@ -46,7 +48,7 @@ const present = (value: string | undefined): boolean => value !== undefined && v
*/
export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind {
if (facts.bindHost !== '127.0.0.1') return 'browse'
if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse'
if (facts.ssh) return 'browse'
if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse'
@@ -20,6 +20,9 @@ import HttpServer from '@deepseek-ai/dsh-host-webserver'
import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'
import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
import {
createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot,
} from '@deepseek-ai/dsh-launch-environment'
import * as DirectoryPickerAuto from '../src/index.ts'
const renameControl = vi.hoisted(() => ({
@@ -90,7 +93,7 @@ afterEach(async () => {
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
async function loadComposition(
bindHost: '127.0.0.1' | '0.0.0.0',
options: { failSurface?: boolean } = {},
options: { failSurface?: boolean; launchEnvironment?: LaunchEnvironmentSnapshot } = {},
): Promise<{ ctx: Context; configPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
const configPath = join(root, 'cordis.yml')
@@ -104,6 +107,7 @@ async function loadComposition(
].join('\n'))
context = new Context()
if (options.launchEnvironment !== undefined) context.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.launchEnvironment)
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
@@ -163,6 +167,19 @@ function stubAttendedHost(): void {
}
describe('real Loader composition', () => {
it.each(['project-env', 'user-env'] as const)('keeps the native backend with materialized SSH markers from %s', async (source) => {
stubAttendedHost()
vi.stubEnv('SSH_CONNECTION', 'stale-connection')
vi.stubEnv('SSH_TTY', '/dev/pts/stale')
const launchEnvironment = createLaunchEnvironmentSnapshot([
{ source, values: { SSH_CONNECTION: 'stale-connection', SSH_TTY: '/dev/pts/stale' } },
])
const { ctx } = await loadComposition('127.0.0.1', { launchEnvironment })
expect(ctx.get('directoryPicker')?.capability().kind).toBe('native')
expect(entryNames(ctx)).toContain(NATIVE_SURFACE)
expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE)
})
// The 60s budget covers this file's static imports (webserver plus both
// backend node halves through tsx), which dominate on cold caches; the
// Loader itself resolves nothing here — `loader.internal` is a module map.
@@ -10,6 +10,7 @@ import type { DirectoryPickerHostFacts } from '../src/resolve.ts'
const attended: DirectoryPickerHostFacts = {
bindHost: '127.0.0.1',
platform: 'darwin',
ssh: false,
env: {},
linuxChooser: false,
}
@@ -24,9 +25,8 @@ describe('resolveDirectoryPickerBackend', () => {
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse')
})
it('resolves browse under an SSH launch (either env marker)', () => {
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '10.0.0.2 55 10.0.0.9 22' } })).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse')
it('resolves browse under an SSH launch', () => {
expect(resolveDirectoryPickerBackend({ ...attended, ssh: true })).toBe('browse')
})
it('requires a display session and a chooser binary on linux', () => {
@@ -43,7 +43,6 @@ describe('resolveDirectoryPickerBackend', () => {
})
it('treats blank env exports as unset', () => {
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native')
expect(resolveDirectoryPickerBackend({
...attended, platform: 'linux', linuxChooser: true, env: { DISPLAY: '', WAYLAND_DISPLAY: '' },
})).toBe('browse')
@@ -19,6 +19,9 @@
},
{
"path": "../webserver"
},
{
"path": "../../util/launch-environment"
}
]
}
+2 -2
View File
@@ -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/host/open-in-app/README.md
README.md: d13ca42031e41c7e3eb6332e22fc4ea26e875dd9
README.zh.md: 1fafb5a9b4a7c2a5e32b95b70edfaac8b4d10242
README.md: ca18efd4025f8742350dd0fa0503cab7d5fa84ff
README.zh.md: 793df6cfe1f11efb064f90cf29ab95a77a9b7c55
+2
View File
@@ -59,6 +59,8 @@ The catalog is a fixed whitelist covering editors and IDEs (Cursor, VS Code and
### What to expect
When the inherited process layer of the [launch environment](../../util/launch-environment/README.md) contains a non-empty `SSH_CONNECTION` or `SSH_TTY`, the application list is empty and the Web header hides Open In, including any remembered choice. Project and user `.env` values do not establish an SSH launch. The host skips application probing and refuses icon and launch requests for unavailable applications. This rule also applies when an SSH session carries a display or VS Code IPC connection; it does not identify remote deployments whose launchers remove both SSH markers.
Resolution runs lazily, once per host process, on the first request that needs it; installing an application takes effect on the next restart, while an uninstalled one heals immediately — a launch that finds its executable gone re-resolves that one entry and drops it from the list when nothing proves it anymore. The icon route serves the real application icon on every platform where one is extractable: the bundle's `.icns` as a 128px PNG on macOS, the executable's associated icon as a 32px PNG on Windows, and the desktop entry's hicolor-theme icon (PNG or SVG) on Linux; a missing icon answers 404 and the browser surface renders a generic glyph.
### The `./shared` subpath
+2
View File
@@ -59,6 +59,8 @@ kind: "package-reference"
### 预期行为
[启动环境](../../util/launch-environment/README.zh.md)中继承的进程层的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时,应用列表为空,Web 头部隐藏 Open In,包括已记住的应用选择。项目与用户 `.env` 中的值不作为 SSH 启动的依据。主机跳过应用探测,并拒绝不可用应用的图标和启动请求。SSH 会话即使携带显示服务或 VS Code IPC 连接,也遵循此规则;若启动器移除了两个 SSH 标记,本规则无法识别该远端部署。
解析惰性执行,每主机进程一次,在首个需要它的请求上进行;安装应用要下次重启后生效,卸载方向则立即自愈——启动时发现可执行文件已消失会只重解析该条目一次,无法再证明时把它从列表中移除。图标路由在每个可提取的平台上提供应用真实图标:macOS 上 bundle 的 `.icns` 转 128px PNG,Windows 上可执行文件的关联图标转 32px PNG,Linux 上 desktop 条目在 hicolor 主题中的图标(PNG 或 SVG);提取不到的图标应答 404,浏览器表面渲染通用占位图形。
### `./shared` 子路径
+1
View File
@@ -36,6 +36,7 @@
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
+3
View File
@@ -26,6 +26,7 @@ import { stat } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-subprocess'
import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import z from '@deepseek-ai/schemastery'
import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from './catalog.ts'
import {
@@ -135,8 +136,10 @@ function parseOpenBody(text: string): { app: string; path: string } | null {
/** Register the apps, icon, and open routes behind the connection trust fence. */
export function apply(ctx: Context, config: Config): void {
const ssh = launchedThroughSsh(launchEnvironmentOf(ctx))
/** Test-seam facts completed with the composition's PATH resolver. */
const catalogInternals = (): OpenInAppInternals => ({
ssh,
resolveExecutable: async (name) => {
try {
return await ctx.subprocess.resolveExecutable(name)
+10 -1
View File
@@ -98,6 +98,8 @@ export const launchDetachedApp: OpenInAppLauncher = (command, args, options) =>
/** Injectable platform facts for deterministic tests. */
export interface OpenInAppInternals {
platform?: NodeJS.Platform
/** SSH launch fact from the inherited process layer, independent of `.env` values. */
ssh?: boolean
/** Bundle-directory roots replacing `/Applications` and `~/Applications`. */
applicationRoots?: readonly string[]
/** Environment for `${VAR}`/`%VAR%` expansion in candidates and registry values. */
@@ -113,6 +115,7 @@ export interface OpenInAppInternals {
/** Platform facts after the one explicit defaulting step at each public entry. */
export interface ResolvedInternals {
platform: NodeJS.Platform
ssh: boolean
applicationRoots: readonly string[]
env: Readonly<Record<string, string | undefined>>
home: string
@@ -137,6 +140,7 @@ export function resolveInternals(internals: OpenInAppInternals): ResolvedInterna
}
return {
platform: internals.platform ?? osPlatform(),
ssh: internals.ssh ?? false,
applicationRoots: internals.applicationRoots ?? ['/Applications', join(home, 'Applications')],
env: internals.env ?? process.env,
home,
@@ -617,12 +621,13 @@ async function locate(
* @param app - catalog entry.
* @param probeTimeoutMs - per-command deadline for resolution host commands.
* @param internals - platform and runner hooks for deterministic tests.
* @returns the verified launch, or null when the entry is not installed here.
* @returns the verified launch, or null during SSH launches or when the entry is not installed here.
*/
export async function resolveLaunch(
app: OpenInAppApp, probeTimeoutMs: number, internals: OpenInAppInternals = {},
): Promise<OpenInAppResolvedLaunch | null> {
const resolved = resolveInternals(internals)
if (resolved.ssh) return null
return resolveWithRegistry(app, probeTimeoutMs, new RegistryViewOnce(probeTimeoutMs, resolved), resolved)
}
@@ -648,6 +653,7 @@ async function resolveWithRegistry(
* The returned map is the mutable authority the caller owns — the routes
* serve its keys and launch from its values, and a stale entry is replaced
* or removed in place after an `ENOENT` launch.
* An SSH launch returns an empty map without probing.
* @param probeTimeoutMs - per-command deadline for resolution host commands.
* @param internals - platform and runner hooks for deterministic tests.
* @returns catalog id to verified launch, in catalog order.
@@ -656,6 +662,9 @@ export async function resolveOpenInAppApps(
probeTimeoutMs: number, internals: OpenInAppInternals = {},
): Promise<Map<string, OpenInAppResolvedLaunch>> {
const resolved = resolveInternals(internals)
if (resolved.ssh) {
return new Map()
}
const registry = new RegistryViewOnce(probeTimeoutMs, resolved)
const entries = await Promise.all(OPEN_IN_APP_CATALOG.map(async app =>
[app.id, await resolveWithRegistry(app, probeTimeoutMs, registry, resolved)] as const))
@@ -20,6 +20,9 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import WebServer from '@deepseek-ai/dsh-host-webserver'
import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
import {
createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentLayerInput,
} from '@deepseek-ai/dsh-launch-environment'
import * as OpenInApp from '../src/index.ts'
import { internals } from '../src/internals.ts'
import type { OpenInAppLauncher } from '../src/resolver.ts'
@@ -36,6 +39,7 @@ afterEach(async () => {
root = undefined
internals.catalog = {}
trust.rejection = undefined
vi.unstubAllEnvs()
})
/** PATH-resolution fake answering from a fixed name-to-path table. */
@@ -44,7 +48,8 @@ function pathTable(entries: Record<string, string> = {}): (name: string) => Prom
}
/** Boot webserver + open-in-app rows through the real Loader. */
async function boot(): Promise<string> {
async function boot(layers: readonly LaunchEnvironmentLayerInput[] = []): Promise<string> {
internals.catalog = { env: {}, ...internals.catalog }
root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
@@ -61,6 +66,7 @@ async function boot(): Promise<string> {
].join('\n'))
context = new Context()
context.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot(layers))
context.baseUrl = pathToFileURL(root).href + '/'
context.provide('connection', { requestRejection: () => trust.rejection } as never)
// The plugin resolves PATH names through the composition's subprocess
@@ -125,6 +131,43 @@ async function cursorBundle(home: string): Promise<void> {
}
describe('open-in-app host routes (real Loader composition)', () => {
it.each(['project-env', 'user-env'] as const)('ignores materialized SSH markers from %s', async (source) => {
vi.stubEnv('SSH_CONNECTION', 'stale-connection')
vi.stubEnv('SSH_TTY', '/dev/pts/stale')
internals.catalog = {
platform: 'darwin', applicationRoots: [], env: process.env,
run: () => Promise.reject(new Error('fixture rejects')), resolveExecutable: pathTable(),
}
const base = await boot([{ source, values: { SSH_CONNECTION: 'stale-connection', SSH_TTY: '/dev/pts/stale' } }])
expect(await (await fetch(`${base}/open-in-app/apps`)).json()).toEqual({ apps: ['finder', 'terminal'] })
})
it.each([
{ SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' },
{ SSH_TTY: '/dev/pts/3' },
])('returns an empty catalog and refuses icons and launches over SSH: %j', async (env) => {
const run = vi.fn<NativeCommandRunner>()
const launch = vi.fn<OpenInAppLauncher>()
const resolveExecutable = vi.fn(pathTable())
internals.catalog = { platform: 'darwin', env, run, launch, resolveExecutable }
const base = await boot([{ source: 'process', values: env }])
const apps = await fetch(`${base}/open-in-app/apps`)
expect(apps.status).toBe(200)
expect(await apps.json()).toEqual({ apps: [] })
expect((await fetch(`${base}/open-in-app/icon/finder`)).status).toBe(404)
const open = await fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'finder', path: root }),
})
expect(open.status).toBe(400)
expect(run).not.toHaveBeenCalled()
expect(resolveExecutable).not.toHaveBeenCalled()
expect(launch).not.toHaveBeenCalled()
})
it('keeps the function-plugin runtime surface to Loader exports', () => {
expect(Object.keys(OpenInApp).sort()).toEqual(['Config', 'apply', 'inject', 'name'])
})
@@ -54,7 +54,7 @@ function byId(id: string): OpenInAppApp {
/** Internals baseline every call completes: a rejecting runner and an empty PATH. */
function bare(overrides: OpenInAppInternals): OpenInAppInternals {
return { run: runner(() => null), resolveExecutable: pathTable(), ...overrides }
return { env: {}, run: runner(() => null), resolveExecutable: pathTable(), ...overrides }
}
/** Hermetic Linux environment: XDG lookups stay inside the temp home. */
@@ -63,6 +63,26 @@ function linuxEnv(home: string): Readonly<Record<string, string>> {
}
describe('resolveOpenInAppApps', () => {
it.each(['darwin', 'win32', 'linux'] as const)('offers no applications over SSH on %s without probing', async (platform) => {
const run = vi.fn<NativeCommandRunner>()
const resolveExecutable = vi.fn(pathTable({ code: '/usr/bin/code' }))
const facts = {
platform, ssh: true, env: { DISPLAY: ':0', VSCODE_IPC_HOOK_CLI: '/tmp/vscode.sock' },
run, resolveExecutable,
}
await expect(resolveOpenInAppApps(TIMEOUT_MS, facts)).resolves.toEqual(new Map())
await expect(resolveLaunch(byId('vscode'), TIMEOUT_MS, facts)).resolves.toBeNull()
expect(run).not.toHaveBeenCalled()
expect(resolveExecutable).not.toHaveBeenCalled()
})
it('keeps local applications available regardless of flattened SSH markers', async () => {
const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({
platform: 'darwin', applicationRoots: [], env: { SSH_CONNECTION: 'stale-value' },
}))
expect([...map.keys()]).toEqual(['finder', 'terminal'])
})
it('fails loud when the PATH resolver is not supplied', async () => {
await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'linux' }))
.rejects.toThrow(/resolveExecutable is required/)
+3
View File
@@ -25,6 +25,9 @@
},
{
"path": "../../util/native-command"
},
{
"path": "../../util/launch-environment"
}
]
}
@@ -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/util/launch-environment/README.md
README.md: 312952f527af02c9bd9b19d3560d65c84b446b7d
README.zh.md: 3071052c941737b365d1557ead005ede5ba857d3
README.md: e2dfe9d2c2024084ed16cf80e5fa26f4e90db52b
README.zh.md: b846f66e3281856f9c206fd71cbd3ded59bcfef8
@@ -37,6 +37,8 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the named layers without changing that trust order — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in.
`launchedThroughSsh(snapshot)` returns true only for a non-empty `SSH_CONNECTION` or `SSH_TTY` in the inherited process layer. Web browser handoff, the adaptive directory picker, and Open In share this predicate; project and user `.env` values never establish an SSH session.
### How layers rank
| Layer | What it is |
@@ -37,6 +37,8 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索指定的层,不改变这一可信顺序——绝不能接受某一层的调用方不把它列进去,因此后续任何重新排序都无法让它回来。
`launchedThroughSsh(snapshot)` 仅在继承的进程层中存在非空 `SSH_CONNECTION` 或 `SSH_TTY` 时返回 true。Web 浏览器唤起、自适应目录选择器与 Open In 共用此判断;项目与用户 `.env` 中的值不作为 SSH 会话的依据。
### 各层的优先级
| 层 | 它是什么 |
@@ -116,6 +116,19 @@ export function launchEnvironmentOf(ctx: Context): LaunchEnvironmentSnapshot {
?? createLaunchEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
}
/**
* Detect SSH from non-empty SSH_CONNECTION or SSH_TTY inherited at launch.
* Project and user `.env` values never establish an SSH session.
* @param environment - the launcher's environment snapshot.
* @returns whether the inherited process layer carries either SSH marker.
*/
export function launchedThroughSsh(environment: LaunchEnvironmentSnapshot): boolean {
return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => {
const value = environment.getFrom(name, ['process'])?.value
return value !== undefined && value !== ''
})
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import {
createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchEnvironmentOf,
createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchedThroughSsh, launchEnvironmentOf,
} from '../src/index.ts'
const layered = createLaunchEnvironmentSnapshot([
@@ -10,6 +10,24 @@ const layered = createLaunchEnvironmentSnapshot([
{ source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } },
])
describe('launchedThroughSsh', () => {
it.each(['SSH_CONNECTION', 'SSH_TTY'].flatMap(name =>
(['process', 'project-env', 'user-env'] as const).map(source => ({ name, source })),
))('classifies $name from $source', ({ name, source }) => {
const snapshot = createLaunchEnvironmentSnapshot([{ source, values: { [name]: 'ssh-marker' } }])
expect(launchedThroughSsh(snapshot)).toBe(source === 'process')
})
it.each([{}, { SSH_CONNECTION: '', SSH_TTY: '' }])('keeps absent or empty inherited markers local: %j', (values) => {
const snapshot = createLaunchEnvironmentSnapshot([
{ source: 'process', values },
{ source: 'project-env', values: { SSH_CONNECTION: 'stale-connection' } },
{ source: 'user-env', values: { SSH_TTY: 'stale-tty' } },
])
expect(launchedThroughSsh(snapshot)).toBe(false)
})
})
describe('createLaunchEnvironmentSnapshot', () => {
it('resolves across every layer, most trusted first, and reports the winning source', () => {
expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' })
+10
View File
@@ -612,6 +612,9 @@ importers:
'@deepseek-ai/dsh-experimental-webworker-runtime':
specifier: workspace:^
version: link:../../packages/experimental/webworker-runtime
'@deepseek-ai/dsh-launch-environment':
specifier: workspace:^
version: link:../../packages/util/launch-environment
'@deepseek-ai/dsh-pwsh-local':
specifier: workspace:^
version: link:../../packages/shell/pwsh-local
@@ -6473,6 +6476,10 @@ importers:
version: link:../../../vendor/cordis
packages/host/directory-picker-auto:
dependencies:
'@deepseek-ai/dsh-launch-environment':
specifier: workspace:^
version: link:../../util/launch-environment
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -6558,6 +6565,9 @@ importers:
packages/host/open-in-app:
dependencies:
'@deepseek-ai/dsh-launch-environment':
specifier: workspace:^
version: link:../../util/launch-environment
'@deepseek-ai/dsh-native-command':
specifier: workspace:^
version: link:../../util/native-command
+41
View File
@@ -774,6 +774,47 @@ describe('Python release workflows', () => {
})
})
describe('Request review workflow', () => {
it('runs trusted routing on reviewable pull request updates', () => {
const workflow = loadWorkflow('.github/workflows/request-review.yml')
const event = workflowEvent(workflow, 'pull_request_target')
const job = workflowJob(workflow, 'request-review')
if (!isRecord(workflow.on)) throw new TypeError('request-review workflow must define events')
if (!Array.isArray(job.steps)) throw new TypeError('request-review job must define steps')
const steps = job.steps.filter(isRecord)
const checkout = steps.find(step => step.name === 'Check out trusted review policy')
const request = steps.find(step => step.name === 'Request reviewers')
expect(workflow.name).toBe('request-review')
expect(Object.keys(workflow.on)).toEqual(['pull_request_target'])
expect(event.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review'])
expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'write' })
expect(workflow.concurrency).toEqual({
group: 'request-review-${{ github.event.pull_request.number }}',
'cancel-in-progress': true,
})
expect(job).toMatchObject({
name: 'request-review',
if: '${{ !github.event.pull_request.draft }}',
'runs-on': 'ubuntu-latest',
'timeout-minutes': 5,
})
expect(checkout).toMatchObject({
uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1',
with: {
ref: '${{ github.event.repository.default_branch }}',
'persist-credentials': false,
},
})
expect(request).toMatchObject({
env: { GITHUB_TOKEN: '${{ github.token }}' },
run: 'node .github/review-ownership/request-review.mjs',
})
expect(JSON.stringify(workflow)).not.toContain('github.event.pull_request.head')
expect(JSON.stringify(workflow)).not.toContain('secrets.')
})
})
describe('Issue lifecycle workflow', () => {
it('runs the lifecycle job on every PR/review event but gates token and board steps', () => {
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
+9
View File
@@ -240,6 +240,15 @@ describe('gate graph validation', () => {
},
)
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
'keeps review request policy tests in %s',
(mode) => {
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
expect(ids).toContain('request-review')
},
)
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
'keeps hard-coded Client UI copy enforcement in %s',
(mode) => {
+2
View File
@@ -267,6 +267,7 @@ export function gatesForMode(selected: Mode): Gate[] {
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
pnpmScript('test', 'test'),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
pnpmScript('duplication', 'duplication'),
snapshotGate(),
expectedOutputGate(),
@@ -310,6 +311,7 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
]
}
@@ -0,0 +1,11 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- button "Session log":
- text: Session log
- img
- button "Open the sidebar":
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -0,0 +1,9 @@
version: 1
scenario: open-in-app-ssh
profile: web
composition: web-default
recording: authored
header:
class: web-default
session:
source: ../seeded-history/session.v2.jsonl
+1
View File
@@ -53,6 +53,7 @@
"apps/web/tests/web-search-round.e2e.ts",
"apps/web/tests/file-upload-round.e2e.ts",
"apps/web/tests/message-actions.e2e.ts",
"apps/web/tests/open-in-app-ssh.e2e.ts",
"apps/web/tests/message-feedback.e2e.ts",
"apps/web/tests/message-feedback-layout.e2e.ts",
"apps/web/tests/markdown-images.e2e.ts",