Skip to content

feat(web-shell): restore remote workspace add flow - #12085

Merged
yiliang114 merged 18 commits into
mainfrom
codex/11475-remote-folder-entry
Sep 19, 2026
Merged

yiliang114 merged 18 commits into
mainfrom
codex/11475-remote-folder-entry

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

This PR adds Codex-style remote connections to the standalone Web Shell. Settings > Connections adds and manages verified daemon origins, while bearer tokens remain tab-scoped. Adding a computer verifies it through the existing connection gate and then returns to Settings > Connections. The normal Add workspace action opens the directory browser directly; its Folder source selector switches between this computer and connected remote computers without a separate Local/Remote step. Workspace rows from a remote daemon use a small blue globe badge on the folder icon; local rows keep the plain folder icon.

The directory-add continuation is deliberately one-shot and tab-scoped. Changing Folder source resumes the same browser on the selected computer, Cancel returns to the exact source URL, a successful add stays on the selected daemon, and ordinary daemon switches never resume the flow. The persistent catalog contains verified origins only, not tokens, workspaces, or cross-daemon project state.

The directory browser supports daemon-provided suggestions, parent navigation, manual absolute paths, loading/error/unsupported states, and responsive desktop/mobile controls. Authentication failures remain inside the same flow, while tokens remain origin-scoped in session storage and never enter the URL.

Why it's needed

After #11548 was narrowed to one selected daemon, adding a remote folder required switching the whole shell through Daemon Status and then manually entering an absolute path. It also treated the server address as part of each add operation instead of modeling a remote computer as a reusable connection.

This restores a productized workflow without reintroducing cross-daemon workspace aggregation or a second workspace data model.

Reviewer Test Plan

How to verify

  1. Add a remote daemon through Settings > Connections. Confirm the connection is verified and returns to the same Settings category, the origin appears under Connected computers and can be forgotten, and forgetting it also clears its tab-scoped token.
  2. Choose the normal Add workspace action. Confirm the directory browser opens immediately with Folder source set to This computer and no separate Local/Remote dialog.
  3. Select a connected computer from Folder source. Confirm the token is absent from the URL, the same directory browser resumes with remote folders after navigation, and remote workspace rows show a blue globe badge on their folder icon.
  4. Navigate through suggested directories, use Parent folder, and add a folder. Confirm the workspace is registered on the selected daemon and the flow state is cleared.
  5. Switch Folder source back to This computer, then repeat and Cancel. Confirm the browser stays in the directory flow when switching sources and Cancel returns to the exact source page.
  6. Use an invalid token and cancel from the connection gate. Confirm the source page is restored and no continuation state remains.
  7. Start on remote daemon A, begin adding from daemon B, then cancel. Confirm daemon A is restored without another unfamiliar-target gate.
  8. Switch daemons through Connections or Daemon Status. Confirm the remote folder browser does not open.
  9. Verify the browser dialog at desktop and 390 x 844 mobile viewports; all controls should remain visible and inside the viewport.

Current-head UI evidence was captured manually against two local mock daemon endpoints. This evidence refresh did not run local lint, tests, or npm ci; repository gates remain with CI.

Evidence (Before & After)

Before: remote daemon details were entered as part of an independent add operation, so adding a workspace did not reuse a configured remote-computer concept.

After, Connections:

Connections settings after adding a remote computer

After, direct directory browser with a remote Folder source:

Remote directory browser

Tested on

OS Status
🍏 macOS ✅
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

macOS, Chromium Playwright against mocked local and cross-origin daemon endpoints.

Risk & Scope

  • Main risk or tradeoff: the workflow crosses a full-page daemon navigation, so its source URL and continuation marker are kept only in the current tab and are aggressively cleared on completion or cancellation.
  • Not validated / out of scope: multi-daemon workspace aggregation, remote daemon lifecycle management, Windows-specific UI verification, and a real second-machine daemon are not part of this PR.
  • Breaking changes / migration notes: the intermediate Local/Remote chooser is removed; connection management lives in Settings and source selection lives in the normal directory browser.

Design: English · 简体中文

Linked Issues

Refs #11475

中文说明

本 PR 做了什么

本 PR 为独立 Web Shell 增加 Codex 风格的远程连接概念。设置 > 连接用于添加和管理验证过的 daemon origin,bearer token 仍只保存在当前标签页。添加计算机时会通过已有连接页完成验证,随后返回设置 > 连接。普通的添加工作区操作会直接打开目录浏览器;顶部的目录来源可以在这台计算机和已连接的远程计算机之间切换,不再弹出独立的“本地 / 远程”步骤。来自远程 daemon 的 workspace 会在文件夹图标右下角显示蓝色小地球,本地 workspace 保持普通文件夹图标。

目录添加续接被明确限制为当前标签页内的一次性流程。切换目录来源会在所选计算机上续接同一个浏览器,取消会返回准确的来源 URL,添加成功后留在所选 daemon,普通 daemon 切换不会续接该流程。持久化目录只包含验证过的 origin,不包含 token、workspace 或跨 daemon 项目状态。

目录浏览器支持 daemon 返回的目录建议、上一级导航、手工绝对路径、加载/错误/不支持状态,以及桌面和移动端响应式操作区。认证失败仍保留在同一流程中;token 继续按 origin 隔离在 session storage 中,且不会进入 URL。

为什么需要

#11548 收窄为单个已选 daemon 后,添加远程目录要求用户先通过 Daemon 状态切换整个 shell,再手工输入绝对路径;服务器地址也被当成每次添加操作的一部分,而不是一台可复用的远程计算机。

本 PR 恢复产品化流程,同时不重新引入跨 daemon workspace 聚合或第二套 workspace 数据模型。

Reviewer Test Plan

如何验证

  1. 通过设置 > 连接添加一个远程 daemon。确认完成验证后返回同一个设置分类,origin 出现在“已连接的计算机”中并且可以移除,移除时也会清理当前标签页中的对应 token。
  2. 选择普通的添加工作区操作。确认目录浏览器立即打开,目录来源默认为“这台计算机”,并且没有独立的“本地 / 远程”弹窗。
  3. 从目录来源中选择一台已连接计算机。确认 URL 不包含 token,导航后在同一个目录浏览器中显示远端目录,并且远程 workspace 的文件夹图标带有蓝色小地球。
  4. 通过目录建议进入子目录,使用上一级,然后添加目录。确认 workspace 注册在所选 daemon 上,且流程状态已清理。
  5. 将目录来源切回“这台计算机”,然后重新执行并取消。确认切换来源时目录浏览流程保持打开,取消后返回准确的来源页面。
  6. 使用无效 token,并从连接页取消。确认恢复来源页面且不残留续接状态。
  7. 从远程 daemon A 开始,在 daemon B 上启动添加流程后取消。确认直接恢复 daemon A,不再出现陌生目标确认页。
  8. 通过“连接”或 Daemon 状态切换 daemon。确认不会打开远程目录浏览器。
  9. 分别在桌面和 390 x 844 手机视口检查目录浏览弹窗;所有控件都应完整可见且位于视口内。

当前 head 的 UI 证据通过两个本地 mock daemon 手工采集。本次证据更新没有在本地执行 lint、测试或 npm ci;仓库门禁由 CI 负责。

证据(Before & After)

Before: 远程 daemon 信息属于一次独立的添加操作,普通“添加工作区”无法复用已配置的远程计算机。

After,连接设置:

添加远程计算机后的连接设置

After,直接打开并选择远程目录来源的目录浏览器:

远程目录浏览器

测试平台

系统 状态
macOS ✅
Windows N/A
Linux N/A

环境(可选)

macOS,Chromium Playwright,使用模拟的本地与跨来源 daemon endpoint。

风险与范围

  • 主要风险或权衡:该流程会执行完整页面 daemon 导航,因此来源 URL 和续接标记只保存在当前标签页,并在完成或取消时主动清理。
  • 未验证 / 范围外:多 daemon workspace 聚合、远程 daemon 生命周期管理、Windows 专项 UI 验证,以及真实第二台机器上的 daemon 均不属于本 PR。
  • 破坏性变更 / 迁移说明:移除中间的“本地 / 远程”选择页;连接管理位于设置中,来源选择位于普通目录浏览器中。

设计文档:English · 简体中文

关联 Issue

Refs #11475

@yiliang114

yiliang114 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Superseded verification note

The previous report targeted 676964111b43 and described the removed Local/Remote chooser. It does not apply to the current PR head.

Current-head UI evidence for 1b5c3d647c is recorded in the updated UI verification comment: #12085 (comment)

A Local add resumes through the same full-page navigation as a remote one,
so the shells shown while the daemon's capabilities are still unknown, or
when it cannot register workspaces at all, were announcing a local add as a
remote one. Both now use the neutral dialog title, and the connection gate
no longer offers to cancel a remote add on the local path.

The typed-path autocomplete also stopped holding its previous results while
the next lookup was pending. Clearing the list is only needed in browse mode,
where the list is the primary control, so it is scoped there again.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Real-daemon browser verification

Verified 67337b7faf90 plus the two fixes below, in Chromium, against two real qwen serve
daemons: the Web Shell built from this branch served on 127.0.0.1:4190 with a local workspace,
and a second daemon on 127.0.0.1:4191 behind a bearer token with --allow-origin for the first,
its workspace set one level above a directory that exists nowhere else so the two suggestion lists
cannot be confused. The page never left the first origin: ?daemon=http://127.0.0.1:4191 switched
the API target while location.origin stayed 127.0.0.1:4190, which is what lets the tab-scoped
return URL survive the navigation.

Driven and observed:

  1. Add workspace on the standalone shell opens a dialog titled Add Workspace with a Workspace location choice. With an empty catalog, Remote reads "Connect a computer in Daemon Status first." and Local is preselected.
  2. Local → Next opens the browser at /private/tmp/ (the parent of the local daemon's workspaceCwd) with a 50-entry list from a live suggest call, plus Parent folder, Browse…, Change computer, Cancel and Add this folder. Parent folder moved it to /private/. Cancel closed the dialog, left the shell where it was, and the local daemon's registration list stayed empty.
  3. Opening ?daemon=http://127.0.0.1:4191 for an origin this browser had never used showed the unfamiliar-target gate with the address prefilled and "Connect only if you trust it." Entering that daemon's token connected the shell: the sidebar switched to the remote workspace, the URL kept no token, and the browser-local catalog became exactly ["http://127.0.0.1:4191"].
  4. Add workspace → Remote then lists 127.0.0.1:4191 as a connected computer and changes the hint to "Use a folder on a connected server." Next resumed on the remote daemon, where the browser opened at that daemon's own directory — alpha/ beta/ reports/ — and the hint named it: "Folder on http://127.0.0.1:4191."
  5. Entering reports/ and clicking Add this folder closed the dialog, kept the shell on the remote daemon, cleared the tab-scoped return marker, and added the workspace to the sidebar. The remote daemon reports the new registration (/private/tmp/pr-remote-ws/reports, active, persisted) while the local daemon's registration list is still empty — the add landed on the selected daemon only.
  6. Remote workspace rows carry the globe badge (2 rows on the remote target, aria-label "Remote"); the same shell on its own daemon shows none.
  7. Change computer from the remote browser returned to the source shell with the location step reopened; Cancel returned to the source shell with the dialog closed. Daemon Status lists the verified computer under Connected computers.

A screenshot was captured at every step (location choice, both browsers, the gate, the empty-directory state, the added workspace, the badge control, Change computer, Cancel, Daemon Status) and the run is reproducible from the two daemon commands above; the observations quoted here are the rendered UI text, not a reading of the source.

Not covered: macOS Chromium only, loopback ports standing in for two hosts (no real SSH tunnel or LAN path, so TLS and mixed-content are untested), a single saved computer, and no long-lived remote session or re-auth after a daemon restart.

Review follow-ups

Six suggestions came back from triage, none blocking. Two are fixed here, and the rest are answered against the current head.

  1. Local announced as remote — fixed. The two shells that stand in while capabilities load (or when the daemon cannot register workspaces) were titled with the remote wording even though a Local add resumes into them; they now use the neutral title, and the gate's cancel button no longer offers to cancel a remote add. The sidebar entry itself was already neutral.
  2. Autocomplete blanking during the debounce — fixed. The suggestion list is now cleared only in browse mode, where the list is the primary control, so the pre-existing typed-path autocomplete keeps its previous results until the next answer arrives instead of flashing empty on every keystroke. This was a regression against main, not just a remote-path concern.
  3. Design docs — already updated for the Local option in both languages at this head; both describe the this-computer/connected-computer choice and its acceptance criterion.
  4. "Standalone only" pinned negatively — already covered by the existing two-entry-points case, which sets the registration capability and, with the embedded default, requires the plain dialog to open. Inverting the branch fails that test.
  5. Escape/close removed while submitting — left as written. The workspace mutation goes through the daemon client, which has its own request timeouts, and the dialog is the only writer of that state; I would rather not widen the diff for a hypothetical hang.
  6. typeof window guard — declining, with the reasoning that web-shell mounts through ReactDOM.createRoot and needs no server-render path; the sibling module the suggestion compares against reads location unguarded on its own entry points, so the proposed invariant does not actually hold there either.

The E2E specs cited in the body are still not part of the diff, so they cannot be re-run by a reviewer; the run above is the record that a real daemon drives the whole flow.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Fixes for the copy and autocomplete findings are pushed in 11d6bac, and the real-daemon browser run is recorded above. @qwen-code /triage

The sidebar's Add workspace action now renders in every standalone shell
regardless of the daemon's registration capability, so an accessible-name
substring match on "Add" resolves to two buttons and the extension archive
smoke test fails strict mode. Match the dialog's button exactly, as the rest
of the suite does.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

web-shell E2E Smoke failed on 11d6bac — cause and fix

The job failed all three attempts (original + 2 retries) in the extension-manager smoke test, not in anything the copy fixes touched:

Error: strict mode violation: getByRole('button', { name: 'Add' }) resolved to 2 elements:
  1) <button title="Add workspace" aria-label="Add workspace" class="_projectsHeaderAction_jzzo8_322">
  2) <button data-slot="button" …> aka getByTestId('inline-panel').getByRole('button', { name: 'Add' })

An accessible name matches by substring, so the sidebar's Add workspace action also matches Add. That action renders only when the shell passes the add-workspace handler, and this PR made that condition standalone || dynamic_workspace_registration instead of the capability alone. The e2e fake daemon advertises neither the registration capability nor a locked workspace, and the dev entry mounts standalone — so before this PR the button could not appear in that spec, and now it always can. That is the same behaviour widening as the earlier note about standalone shells offering the entry on daemons that cannot register workspaces, and this is its concrete cost: an unrelated, previously green test became ambiguous.

b910bfb matches the dialog's button exactly, the disambiguation the rest of the suite already uses. Verified against this branch on a free port: the spec fails without the change and passes with it, and the whole @smoke set is 84/84 green locally. Nothing else in the suite collides — the other 83 tests passed on CI with the button already present.

If reviewers would rather keep the entry gated on the daemon's capability, that decision supersedes this test fix, and the test should then be left as it was.

Reproduction note for anyone running these specs locally: the config sets reuseExistingServer outside CI, so a dev server left on the default port by another checkout is silently reused and the run tests that code instead. Pin PLAYWRIGHT_PORT to a free port.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Extension-manager smoke fix pushed in b910bfb. @qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

E2E test report — remote workspace add flow

The behavioural claim in this PR now has a committed test instead of a throwaway config: packages/web-shell/client/e2e/web-shell.remote-workspace-add.spec.ts, four @smoke scenarios in the repository's own suite.

cd packages/web-shell
npx playwright test --config playwright.config.ts client/e2e/web-shell.remote-workspace-add.spec.ts --project=chromium
4 passed (16.5s)

Chromium on macOS. If a dev server from another checkout already owns the default port, pass a free one (PLAYWRIGHT_PORT=5274 …): outside CI reuseExistingServer is enabled, so the run will otherwise exercise that other tree's code.

Two mock daemons are installed on one page — the page's own origin with a local workspace at /srv/local-project, and http://127.0.0.1:5199 with a remote workspace at /srv/remote-project — and each host lists one folder that does not exist on the other, so a suggestion list identifies which daemon answered.

  1. the resumed browser lists the chosen computer directories — the Remote choice navigates the tab to the chosen computer and the resume marker survives it; the resumed dialog lists shared-checkout and never local-checkout; the source daemon receives no suggestion request at all; picking the folder registers /srv/shared-checkout/ on the chosen computer, and the source daemon receives no registration either.
  2. cancelling returns to the exact source tab — toHaveURL(sourceUrl) with an empty query string, the dialog gone, the sidebar entry back, and no registration on either host.
  3. changing the computer reopens the chooser on the source tab — the shell is not behind the unfamiliar-target gate, the chooser is open with Local selected, and the origin is unchanged.
  4. the local choice browses folders on the page origin — the local folder is listed, the origin never changes, the continuation marker is already cleared from the URL, exactly one reload happened (two load events including the initial navigation), and the suggestion request went to the page's own daemon.

Test infrastructure added for this (client/e2e/utils/mockDaemon.ts): GET /workspace-path-suggestions and POST /workspaces, plus a pathSuggestions scenario field. Both are additive — nothing in the SDK or the Web Shell client calls GET /workspaces, so no other spec's routing changes — and the responses were matched to the real contracts (DaemonWorkspacePathSuggestions, DaemonAddWorkspaceResult) rather than guessed, including updating the capability snapshot so the post-add refresh sees the new workspace.

@yiliang114

yiliang114 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Scope ledger: the earliest reviewed head (3ccc4e8) was 17 files / 1,246 changed lines. The current PR diff at 0696655 is 31 files / 2,756 changed lines against base 2bd62fb. All current implementation files map to the verified-connections catalog, folder-source workspace flow, remote-host indicator, or the bounded F1 abandoned-hand-over fix. This is substantive round 4; scope-correction mode remains active, and N1/N4 stay outside this PR.

chiga0
chiga0 previously approved these changes Sep 17, 2026

@chiga0 chiga0 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.

No blocking findings.
Approval blockers: none.


Scope: Source files only — packages/web-shell/client/ (all 28 changed files). Docs reviewed for consistency with code. E2E spec reviewed but not executed (no working tree). Windows behavior not exercised (author notes same).

Checked (static):

  • Token paths — startRemoteWorkspaceAdd strips token and hash from the return URL before writing to sessionStorage. leaveRemoteWorkspaceAdd strips them again on the return leg. buildDaemonConnectionUrl deletes addRemoteWorkspace alongside the existing params. No token reaches localStorage or a URL.
  • Return URL origin guard — leaveRemoteWorkspaceAdd rejects a stored URL whose .origin differs from window.location.origin. Since the web shell is a SPA served at one origin (daemon target is a query param, not a navigation), this guard covers both the local-reload and cross-origin remote paths correctly.
  • remote-connections.ts trust model — stored values are re-validated via getAllowedDaemonOrigin(origin) === origin at every read. The new isRemoteConnectionKnown bypass in main.tsx is intentional: only origins the user explicitly connected to skip the unfamiliar-target gate, and they pass the validator on each read.
  • remoteWorkspaceAddActiveRef lifecycle — traced all six mutation sites. Each is co-located with a setShowAddWorkspace* setter that forces a re-render; handleSubmit/onClose closes the success path correctly.
  • Change-computer return path — leaveRemoteWorkspaceAdd(true) appends ?addRemoteWorkspace=connect to the validated return URL, calls confirmDaemonTarget(savedDaemonOrigin) before navigating, then clears RETURN_URL_KEY. App reads the param on load, opens the chooser dialog, clearRemoteWorkspaceAddStep removes it. Round-trip is clean.
  • i18n — sidebar.addRemoteWorkspace removed; dialog titles use the neutral sidebar.addWorkspaceTitle; cancel label updated in both locales; all new keys present in EN and ZH.
  • navigateToDaemon API — additive optional third parameter, all existing callers unaffected.

Cross-check against qwen-code-ci-bot's earlier review:

Bot filed two non-blocking items: (1) copy inconsistency in dialog/sidebar labels — resolved in commits 5 and 6, confirmed fixed at head; (2) remoteWorkspaceAddActiveRef fragility — I traced the same paths and agree: correct today, but the pattern requires pairing every future ref mutation with a state setter. No bot finding survived unrebutted.

Unreviewed dimensions: execution tier not run (no working tree); Windows not exercised.

Reviewed with AI assistance.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Round 2 — findings disposition at b045a7de

Two commits on top of b910bfb4: 74fb08e fix(web-shell): guard the resumed add step outside a document and b045a7de test(web-shell): cover the remote workspace add flow end to end.

# Finding at 3ccc4e85 Disposition Where
1 Copy still announces a remote add on the local path (sidebar action, both resumed DialogShell titles, connection-gate cancel) Fixed. sidebar.addRemoteWorkspace is gone from client/; the sidebar action uses sidebar.addWorkspace and both resumed shells use sidebar.addWorkspaceTitle; the gate reads "Cancel adding workspace" / "取消添加工作区". 11d6bacd
2 Design docs still describe the operation as remote-only Fixed. Both languages now describe the choice as this computer or a previously verified remote connection, and the acceptance criteria match; the badge is documented too. e666df98, 67337b7f
3 The central claim is not pinned by any committed test Fixed. New client/e2e/web-shell.remote-workspace-add.spec.ts — four @smoke scenarios in the repository's own suite, plus the mock-daemon support they need. Separate E2E report below. b045a7de
4 Only the positive standalone assertion exists Fixed. New App.test.tsx case asserts data-has-add-workspace === 'false' for an embedded shell whose daemon lacks the capability. b045a7de
5a dismissible={!submitting} removes Escape and close with no way out if the mutation hangs Not a defect. workspaceActions.addWorkspace is wrapped in withActionTimeout(…, 'Add workspace timed out') (client/daemon/workspace/actions.ts:1135, 30 s default in client/daemon/timing.ts), so submit always settles and the dialog becomes dismissible again. no change
5b The suggestion effect blanked the typed-path autocomplete on every keystroke Fixed. Scoped to browse mode, where the list is the primary control. 11d6bacd
6 remote-workspace-add.ts has no typeof window guard Fixed. getRemoteWorkspaceAddStep() returns undefined outside a document, with a jsdom unit test that fails when the guard is removed. 74fb08e

Why the new tests are load-bearing rather than decorative

Each one was checked by breaking the code under it, not only by watching it pass:

  • Forcing remoteWorkspaceAddActiveRef to false fails all four E2E scenarios; forcing the resumed dialog's initialPath to undefined fails the first one — the dialog appears but never lists the chosen computer's folders.
  • The embedded-shell assertion fails when the sidebar entry is passed unconditionally.
  • The typeof window unit test fails with the guard removed.

The mock daemon's two new routes were matched to the real contracts instead of guessed: GET /workspace-path-suggestions answers DaemonWorkspacePathSuggestions (kind/dir/sep/suggestions[]/truncated, client/daemon/workspace/types.ts:400), which is exactly what AddWorkspaceDialog reads, and POST /workspaces answers DaemonAddWorkspaceResult (types.ts:351) while updating the capability snapshot so the post-add refresh sees the new workspace. An OPTIONS preflight branch I wrote first was deleted after a request log proved Playwright's route interception never issues one for these calls — dead code.

Gates at b045a7de

  • Changed-file ESLint, prettier --check, tsc --noEmit, and npm run build: clean.
  • Web Shell unit suite: 8443 passed (the run needs dist/, so build first for build-artifact.test.ts). One earlier run on this same head failed a focus assertion in components/composer/AddMenu.test.tsx; it passes in isolation and a full re-run of the same head is green, so it is a load-sensitive flake rather than something this diff reaches.
  • Web Shell E2E, full suite on a free port: 158 passed, 4 failed — all four in web-shell.live-state-poll-interval.spec.ts. They fail identically with my changes reverted, so they are pre-existing and local to this machine; the same specs are green in CI.

One red check, and it comes from the base

Lint & Static failed on b045a7de inside Check lint gate freshness, before any linter ran. That step fails when main changes a gate-defining file after the branch last incorporated it, and it names eslint.config.js and eslint.legacy-core-barrel-imports.mjs from 6c962b3719c6 (#11163, merged today). The same lane was green on b910bfb4 about an hour earlier, so this is main moving, not the two new commits. Neither change touches this diff: one adds the generated web-shell confusables table to the ignore list, the other drops a packages/cli/src/serve/routes/ entry from the barrel-import allowlist, and the branch touches no packages/cli file and neither path. The lane's own remedy is to merge or rebase main into the branch, and main merges cleanly into it, so that stays a deliberate separate step rather than a side effect of this round.

Recorded but not acted on

  • The three JSX branches keyed on remoteWorkspaceAddActiveRef.current and the two near-duplicate DialogShell blocks. I agree this is the least pleasant part of the change, and collapsing it would also remove the pairing requirement between the ref write and the state write. It is a refactor of code this PR just introduced, so it would widen the diff again; recording it here rather than doing it inside a verification commit.
  • The design questions from the first stage (navigateToDaemon's third parameter, whether the "no cross-host continuation" constraint is formally retired on feat(serve): supported remote folders — connect clients to a remote daemon and manage its workspaces/sessions #11475, and whether the two ways of adding a workspace eventually merge). These are issue-thread decisions rather than defects in the diff, and none blocks merge.
  • The widened sidebar entry. On a standalone shell whose daemon lacks dynamic_workspace_registration, the action is now visible and the Local branch reaches the "This server does not support adding workspaces." state where main hid the entry entirely. That is the deliberate trade-off for offering the Local option; the embedded-shell behaviour stays pinned by the new App.test.tsx case, and the ambiguous smoke locator this widening caused is fixed in b910bfb4.

@qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Merged the latest main in 2cde1e4, which clears the stale lint-gate failure. 1773d987 is a subtractive follow-up: it removes the redundant completion ref and collapses the duplicate loading/unsupported shells without changing the flow contract.

Verified with Web Shell typecheck, changed-file ESLint, Prettier, the focused App test, and the four remote workspace-add Playwright scenarios. A fresh correctness/security review and a focused post-cleanup correctness pass found no actionable issues.

@qwen-code /triage

…icate chrome

Follow-up to the remote workspace add flow, addressing frontend review of
this PR.

Behaviour

- Choosing Local, or the remote daemon this tab is already connected to,
  no longer reloads the whole shell. startRemoteWorkspaceAdd always went
  through navigateToDaemon, whose same-origin branch is a full
  window.location.reload() — so the common case (add another folder on the
  daemon I am already on) tore down the open session, its socket, the
  composer draft and the scroll position. Only a different computer now
  takes the full-page handover.
- The location step is skipped when no remote computer has been connected:
  it was a choice with one viable answer on every local add.
- The Add workspace entry is hidden again when the daemon cannot register
  workspaces and there is no remote computer to pick instead. It used to
  appear on any standalone shell and dead-end two steps later on "this
  server does not support adding workspaces".
- Enter in the folder browser opens the typed directory instead of being a
  silent no-op, and reports a non-absolute path. Registering a workspace
  stays a deliberate button press.
- Escape in the folder browser closes the dialog. Browse mode keeps the
  list open by design, so the list-dismiss interception left Escape doing
  nothing at all.

Design

- The daemon is named once as a host chip beside the sidebar Project
  heading, instead of badging every workspace row with the same fact —
  the badge was derived per daemon, not per workspace, so it repeated N
  times and added N screen-reader labels.
- The folder step names its computer in the dialog subtitle rather than
  inside the field hint, and the hint now sits above the list it points
  at ("Choose a folder below" rendered under the list on mobile).
- The path is navigable as breadcrumbs; the single parent-folder icon
  button it subsumes is gone.
- Selected location cards get a check mark and a brighter icon, not just
  a border tint, which was near-invisible on the dark theme.
- The disabled Remote card carries a Connect a computer action instead of
  a dead-end sentence.
- The back button reads Change location, matching the step it returns to.
  It said Change computer even for a Local add, which the code comment
  already flagged as ambiguous.

Cleanup

- One WorkspaceAddStatusDialog replaces three sibling DialogShells
  (loading, unsupported, browser) whose exclusivity rested on three
  overlapping predicates.
- Drop four workspaceHost i18n keys that no code referenced, in both
  locales.
- Format daemon origins through a shared helper rather than a bare
  new URL(origin).host on a render path.

Not run locally: build, typecheck and tests. CI owns those.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Verification at 2364d150

2364d150 records that it left build, typecheck and tests to CI. CI is green on it (Lint & Static, Test (ubuntu-latest), web-shell E2E Smoke, Capture web-shell visuals, both Desktop Shell lanes, Integration Tests (no-AK); review-pr still running), and here is an independent local pass on top of that, on the actual head:

  • Focused unit tests over the surface this commit touches — App.test.tsx, both workspace dialogs, remote-workspace-add, the sidebar workspace-removal test: 1187 passed.
  • The remote-workspace-add Playwright scenarios: 5 passed (16 s), including the new "with no connected computer the folder browser opens directly" and the updated single-load assertion for the local choice.
  • Changed-file ESLint (no errors; the two CSS files are outside the lint config's scope) and Prettier check: clean.

Two notes, neither of them a finding against this diff:

  • The full Web Shell unit suite (8658 tests) came back with one failure, components/composer/AddMenu.test.tsx > returns keyboard focus to the trigger after Escape. It is intermittent and load-sensitive: on this same head it passed twice and failed once when run alone, and the DOM in the failure contains the hidden file input that #12084 added in the merge. The branch touches no composer code, so this is a flake in that test rather than a regression here — but it can redden the suite for anyone, so a re-run is the right response when it appears.
  • If you reproduce locally from a linked worktree that shares the main checkout's node_modules, a Web Shell typecheck after merging main reports seven errors in BranchPickerPopover.tsx and AuthMessage.tsx about missing SDK members. Those are stale build artifacts resolved out of the main checkout, not this branch; building inside the worktree does not change it because the root node_modules is a symlink.

The parent-folder button already navigated upwards and the path input
already shows and edits the full path, so the breadcrumb row was a third
device for the same job — a feature added during review cleanup rather
than a fix for anything. Restore the button and delete the crumb helper,
its nav markup and its i18n key.

Kept from that pass: the hint moved above the list it points at, the
dialog subtitle naming the computer, and Enter opening the typed
directory instead of doing nothing.

Not run locally: build, typecheck and tests. CI owns those.
@yiliang114

yiliang114 commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Current-head UI evidence at 1b5c3d647c

The earlier evidence was captured from 676964111b43, before the interaction was changed, so it has been superseded.

This capture uses the current PR head with the Web Shell dev server and two local mock daemon endpoints. No local lint, tests, or npm ci were run for this evidence refresh.

1. Add and manage the remote computer in Connections

Adding 127.0.0.1:5199 verifies the target through the connection gate and returns to Settings > Connections. The address field is empty for the next connection, and the verified computer is listed separately.

Connections settings after adding a remote computer

2. Add workspace opens the directory browser directly

Clicking Add workspace opens this directory browser immediately. There is no Local/Remote chooser. Folder source selects the connected computer inside the same browser, and the remote daemon supplies shared-checkout/.

Remote directory browser with Folder source

The sidebar also shows the remote host chip and the remote workspace folder with its blue globe badge. This capture uses mock daemon endpoints rather than a physical second machine.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is queued for an available runner; follow the workflow run for progress. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes.

@wenshao

wenshao commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — two real qwen serve daemons, production Web Shell bundle

Verified 1b5c3d647c on macOS against real daemons, not mocked endpoints. The shell under test is the built packages/web-shell/dist served by qwen serve itself (the production static path and its CSP), and the "connected computer" is a second qwen serve behind a real bearer token with --require-auth and --allow-origin. Chromium drove the real UI: 70 scripted assertions, 66 green; the 4 red ones are all one defect (F1 below).

Rig

Piece What it is
Web Shell built from this head (npm run build), served by daemon A at http://127.0.0.1:4190 — not the vite dev server
Daemon A — This computer qwen serve --port 4190 --workspace …/hostA/workA, loopback, isolated QWEN_HOME
Daemon B — connected computer qwen serve --port 4191 --workspace …/hostB/workB --token … --require-auth --allow-origin http://127.0.0.1:4190
Daemon C a third qwen serve on :4192 for multi-computer cases
Filesystem hostA/ and hostB/ hold disjoint directory names, so the two suggestion lists cannot be confused
Driver Playwright/Chromium 1.58, desktop 1440×900 and mobile 390×844

Reviewer Test Plan — 9/9 reproduced

# Step Result
1 Add a remote daemon through Settings > Connections ✅ Verified through the gate, returned to the exact source URL with Connections selected (aria-current="page"), qwen-remote-connections = ["http://127.0.0.1:4191"], token only in sessionStorage under qwen-daemon-token:<origin>, never in localStorage and never in any request URL. Forget drops the origin and its token, and the Folder source list falls back to one entry.
2 Normal Add workspace ✅ Folder browser opens in place, Folder source = This computer, zero radio controls, zero page loads, real hostA folders listed by the daemon.
3 Select a connected computer ✅ Tab moves to ?daemon=http://127.0.0.1:4191 with no token parameter; the same dialog stays open and now lists hostB folders only; the cross-origin GET /workspace-path-suggestions to :4191 carries Authorization: Bearer …; remote rows get the blue globe badge.
4 Navigate, Parent folder, add ✅ Suggestion descends, Parent folder walks up, POST /workspaces goes only to :4191; daemon B's /capabilities then lists the new workspace and …/daemon/workspaces/*.json contains it on disk (persist: true really persisted); the continuation marker and its sessionStorage key are gone.
5 Switch back to This computer, repeat, Cancel ✅ Both directions keep the browser open; Cancel lands on the byte-exact source URL, dialog closed, no continuation state.
6 Invalid token, cancel from the gate ✅ A wrong token keeps the operator on the gate with the flow marker intact; Cancel adding workspace restores the exact source page with no leftover state; re-entering the right token drops straight back into the remote folder browser.
7 Start on remote A, add from another computer, cancel ✅ A catalogued remote boots with no unfamiliar-target gate; cancelling restores the remote source tab exactly, with no second confirmation.
8 Ordinary daemon switch ✅ Switching through Connections moves the shell and does not open the folder browser or leave continuation state.
9 Desktop and 390 × 844 ✅ Dialog 358 px wide inside a 390 px viewport; Folder source, Parent folder, Add this folder, Cancel all fully inside the viewport; the hand-over works on mobile too.

Because the shell is served by the daemon, the production CSP is in play rather than the dev server's: GET / answers connect-src 'self', and GET /?daemon=http://127.0.0.1:4191 answers connect-src 'self' http://127.0.0.1:4191 ws://127.0.0.1:4191 — the cross-origin calls above really are made under that policy.

Extra probes beyond the plan: an unreachable connected computer degrades to the retrying gate and Cancel still restores the source page; an origin that is not in the catalog still faces the unfamiliar-target gate (the security control is intact).

Repo gates run locally at this head

packages/web-shell vitest            338 files / 8868 tests passed
web-shell.remote-workspace-add.spec  6 passed (chromium)
tsc -p tsconfig.json --noEmit        clean
eslint packages/web-shell            clean

Evidence

Settings > Connections after verifying the second computer

Connections settings

Folder source selector — one list, no separate Local/Remote step

Folder source selector

The same browser, resumed on the connected computer (real directories from daemon B)

Remote folder browser

Remote marking — host chip on the Projects header, globe badge on the folder icons

Remote badges

Authentication failure stays inside the flow

Connection gate inside the flow

390 × 844

Mobile viewport

F1 — an abandoned hand-over leaves its return location behind (minor, reproducible 100%)

The continuation is one-shot and tab-scoped, but it is only cleared on completion or cancellation. A reload — or the browser Back button — abandons the flow without clearing it, because the URL marker is stripped by App's effect while qwen-remote-workspace-return stays in sessionStorage. The next Cancel in any Add-workspace dialog then consumes that stale location.

Repro on the rig above:

  1. On :4190, Add workspace → Folder source → 127.0.0.1:4191 (tab hands over).
  2. Press F5. The dialog is gone (flow abandoned), but sessionStorage['qwen-remote-workspace-return'] === "http://127.0.0.1:4190/" survives.
  3. Open Add workspace again — a brand-new, purely local add on :4191 — and press Cancel.
  4. The whole shell navigates away from :4191 back to :4190. Measured settled state: url = http://127.0.0.1:4190/, 0 globe badges, sidebar back to workA.

Stale return location

The Back-button variant is milder but reachable with no refresh at all: after Back, cancelling an ordinary local add triggers a full page navigation (load event fired) instead of just closing the dialog.

Candidate fix — tested on the rig (head red → patched green → head red again), with remote-workspace-add.spec 6/6, client/config + App.test.tsx 1085/1085 and every happy-path probe still green:

// client/config/remote-workspace-add.ts
export function discardAbandonedRemoteWorkspaceAdd(): void {
  try {
    window.sessionStorage.removeItem(RETURN_URL_KEY);
  } catch {
    // Nothing to discard when storage is unavailable.
  }
}
// client/App.tsx
useEffect(() => {
  if (initialRemoteWorkspaceAddStep) clearRemoteWorkspaceAddStep();
  else if (standalone) discardAbandonedRemoteWorkspaceAdd();
}, [initialRemoteWorkspaceAddStep, standalone]);

Safe because the return key is only ever written by startRemoteWorkspaceAdd/selectRemoteWorkspaceLocation immediately before a navigation that carries the marker — a marker-less boot in standalone mode means the flow is over.

Notes (not blockers)

  • N1 — the catalog is written on every successful gate probe. StandaloneAuth calls rememberRemoteConnection(baseUrl) outside the connection-add branch, so confirming a one-off ?daemon= link once permanently adds that origin to qwen-remote-connections, which main.tsx now treats as a confirmed target for every future tab (the confirmation used to be sessionStorage, i.e. per tab). Measured: fresh tab, empty catalog, one ?daemon=… + token at the gate → ["http://127.0.0.1:4191"] persisted and the origin offered as a Folder source. Defensible ("the operator verified it", and it is removable in Settings), but worth a line in the PR description or scoping the write to the add flow.
  • N2 — mutation check on the new tests. 10 mutants across remote-connections.ts, remote-workspace-add.ts, daemon.ts, App.tsx, AddWorkspaceDialog.tsx, each run against client/config, the three dialog suites, App.test.tsx, main.test.tsx and the new e2e spec. 3 are killed (catalog validation, forget-clears-token, remember-on-complete). I traced each of the 7 survivors: token stripping on both return legs is dead code (the URL is already stripped when it is written), the same-origin guard on both return legs guards a state sessionStorage cannot produce, showList's browseDirectories || is redundant once the first fetch sets listOpen, and the same-target reload marker is not reachable from the selector (it short-circuits on the current source). The one survivor that guards real behaviour is hasRemoteWorkspaceLocation — nothing pins the "entry enabled because a remote exists" case, which is also N4 below. Everything else is equivalent-by-construction, so no meaningful test hole.
  • N3 — zh copy. workspaceHost.thisComputer is 这台计算机 but workspaceHost.folderOnThisComputer is 这台电脑上的目录. One word, two spellings.
  • N4 — dead end on a daemon without dynamic_workspace_registration. The unsupported/ loading status dialog has no Folder source selector, so a user whose local daemon lacks the feature (the entry is enabled because a remote exists) can only Cancel. Unreachable with today's qwen serve (dynamicWorkspaceRegistrationAvailable is hard-coded true), so it only matters for an older or third-party daemon.

Not covered

Windows; a real second machine / non-loopback network; HTTPS or a TLS-terminating proxy; a daemon that does not advertise dynamic workspace registration; behaviour of a long-running session across the full-page hand-over.

Verdict

The workflow does what the description claims, on real daemons, including the parts a mock cannot prove: real CORS + bearer auth on the cross-origin suggestion calls, the registration actually landing (and persisting) on the selected daemon, and the credential never touching the URL. F1 is the only defect I found, and the fix is contained (one helper plus one line in an existing effect). From my side this is fine to merge; I'd land F1 either in this PR or as an immediate follow-up.

中文说明

维护者验证 —— 两个真实 qwen serve daemon + 生产构建的 Web Shell

在 macOS 上对 1b5c3d647c 做了真实环境验证(不是 mock 端点):被测 shell 是本地 npm run build 出来的 packages/web-shell/dist,由 qwen serve 自己托管(走生产静态资源路径与其 CSP);“已连接的计算机”是第二个 qwen serve,带真实 bearer token、--require-auth 和 --allow-origin。用 Chromium 驱动真实 UI:70 条脚本断言,66 条通过;4 条失败全部指向同一个缺陷(下面的 F1)。

装置

组成 说明
Web Shell 本 head 构建产物,由 daemon A 在 http://127.0.0.1:4190 托管 —— 不是 vite dev server
Daemon A(这台计算机) qwen serve --port 4190 --workspace …/hostA/workA,loopback,隔离 QWEN_HOME
Daemon B(已连接的计算机) qwen serve --port 4191 --workspace …/hostB/workB --token … --require-auth --allow-origin http://127.0.0.1:4190
Daemon C 第三个 qwen serve(:4192),用于多计算机场景
文件系统 hostA/ 与 hostB/ 下目录名完全不重名,两边的目录建议不可能混淆
驱动 Playwright/Chromium 1.58,桌面 1440×900 与手机 390×844

Reviewer Test Plan —— 9 条全部复现

# 步骤 结果
1 通过设置 > 连接添加远程 daemon ✅ 经连接页验证后回到完全一致的来源 URL,分类仍是 Connections(aria-current="page");qwen-remote-connections = ["http://127.0.0.1:4191"];token 只在 sessionStorage 的 qwen-daemon-token:<origin>,不进 localStorage、不进任何请求 URL。移除会同时清掉 origin 和它的 token,目录来源列表退回单项。
2 普通添加工作区 ✅ 目录浏览器就地打开,目录来源 = 这台计算机,没有任何 radio 控件,零次页面加载,列出的是 daemon 真实返回的 hostA 目录。
3 选择一台已连接计算机 ✅ 标签页跳到 ?daemon=http://127.0.0.1:4191,URL 中没有 token 参数;同一个弹窗保持打开并只显示 hostB 的目录;跨域 GET /workspace-path-suggestions 带 Authorization: Bearer …;远程行出现蓝色地球徽标。
4 目录建议 / 上一级 / 添加 ✅ 建议可下钻、上一级可回退;POST /workspaces 只打到 :4191;随后 daemon B 的 /capabilities 列出新 workspace,磁盘上 …/daemon/workspaces/*.json 也有(persist: true 真的落盘);续接标记与 sessionStorage 键均已清理。
5 切回“这台计算机”,再走一遍并取消 ✅ 两个方向都保持目录浏览器打开;取消后回到逐字符一致的来源 URL,弹窗关闭,无残留状态。
6 无效 token 并从连接页取消 ✅ 错误 token 停留在连接页且流程标记仍在;取消添加工作区恢复精确来源页面且无残留;再输入正确 token 直接回到远程目录浏览器。
7 从远程 A 开始、在另一台计算机上添加后取消 ✅ 目录中的远程 origin 启动时不再出现陌生目标确认页;取消后精确恢复远程来源标签页,没有第二次确认。
8 普通 daemon 切换 ✅ 通过连接切换只换 daemon,不会打开目录浏览器,也不留续接状态。
9 桌面与 390 × 844 ✅ 390px 视口下弹窗宽 358px;目录来源、上一级、添加此文件夹、取消 全部完整落在视口内;手机视口下的交接同样可用。

因为 shell 由 daemon 托管,生效的是生产 CSP 而非 dev server 的:GET / 返回 connect-src 'self',GET /?daemon=http://127.0.0.1:4191 返回 connect-src 'self' http://127.0.0.1:4191 ws://127.0.0.1:4191 —— 上面那些跨域请求确实是在该策略下发出的。

计划外的补充探针:不可达的已连接计算机会退化为带重试的连接页,取消仍能恢复来源页面;不在目录中的 origin 依然会被陌生目标确认页拦下(安全控制未被削弱)。

本 head 上本地跑过的仓库门禁

packages/web-shell vitest            338 个文件 / 8868 条用例通过
web-shell.remote-workspace-add.spec  6 passed(chromium)
tsc -p tsconfig.json --noEmit        干净
eslint packages/web-shell            干净

F1 —— 被放弃的交接会留下返回地址(次要,100% 可复现)

续接确实是一次性、按标签页隔离的,但它只在「完成」或「取消」时清理。刷新页面(或按浏览器后退)会放弃流程却不清理:URL 上的标记被 App 的 effect 抹掉了,而 sessionStorage 里的 qwen-remote-workspace-return 还在。之后任何一个添加工作区弹窗的取消都会消费掉这个过期地址。

复现步骤:

  1. 在 :4190 上添加工作区 → 目录来源 → 127.0.0.1:4191(标签页交接)。
  2. 按 F5。弹窗消失(流程已放弃),但 sessionStorage['qwen-remote-workspace-return'] === "http://127.0.0.1:4190/" 仍在。
  3. 再次打开添加工作区 —— 这是 :4191 上一次全新的、纯本地的添加 —— 然后按取消。
  4. 整个 shell 会从 :4191 跳回 :4190。实测稳定后的状态:url = http://127.0.0.1:4190/,地球徽标 0 个,侧边栏回到 workA。

后退按钮那一版症状更轻但完全不需要刷新:后退之后,取消一次普通的本地添加会触发整页导航(load 事件被触发),而不是只关闭弹窗。

候选修复 —— 已在装置上实测(head 红 → 打补丁绿 → 还原后又红),同时 remote-workspace-add.spec 6/6、client/config + App.test.tsx 1085/1085、所有正向探针保持绿:

// client/config/remote-workspace-add.ts
export function discardAbandonedRemoteWorkspaceAdd(): void {
  try {
    window.sessionStorage.removeItem(RETURN_URL_KEY);
  } catch {
    // Nothing to discard when storage is unavailable.
  }
}
// client/App.tsx
useEffect(() => {
  if (initialRemoteWorkspaceAddStep) clearRemoteWorkspaceAddStep();
  else if (standalone) discardAbandonedRemoteWorkspaceAdd();
}, [initialRemoteWorkspaceAddStep, standalone]);

之所以安全:返回地址只由 startRemoteWorkspaceAdd / selectRemoteWorkspaceLocation 在带标记的导航前一刻写入,因此 standalone 下「启动时没有标记」就意味着流程已经结束。

备注(非阻塞)

  • N1 —— 每次连接页探测成功都会写目录。 StandaloneAuth 把 rememberRemoteConnection(baseUrl) 放在连接添加分支之外,于是用户对一个一次性的 ?daemon= 链接确认一次,该 origin 就会被永久写入 qwen-remote-connections;而 main.tsx 现在把目录中的 origin 视为已确认目标(以前的确认是 sessionStorage,只在当前标签页有效)。实测:全新标签页、目录为空,在连接页输入一次 ?daemon=… + token 之后,目录里出现该 origin,且它出现在目录来源列表中。这个取舍说得通(用户确实验证过,而且可在设置里移除),但建议在 PR 描述里写明,或把写入限定在添加连接流程内。
  • N2 —— 对新测试做了变异检查。 在 remote-connections.ts、remote-workspace-add.ts、daemon.ts、App.tsx、AddWorkspaceDialog.tsx 上共 10 个变异体,每个都跑 client/config、三个弹窗套件、App.test.tsx、main.test.tsx 以及新增的 e2e 规格。杀死 3 个(目录校验、移除时清 token、完成时写目录)。存活的 7 个我逐个追了可达性:两条返回路径上的 token 剥离是死代码(URL 在写入时就已剥掉 token);两处同源校验挡的是 sessionStorage 按 origin 隔离后不可能出现的状态;showList 里的 browseDirectories || 在首次请求把 listOpen 置真之后是冗余的;同目标 reload 分支的标记在选择器上走不到(选中当前来源会被短路)。唯一守着真实行为的存活变异是 hasRemoteWorkspaceLocation —— 「因为存在远程才启用入口」这一路径没有测试钉住,也就是下面的 N4。其余都是构造上的等价变异,因此没有实质测试盲区。
  • N3 —— 中文文案。 workspaceHost.thisComputer 是「这台计算机」,但 workspaceHost.folderOnThisComputer 是「这台电脑上的目录」,同一个词两种说法。
  • N4 —— 不支持动态注册的 daemon 会走进死路。 不支持 / 加载中的状态弹窗没有目录来源选择器,所以当入口是因为存在远程才被启用、而本地 daemon 不支持该能力时,用户只能取消。当前 qwen serve 不可达(dynamicWorkspaceRegistrationAvailable 硬编码为 true),只对旧版或第三方 daemon 有意义。

未覆盖

Windows;真实的第二台机器 / 非 loopback 网络;HTTPS 或 TLS 终止代理;不宣告动态工作区注册能力的 daemon;长时间运行的会话在整页交接后的表现。

结论

这条工作流在真实 daemon 上确实做到了描述中的事情,包括 mock 证明不了的部分:跨域目录建议上的真实 CORS + bearer 鉴权、注册确实落在所选 daemon 上并持久化、凭据始终不进 URL。F1 是我找到的唯一缺陷,修复范围很小(一个辅助函数 + 已有 effect 里的一行)。从我这边看可以合并;建议 F1 在本 PR 或紧随其后的跟进 PR 中修掉。

…hind

The remote-workspace-add continuation is one-shot and tab-scoped, but it was
only cleared on completion or cancellation. A reload or the browser's Back
button abandons the flow without clearing it: App's effect strips the URL
marker while the qwen-remote-workspace-return key stays in sessionStorage, so
the next Cancel in any Add-workspace dialog -- including a purely local one --
consumed that stale location and navigated the whole shell back to it.

Discarding it on a marker-less standalone boot is safe: the key is only ever
written by startRemoteWorkspaceAdd immediately before navigateToDaemon, and
every navigateToDaemon success path either assigns a URL carrying the marker
or replaceStates the marker in before reloading, while a failed switch removes
the key again. So a boot without the marker means the hand-over is over.

Also aligns the zh copy for workspaceHost.folderOnThisComputer with
workspaceHost.thisComputer.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu7rlkyy52
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Landed F1 (the abandoned hand-over leaving its return location behind) at 06966553aa, plus the N3 copy fix. cc @wenshao

F1 — what changed (one helper plus one line, as you proposed):

  • client/config/remote-workspace-add.ts: new discardAbandonedRemoteWorkspaceAdd(), which removes RETURN_URL_KEY (qwen-remote-workspace-return) and tolerates unavailable storage.
  • client/App.tsx: the existing marker-stripping effect gains else if (standalone) discardAbandonedRemoteWorkspaceAdd();, deps now [initialRemoteWorkspaceAddStep, standalone].

I re-checked your safety precondition against the tree instead of taking it on trust, and it holds. The key has exactly one writer, startRemoteWorkspaceAdd (remote-workspace-add.ts:40), and it is removed again at :52 when navigateToDaemon returns false. Both navigateToDaemon success paths carry the marker: the target-change path assigns nextUrl, which sets addRemoteWorkspace=browse at daemon.ts:335-337, and the same-target path replaceStates the marker into the current URL (daemon.ts:365-368) before window.location.reload() at :375. So a marker-less standalone boot can only mean the hand-over is over, and the discard cannot destroy a live continuation. selectRemoteWorkspaceLocation delegates to the same writer, so it adds no path of its own.

The consumption side of the bug is confirmed too: handleOpenExistingWorkspace (App.tsx:13340) sets workspaceBrowseActiveRef.current = standalone for any add in standalone mode, so closeAddWorkspaceDialog (App.tsx:13197) reaches leaveRemoteWorkspaceAdd() and its window.location.assign even for a purely local add.

Tests (jsdom):

  • App.test.tsx: discards a return location an abandoned hand-over left behind (the witness) and keeps the return location of a standalone boot that resumes the hand-over (guard against an over-broad discard).
  • config/remote-workspace-add.test.ts: drops a return location an abandoned hand-over left behind, which also pins the consequence — the stale location can no longer drive a Cancel (leaveRemoteWorkspaceAdd() returns false, location.assign not called).

Non-tautology, measured rather than assumed: reverting the App.tsx effect line reddens exactly the witness (expected 'http://localhost:3000/session/original' to be null) while the resume case stays green; making the helper a no-op reddens the config test and leaves its other 6 green.

Local run: client/config + App.test.tsx + i18n.test.ts = 1089/1089 passed (7 files). eslint clean on all five changed files, prettier --check clean.

Browser-level evidence is inherited from your rig, not re-captured here. I did not stand up the two-daemon + Playwright rig and did not re-run remote-workspace-add.spec; the matched before/after evidence for this exact patch is your measurement (head red → patched green → head red again, remote-workspace-add.spec 6/6, client/config + App.test.tsx 1085/1085).

N3: workspaceHost.folderOnThisComputer is now 这台计算机上的目录, matching workspaceHost.thisComputer = 这台计算机.

N1, N2 and N4 are deliberately untouched — you labelled them non-blockers and this diff is already large, so I kept the change to this one defect.


中文小结:F1 已按你的方案落地(一个 helper + 一行 effect),并在代码里复核了「写 key 必然紧跟一次带 marker 的跳转」这个前提,两条 navigateToDaemon 成功路径都带 marker,所以无 marker 的 standalone 启动只可能是流程已废弃。jsdom 单测 1089/1089 通过,回退修复后对应 witness 变红。浏览器级证据沿用你双 daemon 环境的实测,我没有重跑 e2e rig。N3 一并统一为「这台计算机」。N1/N2/N4 未处理。

@wenshao

wenshao commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Round 2 — re-verified on 06966553aa against the same two real daemons

Rebuilt this head in the same rig (built packages/web-shell/dist served by qwen serve itself, second qwen serve behind --require-auth --token --allow-origin) and re-ran everything, plus new probes aimed at the fix itself. 78 assertions, 76 green; the 2 reds are my F1 bug-characterisation probe, which asserted the teleport and is therefore red by design now (details below).

F1 is closed on the real rig

Same probes, same rig, only the commit changed:

Probe 1b5c3d647c 06966553aa
F1 — reload abandons the hand-over, then Cancel an unrelated add 1 pass / 2 fail 3 pass / 0 fail
F1c — Back button abandons it, then Cancel a local add 2 pass / 2 fail 4 pass / 0 fail

After the abandoning reload, sessionStorage['qwen-remote-workspace-return'] is gone, and the next Cancel now closes the dialog without navigating: the tab stays on ?daemon=http://127.0.0.1:4191, sidebar still workB + beta-remote-checkout with globe badges. The Back-button variant fires no load event at all any more.

F1 fixed

One thing to read correctly: my third probe (probeF1d) exists only to pin the defect's symptom — "after the stale-return cancel the tab is on the source daemon URL, 0 globe badges". On this head it reports 2 failures. That is the expected post-fix polarity, not a regression; the settled state it now measures is url = …?daemon=http://127.0.0.1:4191, badges = 2.

The fix cannot eat a live continuation — measured, not assumed

The risk in "discard on a marker-less standalone boot" is an App mount that happens inside a live flow. I looked for one:

  • Traced the remount paths. StandaloneAuth sets accepted exactly once (setAccepted has a single call site) and never re-gates, and both <App> mount sites render it without a changing key, so App cannot remount mid-flow within one document.
  • Exercised the latest possible mount — the gate path. Hand-over to the remote with no stored token → gate → correct token → App mounts only then, after the marker-bearing URL. The return location survived (qwen-remote-workspace-return === http://127.0.0.1:4190/) and Cancel still returned to the exact source page.
  • Exercised a mid-flow outage. Killed daemon B while the folder browser was open on it: the continuation survived, and Cancel after the outage still restored the source page.

7/7 green on those.

No regressions — the whole Reviewer Test Plan re-run

All nine steps re-measured on this head: 61/61 green (Connections add/verify/return/forget, direct folder browser, hand-over with token out of the URL, cross-origin suggestions with the bearer header, POST /workspaces only on the selected daemon plus its on-disk persistence, Cancel to the byte-exact source URL, the gate paths, ordinary daemon switches, unreachable target, desktop + 390×844).

Repo gates on this head (local)

packages/web-shell vitest            338 files / 8871 tests passed   (8868 → 8871: the 3 new tests)
web-shell.remote-workspace-add.spec  6 passed (chromium)
tsc -p tsconfig.json --noEmit        clean
eslint packages/web-shell            clean

Your test claims, re-measured here

Mutation Result
Revert the else if (standalone) … line Killed — discards a return location an abandoned hand-over left behind, expected 'http://localhost:3000/session/original' to be null (exactly the assertion you quoted)
Helper made a no-op Killed — the config test plus the App witness, 2 red
Discard unconditionally (also when the hand-over is being resumed) Killed — keeps the return location of a standalone boot that resumes the hand-over, so that guard has real teeth
Drop the standalone condition (else discard…) Survives — nothing pins that the discard is standalone-only. Harmless today, since only the standalone shell ever writes the key; noting it as an unpinned condition rather than a defect.

N3

Verified in the shipped bundle, not just the diff: 这台计算机上的目录 appears once in dist/assets/index-*.js and 这台电脑上的目录 is gone. zh UI shows 目录来源 = 这台计算机.

zh folder browser

Verdict

F1 is genuinely closed at browser level on real daemons, the guard against an over-broad discard is real, and nothing in the plan regressed. N1/N2/N4 stay as agreed non-blockers. LGTM to merge from my side.

中文说明

第 2 轮 —— 在同样的两个真实 daemon 上复验 06966553aa

在原装置里重建了这个 head(qwen serve 自己托管构建出的 packages/web-shell/dist,第二个 qwen serve 带 --require-auth --token --allow-origin),把上一轮的全部探针重跑,并针对修复本身补了新探针。78 条断言,76 条绿;2 条红的是我用来“刻画缺陷症状”的探针,现在按设计就应该红(见下)。

F1 在真机上确实关闭了

同样的探针、同样的装置,只换了 commit:

探针 1b5c3d647c 06966553aa
F1 —— 刷新放弃交接后,取消一次无关的添加 1 通过 / 2 失败 3 通过 / 0 失败
F1c —— 用后退放弃交接后,取消一次本地添加 2 通过 / 2 失败 4 通过 / 0 失败

放弃流程的刷新之后 sessionStorage['qwen-remote-workspace-return'] 已不存在,下一次取消只关闭弹窗、不再导航:标签页仍停在 ?daemon=http://127.0.0.1:4191,侧边栏仍是 workB + beta-remote-checkout 且带地球徽标。后退那一版现在完全不再触发 load 事件。

需要正确解读的一点:第三个探针(probeF1d)的存在意义就是钉住缺陷的症状 —— “过期返回地址被消费后标签页停在来源 daemon、地球徽标为 0”。它在这个 head 上报 2 条失败,这是修复后应有的极性反转,不是回归;它现在实测到的稳定状态是 url = …?daemon=http://127.0.0.1:4191、badges = 2。

修复不会吃掉“进行中”的续接 —— 是实测,不是假设

“无 marker 的 standalone 启动就丢弃”这条规则的风险在于:有没有可能在流程仍然存活时发生一次 App 挂载。我逐条找了:

  • 追了重新挂载路径:StandaloneAuth 的 accepted 只被设置一次(setAccepted 只有一个调用点)且不会退回网关;两处 <App> 挂载点都没有会变化的 key,所以同一个文档内 App 不会在流程中途重挂。
  • 走了最晚的一次挂载 —— 网关路径:在没有已存 token 的情况下交接到远端 → 网关 → 输入正确 token → App 这时才挂载(URL 上仍带 marker)。返回地址存活(qwen-remote-workspace-return === http://127.0.0.1:4190/),取消仍然精确回到来源页面。
  • 制造了流程中途的 daemon 掉线:目录浏览器正开在 B 上时把 daemon B 杀掉,续接状态存活,掉线之后取消依旧恢复来源页面。

这三项 7/7 全绿。

没有回归 —— 整份 Reviewer Test Plan 重跑

九条全部在这个 head 上重测:61/61 绿(连接的添加/验证/返回/移除、直接打开目录浏览器、token 不进 URL 的交接、带 bearer 头的跨域目录建议、POST /workspaces 只打所选 daemon 及其落盘持久化、取消回到逐字符一致的来源 URL、两条网关路径、普通 daemon 切换、不可达目标、桌面与 390×844)。

本 head 的仓库门禁(本地)

packages/web-shell vitest            338 个文件 / 8871 条用例通过(8868 → 8871,即新增的 3 条)
web-shell.remote-workspace-add.spec  6 passed(chromium)
tsc -p tsconfig.json --noEmit        干净
eslint packages/web-shell            干净

你对测试的判断,我这边复测了一遍

变异 结果
回退 else if (standalone) … 那一行 被杀 —— discards a return location an abandoned hand-over left behind,报错正是你引用的 expected 'http://localhost:3000/session/original' to be null
把 helper 改成空操作 被杀 —— config 用例 + App witness,共 2 条红
无条件丢弃(连正在续接时也丢) 被杀 —— keeps the return location of a standalone boot that resumes the hand-over,说明这条防“过度丢弃”的用例是有辨别力的
去掉 standalone 条件(写成 else discard…) 存活 —— 没有测试钉住“只在 standalone 下丢弃”。今天无害(只有独立 shell 会写这个 key),记为未钉住的条件,不算缺陷。

N3

不只看 diff,还在打包产物里核实:dist/assets/index-*.js 中 这台计算机上的目录 出现 1 次,这台电脑上的目录 为 0 次;中文 UI 里目录来源 = 这台计算机。

结论

F1 在真实 daemon 的浏览器层面确实关闭,防“过度丢弃”的守卫用例有效,计划内各步骤无回归。N1/N2/N4 按之前约定保持为非阻塞项。从我这边看:可以合并。

@qwen-code-dev-bot qwen-code-dev-bot 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.

APPROVE at 0696655.

No Critical has ever been filed on this PR, and the one dismissed approval was voided by pushes rather than by a finding. Required CI is complete and green on this head; review-pr is the reviewer bot's own workflow and is excluded deliberately, since the main ruleset declares no required status checks.

This adds a flow that sends a bearer token to a user-supplied origin and stores connection state in the browser, so I checked the three properties that decide whether it is safe, on this head rather than on the earlier one the static review traced:

  • The token never reaches persistent storage or a URL. daemon.ts writes it to sessionStorage only, with the tab-scoping rationale stated at the call site, and remote-connections.ts persists string[] origins to localStorage — no token, no workspace state. Both startRemoteWorkspaceAdd and the return leg call searchParams.delete('token'), so neither the stored return URL nor the URL actually navigated can carry one.
  • Every stored origin is re-validated on read, not on write only. readRemoteConnections() filters on typeof origin === 'string' && getAllowedDaemonOrigin(origin) === origin && origin !== window.location.origin, de-duplicates, and returns an empty list on any parse failure — so a hand-edited or stale localStorage entry cannot smuggle an arbitrary origin into the panel, and the shell's own origin is never listed as a remote computer. rememberRemoteConnection normalizes and refuses self-origin before writing, too.
  • The return path is not an open redirect. returnFromRemoteConnectionAdd() reads and removes the one-shot key first, then rejects unless url.origin === window.location.origin, strips the flow parameter, the token and the hash, re-validates the daemon parameter through getAllowedDaemonOrigin, requires confirmDaemonTarget on the result, and only then assigns. A cross-origin or malformed stored URL is discarded rather than followed.

The F1 fix is the part I would most want measured rather than read, and it was: the maintainer rig re-ran the whole test plan on this head (61/61), traced the remount paths to show StandaloneAuth sets accepted once and neither App mount site uses a changing key, then exercised the latest possible mount through the token gate and a mid-flow daemon kill to confirm discarding on a marker-less boot cannot eat a live continuation (7/7). Local gates on the head are 8871 unit tests, the remote-workspace-add e2e spec, typecheck and lint.

No new Critical. Two notes, neither blocking:

  • The remoteWorkspaceAddActiveRef pattern requires every future ref mutation to be paired with a state setter that forces a re-render. It is correct at all six mutation sites today, but the invariant lives in a convention rather than in a type, so it is the likeliest place for a later edit to go wrong.
  • Windows was not exercised by the author or by the maintainer rig. Nothing here is platform-conditional — it is URL, storage and fetch behaviour in a browser — which is why it is a note.

已核对 head 0696655:无历史 Critical,required CI 全绿,token 存放、来源复验与返回地址守卫三条安全属性我在当前 head 上逐项验证,未发现新的 Critical。

@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 at 06966553, base 2bd62fbd.

Verdict: APPROVE — no merge-blocking correctness, security, data-loss, regression, or compatibility defect found.

Historical blocking issues

None exist. The PR has never carried a request for changes and has no review threads. Its single prior review records no blocking findings and was dismissed when a later commit landed; I did not treat that pass as evidence and re-verified the surfaces it covered myself.

What I reviewed

This is a large feature diff, so the scan concentrated on every part of it that can carry a trust, credential, or state-machine defect, and I read those in full rather than sampling.

The two new modules are fail-closed throughout. remote-workspace-add.ts builds its return URL by deleting the flow parameter, deleting token, and clearing the hash before writing to sessionStorage, so no credential is persisted; a storage failure returns false rather than throwing; and if the subsequent navigation does not start, it restores the URL and removes the stored key, so a failed hand-over leaves no residue. On the return leg it parses the saved URL, refuses it outright when url.origin !== window.location.origin, strips token, the hash and the flow parameter again, re-validates any daemon parameter through getAllowedDaemonOrigin, and only then confirms the target and assigns. remote-connections.ts applies the same discipline to its catalog: the stored list is parsed defensively, rejected unless it is an array, and every entry must satisfy getAllowedDaemonOrigin(origin) === origin and differ from the current origin before it survives, with de-duplication and a [] fallback on any throw. A tampered or corrupt storage value therefore cannot introduce a non-allowlisted origin. Forgetting a connection also clears that origin's stored token rather than orphaning the credential.

The daemon primitives keep their gate. navigateToDaemon still resolves getAllowedDaemonOrigin(raw) and returns false when it is absent or when the connection URL cannot be built; the new flow parameters are set only after that validation, and the token is persisted only once the origin is known. buildDaemonConnectionUrl now strips addRemoteWorkspace alongside the existing context, addWorkspace, workspaceReturn and token deletions, so a flow parameter cannot ride along into an unrelated connection.

The app-level wiring consumes each deep link exactly once. Both flow parameters are read through lazy useState initialisers gated on the standalone context, so they are sampled at mount rather than re-read per render, and an effect clears each after consumption. The cleanup effect that closes dialogs when project features are unavailable now skips the add-workspace dialog while a ref records the browse flow as active, which is what keeps a deep-linked flow from being dismissed immediately after it opens; the same effect exempts the deep-linked settings category. When no flow parameter is present the effect discards an abandoned hand-over's stored return location, which is the residue the most recent commit addresses.

The auth gate is not weakened. StandaloneAuth.tsx adds cancel affordances for the two flows in both languages and threads the same options object through onChangeTarget; the invalid-target and unconfirmed-target props, the token hint copy and the gate's own conditions are untouched.

The submitting dialog validates and cannot double-fire. The path is trimmed, an explicit error is raised for a non-absolute path, the submit control is disabled while submitting or while the trimmed path is empty, and a location change may be refused by the parent through a false return.

The connections panel neither renders nor retains a credential. Saved origins are keyed and titled by origin but displayed through formatOriginHost, which reduces to a host with a caught fallback; there is no dangerouslySetInnerHTML, no interpolated href, and no direct storage access in the component. Switching passes the token to a handler rather than to the DOM, forgetting routes through the module function that also clears the credential, and a newly added connection is remembered only after the switch succeeds, with the token input cleared afterwards.

What I did not review

Stated so it is not over-read: the remaining changed files carry no state or trust semantics and were not read line by line — the settings message rendering, the sidebar and workspace-section markup, two CSS modules, the added i18n strings, the small main.tsx and settings.ts deltas, and the two design documents. They are covered indirectly by the green lanes below.

CI

Fully settled on this head with no failures across its checks. The ubuntu test lane, the lint and static checks, the no-AK integration lane and both Desktop Shell lanes pass, which matters here because those are the lanes that build and exercise this client. The added coverage is substantial — on the order of fourteen hundred lines of unit and component tests plus an end-to-end spec for this specific flow — and is observed passing rather than assumed.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 009aab0 Sep 19, 2026
52 of 53 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.

5 participants