diff --git a/docs/design/2026-10-30-live-remote-access.md b/docs/design/2026-10-30-live-remote-access.md new file mode 100644 index 00000000000..d7d3f1dc912 --- /dev/null +++ b/docs/design/2026-10-30-live-remote-access.md @@ -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). diff --git a/docs/design/2026-10-30-live-remote-access.zh-CN.md b/docs/design/2026-10-30-live-remote-access.zh-CN.md new file mode 100644 index 00000000000..927d7fec98d --- /dev/null +++ b/docs/design/2026-10-30-live-remote-access.zh-CN.md @@ -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)。 diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index b6662c1ec1a..31a3ad3f26c 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -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 ``` diff --git a/packages/cli/src/serve/acp-http/connection-registry.test.ts b/packages/cli/src/serve/acp-http/connection-registry.test.ts index e424572071e..bb95ca0a845 100644 --- a/packages/cli/src/serve/acp-http/connection-registry.test.ts +++ b/packages/cli/src/serve/acp-http/connection-registry.test.ts @@ -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[] = []; diff --git a/packages/cli/src/serve/acp-http/connection-registry.ts b/packages/cli/src/serve/acp-http/connection-registry.ts index 433a1c6df87..08df0b9c7b8 100644 --- a/packages/cli/src/serve/acp-http/connection-registry.ts +++ b/packages/cli/src/serve/acp-http/connection-registry.ts @@ -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 diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 287432ff8d3..16ffe9af703 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -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); + } } } diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index 6d08e21ab27..71ec27834d0 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -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(), ); diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 6efd5f2a405..fbb83578fdd 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -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((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'); diff --git a/packages/cli/src/serve/auth.ts b/packages/cli/src/serve/auth.ts index 05b2f529690..1e51f6520cd 100644 --- a/packages/cli/src/serve/auth.ts +++ b/packages/cli/src/serve/auth.ts @@ -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; } diff --git a/packages/cli/src/serve/local-control/credentials.ts b/packages/cli/src/serve/local-control/credentials.ts index 46e18fa9a0d..c5b9e659a0b 100644 --- a/packages/cli/src/serve/local-control/credentials.ts +++ b/packages/cli/src/serve/local-control/credentials.ts @@ -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(); /** 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( diff --git a/packages/cli/src/serve/local-control/listener-identity.ts b/packages/cli/src/serve/local-control/listener-identity.ts index 4143ae27962..ef12067e40e 100644 --- a/packages/cli/src/serve/local-control/listener-identity.ts +++ b/packages/cli/src/serve/local-control/listener-identity.ts @@ -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; } /** diff --git a/packages/cli/src/serve/remote-access.test.ts b/packages/cli/src/serve/remote-access.test.ts new file mode 100644 index 00000000000..0d0fa75f344 --- /dev/null +++ b/packages/cli/src/serve/remote-access.test.ts @@ -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()), + 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((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((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); +}); diff --git a/packages/cli/src/serve/remote-access.ts b/packages/cli/src/serve/remote-access.ts new file mode 100644 index 00000000000..2a20d31ec28 --- /dev/null +++ b/packages/cli/src/serve/remote-access.ts @@ -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; + 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 = 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(); + 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((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((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(operation: () => Promise): Promise { + const next = this.#transition.then(operation); + this.#transition = next.catch(() => {}); + return next; + } +} diff --git a/packages/cli/src/serve/routes/remote-access.ts b/packages/cli/src/serve/routes/remote-access.ts new file mode 100644 index 00000000000..1b326bb5e88 --- /dev/null +++ b/packages/cli/src/serve/routes/remote-access.ts @@ -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) + : {}; + 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.', + }); + } + }, + ); + } +} diff --git a/packages/cli/src/serve/routes/workspace-local-control.test.ts b/packages/cli/src/serve/routes/workspace-local-control.test.ts index 32237568890..e9ba628dc7c 100644 --- a/packages/cli/src/serve/routes/workspace-local-control.test.ts +++ b/packages/cli/src/serve/routes/workspace-local-control.test.ts @@ -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((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((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((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((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(); diff --git a/packages/cli/src/serve/routes/workspace-local-control.ts b/packages/cli/src/serve/routes/workspace-local-control.ts index 4b94ec49496..366e63528b9 100644 --- a/packages/cli/src/serve/routes/workspace-local-control.ts +++ b/packages/cli/src/serve/routes/workspace-local-control.ts @@ -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>, 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; diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 6ef92e9fac4..ef0e5a1a4f9 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -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; diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 24935b6fab3..2d9c3607d8f 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -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, diff --git a/packages/web-shell/client/components/DaemonConnectionHelp.tsx b/packages/web-shell/client/components/DaemonConnectionHelp.tsx new file mode 100644 index 00000000000..5fbcae51b17 --- /dev/null +++ b/packages/web-shell/client/components/DaemonConnectionHelp.tsx @@ -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 ( +
+ + {t('daemon.connection.setup')} + +
+

+ {t('daemon.connection.pageOrigin')}:{' '} + {pageOrigin} +

+

{t('daemon.connection.setupCommand')}

+

{t('daemon.connection.setupHttps')}

+

{t('daemon.connection.setupOrigins')}

+

{t('daemon.connection.existingService')}

+
+
+ ); +} diff --git a/packages/web-shell/client/components/RemoteAccessSettingsCard.test.tsx b/packages/web-shell/client/components/RemoteAccessSettingsCard.test.tsx new file mode 100644 index 00000000000..424e9699687 --- /dev/null +++ b/packages/web-shell/client/components/RemoteAccessSettingsCard.test.tsx @@ -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( + + + , + ), + ); +const toggle = () => + container.querySelector('[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( + '[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('#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); +}); diff --git a/packages/web-shell/client/components/RemoteAccessSettingsCard.tsx b/packages/web-shell/client/components/RemoteAccessSettingsCard.tsx new file mode 100644 index 00000000000..f954ee72f5f --- /dev/null +++ b/packages/web-shell/client/components/RemoteAccessSettingsCard.tsx @@ -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(); + 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(undefined); + const pending = useRef(false); + const epoch = useRef(0); + const revision = useRef(0); + const controller = useRef(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 ( +
+
+
+ +

+ {t('remoteAccess.description')} +

+
+ {busy && } + {!status?.externallyConfigured && ( + + void perform( + enabled ? 'enable' : 'disable', + enabled + ? { + address, + port: port.trim() ? Number(port) : undefined, + origins: configuredOrigins(), + } + : undefined, + ) + } + /> + )} +
+

+ {t('remoteAccess.server')}: {baseUrl} +

+

+ {!available + ? t('daemon.connection.unavailable') + : !status + ? t('remoteAccess.loading') + : status.externallyConfigured + ? t('remoteAccess.external') + : status.active + ? t('remoteAccess.on') + : t('remoteAccess.off')} +

+ {status && !status.canManage && !status.externallyConfigured && ( +

+ {t('remoteAccess.localOnly')} +

+ )} + {canManage && + !status?.active && + (status?.interfaces?.length ?? 0) !== 1 && ( + + )} + {canManage && !status?.active && status?.interfaces?.length === 0 && ( +

{t('remoteAccess.noNetwork')}

+ )} + {canManage && !status?.active && address && ( +

+ {t('remoteAccess.network')}: {address} +

+ )} + {canManage && status?.active && status.url && ( +
+ +
+ + +
+ + +
+ + + +
+

+ {t('remoteAccess.resetHint')} +

+

+ {t( + status.encrypted ? 'remoteAccess.encrypted' : 'remoteAccess.http', + )} +

+
+ )} + {canManage && ( +
+ + {t('remoteAccess.advanced')} + +
+ {!status?.active && ( + <> + + setPort(event.target.value)} + /> + + )} + +