Skip to content

feat(serve): support SSH workspaces without a remote daemon - #12255

Merged
wenshao merged 8 commits into
mainfrom
codex/ssh-workspaces
Sep 22, 2026
Merged

wenshao merged 8 commits into
mainfrom
codex/ssh-workspaces

Conversation

@wenshao

@wenshao wenshao commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Users can add ssh://user@host:2222/absolute/project in 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

  1. Add an SSH project using non-interactive authentication and a trusted host key. Confirm the remote identity/display name and persistence after restarting the daemon. Before trusting the workspace, reads should work and writes should be denied; trusting it should enable remote changes.
  2. Ask the agent to read a BOM/CRLF file, edit several lines using LF arguments and overwrite it. Verify the remote bytes retain BOM/CRLF, while a stale conditional edit or symlink escape fails. The local anchor must remain untouched.
  3. Run a command using Bash brackets, source, pipefail and brace expansion. Confirm its actual shell matches the advertised Bash contract. Timeout or cancel a finite command with delayed side effects, including one with redirected output or a SIGTERM-ignoring child; the marker must stay absent after the termination grace period. Normal output, stderr and nonzero exit codes must still arrive.
  4. Search a repository containing nested ignore rules and a large ignored directory. Compare remote Git status/diffs and the counts-only overview above 500 changed files. Check byte windows beyond 16 MiB, upload limits, terminal working directory/output replay and remote-path menu actions.
  5. Check ACP HTTP initialization, SSE responses and teardown. Unsupported services, outages and invalid descriptors must fail without falling back to a local workspace. Local workspaces must remain usable.

Evidence (Before & After)

  • The global CLI rejects SSH registration with HTTP 400. The real LAN report on aa60679971 includes screenshots and reproduces the Bash, BOM/CRLF and command-cancellation defects.
  • The follow-up independently reproduced all three defects on the same Linux host through real SSH and CLI tool calls. After rebuilding, the same scenarios passed: Bash syntax succeeds, file bytes match the local BOM/CRLF controls, and delayed writes do not survive timeout/cancellation. The follow-up verification report records detailed evidence and limitations.
  • Build, bundle, whole-repository typecheck, changed-file lint/format and targeted regressions passed. The previous aa60679 CI passed its code/test lanes, including full Linux CLI/Core tests; its separate automatic review later timed out without a verdict. That timeout is not an approval, and previous CI results are not presented as results for the new commit.

Tested on

OS Status
🍏 macOS ✅ Local daemon; direct remote-script cancellation regressions
🪟 Windows ⚠️ Shell-declaration and path models only; no actual Windows SSH session
🐧 Linux ✅ Real Ubuntu 22.04 arm64 SSH target, Python 3.10.12, Bash 5.1.16, /bin/sh=dash

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

  • Main risk or tradeoff: main-session execution and workspace routing cross package boundaries. Each operation opens an SSH connection. Network partitions can delay cancellation detection; deliberately detached descendants are outside process-group cleanup. Command outcomes can remain uncertain and mutations are never replayed. The daemon primary workspace stays local.
  • Not validated / out of scope: actual Windows SSH execution and ProxyJump were not tested in the follow-up. Password prompts, managed background jobs, remote hooks/skills, MCP/LSP, subagents, workflows, channels, worktrees and automatic memory/artifact discovery remain unsupported. Search reports omitted submodule contents as incomplete; non-Git projects with ignore rules and dubious Git ownership fail explicitly. Abrupt termination may leave a temporary write file. Non-blocking interface/latency observations are explicitly deferred in the follow-up response.
  • Breaking changes / migration notes: local workspaces require no migration. SSH shell commands require Bash. Metadata/session storage uses a reserved private local namespace; project files are not synchronized locally.

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 工作区允许用户操作已有远程项目,同时将运行时和认证保留在本机。

审查者测试计划

如何验证

  1. 使用非交互认证和已信任的主机密钥添加 SSH 项目。确认远端身份、显示名称和 daemon 重启后的持久化。信任前允许读取、拒绝写入;信任后允许远端修改。
  2. 让 agent 读取 BOM/CRLF 文件,使用 LF 参数编辑多行并覆盖写入。核对远端字节仍保留 BOM/CRLF,过期条件编辑与符号链接越界应失败。本地锚点不应被修改。
  3. 执行包含 Bash 方括号、source、pipefail 和花括号展开的命令,确认实际 shell 与声明的 Bash 一致。对具有延迟副作用的有限时命令执行超时和取消,包括重定向输出或包含忽略 SIGTERM 子进程的情况;终止宽限期后仍不应生成标记。正常输出、stderr 和非零退出码仍应完整返回。
  4. 搜索含嵌套忽略规则及大型忽略目录的仓库。对照远端 Git 状态、差异和超过 500 个改动文件时的仅计数概览。检查 16 MiB 之后的字节窗口、上传限制、终端工作目录与输出回放,以及远端路径菜单操作。
  5. 检查 ACP HTTP 初始化、SSE 响应及关闭。不支持的服务、断连和无效描述文件应明确失败,不能回退到本地工作区。本地工作区应继续可用。

证据(改动前后)

  • 全局 CLI 对 SSH 注册返回 HTTP 400。aa60679971 上的真实局域网报告 包含截图,并复现了 Bash、BOM/CRLF 和命令取消问题。
  • 本次补充验证通过真实 SSH 和 CLI 工具调用,在同一台 Linux 主机独立复现了全部三项问题。重新构建后相同场景通过:Bash 语法成功,文件字节与本地 BOM/CRLF 对照一致,超时和取消后不再产生延迟写入。补充验证报告记录详细证据及限制。
  • 构建、打包、全仓类型检查、改动文件 Lint/格式检查和定向回归测试通过。此前 aa60679 的代码和测试 CI 通过,包括 Linux CLI/Core 全量测试;独立自动评审随后超时且没有结论。超时不计为批准,旧提交 CI 不作为新提交的结果。

测试平台

OS 状态
🍏 macOS ✅ 本地 daemon;直接执行远端脚本的取消回归
🪟 Windows ⚠️ 仅 shell 声明及路径模型,未运行实际 Windows SSH 会话
🐧 Linux ✅ 真实 Ubuntu 22.04 arm64 SSH 目标,Python 3.10.12、Bash 5.1.16、/bin/sh=dash

环境

macOS、Node 22.22.2、通过现有 OpenSSH 配置访问的真实局域网 Linux 主机、隔离的本地/远端 fixture,以及驱动实际 CLI 工具的本地模型 fixture。本次补充验证未使用远端 Qwen 服务或付费模型。链接的先前报告单独覆盖真实模型与 Web Shell 交互。测试服务和远端 fixture 已清理。

风险与范围

  • 主要风险或取舍:主会话执行与工作区路由涉及跨包改动。每次操作建立一条 SSH 连接。网络分区可能延迟取消检测,主动脱离进程组的后代进程不在清理范围内。命令结果可能仍不确定,修改操作绝不重放。daemon 主工作区保持本地。
  • 未验证或范围外:本次补充验证未实测 Windows SSH 和 ProxyJump。密码提示、托管后台任务、远程 hooks/skills、MCP/LSP、子代理、工作流、频道、worktree 和自动记忆/产物发现仍不支持。搜索将省略的子模块内容标记为不完整;带忽略规则的非 Git 项目和 Git 所有权可疑的仓库明确失败。突然终止可能留下临时写入文件。非阻塞界面和延迟观察项已在补充回复中明确延期。
  • 破坏性变更或迁移:本地工作区无需迁移。SSH shell 命令需要 Bash。元数据和会话存储使用保留的私有本地命名空间,不向本地同步项目文件。

同步的设计文档:English · 简体中文。

关联 Issue

无。

@wenshao

wenshao commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

SSH workspace E2E report

Result: 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.

Group Passed Verified behavior
Main workflow 50/50 SSH registration and identity, persistence, trust, remote file read/create/edit/search, stale-hash rejection, symlink/path rejection, Git, terminal cwd/resize/reconnect/release, actual ACP agent tools, default write approval, YOLO, remote outage and local-workspace isolation
Connection lifecycle 9/9 Missing or corrupt descriptors isolate only the SSH registration; local daemon remains usable; repaired descriptor restores remote access; missing Python fails registration
Default shell approval 10/10 No remote marker exists before approval; allow-once permits the command; actual marker records the remote project cwd and local markers remain unchanged
Text metadata and result limits 8/8 ACP and HTTP saves preserve actual UTF-8 BOM/CRLF bytes; a 2002-entry directory returns 2000 entries plus truncation; glob at the public 50000-result limit returns the expected two-file fixture

Before implementation, the global CLI returned HTTP 400 with invalid_path for ssh://localhost/absolute/project. Before the final fixes, the boundary fixture reproduced lost BOM/CRLF bytes and a 503 response for the 2002-entry directory. After the fixes, actual remote bytes retained EF BB BF and CRLF, the directory returned HTTP 200 with truncated: true, and the maximum-limit glob request returned HTTP 200 with exactly the expected two matches.

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 /review verdict or a full repository test-suite run.

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、浏览器交互和操作进行中的实际断网尚未实测;未将未完成的自动 /review 计为通过,也未宣称运行整个仓库测试集。

@wenshao
wenshao marked this pull request as ready for review September 19, 2026 13:15
@wenshao

wenshao commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@wenshao

wenshao commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Linux verification round — real SSH host, real daemon, real bundle

The first report on this PR covered macOS; the PR body and docs/design/ssh-workspaces.md both mark Linux as not exercised. This round closes that gap: I built the branch head (a75c998) into dist/cli.js, ran the daemon against an isolated OpenSSH server on Linux (its own host key, its own authorized key, its own port, Python 3.13 on the target), and drove the real Web Shell in Chromium against that daemon.

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 400 invalid_path for POST /workspaces {"cwd":"ssh://[email protected]:<port>/…"}; the branch build answers 201 with a private anchor under $QWEN_HOME/ssh-workspaces/<sha256(url)>/workspace.

What ran

Group Checks What it proves
Registration and identity 14 ssh:// accepted, deterministic anchor = sha256(canonical url), 0600 descriptor, /capabilities carries ssh{host,port,directory}, alias form is a separate identity, five malformed/unreachable URLs rejected, nothing installed on the remote host
Web Shell file routes 22 real remote bytes on read/write/edit, stale-hash rejection, BOM+CRLF byte-exact preservation without explicit metadata, 2002-entry directory → 2000 + truncated, glob at the 50000 limit, .gitignore honored, traversal and symlink escapes refused, byte windows, local workspace unaffected
Git inspection 9 branch/status/diff/diff-file match git run directly on the remote repo, untracked file renders as an all-added hunk, mutating and unsupported Git routes answer 501
Route boundary census 4 91 of the 94 workspace-scoped routes probed (the three workspace-lifecycle routes are skipped because probing them would unregister the workspace mid-census): 55 answer 501 ssh_workspace_operation_unsupported, 36 pass through, none outside the intended allowlist, and no response leaks local-workspace content
Web terminal 9 the pty is a real ssh -tt … BatchMode=yes StrictHostKeyChecking=yes … cd '<remote>' && exec $SHELL -l process (checked in /proc), remote cwd, resize reaches the remote pty, reconnect replays, release kills the ssh process, local workspaces still get a local shell
Agent tools over SSH 16 read/grep hit the remote project, write and shell wait for approval, approval runs it remotely and denial does not, unread-file overwrite refused, YOLO skips the prompt, tool surface is 6 execution tools, tool_call/tool_search cannot reach a non-SSH tool, session history stays local
ACP transport gate 7 over the ACP WebSocket transport initialize/session/*/_qwen/file/read work while _qwen/workspace/memory, _qwen/session/shell, _qwen/workspace/agents/list, _qwen/workspace/init are refused — with a local-workspace negative control where the same methods are allowed
Slash-command policy 5 an SSH session advertises only compress/context/effort/model; a blocked command ends the turn with an unsupported-action error and writes nothing remotely; a local session still advertises 51 commands
Persistence, descriptor damage, outage 22 persisted registration restores after restart; a corrupted or missing descriptor keeps the workspace out of the catalog, logs why, and never serves the anchor; during an sshd outage remote routes fail 503 while the local workspace keeps working; recovery after the host returns
Folder trust 8 with security.folderTrust.enabled, reads, writes and Git on the SSH workspace are refused 403 untrusted_workspace; after trusting the anchor all three work again
Hostile remote path and limits 11 a remote project named proj $(touch /tmp/…) 'quoted registers, reads, writes and opens a terminal with no command substitution executed (canary stayed clean); a 17 MiB file and a 20 MiB write are refused 413 with nothing created

Package gates on the branch head: packages/cli 12 files / 1957 tests, packages/core 8 files / 1474 tests, packages/web-shell AddWorkspaceDialog 43 tests — all passing. ESLint --max-warnings 0 over all 56 changed .ts/.tsx files is clean, Prettier is clean, and npm run build -- --cli-only + npm run bundle succeed from a clean checkout of the head.

Screenshots (real browser against the live daemon)

Sidebar after adding two connections to the same remote project — [email protected]:<port> and the SSH-config alias qwen-ssh-lab — each with its own identity beside the local workspace:

sidebar

Approval card for a remote shell command — the title names the SSH host and the remote project directory:

approval

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):

terminal

Add-workspace dialog, default state vs. an ssh:// URL typed into the same field (see N1):

dialog

Non-blocking observations

N1 — the new SSH guidance is invisible in the default Web Shell. sidebar.addWorkspaceHint (the string this PR rewrote to mention ssh://…, key auth, host key and Python 3) is rendered only in the !browseDirectories branch (AddWorkspaceDialog.tsx:573-577). With the directory browser on — the default for a local daemon — the dialog renders workspaceHost.browseHint ("Choose a folder below, or type an absolute path.") instead, and the path field is pre-filled with the daemon's cwd, so the new placeholder "/absolute/path or ssh://user@host/project" never appears either. Typing an ssh:// URL works (Enter submits it, verified in the browser), but nothing in the dialog tells the user it is possible. Consider appending the SSH sentence to the browse hint, or adding an "SSH" entry to the Folder source selector, which still reads "This computer".

N2 — the HTTP ACP transport is closed for SSH workspaces, the WebSocket one is not. registerSshWorkspaceBoundary rejects POST /workspaces/:id/acp with 501 before the dispatcher runs, so an ACP client that speaks only Streamable-HTTP cannot use an SSH workspace at all; the SSH_METHODS allowlist added in acp-http/dispatch.ts is reachable only over the WS transport (where I verified it, including the negative control). Fail-closed is defensible, but the two transports now differ — worth either an /acp passthrough or a line in the design doc.

N3 — SSH_EXECUTION_TOOL_NAMES lists ToolNames.LS, but list_directory is never registered under default settings (Config skips it when isLsToolEnabled() is false, which is also true for local workspaces). The SSH list branch in SshExecutionEnvironment.execute is therefore unreachable in a default install; the agent lists remote directories through glob/shell instead. Not a regression, just dead weight unless the tool is enabled.

N4 — slash-command messaging. help is in the SSH allowlist but ACP sessions reject /help anyway ("not supported in this mode" — same for local workspaces), and a blocked command surfaces the generic This action is not supported in this standalone session. rather than the SSH-specific reason added in nonInteractiveCliCommands.ts. /model, /compress, /context, /effort work as intended.

N5 — documentation status lines. docs/design/ssh-workspaces.md still says "A Linux target has not yet been exercised", and the PR body's Tested on table marks Linux ⚠️. Both can be updated with this round.

Environment

Linux 6.12 (Debian 13), Node.js 22.22.2, branch head a75c998 built locally (build --cli-only + bundle). Target: a dedicated sshd on loopback with its own host key, key-only auth, StrictHostKeyChecking=yes satisfied from a private known_hosts, Python 3.13.5; the daemon's ~/.ssh was redirected into the lab through a private mount namespace, so no shared SSH configuration was touched. Model traffic went to a local OpenAI-compatible stub — no live model API, no Qwen on the target. The lab sshd, the daemon and the stub were stopped afterwards, and the fixtures live entirely in a scratch directory.

Verdict from this round: merge-ready on Linux. The five items above are follow-ups, not blockers.

中文说明

Linux 验证轮次 —— 真实 SSH 主机、真实 daemon、真实 bundle

本 PR 上一轮报告覆盖的是 macOS,PR 描述与 docs/design/ssh-workspaces.md 都标注 Linux 未验证。这一轮补上:我把分支头 a75c998 构建为 dist/cli.js,让 daemon 对接一台 隔离的 Linux OpenSSH 服务(独立主机密钥、独立授权密钥、独立端口,远端 Python 3.13),并用 Chromium 中真实的 Web Shell 驱动该 daemon。

结果:121 项独立检查全部通过,0 项失败。 未发现阻塞缺陷;下列 5 条为非阻塞观察。

Linux 上的前后对照:全局安装的 CLI(0.22.2)对 POST /workspaces {"cwd":"ssh://[email protected]:<port>/…"} 返回 400 invalid_path;本分支构建返回 201,并在 $QWEN_HOME/ssh-workspaces/<sha256(url)>/workspace 下创建私有锚点目录。

验证内容

分组 检查数 验证了什么
注册与身份 14 接受 ssh://;锚点目录名为 sha256(规范化 URL);描述文件权限 0600;/capabilities 带 ssh{host,port,directory};SSH 配置别名是独立身份;五类非法/不可达 URL 被拒;远端不安装任何文件
Web Shell 文件路由 22 读/写/编辑作用于真实远端字节;过期哈希被拒;未显式指定元数据时 BOM+CRLF 逐字节保留;2002 项目录返回 2000 项并标记截断;glob 取 50000 上限;遵守 .gitignore;路径穿越与符号链接逃逸被拒;字节窗口读取;本地工作区不受影响
Git 查看 9 分支/状态/差异/单文件差异与远端仓库直接执行 git 的结果一致;未跟踪文件渲染为全新增 hunk;写类与不支持的 Git 路由返回 501
路由边界普查 4 94 条工作区级路由中探测 91 条(3 条工作区生命周期路由跳过,因为探测它们会在普查中途注销工作区):55 条返回 501 ssh_workspace_operation_unsupported,36 条放行,没有越出既定白名单的路由,也没有任何响应泄漏本地工作区内容
Web 终端 9 pty 是真实的 ssh -tt … BatchMode=yes StrictHostKeyChecking=yes … cd '<远端目录>' && exec $SHELL -l 进程(在 /proc 中核对);远端工作目录正确;resize 传达到远端 pty;重连回放输出;释放后 ssh 进程结束;本地工作区仍使用本地 shell
Agent 工具走 SSH 16 read/grep 作用于远端项目;write 与 shell 等待审批;批准后在远端执行、拒绝后不执行;未读文件的覆盖写被拒;YOLO 模式不提示;工具面只有 6 个执行工具;tool_call/tool_search 无法触达非 SSH 工具;会话历史留在本地
ACP 传输门 7 ACP WebSocket 传输上 initialize/session/*/_qwen/file/read 可用,而 _qwen/workspace/memory、_qwen/session/shell、_qwen/workspace/agents/list、_qwen/workspace/init 被拒——并用本地工作区做反向对照,同样的方法在本地是放行的
斜杠命令策略 5 SSH 会话只暴露 compress/context/effort/model;被禁命令以 unsupported-action 错误结束该轮且远端无任何写入;本地会话仍暴露 51 条命令
持久化、描述文件损坏、断连 22 持久化注册在重启后恢复;描述文件损坏或缺失时工作区不进入目录、日志说明原因、且绝不回退到锚点;sshd 断开期间远端路由以 503 失败而本地工作区照常可用;主机恢复后功能恢复
文件夹信任 8 开启 security.folderTrust.enabled 后,SSH 工作区的读、写、Git 均返回 403 untrusted_workspace;信任锚点后三者恢复
恶意远端路径与上限 11 名为 proj $(touch /tmp/…) 'quoted 的远端项目可注册、可读写、可开终端,且未发生命令替换执行(探针文件始终未出现);17 MiB 文件读取与 20 MiB 写入均返回 413,远端不产生任何文件

分支头上的包级门禁:packages/cli 12 个文件 / 1957 项测试,packages/core 8 个文件 / 1474 项测试,packages/web-shell 的 AddWorkspaceDialog 43 项测试,全部通过。对全部 56 个改动 .ts/.tsx 文件执行 ESLint --max-warnings 0 无告警,Prettier 无差异,npm run build -- --cli-only 与 npm run bundle 在干净检出上成功。

截图(真实浏览器对接运行中的 daemon)

同一个远端项目用两种连接方式添加后的侧边栏 —— [email protected]:<port> 与 SSH 配置别名 qwen-ssh-lab 各自独立,与本地工作区并列:

sidebar

远端 shell 命令的审批卡片 —— 标题标明 SSH 主机与远端项目目录:

approval

同一工作区的集成终端,显示远端工作目录与远端项目列表(含刚才批准的命令创建的标记文件):

terminal

添加工作区对话框:默认状态 vs 在同一输入框中输入 ssh:// 地址(见 N1):

dialog

非阻塞观察

N1 —— 新增的 SSH 提示在默认 Web Shell 中不可见。 本 PR 改写的 sidebar.addWorkspaceHint(提到 ssh://…、密钥认证、主机密钥与远端 Python 3)只在 !browseDirectories 分支渲染(AddWorkspaceDialog.tsx:573-577)。本地 daemon 默认开启目录浏览,此时对话框渲染的是 workspaceHost.browseHint(“Choose a folder below, or type an absolute path.”),且路径框已预填 daemon 当前目录,因此新的 placeholder "/absolute/path or ssh://user@host/project" 同样不会出现。输入 ssh:// 地址是可用的(浏览器中实测回车即提交),但对话框没有任何信息告诉用户可以这么做。建议把 SSH 那句话也追加到浏览提示里,或者在仍显示“This computer”的 Folder source 选择器中增加 SSH 选项。

N2 —— HTTP 版 ACP 传输对 SSH 工作区完全关闭,WebSocket 版则不是。 registerSshWorkspaceBoundary 在 dispatcher 之前就以 501 拒绝 POST /workspaces/:id/acp,因此只支持 Streamable-HTTP 的 ACP 客户端完全无法使用 SSH 工作区;acp-http/dispatch.ts 中新增的 SSH_METHODS 白名单只能经 WS 传输触达(我在该路径上验证了它,并做了反向对照)。fail-closed 是合理取舍,但两条传输现在行为不一致,建议要么放行 /acp,要么在设计文档中写明。

N3 —— SSH_EXECUTION_TOOL_NAMES 中列了 ToolNames.LS,但默认设置下 list_directory 从未注册(Config 在 isLsToolEnabled() 为 false 时跳过,本地工作区同样如此)。因此 SshExecutionEnvironment.execute 中的 list 分支在默认安装下不可达,agent 实际通过 glob/shell 列目录。这不是回归,只是在该工具未启用时属于冗余代码。

N4 —— 斜杠命令的提示信息。 help 在 SSH 白名单内,但 ACP 会话本来就拒绝 /help(“not supported in this mode”,本地工作区亦然);被禁命令返回的是通用的 This action is not supported in this standalone session.,而不是 nonInteractiveCliCommands.ts 中新增的 SSH 专属说明。/model、/compress、/context、/effort 工作正常。

N5 —— 文档状态行。 docs/design/ssh-workspaces.md 仍写着 “A Linux target has not yet been exercised”,PR 描述的 Tested on 表格中 Linux 仍是 ⚠️。两处都可以据本轮结果更新。

环境

Linux 6.12(Debian 13)、Node.js 22.22.2、本地构建的分支头 a75c998(build --cli-only + bundle)。目标端:仅监听回环的专用 sshd,独立主机密钥、仅密钥认证,StrictHostKeyChecking=yes 由私有 known_hosts 满足,远端 Python 3.13.5;daemon 的 ~/.ssh 通过私有 mount namespace 重定向到实验目录,未改动任何共享 SSH 配置。模型流量指向本地 OpenAI 兼容桩服务——没有真实模型 API,远端也没有 Qwen。验证结束后已停止实验用 sshd、daemon 与桩服务,全部夹具位于临时目录内。

本轮结论:Linux 上可合入。上述五条是后续项,不构成阻塞。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Blocking finding(s) follow.

⚠️ Downgraded from Request changes to Comment: self-PR. Partially reviewed — gaps disclosed. Suggestions are inline. 7 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

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

中文说明

⚠️ 已从请求修改降级为评论:self-PR。 仅完成部分审查,审查缺口已披露。 建议见行内评论。 7 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

未审查(原文为英文):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)

Comment thread packages/cli/src/config/config.ts
Comment thread packages/cli/src/nonInteractiveCliCommands.ts Outdated
Comment thread packages/cli/src/serve/fs/ssh-workspace-file-system.test.ts
Comment thread packages/cli/src/serve/fs/ssh-workspace-file-system.ts Outdated
Comment thread packages/core/src/config/config.ts
Comment thread packages/core/src/services/ssh-workspace.ts Outdated
Comment thread packages/core/src/services/ssh-workspace.ts
Comment thread packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx Outdated
Comment thread packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
Comment thread packages/web-shell/client/utils/workspace.ts

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Blocking finding(s) follow.

⚠️ Downgraded from Request changes to Comment: self-PR. Partially reviewed — gaps disclosed. 7 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

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

中文说明

⚠️ 已从请求修改降级为评论:self-PR。 仅完成部分审查,审查缺口已披露。 7 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

未审查(原文为英文):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

wenshao commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

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

  • Global CLI baseline: SSH registration rejected with HTTP 400.

  • Reviewed a75c998 baseline: 12 findings reproduced (11 against isolated macOS OpenSSH, one with native/Windows path models).

  • After the review fixes: all 12 checks passed, plus 13 supplementary checks for remote terminal cwd/replay, exit 255 versus disconnect, untrusted mutation denial, tool discovery, capability metadata, file format/hash handling, and trusted HTTP create/conditional edit.

  • The final ignore-rule run covered ordinary and nested Qwen rules, Git ignoring the rule files themselves, a 19,000-file ignored directory exceeding the old 4 MiB enumeration bound, multiple literal candidate batches and an unreadable-directory control. All 34 checks passed after the HTTP completeness fix; 5 further real ACP checks passed (39/39), including complete results, result caps and unreadable entries. A subsequent one-line literal-directory correction passed 9/9 checks on the final bundle: ordinary and magic-looking directory names honor both Git and Qwen rules, includeIgnored returns correct flags, and local files remain unchanged.

  • The last timeout correction passed 7/7 real-SSH checks: invalid defaults use 120000 ms, zero remains disabled, ordinary commands return remote output, and oversized explicit deadlines are rejected with zero SSH launches. These later narrow checks followed the earlier ignore/list runs; their bundle fingerprints are recorded separately.

Build and test evidence

Root build, bundle, full typecheck, changed-file ESLint/Prettier and git diff --check passed after the last source fix. Focused suites include 102 core SSH tests, 214 CLI routing/terminal/management tests, 194 Web Shell tests, the SSH daemon capability/primary checks and ACP session checks. The final filesystem/HTTP/ACP regression set passed 226 tests, including both local and SSH filesystem contracts.

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 de99f92fbab55ab97984a4b3eba8e171297c16373c2b9351d3efc54e3596d8de. The native high-effort command separately timed out after 40 minutes with no verdict; it is not counted as an approval or a clean pass. No code was committed while those issues remained open.

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 a75c998, not this follow-up commit. Browser behavior was checked through component tests rather than captured screenshots. Test services were stopped and temporary permission changes restored.

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.

@wenshao

wenshao commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

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:

  • Root build, bundle and complete typecheck passed. Whole-repository ESLint and Prettier passed. Java 21 Runtime Broker tests passed 11/11. Generated Java coverage output was moved into ignored investigation artifacts before whole-repository formatting/lint checks; no lint rule or source exclusion was weakened.
  • Final core SSH unit tests: 112/112. Final CLI configuration, complete daemon-startup, filesystem, ACP and SSH-route suites: 987/987. Earlier merged-tree Web Shell checks passed 340/340 and the actual SSH integration suite passed 25 groups, including an ACP prompt-to-remote-shell round trip.
  • Real SSH follow-up: 20 ignore-rule wire cases plus 32 CLI calls passed; saved compound permissions still allow the same command after recreating the permission manager; seven public filesystem rejection scenarios each produce exactly one denial, with raw-path privacy and a nested remote-conflict control verified.
  • Secondary Streamable HTTP passed 7/7: initialize 200, GET SSE 200, POST stat/read 202 with actual remote results on SSE, DELETE 202 with EOF, and the closed connection rejected with 404. This uses HTTP/SSE, not WebSocket.
  • Two consecutive independent open-ended/reverse audit rounds found no further confirmed issue in the 18-file follow-up and the overlapping main integration paths. The commit tree was checked byte-for-byte against the audited staged tree after pre-commit hooks.

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 自动评审仍在运行。

@wenshao

wenshao commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

SSH workspace verification on a real LAN host (aa60679)

This round tests the PR against a second physical Linux machine over the LAN, not a loopback sshd, and uses a real model to drive the agent through the real Web Shell. The target differs from both earlier reports:

  • Ubuntu 22.04 on arm64 (Orange Pi 5)
  • /bin/sh is dash
  • Python 3.10.12 and Git 2.34.1
  • root's login shell is zsh
  • the connection uses the maintainer's everyday ~/.ssh key and known_hosts, with no lab-only configuration

I started on 1988bf9. The head moved to aa60679 partway through, so I rebuilt and re-ran everything below on aa60679. Results from 1988bf9 appear only where they add something.

Result on aa60679: 93 checks passed and 3 failed. The daemon and Web Shell boundary held in every area I tested: trust, containment, symlinks, hostile names, outages, persistence, Git, the terminal and ACP. All the failures are in the agent's SSH execution path. That path differs from the local tools in ways a real model runs into immediately on a stock Debian or Ubuntu host (F1 and F2 below). I measured a third problem separately: timed-out or cancelled commands stay running on the remote host (F4). A candidate patch fixes all three on the same host: 4 files, +103/−15 lines including 3 tests. A full re-run on the patched build passed 89 of 89 checks.

A/B summary

What ran on aa60679

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.

Add Workspace dialog with an ssh:// URL and the SSH prerequisites Agent report for the remote project beside the integrated terminal on orangepi5
Add Workspace dialog with an ssh:// URL; the SSH prerequisites now show in the default browse mode. Agent report for the remote project (left) and the integrated terminal on orangepi5 (right). The after-timeout/after-cancel files come from F4.

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.

F1

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 with old_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 one edit, 8 tool calls and 37 s.
  • write_file: the remote bytes became red\ngreen\nblue\n. The BOM was silently dropped and CRLF became LF. The local workspace kept EF BB BF and 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.

F2

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/bash and 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 same detectLineEnding as the local tools.

Results on orangepi5 with the patched build:

  • F1: the command printed DOUBLE_BRACKET_OK and shell is /bin/bash; pipefail and source work.
  • F2: one edit call, 0 tool errors. The bytes are EF BB BF alpha\r\nbeta-and-gamma\r\n and EF 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 description when one is present. It falls back to run_shell_command on [email protected]: /root/… only when the description is missing (getDescriptionText in ToolApproval.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/ControlPersist might help. I did not test that here.

Approval card for a remote shell command showing the model's description and no SSH host

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.js built at aa60679 (pnpm worktree bootstrap, then npm run build && npm run bundle), with an isolated QWEN_HOME and 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/sh is 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-lab have 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 项检查全部通过。

A/B 汇总

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 秒后自动删除,并已确认不再存在。

@wenshao

wenshao commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to the real LAN report: F1, F2 and F4 are fixed in 0fbecd3.

  • F1: remote agent commands now execute with Bash, and SSH tool descriptions/command schemas use the same Bash contract even when the local daemon would normally use cmd.exe or PowerShell. Bash is an explicit prerequisite for shell commands, with no silent fallback to sh; the visible hint and both design documents are updated.
  • F2: multiline edits normalize CRLF/LF for matching, while writes, edits and manually revised proposals preserve existing BOM and line-ending format. Whole-file edits copied from BOM-prefixed reads remain valid, new files retain supplied text, and freshness checks still use hashes of the original bytes.
  • F4: execute requests keep SSH stdin open after a bounded JSON line. EOF or output failure triggers remote process-group cleanup: SIGTERM, a two-second grace period, then SIGKILL before reaping the leader. Monitoring continues after output redirection. This also handles a leader that exits while a child ignores SIGTERM. Network partitions may delay disconnect detection, and deliberately detached descendants remain outside the guarantee.

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 EF BB BF alpha\r\nbeta-and-gamma\r\n and EF BB BF red\r\ngreen\r\nblue\r\n, identical to the local-tool controls. Timeout and AbortSignal cancellation prevent the delayed markers. A separate parent/child pair ignoring SIGTERM disappears before its natural six-second deadline, and the child's marker remains absent. Normal completion with closed output streams, pre-abort/late-abort behavior, remote exit 255 and a 1,290,004-byte UTF-8 stdout payload plus separate stderr and exit code 4 all pass. Test processes, services and independently created remote directories were cleaned; the local anchor stayed untouched.

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 60597317b39167986fe00709ef062d5c93ea15a48b276bb491573345f83dfd3c.

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 5a3dcfde8489289af8004191df592fa1506c796ecf5dafa00150079a071107a0. After commit hooks, the committed tree exactly matched the audited tree 740eb88c970c4a41f424d84884819014d93d901f. The earlier six-hour GitHub review timeout produced no verdict and is not counted as an approval. New CI is running for this commit.

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:

  • O1: always show the SSH host/target on approval cards even when the model supplies a description.
  • O2: show the remote path in session tooltips instead of the private anchor.
  • O3: distinguish failures before command start from uncertain execution outcomes in wording.
  • O4: hide local folder browsing suggestions while entering an SSH address.
  • O5: separately evaluate and document OpenSSH connection sharing and its latency impact; it was not evaluated here.

针对真实 LAN 报告的 F1、F2、F4 已在 0fbecd37e3 修复:SSH 统一声明并执行 Bash;agent 多行编辑、写入和手工修改提案保留 BOM/换行格式;超时和取消会清理远端命令进程组,包括重定向输出及忽略 SIGTERM 的子进程。已在同一台 Linux 主机独立复现并验证修复,正常输出、stderr、退出码和取消边界均通过,测试进程与目录已清理。

提交前完成连续两轮独立无方向审计与反向审计,发现测试清理风险后曾重置计数;提交树与审计树完全一致。构建、打包、全仓类型检查、改动文件 Lint/格式检查及上述定向测试通过。GitHub 自动评审此前因六小时时限失败,没有结论,不计为批准。新提交 CI 正在运行。

O1–O5 按仓库多轮评审后的范围要求明确延期:审批卡片始终显示主机、会话 tooltip 远端路径、执行前失败措辞、SSH 输入时隐藏本地浏览建议,以及 SSH 连接复用的延迟评估;本次没有宣称这些观察项已修复。

@wenshao

wenshao commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: merge-ready — 167/167 scripted assertions passed, 0 failed. Verified head 0fbecd37e389a64865a65aed5cdf191404816aa6 (base 97b1b252e3aae5e8be34a2d2f33290cbdd4b0198, defect arm aa60679971ea980f87a292065e096c15a2bf0c19).

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 aa60679971 on both targets, a real-daemon HTTP E2E (56 checks), and a mutation matrix over the fix's own tests.

中文摘要

结论:merge-ready —— 167/167 条脚本断言全部通过。验证 head 0fbecd37e3。

  • 上一轮发现的状态:F1(shell 契约)、F2(BOM/CRLF)、F4(取消后远端进程残留)在 head 全部修复,并在两台架构/系统完全不同的真实 SSH 目标上复验通过:Linux aarch64(Orange Pi,bash 5.2,Python 3.11)和 macOS 15.7.9 x86_64(bash 3.2,Python 3.9)。F4 的 stdin-EOF 取消机制在 macOS 目标上同样生效(此前仅有 macOS 直跑脚本证据,本轮是真实 SSH 链路)。缺陷臂 aa60679971 上三项缺陷全部如实复现,证明 A/B 有效。
  • daemon 级 E2E 56/56:注册(8 类非法 URL 全部 400)、信任门(未信任读通写拒 403,信任热加载生效)、文件路由(BOM/CRLF 字节级保留、409 条件写、符号链接/.. 逃逸 400、16 MiB 后字节窗口、20 MiB 413)、Git 只读路由与 501 边界、ACP 初始化/SSE/拆除/404、daemon 重启后工作区持久化、本地工作区无回归。
  • 变异矩阵 4/4 被捕杀:bash→/bin/sh、去掉 start_new_session、去掉 SIGKILL 升级、preserveFileFormat 置空,每个变异都精确死于对应测试,证明修复的测试不空转。
  • 单测归因:targeted 套件中仅有的失败(config lease、client 微压缩、acpAgent 3 个、metrics flush)在 main 上以同名同因复现,与 PR 无关;3 个时间敏感失败在隔离重跑时通过(负载相关 flake)。
  • 观察项(非阻塞):O3 措辞问题在 head 仍存在并有新证据(注册不可达主机时报 "may still be running",但什么都没启动);O5 延迟量级与前轮一致(每次操作一次 SSH 握手)。O1/O2/O4 本轮未覆盖(需要 Web Shell UI + 真实模型)。
  • 未覆盖:真实模型 + Web Shell 端到端、断网故障注入、Windows 目标、ProxyJump、逐 commit 归因。试合并 origin/main(0adae3c254)无冲突;3 个重叠文件(server.ts、server.test.ts、i18n.tsx)均干净合并,合并态由 PR 的 merge-ref CI 覆盖。

Previous findings: status at 0fbecd37

All carried-forward measurements were re-run at the new head, on both targets — not quoted from the earlier rounds.

# 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); :22 and 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\n byte-for-byte; stale hash → 409 and the file is untouched; ../ and symlink escapes → 400; ignore rules honoured (nested .qwenignore, 500-file node_modules excluded).
  • Large files: byte window at 18 MiB of a 20 MiB file returns 200; full read → 413 file_too_large.
  • Git: /git branch+counts, /git/diff, /git/diff/file hunks match the host's own git; commit/checkout → 501 ssh_workspace_operation_unsupported; host HEAD unchanged.
  • ACP over HTTP: initialize mints a connection id, GET with Accept: text/event-stream returns 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.ts 824/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 tip 0adae3c254): the config.test.ts lease failure and client.test.ts microcompaction failure reproduce byte-identically on main; the 3 acpAgent.test.ts failures and the run-qwen-serve metrics-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 typecheck clean in the head worktree (log typecheck.log).
  • Trial merge: git merge-tree origin/main × head exits 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 is review-pr.
  • One observability note, resolved: a truncated:true flip seen across cells was traced (by labeling every incomplete source 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:false is 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..0fbecd37 delta 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-pr was 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

Head build on Linux target: 28/28 assertions pass Defect arm aa60679971 on Linux target: F1/F2/F4 reproduce as expected
Head 0fbecd37 on the Linux aarch64 target — F1/F2/F4 fixed behaviors all present (28/28). Defect arm aa60679971 on the same Linux target — all three defects reproduce (22/22 expected-outcome assertions).
Head build on macOS target: 28/28 assertions pass Defect arm aa60679971 on macOS target: F1/F2/F4 reproduce as expected
Head on the macOS 15.7.9 x86_64 target (bash 3.2, Python 3.9) — first macOS SSH target in any round; stdin-EOF cancellation works there too (28/28). Defect arm on the same macOS target — contract violation visible as shell=/bin/sh, edit fails on CRLF, markers appear after timeout/cancel (22/22).
Daemon E2E: 56/56 across registration, trust, files, git, ACP, persistence Mutation matrix: 4/4 mutants killed by the intended tests
Real bundled daemon over real SSH to both targets — registration/trust/files/git/ACP/persistence/local non-regression (56/56). Mutation matrix over the fix's own tests — each of the 4 mutants dies at exactly the intended test(s).

@wenshao

wenshao commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts filters 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 answers METHOD_NOT_FOUND with errorKind: 'ssh_workspace_operation_unsupported' and httpStatus: 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.ts records at :240 on the permission-denied path and again at :472 inside a wrapper guarded by a WeakSet<FsError> (:460-472), so an FsError raised anywhere is recorded exactly once rather than only when it reaches request()'s catch.
  • The store read fails closed only where it should. packages/cli/src/serve/ssh-workspace-store.ts returns undefined for 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 that realpathSync(cwd) === path.resolve(cwd), opens connection.json with O_RDONLY | O_NOFOLLOW, requires stat.isFile() and a size at or under 16 KiB, requires an object with a string url, and finally checks connectionId(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-408 returns type: 'exec' with command, rootCommand from getCommandRoots, permissionRules built through extractCommandRules per split command and rendered as Bash(rule), and warnings combining buildShellExecWarnings with an explicit note that an interrupted connection may leave the remote command running. The edit branch returns originalContent, newContent, a createPatchSmart diff and skipIdeDiff: 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 requires os.path.commonpath([root, target]) == root. parent() walks only the relative components beneath a dup of root_fd, so nothing above the root is re-resolved. child_directory() lstats with follow_symlinks=False and fails symlink_escape before opening with O_RDONLY | O_DIRECTORY | O_NOFOLLOW. open_directory() walks from /, closes each fd as it descends and re-raises after closing on error. Reads use O_RDONLY | O_NOFOLLOW | O_NONBLOCK with dir_fd, then fstat and an S_ISREG requirement, so a symlink or FIFO planted at the target fails rather than being followed; inspect() stats with follow_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-standard plus --exclude-per-directory=<name> for each - and config.ts threads configParams.fileFiltering?.customIgnoreFiles into SshExecutionEnvironment. Search also reports incomplete whenever git emits a diagnostic, fails explicitly with Cannot determine remote Git ignore rules. for a non-git directory that has ignore rules, excludes .git paths, 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-2763 the block constructs SshExecutionEnvironment with the operator's own truncateToolOutputThreshold, shellDefaultTimeoutMs and customIgnoreFiles - 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.

@wenshao
wenshao enabled auto-merge September 22, 2026 02:17

@yiliang114 yiliang114 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@wenshao
wenshao dismissed a stale review September 22, 2026 02:46

fixed

@wenshao
wenshao added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 76f0471 Sep 22, 2026
72 of 73 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants