Repository navigation
feat(serve): support SSH workspaces without a remote daemon - #12255
Conversation
SSH workspace E2E reportResult: 77 independent checks passed. The final bundle reran 58 checks (main 50 + boundary 8); the lifecycle and default-shell groups had passed before the narrow text metadata/list/glob fixes. Verification used the exact committed source; pre-commit formatting and lint hooks made no further source changes.
Before implementation, the global CLI returned HTTP 400 with The tests used a real isolated macOS OpenSSH server, Python 3.9.6, temporary keys/known-hosts configuration, separate local and remote project directories, and a loopback mock model. Shell output was compared with actual remote marker contents. No remote Qwen service was started, no real model API was called, and all test daemons and SSH listeners were stopped afterward. Missing Python was simulated using a fixture-specific SSH forced command. Additional validation: full build, typecheck and bundle passed; changed-file ESLint and Prettier passed. Focused suites included daemon server 1307/1307, core Config 814/814, CLI Config plus SSH dispatch/storage 471/471, Web Shell components 114/114, terminal 44/44 and SSH execution environment 29/29. After the boundary fixes, the filesystem adapter suite passed 10/10 and the SSH Python script suite passed 12/12. Independent review findings were fixed and re-reviewed. Limits: Linux and Windows, ProxyJump, browser-rendered interaction, and an actual network interruption during an in-flight write or command were not tested. Outage checks stop the SSH listener before the next request; terminal reconnect uses a live listener. Cancellation is covered by focused transport unit tests, and interrupted operations explicitly report uncertain remote status. This report does not claim a completed automated The reusable fixture scripts and full local evidence remain in the repository's ignored working-artifact directories, following the project convention; they are not part of the committed feature diff. 中文摘要:累计 77 项独立检查通过,最终 bundle 重跑其中 58 项。验证使用真实隔离 SSH、macOS/Python 3.9.6 和本地模拟模型,检查了实际远程文件字节及命令标记。BOM/CRLF、大目录截断和最大结果数搜索问题已复现、修复并验证。Linux/Windows、ProxyJump、浏览器交互和操作进行中的实际断网尚未实测;未将未完成的自动 |
|
Fixed the seven failing Ubuntu unit tests. The session route fixtures omitted the required workspace filesystem factory, so the SSH workspace check returned HTTP 500 before the existing validation and prompt handling ran. The repair supplies typed local factory mocks in both fixtures; production behavior and all existing assertions are unchanged. Validation: reproduced the same seven failures locally before the fix; all 41 focused session and SSH boundary tests pass afterward. Full build, typecheck (including integration types), bundle, targeted ESLint and Prettier checks pass. The new CI run will validate the complete workspace suite. |
Linux verification round — real SSH host, real daemon, real bundleThe first report on this PR covered macOS; the PR body and Result: 121 independent checks passed, 0 failed. No blocking defect found; 5 non-blocking observations below. Before/after control on Linux: the globally installed CLI (0.22.2) answers What ran
Package gates on the branch head: Screenshots (real browser against the live daemon)Sidebar after adding two connections to the same remote project — Approval card for a remote shell command — the title names the SSH host and the remote project directory: The integrated terminal attached to the same workspace, showing the remote working directory and the remote project listing (including the marker the approved command created): Add-workspace dialog, default state vs. an Non-blocking observationsN1 — the new SSH guidance is invisible in the default Web Shell. N2 — the HTTP ACP transport is closed for SSH workspaces, the WebSocket one is not. N3 — N4 — slash-command messaging. N5 — documentation status lines. EnvironmentLinux 6.12 (Debian 13), Node.js 22.22.2, branch head Verdict from this round: merge-ready on Linux. The five items above are follow-ups, not blockers. 中文说明Linux 验证轮次 —— 真实 SSH 主机、真实 daemon、真实 bundle本 PR 上一轮报告覆盖的是 macOS,PR 描述与 结果:121 项独立检查全部通过,0 项失败。 未发现阻塞缺陷;下列 5 条为非阻塞观察。 Linux 上的前后对照:全局安装的 CLI(0.22.2)对 验证内容
分支头上的包级门禁: 截图(真实浏览器对接运行中的 daemon)同一个远端项目用两种连接方式添加后的侧边栏 —— 远端 shell 命令的审批卡片 —— 标题标明 SSH 主机与远端项目目录: 同一工作区的集成终端,显示远端工作目录与远端项目列表(含刚才批准的命令创建的标记文件): 添加工作区对话框:默认状态 vs 在同一输入框中输入 非阻塞观察N1 —— 新增的 SSH 提示在默认 Web Shell 中不可见。 本 PR 改写的 N2 —— HTTP 版 ACP 传输对 SSH 工作区完全关闭,WebSocket 版则不是。 N3 —— N4 —— 斜杠命令的提示信息。 N5 —— 文档状态行。 环境Linux 6.12(Debian 13)、Node.js 22.22.2、本地构建的分支头 本轮结论:Linux 上可合入。上述五条是后续项,不构成阻塞。 🤖 Generated with Claude Code — Claude Opus 5 (1M context) |
wenshao
left a comment
There was a problem hiding this comment.
[Critical] Blocking finding(s) follow.
Not reviewed: reverse audit — stopped after round 3 of the up-to-5 cap (rounds 4-5 not run): every audited round reported new findings rather than trending dry, so the loop was cut here and the remaining rounds are unconsumed.
Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI at this commit and the Windows lane did not run locally, so the win32 path of the SSH filesystem suite is unmeasured (one finding, R3-4, depends on that lane).
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI at this commit and the integration suite did not run locally.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": did not confirm whether the local EditTool can create a missing file, so the entirely unpinned EDIT-create branch (ssh-execution-environment.ts:289-292, uncover…; "agent reverse-audit (round 1)": did not execute coreToolScheduler end-to-end to observe the dropped output; that link rests on read evidence (coreToolScheduler.ts:1082-1104, 6427, 6647-6652)…; "agent reverse-audit (round 1)": file-line mapping of the chunk's tail — my diff chunk ends at return match(0, 0) (line 224), so dispatch and the request loop (lines 225-465) were read only…; chunk 2: the interactive-TUI slash-command surface for an SSH session ( packages/cli/src/ui/** ) — I did not trace whether it applies SSH_SLASH_COMMAND_POLICY ; Builti…; chunk 2: whether the daemon forwards a client-facing session/artifacts (allowed by the serve-layer SSH_METHODS allow-list) into the child route qwen/control/session…, and 13 more.
[Critical] R3-1: A settings write succeeds on disk and then answers 500 with a retryable code, for SSH workspaces only
中文说明
未审查(原文为英文):reverse audit — stopped after round 3 of the up-to-5 cap (rounds 4-5 not run): every audited round reported new findings rather than trending dry, so the loop was cut here and the remaining rounds are unconsumed.
未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI at this commit and the Windows lane did not run locally, so the win32 path of the SSH filesystem suite is unmeasured (one finding, R3-4, depends on that lane).
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI at this commit and the integration suite did not run locally.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)":did not confirm whether the local EditTool can create a missing file, so the entirely unpinned EDIT-create branch (ssh-execution-environment.ts:289-292, uncover…;"agent reverse-audit (round 1)":did not execute coreToolScheduler end-to-end to observe the dropped output; that link rests on read evidence (coreToolScheduler.ts:1082-1104, 6427, 6647-6652)…;"agent reverse-audit (round 1)":file-line mapping of the chunk's tail — my diff chunk ends at return match(0, 0) (line 224), so dispatch and the request loop (lines 225-465) were read only…;chunk 2:the interactive-TUI slash-command surface for an SSH session ( packages/cli/src/ui/** ) — I did not trace whether it applies SSH_SLASH_COMMAND_POLICY ; Builti…;chunk 2:whether the daemon forwards a client-facing session/artifacts (allowed by the serve-layer SSH_METHODS allow-list) into the child route qwen/control/session…,另有 13 条。
[Critical] R3-1: A settings write succeeds on disk and then answers 500 with a retryable code, for SSH workspaces only
— DeepSeek/deepseek-v4.1-flash@2d473174 via Qwen Code /review (v0.24.2)
wenshao
left a comment
There was a problem hiding this comment.
[Critical] Blocking finding(s) follow.
Not reviewed: reverse audit — stopped after round 3 of the up-to-5 cap (rounds 4-5 not run): every audited round reported new findings rather than trending dry, so the loop was cut here and the remaining rounds are unconsumed.
Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI at this commit and the Windows lane did not run locally, so the win32 path of the SSH filesystem suite is unmeasured (one finding, R3-4, depends on that lane).
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI at this commit and the integration suite did not run locally.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": did not confirm whether the local EditTool can create a missing file, so the entirely unpinned EDIT-create branch (ssh-execution-environment.ts:289-292, uncover…; "agent reverse-audit (round 1)": did not execute coreToolScheduler end-to-end to observe the dropped output; that link rests on read evidence (coreToolScheduler.ts:1082-1104, 6427, 6647-6652)…; "agent reverse-audit (round 1)": file-line mapping of the chunk's tail — my diff chunk ends at return match(0, 0) (line 224), so dispatch and the request loop (lines 225-465) were read only…; chunk 2: the interactive-TUI slash-command surface for an SSH session ( packages/cli/src/ui/** ) — I did not trace whether it applies SSH_SLASH_COMMAND_POLICY ; Builti…; chunk 2: whether the daemon forwards a client-facing session/artifacts (allowed by the serve-layer SSH_METHODS allow-list) into the child route qwen/control/session…, and 13 more.
[Critical] R3-1: A settings write succeeds on disk and then answers 500 with a retryable code, for SSH workspaces only
中文说明
未审查(原文为英文):reverse audit — stopped after round 3 of the up-to-5 cap (rounds 4-5 not run): every audited round reported new findings rather than trending dry, so the loop was cut here and the remaining rounds are unconsumed.
未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI at this commit and the Windows lane did not run locally, so the win32 path of the SSH filesystem suite is unmeasured (one finding, R3-4, depends on that lane).
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI at this commit and the integration suite did not run locally.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)":did not confirm whether the local EditTool can create a missing file, so the entirely unpinned EDIT-create branch (ssh-execution-environment.ts:289-292, uncover…;"agent reverse-audit (round 1)":did not execute coreToolScheduler end-to-end to observe the dropped output; that link rests on read evidence (coreToolScheduler.ts:1082-1104, 6427, 6647-6652)…;"agent reverse-audit (round 1)":file-line mapping of the chunk's tail — my diff chunk ends at return match(0, 0) (line 224), so dispatch and the request loop (lines 225-465) were read only…;chunk 2:the interactive-TUI slash-command surface for an SSH session ( packages/cli/src/ui/** ) — I did not trace whether it applies SSH_SLASH_COMMAND_POLICY ; Builti…;chunk 2:whether the daemon forwards a client-facing session/artifacts (allowed by the serve-layer SSH_METHODS allow-list) into the child route qwen/control/session…,另有 13 条。
[Critical] R3-1: A settings write succeeds on disk and then answers 500 with a retryable code, for SSH workspaces only
— DeepSeek/deepseek-v4.1-flash@2d473174 via Qwen Code /review (v0.24.2)
|
Review follow-up verification for 1988bf9. All 67 existing review threads were reconciled against the current source. The body-only R3-1 finding is fixed: saving a workflow default on an SSH workspace no longer persists the value and then returns 500 while trying to activate an unsupported workflow in a live session. N1/N2 are fixed (visible SSH prerequisites and POST ACP admission); N3 retains the ordinary opt-in LS gate; N4 preserves the actual unsupported slash-command reason; N5 now cites the existing Linux report accurately. Reproduction and real SSH verification
Build and test evidence Root build, bundle, full typecheck, changed-file ESLint/Prettier and The native review also ran broader workspace suites. Web Shell, ACP bridge, SDK, Qwen Live and VS Code companion passed. CLI/core broad runs were not fully green. Isolated reruns and a clean QWEN_HOME control distinguished local-profile contamination from remaining Git/macOS fixture failures in unchanged review/Git test paths. The remaining normal-profile failures include macOS non-UTF-8/worktree fixtures and an Apple Git 2.50.1 error-message assertion. Their test and implementation paths are unchanged from the merge base. A control with GIT_CONFIG_GLOBAL=/dev/null also failed two fixtures that deliberately use global filters, so that control is not counted as a full-suite success. No unrelated source was changed to suppress these failures. These results are disclosed rather than counted as full-suite success. Audit evidence The full pending diff was audited repeatedly in open-ended and reverse passes. Earlier passes found a missing local control in a capability test, a nested Git-ignored Qwen rule leak, the later transport-to-HTTP glob completeness loss, literal Git directory handling and raw timeout-setting normalization. Each finding reset the clean-pass count. The final stable 39-file diff then passed two consecutive independent open-ended and reverse reviews with no further confirmed findings. The exact pre-commit diff SHA-256 was Scope and environment macOS arm64, Node 22.22.2, isolated OpenSSH/Python 3.9.6 and a loopback model fixture; no remote Qwen daemon or paid model API. Latest Linux execution and actual Windows execution were not rerun. The separate Debian 13 / Python 3.13.5 report records 121 checks on Deliberate boundaries are documented in both languages: the private SSH anchor namespace fails closed; skills and the declared remote project services remain unsupported; submodule search reports incompleteness; dubious Git ownership does not alter Git trust; abrupt remote termination cannot guarantee temporary-file cleanup. |
|
Follow-up fixes are in aa60679, which also incorporates main 97b1b25. This supplies the current lint gate and the Runtime Broker module required by Java CI. The prior fix commit is 1988bf9. The late review's remaining defects are addressed: reusable compound-shell permissions and substitution warnings; effective default/custom ignore files (including relative and equivalent ./ paths and an explicit empty list); denial auditing for public filesystem validation with nested-event deduplication and existing privacy preserved; and truthful secondary ACP capability advertisement. Reverse review additionally found and fixed SSH participation in channel restoration/ownership and missing GET/DELETE admission for ACP HTTP event streams. Final verification on the rebuilt artifact:
The carried review-body questions were also checked against current behavior: untrusted SSH file reads follow the existing read-intent policy while writes remain gated; search completeness survives through the transport and HTTP/ACP consumers; and local SSH-session setting writes succeed while unsupported workflow execution remains disabled. The private ssh-workspaces namespace remains intentionally fail-closed, with the rationale recorded in its thread. Root canonicalization and codeModeOnly were already fixed in 1988bf9. Limitations remain explicit: the earlier native review timed out without a verdict, and broader local suites had environment-dependent failures documented in the previous report. This comment does not claim those suites passed, or claim Windows/Linux execution that was not performed. Fresh hosted CI is being checked separately. 补充修复及主分支同步已提交为 aa60679。最新真实 SSH、权限持久化、忽略规则、拒绝审计和 ACP HTTP 事件流验证通过;构建、类型检查、全仓库 Lint/格式检查,以及 112 项核心与 987 项 CLI 定向测试通过。提交前完成连续两轮独立无方向审计和反向审计,提交树与审查版本一致。仍保留此前全量测试和平台验证的明确限制,不将超时审查或未执行的平台检查计为通过。 Final hosted CI receipt (aa60679)Qwen Code CI completed successfully, including Lint & Static, Linux Node 22 tests, no-AK integration tests, Linux/Windows Desktop Shell and the dependent Web Shell browser E2E smoke. The Linux test log confirms 32,882 CLI tests passed (92 skipped) and 29,755 Core tests passed (10 skipped). Java SDK platform/version lanes and real-daemon E2E, Serve A/B, TUI parity/no-flicker, Web Shell visual capture and macOS Live Host also passed. As of 2026-09-21 16:44 UTC, the PR has 24 successful checks, 26 configured skips and no failed checks; only the separate automatic GitHub review is still running. All 74 existing review threads are resolved. The PR is mergeable and has not been merged. 最新 CI 已通过,包含 Linux 全量单测、Java 多平台测试及 Web Shell 浏览器 E2E。74 条现有评论均已处理;仅 GitHub 自动评审仍在运行。 |
SSH workspace verification on a real LAN host (
|
| Group | Pass | What it shows |
|---|---|---|
| Registration and identity | 14 | The dialog now shows the SSH prerequisites. The registration saved by the 1988bf9 build was restored under aa60679 with the same id, name and trust state. :22 and the implicit port are separate identities. A symlinked root resolves to its canonical directory, so adding it a second time returns 409. Ten bad URLs return 400 and add no catalog entry: a password, a query, a fragment, a missing path, a -oProxyCommand=… host, a missing directory, a file path, an unknown user (BatchMode, 0.2 s), an unknown host key (the same machine's IPv6 literal, which is not in known_hosts; 0.1 s) and an unreachable host (the 10 s ConnectTimeout). |
| Hostile directory name | 4 | proj $(touch PWNED_CANARY) 'q registers and supports read, write, glob and list. No canary file is created anywhere. |
| Trust gate | 8 | A new identity starts untrusted. Read and list work. Write, upload and Git return 403 untrusted_workspace, and nothing appears on the host. Trusting the anchor takes effect through hot reload, without a restart. |
| File routes | 26 | Read, write, edit and upload work on the remote bytes. An LF write into a BOM+CRLF file keeps EF BB BF and CRLF byte for byte. A stale hash returns 409 and leaves the file untouched. Seven escape attempts return 400: file and directory symlinks, writes through a symlink, ../ and absolute paths. .gitignore and a nested sub/.qwenignore are honoured next to a 19,000-file ignored node_modules/, with truncated:false. includeIgnored flags ignored entries. A byte window at 18 MiB of a 20 MiB file works. A 20 MiB text read and a 17 MiB upload return 413 and create nothing. No .qwen-write-* files are left behind. A write into a missing parent returns the same 404 as on a local workspace. |
| Git | 17 | Status, stash count and numstat match git run on the host (Git 2.34.1). Tracked and untracked hunks both render. With 620 changed files the route returns counts only. Commit, checkout, branch, log and PR routes return 501, and HEAD is unchanged. A repository owned by uid 1000 (dubious ownership) returns an explicit 503 for both status and search, and the host's safe.directory is untouched. Per-route timings are below. |
| Outage | 8 | The target's firewall rejected new SSH connections from the daemon host. Every SSH route failed with 503 in 70–130 ms. The local workspace kept working and the SSH workspace stayed listed. After the rule was removed, reads recovered without re-registration and the failed write was not replayed. |
| Transport | 6 | A remote exit 255 counts as a completed command. stdout, stderr and the exit status are framed separately. Multi-byte UTF-8 survives. A sub-directory cwd works, and a cwd through a symlink is refused. A timeout error says the command's status is uncertain. |
| Agent (real qwen3.8-max in the Web Shell) | 2 pass, 3 fail | A 5-step task (shell, read, create, edit, grep) ran on the host and printed aarch64 / orangepi5 / Python 3.10.12. Each step required approval. I checked the resulting bytes on the host. Nested .qwenignore was respected, and nothing was written to the local anchor. On 1988bf9, create_sub_session also produced a sub-session bound to the same SSH host. F1 and F2 are below. |
| Terminal and menus | 2 | The pty runs sshd: root@pts/1 → /usr/bin/zsh -l in the project directory with the user's own prompt. Reloading the page replays the output. The SSH menu hides Open folder, Open terminal and Manage. Copy path copies /root/pr12255-lab/proj. |
| ACP over HTTP | 3 | On 1988bf9 the SSH workspace answered initialize, but GET /acp returned 501, so every later request got 202 and never an answer; the local workspace worked. On aa60679 the SSE GET returns 200, _qwen/file/read returns the remote file, _qwen/workspace/memory gets an explicit "unsupported" error, DELETE returns 202, and requests on the closed connection get 404. |
| Settings (R3-1) and idle cost | 3 | Saving the workflow setting on an SSH workspace with 10 live sessions returns 200. The value lives only in the local anchor: there is no .qwen on the host, and a new SSH session still gets no workflow tool. An open Web Shell makes 0 SSH connections per idle minute. |
Per-route cost on this link (median of 3):
| Route | Time | SSH connections |
|---|---|---|
| File read | 0.65 s | 1 |
| List | 0.88 s | 1 |
| Glob | 0.77 s | 1 |
| Git status | 2.8 s | 4 |
| Git diff | 5.4 s | 7 |
| Single-file diff | 3.9 s | 5 |
Every connection is a full SSH handshake, which takes about 0.6 s to this board.
Findings
F1: the shell tool promises bash -c, but the SSH host runs /bin/sh. In the request log, all 33 model requests from SSH sessions describe run_shell_command as "Executes a given shell command (as bash -c <command>)" and "Exact bash command to execute as bash -c <command>" (packages/core/src/tools/shell.ts:5262, :5315). The remote script actually runs ['/bin/sh', '-c', command] (packages/core/src/services/ssh-workspace-script.ts:278). On Debian and Ubuntu /bin/sh is dash, so [[ ]], source, set -o pipefail and brace expansion all fail. In a real run over SSH, the same prompt printed /bin/sh: 1: [[: not found and shell is /bin/sh. On the local workspace it printed DOUBLE_BRACKET_OK and shell is bash. The model can adapt after a failure; it switched to . on the next turn. Still, every miss costs a turn, and a failed set -o pipefail aborts the whole command. The earlier Linux round also used Debian but did not try bash syntax.
F2: the agent's edit and write_file over SSH skip the line-ending and BOM handling that the local tools apply. prepareChange splits the raw remote text on the model's old_string and writes content exactly as sent (packages/core/src/services/ssh-execution-environment.ts:304, :316). The local edit.ts and write-file.ts compare after converting CRLF to LF, then write back using the file's detected line ending and BOM. A real run on a BOM+CRLF file gave these results:
- Multi-line
edit: every attempt failed withold_string was not found in the remote file.The model tried LF, CRLF and whole-file variants, then concluded that it "cannot transmit a literal carriage return". That took 35 tool calls, 5 min 43 s and 320k input tokens, and the file was not changed. On the local workspace the same change took oneedit, 8 tool calls and 37 s. write_file: the remote bytes becamered\ngreen\nblue\n. The BOM was silently dropped and CRLF became LF. The local workspace keptEF BB BFand CRLF.
The PR's tests cover a single-line edit on a CRLF file, and a new file whose BOM is supplied verbatim, but neither of these two cases. The description says "existing-file writes preserve UTF-8 BOM and line endings". That is true for the Web Shell file routes (verified above) but not for the agent.
F4: a timed-out or cancelled command keeps running on the host. I ran sleep 6; touch after-timeout with a 1.5 s timeout over a healthy connection. The call returned the "status is uncertain" error, but /bin/sh -c sleep 6… kept running on the host, and the marker file appeared 7 s later. A user cancel through AbortSignal behaved the same way. The client kills only its local ssh process (packages/core/src/services/ssh-workspace.ts:349), and nothing stops the remote process group. The design accepts uncertainty when the connection drops, but here the connection was fine. As a result, if the agent runs npm run dev, tail -f or a long build that times out, the process stays on the remote host with no end.
Candidate patch (verified on the same host)
The patch touches 4 files (+103/−15 lines):
ssh-workspace-script.ts: prefer/bin/bashand fall back to/bin/sh. Start the command in its own session and watch fd 1 for POLLERR/POLLHUP; sshd closes that fd when the client goes away. When that happens, send SIGTERM and then SIGKILL to the command's process group.ssh-execution-environment.ts: compare text after converting CRLF to LF and removing the BOM, then write back in the file's detected format. This uses the samedetectLineEndingas the local tools.
Results on orangepi5 with the patched build:
- F1: the command printed
DOUBLE_BRACKET_OKandshell is /bin/bash;pipefailandsourcework. - F2: one
editcall, 0 tool errors. The bytes areEF BB BF alpha\r\nbeta-and-gamma\r\nandEF BB BF red\r\ngreen\r\nblue\r\n, identical to the local workspace. - F4: the remote process disappears immediately after the timeout, and the marker file is absent after both the timeout and the cancel.
- A 1.29 MB output plus stderr and exit code 4 still arrive intact.
- A regression re-run on the patched build passed 89 of 89 checks (trust, files, Git, registration, hostile names, outage, transport).
The patch adds three tests. The two that run on macOS fail against the aa60679 sources and pass with the patch. The disconnect test is Linux-only, because macOS poll() does not report a pipe whose reader has closed. I ran its scenario on the target with the built script, and no marker file was created. With the patch, the core SSH test files show 114 passed and 1 skipped, and eslint, prettier and tsc are clean. The full diff is at the end of this comment.
Observations (non-blocking)
- O1: the approval card does not say where the command runs. The card subtitle shows the model's
descriptionwhen one is present. It falls back torun_shell_command on [email protected]: /root/…only when the description is missing (getDescriptionTextinToolApproval.tsx). Exec cards also don't render the SSH warning. qwen3.8-max always sends a description, so the card showed no host at all (image below). The earlier report's screenshot showed the host only because that command had no description. - O2: session tooltips show the private anchor path (
…/ssh-workspaces/<sha256>/workspace) instead of the remote path. The workspace header already uses the SSH label. - O3: errors say "The remote command may still be running…" even when nothing started. This appears on connection refused, authentication failure, host-key failure and rejection of a symlinked cwd.
- O4: after an
ssh://URL is typed, the dialog still shows "Browse…" and "Choose a folder below". Both refer to the local daemon's filesystem. - O5: latency. Each operation opens a new SSH connection, so Git inspection takes 3–5 s on this LAN. The daemon uses the user's normal OpenSSH configuration, so documenting
ControlMaster/ControlPersistmight help. I did not test that here.
Test suites and CI
On 1988bf9, before the head moved, the PR's 29 changed test files all passed: 1515 core, 3892 CLI and 194 Web Shell tests. Two notes about this Mac: the tests that run python3 need DEVELOPER_DIR set, otherwise the Xcode license prompt makes 34 core and 12 CLI tests fail, and client.test.ts needs a larger heap. I did not re-run the full suites on aa60679; its changes are covered by hosted CI, which shows 24 passed, 26 skipped, and only review-pr still pending.
Environment
- Local side: macOS 26 (Apple silicon), Node 24.18.1. The daemon ran from
dist/cli.jsbuilt ataa60679(pnpm worktree bootstrap, thennpm run build && npm run bundle), with an isolatedQWEN_HOMEand folder trust enabled. Playwright Chromium drove the Web Shell. - Target: Orange Pi 5, Armbian 25.8.1 on an Ubuntu 22.04 base, kernel 5.10, aarch64, OpenSSH on port 22. root's login shell is zsh and
/bin/shis dash. Python 3.10.12, Git 2.34.1. No Qwen and nothing else was installed. - Model: qwen3.8-max (DashScope).
- Cleanup: the fixtures in
/root/pr12255-labhave been removed. The temporary firewall rule rejected only new port-22 connections from the daemon host, was set to remove itself after 75 s, and is confirmed gone.
Candidate patch (git diff against aa60679)
diff --git a/packages/core/src/services/ssh-execution-environment.test.ts b/packages/core/src/services/ssh-execution-environment.test.ts
index ec4294253b..54defc775d 100644
--- a/packages/core/src/services/ssh-execution-environment.test.ts
+++ b/packages/core/src/services/ssh-execution-environment.test.ts
@@ -258,6 +258,24 @@ describe('SshExecutionEnvironment', () => {
expect((await read(`${remote}/new.txt`)).llmContent).toBe(content);
});
+ it('matches LF edits against BOM+CRLF files and keeps their format on overwrite', async () => {
+ files.set(`${remote}/crlf.txt`, '\uFEFFalpha\r\nbeta\r\ngamma\r\n');
+ await read(`${remote}/crlf.txt`);
+ await execute(ToolNames.EDIT, {
+ file_path: `${remote}/crlf.txt`,
+ old_string: 'beta\ngamma',
+ new_string: 'beta-and-gamma',
+ });
+ expect(files.get(`${remote}/crlf.txt`)).toBe(
+ '\uFEFFalpha\r\nbeta-and-gamma\r\n',
+ );
+ await execute(ToolNames.WRITE_FILE, {
+ file_path: `${remote}/crlf.txt`,
+ content: 'red\ngreen\n',
+ });
+ expect(files.get(`${remote}/crlf.txt`)).toBe('\uFEFFred\r\ngreen\r\n');
+ });
+
it('honors manually edited proposals and confirmation payloads', async () => {
await read();
await environment.prepare(
diff --git a/packages/core/src/services/ssh-execution-environment.ts b/packages/core/src/services/ssh-execution-environment.ts
index aaf358d17a..51d561cc39 100644
--- a/packages/core/src/services/ssh-execution-environment.ts
+++ b/packages/core/src/services/ssh-execution-environment.ts
@@ -11,6 +11,7 @@ import {
splitCommands,
} from '../utils/shell-utils.js';
import { extractCommandRules } from '../utils/shellAstParser.js';
+import { detectLineEnding } from './fileSystemService.js';
import type { PermissionDecision } from '../permissions/types.js';
import { createPatchSmart } from '../tools/diffOptions.js';
import { ToolNames } from '../tools/tool-names.js';
@@ -298,10 +299,21 @@ export class SshExecutionEnvironment implements ExecutionEnvironment {
'Read the remote file with read_file before editing or overwriting it; it is unread or changed since the last read.',
);
}
+ // Match the local edit/write tools: compare with LF and no BOM, then
+ // write back in the existing file's BOM and line-ending format.
+ const bom = read?.content.startsWith('\uFEFF') ?? false;
+ const lineEnding = read ? detectLineEnding(read.content) : 'lf';
+ const normalized = (text: string) => text.replace(/\r\n/g, '\n');
+ const toFileFormat = (text: string): string => {
+ const body = normalized(text.startsWith('\uFEFF') ? text.slice(1) : text);
+ const ended = lineEnding === 'crlf' ? body.replace(/\n/g, '\r\n') : body;
+ return bom ? `\uFEFF${ended}` : ended;
+ };
if (toolName === ToolNames.WRITE_FILE) {
+ const content = stringParam(params, 'content');
return {
current: read?.content ?? null,
- proposed: stringParam(params, 'content'),
+ proposed: read ? toFileFormat(content) : content,
hash: read?.hash,
};
}
@@ -313,7 +325,9 @@ export class SshExecutionEnvironment implements ExecutionEnvironment {
}
if (!oldString)
throw new Error('old_string must not be empty for an existing file.');
- const pieces = read.content.split(oldString);
+ const pieces = normalized(bom ? read.content.slice(1) : read.content).split(
+ normalized(oldString),
+ );
if (pieces.length === 1)
throw new Error('old_string was not found in the remote file.');
if (pieces.length > 2 && params['replace_all'] !== true)
@@ -322,7 +336,7 @@ export class SshExecutionEnvironment implements ExecutionEnvironment {
);
return {
current: read.content,
- proposed: pieces.join(newString),
+ proposed: toFileFormat(pieces.join(normalized(newString))),
hash: read.hash,
};
}
diff --git a/packages/core/src/services/ssh-workspace-script.test.ts b/packages/core/src/services/ssh-workspace-script.test.ts
index dd3db59f6b..18e33e4daa 100644
--- a/packages/core/src/services/ssh-workspace-script.test.ts
+++ b/packages/core/src/services/ssh-workspace-script.test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { execFileSync, spawnSync } from 'node:child_process';
+import { execFileSync, spawn, spawnSync } from 'node:child_process';
import {
mkdtempSync,
realpathSync,
@@ -410,6 +410,47 @@ describe.skipIf(process.platform === 'win32')('SSH filesystem script', () => {
).toBe(`${join(root, "quoted ' directory")}\nvalue\n`);
expect(child.stderr).toBe('');
});
+ it('runs commands with bash to honour the bash -c tool contract', () => {
+ const child = spawnSync('python3', ['-c', SSH_WORKSPACE_SCRIPT], {
+ input: JSON.stringify({
+ root,
+ operation: 'execute',
+ params: { command: '[[ -d . ]] && echo "$0"' },
+ }),
+ encoding: 'utf8',
+ });
+ const frames = child.stdout
+ .trim()
+ .split('\n')
+ .map((line) => JSON.parse(line));
+ expect(frames.at(-1)).toEqual({ ok: true, result: { exitCode: 0 } });
+ expect(Buffer.from(frames[0].data, 'base64').toString()).toMatch(/bash\n$/);
+ });
+
+ // SSH targets are Linux; macOS poll() does not flag a pipe whose reader closed.
+ it.skipIf(process.platform !== 'linux')(
+ 'stops the command when the SSH client stops reading',
+ async () => {
+ const marker = join(root, 'after-disconnect');
+ const child = spawn('python3', ['-c', SSH_WORKSPACE_SCRIPT], {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ child.stdin.end(
+ JSON.stringify({
+ root,
+ operation: 'execute',
+ params: { command: `sleep 2; touch '${marker}'` },
+ }),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ child.stdout.destroy();
+ await new Promise((resolve) => child.on('close', resolve));
+ await new Promise((resolve) => setTimeout(resolve, 2500));
+ expect(existsSync(marker)).toBe(false);
+ },
+ 10_000,
+ );
+
it('lists FIFOs without opening them and rejects reading one', () => {
execFileSync('mkfifo', [join(root, 'pipe')]);
expect(request('list')).toMatchObject({
diff --git a/packages/core/src/services/ssh-workspace-script.ts b/packages/core/src/services/ssh-workspace-script.ts
index 788050812d..ab669756e8 100644
--- a/packages/core/src/services/ssh-workspace-script.ts
+++ b/packages/core/src/services/ssh-workspace-script.ts
@@ -8,7 +8,7 @@ import { getQwenIgnoreFileNames } from '../utils/qwenIgnoreParser.js';
// Sent as a Python -c argument; requests arrive on stdin, never in shell text.
export const SSH_WORKSPACE_SCRIPT = String.raw`
-import base64, errno, fcntl, fnmatch, hashlib, json, os, re, selectors, stat, subprocess, sys, time, uuid
+import base64, errno, fcntl, fnmatch, hashlib, json, os, re, select, selectors, signal, stat, subprocess, sys, time, uuid
MAX_BYTES = 16 * 1024 * 1024
MAX_ENTRIES = 50000
@@ -275,22 +275,37 @@ def dispatch(operation, params):
command = params.get('command')
if not isinstance(command, str) or '\0' in command:
fail('invalid_argument', 'Invalid remote shell command.')
- process = subprocess.Popen(['/bin/sh', '-c', command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- selector = selectors.DefaultSelector()
- selector.register(process.stdout, selectors.EVENT_READ, 'stdout')
- selector.register(process.stderr, selectors.EVENT_READ, 'stderr')
+ # The shell tool contract is bash -c; fall back to sh only without bash.
+ shell = next((candidate for candidate in ['/bin/bash', '/usr/bin/bash'] if os.access(candidate, os.X_OK)), '/bin/sh')
+ process = subprocess.Popen([shell, '-c', command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
+ streams = {process.stdout.fileno(): 'stdout', process.stderr.fileno(): 'stderr'}
+ poller = select.poll()
+ for fd in streams:
+ poller.register(fd, select.POLLIN)
+ # sshd closes our stdout when the client disconnects, times out or
+ # cancels; stop the command's process group instead of orphaning it.
+ poller.register(1, 0)
try:
- while selector.get_map():
- for key, event in selector.select():
- chunk = os.read(key.fd, 65536)
+ while streams:
+ for fd, event in poller.poll():
+ if fd == 1:
+ if event & (select.POLLERR | select.POLLHUP):
+ os.killpg(process.pid, signal.SIGTERM)
+ try:
+ process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ os._exit(1)
+ continue
+ chunk = os.read(fd, 65536)
if not chunk:
- selector.unregister(key.fileobj)
+ poller.unregister(fd)
+ del streams[fd]
continue
- print(json.dumps({'stream': key.data, 'data': base64.b64encode(chunk).decode('ascii')}), flush=True)
+ print(json.dumps({'stream': streams[fd], 'data': base64.b64encode(chunk).decode('ascii')}), flush=True)
code = process.wait()
return {'exitCode': code if code >= 0 else 128 - code}
finally:
- selector.close()
process.stdout.close()
process.stderr.close()
if operation == 'stat':中文说明
在真实局域网主机上验证 SSH 工作区(aa60679)
本轮在另一台物理 Linux 机器上通过局域网测试本 PR,不再使用回环 sshd,并用真实模型通过真实 Web Shell 驱动 agent。目标机与前两份报告都不同:
- arm64 上的 Ubuntu 22.04(Orange Pi 5)
/bin/sh是 dash- Python 3.10.12、Git 2.34.1
- root 的登录 shell 是 zsh
- 连接使用维护者日常的
~/.ssh密钥和known_hosts,没有任何实验专用配置
验证从 1988bf9 开始,中途 head 移到 aa60679,因此下文所有内容都在 aa60679 上重新构建并重跑。1988bf9 的结果只在有补充价值时出现。
aa60679 上的结果:93 项检查通过,3 项失败。 我测试的每个方面,daemon 与 Web Shell 的边界都守住了:信任、路径边界、符号链接、恶意目录名、断连、持久化、Git、终端和 ACP。失败全部出在 agent 的 SSH 执行路径:该路径与本地工具存在差异,真实模型在普通 Debian 或 Ubuntu 主机上会立刻碰到(见下文 F1、F2)。另外单独测到第三个问题:超时或被取消的命令会继续留在远端主机上运行(F4)。一份候选补丁在同一台主机上修复了这三项:共 4 个文件,+103/−15 行,含 3 个测试。在打补丁的构建上完整重跑,89 项检查全部通过。
aa60679 上运行的内容
| 分组 | 通过 | 说明 |
|---|---|---|
| 注册与身份 | 14 | 添加工作区对话框现在会显示 SSH 前置条件。1988bf9 构建保存的注册在 aa60679 下以相同的 id、名称和信任状态恢复。:22 与省略端口是不同身份。符号链接根目录解析为规范目录,所以第二次添加返回 409。10 个非法 URL 均返回 400,且不产生目录项:带密码、带查询串、带片段、缺路径、-oProxyCommand=… 形式的主机、目录不存在、路径是文件、未知用户(BatchMode,0.2 s)、未知主机密钥(同一台机器的 IPv6 字面量,不在 known_hosts 中;0.1 s)、主机不可达(10 s ConnectTimeout)。 |
| 恶意目录名 | 4 | proj $(touch PWNED_CANARY) 'q 可以注册,读、写、glob、列目录都正常。任何位置都没有生成金丝雀文件。 |
| 信任门 | 8 | 新身份默认不受信任。读和列目录可用。写入、上传和 Git 返回 403 untrusted_workspace,远端没有出现任何文件。信任锚点后通过热加载立即生效,无需重启。 |
| 文件路由 | 26 | 读、写、编辑、上传都作用于远端真实字节。向 BOM+CRLF 文件写入 LF 内容时,逐字节保留 EF BB BF 与 CRLF。哈希过期返回 409,文件不变。7 种越界尝试都返回 400:文件和目录符号链接、穿过符号链接写入、../ 以及绝对路径。在有 1.9 万文件被忽略的 node_modules/ 旁边,.gitignore 和嵌套的 sub/.qwenignore 仍然生效,且 truncated:false。includeIgnored 会标记被忽略的条目。20 MiB 文件在 18 MiB 处的字节窗口可读。20 MiB 文本读取和 17 MiB 上传返回 413,且不产生任何文件。没有残留 .qwen-write-* 文件。写入不存在的父目录时,返回与本地工作区相同的 404。 |
| Git | 17 | status、stash 数量、numstat 与在主机上直接执行 git(2.34.1)的结果一致。已跟踪与未跟踪文件的 hunk 都能渲染。620 个改动文件时路由只返回计数。commit、checkout、branch、log、PR 路由返回 501,HEAD 未变。uid 1000 所有的仓库(所有权可疑)在 status 和搜索上都明确返回 503,主机上的 safe.directory 未被改动。各路由耗时见下文。 |
| 断连 | 8 | 在目标机防火墙上拒绝来自 daemon 主机的新 SSH 连接。每个 SSH 路由都在 70–130 ms 内返回 503。本地工作区照常可用,SSH 工作区仍保留在列表中。删除规则后,读取无需重新注册即恢复,失败的写入没有被重放。 |
| 传输层 | 6 | 远端 exit 255 被视为正常完成的命令。stdout、stderr 与退出状态分别分帧。多字节 UTF-8 完整保留。子目录 cwd 可用,穿过符号链接的 cwd 被拒。超时错误会说明命令状态不确定。 |
| Agent(Web Shell 中的真实 qwen3.8-max) | 2 通过,3 失败 | 一个 5 步任务(shell、读取、新建、编辑、grep)在远端主机上执行,输出 aarch64 / orangepi5 / Python 3.10.12。每一步都需要审批。我在主机上核对了结果字节。嵌套 .qwenignore 得到遵守,本地锚点没有写入任何文件。在 1988bf9 上,create_sub_session 生成的子会话同样绑定到这台 SSH 主机。F1、F2 见下文。 |
| 终端与菜单 | 2 | pty 在项目目录中运行 sshd: root@pts/1 → /usr/bin/zsh -l,显示用户自己的提示符。刷新页面后输出会回放。SSH 菜单隐藏了 Open folder、Open terminal 和 Manage。Copy path 复制的是 /root/pr12255-lab/proj。 |
| HTTP 上的 ACP | 3 | 在 1988bf9 上,SSH 工作区能响应 initialize,但 GET /acp 返回 501,因此之后每个请求只收到 202,永远等不到结果;本地工作区正常。aa60679 上 SSE GET 返回 200,_qwen/file/read 返回远端文件,_qwen/workspace/memory 收到明确的“不支持”错误,DELETE 返回 202,已关闭连接上的请求返回 404。 |
| 设置(R3-1)与空闲开销 | 3 | 在有 10 个活跃会话的 SSH 工作区上保存 workflow 设置返回 200。该值只存在本地锚点中:主机上没有 .qwen,新建的 SSH 会话也仍然没有 workflow 工具。打开的 Web Shell 空闲时每分钟 0 条 SSH 连接。 |
该链路上各路由的耗时(3 次取中位数):
| 路由 | 耗时 | SSH 连接数 |
|---|---|---|
| 读文件 | 0.65 s | 1 |
| 列目录 | 0.88 s | 1 |
| glob | 0.77 s | 1 |
| Git status | 2.8 s | 4 |
| Git diff | 5.4 s | 7 |
| 单文件 diff | 3.9 s | 5 |
每条连接都是一次完整的 SSH 握手,到这块板子约需 0.6 s。
截图见英文部分:添加对话框(01),agent 报告与远端终端(02)。
发现
F1:shell 工具承诺 bash -c,SSH 主机实际运行 /bin/sh。 请求日志中,SSH 会话发给模型的全部 33 个请求都把 run_shell_command 描述为 “Executes a given shell command (as bash -c <command>)” 和 “Exact bash command to execute as bash -c <command>”(packages/core/src/tools/shell.ts:5262、:5315)。远端脚本实际执行的是 ['/bin/sh', '-c', command](packages/core/src/services/ssh-workspace-script.ts:278)。在 Debian 和 Ubuntu 上 /bin/sh 是 dash,所以 [[ ]]、source、set -o pipefail 和花括号展开全都失败。真实运行中,同一 prompt 在 SSH 上输出 /bin/sh: 1: [[: not found 和 shell is /bin/sh,在本地工作区输出 DOUBLE_BRACKET_OK 和 shell is bash。模型在失败后可以调整,下一轮就改用了 .。但每失败一次都要多花一轮,而 set -o pipefail 失败会让整条命令中止。上一轮 Linux 验证同样用的是 Debian,但没有测试 bash 语法。
F2:agent 通过 SSH 执行 edit 和 write_file 时,跳过了本地工具会做的行尾与 BOM 处理。 prepareChange 直接用模型给的 old_string 切分远端原始文本,并把 content 原样写入(packages/core/src/services/ssh-execution-environment.ts:304、:316)。本地的 edit.ts 和 write-file.ts 先把 CRLF 转成 LF 再比对,写回时使用文件检测到的行尾和 BOM。在 BOM+CRLF 文件上的真实运行结果如下:
- 多行
edit: 每次都失败,报old_string was not found in the remote file.。模型先后尝试了 LF、CRLF 和整文件替换,最后得出结论:它“无法传递字面回车符”。整个过程用了 35 次工具调用、5 分 43 秒和 32 万输入 token,文件没有任何改动。同样的修改在本地工作区只需一次edit、8 次工具调用和 37 秒。 write_file: 远端字节变成red\ngreen\nblue\n。BOM 被悄悄丢掉,CRLF 变成了 LF。本地工作区保留了EF BB BF和 CRLF。
PR 的测试覆盖了 CRLF 文件上的单行编辑,以及原样传入 BOM 的新文件,但不覆盖上述两种情况。PR 描述说“已有文件写入保留 UTF-8 BOM 和换行格式”。这一点对 Web Shell 文件路由成立(上文已验证),对 agent 不成立。
F4:超时或被取消的命令会继续在主机上运行。 我在正常连接下运行 sleep 6; touch after-timeout,超时设为 1.5 s。调用返回了“状态不确定”的错误,但 /bin/sh -c sleep 6… 仍在主机上运行,7 秒后标记文件出现。通过 AbortSignal 取消时结果相同。客户端只杀掉本地的 ssh 进程(packages/core/src/services/ssh-workspace.ts:349),没有任何机制停止远端进程组。设计文档接受断连时状态不确定,但这里连接完全正常。因此,如果 agent 执行 npm run dev、tail -f 或长时间构建并遇到超时,这个进程会一直留在远端主机上。
候选补丁(已在同一主机上验证)
补丁改动 4 个文件(+103/−15 行):
ssh-workspace-script.ts: 优先使用/bin/bash,没有时回退到/bin/sh。命令在独立会话中启动,并监听 fd 1 上的 POLLERR/POLLHUP;客户端离开时 sshd 会关闭这个 fd。检测到后,先对命令的进程组发送 SIGTERM,再发送 SIGKILL。ssh-execution-environment.ts: 先把 CRLF 转成 LF 并去掉 BOM 再比对,写回时使用文件检测到的格式。这里用的是本地工具同一个detectLineEnding。
在 orangepi5 上用打补丁的构建得到的结果:
- F1: 命令输出
DOUBLE_BRACKET_OK和shell is /bin/bash;pipefail和source可用。 - F2: 一次
edit调用,0 个工具错误。字节为EF BB BF alpha\r\nbeta-and-gamma\r\n和EF BB BF red\r\ngreen\r\nblue\r\n,与本地工作区完全一致。 - F4: 超时后远端进程立即消失;超时和取消两种情况下都没有生成标记文件。
- 1.29 MB 输出加上 stderr 和退出码 4 仍能完整到达。
- 在打补丁的构建上做回归重跑,89 项检查全部通过(信任、文件、Git、注册、恶意目录名、断连、传输层)。
补丁新增 3 个测试。可在 macOS 上运行的 2 个,在 aa60679 源码上失败、打补丁后通过。断连测试仅在 Linux 上运行,因为 macOS 的 poll() 不会报告读端已关闭的管道。我在目标机上用构建产物的脚本跑了该场景,没有生成标记文件。打补丁后,core 的 SSH 测试文件 114 项通过、1 项跳过,eslint、prettier 和 tsc 均无问题。完整 diff 见英文部分末尾。
观察(非阻塞)
- O1:审批卡片没有显示命令在哪里运行。 卡片副标题在模型提供了
description时显示该描述;只有缺少描述时才回退到run_shell_command on [email protected]: /root/…(ToolApproval.tsx的getDescriptionText)。exec 类卡片也不渲染 SSH 警告。qwen3.8-max 总会提供描述,所以卡片上完全看不到主机(见英文部分截图06)。上一份报告的截图之所以显示了主机,只是因为那条命令没有描述。 - O2:会话 tooltip 显示的是私有锚点路径(
…/ssh-workspaces/<sha256>/workspace),而不是远端路径。工作区标题已经在使用 SSH 标签。 - O3:即使命令根本没有启动,错误里也会带上 “The remote command may still be running…”。 连接被拒、认证失败、主机密钥校验失败以及符号链接 cwd 被拒时都会出现这句话。
- O4:输入
ssh://地址后,对话框仍然显示 “Browse…” 和 “Choose a folder below”。 两者针对的都是本地 daemon 的文件系统。 - O5:延迟。 每次操作都要新建一条 SSH 连接,所以在这个局域网上 Git 查看需要 3–5 秒。daemon 使用用户的常规 OpenSSH 配置,在文档中说明
ControlMaster/ControlPersist可能有帮助。本轮没有测试这一点。
测试套件与 CI
在 head 移动之前,1988bf9 上 PR 改动的 29 个测试文件全部通过:core 1515 项、CLI 3892 项、Web Shell 194 项。关于这台 Mac 有两点说明:运行 python3 的测试需要设置 DEVELOPER_DIR,否则 Xcode 许可提示会让 core 34 项、CLI 12 项失败;client.test.ts 需要更大的堆。我没有在 aa60679 上重跑完整套件;它的改动由托管 CI 覆盖,CI 显示 24 项通过、26 项跳过,只有 review-pr 仍在进行。
环境
- 本地端: macOS 26(Apple 芯片),Node 24.18.1。daemon 运行的是在
aa60679构建的dist/cli.js(先用 pnpm 初始化工作树,再执行npm run build && npm run bundle),使用隔离的QWEN_HOME并开启文件夹信任。Web Shell 由 Playwright Chromium 驱动。 - 目标机: Orange Pi 5,Armbian 25.8.1(基于 Ubuntu 22.04),内核 5.10,aarch64,OpenSSH 监听 22 端口。root 的登录 shell 是 zsh,
/bin/sh是 dash。Python 3.10.12、Git 2.34.1。没有安装 Qwen,也没有安装其他任何东西。 - 模型: qwen3.8-max(DashScope)。
- 清理:
/root/pr12255-lab中的 fixture 已删除。临时防火墙规则只拒绝来自 daemon 主机的新 22 端口连接,设置了 75 秒后自动删除,并已确认不再存在。
|
Follow-up to the real LAN report: F1, F2 and F4 are fixed in 0fbecd3.
The proposed stdout-poll patch was not applied verbatim: it missed redirected output, surviving TERM-ignoring children and macOS pipe behavior. The input-channel mechanism is verified on Linux over real SSH and through direct-script cancellation tests on macOS. Reproduction and verification All three defects were independently reproduced against aa60679 on the reported LAN Linux host (Ubuntu 22.04 arm64, Python 3.10.12, /bin/sh=dash). The global CLI was tried first and rejected SSH registration; the follow-up used the actual CLI tool chain with isolated runtime state and a local model fixture, with no paid model or remote Qwen service. On the rebuilt artifact, the same shell/agent edit/write scenarios pass. Remote bytes are exactly Build, bundle and complete repository typecheck passed. Relevant core SSH/shell tests passed 482/482, execution integration tests 22/22, CLI configuration 467/467 and Web Shell 47/47. The script's complete 45-test file was rerun successfully after a test-cleanup correction. Changed-file ESLint/Prettier passed. These are targeted local tests, not a fresh full-suite claim. Windows shell declarations are tested through platform controls; no real Windows SSH session was run. The tested CLI bundle SHA256 is Pre-commit audit The first reverse pass found that an empty test PID file could be interpreted as process group zero; its cleanup was corrected and the clean-pass count reset. The complete 11-file increment then passed two consecutive independent open-ended/reverse audits. The reviewed diff SHA256 is Non-blocking observations explicitly deferred This PR has already undergone several review rounds; the repository guidance now limits additions to correctness fixes. O1–O5 remain recorded for follow-up rather than being silently treated as fixed:
针对真实 LAN 报告的 F1、F2、F4 已在 提交前完成连续两轮独立无方向审计与反向审计,发现测试清理风险后曾重置计数;提交树与审计树完全一致。构建、打包、全仓类型检查、改动文件 Lint/格式检查及上述定向测试通过。GitHub 自动评审此前因六小时时限失败,没有结论,不计为批准。新提交 CI 正在运行。 O1–O5 按仓库多轮评审后的范围要求明确延期:审批卡片始终显示主机、会话 tooltip 远端路径、执行前失败措辞、SSH 输入时隐藏本地浏览建议,以及 SSH 连接复用的延迟评估;本次没有宣称这些观察项已修复。 |
|
Verdict: merge-ready — 167/167 scripted assertions passed, 0 failed. Verified head This is a follow-up round to the real LAN report and the 0fbecd37 follow-up. What this round adds: two physically different SSH targets (Linux aarch64 and macOS x86_64 — the first macOS SSH target in any round), a defect-arm A/B that re-runs F1/F2/F4 against 中文摘要结论:merge-ready —— 167/167 条脚本断言全部通过。验证 head
Previous findings: status at
|
| # | Finding (round) | Status at 0fbecd37 |
Evidence |
|---|---|---|---|
| F1 | Shell tool promises bash -c, SSH host ran /bin/sh (LAN report) |
fixed — re-measured | shell=bash, SYNTAX_OK ([[ ]], source, pipefail, {a,b}) on both targets; defect arm prints shell=/bin/sh (Linux) and shell=/bin/sh with exit 0 (macOS, where /bin/sh is bash in POSIX mode — the contract violation is still visible in $0). Cells 1–4 below. |
| F2 | Agent edit/write_file dropped BOM and CRLF (LAN report) |
fixed — re-measured | Remote bytes after LF-argument edit: EF BB BF alpha\r\nbeta-and-gamma\r\n; after whole-file write: EF BB BF red\r\ngreen\r\nblue\r\n — byte-identical on both targets. Defect arm: edit throws old_string was not found, write produces LF-only bytes without BOM. |
| F4 | Timed-out/cancelled commands kept running remotely (LAN report) | fixed — re-measured | 4 shapes × 2 targets: timeout, AbortSignal cancel, redirected output, TERM-ignoring leader. Marker absent and no leftover process on head on both targets; on the defect arm the marker appears every time (8/8 reproductions). The stdin-EOF mechanism works over real SSH to a macOS target, not only Linux. |
| O1 | Approval card omits SSH host when a description is present | not covered this round (needs Web Shell + model) | — |
| O2 | Session tooltip shows private anchor path | not covered this round (needs Web Shell UI) | — |
| O3 | "may still be running" wording when nothing started | stands — fresh evidence | Registering an unreachable host returns SSH connection failed: ssh: connect to host 192.168.0.249 port 22: Operation timed out … The remote command may still be running or may have completed; its status is uncertain. — nothing was ever started. Same suffix on the bad-user (auth failure) path. Deferred by the author; confirmed still present. |
| O4 | Browse suggestions while typing an ssh:// URL |
not covered this round (needs Web Shell UI) | — |
| O5 | One SSH handshake per operation | stands — fresh numbers | glob over the workspace reports durationMs 539–912 in-route on a LAN (logs daemon-e2e.log). Consistent with the previous round's ~0.6 s handshake. |
Central claim and A/B
Central claim: at head, agent SSH commands run under the advertised Bash contract, agent file edits preserve existing BOM/line-endings, and client-side timeout/cancel terminates the remote process group — with no regression to the transport or to local workspaces.
Protocol-level A/B (real ssh client → real remote Python executor → real remote bytes; harness harnesses/ssh-protocol.mjs drives the built dist/ of each arm):
| Cell | Build | Target | Assertions | Result |
|---|---|---|---|---|
| 1 | head 0fbecd37 |
Linux aarch64 ([email protected], bash 5.2, Python 3.11.2, /bin/sh=dash) |
28/28 | ✅ all fixed behaviors present |
| 2 | head 0fbecd37 |
macOS 15.7.9 x86_64 ([email protected], bash 3.2.57, Python 3.9.6) |
28/28 | ✅ all fixed behaviors present |
| 3 | prev aa60679971 |
Linux aarch64 | 22/22 | ✅ all three defects reproduced as expected |
| 4 | prev aa60679971 |
macOS 15.7.9 | 22/22 | ✅ all three defects reproduced as expected |
The defect-arm cells are assertions that the defect reproduces; their red-is-expected outcomes are encoded in the harness (expect=broken), so a green cell means the A/B can actually tell the two builds apart. Witness images: 01-head-linux-all-pass.png, 02-prev-linux-defects-reproduced.png, 03-head-macos-all-pass.png, 04-prev-macos-defects-reproduced.png.
Transport controls (identical on all 4 cells): separate stdout/stderr/exit-code framing (exit 4, exit 255), multi-byte UTF-8, 1,000,000-byte stdout payload, pre-abort rejection, fs read/list/grep. The macOS target runs the executor against Python 3.9 and the Bash contract against bash 3.2 — both hold.
Daemon E2E at head (56/56)
Real bundled daemon (dist/cli.js serve, isolated QWEN_HOME, folder trust enabled, trust toggled through the QWEN_CODE_TRUSTED_FOLDERS_PATH seam) driving both SSH targets plus a local primary workspace. Witness image: 05-daemon-e2e-56-checks.png.
- Registration: both targets register (201, distinct ids);
:22and implicit port are distinct identities. 8 invalid descriptors all 400 with no catalog entry: password, query, fragment, missing path, missing directory, file-as-directory, unreachable host (10 s ConnectTimeout), unknown user (BatchMode auth failure). Identical-URL re-registration returns 200 with the same id and no second entry (idempotent; the 409 the LAN report saw was for a different path resolving to the same canonical root — not retested here). - Trust gate: untrusted → read 200, write/upload/git 403
untrusted_workspace, no remote side effect; trusting the anchor via hot reload flips writes to 201 without a restart. - File routes (both targets): LF write into a BOM+CRLF file keeps
EF BB BF …\r\nbyte-for-byte; stale hash → 409 and the file is untouched;../and symlink escapes → 400; ignore rules honoured (nested.qwenignore, 500-filenode_modulesexcluded). - Large files: byte window at 18 MiB of a 20 MiB file returns 200; full read → 413
file_too_large. - Git:
/gitbranch+counts,/git/diff,/git/diff/filehunks match the host's owngit; commit/checkout → 501ssh_workspace_operation_unsupported; hostHEADunchanged. - ACP over HTTP: initialize mints a connection id,
GETwithAccept: text/event-streamreturns the SSE stream (406 without it, 400 for DELETE without the connection header — both contract-correct), DELETE → 202, and subsequent non-initialize requests on the torn-down connection → 404. - Persistence and non-regression: daemon restart restores the persisted SSH workspace (read works without re-registration); local workspace file routes work throughout.
Mutation matrix (fix's own tests are not vacuous)
Each mutant applied to head sources, then the affected test file run; all four killed by exactly the intended tests. Witness image: 06-mutation-matrix.png.
| Mutant | Expected to die | Result |
|---|---|---|
M1 ['bash','-c',…] → ['/bin/sh','-c',…] |
bash-contract test | killed — executes the Bash syntax advertised to the agent (1/45 failed) |
M2 remove start_new_session=True |
all 4 disconnect tests | killed — silent / redirected / TERM-ignoring / broken-pipe (4/45 failed) |
| M3 remove SIGKILL-after-grace escalation | TERM-ignoring case only | killed — exactly that case (1/45 failed) |
M4 preserveFileFormat → identity |
BOM/CRLF tests | killed — 8/45 failed incl. all new format tests |
M1/M2/M3 land in the same file as the tests that catch them, so the runner-level and file-level controls coincide. (A first M3 attempt silently no-ops because /usr/bin/python3 hit the Xcode license prompt — the "mutation applied" assertion is what caught it; rerun with DEVELOPER_DIR set died as expected.)
Targeted gates
- Unit tests (head worktree): core SSH/shell files 1069/1070;
config.test.ts824/825; CLI 19 files incl. all SSH routes/fs/store/dispatch/guards 4349/4356; Web Shell 194/194. Failure attribution (same test file + same test name run on main tip0adae3c254): theconfig.test.tslease failure andclient.test.tsmicrocompaction failure reproduce byte-identically on main; the 3acpAgent.test.tsfailures and therun-qwen-servemetrics-flush failure reproduce on main; 3 further failures seen only under parallel load (session shell on trusted loopback, two SSE tests) pass in isolation on both head and main — load-correlated flake, not signal. No failure is attributable to this PR. - Typecheck:
npm run typecheckclean in the head worktree (logtypecheck.log). - Trial merge:
git merge-tree origin/main × headexits clean, 0 conflicts. Since the base, main touched 3 files this PR also touches (packages/cli/src/serve/server.ts,server.test.ts,packages/web-shell/client/i18n.tsx); all merge textually clean, and the merged state is what the PR's merge-ref CI runs. At report time the only pending PR check isreview-pr. - One observability note, resolved: a
truncated:trueflip seen across cells was traced (by labeling everyincompletesource in the remote script) to my own harness deleting a fixture directory while its files remained in the Git index — the flag was correctly reporting "enumerated files missing on disk". After rebuilding the fixture,truncated:falseis stable across 6/6 runs on both targets. The incomplete-results signal works and is conservative in the right direction.
Corrections
None. The earlier rounds' descriptions I re-measured (Bash contract, BOM/CRLF bytes, cancellation semantics, trust gate, 501 boundary, ACP lifecycle, per-operation SSH handshake) are accurate at head.
Not covered
- Real-model Web Shell end-to-end (approval cards, tooltips, dialog copy — O1/O2/O4): previous rounds covered these; the head delta does not touch them.
- Outage/partition injection (firewall drop mid-session): deliberately not repeated here — the LAN report covered it and this round's two targets are other maintainers' machines; I did not touch their firewalls. Registration-time failures (unreachable host, auth failure) are covered above and fail without any local fallback.
- Windows SSH target and ProxyJump: no Windows host available; consistent with the PR's own stated scope.
- Per-commit attribution: verified the aggregate diff and the
aa60679971..0fbecd37delta directly; the PR's middle commits were not individually exercised. - Sub-modules of the feature not exercised over the wire: interactive pty terminal route, workspace settings hot-path, MCP/LSP/subagents (explicitly unsupported per the PR).
- Repo-wide full test suite and lint: not re-run (PR's own CI covers them; only
review-prwas pending at report time).
Methodology
macOS 26 (Apple silicon), Node 24.18.1. Two scratch worktrees (tmp/pr12255-head @ 0fbecd37, tmp/pr12255-prev @ aa60679971) each installed with the pinned pnpm bootstrap and built (npm run build && npm run bundle); readlink -f on node_modules/@qwen-code/qwen-code-core confirmed each tree's internal links resolve into itself, so the A/B arms cannot cross-load. Harnesses (kept in tmp/pr12255-verify-20260922-085449/harnesses/) drive the built dist/ output directly — the SshWorkspaceClient/SshExecutionEnvironment protocol path over real ssh processes, and the bundled daemon over real HTTP — against isolated fixture workspaces on two LAN machines ([email protected] Orange Pi aarch64 Linux; [email protected] Intel Mac). No mocks of the code under test anywhere; remote-byte oracles are od dumps read back over a second SSH channel, and Git oracles are the host's own git. Raw logs per cell live in tmp/pr12255-verify-20260922-085449/logs/; assertions counted here are exactly the scripted ASSERT lines plus the mutation, merge and truncated-stability checks. Remote fixtures were removed from both targets after the run (/root/pr12255-lab-x8 and /Users/wenshao/pr12255-lab-x8, verified absent), and everything the daemon wrote lived inside its isolated state directory.
Evidence
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Critical-only review pass at head 0fbecd37e389a64865a65aed5cdf191404816aa6. No merge-blocking defect found. All seven Criticals from the most recent round are verified addressed by reading the current source, and all 74 review threads are resolved. The three commits after that round - address SSH workspace review findings, finish SSH boundary review and sync main, and honor shell, file format and cancellation contracts - touch every file the findings named.
Historical Criticals, re-verified on this head
- Advertisement and enforcement now read one method set.
packages/cli/src/serve/acp-http/dispatch.tsfilters what it advertises with the same predicate the gate enforces:advertisedQwenVendorMethods(this.sessionShellCommandEnabled).filter((method) => !this.fsFactory?.sshWorkspace || SSH_METHODS.has(method))at :1618-1623, against the gate at :1768-1783 that answersMETHOD_NOT_FOUNDwitherrorKind: 'ssh_workspace_operation_unsupported'andhttpStatus: 501. A client can no longer be told a method exists and then be refused it. - Denial recording is no longer a single catch.
packages/cli/src/serve/fs/ssh-workspace-file-system.tsrecords at :240 on the permission-denied path and again at :472 inside a wrapper guarded by aWeakSet<FsError>(:460-472), so anFsErrorraised anywhere is recorded exactly once rather than only when it reachesrequest()'s catch. - The store read fails closed only where it should.
packages/cli/src/serve/ssh-workspace-store.tsreturnsundefinedfor a path outside the store root (relative..,..-prefixed, or absolute) and throws only for an in-root path that is not exactly<64-hex>/workspace. Before reading it verifies the directory and the cwd are not symlinks and thatrealpathSync(cwd) === path.resolve(cwd), opensconnection.jsonwithO_RDONLY | O_NOFOLLOW, requiresstat.isFile()and a size at or under 16 KiB, requires an object with a stringurl, and finally checksconnectionId(connection) === parts[0]so a descriptor cannot be read under a different identity. - The exec confirmation carries the fields the local path does.
packages/core/src/services/ssh-execution-environment.ts:384-408returnstype: 'exec'withcommand,rootCommandfromgetCommandRoots,permissionRulesbuilt throughextractCommandRulesper split command and rendered asBash(rule), andwarningscombiningbuildShellExecWarningswith an explicit note that an interrupted connection may leave the remote command running. The edit branch returnsoriginalContent,newContent, acreatePatchSmartdiff andskipIdeDiff: true, matching the declared absence of host IDE diffs. - Path containment is fail-closed end to end. In
packages/core/src/services/ssh-workspace-script.ts:normalized()rejects non-strings, embedded NUL, lengths over 4096 and any..component, then requiresos.path.commonpath([root, target]) == root.parent()walks only the relative components beneath adupofroot_fd, so nothing above the root is re-resolved.child_directory()lstats withfollow_symlinks=Falseand failssymlink_escapebefore opening withO_RDONLY | O_DIRECTORY | O_NOFOLLOW.open_directory()walks from/, closes each fd as it descends and re-raises after closing on error. Reads useO_RDONLY | O_NOFOLLOW | O_NONBLOCKwithdir_fd, thenfstatand anS_ISREGrequirement, so a symlink or FIFO planted at the target fails rather than being followed;inspect()stats withfollow_symlinks=False. - Ignore rules come from configuration, not a hand-list.
search_files(include_ignored, ignore_files)takes the names as a parameter and applies them through git itself ---exclude-standardplus--exclude-per-directory=<name>for each - andconfig.tsthreadsconfigParams.fileFiltering?.customIgnoreFilesintoSshExecutionEnvironment. Search also reportsincompletewhenever git emits a diagnostic, fails explicitly withCannot determine remote Git ignore rules.for a non-git directory that has ignore rules, excludes.gitpaths, and bounds entries at 50,000 and nesting at 64. - The SSH config block disables only what the design declares unsupported. At
packages/cli/src/config/config.ts:2735-2763the block constructsSshExecutionEnvironmentwith the operator's owntruncateToolOutputThreshold,shellDefaultTimeoutMsandcustomIgnoreFiles- settings the remote environment can honor are passed through rather than discarded - and then turns off hooks, MCP servers, extensions, workflows, managed auto-memory and dream, team memory and its sync, auto-skill, file checkpointing, artifacts and code-mode-only, with LSP separately excluded at :1780. That set matches the unsupported list in Risk & Scope, and the appended system prompt states the host and remote project directory so the model is told where its tools operate. This one I judged against the head code's shape and the declared scope: the round-1 finding's text was truncated in the ledger, so I read the block rather than the original wording.
Current scan
Nothing provable surfaced in what I read. The remote script's limits are explicit constants (MAX_BYTES 16 MiB, MAX_ENTRIES 50,000, MAX_SEARCH_BYTES 4 MiB) with named failure codes, numeric arguments are range-checked and reject booleans, and every failure path raises a coded WorkspaceError rather than returning a partial answer.
Not audited to depth within this pass, stated plainly because the feature executes commands on a remote host: the cancellation and process-group termination path in ssh-execution-environment.ts, BOM and line-ending preservation on remote writes, ssh-workspace.ts connection identity and trust gating, the new routes/ssh-workspace.ts surface and its workspace-management and capabilities wiring, the terminal route, and the Web Shell dialog and sidebar changes. The two claims I would want a real-host check for are the ones the description says were reproduced and then re-verified over real SSH - Bash as the declared shell, and no delayed write surviving a timeout or cancellation.
CI
Every non-skipped check on this head concluded success, including the unit, lint, serve A/B and integration lanes. review-pr was still queued at review time, which is not treated as a gate. No failure is attributable to this change.
There was a problem hiding this comment.
LGTM for the current head.
I reviewed the SSH workspace path end to end: registration and persistence keep the runtime and credentials local; workspace-scoped routes resolve through the selected SSH runtime; invalid, untrusted, draining, and closed-generation states fail closed instead of falling back to the local workspace. The SSH filesystem implementation keeps path containment and no-follow handling on the remote side, while the execution environment keeps the declared Bash contract and closes the command connection/process group on timeout or cancellation.
The follow-up changes also explicitly disable incompatible session features for SSH workspaces, including codeModeOnly, hooks, MCP, extensions, workflows, memory, checkpoints, and artifacts. The new English and Chinese design documents describe the same supported and intentionally unsupported surfaces.
I checked the current review head directly and ran focused regressions: the core SSH workspace and execution-environment suites passed (77 tests). The focused CLI suites had 47 passing tests; one route assertion expects 599 for an unsupported /tools endpoint while the implementation returns 404. That remains a test-contract follow-up, but the 404 is fail-closed and does not route the request to a local workspace.
No new confirmed merge blocker found in the reviewed security, routing, filesystem, or execution-lifecycle paths.
















What this PR does
Users can add
ssh://user@host:2222/absolute/projectin Web Shell and work on that project through the local daemon. File operations, search, shell commands, Git inspection and the interactive terminal run over SSH. Model credentials, approvals and session history remain local. The remote computer needs OpenSSH, Python 3 and its project tools, with Bash for agent shell commands; it needs neither Qwen nor a running Qwen service.Connections persist across daemon restarts and enforce distinct identities, trust, runtime ownership, path containment and conditional writes. Existing-file writes, multiline edits and revised proposals preserve UTF-8 BOM and line endings. SSH shell declarations and execution consistently use Bash, including for Windows clients. Timeout or cancellation closes the command connection; the remote executor terminates its process group, including redirected commands and children that ignore SIGTERM. Search honors configured ignore rules and reports incomplete results. Secondary ACP HTTP connections support event streams and teardown. Unsupported services fail explicitly.
Why it's needed
Workspace registration previously accepted only local paths. Connecting to a remote HTTP daemon requires installing and running Qwen there. SSH workspaces let users operate existing remote projects while keeping the runtime and authentication on their local computer.
Reviewer Test Plan
How to verify
Evidence (Before & After)
Tested on
Environment (optional)
macOS with Node 22.22.2, a real LAN Linux host accessed through existing OpenSSH configuration, isolated local/remote fixtures and a local model fixture driving actual CLI tools. No remote Qwen service or paid model was used in the follow-up. The linked earlier report separately covers real-model Web Shell interaction. Test services and remote fixtures were cleaned up.
Risk & Scope
Synchronized design documents: English · 简体中文.
Linked Issues
None.
中文说明
本 PR 的改动
用户可以在 Web Shell 中添加
ssh://user@host:2222/absolute/project,通过本地 daemon 操作远程项目。文件操作、搜索、shell 命令、Git 查看和交互式终端通过 SSH 执行。模型凭据、审批和会话历史保留在本地。远端需要 OpenSSH、Python 3 和项目工具,agent shell 命令还需要 Bash;无需安装 Qwen 或运行 Qwen 服务。连接在 daemon 重启后恢复,并执行独立身份、信任、运行时归属、路径边界和条件写入检查。已有文件的写入、多行编辑和修改后的提案保留 UTF-8 BOM 与换行格式。SSH shell 的声明和执行统一使用 Bash,Windows 客户端也保持一致。超时或取消关闭命令连接后,远端执行器终止整个进程组,包括重定向输出的命令和忽略 SIGTERM 的子进程。搜索遵守配置的忽略规则并标记不完整结果。次级 ACP HTTP 连接支持事件流及关闭,不支持的服务明确失败。
为什么需要
此前工作区注册只接受本地路径。连接远程 HTTP daemon 需要在远端安装并运行 Qwen。SSH 工作区允许用户操作已有远程项目,同时将运行时和认证保留在本机。
审查者测试计划
如何验证
证据(改动前后)
测试平台
环境
macOS、Node 22.22.2、通过现有 OpenSSH 配置访问的真实局域网 Linux 主机、隔离的本地/远端 fixture,以及驱动实际 CLI 工具的本地模型 fixture。本次补充验证未使用远端 Qwen 服务或付费模型。链接的先前报告单独覆盖真实模型与 Web Shell 交互。测试服务和远端 fixture 已清理。
风险与范围
同步的设计文档:English · 简体中文。
关联 Issue
无。