mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(serve): enable remote access from settings without restarting (#15887)
* feat(serve): enable remote access from settings without restarting Add a locally managed, independently authenticated listener with live revocation and session continuity. Explain connection setup and reflect daemon availability in the sidebar. Co-authored-by: Qwen-Coder <[email protected]> * test(web-shell): expose isDaemonMixedContent in the boot-test daemon mock Co-authored-by: Qwen-Coder <[email protected]> * fix(serve): scope the Local Control pairing secret to its own listeners `GET /workspace/local-control` returned the unredacted pairing URL and QR to any bearer-authenticated request. The secondary listeners serve this same Express app and each verifies only its own token, so a remote-access client reached the Local Control pairing credential — a secret belonging to a different listener, which disabling remote access never revokes. Gate the unredacted branch on the listener that owns the credential (primary or local-control) as well as on operator authority, and pin both arms: a remote-access bearer gets `urlRedacted: true`, the owning listener still gets the URL. `POST /workspace/local-control/disable` stays open to remote-access callers. That is now a stated decision rather than an inherited default: the bearer already reaches every other route on this app, so denying it here would be security theatre while breaking remote management, and the response is redacted by the gate above. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmvz8eo3a8v * fix(cli): tear down a permission-blocked prompt at disconnect grace expiry The preserve-running-prompts branch returned early for any live promptAbort, including a prompt blocked on a client permission vote registered while the stream was live. Once the remote entrance is revoked the vote can never arrive (permissionResponseTimeoutMs defaults to 0), so the turn never settled, promptSettled never ran, hasRunningDetachedPrompt stayed true, and sweep plus both connection reapers skipped the connection forever — pinning an ownedSessions entry and one of the maxConnections slots until restart. Gate the preserve branch on hasPendingClientRequest: a session with an outstanding permission vote falls through to the existing teardown, which abandons the pending request and detaches the bridge client. A prompt that is not waiting on the client is still preserved unchanged. Witness: the new regression test fails without the condition (abandon callback never fires: [] vs ['bridge-req-1']) and passes with it; the existing 31-minute preserve test stays green (65/65). --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
31 files changed
+2100
-57
No files matched your search
@@ -0,0 +1,94 @@
|
||||
# Enable remote access from Settings without restarting
|
||||
|
||||
[English](2026-10-30-live-remote-access.md) | [简体中文](2026-10-30-live-remote-access.zh-CN.md)
|
||||
|
||||
## Status and decision
|
||||
|
||||
Implemented design, 2026-10-30. The behavior is **remote access off by default, enabled from the UI while the daemon keeps running**. No additional terminal command, restart, second Qwen instance, or mandatory startup flag is part of the normal flow.
|
||||
|
||||
Older running versions and compatibility gateways are explicitly out of scope. Local runtime acceptance used isolated daemons, a browser, and controlled model providers. No existing remote service was modified.
|
||||
|
||||
## User experience
|
||||
|
||||
Start an ordinary daemon normally. Its primary listener remains local by default. At any later time, open that daemon's Settings → Connections → Allow remote connections and turn the switch on. After successful activation, the card shows the actual remote address and a masked bearer token with reveal/copy/reset actions. Another machine's Add remote connection form requires only that address and token.
|
||||
|
||||
Keep these two cards distinct:
|
||||
|
||||
- **Allow remote connections** controls access to the daemon serving this settings page. Show the daemon host explicitly. This card is available only to an authorized local operator.
|
||||
- **Add remote connection** connects this client to another daemon. Adding a saved connection cannot change the target server's exposure policy.
|
||||
|
||||
While off, show “Remote access is off” and no usable credential. While enabling/disabling/resetting, show pending state and prevent duplicate operations. Report enabled only after listener and authentication are ready. On failure, retain the actual server state and show the reason. Read the state from the daemon on mount/reconnect; browser storage is not the source of truth.
|
||||
|
||||
Turn the switch off to revoke the remote credential and disconnect remote clients. Keep the primary local listener, workspace, sessions, and daemon-owned tasks running. Re-enabling issues a fresh token. Resetting a token also disconnects clients using the old token, with an inline explanation before the action. The token is masked until revealed and never included in ordinary URLs or diagnostics.
|
||||
|
||||
The toggle is a runtime setting: after a daemon restart, the default is off. Do not silently persist exposure. This implementation does not carry the earlier `--remote` startup-flag patch; UI activation does not require it. Preserve explicit legacy `--hostname` deployments. A pre-existing externally bound primary listener must not be represented as “remote access off” or falsely controlled by this toggle: show that it is externally configured and that this toggle manages a separate entrance.
|
||||
|
||||
## Architecture and ownership
|
||||
|
||||
Reuse the existing LocalControlService approach: create a new HTTP(S) listener in the same process over the existing application and attach its existing upgrade handlers. Local and remote entrances dispatch into the same workspace registry and session owners. Do not launch another agent, clone a workspace, or migrate sessions.
|
||||
|
||||
Separate remote-access policy from Local Control's private-LAN selection, pairing, and sleep policy. Reuse only suitable lifecycle/authentication primitives. Identify each entrance by listener identity, with its own Host authorities, origin matcher, token generation, and owned sockets. Do not add remote permissions to unrelated listeners or replace the primary operator token.
|
||||
|
||||
Remote-access state, enable/disable/reset operations, and address enumeration are **process-global**, even when opened from a workspace settings page. Workspace data remains selected-runtime scoped; sessions remain live-session-owner scoped. Unknown, removed, untrusted, or ambiguous runtimes must not fall back to the primary runtime.
|
||||
|
||||
Use local management HTTP routes behind the existing operator authority, Host, and request-origin guards. Only an authorized primary local context can enable access, reveal/reset the token, or change configuration. Remote credentials do not authorize these management routes. Do not treat CORS, a caller-supplied Host/forwarded header, or browser-local storage as authorization. Preserve a no-extra-terminal-command experience by integrating with existing local operator credential handoff where required; do not expose secrets to an unauthenticated browser merely to simplify the UI.
|
||||
|
||||
This is not a sandbox against a user already allowed to execute shell commands as the daemon OS user. Embedded/managed deployments retain their existing operator policy; unsupported management contexts show an explicit reason instead of granting additional privileges.
|
||||
|
||||
## Addresses and ports
|
||||
|
||||
Prefer the primary numeric port on one explicitly selected non-loopback interface. With a primary at `127.0.0.1:4173`, try the selected interface at port 4173 without changing the original socket. Actual bind success is authoritative; wildcard listeners and platform behavior can cause conflicts.
|
||||
|
||||
If there is one eligible interface, activation can select it automatically. With multiple candidates, the UI asks which interface to expose. Allow explicit selection of assigned corporate/VPN IPv4 addresses, including `11.*`; do not misclassify them as private or apply Local Control's private-address-only filter. Never silently expose all interfaces.
|
||||
|
||||
On collision or permission failure, keep the toggle off and show the attempted endpoint and reason. Offer an explicit alternate port in advanced settings. Do not silently allocate 4174/4175, kill another service, or rebind the primary. A changed entrance port still routes to the original sessions. Advertise the actual bound endpoint, not `0.0.0.0`. IPv6 requires complete client CSP, URL validation, Host, and upgrade support before it can be offered.
|
||||
|
||||
Listener activation does not prove reachability through a firewall or NAT. Do not alter firewall, router, DNS, or proxy configuration automatically. Use existing TLS configuration when applicable and explain HTTPS-page/HTTP-target restrictions. The UI must distinguish “enabled/listening” from “client verified”.
|
||||
|
||||
## Authentication and browser behavior
|
||||
|
||||
Create a separate high-entropy remote token and install its policy before accepting traffic. All protected HTTP, SSE, and WebSocket requests require that token even when the primary local service permits tokenless access. Use the supported browser WebSocket credential mechanism; do not put tokens in query strings. Ordinary status responses redact tokens; authorized reveal/activation/reset responses are non-cacheable.
|
||||
|
||||
Default browser origins cover `http://localhost:*`, `http://127.0.0.1:*`, and the remote entrance's canonical origin. Advanced settings can add exact custom origins at runtime. Do not allow arbitrary origins or `null`. Apply the same decision to HTTP and all upgrade handlers. Preflight for an allowed origin does not require a bearer, but actual protected operations do. Include appropriate CORS headers on auth errors so clients can read 401/403.
|
||||
|
||||
Keep origin-scoped client credential storage and browser-local file isolation. Save a candidate token only after successful authentication; failed attempts do not erase a working saved connection. Keep accurate 401, readable 403, mixed-content, and inconclusive network/CORS/certificate diagnostics. Connection setup copy points to the server's Settings toggle, not a mandatory command.
|
||||
|
||||
The focused host's sidebar badge follows the existing daemon availability state: connected, offline, or unauthorized. Selecting a host alone must not claim that it remains connected after remote access is revoked.
|
||||
|
||||
## Lifecycle and continuity
|
||||
|
||||
Serialize `disabled -> enabling -> enabled -> disabling -> disabled` transitions. Repeated enable must be idempotent and must not rotate the token. Read actual state after a lost response. Opening a listener must be transactional: prepare credentials and handlers first, commit enabled state only once listening, and roll back only the newly allocated resources on failure.
|
||||
|
||||
Disable closes admission and revokes the token before tearing down the entrance. Track and close entrance-owned keep-alive, SSE, and upgraded WebSocket connections with bounded cleanup. Do not use daemon-wide shutdown or dispose shared session/ACP resources. HTTP `closeAllConnections()` alone does not close upgraded WebSockets.
|
||||
|
||||
Token reset invalidates the old generation and its active remote connections while preserving tasks. Re-enable after disable starts a fresh generation. Daemon shutdown disposes its remote entrance; restart defaults to off without restoring the UI runtime grant. UI reload alone does not disable access.
|
||||
|
||||
Audit disconnect cancellation semantics. Detaching a remote transport must not implicitly cancel daemon-owned jobs or terminate unrelated local clients. Reconnect targets the same session ID and uses existing event cursors where supported. Never replay prompts/tool mutations or create a substitute session automatically. Client-owned transient media streams may end; do not promise replay of unbuffered data.
|
||||
|
||||
## Implementation and acceptance
|
||||
|
||||
1. Add daemon-owned remote-access lifecycle and listener-specific policy using existing primitives; retain Local Control behavior.
|
||||
2. Add process-global, locally authorized status/enable/disable/reveal/reset and origin configuration operations, including safe token presentation.
|
||||
3. Build the Settings toggle and address/token card, pending/error states, optional interface/port/origin controls, and clear separation from Add remote connection.
|
||||
4. Leave out the earlier `--remote` patch. Preserve ordinary loopback defaults and internal/embedded launch contracts across both CLI parsers.
|
||||
5. Complete one consolidated isolated verification pass when authorized; never use the active remote 4173 service as a fixture.
|
||||
|
||||
Acceptance requires:
|
||||
|
||||
- Ordinary startup has no new remote listener; the settings toggle is off.
|
||||
- Start a task, enable from the UI, and connect from another machine using only address/token. No terminal command, restart, or second runtime is involved; original PID, workspace, session ID, and task progress remain continuous.
|
||||
- HTTP, SSE, and every advertised WebSocket feature work from a local page on an arbitrary port. Direct remote Web Shell use follows the same auth/origin policy.
|
||||
- Disable/reset revokes established remote connections and old credentials while local clients and daemon-owned tasks continue. Re-enable and reconnect return to the original sessions without mutation replay.
|
||||
- Failed binding, duplicate clicks, concurrent tabs, lost management responses, daemon drain, and browser refresh display the actual committed state without leaks or false success.
|
||||
- Bad tokens, unrelated origins, forged Host/forwarded headers, and remote management attempts fail across HTTP and upgrades; token values do not appear in normal logs, URLs, or status responses.
|
||||
- Multi-interface selection and port conflicts are explicit. Existing externally configured primary listeners are never falsely shown as disabled by this switch.
|
||||
- Local Control, explicit binds, managed/Desktop/IDE deployments, and programmatic users keep their declared behavior.
|
||||
|
||||
Local acceptance covered the Settings flow, cross-origin address/token connection, four HTTP/SSE and WebSocket task-continuity scenarios across disable/reset, token/Host/origin enforcement, port collisions, restart defaults, and explicit external-primary compatibility. Two independently configured daemons also verified that messages submitted from one daemon's page are processed and persisted only by the selected remote daemon, with unchanged process IDs and the same session after reconnecting. Targeted tests, affected-package typechecks, and the Web Shell application build passed. These checks used the local machine’s assigned interface and controlled providers; they do not establish reachability from another machine through its firewall or NAT. No remote deployment was performed.
|
||||
|
||||
## References
|
||||
|
||||
- [Existing Local Control design](local-control-cli.md).
|
||||
- [Existing remote Web Shell design](remote-web-shell-daemon.md).
|
||||
- [Node.js HTTP shutdown](https://nodejs.org/api/http.html#servercloseallconnections): upgraded sockets need explicit teardown.
|
||||
- [MDN CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) and [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content).
|
||||
@@ -0,0 +1,94 @@
|
||||
# 在设置中免重启开启远程访问
|
||||
|
||||
[English](2026-10-30-live-remote-access.md) | [简体中文](2026-10-30-live-remote-access.zh-CN.md)
|
||||
|
||||
## 状态与决策
|
||||
|
||||
已实现设计,2026-10-30。行为是:**远程访问默认关闭,在 UI 中开启,daemon 持续运行。** 正常流程不需要额外终端命令、重启、第二个 Qwen 实例或必填启动参数。
|
||||
|
||||
旧版运行实例与兼容网关明确不在范围内。本地运行验收使用隔离的 daemon、浏览器及受控模型服务,没有修改已有远端服务。
|
||||
|
||||
## 用户体验
|
||||
|
||||
正常启动普通 daemon,主入口默认保持本地监听。之后随时在该 daemon 的“设置 → 连接 → 允许远程连接”打开开关。开启成功后,卡片显示实际远程地址,以及默认遮罩、支持查看/复制/重置的 bearer token。另一台机器的“添加远程连接”只需填写这个地址和 token。
|
||||
|
||||
明确区分两个卡片:
|
||||
|
||||
- **允许远程连接**:控制提供当前设置页面的 daemon 是否接受远程访问。明确显示 daemon 所在主机,仅授权的本地操作者可操作。
|
||||
- **添加远程连接**:让当前客户端连接其他 daemon。添加已保存连接不能改变目标服务器的开放策略。
|
||||
|
||||
关闭时显示“远程访问已关闭”,不显示可用凭据。开启/关闭/重置过程中显示处理中,避免重复操作。只有监听与鉴权准备完成才显示开启成功。失败时保留实际服务状态并展示原因。页面挂载和重连时读取 daemon 状态,浏览器存储不是状态来源。
|
||||
|
||||
关闭开关会撤销远程凭据并断开远程客户端,主入口、本地连接、工作区、会话和 daemon 拥有的任务继续运行。重新开启生成新 token。重置 token 同样断开使用旧 token 的客户端,操作前在页面中明确说明。token 默认遮罩,仅查看后显示,不放入普通 URL 或诊断。
|
||||
|
||||
这是运行时设置:daemon 重启后默认关闭,不隐式持久化对外开放。本次不携带此前的 `--remote` 启动参数补丁,UI 开启不需要它。保留显式旧有 `--hostname` 部署。已有主入口本身对外监听时,不能显示“远程访问已关闭”或假装本开关能控制它;应显示“由启动配置对外开放”,说明本开关管理的是独立入口。
|
||||
|
||||
## 架构与归属
|
||||
|
||||
复用现有 LocalControlService 的方式:在同一个进程内,为原应用创建新的 HTTP(S) 监听器并挂载已有升级处理器。本地和远程入口分发到同一个工作区注册表和会话所有者。不启动另一个 agent、不复制工作区、不迁移会话。
|
||||
|
||||
远程访问策略与 Local Control 的私有局域网选择、配对、防休眠策略分开,只复用合适的生命周期/鉴权基础能力。通过监听器身份区分入口,每个入口拥有自己的 Host、来源匹配器、token 代次及连接。不向无关监听器增加远程权限,不替换主入口操作者 token。
|
||||
|
||||
远程访问状态、开启/关闭/重置及地址枚举属于**进程全局**,即使从某个工作区设置页面打开也一样。工作区数据仍限定在选定 runtime,会话仍属于原实时会话。未知、已移除、不可信或歧义 runtime 不得退回主 runtime。
|
||||
|
||||
本地管理 HTTP 路由沿用已有操作者权限、Host 和请求来源检查。只有经过授权的本地主入口上下文才能开启、查看/重置 token 或改变配置。远程凭据不授权这些管理路由。不能将 CORS、调用者提供的 Host/转发头或浏览器存储当作授权。需要时与已有本地操作者凭据传递整合,保证用户不必额外执行终端命令;不能为了简化 UI 将秘密开放给未鉴权浏览器。
|
||||
|
||||
这不是针对已获准以 daemon 系统用户执行 shell 命令者的沙箱。嵌入式/managed 部署保留自身操作者策略,不支持管理的上下文应明确解释原因,不能额外授予权限。
|
||||
|
||||
## 地址与端口
|
||||
|
||||
优先在一个明确选择的非回环网卡上沿用主入口的数字端口。主入口为 `127.0.0.1:4173` 时,尝试在所选网卡上绑定 4173,不改变原套接字。以实际 bind 成功为准,通配监听和平台行为可能造成冲突。
|
||||
|
||||
只有一个合适网卡时可以自动选择;有多个候选时由 UI 询问开放哪个网卡。允许显式选择本机已有的企业/VPN IPv4 地址,包括 `11.*`,不误称其为私有地址,也不采用 Local Control 的私有网段限制。不能悄悄开放所有网卡。
|
||||
|
||||
绑定冲突或权限不足时,开关保持关闭,显示尝试的地址和原因。高级设置允许显式选择其他端口。不偷偷分配 4174/4175,不杀其他服务,不重绑主入口。入口端口变化仍连接原会话。公布实际绑定地址,不显示 `0.0.0.0` 让用户连接。IPv6 必须在客户端 CSP、URL 校验、Host 和升级路径完整支持后才提供。
|
||||
|
||||
开启监听不代表防火墙/NAT 后的可达性,不自动改防火墙、路由器、DNS 或代理。适用时沿用 TLS 配置,并解释 HTTPS 页面与 HTTP 目标之间的限制。UI 区分“已开启/监听中”和“客户端已验证”。
|
||||
|
||||
## 鉴权与浏览器行为
|
||||
|
||||
接受流量前生成独立高熵远程 token 并安装策略。所有受保护 HTTP、SSE 和 WebSocket 请求都需要该 token,即使本地主入口允许无 token。使用已有浏览器 WebSocket 凭据机制,不将 token 放进查询参数。普通状态响应隐藏 token;授权的查看/开启/重置响应禁止缓存。
|
||||
|
||||
默认允许 `http://localhost:*`、`http://127.0.0.1:*` 和远程入口自身的规范化来源。高级设置可运行中添加精确自定义来源。不允许任意来源或 `null`。HTTP 与全部升级处理器使用相同判定。合法来源的预检不需 bearer,但实际受保护操作需要。鉴权错误返回适当 CORS 头,使客户端能读取 401/403。
|
||||
|
||||
保留按 origin 隔离的客户端凭据和浏览器本地文件隔离。候选 token 鉴权成功后才保存,失败不清除有效旧连接。保留准确的 401、可读 403、混合内容及无法确定的网络/CORS/证书诊断。连接配置指引指向服务器设置开关,不再要求执行命令。
|
||||
|
||||
当前主机的侧栏徽标跟随已有 daemon 可用状态,显示已连接、离线或未授权。选中某台主机不能代表撤销远程访问后仍保持连接。
|
||||
|
||||
## 生命周期与连续性
|
||||
|
||||
串行执行 `disabled -> enabling -> enabled -> disabling -> disabled` 状态转换。重复开启必须幂等,不轮换 token。管理响应丢失后重新读取实际状态。开启监听采用事务式处理:先准备凭据和处理器,真正监听后才提交开启状态,失败只回滚本次新资源。
|
||||
|
||||
关闭时先停止准入和撤销 token,再清理入口。追踪并在有界时间内关闭该入口的 keep-alive、SSE 和已升级 WebSocket。不得使用 daemon 全局关闭流程或销毁共享会话/ACP 资源。HTTP `closeAllConnections()` 本身不会关闭升级后的 WebSocket。
|
||||
|
||||
重置 token 撤销旧代次及其活动远程连接,但保留任务。关闭后重新开启创建新代次。daemon 退出时销毁它的远程入口;重启默认关闭,不自动恢复 UI 的运行时授权。仅刷新 UI 不会关闭远程访问。
|
||||
|
||||
检查断线触发取消的语义。分离远程传输不能隐式取消 daemon 任务,也不能终止无关本地客户端。重连使用原会话 ID,在支持时使用已有事件游标。不自动重放 prompt/工具修改,也不创建替代会话。客户端拥有的临时媒体流可以结束,不承诺未缓冲数据回放。
|
||||
|
||||
## 实施与验收
|
||||
|
||||
1. 基于已有能力增加 daemon 拥有的远程访问生命周期和按监听器隔离的策略,保留 Local Control 行为。
|
||||
2. 增加进程全局、限本地授权操作者的状态/开启/关闭/查看/重置和来源配置操作,安全展示 token。
|
||||
3. 完成设置开关、地址/token 卡片、处理中/错误状态、可选网卡/端口/来源控制,与“添加远程连接”明确区分。
|
||||
4. 不携带旧的 `--remote` 补丁;两条 CLI 解析路径保持普通回环默认值以及内部/嵌入式启动契约。
|
||||
5. 获得授权后做一次集中隔离验证,不使用正在运行的远端 4173 当测试夹具。
|
||||
|
||||
验收要求:
|
||||
|
||||
- 普通启动没有新增远程监听器,设置开关默认关闭。
|
||||
- 先启动任务,再通过 UI 开启,从另一台机器只填地址/token 连接。无需终端命令、重启或第二个 runtime;原 PID、工作区、会话 ID 和任务进度连续。
|
||||
- 任意端口本地页面的 HTTP、SSE 和每项公布的 WebSocket 功能可用;直接打开远端 Web Shell 遵循同一鉴权/来源策略。
|
||||
- 关闭/重置撤销已有远程连接和旧凭据,本地客户端与 daemon 任务继续运行。重新开启和重连回到原会话,不重放修改。
|
||||
- bind 失败、重复点击、多页面并发、管理响应丢失、daemon drain 和浏览器刷新都显示实际提交状态,不泄漏资源或误报成功。
|
||||
- 错误 token、无关来源、伪造 Host/转发头和远程管理请求在 HTTP/升级上都被拒绝;普通日志、URL 和状态响应不出现 token。
|
||||
- 多网卡选择和端口冲突明确展示。已有外部配置的主监听器不能被本开关错误显示为关闭。
|
||||
- Local Control、显式绑定、managed/Desktop/IDE 部署和程序调用保留各自声明的行为。
|
||||
|
||||
本地验收覆盖设置页面、跨源地址/token 连接、HTTP/SSE 与 WebSocket 在关闭入口和重置 token 时的四种任务连续性场景、token/Host/来源校验、端口冲突、重启默认关闭,以及显式外部主入口的兼容性。另用两个独立配置的 daemon 验证:从一个 daemon 的页面提交的消息只由选中的远端 daemon 处理和持久化,进程 PID 不变,重连恢复同一会话。针对性测试、受影响包的类型检查和 Web Shell 页面构建已通过。验收使用本机已分配网卡地址和受控模型服务,不代表已验证其他机器经防火墙或 NAT 的可达性。未部署远端。
|
||||
|
||||
## 参考
|
||||
|
||||
- [现有 Local Control 设计](local-control-cli.md)。
|
||||
- [现有远程 Web Shell 设计](remote-web-shell-daemon.zh-CN.md)。
|
||||
- [Node.js HTTP 关闭](https://nodejs.org/api/http.html#servercloseallconnections):升级后的套接字需要单独关闭。
|
||||
- [MDN CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) 和[混合内容](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content)。
|
||||
@@ -35,6 +35,32 @@ For the deeper "what we won't fix in Stage 1" enumeration (single-host session-s
|
||||
|
||||
### Remote Web Shell and phone access
|
||||
|
||||
To connect to a running local daemon from another machine, open its Web Shell
|
||||
and go to **Settings → Connections → Allow remote connections**. Remote access
|
||||
is off by default. Turn it on, choose a network interface if prompted, and copy
|
||||
the displayed address and bearer token into the other Web Shell's connection
|
||||
form. No extra terminal command or daemon restart is required.
|
||||
|
||||
The remote listener serves the same workspaces and sessions as the local
|
||||
listener. It first uses the same numeric port on the selected interface. If
|
||||
that address is occupied, choose a different port in advanced settings; Qwen
|
||||
does not silently change ports or stop another service. HTTP pages on
|
||||
`localhost` and `127.0.0.1` at any port are allowed. Advanced settings accept
|
||||
additional exact browser origins, including HTTPS pages.
|
||||
|
||||
Turning access off or resetting the token revokes existing remote connections;
|
||||
local clients and daemon-owned running tasks continue. Re-enabling generates a
|
||||
new token. The setting is not persisted across daemon restarts. The token is
|
||||
available only to the authorized local operator. A primary listener already
|
||||
exposed by explicit startup/proxy configuration is labeled separately and is
|
||||
not closed by this switch.
|
||||
|
||||
The address must be reachable through the network and firewall. HTTP does not
|
||||
encrypt the token; use a trusted network or configure HTTPS. An HTTPS page
|
||||
cannot connect to a remote HTTP endpoint. The UI distinguishes authentication,
|
||||
policy, and mixed-content errors; a generic browser network error cannot
|
||||
reliably distinguish CORS, certificate, and connectivity failures.
|
||||
|
||||
```bash
|
||||
qwen serve --hostname 0.0.0.0
|
||||
```
|
||||
|
||||
@@ -1448,6 +1448,78 @@ describe('ConnectionRegistry.getSnapshot', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a remote prompt beyond disconnect grace and releases it when it settles', () => {
|
||||
vi.useFakeTimers();
|
||||
const detached = vi.fn();
|
||||
const registry = new ConnectionRegistry(undefined, detached);
|
||||
try {
|
||||
const conn = registry.create(false)!;
|
||||
conn.preserveRunningPromptsOnDisconnect = true;
|
||||
conn.ownSession('remote-session');
|
||||
const stream = new FakeStream('sse');
|
||||
const binding = conn.attachSessionStream(
|
||||
'remote-session',
|
||||
stream,
|
||||
new AbortController(),
|
||||
);
|
||||
const running = new AbortController();
|
||||
binding.promptAbort = running;
|
||||
conn.detachSessionStream('remote-session', stream, 10_000);
|
||||
vi.advanceTimersByTime(31 * 60_000);
|
||||
expect(running.signal.aborted).toBe(false);
|
||||
expect(registry.get(conn.connectionId)).toBe(conn);
|
||||
expect(conn.ownsSession('remote-session')).toBe(true);
|
||||
expect(detached).not.toHaveBeenCalled();
|
||||
binding.promptAbort = undefined;
|
||||
conn.promptSettled('remote-session');
|
||||
expect(conn.ownsSession('remote-session')).toBe(false);
|
||||
expect(detached).toHaveBeenCalled();
|
||||
} finally {
|
||||
registry.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('tears down a permission-blocked prompt at grace expiry instead of preserving it', () => {
|
||||
vi.useFakeTimers();
|
||||
const abandoned: string[] = [];
|
||||
const detached = vi.fn();
|
||||
const registry = new ConnectionRegistry((req) => {
|
||||
abandoned.push(req.bridgeRequestId);
|
||||
return true;
|
||||
}, detached);
|
||||
try {
|
||||
const conn = registry.create(false)!;
|
||||
conn.preserveRunningPromptsOnDisconnect = true;
|
||||
conn.ownSession('remote-session');
|
||||
const stream = new FakeStream('sse');
|
||||
const binding = conn.attachSessionStream(
|
||||
'remote-session',
|
||||
stream,
|
||||
new AbortController(),
|
||||
);
|
||||
const running = new AbortController();
|
||||
binding.promptAbort = running;
|
||||
// A permission vote registered while the stream was live: once the
|
||||
// entrance is revoked it can never arrive, so the prompt must not be
|
||||
// preserved across the disconnect.
|
||||
conn.pending.set('bridge-req-1', {
|
||||
sessionId: 'remote-session',
|
||||
bridgeRequestId: 'bridge-req-1',
|
||||
kind: 'permission',
|
||||
});
|
||||
conn.detachSessionStream('remote-session', stream, 10_000);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(abandoned).toEqual(['bridge-req-1']);
|
||||
expect(conn.ownsSession('remote-session')).toBe(false);
|
||||
expect(running.signal.aborted).toBe(true);
|
||||
expect(detached).toHaveBeenCalled();
|
||||
} finally {
|
||||
registry.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('attachSessionStream within the grace window reclaims (cancels the pending teardown)', () => {
|
||||
vi.useFakeTimers();
|
||||
const detached: string[] = [];
|
||||
|
||||
@@ -215,6 +215,7 @@ export interface ConnectionRegistrySnapshot {
|
||||
}
|
||||
|
||||
export class AcpConnection {
|
||||
preserveRunningPromptsOnDisconnect = false;
|
||||
readonly connectionId: string;
|
||||
/** Connection-scoped SSE stream (the client's `GET /acp` with only the conn header). */
|
||||
connStream?: TransportStream;
|
||||
@@ -334,6 +335,20 @@ export class AcpConnection {
|
||||
return this.ownedSessions.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a client request (a permission vote) is still outstanding for
|
||||
* this session. Such a prompt cannot make progress without the client: once
|
||||
* the entrance is revoked the vote can never arrive, so it must not be
|
||||
* preserved across disconnect — it has to go through teardown, which
|
||||
* abandons the pending request and releases the connection.
|
||||
*/
|
||||
hasPendingClientRequest(sessionId: string): boolean {
|
||||
for (const req of this.pending.values()) {
|
||||
if (req.sessionId === sessionId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
captureSessionOwnershipIdentity(sessionId: string): SessionOwnershipIdentity {
|
||||
let state = this.sessionOwnershipStates.get(sessionId);
|
||||
if (!state) {
|
||||
@@ -474,6 +489,32 @@ export class AcpConnection {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasRunningDetachedPrompt(): boolean {
|
||||
return (
|
||||
this.preserveRunningPromptsOnDisconnect &&
|
||||
[...this.sessions.values()].some(
|
||||
(binding) =>
|
||||
binding.promptAbort !== undefined &&
|
||||
!binding.promptAbort.signal.aborted,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
promptSettled(sessionId: string): void {
|
||||
const binding = this.sessions.get(sessionId);
|
||||
if (
|
||||
!this.preserveRunningPromptsOnDisconnect ||
|
||||
!binding ||
|
||||
binding.stream ||
|
||||
binding.graceTimer ||
|
||||
binding.promptAbort ||
|
||||
!binding.abort.signal.aborted
|
||||
)
|
||||
return;
|
||||
this.closeSessionStream(sessionId);
|
||||
this.onSessionGraceExpired?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any session is mid-reconnect: its transport stream detached but
|
||||
* its `SESSION_GRACE_MS` reclaim window is still armed (`graceTimer` set),
|
||||
@@ -485,6 +526,7 @@ export class AcpConnection {
|
||||
* still inside its OWN grace window — the reconnect would then 404.
|
||||
*/
|
||||
hasRecoverableSession(): boolean {
|
||||
if (this.hasRunningDetachedPrompt()) return true;
|
||||
for (const b of this.sessions.values()) {
|
||||
if (b.graceTimer) return true;
|
||||
}
|
||||
@@ -845,6 +887,20 @@ export class AcpConnection {
|
||||
binding.stream = undefined;
|
||||
if (binding.graceTimer) clearTimeout(binding.graceTimer);
|
||||
binding.graceTimer = setTimeout(() => {
|
||||
binding.graceTimer = undefined;
|
||||
// Revoking a remote entrance detaches its client, not the running job.
|
||||
// The prompt's finally path releases this binding once it settles. A
|
||||
// prompt blocked on a client permission vote is the exception: the vote
|
||||
// can never arrive after the credential is revoked, so preserving it
|
||||
// would hang the turn and pin the connection forever. Let it fall through
|
||||
// to teardown, which abandons the pending request.
|
||||
if (
|
||||
this.preserveRunningPromptsOnDisconnect &&
|
||||
binding.promptAbort &&
|
||||
!binding.promptAbort.signal.aborted &&
|
||||
!this.hasPendingClientRequest(sessionId)
|
||||
)
|
||||
return;
|
||||
// Grace expired with no reconnect → full teardown (aborts the prompt,
|
||||
// releases ownership, detaches the bridge client). Log it so an operator
|
||||
// debugging a vanished session can tell grace-expiry teardown apart from
|
||||
@@ -1668,7 +1724,8 @@ export class ConnectionRegistry {
|
||||
private sweep(): void {
|
||||
const cutoff = Date.now() - this.idleTtlMs;
|
||||
for (const [id, conn] of this.byId) {
|
||||
if (conn.lastActiveMs >= cutoff) continue;
|
||||
if (conn.lastActiveMs >= cutoff || conn.hasRunningDetachedPrompt())
|
||||
continue;
|
||||
// Observability: a reaped connection silently dropping its SSE
|
||||
// streams is otherwise invisible to operators chasing "my client
|
||||
// froze". Note that `touch()` fires on inbound HTTP AND on event
|
||||
|
||||
@@ -6388,7 +6388,10 @@ export class AcpDispatcher {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (binding.promptAbort === abort) binding.promptAbort = undefined;
|
||||
if (binding.promptAbort === abort) {
|
||||
binding.promptAbort = undefined;
|
||||
conn.promptSettled(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
type DesktopRelayCredentialClaim,
|
||||
type ListenerScopedCredentials,
|
||||
} from '../local-control/credentials.js';
|
||||
import { listenerIdentityOfSocket } from '../local-control/listener-identity.js';
|
||||
import {
|
||||
listenerIdentityOf,
|
||||
listenerIdentityOfSocket,
|
||||
} from '../local-control/listener-identity.js';
|
||||
import type { WorkspaceFileSystemFactory } from '../fs/index.js';
|
||||
import { resolveAcpHttpEnabled } from '../acp-http-enabled.js';
|
||||
import type { DeviceFlowRegistry } from '../auth/device-flow.js';
|
||||
@@ -1012,6 +1015,9 @@ export function mountAcpHttp(
|
||||
// `initialize` mints a connection and replies inline (200 + JSON).
|
||||
if (isRequest(message) && message.method === 'initialize') {
|
||||
const conn = mount.registry.create(isLoopbackReq(req));
|
||||
if (conn)
|
||||
conn.preserveRunningPromptsOnDisconnect =
|
||||
listenerIdentityOf(req).kind === 'remote-access';
|
||||
if (!conn) {
|
||||
// Connection cap reached — shed load rather than grow unbounded.
|
||||
writeStderrLine(
|
||||
@@ -1661,7 +1667,7 @@ export function mountAcpHttp(
|
||||
// domain resolves to 127.0.0.1 and the browser sends the
|
||||
// attacker's Host header. Match the full host:port string like
|
||||
// the REST middleware does; extract port from the socket.
|
||||
if (upgradeListenerIdentity.kind === 'local-control') {
|
||||
if (upgradeListenerIdentity.kind !== 'primary') {
|
||||
const authority = upgradeListenerIdentity.authority;
|
||||
const browserHost =
|
||||
upgradeListenerIdentity.origin === undefined
|
||||
@@ -1735,12 +1741,14 @@ export function mountAcpHttp(
|
||||
const isPrimaryOrigin =
|
||||
authenticatedRemoteBind &&
|
||||
new URL(`${scheme}://${host}`).origin === origin;
|
||||
if (
|
||||
!isLoopbackOrigin &&
|
||||
!isAllowlistedOrigin &&
|
||||
!isListenerOrigin &&
|
||||
!isPrimaryOrigin
|
||||
) {
|
||||
const allowed =
|
||||
upgradeListenerIdentity.kind === 'remote-access'
|
||||
? upgradeListenerIdentity.allowedOrigins?.allows(origin) === true
|
||||
: isLoopbackOrigin ||
|
||||
isAllowlistedOrigin ||
|
||||
isListenerOrigin ||
|
||||
isPrimaryOrigin;
|
||||
if (!allowed) {
|
||||
logReject(`origin-not-allowed ${origin}`);
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
@@ -2361,6 +2369,9 @@ export function mountAcpHttp(
|
||||
}
|
||||
|
||||
const conn = activeMount.registry.create(fromLoopback);
|
||||
if (conn)
|
||||
conn.preserveRunningPromptsOnDisconnect =
|
||||
upgradeListenerIdentity.kind === 'remote-access';
|
||||
if (!conn) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
@@ -2389,7 +2400,27 @@ export function mountAcpHttp(
|
||||
writeStderrLine(
|
||||
`qwen serve: ${activeMount.routeLabel} WS closed (${conn.connectionId.slice(0, 8)})`,
|
||||
);
|
||||
activeMount.registry.delete(conn.connectionId);
|
||||
if (!conn.preserveRunningPromptsOnDisconnect) {
|
||||
activeMount.registry.delete(conn.connectionId);
|
||||
return;
|
||||
}
|
||||
conn.onSessionGraceExpired = () => {
|
||||
if (
|
||||
!conn.hasLiveSessionStream() &&
|
||||
!conn.hasRecoverableSession()
|
||||
) {
|
||||
activeMount.registry.delete(conn.connectionId);
|
||||
}
|
||||
};
|
||||
for (const binding of conn.sessions.values()) {
|
||||
if (binding.stream)
|
||||
conn.detachSessionStream(
|
||||
binding.sessionId,
|
||||
binding.stream,
|
||||
SESSION_GRACE_MS,
|
||||
);
|
||||
}
|
||||
conn.onSessionGraceExpired();
|
||||
},
|
||||
() => conn.touch(),
|
||||
);
|
||||
|
||||
@@ -13407,6 +13407,44 @@ describe('ACP WebSocket transport security', () => {
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it('uses remote listener origins and credentials without changing primary access', async () => {
|
||||
await startServer({ token: 'operator', localControlToken: 'pairing' });
|
||||
credentialStore!.setRemoteToken('remote-token');
|
||||
const origin = `http://127.0.0.1:${lanPort}`;
|
||||
tagListener(lanServer!, {
|
||||
kind: 'remote-access',
|
||||
authority: `127.0.0.1:${lanPort}`,
|
||||
origin,
|
||||
allowedOrigins: {
|
||||
allows: (candidate) => candidate === 'http://localhost:5273',
|
||||
},
|
||||
});
|
||||
const accepted = await wsConnectLocalControl('remote-token', {
|
||||
Origin: 'http://localhost:5273',
|
||||
});
|
||||
expect(accepted.code).toBe(101);
|
||||
const closed = new Promise<void>((resolve) =>
|
||||
accepted.socket!.once('close', () => resolve()),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await wsConnectLocalControl('operator', {
|
||||
Origin: 'http://localhost:5273',
|
||||
})
|
||||
).code,
|
||||
).toBe(401);
|
||||
expect(
|
||||
(await wsConnectLocalControl('remote-token', { Origin: origin })).code,
|
||||
).toBe(403);
|
||||
const primary = await wsConnect({
|
||||
headers: { Authorization: 'Bearer operator' },
|
||||
});
|
||||
acpHandle!.detachServer(lanServer!);
|
||||
await closed;
|
||||
expect(primary.readyState).toBe(WebSocket.OPEN);
|
||||
primary.terminate();
|
||||
});
|
||||
|
||||
it('terminates LAN WebSockets when their listener is detached', async () => {
|
||||
await startServer({ localControlToken: 'pairing-token' });
|
||||
const paired = await wsConnectLocalControl('pairing-token');
|
||||
|
||||
@@ -369,7 +369,11 @@ export function allowOriginCors(
|
||||
res.status(403).json({ error: 'Request denied by CORS policy' });
|
||||
return;
|
||||
}
|
||||
const matched = matcher.allows(origin);
|
||||
const listener = listenerIdentityOf(req);
|
||||
const matched =
|
||||
listener.kind === 'remote-access'
|
||||
? listener.allowedOrigins?.allows(origin) === true
|
||||
: matcher.allows(origin);
|
||||
if (matched) {
|
||||
// Echo the request's origin verbatim (not literal `*`) even under
|
||||
// the any-origin pattern. Browser caches use the echo paired with
|
||||
@@ -378,10 +382,25 @@ export function allowOriginCors(
|
||||
// a schema change (the CORS spec forbids `*` with credentials).
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
res.setHeader('Access-Control-Allow-Methods', allowedMethods);
|
||||
res.setHeader('Access-Control-Allow-Headers', allowedHeaders);
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Methods',
|
||||
listener.kind === 'remote-access'
|
||||
? `${allowedMethods}, PUT`
|
||||
: allowedMethods,
|
||||
);
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
listener.kind === 'remote-access'
|
||||
? `${allowedHeaders}, Acp-Connection-Id, Acp-Session-Id`
|
||||
: allowedHeaders,
|
||||
);
|
||||
res.setHeader('Access-Control-Max-Age', maxAgeSeconds);
|
||||
res.setHeader('Access-Control-Expose-Headers', exposedHeaders);
|
||||
res.setHeader(
|
||||
'Access-Control-Expose-Headers',
|
||||
listener.kind === 'remote-access'
|
||||
? `${exposedHeaders}, Acp-Connection-Id`
|
||||
: exposedHeaders,
|
||||
);
|
||||
if (
|
||||
req.method === 'OPTIONS' &&
|
||||
(req.headers['access-control-request-method'] ||
|
||||
@@ -419,7 +438,7 @@ export function hostAllowlist(
|
||||
const primaryGate = buildPrimaryHostGate(bind, getPort);
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const listener = listenerIdentityOf(req);
|
||||
if (listener.kind !== 'local-control') {
|
||||
if (listener.kind === 'primary') {
|
||||
primaryGate(req, res, next);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface ListenerScopedCredentials {
|
||||
*/
|
||||
export class CredentialStore implements ListenerScopedCredentials {
|
||||
#runtime: Buffer | undefined;
|
||||
#remote: Buffer | undefined;
|
||||
/** Pairing id → sha256 of the pairing secret. */
|
||||
readonly #pairing = new Map<string, Buffer>();
|
||||
/** Device id → the digest and issuance time of its Web Shell credential. */
|
||||
@@ -70,6 +71,10 @@ export class CredentialStore implements ListenerScopedCredentials {
|
||||
this.#runtime = hashToken(runtimeToken);
|
||||
}
|
||||
|
||||
setRemoteToken(token: string | undefined): void {
|
||||
this.#remote = hashToken(token);
|
||||
}
|
||||
|
||||
addPairingToken(id: string, token: string): void {
|
||||
this.#pairing.set(id, createHash('sha256').update(token, 'utf8').digest());
|
||||
}
|
||||
@@ -145,7 +150,7 @@ export class CredentialStore implements ListenerScopedCredentials {
|
||||
* "operator chose the surface area" reasoning that governs `--hostname`.
|
||||
*/
|
||||
isOpen(listener: ListenerIdentity): boolean {
|
||||
if (listener.kind === 'local-control') return false;
|
||||
if (listener.kind !== 'primary') return false;
|
||||
return this.#runtime === undefined;
|
||||
}
|
||||
|
||||
@@ -158,6 +163,9 @@ export class CredentialStore implements ListenerScopedCredentials {
|
||||
*/
|
||||
verify(credentials: string, listener: ListenerIdentity): boolean {
|
||||
const candidate = createHash('sha256').update(credentials, 'utf8').digest();
|
||||
if (listener.kind === 'remote-access') {
|
||||
return this.#remote !== undefined && equalDigest(candidate, this.#remote);
|
||||
}
|
||||
let matched = false;
|
||||
if (listener.kind === 'local-control') {
|
||||
for (const expected of this.#pairing.values()) {
|
||||
@@ -190,10 +198,9 @@ export function singleTokenCredentials(
|
||||
): ListenerScopedCredentials {
|
||||
const expected = hashToken(token);
|
||||
return {
|
||||
isOpen: (listener) =>
|
||||
expected === undefined && listener.kind !== 'local-control',
|
||||
isOpen: (listener) => expected === undefined && listener.kind === 'primary',
|
||||
verify: (candidate, listener) => {
|
||||
if (expected === undefined || listener.kind === 'local-control') {
|
||||
if (expected === undefined || listener.kind !== 'primary') {
|
||||
return false;
|
||||
}
|
||||
return equalDigest(
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { Server } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { Request } from 'express';
|
||||
import type { OriginMatcher } from '../auth.js';
|
||||
|
||||
/**
|
||||
* Which listener a request arrived on.
|
||||
@@ -21,7 +22,7 @@ import type { Request } from 'express';
|
||||
* carried the runtime token; in-daemon we get it by attributing the request to
|
||||
* its listener instead.
|
||||
*/
|
||||
export type ListenerKind = 'primary' | 'local-control';
|
||||
export type ListenerKind = 'primary' | 'local-control' | 'remote-access';
|
||||
|
||||
export interface ListenerIdentity {
|
||||
readonly kind: ListenerKind;
|
||||
@@ -34,6 +35,7 @@ export interface ListenerIdentity {
|
||||
readonly authority?: string;
|
||||
/** Exact browser Origin accepted by the Local Control WebSocket gate. */
|
||||
readonly origin?: string;
|
||||
readonly allowedOrigins?: OriginMatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import * as os from 'node:os';
|
||||
import { RemoteAccessService } from './remote-access.js';
|
||||
import { CredentialStore } from './local-control/credentials.js';
|
||||
import {
|
||||
allowOriginCors,
|
||||
bearerAuth,
|
||||
hostAllowlist,
|
||||
MutableOriginAllowlist,
|
||||
parseAllowOriginPatterns,
|
||||
} from './auth.js';
|
||||
import { registerRemoteAccessRoutes } from './routes/remote-access.js';
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:os')>()),
|
||||
networkInterfaces: vi.fn(),
|
||||
}));
|
||||
|
||||
let primary: Server;
|
||||
let service: RemoteAccessService;
|
||||
let local: string;
|
||||
const session = { id: 'existing-session', progress: 0 };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(os.networkInterfaces).mockReturnValue({
|
||||
test: [
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
family: 'IPv4',
|
||||
internal: false,
|
||||
netmask: '255.0.0.0',
|
||||
mac: '00:00:00:00:00:00',
|
||||
cidr: '127.0.0.1/8',
|
||||
},
|
||||
],
|
||||
});
|
||||
const app = express();
|
||||
const credentials = new CredentialStore();
|
||||
primary = createServer(app);
|
||||
await new Promise<void>((resolve) => primary.listen(0, '127.0.0.1', resolve));
|
||||
const port = (primary.address() as { port: number }).port;
|
||||
local = `http://127.0.0.1:${port}`;
|
||||
app.use(hostAllowlist('127.0.0.1', () => port));
|
||||
app.use(
|
||||
allowOriginCors(new MutableOriginAllowlist(parseAllowOriginPatterns([]))),
|
||||
);
|
||||
app.use(bearerAuth(credentials));
|
||||
app.use(express.json());
|
||||
app.get('/session', (_req, res) => res.json(session));
|
||||
service = new RemoteAccessService({
|
||||
app,
|
||||
credentials,
|
||||
getPort: () => port,
|
||||
attachWebSocket: () => {},
|
||||
detachWebSocket: () => {},
|
||||
});
|
||||
registerRemoteAccessRoutes(app, {
|
||||
service,
|
||||
primaryHostname: '127.0.0.1',
|
||||
trustedLoopbackMode: true,
|
||||
mutate: () => (_req, _res, next) => next(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await service.dispose();
|
||||
primary.closeAllConnections();
|
||||
await new Promise<void>((resolve) => primary.close(() => resolve()));
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function manage(action: string, body: object = {}) {
|
||||
return fetch(`${local}/remote-access/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it('enables the same application through local management and revokes only remote access', async () => {
|
||||
expect(service.status().active).toBe(false);
|
||||
const enabled = await (await manage('enable', { port: 0 })).json();
|
||||
expect(enabled.active).toBe(true);
|
||||
expect(enabled.token).toBeUndefined();
|
||||
const { token } = await (await manage('reveal')).json();
|
||||
const origin = 'http://localhost:5273';
|
||||
const remote = (secret: string) =>
|
||||
fetch(`${enabled.url}/session`, {
|
||||
headers: { Origin: origin, Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
expect((await remote('wrong')).status).toBe(401);
|
||||
const response = await remote(token);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(origin);
|
||||
expect(await response.json()).toEqual(session);
|
||||
const denied = await fetch(`${enabled.url}/remote-access/disable`, {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin, Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(denied.status).toBe(403);
|
||||
const reset = await (await manage('reset-token')).json();
|
||||
expect(reset.token).not.toBe(token);
|
||||
expect((await remote(token)).status).toBe(401);
|
||||
expect((await remote(reset.token)).status).toBe(200);
|
||||
await manage('disable');
|
||||
expect(service.status().active).toBe(false);
|
||||
session.progress += 1;
|
||||
expect(await (await fetch(`${local}/session`)).json()).toEqual(session);
|
||||
await expect(remote(reset.token)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses collisions without changing the local listener or selecting another port', async () => {
|
||||
const response = await manage('enable');
|
||||
expect(response.status).toBe(409);
|
||||
expect((await response.json()).code).toBe('EADDRINUSE');
|
||||
expect(service.status().active).toBe(false);
|
||||
expect((await fetch(`${local}/session`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('handles preflight and exact custom origins without allowing unrelated pages', async () => {
|
||||
const { url } = await (await manage('enable', { port: 0 })).json();
|
||||
const { token } = await (await manage('reveal')).json();
|
||||
const probe = (origin: string) =>
|
||||
fetch(`${url}/session`, {
|
||||
headers: { Origin: origin, Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const preflight = await fetch(`${url}/session`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'http://127.0.0.1:54321',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers':
|
||||
'authorization, acp-connection-id, acp-session-id',
|
||||
},
|
||||
});
|
||||
expect(preflight.status).toBe(204);
|
||||
expect(preflight.headers.get('access-control-allow-methods')).toContain(
|
||||
'PUT',
|
||||
);
|
||||
expect(preflight.headers.get('access-control-allow-headers')).toContain(
|
||||
'Acp-Connection-Id',
|
||||
);
|
||||
expect(preflight.headers.get('access-control-allow-headers')).toContain(
|
||||
'Acp-Session-Id',
|
||||
);
|
||||
expect((await probe('https://shell.example')).status).toBe(403);
|
||||
await manage('origins', { origins: ['https://shell.example'] });
|
||||
expect((await probe('https://shell.example')).status).toBe(200);
|
||||
expect((await probe('https://unrelated.example')).status).toBe(403);
|
||||
expect((await probe('null')).status).toBe(403);
|
||||
expect((await manage('origins', { origins: ['*'] })).status).toBe(409);
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { createServer as createSecureServer } from 'node:https';
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import type { Socket } from 'node:net';
|
||||
import type { Application } from 'express';
|
||||
import { MutableOriginAllowlist, parseAllowOriginPatterns } from './auth.js';
|
||||
import type { CredentialStore } from './local-control/credentials.js';
|
||||
import { tagListener } from './local-control/listener-identity.js';
|
||||
|
||||
export function remoteAccessInterfaces() {
|
||||
return Object.entries(networkInterfaces()).flatMap(
|
||||
([interfaceName, entries]) =>
|
||||
(entries ?? [])
|
||||
.filter((entry) => entry.family === 'IPv4' && !entry.internal)
|
||||
.map(({ address }) => ({ interfaceName, address })),
|
||||
);
|
||||
}
|
||||
|
||||
export class RemoteAccessError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoteAccessOptions {
|
||||
address?: string;
|
||||
port?: number;
|
||||
origins?: string[];
|
||||
}
|
||||
|
||||
interface ActiveEntrance {
|
||||
server: Server;
|
||||
sockets: Set<Socket>;
|
||||
url: string;
|
||||
address: string;
|
||||
port: number;
|
||||
token: string;
|
||||
generation: string;
|
||||
origins: string[];
|
||||
}
|
||||
|
||||
const LOCAL_ORIGINS = ['http://127.0.0.1:*', 'http://localhost:*'];
|
||||
|
||||
function exactOrigins(origins: string[]): string[] {
|
||||
if (origins.length > 32) {
|
||||
throw new RemoteAccessError(
|
||||
'invalid_origins',
|
||||
'At most 32 page origins are supported.',
|
||||
);
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
origins.map((origin) => {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.pathname !== '/' ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
url.hostname.includes('*')
|
||||
)
|
||||
throw new Error();
|
||||
return url.origin;
|
||||
} catch {
|
||||
throw new RemoteAccessError(
|
||||
'invalid_origins',
|
||||
'Enter exact HTTP or HTTPS page origins, without paths or wildcards.',
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export class RemoteAccessService {
|
||||
#active?: ActiveEntrance;
|
||||
#transition: Promise<unknown> = Promise.resolve();
|
||||
#disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
app: Application;
|
||||
credentials: CredentialStore;
|
||||
getPort(): number;
|
||||
attachWebSocket(server: Server): void;
|
||||
detachWebSocket(server: Server): void;
|
||||
tlsPaths?: { cert: string; key: string };
|
||||
},
|
||||
) {}
|
||||
|
||||
status() {
|
||||
const active = this.#active;
|
||||
return {
|
||||
active: Boolean(active),
|
||||
defaultPort: this.deps.getPort(),
|
||||
interfaces: remoteAccessInterfaces(),
|
||||
...(active
|
||||
? {
|
||||
url: active.url,
|
||||
address: active.address,
|
||||
port: active.port,
|
||||
generation: active.generation,
|
||||
origins: active.origins,
|
||||
encrypted: Boolean(this.deps.tlsPaths),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
reveal() {
|
||||
return { ...this.status(), token: this.#active?.token };
|
||||
}
|
||||
|
||||
enable(options: RemoteAccessOptions = {}) {
|
||||
return this.#serialize(async () => {
|
||||
if (this.#disposed)
|
||||
throw new RemoteAccessError(
|
||||
'daemon_draining',
|
||||
'The daemon is shutting down.',
|
||||
);
|
||||
if (this.#active) return this.status();
|
||||
const interfaces = remoteAccessInterfaces();
|
||||
const address =
|
||||
options.address ??
|
||||
(interfaces.length === 1 ? interfaces[0]!.address : undefined);
|
||||
if (!address || !interfaces.some((entry) => entry.address === address)) {
|
||||
throw new RemoteAccessError(
|
||||
'select_interface',
|
||||
'Select a current network interface on this server.',
|
||||
);
|
||||
}
|
||||
const port = options.port ?? this.deps.getPort();
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new RemoteAccessError(
|
||||
'invalid_port',
|
||||
'Port must be an integer between 0 and 65535.',
|
||||
);
|
||||
}
|
||||
const origins = exactOrigins(options.origins ?? []);
|
||||
const matcher = new MutableOriginAllowlist(
|
||||
parseAllowOriginPatterns([...LOCAL_ORIGINS, ...origins]),
|
||||
);
|
||||
const tls = this.deps.tlsPaths;
|
||||
const server = tls
|
||||
? createSecureServer(
|
||||
{ cert: readFileSync(tls.cert), key: readFileSync(tls.key) },
|
||||
this.deps.app,
|
||||
)
|
||||
: createServer(this.deps.app);
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const sockets = new Set<Socket>();
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
server.maxConnections = 64;
|
||||
server.headersTimeout = 10_000;
|
||||
server.requestTimeout = 30 * 60_000;
|
||||
server.keepAliveTimeout = 5000;
|
||||
tagListener(server, { kind: 'remote-access', allowedOrigins: matcher });
|
||||
this.deps.credentials.setRemoteToken(token);
|
||||
try {
|
||||
this.deps.attachWebSocket(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, address, () => {
|
||||
server.removeListener('error', reject);
|
||||
const bound = server.address();
|
||||
if (!bound || typeof bound === 'string') {
|
||||
reject(new Error('Remote listener has no TCP address.'));
|
||||
return;
|
||||
}
|
||||
const url = new URL(
|
||||
`${tls ? 'https' : 'http'}://${address}:${bound.port}`,
|
||||
).origin;
|
||||
matcher.add('self', url);
|
||||
tagListener(server, {
|
||||
kind: 'remote-access',
|
||||
authority: `${address}:${bound.port}`,
|
||||
origin: url,
|
||||
allowedOrigins: matcher,
|
||||
});
|
||||
this.#active = {
|
||||
server,
|
||||
sockets,
|
||||
url,
|
||||
address,
|
||||
port: bound.port,
|
||||
token,
|
||||
generation: randomUUID(),
|
||||
origins,
|
||||
};
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.deps.credentials.setRemoteToken(undefined);
|
||||
this.deps.detachWebSocket(server);
|
||||
for (const socket of sockets) socket.destroy();
|
||||
if (server.listening) server.close();
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
throw new RemoteAccessError(
|
||||
code ?? 'enable_failed',
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Could not open the remote listener.',
|
||||
);
|
||||
}
|
||||
server.on('error', () => {
|
||||
void this.disable().catch(() => {});
|
||||
});
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
|
||||
updateOrigins(origins: string[]) {
|
||||
return this.#serialize(async () => {
|
||||
const active = this.#requireActive();
|
||||
const exact = exactOrigins(origins);
|
||||
const matcher = new MutableOriginAllowlist(
|
||||
parseAllowOriginPatterns([...LOCAL_ORIGINS, ...exact, active.url]),
|
||||
);
|
||||
active.origins = exact;
|
||||
tagListener(active.server, {
|
||||
kind: 'remote-access',
|
||||
authority: `${active.address}:${active.port}`,
|
||||
origin: active.url,
|
||||
allowedOrigins: matcher,
|
||||
});
|
||||
this.#disconnect(active);
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
|
||||
resetToken() {
|
||||
return this.#serialize(async () => {
|
||||
const active = this.#requireActive();
|
||||
active.token = randomBytes(32).toString('base64url');
|
||||
active.generation = randomUUID();
|
||||
this.deps.credentials.setRemoteToken(active.token);
|
||||
this.#disconnect(active);
|
||||
return this.reveal();
|
||||
});
|
||||
}
|
||||
|
||||
disable() {
|
||||
return this.#serialize(async () => {
|
||||
const active = this.#active;
|
||||
this.deps.credentials.setRemoteToken(undefined);
|
||||
if (!active) return this.status();
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
active.server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
this.deps.detachWebSocket(active.server);
|
||||
for (const socket of active.sockets) socket.destroy();
|
||||
await closed;
|
||||
this.#active = undefined;
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#disposed = true;
|
||||
return this.disable();
|
||||
}
|
||||
|
||||
#requireActive() {
|
||||
if (!this.#active)
|
||||
throw new RemoteAccessError('remote_access_off', 'Remote access is off.');
|
||||
return this.#active;
|
||||
}
|
||||
|
||||
#disconnect(active: ActiveEntrance) {
|
||||
this.deps.detachWebSocket(active.server);
|
||||
for (const socket of active.sockets) socket.destroy();
|
||||
this.deps.attachWebSocket(active.server);
|
||||
}
|
||||
|
||||
#serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const next = this.#transition.then(operation);
|
||||
this.#transition = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Application, Request, RequestHandler } from 'express';
|
||||
import { requestHasOperatorAuthority } from '../auth.js';
|
||||
import { isLoopbackAddress, isLoopbackBind } from '../loopback-binds.js';
|
||||
import { listenerIdentityOf } from '../local-control/listener-identity.js';
|
||||
import {
|
||||
RemoteAccessError,
|
||||
type RemoteAccessService,
|
||||
} from '../remote-access.js';
|
||||
|
||||
export function registerRemoteAccessRoutes(
|
||||
app: Application,
|
||||
deps: {
|
||||
service: RemoteAccessService;
|
||||
primaryHostname: string;
|
||||
trustedLoopbackMode: boolean;
|
||||
mutate: (options?: { strict?: boolean }) => RequestHandler;
|
||||
isDraining?: () => boolean;
|
||||
},
|
||||
) {
|
||||
const externallyConfigured = !isLoopbackBind(deps.primaryHostname);
|
||||
const canManage = (req: Request) =>
|
||||
!externallyConfigured &&
|
||||
listenerIdentityOf(req).kind === 'primary' &&
|
||||
Boolean(
|
||||
req.socket.remoteAddress && isLoopbackAddress(req.socket.remoteAddress),
|
||||
) &&
|
||||
requestHasOperatorAuthority(req, deps.trustedLoopbackMode);
|
||||
|
||||
// This is process-global administration, never selected-workspace state.
|
||||
app.get('/remote-access', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!canManage(req)) {
|
||||
res.json({
|
||||
active: deps.service.status().active,
|
||||
canManage: false,
|
||||
externallyConfigured,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
...deps.service.status(),
|
||||
canManage: true,
|
||||
externallyConfigured,
|
||||
});
|
||||
});
|
||||
|
||||
for (const action of [
|
||||
'enable',
|
||||
'disable',
|
||||
'reveal',
|
||||
'reset-token',
|
||||
'origins',
|
||||
] as const) {
|
||||
app.post(
|
||||
`/remote-access/${action}`,
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!canManage(req)) {
|
||||
res.status(403).json({
|
||||
code: 'local_operator_required',
|
||||
error:
|
||||
'Manage remote access from this daemon’s authorized local settings.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (deps.isDraining?.()) {
|
||||
res.status(503).json({
|
||||
code: 'daemon_draining',
|
||||
error: 'The daemon is shutting down.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body: unknown = req.body;
|
||||
const input =
|
||||
typeof body === 'object' && body !== null && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>)
|
||||
: {};
|
||||
try {
|
||||
if (
|
||||
(input['address'] !== undefined &&
|
||||
typeof input['address'] !== 'string') ||
|
||||
(input['port'] !== undefined &&
|
||||
typeof input['port'] !== 'number') ||
|
||||
(input['origins'] !== undefined &&
|
||||
(!Array.isArray(input['origins']) ||
|
||||
!input['origins'].every(
|
||||
(entry: unknown) => typeof entry === 'string',
|
||||
)))
|
||||
) {
|
||||
throw new RemoteAccessError(
|
||||
'invalid_options',
|
||||
'Invalid remote access options.',
|
||||
);
|
||||
}
|
||||
const options = input as {
|
||||
address?: string;
|
||||
port?: number;
|
||||
origins?: string[];
|
||||
};
|
||||
const status =
|
||||
action === 'enable'
|
||||
? await deps.service.enable(options)
|
||||
: action === 'disable'
|
||||
? await deps.service.disable()
|
||||
: action === 'reset-token'
|
||||
? await deps.service.resetToken()
|
||||
: action === 'origins'
|
||||
? await deps.service.updateOrigins(options.origins ?? [])
|
||||
: deps.service.reveal();
|
||||
res.json({ ...status, canManage: true, externallyConfigured });
|
||||
} catch (error) {
|
||||
res.status(error instanceof RemoteAccessError ? 409 : 500).json({
|
||||
code:
|
||||
error instanceof RemoteAccessError
|
||||
? error.code
|
||||
: 'remote_access_failed',
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Remote access operation failed.',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,76 @@ describe('Local Control routes', () => {
|
||||
expect(response.body.urlRedacted).toBe(true);
|
||||
});
|
||||
|
||||
it('redacts the pairing secret from a remote-access bearer', async () => {
|
||||
// The secondary listeners serve this same app and each verifies only its
|
||||
// own token, so "some credential was verified" would hand the Local
|
||||
// Control pairing secret to a remote-access client; nothing revokes
|
||||
// `#pairing` when remote access is turned off.
|
||||
const url = 'http://192.168.1.10:4170/#token=abc123';
|
||||
const app = express();
|
||||
app.use(asAuthenticated);
|
||||
registerWorkspaceLocalControlRoutes(app, {
|
||||
service: {
|
||||
status: vi.fn(() => ({ active: true, url })),
|
||||
} as unknown as LocalControlService,
|
||||
mutate: () => (_req, _res, next) => next(),
|
||||
safeBody: () => ({}),
|
||||
trustedLoopbackMode: true,
|
||||
});
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
tagListener(server, { kind: 'remote-access' });
|
||||
|
||||
try {
|
||||
const response = await request(server).get('/workspace/local-control');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.active).toBe(true);
|
||||
expect(response.body.url).toBeUndefined();
|
||||
expect(response.body.qrText).toBeUndefined();
|
||||
expect(response.body.urlRedacted).toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the pairing secret to the listener that owns it', async () => {
|
||||
const url = 'http://192.168.1.10:4170/#token=abc123';
|
||||
const app = express();
|
||||
app.use(asAuthenticated);
|
||||
registerWorkspaceLocalControlRoutes(app, {
|
||||
service: {
|
||||
status: vi.fn(() => ({ active: true, url })),
|
||||
} as unknown as LocalControlService,
|
||||
mutate: () => (_req, _res, next) => next(),
|
||||
safeBody: () => ({}),
|
||||
});
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
tagListener(server, {
|
||||
kind: 'local-control',
|
||||
authority: `127.0.0.1:${port}`,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(server)
|
||||
.get('/workspace/local-control')
|
||||
.set('Host', `127.0.0.1:${port}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.url).toBe(url);
|
||||
expect(response.body.urlRedacted).toBeUndefined();
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the pairing secret to a trusted-loopback primary caller', async () => {
|
||||
const url = 'http://192.168.1.10:4170/#token=abc123';
|
||||
const app = express();
|
||||
|
||||
@@ -64,18 +64,38 @@ async function withUiData(status: LocalControlStatus) {
|
||||
return { ...status, qrText, interfaces: listLanCandidates() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pairing credential belongs to the Local Control listener, so only
|
||||
* requests from that listener or from the primary one may see it.
|
||||
*
|
||||
* Being authenticated is not the same fact: the secondary listeners share one
|
||||
* Express app, and each verifies only its own token. A remote-access bearer is
|
||||
* authenticated on the `remote-access` listener, which must not reach the
|
||||
* pairing secret of a different listener — nothing revokes `#pairing` when
|
||||
* remote access is turned off.
|
||||
*/
|
||||
function ownsLocalControlListener(req: Request): boolean {
|
||||
const kind = listenerIdentityOf(req).kind;
|
||||
return kind === 'primary' || kind === 'local-control';
|
||||
}
|
||||
|
||||
/**
|
||||
* `url` carries the pairing token in its fragment and `qrText` encodes it.
|
||||
* Return that material only to a request with operator authority: either a
|
||||
* listener credential was verified or the request arrived on the trusted
|
||||
* primary loopback listener. Everyone else gets a redacted status.
|
||||
* Return that material only to a request that both arrived on a listener which
|
||||
* owns it and holds operator authority there. Everyone else gets a redacted
|
||||
* status.
|
||||
*/
|
||||
function presentStatus(
|
||||
req: Request,
|
||||
ui: Awaited<ReturnType<typeof withUiData>>,
|
||||
trustedLoopbackMode: boolean,
|
||||
) {
|
||||
if (requestHasOperatorAuthority(req, trustedLoopbackMode)) return ui;
|
||||
if (
|
||||
ownsLocalControlListener(req) &&
|
||||
requestHasOperatorAuthority(req, trustedLoopbackMode)
|
||||
) {
|
||||
return ui;
|
||||
}
|
||||
const { url: _url, qrText: _qrText, ...rest } = ui;
|
||||
return { ...rest, urlRedacted: ui.url !== undefined };
|
||||
}
|
||||
@@ -90,10 +110,11 @@ function presentStatus(
|
||||
* machine can grant.
|
||||
*
|
||||
* Disabling stays open to every caller admitted by its listener policy,
|
||||
* including the trusted primary listener and a paired phone. Revoking your own
|
||||
* access is always safe, and a user who realizes they are on an untrusted
|
||||
* network needs to cut the connection from the device in their hand, not from
|
||||
* the laptop they walked away from.
|
||||
* including a paired phone and a remote-access client. Revoking access is
|
||||
* always safe, and a remote-access bearer already reaches every other route
|
||||
* on this app, so denying it here would be security theatre while breaking
|
||||
* remote management. What it must not get is the pairing secret —
|
||||
* `presentStatus` scopes that to the listeners that own it.
|
||||
*/
|
||||
function requirePrimaryListener(req: Request, res: Response): boolean {
|
||||
if (listenerIdentityOf(req).kind === 'primary') return true;
|
||||
|
||||
@@ -140,6 +140,7 @@ import {
|
||||
parseAllowOriginPatterns,
|
||||
} from './auth.js';
|
||||
import type { LocalControlService } from './local-control/index.js';
|
||||
import type { RemoteAccessService } from './remote-access.js';
|
||||
import {
|
||||
createPermissionAuditPublisher,
|
||||
PermissionAuditRing,
|
||||
@@ -5269,6 +5270,15 @@ async function runQwenServeImpl(
|
||||
// serialized disable runs on the next microtask and wins before further IO;
|
||||
// ACP disposal below also removes the upgrade listeners while the daemon
|
||||
// mount is being torn down.
|
||||
const remoteAccessService = app.locals?.['remoteAccessService'] as
|
||||
| RemoteAccessService
|
||||
| undefined;
|
||||
if (remoteAccessService) {
|
||||
void remoteAccessService.dispose().catch((error: unknown) => {
|
||||
daemonLog.warn(`Remote access dispose error: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const localControlService = app.locals?.['localControlService'] as
|
||||
| LocalControlService
|
||||
| undefined;
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
LocalControlService,
|
||||
} from './local-control/index.js';
|
||||
import { registerWorkspaceLocalControlRoutes } from './routes/workspace-local-control.js';
|
||||
import { RemoteAccessService } from './remote-access.js';
|
||||
import { registerRemoteAccessRoutes } from './routes/remote-access.js';
|
||||
import { registerWebShellPairingRoutes } from './routes/web-shell-pairing.js';
|
||||
import type {
|
||||
DeviceFlowProvider,
|
||||
@@ -2373,7 +2375,7 @@ export function createServeApp(
|
||||
});
|
||||
if (healthRoutes.exposeHealthPreAuth) {
|
||||
app.use('/health', (req, res, next) => {
|
||||
if (listenerIdentityOf(req).kind === 'local-control') {
|
||||
if (listenerIdentityOf(req).kind !== 'primary') {
|
||||
authenticate(req, res, next);
|
||||
return;
|
||||
}
|
||||
@@ -4277,6 +4279,26 @@ export function createServeApp(
|
||||
});
|
||||
app.locals['localControlService'] = localControlService;
|
||||
|
||||
const remoteAccessService = new RemoteAccessService({
|
||||
app,
|
||||
credentials,
|
||||
getPort,
|
||||
attachWebSocket: (server) => acpHandleRef.current?.attachServer(server),
|
||||
detachWebSocket: (server) => acpHandleRef.current?.detachServer(server),
|
||||
tlsPaths:
|
||||
opts.tlsCert && opts.tlsKey
|
||||
? { cert: opts.tlsCert, key: opts.tlsKey }
|
||||
: undefined,
|
||||
});
|
||||
app.locals['remoteAccessService'] = remoteAccessService;
|
||||
registerRemoteAccessRoutes(app, {
|
||||
service: remoteAccessService,
|
||||
primaryHostname: opts.hostname,
|
||||
trustedLoopbackMode,
|
||||
mutate,
|
||||
isDraining: deps.isChannelControlDraining,
|
||||
});
|
||||
|
||||
registerWorkspaceLocalControlRoutes(app, {
|
||||
service: localControlService,
|
||||
daemonLog,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getTranslator, type WebShellLanguage } from '../i18n';
|
||||
|
||||
export function DaemonConnectionHelp({
|
||||
language,
|
||||
expanded = false,
|
||||
}: {
|
||||
language: WebShellLanguage;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
const t = getTranslator(language);
|
||||
const pageOrigin = window.location.origin;
|
||||
|
||||
return (
|
||||
<details
|
||||
open={expanded || undefined}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{t('daemon.connection.setup')}
|
||||
</summary>
|
||||
<div className="mt-2 flex min-w-0 flex-col gap-2">
|
||||
<p>
|
||||
{t('daemon.connection.pageOrigin')}:{' '}
|
||||
<code className="break-all">{pageOrigin}</code>
|
||||
</p>
|
||||
<p>{t('daemon.connection.setupCommand')}</p>
|
||||
<p>{t('daemon.connection.setupHttps')}</p>
|
||||
<p>{t('daemon.connection.setupOrigins')}</p>
|
||||
<p>{t('daemon.connection.existingService')}</p>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { I18nProvider } from '../i18n';
|
||||
import { RemoteAccessSettingsCard } from './RemoteAccessSettingsCard';
|
||||
|
||||
const request = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../daemon/rest-request.js', () => ({
|
||||
createDaemonRestRequest: () => request,
|
||||
}));
|
||||
vi.mock('../daemon/availability-context.js', () => ({
|
||||
useDaemonAvailability: () => 'available',
|
||||
}));
|
||||
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
||||
useWorkspace: () => ({ baseUrl: 'http://localhost:4173', token: 'operator' }),
|
||||
}));
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
const off = {
|
||||
active: false,
|
||||
canManage: true,
|
||||
externallyConfigured: false,
|
||||
interfaces: [{ address: '11.1.2.3', interfaceName: 'eth0' }],
|
||||
defaultPort: 4173,
|
||||
};
|
||||
const on = {
|
||||
...off,
|
||||
active: true,
|
||||
generation: 'first',
|
||||
url: 'http://11.1.2.3:4173',
|
||||
address: '11.1.2.3',
|
||||
port: 4173,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
const mount = () =>
|
||||
act(async () =>
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<RemoteAccessSettingsCard />
|
||||
</I18nProvider>,
|
||||
),
|
||||
);
|
||||
const toggle = () =>
|
||||
container.querySelector<HTMLButtonElement>('[role="switch"]')!;
|
||||
|
||||
it('stays off until the user enables access and then exposes address and token controls', async () => {
|
||||
request.mockResolvedValueOnce(Response.json(off));
|
||||
await mount();
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
request.mockResolvedValueOnce(Response.json(on));
|
||||
await act(async () => toggle().click());
|
||||
expect(request.mock.calls[1][0]).toBe('/remote-access/enable');
|
||||
expect(JSON.parse(request.mock.calls[1][1].body)).toEqual({
|
||||
address: '11.1.2.3',
|
||||
origins: [],
|
||||
});
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true');
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Connection address"]',
|
||||
)?.value,
|
||||
).toBe(on.url);
|
||||
request.mockResolvedValueOnce(Response.json({ ...on, token: 'secret' }));
|
||||
const reveal = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show token',
|
||||
)!;
|
||||
await act(async () => reveal.click());
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('#remote-access-token')?.value,
|
||||
).toBe('secret');
|
||||
request.mockResolvedValueOnce(Response.json(off));
|
||||
await act(async () => toggle().click());
|
||||
expect(container.querySelector('#remote-access-token')).toBeNull();
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('reconciles a lost enable response rather than reporting a false off state', async () => {
|
||||
request.mockResolvedValueOnce(Response.json(off));
|
||||
await mount();
|
||||
request.mockRejectedValueOnce(new TypeError('connection lost'));
|
||||
request.mockResolvedValueOnce(Response.json(on));
|
||||
await act(async () => toggle().click());
|
||||
expect(request.mock.calls[2][0]).toBe('/remote-access');
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true');
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'connection lost',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a remote client manage access or disguise an external primary as off', async () => {
|
||||
request.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
active: false,
|
||||
canManage: false,
|
||||
externallyConfigured: true,
|
||||
}),
|
||||
);
|
||||
await mount();
|
||||
expect(container.querySelector('[role="switch"]')).toBeNull();
|
||||
expect(container.textContent).toContain('externally configured');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
|
||||
import { useDaemonAvailability } from '../daemon/availability-context.js';
|
||||
import { createDaemonRestRequest } from '../daemon/rest-request.js';
|
||||
import { useI18n } from '../i18n';
|
||||
import { writeClipboardText } from '../utils/clipboard';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Spinner } from './ui/spinner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select';
|
||||
|
||||
interface RemoteAccessStatus {
|
||||
active: boolean;
|
||||
canManage: boolean;
|
||||
externallyConfigured: boolean;
|
||||
defaultPort?: number;
|
||||
interfaces?: Array<{ address: string; interfaceName: string }>;
|
||||
url?: string;
|
||||
address?: string;
|
||||
port?: number;
|
||||
generation?: string;
|
||||
encrypted?: boolean;
|
||||
origins?: string[];
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export function RemoteAccessSettingsCard() {
|
||||
const { baseUrl, token, client } = useWorkspace();
|
||||
const available = useDaemonAvailability() === 'available';
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<RemoteAccessStatus>();
|
||||
const [address, setAddress] = useState('');
|
||||
const [port, setPort] = useState('');
|
||||
const [origins, setOrigins] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const current = useRef<RemoteAccessStatus | undefined>(undefined);
|
||||
const pending = useRef(false);
|
||||
const epoch = useRef(0);
|
||||
const revision = useRef(0);
|
||||
const controller = useRef<AbortController | undefined>(undefined);
|
||||
const unavailable = useRef(t('remoteAccess.unavailable'));
|
||||
unavailable.current = t('remoteAccess.unavailable');
|
||||
|
||||
const accept = (next: RemoteAccessStatus) => {
|
||||
const previous = current.current;
|
||||
if (previous?.generation !== next.generation || !next.active) {
|
||||
setSecret('');
|
||||
setRevealed(false);
|
||||
}
|
||||
if (!previous || previous.generation !== next.generation) {
|
||||
setOrigins((next.origins ?? []).join('\n'));
|
||||
} else if (
|
||||
JSON.stringify(previous.origins) !== JSON.stringify(next.origins)
|
||||
) {
|
||||
setOrigins((draft) =>
|
||||
draft === (previous.origins ?? []).join('\n')
|
||||
? (next.origins ?? []).join('\n')
|
||||
: draft,
|
||||
);
|
||||
}
|
||||
if (next.token) setSecret(next.token);
|
||||
setAddress((selected) => {
|
||||
if (next.active && next.address) return next.address;
|
||||
const choices = next.interfaces ?? [];
|
||||
if (choices.some((entry) => entry.address === selected)) return selected;
|
||||
return choices.length === 1 ? choices[0]!.address : '';
|
||||
});
|
||||
current.current = next;
|
||||
setStatus(next);
|
||||
};
|
||||
|
||||
const request = async (action?: string, body?: object) => {
|
||||
controller.current?.abort();
|
||||
const abort = new AbortController();
|
||||
controller.current = abort;
|
||||
const timer = window.setTimeout(() => abort.abort(), 10_000);
|
||||
try {
|
||||
const response = await createDaemonRestRequest(
|
||||
baseUrl,
|
||||
token,
|
||||
client,
|
||||
)(`/remote-access${action ? `/${action}` : ''}`, {
|
||||
method: action ? 'POST' : 'GET',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (response.status === 404) throw new Error(unavailable.current);
|
||||
const result = (await response.json()) as RemoteAccessStatus & {
|
||||
error?: string;
|
||||
};
|
||||
if (!response.ok)
|
||||
throw new Error(result.error || `HTTP ${response.status}`);
|
||||
if (
|
||||
typeof result.active !== 'boolean' ||
|
||||
typeof result.canManage !== 'boolean'
|
||||
) {
|
||||
throw new Error(unavailable.current);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
const requestRef = useRef(request);
|
||||
requestRef.current = request;
|
||||
const acceptRef = useRef(accept);
|
||||
acceptRef.current = accept;
|
||||
|
||||
useEffect(() => {
|
||||
const generation = ++epoch.current;
|
||||
current.current = undefined;
|
||||
setStatus(undefined);
|
||||
setSecret('');
|
||||
setRevealed(false);
|
||||
setError('');
|
||||
setNotice('');
|
||||
setPort('');
|
||||
setAddress('');
|
||||
setBusy(false);
|
||||
pending.current = false;
|
||||
if (!available) return;
|
||||
let loading = false;
|
||||
const refresh = async () => {
|
||||
if (loading || pending.current || document.hidden) return;
|
||||
loading = true;
|
||||
const observedRevision = revision.current;
|
||||
try {
|
||||
const next = await requestRef.current();
|
||||
if (
|
||||
epoch.current !== generation ||
|
||||
pending.current ||
|
||||
revision.current !== observedRevision
|
||||
)
|
||||
return;
|
||||
acceptRef.current(next);
|
||||
} catch (failure) {
|
||||
if (
|
||||
epoch.current === generation &&
|
||||
!pending.current &&
|
||||
revision.current === observedRevision
|
||||
) {
|
||||
setError(
|
||||
failure instanceof Error ? failure.message : String(failure),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 5000);
|
||||
const onFocus = () => void refresh();
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
epoch.current = generation + 1;
|
||||
controller.current?.abort();
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [available, baseUrl, token, client]);
|
||||
|
||||
const perform = async (action: string, body?: object) => {
|
||||
if (pending.current || !available) return undefined;
|
||||
const generation = epoch.current;
|
||||
pending.current = true;
|
||||
++revision.current;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const next = await request(action, body);
|
||||
if (epoch.current !== generation) return undefined;
|
||||
accept(next);
|
||||
return next;
|
||||
} catch (failure) {
|
||||
if (epoch.current !== generation) return undefined;
|
||||
const message =
|
||||
failure instanceof Error ? failure.message : String(failure);
|
||||
// A lost response does not mean the server rolled the operation back.
|
||||
try {
|
||||
const next = await request();
|
||||
if (epoch.current === generation) accept(next);
|
||||
} catch {
|
||||
/* The next status refresh can reconcile the server state. */
|
||||
}
|
||||
if (epoch.current === generation) setError(message);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (epoch.current === generation) {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (value: string) => {
|
||||
try {
|
||||
await writeClipboardText(value);
|
||||
setNotice(t('remoteAccess.copied'));
|
||||
} catch {
|
||||
setError(t('remoteAccess.copyFailed'));
|
||||
}
|
||||
};
|
||||
const configuredOrigins = () =>
|
||||
origins
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const canManage =
|
||||
available && status?.canManage && !status.externallyConfigured;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-3 rounded-lg border border-border p-4"
|
||||
aria-labelledby="remote-access-title"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label id="remote-access-title" htmlFor="remote-access-toggle">
|
||||
{t('remoteAccess.title')}
|
||||
</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('remoteAccess.description')}
|
||||
</p>
|
||||
</div>
|
||||
{busy && <Spinner />}
|
||||
{!status?.externallyConfigured && (
|
||||
<Switch
|
||||
id="remote-access-toggle"
|
||||
checked={status?.active ?? false}
|
||||
disabled={!canManage || busy || (!status?.active && !address)}
|
||||
onCheckedChange={(enabled) =>
|
||||
void perform(
|
||||
enabled ? 'enable' : 'disable',
|
||||
enabled
|
||||
? {
|
||||
address,
|
||||
port: port.trim() ? Number(port) : undefined,
|
||||
origins: configuredOrigins(),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="break-all text-sm text-muted-foreground">
|
||||
{t('remoteAccess.server')}: {baseUrl}
|
||||
</p>
|
||||
<p className="text-sm" role="status">
|
||||
{!available
|
||||
? t('daemon.connection.unavailable')
|
||||
: !status
|
||||
? t('remoteAccess.loading')
|
||||
: status.externallyConfigured
|
||||
? t('remoteAccess.external')
|
||||
: status.active
|
||||
? t('remoteAccess.on')
|
||||
: t('remoteAccess.off')}
|
||||
</p>
|
||||
{status && !status.canManage && !status.externallyConfigured && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('remoteAccess.localOnly')}
|
||||
</p>
|
||||
)}
|
||||
{canManage &&
|
||||
!status?.active &&
|
||||
(status?.interfaces?.length ?? 0) !== 1 && (
|
||||
<Select value={address} onValueChange={setAddress} disabled={busy}>
|
||||
<SelectTrigger aria-label={t('remoteAccess.network')}>
|
||||
<SelectValue placeholder={t('remoteAccess.network')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{status?.interfaces?.map((entry) => (
|
||||
<SelectItem
|
||||
key={`${entry.interfaceName}:${entry.address}`}
|
||||
value={entry.address}
|
||||
>
|
||||
{entry.interfaceName} · {entry.address}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{canManage && !status?.active && status?.interfaces?.length === 0 && (
|
||||
<p className="text-sm">{t('remoteAccess.noNetwork')}</p>
|
||||
)}
|
||||
{canManage && !status?.active && address && (
|
||||
<p className="text-sm">
|
||||
{t('remoteAccess.network')}: {address}
|
||||
</p>
|
||||
)}
|
||||
{canManage && status?.active && status.url && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t('remoteAccess.address')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={status.url}
|
||||
aria-label={t('remoteAccess.address')}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => void copy(status.url!)}>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
<Label htmlFor="remote-access-token">Bearer token</Label>
|
||||
<Input
|
||||
id="remote-access-token"
|
||||
readOnly
|
||||
autoComplete="off"
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={secret || '••••••••••••'}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (revealed) {
|
||||
setRevealed(false);
|
||||
return;
|
||||
}
|
||||
if (secret || (await perform('reveal'))) setRevealed(true);
|
||||
}}
|
||||
>
|
||||
{t(revealed ? 'remoteAccess.hide' : 'remoteAccess.reveal')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const value = secret || (await perform('reveal'))?.token;
|
||||
if (value) await copy(value);
|
||||
}}
|
||||
>
|
||||
{t('remoteAccess.copyToken')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setRevealed(false);
|
||||
void perform('reset-token');
|
||||
}}
|
||||
>
|
||||
{t('remoteAccess.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('remoteAccess.resetHint')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
status.encrypted ? 'remoteAccess.encrypted' : 'remoteAccess.http',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{canManage && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t('remoteAccess.advanced')}
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{!status?.active && (
|
||||
<>
|
||||
<Label htmlFor="remote-access-port">
|
||||
{t('remoteAccess.port')}
|
||||
</Label>
|
||||
<Input
|
||||
id="remote-access-port"
|
||||
inputMode="numeric"
|
||||
type="number"
|
||||
min={0}
|
||||
max={65535}
|
||||
disabled={busy}
|
||||
placeholder={String(status?.defaultPort ?? '')}
|
||||
value={port}
|
||||
onChange={(event) => setPort(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Label htmlFor="remote-access-origins">
|
||||
{t('remoteAccess.origins')}
|
||||
</Label>
|
||||
<textarea
|
||||
id="remote-access-origins"
|
||||
className="min-h-20 rounded-md border border-input bg-transparent p-2 text-sm"
|
||||
disabled={busy}
|
||||
value={origins}
|
||||
onChange={(event) => setOrigins(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('remoteAccess.originsHint')}
|
||||
</p>
|
||||
{status?.active && (
|
||||
<Button
|
||||
className="w-fit"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void perform('origins', { origins: configuredOrigins() })
|
||||
}
|
||||
>
|
||||
{t('remoteAccess.apply')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="text-sm" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -113,7 +113,7 @@ it('keeps the rescan copy when an empty submit is rejected after a failed pairin
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain('Scan a fresh QR code');
|
||||
expect(container.textContent).not.toContain(
|
||||
'Enter the bearer token from the daemon terminal.',
|
||||
'Enter the bearer token from the server’s remote connection settings or startup output.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -487,7 +487,8 @@ it('distinguishes policy rejection from authentication failure', async () => {
|
||||
vi.fn().mockResolvedValue(stubResponse({ status: 403 })),
|
||||
);
|
||||
await mount();
|
||||
expect(container.textContent).toContain('Origin or Host policy');
|
||||
expect(container.textContent).toContain('HTTP 403');
|
||||
expect(container.textContent).toContain('Host policy');
|
||||
expect(addressInput().value).toBe('http://daemon.test');
|
||||
});
|
||||
it('keeps tokenless loopback access working', async () => {
|
||||
@@ -980,7 +981,7 @@ it('asks a first-visit operator to enter the token, not correct an invalid one',
|
||||
);
|
||||
await mount();
|
||||
expect(container.textContent).toContain(
|
||||
'Enter the bearer token from the daemon terminal.',
|
||||
'Enter the bearer token from the server’s remote connection settings or startup output.',
|
||||
);
|
||||
expect(container.textContent).not.toContain('Invalid or expired');
|
||||
expect(container.textContent).not.toContain('Connected ');
|
||||
@@ -1088,12 +1089,50 @@ it('offers cross-origin diagnostics without treating network failures as permane
|
||||
const fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await mount();
|
||||
expect(container.textContent).toContain('--allow-origin');
|
||||
expect(container.textContent).toContain(
|
||||
'advanced remote connection settings',
|
||||
);
|
||||
expect(container.textContent).toContain('network');
|
||||
expect(container.querySelector('details')?.open).toBe(true);
|
||||
expect(container.textContent).toContain(window.location.origin);
|
||||
expect(container.textContent).toContain('Allow remote connections');
|
||||
expect(container.textContent).toContain('cannot distinguish');
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2_000));
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['http://remote.example:4170', false],
|
||||
['http://127.0.0.1:4170', true],
|
||||
])('checks mixed content before probing %s', async (baseUrl, allowed) => {
|
||||
const originalLocation = Object.getOwnPropertyDescriptor(window, 'location')!;
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('https://shell.example/'),
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(stubResponse({ status: 401 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<StandaloneAuth baseUrl={baseUrl} initialToken="secret">
|
||||
{() => <p>Connected</p>}
|
||||
</StandaloneAuth>,
|
||||
),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(allowed ? 1 : 0);
|
||||
if (!allowed) {
|
||||
expect(container.textContent).toContain('HTTPS page cannot connect');
|
||||
expect(container.textContent).not.toContain('Connected');
|
||||
expect(getDaemonToken(baseUrl)).not.toBe('secret');
|
||||
await act(submitForm);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(window, 'location', originalLocation);
|
||||
}
|
||||
});
|
||||
|
||||
it('remembers only the selected daemon in the current tab across reload checks', async () => {
|
||||
sessionStorage.clear();
|
||||
expect(isKnownDaemonTarget('https://remote.example')).toBe(false);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
confirmDaemonTarget,
|
||||
getAllowedDaemonOrigin,
|
||||
getDaemonToken,
|
||||
isDaemonMixedContent,
|
||||
navigateToDaemon,
|
||||
persistDaemonToken,
|
||||
} from '../config/daemon';
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Spinner } from './ui/spinner';
|
||||
import { DaemonConnectionHelp } from './DaemonConnectionHelp';
|
||||
|
||||
// A probe must give up well before the SDK's own 30s fetch timeout
|
||||
// (DaemonClient DEFAULT_FETCH_TIMEOUT_MS) so the gate keeps retrying while the
|
||||
@@ -63,6 +65,7 @@ interface AuthCopy {
|
||||
enterToken: string;
|
||||
policyBlocked: string;
|
||||
invalidAddress: string;
|
||||
mixedContent: string;
|
||||
addressChanged: string;
|
||||
confirmTarget: string;
|
||||
remoteHint: string;
|
||||
@@ -89,15 +92,18 @@ const EN_COPY: AuthCopy = {
|
||||
starting: 'Daemon is starting…',
|
||||
unreachable: 'Cannot reach the daemon. Retrying…',
|
||||
remoteUnreachable:
|
||||
'Cannot reach the daemon. Retrying… Check the address, network, HTTPS certificate, and --allow-origin for this page origin.',
|
||||
'Cannot reach the daemon. Retrying… The browser cannot distinguish a network, certificate, or cross-origin failure. See remote connection setup below.',
|
||||
notReady: 'Daemon is not ready. Retrying…',
|
||||
startFailed: 'Daemon failed to start.',
|
||||
invalidToken:
|
||||
'Invalid or expired token. Enter the token from the daemon terminal.',
|
||||
enterToken: 'Enter the bearer token from the daemon terminal.',
|
||||
'Invalid or expired token. Copy the current token from the server’s remote connection settings or startup output.',
|
||||
enterToken:
|
||||
'Enter the bearer token from the server’s remote connection settings or startup output.',
|
||||
policyBlocked:
|
||||
'Access blocked by the daemon Origin or Host policy. Open its direct address, or check --allow-origin for cross-origin access.',
|
||||
'The server or proxy refused the request (HTTP 403). Check its access rules, Host policy, and allowed page origins in remote connection settings.',
|
||||
invalidAddress: 'Invalid daemon address. Enter an HTTP or HTTPS origin.',
|
||||
mixedContent:
|
||||
'This HTTPS page cannot connect to a remote HTTP daemon. Use an HTTPS address for the remote service.',
|
||||
addressChanged:
|
||||
'Connection paused. Select Connect to use the entered address.',
|
||||
confirmTarget:
|
||||
@@ -126,14 +132,17 @@ const COPY: Record<WebShellLanguage, AuthCopy> = {
|
||||
starting: '守护进程正在启动…',
|
||||
unreachable: '无法访问守护进程,正在重试…',
|
||||
remoteUnreachable:
|
||||
'无法访问守护进程,正在重试… 请检查地址、网络、HTTPS 证书,以及 --allow-origin 是否允许当前页面来源。',
|
||||
'无法连接守护进程,正在重试… 浏览器无法区分网络、证书或跨域失败,请查看下方的远程连接配置说明。',
|
||||
notReady: '守护进程尚未就绪,正在重试…',
|
||||
startFailed: '守护进程启动失败。',
|
||||
invalidToken: '令牌无效或已过期,请输入守护进程终端中显示的令牌。',
|
||||
enterToken: '请输入守护进程终端中显示的 bearer token。',
|
||||
invalidToken:
|
||||
'令牌无效或已过期,请从服务器远程连接设置或启动输出复制当前 token。',
|
||||
enterToken: '请输入服务器远程连接设置或启动输出中的 bearer token。',
|
||||
policyBlocked:
|
||||
'访问被守护进程的 Origin 或 Host 策略拦截。请直接打开守护进程地址,或检查 --allow-origin 以允许跨域访问。',
|
||||
'服务或代理拒绝了请求(HTTP 403),请检查访问规则、Host 策略,以及远程连接设置是否允许当前页面来源。',
|
||||
invalidAddress: 'Daemon 地址无效。请输入 HTTP 或 HTTPS origin。',
|
||||
mixedContent:
|
||||
'当前 HTTPS 页面无法连接远端 HTTP 服务,请使用远端服务的 HTTPS 地址。',
|
||||
addressChanged: '连接已暂停。点击“连接”以使用填写的地址。',
|
||||
confirmTarget:
|
||||
'此页面指向一个本浏览器从未连接过的守护进程。仅在你信任它时再连接。',
|
||||
@@ -265,6 +274,12 @@ export function StandaloneAuth({
|
||||
|
||||
const connect = useCallback(
|
||||
async (candidate: string) => {
|
||||
if (isDaemonMixedContent(baseUrl)) {
|
||||
setBusy(false);
|
||||
setNeedsToken(false);
|
||||
setStatus(copyRef.current.mixedContent);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
if (operatorProbeRef.current) {
|
||||
@@ -480,6 +495,12 @@ export function StandaloneAuth({
|
||||
setStatus(copy.invalidAddress);
|
||||
return;
|
||||
}
|
||||
if (isDaemonMixedContent(normalizedAddress)) {
|
||||
retireProbe();
|
||||
setBusy(false);
|
||||
setStatus(copy.mixedContent);
|
||||
return;
|
||||
}
|
||||
if (changingTarget) {
|
||||
const candidate =
|
||||
token.trim() || getDaemonToken(normalizedAddress);
|
||||
@@ -555,6 +576,17 @@ export function StandaloneAuth({
|
||||
: copy.retry}
|
||||
</Button>
|
||||
</form>
|
||||
{normalizedAddress &&
|
||||
normalizedAddress !== window.location.origin && (
|
||||
<DaemonConnectionHelp
|
||||
language={language}
|
||||
expanded={
|
||||
status === copy.remoteUnreachable ||
|
||||
status === copy.policyBlocked ||
|
||||
status === copy.mixedContent
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(remoteWorkspaceAddActive ||
|
||||
remoteConnectionAddActive ||
|
||||
invalidTarget ||
|
||||
|
||||
@@ -221,6 +221,10 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../RemoteAccessSettingsCard', () => ({
|
||||
RemoteAccessSettingsCard: () => null,
|
||||
}));
|
||||
|
||||
const { DaemonConnectionsSettings, DaemonStatusDialog } = await import(
|
||||
'./DaemonStatusDialog'
|
||||
);
|
||||
@@ -341,6 +345,47 @@ describe('DaemonStatusDialog', () => {
|
||||
expect(fullReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a mixed-content target before passing its token to navigation', () => {
|
||||
const originalLocation = Object.getOwnPropertyDescriptor(
|
||||
window,
|
||||
'location',
|
||||
)!;
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('https://shell.example/'),
|
||||
});
|
||||
const onAddConnection = vi.fn();
|
||||
try {
|
||||
mountConnections(onAddConnection);
|
||||
const address = container!.querySelector<HTMLInputElement>(
|
||||
'#daemon-connection-address',
|
||||
)!;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!.call(address, 'http://remote.example:4173');
|
||||
address.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const token = typeToken('secret');
|
||||
act(() => {
|
||||
token
|
||||
.closest('form')!
|
||||
.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(onAddConnection).not.toHaveBeenCalled();
|
||||
expect(container!.querySelector('[role="alert"]')!.textContent).toContain(
|
||||
'HTTPS page cannot connect',
|
||||
);
|
||||
expect(container!.textContent).toContain('Allow remote connections');
|
||||
expect(window.localStorage.getItem('qwen-remote-connections')).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(window, 'location', originalLocation);
|
||||
}
|
||||
});
|
||||
|
||||
it('adds a new remote origin without replacing the current target', () => {
|
||||
const onAddConnection = vi.fn(() => true);
|
||||
mountConnections(onAddConnection);
|
||||
@@ -581,6 +626,7 @@ describe('DaemonStatusDialog', () => {
|
||||
// a failed probe keeps the old token instead of navigating.
|
||||
it.each([
|
||||
[401, 'rejected'],
|
||||
[403, 'HTTP 403'],
|
||||
[503, 'did not accept'],
|
||||
])(
|
||||
'keeps the stored credential when a same-target probe returns %i',
|
||||
@@ -628,7 +674,7 @@ describe('DaemonStatusDialog', () => {
|
||||
expect(onChangeTarget).not.toHaveBeenCalled();
|
||||
expect(getDaemonToken('http://localhost:4170')).toBe('working-token');
|
||||
expect(container!.querySelector('[role="alert"]')!.textContent).toContain(
|
||||
'did not accept',
|
||||
'browser could not reach',
|
||||
);
|
||||
} finally {
|
||||
persistDaemonToken('', 'http://localhost:4170');
|
||||
@@ -665,7 +711,7 @@ describe('DaemonStatusDialog', () => {
|
||||
expect(onChangeTarget).not.toHaveBeenCalled();
|
||||
expect(getDaemonToken('http://localhost:4170')).toBe('working-token');
|
||||
expect(container!.querySelector('[role="alert"]')!.textContent).toContain(
|
||||
'did not accept',
|
||||
'browser could not reach',
|
||||
);
|
||||
} finally {
|
||||
persistDaemonToken('', 'http://localhost:4170');
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
getAllowedDaemonOrigin,
|
||||
getDaemonToken,
|
||||
isDaemonMixedContent,
|
||||
navigateToDaemon,
|
||||
} from '../../config/daemon';
|
||||
import {
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
} from '../../config/remote-connections';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
import { DaemonConnectionHelp } from '../DaemonConnectionHelp';
|
||||
import { RemoteAccessSettingsCard } from '../RemoteAccessSettingsCard';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
@@ -640,7 +643,7 @@ function DaemonStatusDialogInner({
|
||||
onAddConnection?: (daemonOrigin: string, token?: string) => boolean | void;
|
||||
connectionsOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
const workspace = useWorkspace();
|
||||
// Switching targets navigates the page, which only the standalone shell
|
||||
// owns; embedders keep a read-only view of the connection.
|
||||
@@ -830,6 +833,11 @@ function DaemonStatusDialogInner({
|
||||
setConnectionError(t('daemon.connection.invalid'));
|
||||
return;
|
||||
}
|
||||
if (isDaemonMixedContent(daemonOrigin)) {
|
||||
retireConnectionProbe();
|
||||
setConnectionError(t('daemon.connection.mixedContent'));
|
||||
return;
|
||||
}
|
||||
const token =
|
||||
connectionToken.trim() || getDaemonToken(daemonOrigin);
|
||||
// A changed target keeps the write-then-navigate switch
|
||||
@@ -876,7 +884,9 @@ function DaemonStatusDialogInner({
|
||||
setConnectionError(
|
||||
response.status === 401
|
||||
? t('daemon.connection.authFailed')
|
||||
: t('daemon.connection.notReady'),
|
||||
: response.status === 403
|
||||
? t('daemon.connection.policyBlocked')
|
||||
: t('daemon.connection.notReady'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -897,13 +907,9 @@ function DaemonStatusDialogInner({
|
||||
})
|
||||
.catch(() => {
|
||||
if (!owned()) return;
|
||||
// A rejection means no answer arrived — including this
|
||||
// handler's own 10 s abort, so "the daemon did not
|
||||
// answer" is conclusive rather than inconclusive. Report
|
||||
// it and leave the stored credential alone: navigating
|
||||
// here would overwrite a working token with one nothing
|
||||
// accepted and read as a successful switch.
|
||||
setConnectionError(t('daemon.connection.notReady'));
|
||||
// Fetch hides CORS failures behind the same error as network
|
||||
// failures. Preserve the credential and offer both checks.
|
||||
setConnectionError(t('daemon.connection.unreachable'));
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(timeout);
|
||||
@@ -941,6 +947,7 @@ function DaemonStatusDialogInner({
|
||||
retireConnectionProbe();
|
||||
setConnectionAddress(event.target.value);
|
||||
setConnectionToken('');
|
||||
setConnectionError('');
|
||||
}}
|
||||
/>
|
||||
{connectionError && (
|
||||
@@ -984,13 +991,22 @@ function DaemonStatusDialogInner({
|
||||
: 'daemon.connection.connect',
|
||||
)}
|
||||
</Button>
|
||||
<DaemonConnectionHelp
|
||||
language={language}
|
||||
expanded={Boolean(connectionError)}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (connectionsOnly) {
|
||||
return <div className={styles.dialog}>{connectionCard}</div>;
|
||||
return (
|
||||
<div className={styles.dialog}>
|
||||
<RemoteAccessSettingsCard />
|
||||
{connectionCard}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ReactElement } from 'react';
|
||||
import { act, type ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { DaemonAvailability } from '../../daemon/availability.js';
|
||||
import { DaemonAvailabilityContext } from '../../daemon/availability-context.js';
|
||||
import { cleanupReact, flushReact, mountReact } from '../../test/reactHarness';
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -148,6 +150,42 @@ describe('WorkspaceHostHeading / OtherHostProjects', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the focused host badge after disconnect, rejected credentials, and recovery', async () => {
|
||||
state.baseUrl = 'http://remote.example:4173';
|
||||
let status = 200;
|
||||
const availability = new DaemonAvailability(
|
||||
state.baseUrl,
|
||||
undefined,
|
||||
async (url) =>
|
||||
Response.json(
|
||||
String(url).endsWith('/health') ? { status: 'ok' } : { features: [] },
|
||||
{ status },
|
||||
),
|
||||
);
|
||||
const heading = renderWithHosts(
|
||||
<DaemonAvailabilityContext.Provider value={availability}>
|
||||
<WorkspaceHostHeading />
|
||||
</DaemonAvailabilityContext.Provider>,
|
||||
);
|
||||
const badge = () =>
|
||||
heading.querySelector('[data-testid="host-focused-badge"]');
|
||||
try {
|
||||
expect(badge()?.textContent).toBe('connected');
|
||||
for (const [code, label] of [
|
||||
[503, 'offline'],
|
||||
[401, 'unauthorized'],
|
||||
[200, 'connected'],
|
||||
] as const) {
|
||||
status = code;
|
||||
await act(async () => availability.check(true));
|
||||
expect(badge()?.textContent).toBe(label);
|
||||
expect(heading.textContent).toContain('remote.example:4173');
|
||||
}
|
||||
} finally {
|
||||
availability.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders live workspaces and sessions from every online host', async () => {
|
||||
const onOpenHostSession = vi.fn();
|
||||
rememberWorkspaceHost('https://host-a.example', [
|
||||
|
||||
@@ -27,6 +27,7 @@ import { WorkspaceSection } from '../sidebar/WorkspaceSection';
|
||||
import sectionStyles from '../sidebar/WorkspaceSection.module.css';
|
||||
import sidebarStyles from '../sidebar/WebShellSidebar.module.css';
|
||||
import { HostLabel } from './HostLabel';
|
||||
import { useDaemonAvailability } from '../../daemon/availability-context.js';
|
||||
|
||||
const GROUP_LABEL_CLASS =
|
||||
'flex w-full min-w-0 items-center gap-1.5 px-2 pb-1 pt-3 text-left text-[11px] font-medium text-muted-foreground';
|
||||
@@ -69,6 +70,19 @@ function CurrentHostHeading() {
|
||||
const { t } = useI18n();
|
||||
const origin = useCurrentHostOrigin();
|
||||
const others = useOtherOrigins(origin);
|
||||
const availability = useDaemonAvailability();
|
||||
const statusLabel =
|
||||
availability === 'unavailable'
|
||||
? t('workspaceHost.statusOffline')
|
||||
: availability === 'auth-required'
|
||||
? t('workspaceHost.statusUnauthorized')
|
||||
: t('workspaceHost.focused');
|
||||
const statusHint =
|
||||
availability === 'unavailable'
|
||||
? t('workspaceHost.offlineHint')
|
||||
: availability === 'auth-required'
|
||||
? t('workspaceHost.unauthorizedHint')
|
||||
: t('workspaceHost.focusedHint');
|
||||
if (others.length === 0) return null;
|
||||
return (
|
||||
<div className={GROUP_LABEL_CLASS} title={origin}>
|
||||
@@ -76,9 +90,9 @@ function CurrentHostHeading() {
|
||||
<span
|
||||
className={sectionStyles.badge}
|
||||
data-testid="host-focused-badge"
|
||||
title={t('workspaceHost.focusedHint')}
|
||||
title={statusHint}
|
||||
>
|
||||
{t('workspaceHost.focused')}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -316,6 +316,18 @@ export function getAllowedDaemonOrigin(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function isDaemonMixedContent(daemonOrigin: string): boolean {
|
||||
if (typeof window === 'undefined' || window.location.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
const target = new URL(daemonOrigin || window.location.origin);
|
||||
return (
|
||||
target.protocol === 'http:' &&
|
||||
!isLoopbackHostname(target.hostname) &&
|
||||
!target.hostname.endsWith('.localhost')
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDaemonConnectionUrl(
|
||||
raw: string,
|
||||
currentHref: string,
|
||||
|
||||
@@ -109,6 +109,44 @@ function ruPlural(
|
||||
}
|
||||
|
||||
const EN: Messages = {
|
||||
'remoteAccess.title': 'Allow remote connections',
|
||||
'remoteAccess.description':
|
||||
'Enable access to this server without restarting it. Local sessions and tasks keep running.',
|
||||
'remoteAccess.server': 'Current server',
|
||||
'remoteAccess.loading': 'Reading remote access status…',
|
||||
'remoteAccess.on': 'Remote access is enabled.',
|
||||
'remoteAccess.off':
|
||||
'Remote access is off. Enable it here when needed; it defaults to off after a server restart.',
|
||||
'remoteAccess.external':
|
||||
'The primary listener is externally configured. This switch cannot close access opened by startup or proxy configuration.',
|
||||
'remoteAccess.localOnly':
|
||||
'Open this server’s settings through its authorized local connection to manage remote access.',
|
||||
'remoteAccess.unavailable':
|
||||
'This daemon does not support remote access settings.',
|
||||
'remoteAccess.network': 'Network interface',
|
||||
'remoteAccess.noNetwork':
|
||||
'No external IPv4 interface is available on this server.',
|
||||
'remoteAccess.address': 'Connection address',
|
||||
'remoteAccess.reveal': 'Show token',
|
||||
'remoteAccess.hide': 'Hide token',
|
||||
'remoteAccess.copyToken': 'Copy token',
|
||||
'remoteAccess.reset': 'Reset token',
|
||||
'remoteAccess.resetHint':
|
||||
'Turning access off or resetting the token disconnects remote clients. Running server tasks continue.',
|
||||
'remoteAccess.http':
|
||||
'HTTP is unencrypted. Use this address on a trusted network; HTTPS pages require an HTTPS endpoint.',
|
||||
'remoteAccess.encrypted':
|
||||
'This endpoint uses HTTPS. The client must trust its certificate.',
|
||||
'remoteAccess.advanced': 'Advanced connection settings',
|
||||
'remoteAccess.port': 'Port (blank uses the current server port)',
|
||||
'remoteAccess.origins': 'Additional page origins (one per line)',
|
||||
'remoteAccess.originsHint':
|
||||
'HTTP pages on localhost and 127.0.0.1 at any port are already allowed. Applying origins reconnects remote clients.',
|
||||
'remoteAccess.apply': 'Apply origins',
|
||||
'remoteAccess.copied': 'Copied.',
|
||||
'remoteAccess.copyFailed':
|
||||
'Could not copy. Reveal and select the value to copy it manually.',
|
||||
|
||||
'capacityChoice.persistenceUnconfirmed':
|
||||
'Saving the last interrupted turn could not be confirmed.',
|
||||
'capacityChoice.title': 'Choose a workspace to stop',
|
||||
@@ -1435,6 +1473,22 @@ const EN: Messages = {
|
||||
'The daemon did not accept the connection; the stored credential was left unchanged.',
|
||||
'daemon.connection.authFailed':
|
||||
'The daemon rejected that token; the stored credential was left unchanged.',
|
||||
'daemon.connection.unreachable':
|
||||
'The browser could not reach the daemon. A network, certificate, or cross-origin failure may be responsible. Check the remote connection setup below; your saved token has not changed.',
|
||||
'daemon.connection.policyBlocked':
|
||||
'The server or proxy refused the request (HTTP 403). Check its access rules, Host policy, and allowed page origins.',
|
||||
'daemon.connection.mixedContent':
|
||||
'This HTTPS page cannot connect to a remote HTTP daemon. Use an HTTPS address for the remote service.',
|
||||
'daemon.connection.setup': 'Remote connection setup',
|
||||
'daemon.connection.pageOrigin': "This page's origin",
|
||||
'daemon.connection.setupCommand':
|
||||
'On the server, open Settings → Connections and turn on Allow remote connections. Then enter the displayed address and bearer token here.',
|
||||
'daemon.connection.setupHttps':
|
||||
'An HTTPS address requires a trusted TLS certificate or an HTTPS proxy. Use HTTPS on untrusted networks.',
|
||||
'daemon.connection.setupOrigins':
|
||||
'For an HTTPS page or a custom page hostname, add the page origin shown above in the server’s advanced remote connection settings.',
|
||||
'daemon.connection.existingService':
|
||||
'The switch takes effect without restarting the server. Existing listeners or proxies configured separately remain independently managed.',
|
||||
'daemon.connection.reloadUnavailable':
|
||||
'Browser storage is unavailable, so the new token could not be applied to this connection.',
|
||||
'daemon.connection.switchUnavailable':
|
||||
@@ -4752,6 +4806,39 @@ const EN: Messages = {
|
||||
|
||||
const ZH: Messages = {
|
||||
...EN,
|
||||
'remoteAccess.title': '允许远程连接',
|
||||
'remoteAccess.description':
|
||||
'无需重启即可开放当前服务器,本地会话和任务继续运行。',
|
||||
'remoteAccess.server': '当前服务器',
|
||||
'remoteAccess.loading': '正在读取远程访问状态…',
|
||||
'remoteAccess.on': '远程访问已开启。',
|
||||
'remoteAccess.off': '远程访问已关闭。需要时在此开启;服务重启后默认关闭。',
|
||||
'remoteAccess.external':
|
||||
'主入口由外部配置开放;此开关无法关闭启动参数或代理配置开放的访问。',
|
||||
'remoteAccess.localOnly':
|
||||
'请通过该服务器已授权的本地连接打开设置,管理远程访问。',
|
||||
'remoteAccess.unavailable': '此 daemon 不支持远程访问设置。',
|
||||
'remoteAccess.network': '网络接口',
|
||||
'remoteAccess.noNetwork': '当前服务器没有可用的外部 IPv4 网络接口。',
|
||||
'remoteAccess.address': '连接地址',
|
||||
'remoteAccess.reveal': '查看 token',
|
||||
'remoteAccess.hide': '隐藏 token',
|
||||
'remoteAccess.copyToken': '复制 token',
|
||||
'remoteAccess.reset': '重置 token',
|
||||
'remoteAccess.resetHint':
|
||||
'关闭访问或重置 token 会断开远程客户端,服务器上运行的任务继续。',
|
||||
'remoteAccess.http':
|
||||
'HTTP 未加密,请在可信网络使用;HTTPS 页面需要连接 HTTPS 入口。',
|
||||
'remoteAccess.encrypted': '该入口使用 HTTPS,客户端需信任其证书。',
|
||||
'remoteAccess.advanced': '高级连接设置',
|
||||
'remoteAccess.port': '端口(留空沿用当前服务器端口)',
|
||||
'remoteAccess.origins': '额外页面来源(每行一个)',
|
||||
'remoteAccess.originsHint':
|
||||
'已允许 localhost 和 127.0.0.1 任意端口的 HTTP 页面。应用来源设置会断开远程客户端以便重新连接。',
|
||||
'remoteAccess.apply': '应用来源',
|
||||
'remoteAccess.copied': '已复制。',
|
||||
'remoteAccess.copyFailed': '复制失败,请显示并选中内容后手动复制。',
|
||||
|
||||
'capacityChoice.persistenceUnconfirmed':
|
||||
'无法确认最后一轮被中断内容是否已保存。',
|
||||
'capacityChoice.title': '选择要停止的工作区',
|
||||
@@ -6054,6 +6141,22 @@ const ZH: Messages = {
|
||||
'daemon.connection.notReady': 'Daemon 未接受该连接,已保存的凭据未被修改。',
|
||||
'daemon.connection.authFailed':
|
||||
'Daemon 拒绝了该 token,已保存的凭据未被修改。',
|
||||
'daemon.connection.unreachable':
|
||||
'浏览器无法连接守护进程,可能是网络、证书或跨域问题。请查看下方的远程连接配置说明,已保存的 token 未被修改。',
|
||||
'daemon.connection.policyBlocked':
|
||||
'服务或代理拒绝了请求(HTTP 403),请检查访问规则、Host 策略和允许的页面来源。',
|
||||
'daemon.connection.mixedContent':
|
||||
'当前 HTTPS 页面无法连接远端 HTTP 服务,请使用远端服务的 HTTPS 地址。',
|
||||
'daemon.connection.setup': '远程连接如何配置',
|
||||
'daemon.connection.pageOrigin': '当前页面来源',
|
||||
'daemon.connection.setupCommand':
|
||||
'在服务器的「设置 → 连接」开启「允许远程连接」,然后在此填写显示的地址和 bearer token。',
|
||||
'daemon.connection.setupHttps':
|
||||
'HTTPS 地址需要可信的 TLS 证书或 HTTPS 代理;不可信网络上请使用 HTTPS。',
|
||||
'daemon.connection.setupOrigins':
|
||||
'HTTPS 页面或自定义页面域名,请在服务器的远程连接高级设置中添加上方显示的页面来源。',
|
||||
'daemon.connection.existingService':
|
||||
'开关无需重启即可生效;已有启动参数或代理开放的入口仍由原配置独立管理。',
|
||||
'daemon.connection.reloadUnavailable':
|
||||
'浏览器存储不可用,因此新 token 无法应用到当前连接。',
|
||||
'daemon.connection.switchUnavailable':
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock('./config/daemon', () => ({
|
||||
getAllowedDaemonOrigin: (value: string) => value,
|
||||
confirmDaemonTarget: vi.fn(),
|
||||
isKnownDaemonTarget: () => false,
|
||||
isDaemonMixedContent: () => false,
|
||||
// No token in the URL, so boot blocks on the postMessage handshake — the
|
||||
// window in which the watchdog's grace period can expire.
|
||||
getDaemonToken: () => testState.storedToken,
|
||||
|
||||
@@ -235,6 +235,7 @@ export default defineConfig(({ command }) => ({
|
||||
// it the SPA fallback answers with index.html and the dialog fails JSON
|
||||
// parsing in dev.
|
||||
'/daemon/status': daemonProxy,
|
||||
'/remote-access': daemonProxy,
|
||||
'/standalone/sessions': daemonProxy,
|
||||
'/session': daemonProxy,
|
||||
'/permission': daemonProxy,
|
||||
|
||||
Reference in new issue
Block a user