# 真机测试:约束与 Runbook > **本文是「AI 真机测试约束」的唯一归宿。** 平台环境搭建见 > [../scripts/windows/README.md](../scripts/windows/README.md)(Windows 11); > 通用排障见 [troubleshooting.md](./troubleshooting.md)。 ## 为什么单开一层 **真机脚本进不了 CI。** 它们要真实 PTY、真实 SSH、真实浏览器、真实宿主,CI runner 给不了。 后果是:**真机脚本的正确性只能靠跑一遍**——静态断言可以守住「脚本里有隔离」,守不住 「拷两次不会崩」。 实测教训(`codegraph CG48`):给 indexForce 脚本加的 `cpSync` 隔离**从没被运行过**, 第二轮往已存在的目标上拷时崩掉,于是「indexForce 真机验证通过」对其中一半是**假的**, 而 `verify-scripts-safety.test.ts` 全程绿。 所以本层的目标是:**让下一次真机测试不重踩同样的坑,且能判断「通过」是不是真的。** ## 三条硬约束 ### ① 不污染用户的真实配置 被测插件会按 `dshHome()` 往 `$DSH_HOME/cordis.patch.yml` 写托管行。脚本若继承真实 `DSH_HOME`,就会把**指向临时目录**的行写进用户真实配置,脚本结束一 `rmSync`, 用户配置里留下一条指向不存在路径的托管行。 **做法**:给被测宿主**隔离的 `DSH_HOME`**——整份拷入被测 profile,组装产物与托管行全部落在 临时目录,真实 `~/.dsh` 全程只读。 **必须自证**:收尾加一条断言「真实补丁逐字节未变」,把「不污染」从承诺变成会被执行的检查。 (范本:`scripts/verify-codegraph-host-contract.mjs`,`codegraph CG45`。) ### ② 不把「环境限制」当成「代码回归」 受限沙箱下 `posix_openpt` 会被拒,`tty` 的 `integration.mjs` 会在 `[1] 全链路` 直接崩—— 那是**沙箱限制**,不是回归。同样地,假的 docker 是 `#!/bin/sh` 脚本,`docker` 的路由冒烟在 Windows 上按设计跑不了(CI 里也是 ubuntu-only)。 **`integration.mjs` 在 Windows 上也按设计跑不了**(2026-09-26 实测):它给终端送的是 POSIX 命令(`printf "IT_TERM_%s\n" "$TERM"`),而 Windows 侧是 cmd.exe——`[1] 全链路` 会等超时, 报错里能看到 cmd 把整行原样回显。Windows 的对应入口是 `windows-smoke.mjs`(10/10,用的是 `%OS%` 这类 cmd 原生写法;W6 钉 D74 的裸 LF 归一化、W7 钉 D77 的「退出后仍可读」)。 **判据**:先问「这条在受限环境下有没有可能过」,再决定是修代码还是记成覆盖缺口。 ### ③ 隔离与审批 - 文件策略通常是 `workspace-write`(只允许改仓库内)。把文件写到仓库外、或执行 `prlctl` / 装包这类命令,需要**一次性的更宽权限**,逐条申请。 - 不要为了图省事把整台机器的权限要下来。逐条申请、逐条说明。 - **长驻进程用终端面板会话跑**(dev server / watch / 交互式程序),不要在一次性命令里挂起等待。 ## 通用验证闭环 真机验证的骨架(平台细节见 `scripts/windows/README.md`): ```sh node -v ; pnpm -v ; dsh --version # dsh 必须是**本仓 cohort**(= 各包 peer 下限),不是 npm 的 latest——低一档会让全部插件 # 被兼容性 preflight **静默**整行 disabled(宿主照常启动,日志里才有一行 disabling) pnpm install pnpm -r build pnpm -r typecheck # 关键一步:让插件与宿主共用同一份 @deepseek-ai/* node scripts/link-dsh-runtime.mjs --dry-run node scripts/link-dsh-runtime.mjs # 测试 profile = 根 bundle + **Web 应用** dsh plugin --profile <测试 profile> add link:$(pwd) dsh plugin --profile <测试 profile> add "@deepseek-ai/dsh-web-app@" # 版本必须钉:裸包名解析到 npm 的 latest(0.0.1-rc.1),会被 preflight 正当地拒掉 dsh --profile <测试 profile> --dump-config # 起宿主:用 patch 指定端口(`dsh --profile X web` 不是合法形式,见 scripts/windows/README.md) dsh --profile <测试 profile> --patch # 日志里出现 `dsh web: http://127.0.0.1:/?token=…` 才算真起来了——「插件 mounted」不够 ``` - **`link-dsh-runtime` 是必需的**:插件从本地路径加载时,Node 会解析到仓库 `.pnpm` 里那套 `@deepseek-ai/*`,而不是宿主进程正在用的那套。升级 dsh 后会出两类**假象**—— `instanceof` / schema 对不上报出与真实无关的错,或插件继续用旧 API 悄悄跑通、掩盖真实兼容性问题。 详见 [link-dsh-runtime.md](./link-dsh-runtime.md)。 - ⚠️ **`pnpm install` 会重建 `node_modules`、冲掉这些链接**——装完依赖要重跑一次。 pnpm 10 还会默认拦掉原生模块的构建脚本(esbuild / node-pty / ssh2 …),不补跑 (`pnpm rebuild `)就打不出浏览器半体、真 PTY 也起不来。 - ⚠️ 别同时装 `@hyzyn/dsh-all`(或任一子包)与根 bundle,插件行重复挂载会报 `duplicate loader entry id`。 - **用专门的测试 profile**(如 `compat` / `wintest`),不要用 `web`。 **脚本里别写死 profile 名**——启动方式会变,写死会让断言在换 profile 时永久变红。 - **profile 里没有 `dsh-web-app` 时,宿主会「起来但没端口」**:进程活着、插件全 mounted、 日志一切正常,`netstat` 里却什么都没有。阶段 B 的活体探针全部无从谈起—— 所以上面第 4 步那两条 `dsh plugin add` 缺一不可。 ## 驱动真宿主的面板:浏览器半体的真机验证 客户端半体(面板 / 卡片)的行为只有**真浏览器 + 真宿主**能验:`packages/tty` 的 `preview` 是 mock 宿主(验布局与文案,见 [tty 包 README](../packages/tty/README.md) 的预览一节),验不到 「宿主推的真帧到达之后,客户端怎么反应」。这条路有四条实测约束(2026-10-10 走通一次,方法照抄即可): 1. **token 只有自己起的宿主才有。** `dsh web` 打印的 `?token=…` 是**进程内随机** (`processLaunchToken` → `randomBytes`,不落盘),而子进程环境里的 `DSH_WEB_URL` **不带 token**。于是**已经在跑的宿主,agent 驱动不了它的页面**(`GET /` 与 `/api/*` 都是 401) ——`verify-client-ui.mjs --url <宿主> --token ` 里那个 token,只能是**你自己起的那一个**, 或由用户从他的地址栏给你。 2. **要「真设置 + 能跑 agent」,用真 `DSH_HOME` + 复制一份 profile**(不要隔离 home): ```sh cp -R ~/.dsh/profiles/<源> ~/.dsh/profiles/<临时名> # 相对符号链接同深度,拷完照常可用 dsh --profile <临时名> --port <空闲端口> # stdout 那行 `dsh web: …/?token=…` 就是入口 ``` 副本跑在**真 `DSH_HOME`** 上,所以用户的设置、模型路由与凭据都在(隔离 home 里没有这些, 除非另行播种)⇒ **宿主里的 agent 真能跑**;profile 名与端口都不同,也不会去抢源 profile 的 tmux socket。Windows 上别用 `fs.cpSync` (会把 junction 展开成真副本)——`scripts/live-host-smoke.mjs` 的 `copyProfileTree` 就是为这个写的。 收尾:关宿主、关页面;临时 profile 的删除先问用户。 3. **驱动用会话里的浏览器自动化**(Playwright MCP 的 `browser_*`,或仓内零依赖的 `scripts/chrome-cdp.mjs`)。三个坑都是实测踩到的: - **先确认页面跑的是哪一版产物**:bundle 从磁盘现读、rev 按 `mtime/ctime/size` 现算, 所以「源码改了」不等于「这个页面加载了新代码」。拿**新版才有的 DOM 节点 / 样式规则**当探针 最省事(本次用的是面板骨架里的 `.tt_exitBar` 节点与样式表里的 `.tt_confirmLayer` 规则)。 - **模态背后的输入框点不到、但能键入**:`element.focus()` + `page.keyboard.type()` 照常送达; `document.execCommand('insertText')` 在那套富文本编辑器上**不生效**(`textContent` 仍是空), 别用它;也别在核对 `textContent` 之前按 Enter(会发出一条空/半截的消息)。 - **权限档位会拦 agent 的工具调用**:默认「工作区内修改」下 `tty_open` 之类会停在 「等待审批:拒绝 / 允许一次」——驱动脚本要能等到并点掉它,否则断言卡在「目标没出现」, 看起来像产品缺陷。 4. **断言要取两侧**:宿主侧的工具输出只说明「会话没了」,面板里的标签有没有收走是**客户端**的行为 (`tty_list` 看到 0 条、标签栏却还挂着,正是 tty D97 的现场)。要区分「用户亲手点过」与 「客户端自己顶上来」,就用**真点击**(`page.click()`)制造前者。 ## 各包真机入口 **入口一律写成一条可粘贴命令**(包的脚本条目在各自 `package.json` 里)。仓库根**每个** `scripts/verify-*.mjs`(条数由目录现算,不在本文写死)也能被一条命令列全:`pnpm verify:list` ——它现算「脚本 / 所在包 / 需要什么 / 是否进 CI / 那条命令」,且**只列不跑**(它们会起真宿主与 真 Chrome,聚合执行会把垃圾进程与临时目录留在机器上)。 | 包 | 一条可粘贴命令 | 需要什么 | |---|---|---| | `tty` | `pnpm --filter @hyzyn/dsh-tty run integration`(真实 PTY 全链路)、`… run ssh-smoke`(内存 sshd)、`… run probe-smoke`、`… run probe-route-smoke`、`… run sftplimits-smoke`、`… run preview`(Chrome,**mock** 宿主,界面场景)、`… run windows-smoke`(仅 Windows 有意义);「真宿主 + 真浏览器驱动面板」见 [§ 驱动真宿主的面板](#驱动真宿主的面板浏览器半体的真机验证) | 真实 PTY / Chrome / Windows | | `codegraph` | `pnpm --filter @hyzyn/dsh-codegraph run agent-scope-smoke`(最小 Cordis 根)、`… run agent-integration-smoke`(真 `AgentRegistry` 驱动真 `agent/created`)、`… run indexforce-smoke`、`… run host-contract-smoke`(真宿主路由与开关)、`… run client-ui-smoke`(自起隔离宿主 + 真 Chrome) | 真 DSH 宿主 / 真 CLI / Chrome | | `kit` | `pnpm --filter @hyzyn/dsh-kit run tier-smoke`(= [`verify-tier-restrict.mjs`](../scripts/verify-tier-restrict.mjs):真 cordis 根 + 真 `tools` / `sessions` / `sandboxPolicy` 服务 + **三个包的真实分档表**,逐条打宿主契约) | 装好的 DSH 运行时(`node scripts/link-dsh-runtime.mjs` 之后解析到宿主那一份);**不要**宿主在跑、不要浏览器 | | `mcp` | `pnpm --filter @hyzyn/dsh-mcp run http-smoke`(streamable-http 的三种响应模式;只打 `/api/dsh-mcp/test`,不写配置)、`… run tools-smoke`(保存 → 热加载 → 工具真的进注册表;会写宿主 MCP 配置并**逐条写回**)、`… run on-demand-smoke`(= [`verify-mcp-on-demand.mjs`](../scripts/verify-mcp-on-demand.mjs):进程内真 cordis 根 + 真 `dsh-mcp-client` 挂真 stdio 探针服务器 + 真插件,验 `agentToolLoading: 'on-demand'` 的工具面) | 正在跑的宿主(装了 `dsh-mcp`;`tools-smoke` 的 L2 那半还需 `dsh-search` 与一次 agent 回合);`on-demand-smoke` **不要**宿主在跑、不要浏览器,只要装好的 DSH 运行时 | | `rss` | `pnpm --filter @hyzyn/dsh-rss run opml-smoke`(OPML 导入 / 导出 / 回环;先存基线,收尾写回并重刷 digest) | 正在跑的宿主 + token + 真 Chrome(受限沙箱加 `--chrome-arg --no-sandbox`) | | `docker` | `pnpm --filter @hyzyn/dsh-docker run smoke`(`smoke.mjs` + `route-smoke.mjs` + `client-smoke.mjs`,hermetic,能进 CI);真机项需真 docker daemon | 真 docker | | 通用(L0) | `node scripts/verify-client-ui.mjs --url <宿主> --token `(应用壳 + 逐插件配置页;`--mode boot` 只验壳;**token 从哪来见 [§ 驱动真宿主的面板](#驱动真宿主的面板浏览器半体的真机验证)**)、`pnpm verify:list`(只列不跑) | 正在跑的宿主 + token + 真 Chrome | | 通用(L0) | `pnpm verify:issue-13-14`(= `node scripts/verify-issue-13-14.mjs --from test`):外部测评 issue #13/#14 那 11 条的**真机**验收,**自起**隔离 home + 真宿主,逐条打真路由;`--from ` 换模板、`--as <名>` 绕开 CLI 独占的 `desktop` | 装了 DSH 的任意机器(不需要外部宿主,也不碰真实 `~/.dsh`) | | 全部 | `pnpm live-smoke`(= `node scripts/live-host-smoke.mjs`;干净机器 `node scripts/live-host-smoke.mjs --bootstrap --strict`)(真宿主:能力开关授权阶梯 / 路由门控 / 工具清单 / 试连文案 / 宿主正服务的 `client.js`) | 装了 DSH 的任意机器(干净机器加 `--bootstrap`) | | 全部 | `node scripts/windows/setup-dsh-testenv.ps1 -WithRepo` | Windows 11 | > **真模型回合的验收**(脚本证明不了「模型会不会主动装载」):做法与结论记在根 > [ROADMAP.md](../ROADMAP.md) 的「`agentToolLoading` 的**真模型回合**验收(默认仍 `eager`)」一条 > ——复制真实 profile + `--patch` 覆盖 + 真浏览器发一句、**判据取自会话日志的 `tool/call`**。 > > **`tier-smoke` 与 `on-demand-smoke` 的分工**:两者都**不进 CI、不 spawn 宿主**(进程内挂真模块 + > 隔离 `DSH_HOME`),但验的是两件事——`tier-smoke`(kit)验**档位契约**:`tools.restrict()` 的投影 > 让受限档看见什么、升 / 降档怎么重算、per-agent 挂载为什么不受前缀裁剪;`on-demand-smoke`(mcp)验 > **按需加载**:`on-demand` 下 agent 默认看不见 `mcp__*`、`mcp_servers` / `mcp_load` 装回来之后 > **真调用**拿到结果、且只影响那一个 agent。改档位实现看前者,改按需加载看后者。 > ✅ **2026-09-30 真机分诊 → 复跑 → 修复的结论**: > `… run client-ui-smoke` 与 `node scripts/verify-client-ui.mjs --mode full` 当时**全红**(UI8/UI9/UI11/UI12), > 根因是**四条过期的选择器 / 结构口径**(DOM dump 实测,不是读代码):① 插件页行文案从短名改成 > 全包名(`codegraph` → `@hyzyn/dsh-codegraph`);② 行级 `配置 ` 入口与 `[data-plugin-row-detail]` > 已不存在——卡片改由 `plugins.bundle.config` **内联渲染在包详情页**;③ 侧边栏「终端」是 > `div[role=button]` 而不是 `