diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index d55b01f6e..a5921c4a1 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -163,6 +163,49 @@ jobs: cache-from: type=gha,scope=build-sandbox-cube cache-to: type=gha,scope=build-sandbox-cube,mode=max + - name: Docker meta (desktop variant) + id: meta-desktop + uses: docker/metadata-action@v6 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-sandbox + flavor: | + suffix=-desktop,onlatest=true + + - name: Build sandbox desktop Image + uses: docker/build-push-action@v7 + with: + push: true + platforms: linux/amd64,linux/arm64 + file: docker/Dockerfile.sandbox + target: desktop + context: . + labels: ${{ steps.meta-desktop.outputs.labels }} + tags: ${{ steps.meta-desktop.outputs.tags }} + cache-from: type=gha,scope=build-sandbox-desktop + cache-to: type=gha,scope=build-sandbox-desktop,mode=max + + - name: Docker meta (desktop Cube variant) + id: meta-desktop-cube + uses: docker/metadata-action@v6 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-sandbox + flavor: | + suffix=-desktop-cube,onlatest=true + + # amd64 only: same cubesandbox-base constraint as the cube target. + - name: Build sandbox desktop Cube Image + uses: docker/build-push-action@v7 + with: + push: true + platforms: linux/amd64 + file: docker/Dockerfile.sandbox + target: desktop-cube + context: . + labels: ${{ steps.meta-desktop-cube.outputs.labels }} + tags: ${{ steps.meta-desktop-cube.outputs.tags }} + cache-from: type=gha,scope=build-sandbox-desktop-cube + cache-to: type=gha,scope=build-sandbox-desktop-cube,mode=max + build-app: strategy: matrix: diff --git a/docker/Dockerfile.sandbox b/docker/Dockerfile.sandbox index c28fc46be..a0c7dba3c 100644 --- a/docker/Dockerfile.sandbox +++ b/docker/Dockerfile.sandbox @@ -2,20 +2,40 @@ # Pre-built environment for executing agent skill scripts in Docker sandbox # Multi-stage build, minimal dependencies # -# Two targets ship from this file: -# runtime (default) - the plain environment. Used by the Docker backend and -# as the base image of E2B templates, whose builder -# injects its own envd. -# cube - the same environment plus Cube's envd daemon. Cube -# builds templates straight from the image and probes -# :49983/health, so an image without envd can only ever -# fail. See docs/sandbox-cluster.md. +# Targets that ship from this file: +# runtime - the plain environment. Used by the Docker backend and +# as the base image of E2B templates, whose builder +# injects its own envd. +# cube - the same environment plus Cube's envd daemon. Cube +# builds templates straight from the image and probes +# :49983/health, so an image without envd can only ever +# fail. See docs/sandbox-cluster.md. +# desktop-runtime - runtime plus XFCE / x11vnc / websockify. Not a published +# tag; the desktop and desktop-cube targets sit on it. +# desktop - desktop-runtime, tagged for E2B (and a future Docker +# desktop path). The Docker backend does not use this yet. +# desktop-cube - desktop-runtime plus Cube envd. amd64 only, same reason +# as cube. +# +# Pin every FROM to TARGETPLATFORM. `docker build --platform` alone is not +# enough: the legacy builder (DOCKER_BUILDKIT=0) on Apple Silicon still +# pulls the host's arm64 python/node, then COPY's amd64 envd — Cube probes +# that mixed image as READY and Exec fails with "exec format error". +# Default amd64 matches cube/desktop-cube; BuildKit overrides this when +# --platform is linux/arm64 (sandbox / desktop). +ARG TARGETPLATFORM=linux/amd64 # Stage 1: Get Node.js binaries -FROM node:20-slim AS node-base +FROM --platform=$TARGETPLATFORM node:20-slim AS node-base + +FROM --platform=$TARGETPLATFORM ghcr.io/astral-sh/uv:0.12.4 AS uv-base # Stage 2: Runtime image -FROM python:3.11-slim AS runtime +# +# 3.12 is the floor because skill sources commonly use PEP 701 nested quotes +# in f-strings (f"x={d["k"]}"). Install-time ast.parse runs this interpreter; +# 3.11 rejects that syntax and the whole skill install fails. +FROM --platform=$TARGETPLATFORM python:3.12-slim AS runtime # Copy Node.js from node image (avoids NodeSource install overhead) COPY --from=node-base /usr/local/bin/node /usr/local/bin/ @@ -26,7 +46,7 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ # Python package manager: uv (fast pip replacement). Preinstalled because each # skill gets its own venv at install time, and building that from a cold pip is # what dominates an install's wall clock. -COPY --from=ghcr.io/astral-sh/uv:0.12.4 /uv /usr/local/bin/uv +COPY --from=uv-base /uv /usr/local/bin/uv RUN ln -s /usr/local/bin/uv /usr/local/bin/uvx # Node package manager: pnpm @@ -87,12 +107,18 @@ USER root # root by default, matching the runtime stage. # # This target is linux/amd64 only: cubesandbox-base publishes no arm64 image, -# which matches Cube itself (PVM is x86_64-only). -FROM ghcr.io/tencentcloud/cubesandbox-base:2026.16 AS cube-base +# which matches Cube itself (PVM is x86_64-only). Hard-pin the FROM so a +# forgotten --platform cannot copy an amd64 envd onto an arm64 runtime. +FROM --platform=linux/amd64 ghcr.io/tencentcloud/cubesandbox-base:2026.16 AS cube-base FROM runtime AS cube +ARG TARGETARCH=amd64 USER root +RUN if [ "$TARGETARCH" != "amd64" ]; then \ + echo "cube is linux/amd64 only (cubesandbox-base has no arm64), got TARGETARCH=$TARGETARCH" >&2; \ + exit 1; \ + fi COPY --from=cube-base /usr/bin/envd /usr/bin/envd COPY --from=cube-base /usr/local/bin/cube-entrypoint.sh /usr/local/bin/cube-entrypoint.sh @@ -101,9 +127,87 @@ EXPOSE 49983 # defines no CMD of its own. ENTRYPOINT ["/usr/local/bin/cube-entrypoint.sh"] -# Stage 4: default target +# Stage 4: desktop environment on top of runtime. +# +# Debian package names (runtime is python:3.12-slim, not Ubuntu). Do not run +# `yes | unminimize` — Debian has no such command. Do not clone noVNC; the +# WeKnora frontend uses @novnc/novnc and the sandbox only needs websockify. +# procps supplies pgrep for the start script; util-linux supplies flock. +FROM runtime AS desktop-runtime + +USER root +# DISPLAY is exported by start-desktop.sh when the desktop actually starts. +# Do not bake it into the image ENV: Cube copies image ENV onto the cubebox +# and a preset DISPLAY=:0 is the only env the working main-cube templates +# do not have. +ENV DEBIAN_FRONTEND=noninteractive +# XFCE apps (mousepad, xfce4-terminal) decode files with the process locale. +# python:slim defaults to POSIX/C, which is why the desktop shows mojibake +# while the WeKnora terminal tab does not: that tab is xterm.js in the +# browser, and Cube/E2B already stamp LANG/LC_ALL on the envd PTY. +# C.UTF-8 is built into glibc; do not pull locales-all. +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + procps \ + util-linux \ + xvfb \ + x11-utils \ + x11-xserver-utils \ + xauth \ + dbus \ + dbus-x11 \ + xfce4 \ + xfce4-goodies \ + x11vnc \ + websockify \ + xdotool \ + scrot \ + fonts-dejavu-core \ + fonts-noto-cjk \ + desktop-base \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* \ + && rm -f /etc/rcS.d/S??procps /etc/rcS.d/S??x11-common \ + /etc/rc2.d/S??dbus /etc/rc3.d/S??dbus \ + /etc/rc4.d/S??dbus /etc/rc5.d/S??dbus + +COPY docker/scripts/start-desktop.sh /usr/local/bin/start-desktop.sh +COPY docker/desktop/xfce4-desktop.xml /etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml +RUN chmod 0755 /usr/local/bin/start-desktop.sh \ + && mkdir -p /root/.config/xfce4/xfconf/xfce-perchannel-xml \ + && cp /etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml \ + /root/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml + +# Stage 5: published desktop image (E2B / future Docker desktop). +FROM desktop-runtime AS desktop +EXPOSE 6080 + +# Stage 6: desktop + Cube envd. linux/amd64 only, same constraint as cube. +FROM desktop-runtime AS desktop-cube + +ARG TARGETARCH=amd64 +USER root +RUN if [ "$TARGETARCH" != "amd64" ]; then \ + echo "desktop-cube is linux/amd64 only (cubesandbox-base has no arm64), got TARGETARCH=$TARGETARCH" >&2; \ + exit 1; \ + fi +COPY --from=cube-base /usr/bin/envd /usr/bin/envd +COPY --from=cube-base /usr/local/bin/cube-entrypoint.sh /usr/local/bin/cube-entrypoint.sh + +# cube-entrypoint.sh redirects envd to /var/log/envd.log. Cube packs this +# ~1.5GiB image into a 1.5GiB ext4 with no spare blocks; mkdir/write of that +# log fails under `set -eu`, envd never binds 49983, and the template probe +# is connection refused. envd as PID 1 (stdio) succeeds on the same rootfs. +# "-" is the entrypoint's documented "inherit container stdio" value. +ENV ENVD_LOG_FILE=- + +EXPOSE 49983 6080 +ENTRYPOINT ["/usr/local/bin/cube-entrypoint.sh"] + +# Stage 7: default target # # Docker builds the last stage when none is named, and that must stay the plain # runtime: a bare `docker build -f docker/Dockerfile.sandbox .` is expected to -# produce the image the Docker backend runs, not the Cube variant. +# produce the image the Docker backend runs, not a Cube or desktop variant. FROM runtime AS sandbox diff --git a/docker/desktop/xfce4-desktop.xml b/docker/desktop/xfce4-desktop.xml new file mode 100644 index 000000000..7e94bbcc4 --- /dev/null +++ b/docker/desktop/xfce4-desktop.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker/scripts/start-desktop.sh b/docker/scripts/start-desktop.sh new file mode 100755 index 000000000..5fd6300c0 --- /dev/null +++ b/docker/scripts/start-desktop.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# Idempotent desktop stack for WeKnora sandbox images. +# Safe to re-run after pause/resume; serialised with flock so concurrent Exec +# (dual tabs, retries, multiple replicas) cannot split the VNC secret. +set -eu +export DISPLAY=:0 +# UTF-8 for every daemon this script starts (XFCE session, xfce4-terminal, +# mousepad). Without it they inherit POSIX/C from python:slim and UTF-8 +# files render as mojibake; the WeKnora terminal tab is unaffected because +# it is drawn in the browser. Prefer an image-provided LANG, fall back here +# so a wrapped older image still starts a UTF-8 session. +export LANG="${LANG:-C.UTF-8}" +export LC_ALL="${LC_ALL:-C.UTF-8}" +mkdir -p /run/desktop + +# Readiness is a LISTEN check read out of /proc, never a connection. Dialling +# 5900 spends an RFB client slot and dialling 6080 parks websockify in +# readline(); a probe must not perturb the stack it measures. (This also +# sidesteps the old nc/dash problems: nc is not in the image and dash has no +# /dev/tcp, and under `set -eu` a 127 exit from `if nc -z ...` does not trip +# set -e, so the script would spin to its timeout with the desktop already up.) +port_open() { + hex=$(printf '%04X' "$1") + cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | awk -v hex="$hex" ' + $4 == "0A" { + n = split($2, addr, ":") + if (toupper(addr[n]) == hex) { found = 1 } + } + END { exit !found } + ' +} + +# pgrep matches zombies. x11vnc -bg leaves one behind whenever PID 1 does not +# reap it (envd does not), and a pgrep-guarded step 7 then skips the restart +# for the life of the sandbox: websockify keeps answering on 6080, DialDesktop +# gets its 101, and the RFB hello never comes. Steps guarded by a listening +# port are immune; this is for the ones with no port of their own. +proc_alive() { + for pid in $(pgrep -x "$1" 2>/dev/null); do + state=$(awk '/^State:/ { print $2; exit }' "/proc/$pid/status" 2>/dev/null || true) + [ "$state" = "Z" ] || return 0 + done + return 1 +} + +# 1. Already ready: both listeners, not just websockify. 6080-only lets +# DialDesktop 101 while x11vnc is dead; the RFB hello never comes. +if port_open 6080 && port_open 5900; then exit 0; fi + +# 2. Serialise the whole start section. Concurrent triggers are normal +# (dual tabs, timeout retries, multi-replica). Every step below is +# check-then-act: without the lock two instances both see "secret missing" +# and each write their own, so websockify uses A while the backend reads B +# — intermittent 401. A Redis connection lock cannot serialise this Exec. +# +# Every daemon started below MUST close fd 9 (`9>&-`). A child that +# inherits it keeps holding the lock after this script exits, and the next +# run that misses the fast path blocks on flock forever — the Exec times +# out at 60s (exit=-1) and 6080 never comes back. +exec 9>/run/desktop/.lock +flock 9 + +# Recheck after waiting for the lock: a previous holder may have finished. +if port_open 6080 && port_open 5900; then exit 0; fi + +# 3. Per-sandbox random secret (first start only). Used by websockify's +# BasicHTTPAuth handshake; never sent to the browser. Write .tmp then +# rename so a reader that sees the file also sees complete contents. +if [ ! -f /run/desktop/secret ]; then + umask 077 + tr -dc 'A-Za-z0-9' /run/desktop/secret.tmp + mv /run/desktop/secret.tmp /run/desktop/secret +fi + +# dbus-uuidgen --ensure: XFCE/dbus fail on a missing /etc/machine-id, which +# slim images often omit. Harmless when the file already exists. +if command -v dbus-uuidgen >/dev/null 2>&1; then + dbus-uuidgen --ensure >/dev/null 2>&1 || true +fi + +# Every daemon below is started through this. Two things must happen or the +# desktop "fails" while actually running: +# - close fd 9, or the daemon keeps holding the flock after this script +# exits and the next run that misses the fast path blocks forever; +# - give it stdio of its own, or it holds the caller's stdout/stderr and +# envd's Exec never returns (it waits for those to close), which surfaces +# as exit=-1 at the 60s timeout. +spawn() { + name=$1 + shift + "$@" >"/run/desktop/$name.log" 2>&1 9>&- & +} + +# 4. Xvfb +proc_alive Xvfb || \ + spawn xvfb Xvfb :0 -ac -screen 0 1280x800x24 -nolisten tcp + +# 4b. Wait until X actually accepts connections. Probe state, do not sleep a +# fixed delay: startxfce4 before X is listening exits with "cannot open +# display", later steps still run, and the failure looks like "6080 is up +# but the screen is black / no window manager". +i=0 +while [ $i -lt 40 ]; do + xdpyinfo -display :0 >/dev/null 2>&1 && break + i=$((i + 1)) + sleep 0.25 +done +xdpyinfo -display :0 >/dev/null 2>&1 || exit 1 + +# 5. dbus + XFCE. Use xfce4-session, not startxfce4: the latter tries to +# launch its own X server and fails with "X server already running" once +# Xvfb is up. The session still starts, but the warning is noisy and the +# extra compositor fight is real (xfwm4: another compositing manager). +proc_alive xfce4-session || { + mkdir -p /run/dbus + dbus-daemon --system --fork >/run/desktop/dbus.log 2>&1 9>&- || true + spawn xfce4-session xfce4-session +} + +# 6. Disable screensaver / DPMS — clock jumps after resume lock or blank the screen. +xset s off -dpms 2>/dev/null || true +xfconf-query -c xfce4-session -p /startup/screensaver/enabled -n -t bool -s false 2>/dev/null || true + +# 7. x11vnc: localhost only; RFB security type None (auth is on websockify). +# Do not pass -ping: it pushes a 1x1 framebuffer update every second and +# would defeat any byte-counting idle detector. +# +# Guard on the port, not on pgrep: a zombie x11vnc is what silently turns +# this step into a no-op. A duplicate only ever loses the bind and exits. +port_open 5900 || \ + x11vnc -display :0 -localhost -rfbport 5900 -nopw \ + -forever -shared -wait 50 -noxdamage -repeat -bg \ + >/run/desktop/x11vnc.log 2>&1 9>&- + +# 8. websockify: the only listener facing the world. Handshake requires +# Authorization: Basic. This is the only gate on Cube's host-port NAT +# path that bypasses CubeProxy. Port-guarded for the same reason as 7. +port_open 6080 || \ + spawn websockify websockify --auth-plugin BasicHTTPAuth \ + --auth-source "weknora:$(cat /run/desktop/secret)" \ + --heartbeat 30 6080 127.0.0.1:5900 + +# 9. Wait for ready (lock held so waiters queue instead of starting twice). +i=0 +while [ $i -lt 60 ]; do + port_open 6080 && port_open 5900 && exit 0 + i=$((i + 1)) + sleep 0.5 +done +exit 1 diff --git a/docs/agent-skills.md b/docs/agent-skills.md index 88d899566..2bccc3e07 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -129,7 +129,7 @@ Sandbox 不再把凭据和模板放进 `WEKNORA_SANDBOX_*`。后端、凭据、 ### Sandbox 模式 -Docker、CubeSandbox、E2B 均通过同一套空间配置 CRUD、连接检查和智能体选择接口管理。CubeSandbox / E2B 的集群搭建和设置页接入流程见 [WeKnora 沙箱集群与标准模板](sandbox-cluster.md)。设置页会通过当前连接拉取模板目录;若没有 WeKnora 标准模板,后端会从标准镜像发起创建,用户无需复制模板 ID。 +Docker、CubeSandbox、E2B 均通过同一套空间配置 CRUD、连接检查和智能体选择接口管理。CubeSandbox / E2B 的集群搭建和设置页接入流程见 [WeKnora 沙箱集群与标准模板](sandbox-cluster.md)。设置页会通过当前连接拉取模板目录;若没有 WeKnora 标准(CLI)模板,后端会从标准镜像发起创建,用户无需复制模板 ID。图形桌面模板更重,不会随刷新自动构建,须在模板步骤单独点「创建」,见 [沙箱图形桌面](sandbox-desktop.md)。 | 模式 | 状态 | 说明 | |------|------|------| @@ -406,7 +406,7 @@ Docker 后端为每个会话提供独立容器,当前隔离和资源配置如 #### 沙箱镜像 -系统使用专用的沙箱镜像 `wechatopenai/weknora-sandbox`,预装了 Python 3.11、Node.js 20、uv 和常用 CLI 工具;技能依赖在技能安装阶段写入各自环境。 +系统使用专用的沙箱镜像 `wechatopenai/weknora-sandbox`,预装了 Python 3.12、Node.js 20、uv 和常用 CLI 工具;技能依赖在技能安装阶段写入各自环境。3.12 才能解析技能源码里带嵌套引号的 f-string(PEP 701);安装校验用的就是镜像里的解释器。 **预拉取镜像**(推荐在首次部署时执行,避免首次执行脚本时等待下载): @@ -423,7 +423,7 @@ sh scripts/build_images.sh -s > 示例使用 `main`;生产部署应固定已验证的版本标签,并确认镜像与应用版本兼容。 **镜像内置环境**: -- Python 3.11 + pip、uv;第三方 Python 包由技能安装阶段提供 +- Python 3.12 + pip、uv;第三方 Python 包由技能安装阶段提供 - Node.js 20 + npm、pnpm - CLI 工具:jq、curl、bash、grep、sed、awk 等 diff --git a/docs/api/session.md b/docs/api/session.md index e95a6ff2b..c23dc6e57 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -18,6 +18,9 @@ | POST | `/sessions/:session_id/pin` | 置顶会话 | | DELETE | `/sessions/:id/pin` | 取消置顶会话 | | GET | `/sessions/continue-stream/:session_id` | 继续未完成的流式响应 | +| POST | `/sessions/:session_id/sandbox/desktop-ticket` | 签发一次性桌面 WebSocket 票据 | +| POST | `/sessions/:session_id/sandbox/desktop/activity` | 上报桌面键鼠活动(parser 降级时续 TTL) | +| GET | `/sessions/:id/sandbox/desktop` | 沙箱桌面 RFB WebSocket 中继(query `ticket`) | > **路由命名说明**:置顶接口的 POST 与 DELETE 使用了不同的路径参数名(POST 用 `:session_id`,DELETE 用 `:id`)。这是由于 gin 路由器为每个 HTTP 方法维护独立的 radix tree,且既有树中的通配符命名不同,必须保留以避免注册时的 `wildcard conflicts` panic。两者语义上都指会话 ID。 @@ -475,3 +478,45 @@ curl --location 'http://localhost:8080/api/v1/sessions/continue-stream/ceb9babb- **响应格式**: 服务器端事件流(Server-Sent Events),事件结构与 `/knowledge-chat/:session_id`、`/agent-chat/:session_id` 返回结果一致。若该消息当前在流中已无事件返回 `404 No stream events found`;若消息记录不存在返回 `404 Incomplete message not found`。 + +## POST `/sessions/:session_id/sandbox/desktop-ticket` - 签发桌面票据 + +为沙箱图形桌面 WebSocket 签发一次性握手票据。访问 JWT 只出现在这次已认证 POST 上,不会进入 WS URL。票据 TTL 为 2 分钟,消费时 `GETDEL`,用过、过期、未知均返回同一类未授权错误。 + +这些端点与终端 WebSocket 一样**未进入 Swagger**。行为说明见 [沙箱图形桌面](../sandbox-desktop.md)。 + +**请求**: + +```curl +curl --location 'http://localhost:8080/api/v1/sessions/ceb9babb-1e30-41d7-817d-fd584954304b/sandbox/desktop-ticket' \ +--header 'Authorization: Bearer ' +``` + +**响应**: + +```json +{ + "success": true, + "data": { + "ticket": "hex-encoded-32-byte-id", + "expires_in": 120 + } +} +``` + +## GET `/sessions/:id/sandbox/desktop` - 桌面 RFB WebSocket + +浏览器 noVNC 将上一步的 `ticket` 放在 query 上升级为 WebSocket。该路由注册在全局 JWT 中间件之前,只认一次性票据。每个会话同一时刻只允许一条中继。 + +沙箱未绑定、已暂停、镜像不支持桌面、启动失败等状态会先 upgrade 再以 close reason 断开(`SANDBOX_NOT_BOUND` / `SANDBOX_PAUSED` / `DESKTOP_UNSUPPORTED` / `DESKTOP_START_FAILED` 等),因为 JS WebSocket API 拿不到失败握手的 HTTP 状态。 + +**请求**: + +``` +GET /api/v1/sessions/ceb9babb-1e30-41d7-817d-fd584954304b/sandbox/desktop?ticket= +Upgrade: websocket +``` + +## POST `/sessions/:session_id/sandbox/desktop/activity` - 上报桌面活动 + +浏览器在键鼠事件时 POST。这是 RFB parser 遇到未知 opcode 时的降级续命;parser 健康时服务端忽略该请求,避免仅靠轮询延长沙箱 TTL。 diff --git a/docs/sandbox-cluster.md b/docs/sandbox-cluster.md index 9b81a7d15..892138e29 100644 --- a/docs/sandbox-cluster.md +++ b/docs/sandbox-cluster.md @@ -17,7 +17,7 @@ 标准镜像定义在 `docker/Dockerfile.sandbox`,当前包含: -- Python 3.11; +- Python 3.12; - Node.js 20、npm 与 npx; - jq 及基础 Shell 工具; - `/workspace` 工作目录; @@ -25,16 +25,18 @@ 生产环境应使用与 WeKnora 相同的版本标签,不建议长期指向 `latest`。Skills 新增系统依赖时,应先更新标准镜像并重新注册模板,再切换集群的默认模板 ID。 -### 两个镜像变体 +### 镜像变体 -`docker/Dockerfile.sandbox` 产出两个 target,内容相同、入口不同: +`docker/Dockerfile.sandbox` 产出多个 target。CLI 变体内容相同、入口不同;桌面变体在同一套 Python 3.12 runtime 上再叠 XFCE,体积明显更大: | 变体 | 标签 | 用途 | | --- | --- | --- | -| `sandbox`(默认) | `wechatopenai/weknora-sandbox:<版本>` | Docker 后端的会话容器镜像;同时作为 E2B 模板的基础镜像 | -| `cube` | `wechatopenai/weknora-sandbox:<版本>-cube` | CubeSandbox 模板 | +| `sandbox`(默认) | `wechatopenai/weknora-sandbox:<版本>` | Docker 后端的会话容器镜像;同时作为 E2B CLI 模板的基础镜像 | +| `cube` | `wechatopenai/weknora-sandbox:<版本>-cube` | CubeSandbox CLI 模板 | +| `desktop` | `wechatopenai/weknora-sandbox:<版本>-desktop` | E2B 图形桌面模板的基础镜像 | +| `desktop-cube` | `wechatopenai/weknora-sandbox:<版本>-desktop-cube` | CubeSandbox 图形桌面模板 | -区别在于 Cube 变体额外注入了 envd。Cube 直接把 OCI 镜像变成模板,并以 `GET :49983/health` 探活,这个端点只有 envd 提供;不带 envd 的镜像建模板必然以 `connection refused` 失败。E2B 不需要这个变体,因为它的构建流程会自行注入 envd;Docker 后端则完全不需要 envd。详见 [Cube 自带镜像接入](https://cubesandbox.com/zh/guide/tutorials/bring-your-own-image.html)。 +区别在于 Cube 变体额外注入了 envd。Cube 直接把 OCI 镜像变成模板,并以 `GET :49983/health` 探活,这个端点只有 envd 提供;不带 envd 的镜像建模板必然以 `connection refused` 失败。E2B 不需要这个变体,因为它的构建流程会自行注入 envd;Docker 后端则完全不需要 envd,也**尚未**使用 `desktop` 变体。详见 [Cube 自带镜像接入](https://cubesandbox.com/zh/guide/tutorials/bring-your-own-image.html)。图形桌面的中继、票据与安全约束见 [沙箱图形桌面](./sandbox-desktop.md)。 Cube 变体只发布 linux/amd64——envd 的来源镜像 `cubesandbox-base` 没有 arm64,Cube 自身的 PVM 形态也只支持 x86_64。变体内 envd 以 root 运行,脚本按请求指定的账号执行;WeKnora 默认显式指定 root。 @@ -63,12 +65,12 @@ Cube 变体只发布 linux/amd64——envd 的来源镜像 `cubesandbox-base` 1. 按 [CubeSandbox Quick Start](https://github.com/TencentCloud/CubeSandbox/blob/master/docs/zh/guide/quickstart.md) 完成控制面、计算节点、CubeProxy 与域名解析。生产环境还需按官方文档完成鉴权、TLS、网络策略与多节点部署。 2. 在 WeKnora 的空间设置中填写 CubeAPI、CubeProxy、sandbox domain 和可选 API Key。需要自定义 guest DNS 时填写「DNS 服务器」(须为 IP);留空则使用集群默认。若这些端点位于 RFC1918/loopback 网络,显式打开“允许访问私网集群地址”。 -3. 点击“连接并继续”。WeKnora 先验证控制面地址与凭据,通过后才进入模板步骤并列出集群模板。**不会自动创建**。没有 WeKnora 标准模板时在占位卡片上点「创建」,会从 `wechatopenai/weknora-sandbox:main-cube` 发起构建。改 DNS 或需要换镜像时在 weknora 卡片上点「重建」:优先对现有标准模板做 in-place rebuild(模板 ID 不变);只有 redo 被拒绝时才先建新模板、成功后再删旧的。已安装 Skill 的配置(以及同一集群上其它已装 Skill 的配置)不能重建。失败模板同样用「重建」(CubeMaster 拒绝 redo、错误码 130400 时尤其需要)。 +3. 点击“连接并继续”。WeKnora 先验证控制面地址与凭据,通过后才进入模板步骤并列出集群模板。**不会自动创建桌面模板**。没有 WeKnora 标准(CLI)模板时在占位卡片上点「创建」,会从 `wechatopenai/weknora-sandbox:main-cube` 发起构建。需要图形桌面时再点桌面占位卡片上的「创建」(XFCE 镜像更重,E2B 为 4CPU/4GB,Cube 可写层 8G)。改 DNS 或需要换镜像时在对应卡片上点「重建」:优先对现有模板做 in-place rebuild(模板 ID 不变);只有 redo 被拒绝时才先建新模板、成功后再删旧的。已安装 Skill 的配置(以及同一集群上其它已装 Skill 的配置)不能重建,也不能在 CLI / 桌面之间切换。失败模板同样用「重建」(CubeMaster 拒绝 redo、错误码 130400 时尤其需要)。 4. 模板构建状态会自动刷新。状态变为 `READY` 后才可选择并进入运行配置;界面显示模板名称、状态和版本,配置内部才保存该集群自己的 `template_id`。 标准模板保留 uid 1000 的 `user` 兼容账号;WeKnora 默认显式以 root 执行脚本与文件操作。模板应提供可写的 `/workspace`,运行前会准备 `input`、`output` 和本次工作目录。技能安装的维护调用只准备其工作目录。自定义只读挂载仍限制 root 的写入。 -多实例 WeKnora 必须配置 Redis,以共享 session 到 sandbox 的绑定。只有单实例开发环境才应使用内存绑定。 +多实例 WeKnora 必须配置 Redis,以共享 session 到 sandbox 的绑定,以及桌面中继的「上次 sandbox ID」(技能安装重建后跨副本仍能发出 `SANDBOX_REBUILT`)。只有单实例开发环境才应使用内存绑定。 ### 验证 @@ -113,7 +115,7 @@ E2B 官方托管服务、自建 E2B Infrastructure,以及任意实现 E2B 协 1. 打开“设置 → 沙箱后端”,点击“添加沙箱后端”。 2. 填写该集群自己的 API、Proxy、sandbox domain 和凭据;这些值只保存在当前空间配置中,不读取 Sandbox 环境变量。 3. 点击“连接并继续”,验证控制面地址和凭据;连接通过后才加载模板列表。 -4. 没有标准模板时点占位卡片上的「创建」,或选择集群已有的兼容模板;改 DNS / 镜像后在 weknora 卡片上点「重建」。**该配置已安装 Skill 时不能更换或重建模板**——技能环境绑在快照上,新底模不会进去;请新建一份沙箱再装 Skill。构建中的模板不可选择,状态会自动刷新。 +4. 没有标准模板时点占位卡片上的「创建」,或选择集群已有的兼容模板;需要图形界面时再单独创建桌面模板。改 DNS / 镜像后在对应卡片上点「重建」。**该配置已安装 Skill 时不能更换或重建模板,也不能把 CLI 底模改成桌面(或反过来)**——技能环境绑在快照上,新底模不会进去;请新建一份沙箱再装 Skill。构建中的模板不可选择,状态会自动刷新。 5. 配置运行参数;上线前可执行一次“完整验证”。完整验证会真实创建、执行并销毁一个沙箱。 6. 保存后,在智能体 Skills 配置中选择该后端。对配置的修改只影响之后新建的沙箱;已有会话仍固定使用创建时的配置。 diff --git a/docs/sandbox-desktop.md b/docs/sandbox-desktop.md new file mode 100644 index 000000000..d6051d1ce --- /dev/null +++ b/docs/sandbox-desktop.md @@ -0,0 +1,70 @@ +# 沙箱图形桌面 + +会话沙箱可以带一个 XFCE 图形桌面(x11vnc + websockify),由 WeKnora 把 RFB 中继到对话侧栏的「桌面」页。本文说明它如何接入、为什么默认不创建,以及部署时必须避开的几条路径。 + +Docker 后端目前**没有**桌面路径;只有 CubeSandbox / E2B 的桌面模板能打开该页。集群与 CLI 标准模板见 [沙箱集群与标准模板](./sandbox-cluster.md)。 + +## 架构 + +浏览器里的 noVNC **不持有任何沙箱凭据**。流量路径是: + +``` +浏览器 (noVNC) + ⇄ WeKnora 中继 一次性票据鉴权、14 字节 RFB 握手、opcode 空闲判定 + ⇄ 提供商网关 e2b-traffic-access-token + Authorization: Basic + ⇄ 沙箱内 websockify :6080 ⇄ 127.0.0.1:5900 上的 x11vnc(-nopw)⇄ Xvfb ⇄ XFCE +``` + +两端凭据都只存在于后端 → 沙箱这一跳: + +- websockify Basic 密码写在沙箱内 `/run/desktop/secret`,由后端 Exec 读取,**从不**下发给浏览器; +- x11vnc 只绑在 `127.0.0.1:5900`,无 RFB 密码。 + +桌面是惰性启动的:E2B 以 envd 为 init、Cube 的 ENTRYPOINT 也是 envd,镜像 CMD 不会跑起来。第一次打开桌面 tab 时,后端在已有沙箱上 Exec `/usr/local/bin/start-desktop.sh`。查找连接(只 lookup、不 provision)不会为了预览去创建或恢复微虚拟机。 + +## 镜像 + +`docker/Dockerfile.sandbox` 在 CLI runtime(Python 3.12)之上再叠 XFCE: + +| 变体 | 标签 | 用途 | +| --- | --- | --- | +| `desktop` | `wechatopenai/weknora-sandbox:<版本>-desktop` | E2B 桌面模板的基础镜像 | +| `desktop-cube` | `wechatopenai/weknora-sandbox:<版本>-desktop-cube` | Cube 桌面模板(amd64,带 envd) | + +E2B 桌面模板构建为 4 CPU / 4 GB;Cube 桌面模板的可写层是 8G(标准 CLI 模板是 1G)。不要把 Python 运行时或 `/workspace` 权限改成「桌面专用」——那会连带改掉所有沙箱镜像。 + +## 模板是显式创建的 + +打开「设置 → 沙箱后端」并刷新模板列表时,**只会**在缺省时自动 ensure 官方 CLI 模板。桌面模板必须在模板步骤点「创建」才会带 `ensure_desktop`;否则会在集群里悄悄拉起一套更重的镜像。 + +保存时 `desktop_enabled` 跟所选目录卡片走:选中桌面卡则为 `true`,选中 CLI 卡则为 `false`。已安装 Skill 的配置不能改这个位——技能环境叠在当时的底模上,换 CLI/桌面等于换代。 + +对话侧栏只在当前智能体的沙箱配置 `desktop_enabled=true` 时显示「桌面」tab。Docker / 未选沙箱的智能体看不到该 tab。共享智能体若本空间拿不到那份配置,仍显示 tab,由后端返回 `DESKTOP_UNSUPPORTED`。 + +## Cube:6080 不要做宿主机 NAT + +Cube 模板的 `exposedPorts` **只**含 envd 的 `49983`。Cube 会把这份列表用 eBPF static NAT 打到宿主机网卡,从而绕过 CubeProxy。 + +WeKnora 访问 websockify 的方式和访问 envd 一样:CubeProxy Host `{port}-{id}.{domain}`(即 `6080-{sandboxId}.{domain}`)加上入站 token。不要把 6080 写进 `exposedPorts`:沙箱内 agent/技能默认以 root Exec,读得到 `/run/desktop/secret`,一旦 6080 暴露在宿主机上就可以跳过票据中继、空闲断开和审计。 + +websockify 上的 Basic auth 只是叠加路径上的纵深防御,不是可以把端口公网化的理由。 + +## 票据与 WebSocket + +桌面 WebSocket **不走**全局 JWT 中间件(和终端一样),握手用一次性票据: + +1. 已登录的 `POST /api/v1/sessions/:session_id/sandbox/desktop-ticket` 签发票据(TTL 2 分钟)。 +2. 浏览器把票据放在 `GET /api/v1/sessions/:id/sandbox/desktop?ticket=` 的 query 上。 +3. 服务端 `GETDEL` 消费票据;用过、过期、未知都是同一类错误,避免探测。 + +票据是不透明随机串,不是 JWT,避免出现在网关 access log 和浏览器历史里。nginx 对该路径使用 `api_no_query` 日志格式,避免把 `ticket` 打进访问日志。 + +每个会话同一时刻只允许一条桌面中继(跨副本用 Redis 槽位)。技能安装可能重建沙箱;中继用 Redis 键 `weknora:desktop-last-sandbox:`(TTL 7 天)记住上次连上的 sandbox ID,换 ID 时以 `SANDBOX_REBUILT` 关掉连接,避免多副本重连进空白桌面还以为 `/workspace` 还在。未配 Redis 时退回进程内存储,只适合单实例。 + +## 空闲断开 + +中继根据 RFB opcode 判断键鼠活动(`FramebufferUpdateRequest` 不算)。浏览器还可以 `POST /api/v1/sessions/:session_id/sandbox/desktop/activity`,但**只有 RFB parser 降级时**才会续 TTL;parser 健康时该 POST 是空操作,避免靠轮询白嫖沙箱寿命。 + +## 相关接口 + +手写说明见 [会话管理 API](./api/session.md)。这些端点与终端 WebSocket 一样未进 Swagger。 diff --git a/docs/sandbox-docker-backend.md b/docs/sandbox-docker-backend.md index 051cf04ea..f55d41ab6 100644 --- a/docs/sandbox-docker-backend.md +++ b/docs/sandbox-docker-backend.md @@ -160,6 +160,7 @@ socket 改成非 root 组的 `660`。 - **域名级出网策略**:Docker 只有 L3/L4。`RemoteNetworkPolicy` 里的域名 allow/deny 在这个后端 只能表达成「全开」或「全关」,要按域名放行得在部署侧加 egress proxy。 - **卷挂载**:`SupportsVolumes` 目前是 false,租户级共享卷还没有映射到 Docker named volume。 +- **图形桌面**:`desktop` 镜像变体尚未接到 Docker 后端。对话侧栏在 Docker 配置上不显示「桌面」tab;Cube / E2B 见 [沙箱图形桌面](./sandbox-desktop.md)。 ## 快照 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d964a71bf..a0c20134b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -155,9 +155,12 @@ server { return 404; } - # Interactive sandbox terminal: WebSocket upgrade + query-stripped logs - # (the handshake carries a short-lived ticket in the query string). - location ~ ^/api/v1/sessions/[^/]+/sandbox/terminal$ { + # Interactive sandbox terminal / desktop: WebSocket upgrade + + # query-stripped logs (the handshake carries a short-lived ticket + # in the query string). Desktop must share this location: the + # generic /api/ proxy clears Connection, so gorilla Upgrade() would + # otherwise reject the handshake with 400. + location ~ ^/api/v1/sessions/[^/]+/sandbox/(terminal|desktop)$ { proxy_pass ${APP_SCHEME}://${APP_HOST}:${APP_PORT}; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eeba3700e..973152ebc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.8.0", "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", + "@novnc/novnc": "^1.7.0", "@types/dompurify": "^3.2.0", "@types/papaparse": "^5.5.2", "@vue-office/pptx": "^1.0.1", @@ -1060,6 +1061,12 @@ "resolved": "https://mirrors.tencent.com/npm/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" }, + "node_modules/@novnc/novnc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz", + "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", + "license": "MPL-2.0" + }, "node_modules/@pagefind/darwin-arm64": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8a79aa5c4..a0e45a584 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", + "@novnc/novnc": "^1.7.0", "@types/dompurify": "^3.2.0", "@types/papaparse": "^5.5.2", "@vue-office/pptx": "^1.0.1", diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index b33b400c3..66f82ff44 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -771,6 +771,7 @@ export interface SandboxConfig { sandbox_type?: string default_timeout_sec?: number terminal_idle_disconnect_sec?: number + desktop_enabled?: boolean allow_private_endpoints?: boolean env_vars?: Record volume_mount?: SandboxVolumeMountConfig @@ -867,6 +868,8 @@ export interface SandboxTemplate { created_at?: string updated_at?: string standard: boolean + /** XFCE sibling of `standard`. The admin picks one ID as this config's boot target. */ + desktop?: boolean /** The provider's own explanation for a failed build, when it reports one. */ error?: string instance_type?: string @@ -877,6 +880,7 @@ export interface SandboxTemplate { export interface SandboxTemplateCatalog { templates: SandboxTemplate[] standard_template_id?: string + desktop_template_id?: string provisioned: boolean } @@ -980,16 +984,21 @@ export function getSandboxConfigInventory(id: string): Promise<{ data: SandboxIn /** * Fetch templates using the connection currently entered in the drawer. - * `ensure_standard` starts a provider-side build when no WeKnora template is - * present. `replace_standard` rebuilds the WeKnora template so a new spec - * (DNS, image) can take effect; it requires `config_id`. The returned - * building item can be polled through the same endpoint. + * `ensure_standard` starts a provider-side CLI build when that WeKnora + * template is missing. `ensure_desktop` does the same for the XFCE image, but + * the settings UI only sends it when the admin clicks Create — listing must + * not provision a desktop template as a side effect. `replace_standard` / + * `replace_desktop` rebuild the matching template so a new spec (DNS, image) + * can take effect; they require `config_id`. The returned building item can + * be polled through the same endpoint. */ export function querySandboxTemplates(payload: { config: SandboxConfig config_id?: string ensure_standard?: boolean replace_standard?: boolean + ensure_desktop?: boolean + replace_desktop?: boolean }): Promise<{ data: SandboxTemplateCatalog }> { return post('/api/v1/sandbox-configs/templates/query', payload) as unknown as Promise<{ data: SandboxTemplateCatalog diff --git a/frontend/src/components/SandboxConfigEditorDrawer.template.test.mjs b/frontend/src/components/SandboxConfigEditorDrawer.template.test.mjs new file mode 100644 index 000000000..e10df0e5a --- /dev/null +++ b/frontend/src/components/SandboxConfigEditorDrawer.template.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync( + new URL('./SandboxConfigEditorDrawer.vue', import.meta.url), 'utf8') + +test('connection step has no desktop-image switch', () => { + const connection = source.slice( + source.indexOf("currentStepKey === 'connection' && isRemoteBackend"), + source.indexOf("currentStepKey === 'connection' && !isRemoteBackend"), + ) + assert.doesNotMatch( + connection, + /desktopEnabled/, + 'desktop_enabled is derived from the template the admin picks, not a connection-step switch', + ) +}) + +test('desktop templates have a sibling create offer like CLI, and listing does not auto-ensure them', () => { + const template = source.slice( + source.indexOf("currentStepKey === 'template'"), + source.indexOf("currentStepKey === 'runtime'"), + ) + assert.ok(template.includes('canCreateStandard'), 'CLI create offer remains a fallback if ensure failed') + assert.ok(template.includes('canCreateDesktop'), 'desktop create offer is opt-in; XFCE images are much heavier') + assert.ok(template.includes('createDesktopTemplate'), 'admin must click create to start the desktop Hub build') + assert.ok(template.includes('weknoraDesktopTemplate'), 'the offer row names the desktop image') + assert.ok( + template.includes('item.desktop'), + 'listed cards still mark the desktop image so picking one can set desktop_enabled', + ) +}) + +test('listing Cube/E2B templates does not auto-ensure the desktop image', () => { + assert.match( + source, + /ensure_desktop:\s*Boolean\(opts\.ensureDesktop\)/, + 'catalog load must not send ensure_desktop unless the admin clicked create', + ) + assert.match( + source, + /opts\.ensureDesktop && desktopID/, + 'clicking create must also select the desktop template so desktop_enabled is saved as true', + ) + assert.doesNotMatch( + source, + /ensure_desktop:\s*opts\.ensureDesktop \|\| \(ensureFirstParty && !opts\.replaceDesktop\)/, + 'opening settings must not provision a 4CPU/4GB desktop template as a side effect', + ) +}) + +test('saving records desktop_enabled from the selected catalog card', () => { + assert.match( + source, + /function collectedDesktopEnabled/, + 'derivation must be a named helper so snapshot vs catalog-miss cannot drift', + ) + assert.match( + source, + /Boolean\(selectedTemplate\.value\.desktop\)/, + 'selecting the CLI card must persist desktop_enabled=false, not omit the field', + ) + assert.match( + source, + /templatesLoaded\.value && !retargetFrozen\.value/, + 'an unmatched catalog ID must not keep a stale desktop_enabled from a previous save', + ) + assert.match( + source, + /effectiveRecord\.value\?\.config\?\.desktop_enabled/, + 'a skill-snapshot UUID is not a catalog card; keep the stored desktop_enabled bit', + ) +}) diff --git a/frontend/src/components/SandboxConfigEditorDrawer.vue b/frontend/src/components/SandboxConfigEditorDrawer.vue index c4b06136c..cfa004ddb 100644 --- a/frontend/src/components/SandboxConfigEditorDrawer.vue +++ b/frontend/src/components/SandboxConfigEditorDrawer.vue @@ -290,6 +290,22 @@

{{ $t('settings.sandbox.createStandardTemplateHint') }}

+
+
+
+ {{ $t('settings.sandbox.weknoraDesktopTemplate') }} + + {{ $t('settings.sandbox.desktopTemplateTag') }} + + + + {{ $t('settings.sandbox.createDesktopTemplate') }} + +
+

{{ $t('settings.sandbox.createDesktopTemplateHint') }}

+
+
{{ $t('settings.sandbox.recommendedTag') }} + + {{ $t('settings.sandbox.desktopTemplateTag') }} + {{ templateStatusLabel(item) }} @@ -323,8 +342,10 @@ {{ $t('settings.sandbox.replaceStandardTemplate') }} @@ -344,7 +365,7 @@

{{ templateFailureReason(item) }}

-

+

{{ $t('settings.sandbox.templateBuildingHint') }}

@@ -947,9 +968,13 @@ const currentTemplateId = computed(() => ( )?.trim() || '') const selectedTemplate = computed(() => templates.value.find((item) => item.id === currentTemplateId.value)) const clusterStandardTemplate = computed(() => templates.value.find((item) => item.standard && item.id)) +const clusterDesktopTemplate = computed(() => templates.value.find((item) => item.desktop && item.id)) const canCreateStandard = computed(() => ( isRemoteBackend.value && templatesLoaded.value && !clusterStandardTemplate.value && !retargetFrozen.value )) +const canCreateDesktop = computed(() => ( + isRemoteBackend.value && templatesLoaded.value && !clusterDesktopTemplate.value && !retargetFrozen.value +)) const wizardSteps = computed>(() => { const steps: Array<{ key: SandboxStepKey; title: string }> = [ { key: 'connection', title: t('settings.sandbox.stepConnection') }, @@ -1266,10 +1291,11 @@ function onTemplateCardClick(item: SandboxTemplate) { } function canRebuildTemplate(item: SandboxTemplate): boolean { - return Boolean(item.standard && item.id) && !isTemplatePending(item) && !retargetFrozen.value + return Boolean((item.standard || item.desktop) && item.id) && !isTemplatePending(item) && !retargetFrozen.value } function templateDisplayName(item: SandboxTemplate): string { + if (item.standard) return t('settings.sandbox.weknoraStandardTemplate') const name = item.name?.trim() || '' const id = item.id?.trim() || '' if (!name || name === id) return t('settings.sandbox.templateUnnamed') @@ -1382,51 +1408,70 @@ function scheduleTemplatePolling() { stopTemplatePolling() if (!props.visible || currentStepKey.value !== 'template' || !hasPendingTemplates.value) return templatePollTimer = setTimeout(() => { - void loadTemplates(false, true) + void loadTemplates({ silent: true }) }, 3000) } -async function loadTemplates(ensureStandard = false, silent = false, replaceStandard = false): Promise { +async function loadTemplates(opts: { + ensureStandard?: boolean + ensureDesktop?: boolean + silent?: boolean + replaceStandard?: boolean + replaceDesktop?: boolean +} = {}): Promise { if (!hasImageCatalog.value) return true if (!connectionReady()) return false - if (!silent) templatesLoading.value = true + if (!opts.silent) templatesLoading.value = true templatesError.value = '' try { + // Cube/E2B: listing ensures the published CLI image when it is missing. + // Desktop is opt-in: XFCE images are much heavier, so they are created + // only when the admin clicks the desktop offer row. + const ensureFirstParty = isRemoteBackend.value const res = await querySandboxTemplates({ config: collectPayload(), config_id: effectiveRecord.value?.id, - ensure_standard: ensureStandard, - replace_standard: replaceStandard, + ensure_standard: opts.ensureStandard || (ensureFirstParty && !opts.replaceStandard && !opts.replaceDesktop && !opts.ensureDesktop), + ensure_desktop: Boolean(opts.ensureDesktop), + replace_standard: opts.replaceStandard, + replace_desktop: opts.replaceDesktop, }) templates.value = res.data?.templates || [] templatesLoaded.value = true const standardID = res.data?.standard_template_id + const desktopID = res.data?.desktop_template_id const current = templates.value.find((item) => item.id === currentTemplateId.value) - if (replaceStandard && standardID) { + if (opts.replaceDesktop && desktopID) { + selectTemplate(desktopID) + } else if (opts.replaceStandard && standardID) { selectTemplate(standardID) + } else if (opts.ensureDesktop && desktopID) { + const next = templates.value.find((item) => item.id === desktopID) + if (next && (isTemplateSelectable(next) || isTemplatePending(next))) { + selectTemplate(desktopID) + } + } else if (opts.ensureStandard && standardID) { + const next = templates.value.find((item) => item.id === standardID) + if (next && (isTemplateSelectable(next) || isTemplatePending(next))) { + selectTemplate(standardID) + } } else if ( currentTemplateId.value && (!current || (!isTemplateSelectable(current) && !isTemplatePending(current))) && !retargetFrozen.value ) { - if (standardID) { - const next = templates.value.find((item) => item.id === standardID) - if (next && (isTemplateSelectable(next) || isTemplatePending(next))) { - selectTemplate(standardID) - } else { - clearTemplateSelection() - } - } else { - clearTemplateSelection() - } + clearTemplateSelection() } - const readyStandard = templates.value.find((item) => item.id === standardID && isTemplateSelectable(item)) - || templates.value.find((item) => item.standard && isTemplateSelectable(item)) - if (!currentTemplateId.value && readyStandard) selectTemplate(readyStandard.id) - if (res.data?.provisioned && !silent) { - MessagePlugin.info(replaceStandard - ? t('settings.sandbox.standardTemplateReplaced') - : t('settings.sandbox.standardTemplateProvisioning')) + if (!currentTemplateId.value && !retargetFrozen.value) { + const firstPartyReady = templates.value.filter((item) => ( + (item.standard || item.desktop) && isTemplateSelectable(item) + )) + if (firstPartyReady.length === 1) selectTemplate(firstPartyReady[0].id) + } + if (res.data?.provisioned && !opts.silent && ( + opts.replaceDesktop || opts.replaceStandard || opts.ensureStandard || opts.ensureDesktop + )) { + MessagePlugin.info(provisionedTemplateMessage(opts)) } scheduleTemplatePolling() return true @@ -1434,17 +1479,34 @@ async function loadTemplates(ensureStandard = false, silent = false, replaceStan templatesError.value = e?.message || t('settings.sandbox.templateLoadFailed') return false } finally { - if (!silent) templatesLoading.value = false + if (!opts.silent) templatesLoading.value = false } } -function createStandardTemplate() { - return loadTemplates(true) +function provisionedTemplateMessage(opts: { + ensureStandard?: boolean + ensureDesktop?: boolean + replaceStandard?: boolean + replaceDesktop?: boolean +}): string { + if (opts.replaceDesktop) return t('settings.sandbox.desktopTemplateReplaced') + if (opts.ensureDesktop) return t('settings.sandbox.desktopTemplateProvisioning') + if (opts.replaceStandard) return t('settings.sandbox.standardTemplateReplaced') + return t('settings.sandbox.standardTemplateProvisioning') } -function replaceStandardTemplate() { +function createStandardTemplate() { + return loadTemplates({ ensureStandard: true }) +} + +function createDesktopTemplate() { + return loadTemplates({ ensureDesktop: true }) +} + +function replaceFirstPartyTemplate(item: SandboxTemplate) { if (retargetFrozen.value) return - return loadTemplates(false, false, true) + if (item.desktop) return loadTemplates({ replaceDesktop: true }) + return loadTemplates({ replaceStandard: true }) } // Re-attaches the redaction placeholder to a secret the admin left untouched: @@ -1454,6 +1516,22 @@ function withStoredSecret(block: T, stored: bool return block } +function collectedDesktopEnabled(): boolean | undefined { + if (selectedTemplate.value) { + // Persist false when the CLI card is selected; omitempty on the + // server would otherwise keep a stale true from a previous desktop save. + return Boolean(selectedTemplate.value.desktop) + } + // Skill snapshots replace template_id with a UUID that is not in the + // catalog. Keep the stored bit. If the catalog loaded and this ID is + // simply unmatched, do not keep a stale true that could disagree with + // template_id. + if (templatesLoaded.value && !retargetFrozen.value) { + return false + } + return effectiveRecord.value?.config?.desktop_enabled || undefined +} + function collectPayload(): SandboxConfig { const envVars: Record = {} for (const row of envRows.value) { @@ -1466,6 +1544,7 @@ function collectPayload(): SandboxConfig { default_timeout_sec: defaultTimeoutSec.value || undefined, terminal_idle_disconnect_sec: terminalIdleDisconnectSec.value || undefined, allow_private_endpoints: allowPrivateEndpoints.value || undefined, + desktop_enabled: collectedDesktopEnabled(), env_vars: envVars, skill_rollout: skillRollout.value, network: collectNetworkPolicy(), @@ -1582,7 +1661,7 @@ async function handlePrimaryAction() { // Docker's template is the image typed on this step. Kick a background // pull so the first session does not block on a cold registry fetch. if (backend.value === 'docker') { - void loadTemplates(true) + void loadTemplates({ ensureStandard: true }) } invalidateCheck() wizardStep.value += 1 diff --git a/frontend/src/components/chat/SandboxSidePanel.test.mjs b/frontend/src/components/chat/SandboxSidePanel.test.mjs index 319cb552a..4a307de50 100644 --- a/frontend/src/components/chat/SandboxSidePanel.test.mjs +++ b/frontend/src/components/chat/SandboxSidePanel.test.mjs @@ -5,9 +5,9 @@ import { readFileSync } from 'node:fs' const panel = readFileSync(new URL('./SandboxSidePanel.vue', import.meta.url), 'utf8') test('artifacts is the first sandbox panel tab', () => { - const artifacts = panel.indexOf("id: 'artifacts' as SandboxPanelTab") - const terminal = panel.indexOf("id: 'terminal' as SandboxPanelTab") - const desktop = panel.indexOf("id: 'desktop' as SandboxPanelTab") + const artifacts = panel.indexOf("id: 'artifacts'") + const terminal = panel.indexOf("id: 'terminal'") + const desktop = panel.indexOf("id: 'desktop'") assert.notEqual(artifacts, -1) assert.ok(artifacts < terminal) assert.ok(terminal < desktop) @@ -21,3 +21,11 @@ test('closing the panel drops the terminal mount flag so Files reopen does not r assert.match(panel, /terminalMounted\.value = false/) assert.match(panel, /tab === 'terminal'/) }) + +test('desktop tab is omitted unless the selected agent sandbox has desktop_enabled', () => { + assert.match(panel, /ensureSandboxConfigs/) + assert.match(panel, /desktopTabVisible/) + assert.match(panel, /desktop_enabled/) + assert.match(panel, /if \(desktopTabVisible\.value\)/) + assert.match(panel, /desktopMounted && desktopTabVisible/) +}) diff --git a/frontend/src/components/chat/SandboxSidePanel.vue b/frontend/src/components/chat/SandboxSidePanel.vue index 049e5560f..b62c4598b 100644 --- a/frontend/src/components/chat/SandboxSidePanel.vue +++ b/frontend/src/components/chat/SandboxSidePanel.vue @@ -73,9 +73,20 @@ -
- -

{{ t('chat.sandbox.desktopPlaceholder') }}

+ + +
+
@@ -92,7 +103,9 @@ import { type SandboxPanelTab, } from '@/composables/useChatSandboxPanel' import SandboxTerminal from '@/views/chat/components/SandboxTerminal.vue' +import SandboxDesktop from '@/views/chat/components/SandboxDesktop.vue' import ChatArtifactsPanel from '@/views/chat/components/ChatArtifactsPanel.vue' +import { useChatResourcesStore } from '@/stores/chatResources' import type { SessionArtifactItem } from '@/utils/sessionArtifacts' const props = withDefaults( @@ -115,35 +128,90 @@ const props = withDefaults( const { t } = useI18n() const panel = useChatSandboxPanel() +const chatResources = useChatResourcesStore() +const sandboxConfigsReady = ref(false) -const tabs = computed(() => [ - { id: 'artifacts' as SandboxPanelTab, icon: 'folder', label: t('chat.sandbox.tabArtifacts') }, - { id: 'terminal' as SandboxPanelTab, icon: 'terminal', label: t('chat.sandbox.tabTerminal') }, - { id: 'desktop' as SandboxPanelTab, icon: 'desktop', label: t('chat.sandbox.tabDesktop') }, -]) +// Hide the desktop tab for CLI / Docker configs. Shared agents whose +// sandbox row is not in this workspace still show the tab and let the +// backend return DESKTOP_UNSUPPORTED. +const desktopTabVisible = computed(() => { + const agentId = props.agentId?.trim() + if (!agentId) return false + const agent = chatResources.agents.find((item) => item.id === agentId) + if (!agent) { + return chatResources.agents.length > 0 + } + const configId = agent.config?.sandbox_config_id?.trim() + if (!configId) return false + if (!sandboxConfigsReady.value) return false + const cfg = chatResources.sandboxConfigs.find((item) => item.id === configId) + if (!cfg) return true + return Boolean(cfg.config?.desktop_enabled) +}) -// 终端实例惰性挂载(首次切到终端 tab 时),面板关闭即销毁(v-if), +const tabs = computed(() => { + const list: Array<{ id: SandboxPanelTab; icon: string; label: string }> = [ + { id: 'artifacts', icon: 'folder', label: t('chat.sandbox.tabArtifacts') }, + { id: 'terminal', icon: 'terminal', label: t('chat.sandbox.tabTerminal') }, + ] + if (desktopTabVisible.value) { + list.push({ id: 'desktop', icon: 'desktop', label: t('chat.sandbox.tabDesktop') }) + } + return list +}) + +// 终端 / 桌面惰性挂载(首次切到对应 tab 时),面板关闭即销毁(v-if), // 与 ChatReferencesDrawer 的开合行为一致;会话切换时由 :key 重建。 const terminalMounted = ref(false) const terminalRef = ref<{ focus?: () => void } | null>(null) +const desktopMounted = ref(false) +const desktopRef = ref<{ start?: () => void } | null>(null) watch( () => [panel?.visible.value, panel?.activeTab.value] as const, ([visible, tab]) => { if (!visible) { - // Drop the lazy-mount flag so reopening on Files does not remount - // SandboxTerminal (which would lookup-connect a PTY and refresh TTL). + // Drop the lazy-mount flags so reopening on Files does not remount + // either panel (which would connect and refresh TTL). terminalMounted.value = false + desktopMounted.value = false return } if (tab === 'terminal') { terminalMounted.value = true void nextTick(() => terminalRef.value?.focus?.()) + return + } + if (tab === 'desktop' && desktopTabVisible.value) { + // Same as the terminal tab: mount runs a lookup-only connect. A + // running sandbox attaches; paused or missing stays on the overlay + // until the user confirms. Opening the panel must never create or + // resume a microVM as a side effect. + desktopMounted.value = true } }, { immediate: true }, ) +watch( + () => [panel?.visible.value, props.agentId] as const, + ([visible]) => { + if (!visible) return + void chatResources.ensureSandboxConfigs().finally(() => { + sandboxConfigsReady.value = true + }) + }, + { immediate: true }, +) + +watch(desktopTabVisible, (show) => { + if (show) return + if (panel?.activeTab.value === 'desktop') { + panel.activeTab.value = 'artifacts' + } + desktopMounted.value = false +}) + watch( () => props.sessionId, () => { @@ -334,6 +402,7 @@ function startResize(event: MouseEvent) { } .chat-sandbox-panel__terminal, +.chat-sandbox-panel__desktop, .chat-sandbox-panel__artifacts { flex: 1; min-height: 0; diff --git a/frontend/src/composables/useSandboxDesktop.test.mjs b/frontend/src/composables/useSandboxDesktop.test.mjs new file mode 100644 index 000000000..024f4ae80 --- /dev/null +++ b/frontend/src/composables/useSandboxDesktop.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' + +const src = readFileSync(new URL('./useSandboxDesktop.ts', import.meta.url), 'utf8') + +test('AUTH_REVOKED close reason maps to unauthorized and does not reconnect', () => { + assert.match(src, /case 'AUTH_REVOKED':\s*return 'unauthorized'/) + assert.match(src, /mapped === 'idle' \|\| mapped === 'needs_provision' \|\| mapped === 'paused'/) + assert.match(src, /mapped !== 'start_failed'/) +}) + +test('panel lookup is provision-opt-in and paused stays on the overlay', () => { + assert.match(src, /allowProvision/) + assert.match(src, /query\.set\('provision',\s*'1'\)/) + assert.match(src, /allowProvision = false/) + assert.match(src, /case 'SANDBOX_PAUSED':\s*return 'paused'/) + assert.match(src, /mapped === 'paused'/) + assert.match(src, /\| 'paused'/) + assert.match(src, /connect:\s*\(target: HTMLElement,\s*options\?: \{ provision\?: boolean \}\)/) +}) + +test('local Cmd/Ctrl+V is bridged as RFB clipboard then Linux Ctrl+V', () => { + assert.match(src, /clipboardPasteFrom/) + assert.match(src, /function pasteLocalClipboard/) + assert.match(src, /function attachDesktopClipboard/) + assert.match(src, /navigator\.clipboard\.readText/) + assert.match(src, /sendGuestChord\(client,\s*XK_v,\s*'KeyV'\)/) + assert.match(src, /sendGuestChord\(client,\s*XK_c,\s*'KeyC'\)/) + assert.match(src, /addEventListener\('paste'/) + assert.match(src, /addEventListener\('clipboard'/) +}) diff --git a/frontend/src/composables/useSandboxDesktop.ts b/frontend/src/composables/useSandboxDesktop.ts new file mode 100644 index 000000000..ddcfbccea --- /dev/null +++ b/frontend/src/composables/useSandboxDesktop.ts @@ -0,0 +1,402 @@ +import { onUnmounted, ref, type Ref } from 'vue' +import RFB from '@novnc/novnc' +import { post } from '@/utils/request' + +export type SandboxDesktopStatus = + | 'idle' + /** 懒启动中:start-desktop.sh 通常要 3–8 秒。 */ + | 'starting' + | 'connected' + | 'disconnected' + /** 501:后端不支持中继,或镜像里没有桌面。 */ + | 'unsupported' + /** 409 DESKTOP_BUSY:本会话已有一个桌面连接。 */ + | 'busy' + /** 503:镜像对但桌面没起来,可重试。 */ + | 'start_failed' + /** 沙箱在连接期间被技能安装重建了。 */ + | 'rebuilt' + /** 409 SANDBOX_NOT_BOUND:会话还没有沙箱。 */ + | 'needs_provision' + /** 409 SANDBOX_PAUSED:会话有沙箱但已暂停,唤醒需要确认。 */ + | 'paused' + | 'unauthorized' + | 'error' + +const RECONNECT_BASE_DELAY_MS = 1000 +const RECONNECT_MAX_DELAY_MS = 30000 +// 活动上报只在 opcode 解析降级时才是唯一信号,所以宁可稀疏也不要吵。 +const ACTIVITY_REPORT_MIN_INTERVAL_MS = 30000 + +// X11 keysyms. Linux guests paste/copy with Ctrl, not the Super key a Mac +// Cmd+V would otherwise send through noVNC. +const XK_Control_L = 0xffe3 +const XK_c = 0x0063 +const XK_v = 0x0076 + +function sendGuestChord(client: RFB, letterKeysym: number, code: string) { + client.sendKey(XK_Control_L, 'ControlLeft', true) + client.sendKey(letterKeysym, code, true) + client.sendKey(letterKeysym, code, false) + client.sendKey(XK_Control_L, 'ControlLeft', false) +} + +async function pasteLocalClipboard(client: RFB) { + let text = '' + try { + text = await navigator.clipboard.readText() + } catch { + return + } + if (!text) return + client.clipboardPasteFrom(text) + sendGuestChord(client, XK_v, 'KeyV') +} + +function attachDesktopClipboard(client: RFB, element: HTMLElement): () => void { + const onKeyDown = (event: KeyboardEvent) => { + if (event.repeat || event.altKey || event.shiftKey) return + if (!(event.ctrlKey || event.metaKey)) return + const key = event.key.toLowerCase() + if (key === 'v') { + // Cmd+V: Super+V is a no-op on XFCE. Leave the event alone so Safari + // still fires `paste` with clipboardData; onPaste does the RFB sync. + if (!event.ctrlKey || event.metaKey) return + event.preventDefault() + event.stopPropagation() + void pasteLocalClipboard(client) + return + } + if (key !== 'c') return + event.preventDefault() + event.stopPropagation() + sendGuestChord(client, XK_c, 'KeyC') + } + const onPaste = (event: ClipboardEvent) => { + const active = document.activeElement + if (active !== element && !element.contains(active)) return + const text = event.clipboardData?.getData('text') ?? '' + if (!text) return + event.preventDefault() + event.stopPropagation() + client.clipboardPasteFrom(text) + sendGuestChord(client, XK_v, 'KeyV') + } + const onClipboard = (event: Event) => { + const text = (event as CustomEvent<{ text?: string }>).detail?.text + if (typeof text !== 'string' || text === '') return + void navigator.clipboard.writeText(text).catch(() => { + // Permission denied: the desktop copy still happened; local OS clipboard + // just does not follow. + }) + } + element.addEventListener('keydown', onKeyDown, true) + document.addEventListener('paste', onPaste, true) + client.addEventListener('clipboard', onClipboard) + return () => { + element.removeEventListener('keydown', onKeyDown, true) + document.removeEventListener('paste', onPaste, true) + client.removeEventListener('clipboard', onClipboard) + } +} + +function resolveWsBase(): string { + const base = (import.meta.env.BASE_URL || '/').replace(/\/+$/, '') + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' + return `${protocol}://${window.location.host}${base}` +} + +async function mintDesktopTicket(sessionId: string): Promise { + const res = await post<{ success?: boolean; data?: { ticket?: string } }>( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/sandbox/desktop-ticket`, + {}, + ) + const ticket = res?.data?.ticket + if (!ticket) throw new Error('missing desktop ticket') + return ticket +} + +/** + * post() 拒绝值:非枚举 `$httpStatus` + gin `{ error: "" }`。 + * 409 同时表示 DESKTOP_BUSY 和 SANDBOX_NOT_BOUND,必须看 error 才能区分。 + */ +function statusFromHttpStatus( + status: number, + errorCode?: string, +): SandboxDesktopStatus { + if (status === 401 || status === 403) return 'unauthorized' + if (status === 409) { + if (errorCode === 'SANDBOX_NOT_BOUND') return 'needs_provision' + if (errorCode === 'SANDBOX_PAUSED') return 'paused' + if (errorCode === 'DESKTOP_BUSY') return 'busy' + return 'error' + } + if (status === 501) return 'unsupported' + if (status === 503) return 'start_failed' + return 'error' +} + +function errorCodeFromRejected(err: unknown): string | undefined { + if (typeof err !== 'object' || err === null) return undefined + const error = (err as { error?: unknown }).error + return typeof error === 'string' ? error : undefined +} + +function httpStatusFromRejected(err: unknown): number | undefined { + if (typeof err !== 'object' || err === null) return undefined + const payload = err as { + $httpStatus?: unknown + status?: unknown + response?: { status?: unknown } + } + if (typeof payload.$httpStatus === 'number') return payload.$httpStatus + if (typeof payload.status === 'number') return payload.status + if (typeof payload.response?.status === 'number') return payload.response.status + return undefined +} + +/** + * Ticket POST 失败走 HTTP 状态码(statusFromHttpStatus)。Ensure 失败 + * (SANDBOX_NOT_BOUND / SANDBOX_PAUSED 等)在升级后用 close reason 下发, + * 因为浏览器 WebSocket API 读不到握手 HTTP 状态。升级之后的可预期关闭 + * (SANDBOX_REBUILT / IDLE_DISCONNECTED 等)同样走 CloseEvent.reason, + * 不是 noVNC 1.7 的 disconnect detail(只有 `{ clean }`)。 + */ +function statusFromCloseReason(reason: string): SandboxDesktopStatus | null { + switch (reason) { + case 'DESKTOP_UNSUPPORTED': return 'unsupported' + case 'DESKTOP_BUSY': return 'busy' + case 'DESKTOP_START_FAILED': return 'start_failed' + case 'SANDBOX_REBUILT': return 'rebuilt' + case 'SANDBOX_NOT_BOUND': return 'needs_provision' + case 'SANDBOX_PAUSED': return 'paused' + case 'IDLE_DISCONNECTED': return 'idle' + case 'AUTH_REVOKED': return 'unauthorized' + default: return null + } +} + +export type SandboxDesktopSession = { + status: Ref + connect: (target: HTMLElement, options?: { provision?: boolean }) => void + disconnect: () => void + reportActivity: () => void + dispose: () => void +} + +export function useSandboxDesktop( + sessionId: Ref, + agentId: Ref, + agentSourceTenantId: Ref = ref(undefined), +): SandboxDesktopSession { + const status = ref('starting') + + let rfb: RFB | null = null + let target: HTMLElement | null = null + let disposed = false + let wanted = false + let opening = false + let reconnectAttempt = 0 + let reconnectTimer: ReturnType | null = null + let lastActivityReport = 0 + let everConnected = false + let detachClipboard: (() => void) | null = null + // 当前这次连接是否带创建意图。只由 connect(..., { provision: true }) 置真。 + let allowProvision = false + + function clearClipboardBridge() { + if (detachClipboard) { + detachClipboard() + detachClipboard = null + } + } + + function clearReconnectTimer() { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + } + + function scheduleReconnect() { + if (disposed || !wanted || !target) return + clearReconnectTimer() + // 自动重连不得复活已被回收或暂停的沙箱:只有用户点击才携带创建意图。 + allowProvision = false + const delay = Math.min( + RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempt, + RECONNECT_MAX_DELAY_MS, + ) + reconnectAttempt += 1 + reconnectTimer = setTimeout(() => { + reconnectTimer = null + void openDesktop() + }, delay) + } + + function buildUrl(ticket: string): string { + const query = new URLSearchParams({ ticket }) + if (allowProvision) { + query.set('provision', '1') + const agent = agentId.value + if (agent && agent !== 'builtin-quick-answer') query.set('agent_id', agent) + const sourceTenant = agentSourceTenantId.value + if (sourceTenant != null && String(sourceTenant).trim() !== '') { + query.set('agent_source_tenant_id', String(sourceTenant).trim()) + } + } + return `${resolveWsBase()}/api/v1/sessions/${encodeURIComponent(sessionId.value)}` + + `/sandbox/desktop?${query.toString()}` + } + + async function openDesktop() { + if (disposed || !wanted || opening || rfb || !target) return + const sid = sessionId.value + if (!sid) return + + opening = true + // 懒启动跑完要 3–8 秒,期间必须有明确反馈,否则用户以为卡死。 + status.value = 'starting' + let socket: WebSocket | undefined + try { + const ticket = await mintDesktopTicket(sid) + if (disposed || !wanted || !target) return + + socket = new WebSocket(buildUrl(ticket), ['binary']) + // 必须用 addEventListener:noVNC Websock.attach 会覆盖 socket.onclose。 + let nativeCloseReason = '' + socket.addEventListener('close', (event: CloseEvent) => { + nativeCloseReason = typeof event.reason === 'string' ? event.reason : '' + }, { once: true }) + if (disposed || !wanted || !target) { + socket.close() + return + } + + const client = new RFB(target, socket, { + // 故意不传 credentials:RFB 安全类型是 None,认证在 websockify 那一跳, + // 由后端加 Authorization 头完成。 + // 不传 wsProtocols:子协议已在 new WebSocket(..., ['binary']) 里协商。 + }) + client.scaleViewport = true + // 必须为 false。它同时是后端中继不解析 RFB opcode 251 的前提条件 + // (internal/handler/session/sandbox_desktop_rfb.go 的 rfbClientMessageSize)。 + // 为了「自适应分辨率」打开它,坏掉的是空闲判定而不是画面。 + client.resizeSession = false + client.background = '#1e1e1e' + client.focusOnClick = true + clearClipboardBridge() + detachClipboard = attachDesktopClipboard(client, target) + + client.addEventListener('connect', () => { + if (rfb !== client) return + everConnected = true + status.value = 'connected' + reconnectAttempt = 0 + }) + client.addEventListener('disconnect', () => { + if (rfb === client) rfb = null + if (disposed || !wanted) return + const mapped = nativeCloseReason ? statusFromCloseReason(nativeCloseReason) : null + if (mapped) { + status.value = mapped + if (mapped === 'idle' || mapped === 'needs_provision' || mapped === 'paused') return + if (mapped !== 'start_failed') return + } else if (!everConnected) { + // Handshake HTTP 409/502 never reaches JS. Reconnecting here while + // the first GET still holds the Redis slot is what the user sees + // as a sticky DESKTOP_BUSY overlay. + status.value = 'error' + return + } else { + status.value = 'disconnected' + } + scheduleReconnect() + }) + + rfb = client + } catch (err: unknown) { + if ( + socket + && (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) + ) { + socket.close() + } + rfb = null + if (disposed || !wanted) return + const httpStatus = httpStatusFromRejected(err) + const errorCode = errorCodeFromRejected(err) + status.value = httpStatus + ? statusFromHttpStatus(httpStatus, errorCode) + : 'error' + if (status.value === 'start_failed') scheduleReconnect() + } finally { + opening = false + } + } + + const session: SandboxDesktopSession = { + status, + connect(element, options?: { provision?: boolean }) { + if (disposed) return + wanted = true + target = element + allowProvision = options?.provision === true + reconnectAttempt = 0 + everConnected = false + clearReconnectTimer() + if (rfb) { + const client = rfb + rfb = null + // Drop wanted so the leftover disconnect cannot scheduleReconnect + // or overwrite paused/needs_provision before the new dial starts. + wanted = false + client.disconnect() + wanted = true + } + void openDesktop() + }, + disconnect() { + wanted = false + clearReconnectTimer() + clearClipboardBridge() + if (rfb) { + const client = rfb + rfb = null + client.disconnect() + } + status.value = 'idle' + }, + /** + * 上报键鼠活动。它只在后端 opcode 解析降级(遇到未知 opcode)时才是唯一的 + * 活动来源,正常情况下是冗余的——这也是它不能作为主信号的原因:不上报就能 + * 白嫖沙箱 TTL。 + */ + reportActivity() { + const now = Date.now() + if (now - lastActivityReport < ACTIVITY_REPORT_MIN_INTERVAL_MS) return + lastActivityReport = now + void post( + `/api/v1/sessions/${encodeURIComponent(sessionId.value)}/sandbox/desktop/activity`, + {}, + ).catch(() => { + // 上报失败不影响画面,也不该打扰用户。 + }) + }, + dispose() { + disposed = true + wanted = false + clearReconnectTimer() + clearClipboardBridge() + if (rfb) { + const client = rfb + rfb = null + client.disconnect() + } + target = null + }, + } + + onUnmounted(() => session.dispose()) + return session +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index e5351773c..b7a02e2cb 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1346,24 +1346,31 @@ export default { refreshTemplates: 'Refresh templates', templateSelectHelp: 'Templates are loaded from this cluster. The saved configuration stores the ID automatically.', templateSelectPlaceholder: 'Connect to the cluster to load templates', - templateLoadHint: 'Enter the cluster connection and refresh. If there is no WeKnora standard template, click Create; after changing DNS or the image, click Delete and rebuild.', + templateLoadHint: 'Enter the cluster connection and refresh. Missing WeKnora CLI templates are built from the official Hub image. Desktop templates are larger; create them from the row below. After changing DNS or the image, rebuild from that card.', templateLoadFailed: 'Failed to load templates', standardTemplateProvisioning: 'The WeKnora standard template is being created. Refresh shortly to see its status.', standardTemplateReplaced: 'The previous standard template was deleted and a rebuild has started. Wait until it is ready.', templateNotReady: 'The selected template is not ready. Refresh and wait for the build to finish.', connectionPassed: 'Connection verified. Templates below are loaded from this cluster.', connectionPassedTitle: 'Cluster connected', - templateStepHint: 'This step only lists templates already on the cluster; it does not create one automatically. Click Create WeKnora standard template if none exists, or Delete and rebuild after changing DNS or the image. You can continue after a template is ready.', + templateStepHint: 'This step lists cluster templates and builds the official CLI image if it is missing. Desktop (XFCE) templates are heavier and are created only when you click Create. After changing DNS or the image, rebuild that card. You can continue after a template is ready.', loadingTemplates: 'Loading templates from the cluster...', - templateBuildingHint: 'The standard template is being built. This list will refresh.', + templateBuildingHint: 'This template is being built. The list will refresh.', templateUntaggedHint: 'The builds finished but none carries the default tag, so sandbox creation cannot resolve this template. Delete it in E2B and refresh; WeKnora will rebuild it.', templateFailedReason: 'Build failed: {reason}', noTemplates: 'No templates were returned by this cluster.', weknoraStandardTemplate: 'WeKnora standard template', createStandardTemplate: 'Create', createStandardTemplateHint: 'Built with the current connection settings, including DNS. After changing those settings, rebuild from the card.', + weknoraDesktopTemplate: 'WeKnora desktop template', + createDesktopTemplate: 'Create', + createDesktopTemplateHint: 'Builds an XFCE graphical desktop from the official desktop image. It is much larger than the CLI template; create it only when you need a GUI.', replaceStandardTemplate: 'Rebuild', replaceStandardTemplateConfirm: 'Rebuild the WeKnora standard template with the current settings, including DNS. The previous spawnable template is not deleted until the replacement is ready.', + desktopTemplateProvisioning: 'The WeKnora desktop template is being created. Refresh shortly to see its status.', + desktopTemplateReplaced: 'The previous desktop template was deleted and a rebuild has started. Wait until it is ready. The CLI template is left untouched.', + desktopTemplateTag: 'Desktop', + replaceDesktopTemplateConfirm: 'Rebuild the WeKnora desktop template with the current settings, including DNS. The previous spawnable desktop template is not deleted until the replacement is ready. The CLI template is left untouched.', templateLockedBySkills: 'This sandbox already has skills. The skill environment is bound to the current snapshot, so the runtime template cannot be changed or rebuilt. Create a new sandbox and install skills from the new template.', templateLockedByInFlight: 'A skill is still installing or being removed. The runtime template cannot be changed or rebuilt until that finishes.', templateUnnamed: 'Unnamed template', @@ -1438,8 +1445,8 @@ export default { dockerNetworkNone: 'none (no egress)', defaultTimeout: 'Execution timeout (s)', defaultTimeoutHelp: 'Longest a single skill script may run before it is killed. Empty means 60 seconds.', - terminalIdleDisconnect: 'Terminal idle disconnect (s)', - terminalIdleDisconnectHelp: 'After the terminal is open, close it if there is no keyboard input or PTY output for this long so the sandbox can pause on its TTL. Empty means 900 seconds; minimum 60 seconds, maximum 24 hours.', + terminalIdleDisconnect: 'Terminal / desktop idle disconnect (s)', + terminalIdleDisconnectHelp: 'After the terminal or desktop is open, close it if there is no interaction for this long so the sandbox can pause on its TTL. Terminal counts keyboard and PTY output; desktop counts mouse and keyboard. Empty means 900 seconds; minimum 60 seconds, maximum 24 hours.', envVars: 'Environment variables', envKey: 'Name', envValue: 'Value', @@ -3547,7 +3554,19 @@ export default { artifactsAll: 'All files', artifactsSearch: 'Search filenames', artifactsNoMatches: 'No matching files', - desktopPlaceholder: 'Desktop visualization coming soon', + desktopNotStarted: 'The desktop is not connected yet. Connecting it attaches to this conversation\'s sandbox.', + desktopStart: 'Connect desktop', + desktopStarting: 'Connecting to the desktop… (3-8 seconds on first use)', + desktopUnsupported: 'This sandbox config has no desktop. Pick a desktop-image template in the workspace sandbox settings, on a Cube or E2B backend.', + desktopBusy: 'This conversation already has a desktop open. Only one connection is allowed at a time, otherwise two people share one keyboard and mouse.', + desktopStartFailed: 'The desktop failed to start. You can retry.', + desktopRebuilt: 'The sandbox was rebuilt for a skill update, so the previous desktop and any unsaved work are gone. Reconnecting gives you a fresh desktop.', + desktopNeedsProvision: 'This conversation has no running sandbox. Create and connect starts a new sandbox, billed according to your workspace configuration.', + desktopCreateAndStart: 'Create and connect', + desktopPaused: 'This conversation\'s sandbox is paused. Connecting the desktop resumes it.', + desktopDisconnected: 'Desktop disconnected', + desktopIdleDisconnected: 'The desktop disconnected after being idle. The sandbox will pause on its own TTL. You can reconnect.', + desktopRetry: 'Reconnect', notStarted: 'The terminal is not running yet. Starting it connects to this conversation\'s sandbox, creating or resuming one if none is available.', paused: 'This conversation\'s sandbox is paused. Starting the terminal resumes it.', start: 'Start terminal', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 156a8400e..a3bbdbd96 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -1346,24 +1346,31 @@ export default { refreshTemplates: 'テンプレートを更新', templateSelectHelp: 'テンプレートはこのクラスタから読み込まれます。保存した設定にはIDが自動的に記録されます。', templateSelectPlaceholder: 'クラスタに接続してテンプレートを読み込んでください', - templateLoadHint: 'クラスタの接続情報を入力して更新してください。WeKnora標準テンプレートがない場合は「作成」をクリックし、DNSやイメージを変更した場合は「再構築」をクリックしてください。', + templateLoadHint: 'クラスタの接続情報を入力して更新してください。公式のCLIテンプレートがない場合はHubイメージから自動構築します。デスクトップテンプレートはサイズが大きいため、下の行から個別に作成してください。DNSやイメージを変更した場合は、該当カードから再構築してください。', templateLoadFailed: 'テンプレートの読み込みに失敗しました', standardTemplateProvisioning: 'WeKnora標準テンプレートを作成しています。しばらくしてから更新してステータスを確認してください。', standardTemplateReplaced: '以前の標準テンプレートを削除し、再構築を開始しました。完了までお待ちください。', templateNotReady: '選択したテンプレートは準備ができていません。更新してビルドの完了をお待ちください。', connectionPassed: '接続を確認しました。以下のテンプレートはこのクラスタから読み込まれています。', connectionPassedTitle: 'クラスタに接続しました', - templateStepHint: 'このステップではクラスタ上にすでに存在するテンプレートのみを表示し、自動的には作成しません。テンプレートがない場合は「作成」をクリックし、DNSやイメージを変更した場合は「再構築」をクリックしてください。テンプレートの準備ができたら次へ進めます。', + templateStepHint: 'このステップではクラスタのテンプレートを表示し、公式のCLIイメージがなければHubから構築します。デスクトップ(XFCE)テンプレートはサイズが大きいため、「作成」を押したときだけ構築します。DNSやイメージを変更した場合は該当カードから「再構築」してください。テンプレートの準備ができたら次へ進めます。', loadingTemplates: 'クラスタからテンプレートを読み込み中...', - templateBuildingHint: '標準テンプレートをビルド中です。このリストは自動的に更新されます。', + templateBuildingHint: 'テンプレートをビルド中です。このリストは自動的に更新されます。', templateUntaggedHint: 'ビルドは完了しましたが、デフォルトタグが付いたものがないため、サンドボックス作成時にこのテンプレートを解決できません。E2Bで削除して更新すると、WeKnoraが再構築します。', templateFailedReason: 'ビルドに失敗しました: {reason}', noTemplates: 'このクラスタからテンプレートが返されませんでした。', weknoraStandardTemplate: 'WeKnora標準テンプレート', createStandardTemplate: '作成', createStandardTemplateHint: 'DNSを含む現在の接続設定でビルドされます。設定を変更した場合は、カードから再構築してください。', + weknoraDesktopTemplate: 'WeKnoraデスクトップテンプレート', + createDesktopTemplate: '作成', + createDesktopTemplateHint: '公式デスクトップイメージからXFCEグラフィカルデスクトップを構築します。CLIテンプレートよりはるかに大きいため、GUIが必要なときだけ作成してください。', replaceStandardTemplate: '再構築', replaceStandardTemplateConfirm: 'DNSを含む現在の設定でWeKnora標準テンプレートを再構築します。既存の起動可能なテンプレートは、新しいテンプレートの準備が整うまで削除されません。', + desktopTemplateProvisioning: 'WeKnoraデスクトップテンプレートを作成しています。しばらくしてから更新してステータスを確認してください。', + desktopTemplateReplaced: '以前のデスクトップテンプレートを削除し、再構築を開始しました。完了までお待ちください。CLIテンプレートは変更しません。', + desktopTemplateTag: 'デスクトップ', + replaceDesktopTemplateConfirm: 'DNSを含む現在の設定でWeKnoraデスクトップテンプレートを再構築します。既存の起動可能なデスクトップテンプレートは、新しいテンプレートの準備が整うまで削除されません。CLIテンプレートは変更しません。', templateLockedBySkills: 'このサンドボックスにはすでにスキルがあります。スキル環境は現在のスナップショットに紐づいているため、ランタイムテンプレートの変更や再構築はできません。新しいサンドボックスを作成し、新しいテンプレートからスキルをインストールしてください。', templateLockedByInFlight: 'スキルのインストールまたは削除が進行中です。完了するまでランタイムテンプレートの変更や再構築はできません。', templateUnnamed: '無題のテンプレート', @@ -1438,8 +1445,8 @@ export default { dockerNetworkNone: 'none(外向き通信なし)', defaultTimeout: '実行タイムアウト(秒)', defaultTimeoutHelp: 'スキルスクリプト1回あたりの最長実行時間です。これを超えると強制終了されます。空欄の場合は60秒です。', - terminalIdleDisconnect: 'ターミナルのアイドル切断(秒)', - terminalIdleDisconnectHelp: 'ターミナルを開いた後、この時間だけキーボード入力もPTY出力もなければ接続を切断し、サンドボックスはTTLに従って一時停止します。空欄の場合は900秒、最短60秒、最長24時間です。', + terminalIdleDisconnect: 'ターミナル / デスクトップのアイドル切断(秒)', + terminalIdleDisconnectHelp: 'ターミナルまたはデスクトップを開いた後、この時間だけ操作がなければ接続を切断し、サンドボックスはTTLに従って一時停止します。ターミナルはキーボードとPTY出力、デスクトップはマウスとキーボードを見ます。空欄の場合は900秒、最短60秒、最長24時間です。', envVars: '環境変数', envKey: '名前', envValue: '値', @@ -3547,7 +3554,19 @@ export default { artifactsAll: 'すべて', artifactsSearch: 'ファイル名を検索', artifactsNoMatches: '一致するファイルがありません', - desktopPlaceholder: 'デスクトップの可視化は近日対応予定です', + desktopNotStarted: 'The desktop is not connected yet. Connecting it attaches to this conversation\'s sandbox.', + desktopStart: 'Connect desktop', + desktopStarting: 'Connecting to the desktop… (3-8 seconds on first use)', + desktopUnsupported: 'This sandbox config has no desktop. Pick a desktop-image template in the workspace sandbox settings, on a Cube or E2B backend.', + desktopBusy: 'This conversation already has a desktop open. Only one connection is allowed at a time, otherwise two people share one keyboard and mouse.', + desktopStartFailed: 'The desktop failed to start. You can retry.', + desktopRebuilt: 'The sandbox was rebuilt for a skill update, so the previous desktop and any unsaved work are gone. Reconnecting gives you a fresh desktop.', + desktopNeedsProvision: 'This conversation has no running sandbox. Create and connect starts a new sandbox, billed according to your workspace configuration.', + desktopCreateAndStart: 'Create and connect', + desktopPaused: 'This conversation\'s sandbox is paused. Connecting the desktop resumes it.', + desktopDisconnected: 'Desktop disconnected', + desktopIdleDisconnected: 'The desktop disconnected after being idle. The sandbox will pause on its own TTL. You can reconnect.', + desktopRetry: 'Reconnect', notStarted: 'ターミナルはまだ起動していません。起動するとこの会話のサンドボックスに接続し、存在しないか一時停止中の場合は作成または再開します。', paused: 'この会話のサンドボックスは一時停止中です。ターミナルを起動すると再開します。', start: 'ターミナルを起動', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 259fcf538..a6c27a29b 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -3604,7 +3604,19 @@ export default { artifactsAll: '전체', artifactsSearch: '파일 이름 검색', artifactsNoMatches: '일치하는 파일이 없습니다', - desktopPlaceholder: '데스크톱 시각화는 곧 지원될 예정입니다', + desktopNotStarted: 'The desktop is not connected yet. Connecting it attaches to this conversation\'s sandbox.', + desktopStart: 'Connect desktop', + desktopStarting: 'Connecting to the desktop… (3-8 seconds on first use)', + desktopUnsupported: 'This sandbox config has no desktop. Pick a desktop-image template in the workspace sandbox settings, on a Cube or E2B backend.', + desktopBusy: 'This conversation already has a desktop open. Only one connection is allowed at a time, otherwise two people share one keyboard and mouse.', + desktopStartFailed: 'The desktop failed to start. You can retry.', + desktopRebuilt: 'The sandbox was rebuilt for a skill update, so the previous desktop and any unsaved work are gone. Reconnecting gives you a fresh desktop.', + desktopNeedsProvision: 'This conversation has no running sandbox. Create and connect starts a new sandbox, billed according to your workspace configuration.', + desktopCreateAndStart: 'Create and connect', + desktopPaused: 'This conversation\'s sandbox is paused. Connecting the desktop resumes it.', + desktopDisconnected: 'Desktop disconnected', + desktopIdleDisconnected: 'The desktop disconnected after being idle. The sandbox will pause on its own TTL. You can reconnect.', + desktopRetry: 'Reconnect', notStarted: '터미널이 아직 실행되지 않았습니다. 시작하면 이 대화의 샌드박스에 연결하며, 샌드박스가 없거나 일시 중지된 경우 생성하거나 다시 시작합니다.', paused: '이 대화의 샌드박스가 일시 중지되었습니다. 터미널을 시작하면 다시 시작됩니다.', start: '터미널 시작', @@ -5503,24 +5515,31 @@ export default { refreshTemplates: 'Refresh templates', templateSelectHelp: 'Templates are loaded from this cluster. The saved configuration stores the ID automatically.', templateSelectPlaceholder: 'Connect to the cluster to load templates', - templateLoadHint: '클러스터 연결을 입력한 뒤 새로고침하세요. WeKnora 표준 템플릿이 없으면 「만들기」를, DNS나 이미지를 바꾼 뒤에는 「삭제 후 재구축」을 누르세요.', + templateLoadHint: '클러스터 연결을 입력한 뒤 새로고침하세요. 공식 CLI 템플릿이 없으면 Hub 이미지로 자동 구축합니다. 데스크톱 템플릿은 용량이 커서 아래 행에서 따로 만들어야 합니다. DNS나 이미지를 바꾼 뒤에는 해당 카드에서 재구축하세요.', templateLoadFailed: 'Failed to load templates', standardTemplateProvisioning: 'The WeKnora standard template is being created. Refresh shortly to see its status.', standardTemplateReplaced: '기존 표준 템플릿을 삭제했고 재구축을 시작했습니다. 준비될 때까지 기다리세요.', templateNotReady: 'The selected template is not ready. Refresh and wait for the build to finish.', connectionPassed: 'Connection verified. Templates below are loaded from this cluster.', connectionPassedTitle: 'Cluster connected', - templateStepHint: '이 단계에서는 클러스터에 이미 있는 템플릿만 나열하며 자동으로 만들지 않습니다. 표준 템플릿이 없으면 「WeKnora 표준 템플릿 만들기」를, DNS나 이미지를 바꾼 뒤에는 「삭제 후 재구축」을 누르세요. 템플릿이 준비된 뒤에 계속할 수 있습니다.', + templateStepHint: '이 단계에서는 클러스터 템플릿을 나열하고, 공식 CLI 이미지가 없으면 Hub에서 구축합니다. 데스크톱(XFCE) 템플릿은 용량이 커서 「만들기」를 눌렀을 때만 구축합니다. DNS나 이미지를 바꾼 뒤에는 해당 카드에서 「재구축」을 누르세요. 템플릿이 준비된 뒤에 계속할 수 있습니다.', loadingTemplates: 'Loading templates from the cluster...', - templateBuildingHint: '표준 템플릿을 구축하는 중입니다. 목록이 자동으로 새로고침됩니다.', + templateBuildingHint: '템플릿을 구축하는 중입니다. 목록이 자동으로 새로고침됩니다.', templateUntaggedHint: '빌드는 끝났지만 default 태그가 붙은 빌드가 없어 샌드박스 생성 시 이 템플릿을 찾을 수 없습니다. E2B에서 템플릿을 삭제하고 새로고침하면 WeKnora가 다시 빌드합니다.', templateFailedReason: '빌드 실패: {reason}', noTemplates: 'No templates were returned by this cluster.', weknoraStandardTemplate: 'WeKnora 표준 템플릿', createStandardTemplate: '만들기', createStandardTemplateHint: '현재 연결 설정(DNS 포함)으로 구축합니다. 설정을 바꾼 뒤에는 카드에서 재구축하세요.', + weknoraDesktopTemplate: 'WeKnora 데스크톱 템플릿', + createDesktopTemplate: '만들기', + createDesktopTemplateHint: '공식 데스크톱 이미지로 XFCE 그래픽 데스크톱을 구축합니다. CLI 템플릿보다 훨씬 크니 GUI가 필요할 때만 만드세요.', replaceStandardTemplate: '재구축', replaceStandardTemplateConfirm: '현재 설정(DNS 포함)으로 WeKnora 표준 템플릿을 다시 만듭니다. 새 템플릿이 준비되기 전에는 기존에 쓸 수 있는 템플릿을 삭제하지 않습니다.', + desktopTemplateProvisioning: 'WeKnora 데스크톱 템플릿을 만들고 있습니다. 잠시 후 새로고침해 상태를 확인하세요.', + desktopTemplateReplaced: '기존 데스크톱 템플릿을 삭제했고 재구축을 시작했습니다. 준비될 때까지 기다리세요. CLI 템플릿은 그대로 둡니다.', + desktopTemplateTag: '데스크톱', + replaceDesktopTemplateConfirm: '현재 설정(DNS 포함)으로 WeKnora 데스크톱 템플릿을 다시 만듭니다. 새 템플릿이 준비되기 전에는 기존에 쓸 수 있는 데스크톱 템플릿을 삭제하지 않으며, CLI 템플릿은 그대로 둡니다.', templateLockedBySkills: '이 샌드박스에는 이미 Skill이 설치되어 있습니다. 스킬 환경이 현재 스냅샷에 묶여 있어 런타임 템플릿을 바꾸거나 재구축할 수 없습니다. 새 샌드박스를 만들고 새 템플릿에서 Skill을 다시 설치하세요.', templateLockedByInFlight: 'Skill을 설치하거나 제거하는 중입니다. 끝날 때까지 런타임 템플릿을 바꾸거나 재구축할 수 없습니다.', templateUnnamed: '이름 없는 템플릿', @@ -5595,8 +5614,8 @@ export default { dockerNetworkNone: 'none (no egress)', defaultTimeout: 'Execution timeout (s)', defaultTimeoutHelp: '스킬 스크립트 한 번이 실행될 수 있는 최대 시간이며, 초과하면 강제 종료됩니다. 비워 두면 60초입니다.', - terminalIdleDisconnect: '터미널 유휴 해제 (초)', - terminalIdleDisconnectHelp: '터미널을 연 뒤 키보드 입력이나 PTY 출력이 이 시간 동안 없으면 연결을 끊어 샌드박스가 TTL에 따라 일시 중지되도록 합니다. 비워 두면 900초이며, 최소 60초, 최대 24시간입니다.', + terminalIdleDisconnect: '터미널 / 데스크톱 유휴 해제 (초)', + terminalIdleDisconnectHelp: '터미널 또는 데스크톱을 연 뒤 이 시간 동안 조작이 없으면 연결을 끊어 샌드박스가 TTL에 따라 일시 중지되도록 합니다. 터미널은 키보드와 PTY 출력을, 데스크톱은 마우스와 키보드를 봅니다. 비워 두면 900초이며, 최소 60초, 최대 24시간입니다.', envVars: 'Environment variables', envKey: 'Name', envValue: 'Value', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index e78e1fdc5..9ff388a00 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -3604,7 +3604,19 @@ export default { artifactsAll: 'Все файлы', artifactsSearch: 'Поиск по имени файла', artifactsNoMatches: 'Нет подходящих файлов', - desktopPlaceholder: 'Визуализация рабочего стола скоро появится', + desktopNotStarted: 'The desktop is not connected yet. Connecting it attaches to this conversation\'s sandbox.', + desktopStart: 'Connect desktop', + desktopStarting: 'Connecting to the desktop… (3-8 seconds on first use)', + desktopUnsupported: 'This sandbox config has no desktop. Pick a desktop-image template in the workspace sandbox settings, on a Cube or E2B backend.', + desktopBusy: 'This conversation already has a desktop open. Only one connection is allowed at a time, otherwise two people share one keyboard and mouse.', + desktopStartFailed: 'The desktop failed to start. You can retry.', + desktopRebuilt: 'The sandbox was rebuilt for a skill update, so the previous desktop and any unsaved work are gone. Reconnecting gives you a fresh desktop.', + desktopNeedsProvision: 'This conversation has no running sandbox. Create and connect starts a new sandbox, billed according to your workspace configuration.', + desktopCreateAndStart: 'Create and connect', + desktopPaused: 'This conversation\'s sandbox is paused. Connecting the desktop resumes it.', + desktopDisconnected: 'Desktop disconnected', + desktopIdleDisconnected: 'The desktop disconnected after being idle. The sandbox will pause on its own TTL. You can reconnect.', + desktopRetry: 'Reconnect', notStarted: 'Терминал ещё не запущен. Запуск подключит его к песочнице этого диалога, создав или возобновив её при необходимости.', paused: 'Песочница этого диалога приостановлена. Запуск терминала возобновит её.', start: 'Запустить терминал', @@ -5503,24 +5515,31 @@ export default { refreshTemplates: 'Refresh templates', templateSelectHelp: 'Templates are loaded from this cluster. The saved configuration stores the ID automatically.', templateSelectPlaceholder: 'Connect to the cluster to load templates', - templateLoadHint: 'Введите подключение к кластеру и обновите список. Если стандартного шаблона WeKnora нет, нажмите «Создать»; после смены DNS или образа — «Удалить и пересобрать».', + templateLoadHint: 'Введите подключение к кластеру и обновите список. Отсутствующий официальный шаблон CLI собирается из образа Hub. Шаблоны рабочего стола больше по размеру — создайте их отдельной строкой ниже. После смены DNS или образа пересоберите шаблон на соответствующей карточке.', templateLoadFailed: 'Failed to load templates', standardTemplateProvisioning: 'The WeKnora standard template is being created. Refresh shortly to see its status.', standardTemplateReplaced: 'Предыдущий стандартный шаблон удалён, начата пересборка. Дождитесь готовности.', templateNotReady: 'The selected template is not ready. Refresh and wait for the build to finish.', connectionPassed: 'Connection verified. Templates below are loaded from this cluster.', connectionPassedTitle: 'Cluster connected', - templateStepHint: 'На этом шаге только перечисляются шаблоны, уже есть в кластере; ничего не создаётся автоматически. Если стандартного шаблона нет, нажмите «Создать стандартный шаблон WeKnora»; после смены DNS или образа — «Удалить и пересобрать». Продолжить можно, когда шаблон готов.', + templateStepHint: 'На этом шаге перечисляются шаблоны кластера; официальный образ CLI собирается из Hub, если его ещё нет. Шаблоны рабочего стола (XFCE) тяжелее и создаются только по кнопке «Создать». После смены DNS или образа пересоберите шаблон на соответствующей карточке. Продолжить можно, когда шаблон готов.', loadingTemplates: 'Loading templates from the cluster...', - templateBuildingHint: 'Стандартный шаблон собирается. Список обновится автоматически.', + templateBuildingHint: 'Шаблон собирается. Список обновится автоматически.', templateUntaggedHint: 'Сборки завершены, но ни одна не имеет тега default, поэтому при создании песочницы шаблон не находится. Удалите его в E2B и обновите список — WeKnora пересоберёт шаблон.', templateFailedReason: 'Сборка не удалась: {reason}', noTemplates: 'No templates were returned by this cluster.', weknoraStandardTemplate: 'Стандартный шаблон WeKnora', createStandardTemplate: 'Создать', createStandardTemplateHint: 'Собирается с текущими настройками подключения, включая DNS. После их изменения пересоберите шаблон на карточке.', + weknoraDesktopTemplate: 'Шаблон рабочего стола WeKnora', + createDesktopTemplate: 'Создать', + createDesktopTemplateHint: 'Собирает графический рабочий стол XFCE из официального образа. Он заметно больше шаблона CLI — создавайте его, только если нужен графический интерфейс.', replaceStandardTemplate: 'Пересобрать', replaceStandardTemplateConfirm: 'Стандартный шаблон WeKnora будет собран заново с текущими настройками, включая DNS. Предыдущий готовый шаблон не удаляется, пока замена не станет готовой к запуску.', + desktopTemplateProvisioning: 'Шаблон рабочего стола WeKnora создаётся. Обновите список чуть позже, чтобы увидеть статус.', + desktopTemplateReplaced: 'Предыдущий шаблон рабочего стола удалён, начата пересборка. Дождитесь готовности. Шаблон CLI не затрагивается.', + desktopTemplateTag: 'Рабочий стол', + replaceDesktopTemplateConfirm: 'Шаблон рабочего стола WeKnora будет собран заново с текущими настройками, включая DNS. Предыдущий готовый шаблон рабочего стола не удаляется, пока замена не станет готовой к запуску. Шаблон CLI не затрагивается.', templateLockedBySkills: 'В этой песочнице уже установлены навыки. Среда навыков привязана к текущему снимку, поэтому шаблон нельзя сменить или пересобрать. Создайте новую песочницу и установите навыки с нового шаблона.', templateLockedByInFlight: 'Навык ещё устанавливается или удаляется. Шаблон нельзя сменить или пересобрать, пока это не закончится.', templateUnnamed: 'Шаблон без имени', @@ -5595,8 +5614,8 @@ export default { dockerNetworkNone: 'none (no egress)', defaultTimeout: 'Execution timeout (s)', defaultTimeoutHelp: 'Максимальное время одного запуска скрипта навыка; по истечении он прерывается. Пусто — 60 секунд.', - terminalIdleDisconnect: 'Отключение простоя терминала (с)', - terminalIdleDisconnectHelp: 'После открытия терминала соединение закрывается, если столько времени нет ввода с клавиатуры и вывода PTY, чтобы песочница могла приостановиться по TTL. Пусто — 900 секунд; минимум 60 секунд, максимум 24 часа.', + terminalIdleDisconnect: 'Отключение простоя терминала / рабочего стола (с)', + terminalIdleDisconnectHelp: 'После открытия терминала или рабочего стола соединение закрывается, если столько времени нет взаимодействия, чтобы песочница могла приостановиться по TTL. Для терминала считаются клавиатура и вывод PTY, для рабочего стола — мышь и клавиатура. Пусто — 900 секунд; минимум 60 секунд, максимум 24 часа.', envVars: 'Environment variables', envKey: 'Name', envValue: 'Value', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 6e30c8480..ae7cafe37 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3606,7 +3606,19 @@ export default { artifactsAll: '全部', artifactsSearch: '搜索文件名', artifactsNoMatches: '没有匹配的文件', - desktopPlaceholder: '桌面可视化即将支持', + desktopNotStarted: '桌面尚未连接。连接后会接入本会话的沙箱环境。', + desktopStart: '连接桌面', + desktopStarting: '正在连接桌面…(首次约需 3–8 秒)', + desktopUnsupported: '当前沙箱配置不支持桌面。需要在工作区沙箱设置的模板列表里选中带桌面的镜像,且后端为 Cube 或 E2B。', + desktopBusy: '本会话已有一个桌面连接。桌面同一时刻只允许一个连接,否则会出现两个人抢同一套键鼠。', + desktopStartFailed: '桌面启动失败,可以重试。', + desktopRebuilt: '沙箱已因技能更新重建,原桌面及未保存的内容已丢失。重新连接会得到一个全新桌面。', + desktopNeedsProvision: '本会话当前没有运行中的沙箱环境。创建并连接会新建一个沙箱,并按工作区配置计费。', + desktopCreateAndStart: '创建并连接', + desktopPaused: '本会话的沙箱已暂停。连接桌面会唤醒它。', + desktopDisconnected: '桌面连接已断开', + desktopIdleDisconnected: '桌面空闲已断开,沙箱将按工作区 TTL 自行暂停。可重新连接。', + desktopRetry: '重新连接', notStarted: '终端尚未启动。启动后会连接本会话的沙箱环境;若沙箱不存在或已暂停,将为你创建或唤醒它。', paused: '本会话的沙箱已暂停。启动终端会唤醒它。', start: '启动终端', @@ -5505,24 +5517,31 @@ export default { refreshTemplates: '刷新模板', templateSelectHelp: '模板由当前集群实时返回,保存时仅记录模板 ID,无需手工复制。', templateSelectPlaceholder: '先连接集群并加载模板', - templateLoadHint: '填写集群连接后刷新模板。没有 WeKnora 标准模板时点「创建」;改 DNS 或镜像后点「删除并重建」。', + templateLoadHint: '填写集群连接后刷新模板。没有官方标准模板时,会从 Docker Hub 镜像自动构建。桌面模板体积更大,需在下方单独点「创建」。改 DNS 或镜像后在对应卡片上点「重建」。', templateLoadFailed: '模板列表加载失败', standardTemplateProvisioning: '已开始在集群中创建 WeKnora 标准模板,请稍后刷新查看状态', standardTemplateReplaced: '已删除原标准模板并开始重建,请等待状态就绪', templateNotReady: '所选模板尚未构建完成,请刷新并等待状态就绪', connectionPassed: '连接验证通过,以下模板均从当前集群实时加载。', connectionPassedTitle: '集群连接成功', - templateStepHint: '进入此步只会列出集群已有模板,不会自动创建。没有标准模板时点「创建 WeKnora 标准模板」;改 DNS 或镜像后点「删除并重建」。模板就绪后才能继续。', + templateStepHint: '进入此步会列出集群模板;若还没有官方标准模板,会从 Docker Hub 镜像自动构建。桌面模板(XFCE)体积较大,需单独点「创建」。改 DNS 或镜像后在对应卡片上点「重建」。选好并就绪后才能继续。', loadingTemplates: '正在从集群加载模板…', - templateBuildingHint: '标准模板正在构建,列表会自动刷新。', + templateBuildingHint: '模板正在构建,列表会自动刷新。', templateUntaggedHint: '构建已完成,但没有构建带 default 标签,创建沙箱时无法解析。请在 E2B 删除该模板,刷新后 WeKnora 会重新构建。', templateFailedReason: '构建失败:{reason}', noTemplates: '当前集群未返回可用模板。', weknoraStandardTemplate: 'WeKnora 标准模板', createStandardTemplate: '创建', createStandardTemplateHint: '按当前连接配置构建,包含 DNS。之后改配置可在卡片上重建。', + weknoraDesktopTemplate: 'WeKnora 桌面模板', + createDesktopTemplate: '创建', + createDesktopTemplateHint: '从官方桌面镜像构建 XFCE 图形桌面。体积明显大于标准模板,仅在需要图形界面时创建。', replaceStandardTemplate: '重建', replaceStandardTemplateConfirm: '将用当前配置(含 DNS)重建 WeKnora 标准模板。新模板就绪前不会删除仍可启动的旧模板。', + desktopTemplateProvisioning: '已开始在集群中创建 WeKnora 桌面模板,请稍后刷新查看状态', + desktopTemplateReplaced: '已删除原桌面模板并开始重建,请等待状态就绪。标准(CLI)模板不受影响。', + desktopTemplateTag: '桌面', + replaceDesktopTemplateConfirm: '将用当前配置(含 DNS)重建 WeKnora 桌面模板。新模板就绪前不会删除仍可启动的旧桌面模板,也不会删除标准(CLI)模板。', templateLockedBySkills: '该沙箱已安装 Skill,技能环境绑在当前快照上,不能更换或重建运行模板。请新建一份沙箱,从新模板再装 Skill。', templateLockedByInFlight: '该沙箱正在安装或移除 Skill,完成前不能更换或重建运行模板。', templateUnnamed: '未命名模板', @@ -5597,8 +5616,8 @@ export default { dockerNetworkNone: 'none(禁止出网)', defaultTimeout: '执行超时(秒)', defaultTimeoutHelp: '单次技能脚本允许运行的最长时间,超时会被强制终止。留空按 60 秒。', - terminalIdleDisconnect: '交互式终端空闲断开(秒)', - terminalIdleDisconnectHelp: '打开终端后,这段时间内没有键盘输入或终端输出就断开连接,沙箱随后按 TTL 自行暂停。留空按 900 秒;最短 60 秒,最长 24 小时。', + terminalIdleDisconnect: '终端 / 桌面空闲断开(秒)', + terminalIdleDisconnectHelp: '打开终端或桌面后,这段时间内没有交互就断开连接,沙箱随后按 TTL 自行暂停。终端看键盘和输出,桌面看键鼠。留空按 900 秒;最短 60 秒,最长 24 小时。', envVars: '环境变量', envKey: '变量名', envValue: '变量值', diff --git a/frontend/src/types/novnc.d.ts b/frontend/src/types/novnc.d.ts new file mode 100644 index 000000000..7d0c88a62 --- /dev/null +++ b/frontend/src/types/novnc.d.ts @@ -0,0 +1,72 @@ +/** + * Hand-written types for @novnc/novnc. + * + * The package ships no .d.ts. Only the surface SandboxDesktop.vue uses is + * declared here — adding a member is cheap, and a wrong guess about a member + * we do not call would be worse than not having it. + * + * `"exports"` is the string `"./core/rfb.js"`, so the only valid import + * specifier is `@novnc/novnc`. Vite 7 rejects the deep subpath. + */ +declare module '@novnc/novnc' { + export interface RFBCredentials { + username?: string + password?: string + target?: string + } + + export interface RFBOptions { + /** + * Not used by WeKnora: the desktop authenticates one hop out, on + * websockify, and the RFB security type is None. Declared so a future + * reader can see that leaving it out is a decision, not an omission. + */ + credentials?: RFBCredentials + shared?: boolean + repeaterID?: string + wsProtocols?: string[] + } + + export default class RFB extends EventTarget { + constructor(target: HTMLElement, urlOrChannel: string | WebSocket, options?: RFBOptions) + + /** Scale the framebuffer to the container instead of scrolling it. */ + scaleViewport: boolean + /** + * MUST stay false. It is also the precondition for the backend relay not + * having to parse RFB opcode 251 (SetDesktopSize); see + * rfbClientMessageSize in internal/handler/session/sandbox_desktop_rfb.go. + */ + resizeSession: boolean + clipViewport: boolean + viewOnly: boolean + background: string + focusOnClick: boolean + + /** + * Push local clipboard text to the server as ClientCutText / extended + * clipboard. Classic CutText is Latin-1; code points above 0xff become + * '?' unless the server advertised the TigerVNC extended clipboard. + */ + clipboardPasteFrom(text: string): void + /** + * Send one key. `down` omitted means press then release. Used to turn a + * local Cmd/Ctrl+V into a Linux Ctrl+V after the clipboard has been + * synced. + */ + sendKey(keysym: number, code: string, down?: boolean): void + + disconnect(): void + focus(): void + blur(): void + + addEventListener( + type: 'connect' | 'disconnect' | 'credentialsrequired' | 'securityfailure' | 'clipboard', + listener: (event: CustomEvent) => void, + ): void + removeEventListener( + type: 'connect' | 'disconnect' | 'credentialsrequired' | 'securityfailure' | 'clipboard', + listener: (event: CustomEvent) => void, + ): void + } +} diff --git a/frontend/src/views/chat/components/SandboxDesktop.test.mjs b/frontend/src/views/chat/components/SandboxDesktop.test.mjs new file mode 100644 index 000000000..2db917646 --- /dev/null +++ b/frontend/src/views/chat/components/SandboxDesktop.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' + +const desktop = readFileSync(new URL('./SandboxDesktop.vue', import.meta.url), 'utf8') + +test('panel open looks up a running sandbox and only provisions on an explicit click', () => { + assert.match(desktop, /connectLookup/) + assert.match(desktop, /onMounted\(\(\) => \{\s*connectLookup\(\)/) + assert.match(desktop, /provision:\s*false/) + assert.match(desktop, /provision:\s*true/) + assert.match(desktop, /status === 'paused'/) + assert.match(desktop, /chat\.sandbox\.desktopPaused/) + assert.match(desktop, /chat\.sandbox\.desktopCreateAndStart/) +}) diff --git a/frontend/src/views/chat/components/SandboxDesktop.vue b/frontend/src/views/chat/components/SandboxDesktop.vue new file mode 100644 index 000000000..1c079abef --- /dev/null +++ b/frontend/src/views/chat/components/SandboxDesktop.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/views/chat/components/SandboxTerminal.test.mjs b/frontend/src/views/chat/components/SandboxTerminal.test.mjs index 80332b86a..f6aa9449b 100644 --- a/frontend/src/views/chat/components/SandboxTerminal.test.mjs +++ b/frontend/src/views/chat/components/SandboxTerminal.test.mjs @@ -24,6 +24,18 @@ test('xterm palette keeps prompt green but ls directories blue', () => { assert.doesNotMatch(prompt, /\]\\w\\\[/) }) +test('hidden terminal does not resize the live PTY to a zero-size fit', () => { + assert.match(terminal, /function containerHasPtySize/) + assert.match(terminal, /clientWidth >= 20/) + assert.match(terminal, /clientHeight >= 20/) + const start = terminal.indexOf('function applyFit') + const end = terminal.indexOf('function fitAndFocus') + assert.ok(start >= 0 && end > start) + const applyFit = terminal.slice(start, end) + assert.match(applyFit, /containerHasPtySize\(\)/) + assert.match(applyFit, /xterm\.cols < 2 \|\| xterm\.rows < 2/) +}) + test('PTY output attaches after the first fit so FitAddon cannot wipe the prompt', () => { const start = terminal.indexOf('function mountTerminal') const end = terminal.indexOf('function unmountTerminal') diff --git a/frontend/src/views/chat/components/SandboxTerminal.vue b/frontend/src/views/chat/components/SandboxTerminal.vue index 4889a34fb..113ec973c 100644 --- a/frontend/src/views/chat/components/SandboxTerminal.vue +++ b/frontend/src/views/chat/components/SandboxTerminal.vue @@ -324,20 +324,35 @@ function schedulePromptNudge() { }, PTY_PROMPT_NUDGE_DELAY_MS); } +// v-show 把终端藏起来时容器是 0×0。FitAddon 仍可能算出 2×1 并送到 PTY, +// 切回终端 tab 时 bash 还停在那组尺寸上,看起来像没连上。尺寸不够就不动。 +function containerHasPtySize() { + const el = containerRef.value; + return !!el && el.clientWidth >= 20 && el.clientHeight >= 20; +} + function applyFit() { - if (!xterm) return; + if (!xterm || !containerHasPtySize()) return; try { fitAddon?.fit(); } catch { // fit 在容器尺寸为 0 时会抛错,忽略即可。 + return; } + if (xterm.cols < 2 || xterm.rows < 2) return; terminal.resize(xterm.cols, xterm.rows); xterm.refresh(0, xterm.rows - 1); } function fitAndFocus() { - applyFit(); - xterm?.focus(); + // v-show 刚打开时 nextTick 里布局可能还没完成,再等两帧再 fit。 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (unmounted) return; + applyFit(); + xterm?.focus(); + }); + }); } defineExpose({ diff --git a/internal/application/service/sandbox_desktop_last.go b/internal/application/service/sandbox_desktop_last.go new file mode 100644 index 000000000..a1dfc4b6e --- /dev/null +++ b/internal/application/service/sandbox_desktop_last.go @@ -0,0 +1,122 @@ +package service + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/Tencent/WeKnora/internal/logger" + "github.com/redis/go-redis/v9" +) + +// DefaultSandboxDesktopLastTTL is long enough to outlive a paused sandbox and +// a skill-install rebuild so a reconnect on another replica still sees the +// previous sandbox ID. +const DefaultSandboxDesktopLastTTL = 7 * 24 * time.Hour + +const desktopLastRedisPrefix = "weknora:desktop-last-sandbox:" + +// SandboxDesktopLastStore remembers which sandbox a session's desktop last +// attached to. SANDBOX_REBUILT is load-bearing: a skill install can recreate +// the sandbox under a live session, and a process-local map misses that when +// the reconnect lands on another replica. +type SandboxDesktopLastStore interface { + Get(ctx context.Context, sessionID string) (string, error) + Set(ctx context.Context, sessionID, sandboxID string) error +} + +// NewSandboxDesktopLastStore returns a Redis-backed store, or an in-process +// one when Redis is not configured (Lite / single-instance). +func NewSandboxDesktopLastStore(rdb *redis.Client) SandboxDesktopLastStore { + if rdb != nil { + return &redisDesktopLastStore{client: rdb, ttl: DefaultSandboxDesktopLastTTL} + } + logger.Warnf(context.Background(), + "[sandbox] No Redis configured, using in-memory desktop last-sandbox store (single-instance)") + return newMemoryDesktopLastStore(DefaultSandboxDesktopLastTTL) +} + +type redisDesktopLastStore struct { + client redis.UniversalClient + ttl time.Duration +} + +func (s *redisDesktopLastStore) Get(ctx context.Context, sessionID string) (string, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return "", nil + } + val, err := s.client.Get(ctx, desktopLastRedisPrefix+sessionID).Result() + if err == redis.Nil { + return "", nil + } + if err != nil { + return "", err + } + return strings.TrimSpace(val), nil +} + +func (s *redisDesktopLastStore) Set(ctx context.Context, sessionID, sandboxID string) error { + sessionID = strings.TrimSpace(sessionID) + sandboxID = strings.TrimSpace(sandboxID) + if sessionID == "" || sandboxID == "" { + return nil + } + return s.client.Set(ctx, desktopLastRedisPrefix+sessionID, sandboxID, s.ttl).Err() +} + +type memoryDesktopLastEntry struct { + sandboxID string + expiresAt time.Time +} + +type memoryDesktopLastStore struct { + mu sync.Mutex + entries map[string]memoryDesktopLastEntry + ttl time.Duration +} + +func newMemoryDesktopLastStore(ttl time.Duration) *memoryDesktopLastStore { + return &memoryDesktopLastStore{ + entries: make(map[string]memoryDesktopLastEntry), + ttl: ttl, + } +} + +func (s *memoryDesktopLastStore) Get(_ context.Context, sessionID string) (string, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return "", nil + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.entries[sessionID] + if !ok { + return "", nil + } + if now.After(entry.expiresAt) { + delete(s.entries, sessionID) + return "", nil + } + return entry.sandboxID, nil +} + +func (s *memoryDesktopLastStore) Set(_ context.Context, sessionID, sandboxID string) error { + sessionID = strings.TrimSpace(sessionID) + sandboxID = strings.TrimSpace(sandboxID) + if sessionID == "" || sandboxID == "" { + return nil + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + for key, entry := range s.entries { + if now.After(entry.expiresAt) { + delete(s.entries, key) + } + } + s.entries[sessionID] = memoryDesktopLastEntry{sandboxID: sandboxID, expiresAt: now.Add(s.ttl)} + return nil +} diff --git a/internal/application/service/sandbox_desktop_last_test.go b/internal/application/service/sandbox_desktop_last_test.go new file mode 100644 index 000000000..5bffebcf7 --- /dev/null +++ b/internal/application/service/sandbox_desktop_last_test.go @@ -0,0 +1,53 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestDesktopLastStoreRoundTrip(t *testing.T) { + store := NewSandboxDesktopLastStore(nil) + ctx := context.Background() + + got, err := store.Get(ctx, "sess-1") + require.NoError(t, err) + require.Empty(t, got) + + require.NoError(t, store.Set(ctx, "sess-1", "sbx-a")) + got, err = store.Get(ctx, "sess-1") + require.NoError(t, err) + require.Equal(t, "sbx-a", got) + + require.NoError(t, store.Set(ctx, "sess-1", "sbx-b")) + got, err = store.Get(ctx, "sess-1") + require.NoError(t, err) + require.Equal(t, "sbx-b", got) +} + +func TestDesktopLastStoreRedisRoundTrip(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + store := NewSandboxDesktopLastStore(rdb) + ctx := context.Background() + require.NoError(t, store.Set(ctx, "sess-1", "sbx-a")) + got, err := store.Get(ctx, "sess-1") + require.NoError(t, err) + require.Equal(t, "sbx-a", got) +} + +func TestDesktopLastStoreExpires(t *testing.T) { + store := newMemoryDesktopLastStore(20 * time.Millisecond) + ctx := context.Background() + require.NoError(t, store.Set(ctx, "sess-1", "sbx-a")) + time.Sleep(40 * time.Millisecond) + got, err := store.Get(ctx, "sess-1") + require.NoError(t, err) + require.Empty(t, got) +} diff --git a/internal/application/service/sandbox_desktop_service.go b/internal/application/service/sandbox_desktop_service.go new file mode 100644 index 000000000..6a70f15ac --- /dev/null +++ b/internal/application/service/sandbox_desktop_service.go @@ -0,0 +1,294 @@ +// Package service provides lazy startup and dialling for the sandbox graphical desktop. +// +// The desktop is not started with the sandbox: E2B runs envd as init and never +// executes the image CMD, and Cube's ENTRYPOINT is already envd's. One +// idempotent script driven over Exec covers both, heals a sandbox after +// pause/resume, and costs nothing for sessions that never open the tab. +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/gorilla/websocket" + + "github.com/Tencent/WeKnora/internal/logger" + "github.com/Tencent/WeKnora/internal/sandbox" + "github.com/Tencent/WeKnora/internal/types" +) + +// desktopStartTimeout must exceed start-desktop.sh's own ~40s ceiling +// (10s waiting for X, 30s waiting for websockify). Cutting it shorter kills +// the Exec while the script still holds its flock, and the next attempt then +// waits on a lock nobody will release until the lease expires. +const desktopStartTimeout = 60 * time.Second + +const desktopProbeTimeout = 15 * time.Second + +var ( + // ErrDesktopUnsupported means this session can never show a desktop as + // currently configured: either the backend cannot relay one, or the + // config's image has no start-desktop.sh. + ErrDesktopUnsupported = errors.New("sandbox desktop is not supported") + + // ErrDesktopStartFailed means the image is right but the desktop did not + // come up. Retryable. + ErrDesktopStartFailed = errors.New("sandbox desktop failed to start") +) + +// SessionDesktop is one live desktop relay leg to a sandbox. +type SessionDesktop struct { + Conn *websocket.Conn + Backend sandbox.SandboxType + + // SandboxID is what the handler compares against the previous desktop of + // this session to detect a rebuild. Without it a skill install silently + // swaps the desktop out from under the user. + SandboxID string + + // IdleDisconnect is the workspace's terminal idle window, reused for the + // desktop: both exist so a sandbox can reach its provider TTL once the + // human walked away. Zero means the handler should use the built-in default. + IdleDisconnect time.Duration + + // StartTTLRefresh extends the provider idle timeout while the relay + // ctx is live. Nil when the backend has no timeout to refresh. + StartTTLRefresh func(ctx context.Context) +} + +// SandboxDesktopService opens desktops on session sandboxes. It shares +// SandboxTerminalService's dependencies deliberately: the desktop must land on +// the exact sandbox the chat turn and the terminal use, which is what makes +// template/snapshot consistency automatic. +type SandboxDesktopService struct { + terminals *SandboxTerminalService +} + +// NewSandboxDesktopService wires the desktop service on top of the terminal +// service's resolution path. +func NewSandboxDesktopService(terminals *SandboxTerminalService) *SandboxDesktopService { + return &SandboxDesktopService{terminals: terminals} +} + +// EnsureSessionDesktop starts the desktop listeners and dials websockify. +// +// An empty sandboxConfigID is lookup-only: a running session sandbox is +// attached (and XFCE is lazily started if needed), a paused one returns +// sandbox.ErrSandboxPaused, and a missing one returns +// sandbox.ErrNoLiveSessionSandbox. A non-empty ID is the confirmed-click +// path and may create or resume, the same contract as EnsureSessionTerminal. +func (s *SandboxDesktopService) EnsureSessionDesktop( + ctx context.Context, + sessionID string, + sandboxConfigID string, +) (*SessionDesktop, error) { + if s == nil || s.terminals == nil { + return nil, sandbox.ErrNoLiveSessionSandbox + } + + // 1. Resolve. Lookup-only skips create/resume; a confirmed config ID + // provisions through the same path a chat turn uses. Either way the + // inbound token is registered on THIS replica, which is what keeps + // the gateway from answering 401. + mgr, err := s.resolveRunningManager(ctx, sessionID, sandboxConfigID) + if err != nil { + return nil, err + } + + provider, ok := mgr.(sandbox.SessionDesktopProvider) + if !ok { + return nil, ErrDesktopUnsupported + } + desktops := provider.SessionDesktopManager() + if desktops == nil { + return nil, ErrDesktopUnsupported + } + + exec, ok := mgr.(sessionShellOptionsExecutor) + if !ok { + return nil, ErrDesktopUnsupported + } + + secret, err := readDesktopSecret(ctx, exec, sessionID) + if err != nil { + return nil, err + } + + // Dial. The secret stays in memory for exactly one handshake and is + // never sent to the browser. + dialed, err := desktops.OpenSessionDesktop(ctx, sessionID, sandbox.RemoteDesktopOptions{ + BasicAuthUser: sandbox.DesktopBasicAuthUser, + BasicAuthPassword: secret, + }) + if err != nil { + if errors.Is(err, sandbox.ErrDesktopUnsupported) { + return nil, ErrDesktopUnsupported + } + return nil, err + } + return &SessionDesktop{ + Conn: dialed.Conn, + Backend: mgr.GetType(), + SandboxID: dialed.SandboxID, + IdleDisconnect: terminalIdleDisconnectFromManager(mgr), + StartTTLRefresh: dialed.StartTTLRefresh, + }, nil +} + +// resolveRunningManager returns a manager whose session sandbox is live. +// With no sandboxConfigID it will not create or resume: a paused binding +// is ErrSandboxPaused so the overlay can ask first. DesktopEnabled is +// checked before provision (and before Exec in EnsureSessionDesktop) so a +// CLI image cannot be created or woken only to miss start-desktop.sh. +func (s *SandboxDesktopService) resolveRunningManager( + ctx context.Context, + sessionID string, + sandboxConfigID string, +) (sandbox.Manager, error) { + t := s.terminals + lookupOnly := strings.TrimSpace(sandboxConfigID) == "" + mgr, _, err := t.resolveSessionManager(ctx, sessionID) + if err == nil { + if err := requireDesktopCapable(mgr); err != nil { + return nil, err + } + if lookupOnly { + if err := requireRunningSessionSandbox(ctx, mgr, sessionID); err != nil { + return nil, err + } + } + return mgr, nil + } + if !errors.Is(err, sandbox.ErrNoLiveSessionSandbox) || lookupOnly { + return nil, err + } + tenantID, _ := types.TenantIDFromContext(ctx) + mgr, _, err = resolveSandboxForExecution( + ctx, t.resolver, t.fallback, t.pinner, tenantID, sessionID, + strings.TrimSpace(sandboxConfigID), t.policy, + ) + if err != nil { + return nil, err + } + if mgr == nil || mgr.GetType() == sandbox.SandboxTypeDisabled { + return nil, sandbox.ErrNoLiveSessionSandbox + } + if err := requireDesktopCapable(mgr); err != nil { + return nil, err + } + if err := t.provisionOnManager(ctx, mgr, sessionID); err != nil { + return nil, err + } + return mgr, nil +} + +// requireDesktopCapable refuses before Exec or provision: a CLI image has no +// start-desktop.sh, and creating/waking a sandbox only to learn that is a bill. +func requireDesktopCapable(mgr any) error { + provider, ok := mgr.(sandbox.SessionDesktopProvider) + if !ok || provider.SessionDesktopManager() == nil { + return ErrDesktopUnsupported + } + return nil +} + +// sessionShellOptionsExecutor is SessionBoundManager.ExecShellCommandWithOptions +// without dragging in SessionInstallShellExecutor's install-mode name. +type sessionShellOptionsExecutor interface { + ExecShellCommandWithOptions( + context.Context, string, string, sandbox.ShellExecOptions, + ) (*sandbox.ExecuteResult, error) +} + +func desktopSkipPrepOpts(timeout time.Duration) sandbox.ShellExecOptions { + return sandbox.ShellExecOptions{Timeout: timeout, SkipWorkspacePrep: true} +} + +func readDesktopSecret( + ctx context.Context, + exec sessionShellOptionsExecutor, + sessionID string, +) (string, error) { + secret, err := runDesktopEnsure(ctx, exec, sessionID) + if err == nil { + return secret, nil + } + if errors.Is(err, ErrDesktopUnsupported) { + return "", err + } + logger.Warnf(ctx, "[sandbox-desktop] ensure failed; resetting stack session=%s: %v", + sessionID, err) + if _, resetErr := exec.ExecShellCommandWithOptions(ctx, sessionID, + sandbox.DesktopResetListenersCmd(), desktopSkipPrepOpts(desktopProbeTimeout)); resetErr != nil { + return "", fmt.Errorf("%w: reset desktop stack: %v", ErrDesktopStartFailed, resetErr) + } + return runDesktopEnsure(ctx, exec, sessionID) +} + +func runDesktopEnsure( + ctx context.Context, + exec sessionShellOptionsExecutor, + sessionID string, +) (string, error) { + result, err := exec.ExecShellCommandWithOptions(ctx, sessionID, + sandbox.DesktopEnsureCmd(), desktopSkipPrepOpts(desktopStartTimeout)) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrDesktopStartFailed, err) + } + if sandbox.IsDesktopEnsureUnsupported(result) { + logger.Warnf(ctx, + "[sandbox-desktop] %s missing in session=%s sandbox; config claims desktop support", + sandbox.DesktopStartScript, sessionID) + return "", ErrDesktopUnsupported + } + if result == nil || !result.IsSuccess() { + logger.Warnf(ctx, "[sandbox-desktop] ensure failed session=%s: %s", + sessionID, desktopExecDiagnostic(result)) + return "", ErrDesktopStartFailed + } + secret, ok := sandbox.ParseDesktopEnsureSecret(result.Stdout) + if !ok { + logger.Warnf(ctx, "[sandbox-desktop] ensure stdout was not READY session=%s: %s", + sessionID, desktopExecDiagnostic(result)) + return "", ErrDesktopStartFailed + } + return secret, nil +} + +// desktopExecDiagnostic trims script output for one log line. Never fall back +// to stdout: a successful ensure prints READY there. Timeouts have +// empty stdio and put ErrTimeout in Error (remoteExecuteResult). +func desktopExecDiagnostic(result *sandbox.ExecuteResult) string { + if result == nil { + return "no result" + } + out := strings.TrimSpace(result.Stderr) + if out == "" && (result.Killed || strings.TrimSpace(result.Error) != "") { + out = strings.TrimSpace(result.Error) + } + if len(out) > 512 { + out = out[len(out)-512:] + } + if result.Killed { + return fmt.Sprintf("exit=%d killed=true %s", result.ExitCode, out) + } + return fmt.Sprintf("exit=%d %s", result.ExitCode, out) +} + +type runningSessionSandboxAsserter interface { + RequireRunningSessionSandbox(context.Context, string) error +} + +func requireRunningSessionSandbox( + ctx context.Context, + mgr sandbox.Manager, + sessionID string, +) error { + if a, ok := mgr.(runningSessionSandboxAsserter); ok { + return a.RequireRunningSessionSandbox(ctx, sessionID) + } + return nil +} diff --git a/internal/application/service/sandbox_desktop_service_test.go b/internal/application/service/sandbox_desktop_service_test.go new file mode 100644 index 000000000..a1fbf71b3 --- /dev/null +++ b/internal/application/service/sandbox_desktop_service_test.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/internal/sandbox" +) + +type fakeDesktopShell struct { + results []*sandbox.ExecuteResult + errs []error + calls []sandbox.ShellExecOptions + cmds []string +} + +func (f *fakeDesktopShell) ExecShellCommandWithOptions( + _ context.Context, _ string, command string, opts sandbox.ShellExecOptions, +) (*sandbox.ExecuteResult, error) { + f.cmds = append(f.cmds, command) + f.calls = append(f.calls, opts) + i := len(f.cmds) - 1 + var err error + if i < len(f.errs) { + err = f.errs[i] + } + var res *sandbox.ExecuteResult + if i < len(f.results) { + res = f.results[i] + } + return res, err +} + +func TestReadDesktopSecretHappyPathSingleExec(t *testing.T) { + secret := "abcdefghijklmnopqrstuvwxyz012345" + f := &fakeDesktopShell{results: []*sandbox.ExecuteResult{{ + Stdout: "READY " + secret + "\n", ExitCode: 0, + }}} + got, err := readDesktopSecret(context.Background(), f, "sess-1") + require.NoError(t, err) + require.Equal(t, secret, got) + require.Len(t, f.cmds, 1) + require.Equal(t, sandbox.DesktopEnsureCmd(), f.cmds[0]) + require.True(t, f.calls[0].SkipWorkspacePrep) + require.Equal(t, desktopStartTimeout, f.calls[0].Timeout) + require.False(t, f.calls[0].AsRoot) + require.False(t, f.calls[0].AllowSkillsRoot) +} + +func TestReadDesktopSecretMissingScriptDoesNotReset(t *testing.T) { + f := &fakeDesktopShell{results: []*sandbox.ExecuteResult{{ + ExitCode: sandbox.DesktopEnsureUnsupportedExit, + Stderr: sandbox.DesktopEnsureUnsupportedMarker + "\n", + }}} + _, err := readDesktopSecret(context.Background(), f, "sess-1") + require.ErrorIs(t, err, ErrDesktopUnsupported) + require.Len(t, f.cmds, 1) +} + +func TestReadDesktopSecretBareExit2IsStartFailed(t *testing.T) { + f := &fakeDesktopShell{results: []*sandbox.ExecuteResult{ + {ExitCode: 2, Stderr: "Syntax error: end of file unexpected\n"}, + {ExitCode: 0}, + {ExitCode: 2, Stderr: "Syntax error: end of file unexpected\n"}, + }} + _, err := readDesktopSecret(context.Background(), f, "sess-1") + require.ErrorIs(t, err, ErrDesktopStartFailed) + require.Equal(t, sandbox.DesktopResetListenersCmd(), f.cmds[1]) + require.Len(t, f.cmds, 3) +} + +func TestReadDesktopSecretTimeoutResetsThenRetries(t *testing.T) { + secret := "abcdefghijklmnopqrstuvwxyz012345" + f := &fakeDesktopShell{ + results: []*sandbox.ExecuteResult{ + {ExitCode: -1, Killed: true, Error: "timeout"}, + {ExitCode: 0}, + {Stdout: "READY " + secret + "\n", ExitCode: 0}, + }, + } + got, err := readDesktopSecret(context.Background(), f, "sess-1") + require.NoError(t, err) + require.Equal(t, secret, got) + require.Equal(t, sandbox.DesktopEnsureCmd(), f.cmds[0]) + require.Equal(t, sandbox.DesktopResetListenersCmd(), f.cmds[1]) + require.Equal(t, sandbox.DesktopEnsureCmd(), f.cmds[2]) + require.True(t, f.calls[0].SkipWorkspacePrep) + require.True(t, f.calls[1].SkipWorkspacePrep) + require.Equal(t, desktopProbeTimeout, f.calls[1].Timeout) + require.Equal(t, desktopStartTimeout, f.calls[2].Timeout) +} + +func TestReadDesktopSecretResetExecError(t *testing.T) { + f := &fakeDesktopShell{ + results: []*sandbox.ExecuteResult{{ExitCode: 1, Stderr: "boom"}}, + errs: []error{nil, errors.New("reset failed")}, + } + _, err := readDesktopSecret(context.Background(), f, "sess-1") + require.ErrorIs(t, err, ErrDesktopStartFailed) + require.Len(t, f.cmds, 2) +} + +func TestDesktopExecDiagnosticOmitsStdout(t *testing.T) { + got := desktopExecDiagnostic(&sandbox.ExecuteResult{ + ExitCode: 1, + Stdout: "READY abcdefghijklmnopqrstuvwxyz012345\n", + Stderr: "x11vnc dead", + }) + require.Contains(t, got, "x11vnc dead") + require.NotContains(t, got, "READY") + require.NotContains(t, got, "abcdefghijklmnopqrstuvwxyz012345") + + got = desktopExecDiagnostic(&sandbox.ExecuteResult{ + ExitCode: 1, + Stdout: "READY abcdefghijklmnopqrstuvwxyz012345\n", + }) + require.NotContains(t, got, "READY") + require.True(t, strings.HasPrefix(got, "exit=1")) +} + +func TestDesktopExecDiagnosticKilledUsesError(t *testing.T) { + got := desktopExecDiagnostic(&sandbox.ExecuteResult{ + ExitCode: -1, + Killed: true, + Error: sandbox.ErrTimeout.Error(), + Stdout: "READY abcdefghijklmnopqrstuvwxyz012345\n", + }) + require.Contains(t, got, "killed=true") + require.Contains(t, got, sandbox.ErrTimeout.Error()) + require.NotContains(t, got, "READY") + require.NotContains(t, got, "abcdefghijklmnopqrstuvwxyz012345") +} + +func TestReadDesktopSecretRejectsUnparseableSuccess(t *testing.T) { + f := &fakeDesktopShell{results: []*sandbox.ExecuteResult{ + {ExitCode: 0, Stdout: "started\n"}, + {ExitCode: 0}, + {ExitCode: 0, Stdout: "started\n"}, + }} + _, err := readDesktopSecret(context.Background(), f, "sess-1") + require.ErrorIs(t, err, ErrDesktopStartFailed) + require.Equal(t, sandbox.DesktopResetListenersCmd(), f.cmds[1]) +} + +type stubDesktopProvider struct { + advertised bool +} + +func (s stubDesktopProvider) SessionDesktopManager() sandbox.SessionDesktopManager { + if !s.advertised { + return nil + } + return stubDesktopManager{} +} + +type stubDesktopManager struct{} + +func (stubDesktopManager) OpenSessionDesktop( + context.Context, string, sandbox.RemoteDesktopOptions, +) (*sandbox.SessionDesktopConn, error) { + return nil, sandbox.ErrDesktopUnsupported +} + +func TestRequireDesktopCapableRejectsWhenNotAdvertised(t *testing.T) { + require.ErrorIs(t, requireDesktopCapable(struct{}{}), ErrDesktopUnsupported) + require.ErrorIs(t, requireDesktopCapable(stubDesktopProvider{}), ErrDesktopUnsupported) +} + +func TestRequireDesktopCapableAcceptsAdvertisedManager(t *testing.T) { + require.NoError(t, requireDesktopCapable(stubDesktopProvider{advertised: true})) +} diff --git a/internal/application/service/sandbox_desktop_ticket.go b/internal/application/service/sandbox_desktop_ticket.go new file mode 100644 index 000000000..04a14ca6c --- /dev/null +++ b/internal/application/service/sandbox_desktop_ticket.go @@ -0,0 +1,192 @@ +// Package service provides one-shot handshake tickets for the sandbox desktop WebSocket. +// +// Unlike the terminal's ticket (sandbox_terminal_ticket.go) this is an opaque +// random string in a store, not a signed JWT. The desktop ticket must be +// single-use, and once a store is needed to enforce that, the stateless JWT's +// only advantage is gone — while its drawback (an HS256 blob sitting in +// gateway access logs and browser history for its whole TTL) remains. The +// terminal's implementation is deliberately left untouched. +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "sync" + "time" + + "github.com/Tencent/WeKnora/internal/logger" + "github.com/redis/go-redis/v9" +) + +// DefaultSandboxDesktopTicketTTL is long enough for the WebSocket handshake, +// short enough that a leaked query string is not a session. +const DefaultSandboxDesktopTicketTTL = 2 * time.Minute + +const desktopTicketRedisPrefix = "weknora:desktop-ticket:" + +// ErrDesktopTicketInvalid covers unknown, expired, and already-consumed +// tickets. They are one error on purpose: telling them apart would let a +// caller probe which tickets ever existed. +var ErrDesktopTicketInvalid = errors.New("invalid or expired desktop ticket") + +// SandboxDesktopTicketClaims is the handshake identity bound to one session. +// TokenID is the auth_tokens.id of the access JWT that minted the ticket, so +// logout and revocation can tear the relay down. +type SandboxDesktopTicketClaims struct { + UserID string `json:"user_id"` + TenantID uint64 `json:"tenant_id"` + SessionID string `json:"session_id"` + TokenID string `json:"token_id"` +} + +func (c SandboxDesktopTicketClaims) valid() bool { + return strings.TrimSpace(c.UserID) != "" && + strings.TrimSpace(c.SessionID) != "" && + strings.TrimSpace(c.TokenID) != "" && + c.TenantID != 0 +} + +// SandboxDesktopTicketStore issues and consumes one-shot desktop tickets. +type SandboxDesktopTicketStore interface { + Issue(ctx context.Context, claims SandboxDesktopTicketClaims) (string, error) + Consume(ctx context.Context, ticket string) (*SandboxDesktopTicketClaims, error) +} + +// NewSandboxDesktopTicketStore returns a Redis-backed store, or an in-process +// one when Redis is not configured. +// +// The memory fallback mirrors selectSessionBindingStore (container/sandbox.go): +// Lite mode has no REDIS_ADDR and runs one process, so a process-local store +// is correct there. In a multi-replica deployment without Redis the ticket +// would be minted on one replica and consumed on another, so this logs loudly. +func NewSandboxDesktopTicketStore(rdb *redis.Client) SandboxDesktopTicketStore { + if rdb != nil { + return &redisDesktopTicketStore{client: rdb, ttl: DefaultSandboxDesktopTicketTTL} + } + logger.Warnf(context.Background(), + "[sandbox] No Redis configured, using in-memory desktop ticket store (single-instance)") + return newMemoryDesktopTicketStore(DefaultSandboxDesktopTicketTTL) +} + +func newDesktopTicketID() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +type redisDesktopTicketStore struct { + client redis.UniversalClient + ttl time.Duration +} + +func (s *redisDesktopTicketStore) Issue( + ctx context.Context, claims SandboxDesktopTicketClaims, +) (string, error) { + if !claims.valid() { + return "", errors.New("desktop ticket requires user, tenant, session, and access token") + } + id, err := newDesktopTicketID() + if err != nil { + return "", err + } + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + if err := s.client.Set(ctx, desktopTicketRedisPrefix+id, payload, s.ttl).Err(); err != nil { + return "", err + } + return id, nil +} + +func (s *redisDesktopTicketStore) Consume( + ctx context.Context, ticket string, +) (*SandboxDesktopTicketClaims, error) { + ticket = strings.TrimSpace(ticket) + if ticket == "" { + return nil, ErrDesktopTicketInvalid + } + // GETDEL is the whole point: read and invalidate in one round trip, so + // two concurrent handshakes cannot both win. + raw, err := s.client.GetDel(ctx, desktopTicketRedisPrefix+ticket).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrDesktopTicketInvalid + } + if err != nil { + return nil, err + } + var claims SandboxDesktopTicketClaims + if err := json.Unmarshal(raw, &claims); err != nil || !claims.valid() { + return nil, ErrDesktopTicketInvalid + } + return &claims, nil +} + +type memoryDesktopTicketEntry struct { + claims SandboxDesktopTicketClaims + expiresAt time.Time +} + +type memoryDesktopTicketStore struct { + mu sync.Mutex + tickets map[string]memoryDesktopTicketEntry + ttl time.Duration +} + +func newMemoryDesktopTicketStore(ttl time.Duration) *memoryDesktopTicketStore { + return &memoryDesktopTicketStore{ + tickets: make(map[string]memoryDesktopTicketEntry), + ttl: ttl, + } +} + +func (s *memoryDesktopTicketStore) Issue( + _ context.Context, claims SandboxDesktopTicketClaims, +) (string, error) { + if !claims.valid() { + return "", errors.New("desktop ticket requires user, tenant, session, and access token") + } + id, err := newDesktopTicketID() + if err != nil { + return "", err + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + // Opportunistic sweep: tickets live 2 minutes and are issued one per + // desktop open, so this never has real work to do. + for key, entry := range s.tickets { + if now.After(entry.expiresAt) { + delete(s.tickets, key) + } + } + s.tickets[id] = memoryDesktopTicketEntry{claims: claims, expiresAt: now.Add(s.ttl)} + return id, nil +} + +func (s *memoryDesktopTicketStore) Consume( + _ context.Context, ticket string, +) (*SandboxDesktopTicketClaims, error) { + ticket = strings.TrimSpace(ticket) + if ticket == "" { + return nil, ErrDesktopTicketInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.tickets[ticket] + if !ok { + return nil, ErrDesktopTicketInvalid + } + delete(s.tickets, ticket) + if time.Now().After(entry.expiresAt) { + return nil, ErrDesktopTicketInvalid + } + claims := entry.claims + return &claims, nil +} diff --git a/internal/application/service/sandbox_desktop_ticket_test.go b/internal/application/service/sandbox_desktop_ticket_test.go new file mode 100644 index 000000000..850a8ff77 --- /dev/null +++ b/internal/application/service/sandbox_desktop_ticket_test.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func desktopTicketClaims() SandboxDesktopTicketClaims { + return SandboxDesktopTicketClaims{ + UserID: "u-1", TenantID: 7, SessionID: "sess-1", TokenID: "tok-1", + } +} + +func TestDesktopTicketRoundTrip(t *testing.T) { + store := NewSandboxDesktopTicketStore(nil) // memory fallback + ctx := context.Background() + + ticket, err := store.Issue(ctx, desktopTicketClaims()) + require.NoError(t, err) + require.NotEmpty(t, ticket) + + got, err := store.Consume(ctx, ticket) + require.NoError(t, err) + require.Equal(t, desktopTicketClaims(), *got) +} + +func TestDesktopTicketIsSingleUse(t *testing.T) { + // The ticket travels in a query string, so it lands in gateway access + // logs and browser history. One-shot consumption is what bounds that. + store := NewSandboxDesktopTicketStore(nil) + ctx := context.Background() + + ticket, err := store.Issue(ctx, desktopTicketClaims()) + require.NoError(t, err) + _, err = store.Consume(ctx, ticket) + require.NoError(t, err) + + _, err = store.Consume(ctx, ticket) + require.ErrorIs(t, err, ErrDesktopTicketInvalid) +} + +func TestDesktopTicketRejectsUnknown(t *testing.T) { + store := NewSandboxDesktopTicketStore(nil) + _, err := store.Consume(context.Background(), "not-a-ticket") + require.ErrorIs(t, err, ErrDesktopTicketInvalid) +} + +func TestDesktopTicketRejectsEmpty(t *testing.T) { + store := NewSandboxDesktopTicketStore(nil) + _, err := store.Consume(context.Background(), "") + require.ErrorIs(t, err, ErrDesktopTicketInvalid) +} + +func TestDesktopTicketRequiresCompleteClaims(t *testing.T) { + store := NewSandboxDesktopTicketStore(nil) + _, err := store.Issue(context.Background(), SandboxDesktopTicketClaims{UserID: "u-1"}) + require.Error(t, err) +} + +func TestDesktopTicketsAreDistinct(t *testing.T) { + store := NewSandboxDesktopTicketStore(nil) + ctx := context.Background() + a, err := store.Issue(ctx, desktopTicketClaims()) + require.NoError(t, err) + b, err := store.Issue(ctx, desktopTicketClaims()) + require.NoError(t, err) + require.NotEqual(t, a, b) +} diff --git a/internal/application/service/tenant_sandbox_config.go b/internal/application/service/tenant_sandbox_config.go index fa34692c2..123711390 100644 --- a/internal/application/service/tenant_sandbox_config.go +++ b/internal/application/service/tenant_sandbox_config.go @@ -76,7 +76,9 @@ func configHasSkillSnapshot(cfg *types.TenantSandboxConfig) bool { } // skillRetargetWouldChange reports edits that would retarget the environment -// a skill snapshot is built from: identity, spawn template, or Cube DNS. +// a skill snapshot is built from: identity, spawn template, Cube DNS, or the +// desktop/CLI base bit. Flipping DesktopEnabled changes the image generation +// skills were stacked on, so installed skills must be rebuilt. func skillRetargetWouldChange(stored, merged *types.TenantSandboxConfig) bool { if SandboxIdentityChanged(stored, merged) { return true @@ -84,9 +86,16 @@ func skillRetargetWouldChange(stored, merged *types.TenantSandboxConfig) bool { if spawnTemplateID(stored) != spawnTemplateID(merged) { return true } + if desktopEnabledOf(stored) != desktopEnabledOf(merged) { + return true + } return !sameStrings(cubeDNSServers(stored), cubeDNSServers(merged)) } +func desktopEnabledOf(cfg *types.TenantSandboxConfig) bool { + return cfg != nil && cfg.DesktopEnabled +} + // spawnTemplateID is the template/image this config would boot without a // skill snapshot. Cube and E2B store it as template_id; Docker as the image. func spawnTemplateID(cfg *types.TenantSandboxConfig) string { @@ -272,11 +281,14 @@ type SandboxTemplateQueryInput struct { ConfigID string EnsureStandard bool ReplaceStandard bool + EnsureDesktop bool + ReplaceDesktop bool } type SandboxTemplateCatalog struct { Templates []sandbox.RemoteTemplate `json:"templates"` StandardTemplateID string `json:"standard_template_id,omitempty"` + DesktopTemplateID string `json:"desktop_template_id,omitempty"` Provisioned bool `json:"provisioned"` } @@ -534,9 +546,9 @@ func (s *TenantSandboxConfigService) Get( return entity, nil } -// QueryTemplates reads the provider's template catalog and optionally installs -// the standard WeKnora image when it is absent. This is intentionally driven by -// workspace credentials instead of deployment environment variables. +// QueryTemplates reads the provider's template catalog and, when asked, +// installs the published WeKnora CLI and desktop images if that cluster has +// none. Credentials come from the workspace connection, not env vars. func (s *TenantSandboxConfigService) QueryTemplates( ctx context.Context, tenantID uint64, in SandboxTemplateQueryInput, ) (*SandboxTemplateCatalog, error) { @@ -544,6 +556,10 @@ func (s *TenantSandboxConfigService) QueryTemplates( return nil, apperrors.NewBadRequestError( "config_id is required to rebuild the standard template") } + if in.ReplaceDesktop && strings.TrimSpace(in.ConfigID) == "" { + return nil, apperrors.NewBadRequestError( + "config_id is required to rebuild the desktop template") + } var existing *types.TenantSandboxConfig if strings.TrimSpace(in.ConfigID) != "" { entity, err := s.repo.GetByID(ctx, tenantID, in.ConfigID) @@ -562,7 +578,7 @@ func (s *TenantSandboxConfigService) QueryTemplates( if merged == nil { return nil, apperrors.NewBadRequestError("sandbox config is required") } - if in.ReplaceStandard { + if in.ReplaceStandard || in.ReplaceDesktop { if err := s.refuseClusterSkillTemplateReplace(ctx, tenantID, merged); err != nil { return nil, err } @@ -634,10 +650,11 @@ func (s *TenantSandboxConfigService) QueryTemplates( result.StandardTemplateID = usable.ID } oldStandardIDs := standardTemplateIDs(result.Templates) - // Listing is read-only. Creating or replacing the WeKnora template is an - // explicit settings-page action: auto-ensure on every refresh made DNS - // and image changes impossible to apply, and left admins unsure whether - // they had asked for a build. + // Missing first-party templates are filled from the published Hub images + // (ensure_standard / ensure_desktop). That is idempotent while a usable + // template already exists, so a catalog refresh cannot apply a new DNS or + // image spec — replace_standard / replace_desktop remains the explicit + // rebuild. wantStandard := in.ReplaceStandard || (in.EnsureStandard && usable == nil) if wantStandard { op := "ensure" @@ -681,10 +698,78 @@ func (s *TenantSandboxConfigService) QueryTemplates( } } } + usableDesktop := pickDesktopTemplate(result.Templates) + if usableDesktop != nil { + result.DesktopTemplateID = usableDesktop.ID + } + oldDesktopIDs := desktopTemplateIDs(result.Templates) + wantDesktop := in.ReplaceDesktop || (in.EnsureDesktop && usableDesktop == nil) + if wantDesktop { + desktopCatalog, ok := any(client).(sandbox.RemoteDesktopTemplateCatalog) + if !ok { + if in.ReplaceDesktop { + return nil, fmt.Errorf("sandbox: provider %q does not expose desktop templates", effective.Type) + } + } else { + op := "ensure-desktop" + if in.ReplaceDesktop { + op = "replace-desktop" + } + key := op + ":" + ensureTemplateKey(tenantID, sandbox.IdentityOf(merged)) + ensured, ensureErr, _ := s.ensureTemplate.Do(key, func() (any, error) { + if in.ReplaceDesktop { + return desktopCatalog.ReplaceDesktopTemplate(ctx) + } + return desktopCatalog.EnsureDesktopTemplate(ctx) + }) + if ensureErr != nil { + if in.ReplaceDesktop { + return nil, ensureErr + } + logger.Warnf(ctx, "[sandbox] ensure desktop template: %v", ensureErr) + } else { + desktop, ok := ensured.(*sandbox.RemoteTemplate) + if !ok || desktop == nil { + if in.ReplaceDesktop { + return nil, fmt.Errorf("sandbox: provider %q returned no desktop template", effective.Type) + } + logger.Warnf(ctx, "[sandbox] ensure desktop template: provider %q returned no desktop template", + effective.Type) + } else { + result.Templates = deduplicateSandboxTemplates(append(result.Templates, *desktop)) + if sandbox.IsTemplateReady(desktop.Status) { + result.Provisioned = true + if in.ReplaceDesktop { + persistErr := s.persistSpawnTemplateID(ctx, tenantID, merged, desktop.ID, oldDesktopIDs) + if persistErr != nil { + logger.Warnf(ctx, + "[sandbox] persist rebuilt desktop template id: %v; keeping previous templates", + persistErr) + } else { + result.DesktopTemplateID = desktop.ID + result.Templates = hideSupersededDesktopTemplates(result.Templates, desktop.ID) + s.deleteSupersededDesktopTemplates(ctx, desktopCatalog, desktop.ID) + } + } else { + result.DesktopTemplateID = desktop.ID + } + } else if !sandbox.IsTemplateBuildFailed(desktop.Status) { + result.Provisioned = true + if result.DesktopTemplateID == "" { + result.DesktopTemplateID = desktop.ID + } + } + } + } + } + } sort.SliceStable(result.Templates, func(i, j int) bool { if result.Templates[i].Standard != result.Templates[j].Standard { return result.Templates[i].Standard } + if result.Templates[i].Desktop != result.Templates[j].Desktop { + return result.Templates[i].Desktop + } return strings.ToLower(result.Templates[i].Name) < strings.ToLower(result.Templates[j].Name) }) return result, nil @@ -714,6 +799,30 @@ func standardTemplateIDs(items []sandbox.RemoteTemplate) []string { return out } +func desktopTemplateIDs(items []sandbox.RemoteTemplate) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + if item.Desktop { + if id := strings.TrimSpace(item.ID); id != "" { + out = append(out, id) + } + } + } + return out +} + +func hideSupersededDesktopTemplates(items []sandbox.RemoteTemplate, keepID string) []sandbox.RemoteTemplate { + keepID = strings.TrimSpace(keepID) + kept := make([]sandbox.RemoteTemplate, 0, len(items)) + for _, item := range items { + if item.Desktop && strings.TrimSpace(item.ID) != keepID { + continue + } + kept = append(kept, item) + } + return kept +} + func (s *TenantSandboxConfigService) refuseClusterSkillTemplateReplace( ctx context.Context, tenantID uint64, merged *types.TenantSandboxConfig, ) error { @@ -824,6 +933,17 @@ func (s *TenantSandboxConfigService) deleteSupersededStandardTemplates( } } +func (s *TenantSandboxConfigService) deleteSupersededDesktopTemplates( + ctx context.Context, catalog sandbox.RemoteDesktopTemplateCatalog, keepID string, +) { + if catalog == nil || strings.TrimSpace(keepID) == "" { + return + } + if err := catalog.DeleteSupersededDesktopTemplates(ctx, keepID); err != nil { + logger.Warnf(ctx, "[sandbox] delete superseded desktop templates failed: %v", err) + } +} + func setSpawnTemplateID(cfg *types.TenantSandboxConfig, id string) { if cfg == nil { return @@ -856,6 +976,19 @@ func pickStandardTemplate(items []sandbox.RemoteTemplate) *sandbox.RemoteTemplat return best } +func pickDesktopTemplate(items []sandbox.RemoteTemplate) *sandbox.RemoteTemplate { + var best *sandbox.RemoteTemplate + for i := range items { + if !items[i].Desktop || sandbox.IsTemplateBuildFailed(items[i].Status) { + continue + } + if best == nil || templateStatusRank(items[i].Status) > templateStatusRank(best.Status) { + best = &items[i] + } + } + return best +} + // ensureTemplateKey names one cluster as seen by one tenant. The identity // carries an API key, so it is hashed rather than formatted: this string is // only ever compared, and it should not be able to surface a credential in a @@ -885,6 +1018,7 @@ func deduplicateSandboxTemplates(items []sandbox.RemoteTemplate) []sandbox.Remot } current := &result[idx] current.Standard = current.Standard || item.Standard + current.Desktop = current.Desktop || item.Desktop if templateStatusRank(item.Status) > templateStatusRank(current.Status) { current.Status = item.Status current.Version = item.Version diff --git a/internal/application/service/tenant_sandbox_config_test.go b/internal/application/service/tenant_sandbox_config_test.go index 34dfecc95..4ab89506c 100644 --- a/internal/application/service/tenant_sandbox_config_test.go +++ b/internal/application/service/tenant_sandbox_config_test.go @@ -329,15 +329,19 @@ func (s stubAgentRepo) ListNamesBySandboxConfigID( } type stubProviderClient struct { - inventories [][]sandbox.RemoteSandboxSummary - templates []sandbox.RemoteTemplate - ensured *sandbox.RemoteTemplate + inventories [][]sandbox.RemoteSandboxSummary + templates []sandbox.RemoteTemplate + ensured *sandbox.RemoteTemplate + ensuredDesktop *sandbox.RemoteTemplate // ensureDelay widens the window in which concurrent provisioning requests // overlap, which is the only way to observe whether they were collapsed. - ensureDelay time.Duration + ensureDelay time.Duration + ensureDesktopErr error - ensureCalls atomic.Int32 - replaceCalls atomic.Int32 + ensureCalls atomic.Int32 + ensureDesktopCalls atomic.Int32 + replaceCalls atomic.Int32 + replaceDesktopCalls atomic.Int32 listCalls int deleted []string @@ -374,6 +378,45 @@ func (s *stubProviderClient) ReplaceStandardTemplate(ctx context.Context) (*sand return s.EnsureStandardTemplate(ctx) } +func (s *stubProviderClient) EnsureDesktopTemplate(context.Context) (*sandbox.RemoteTemplate, error) { + s.ensureDesktopCalls.Add(1) + if s.ensureDelay > 0 { + time.Sleep(s.ensureDelay) + } + if s.ensureDesktopErr != nil { + return nil, s.ensureDesktopErr + } + if s.ensuredDesktop != nil { + copyTpl := *s.ensuredDesktop + return ©Tpl, nil + } + return &sandbox.RemoteTemplate{ID: "tpl-desktop", Name: "weknora-desktop", Status: "building", Desktop: true}, nil +} + +func (s *stubProviderClient) ReplaceDesktopTemplate(ctx context.Context) (*sandbox.RemoteTemplate, error) { + s.replaceDesktopCalls.Add(1) + if s.ensureDelay > 0 { + time.Sleep(s.ensureDelay) + } + if s.ensureDesktopErr != nil { + return nil, s.ensureDesktopErr + } + if s.ensuredDesktop != nil { + copyTpl := *s.ensuredDesktop + return ©Tpl, nil + } + return s.EnsureDesktopTemplate(ctx) +} + +func (s *stubProviderClient) DeleteSupersededDesktopTemplates(_ context.Context, keepID string) error { + for _, item := range s.templates { + if item.Desktop && item.ID != "" && item.ID != keepID { + s.deletedTemplates = append(s.deletedTemplates, item.ID) + } + } + return nil +} + func (s *stubProviderClient) DeleteSupersededStandardTemplates(_ context.Context, keepID string) error { for _, item := range s.templates { if item.Standard && item.ID != "" && item.ID != keepID { @@ -439,6 +482,79 @@ func TestQueryTemplatesEnsuresMissingWeKnoraTemplate(t *testing.T) { require.True(t, result.Templates[0].Standard, "standard template should sort first") } +func TestQueryTemplatesEnsuresMissingDesktopTemplate(t *testing.T) { + client := &stubProviderClient{ + templates: []sandbox.RemoteTemplate{ + {ID: "tpl-weknora", Name: "weknora", Status: "ready", Standard: true}, + }, + ensuredDesktop: &sandbox.RemoteTemplate{ + ID: "tpl-desktop", Name: "weknora-desktop", Status: "building", Desktop: true, + }, + } + svc := newTestConfigService(t, &fakeConfigRepo{}, client, stubAgentRepo{}) + + result, err := svc.QueryTemplates(context.Background(), 7, SandboxTemplateQueryInput{ + Config: e2bCfg("key-a", "https://api.e2b.app", "e2b.app", "", 300), + EnsureDesktop: true, + }) + + require.NoError(t, err) + require.True(t, result.Provisioned) + require.Equal(t, "tpl-weknora", result.StandardTemplateID) + require.Equal(t, "tpl-desktop", result.DesktopTemplateID) + require.Equal(t, int32(0), client.ensureCalls.Load(), "creating desktop must not rebuild the CLI template") + require.Equal(t, int32(1), client.ensureDesktopCalls.Load()) + var sawDesktop bool + for _, item := range result.Templates { + if item.Desktop && item.ID == "tpl-desktop" { + sawDesktop = true + } + } + require.True(t, sawDesktop) +} + +func TestQueryTemplatesEnsureDesktopFailureKeepsCatalog(t *testing.T) { + client := &stubProviderClient{ + templates: []sandbox.RemoteTemplate{ + {ID: "tpl-weknora", Name: "weknora", Status: "ready", Standard: true}, + }, + ensureDesktopErr: stderrors.New("hub missing main-desktop"), + } + svc := newTestConfigService(t, &fakeConfigRepo{}, client, stubAgentRepo{}) + + result, err := svc.QueryTemplates(context.Background(), 7, SandboxTemplateQueryInput{ + Config: e2bCfg("key-a", "https://api.e2b.app", "e2b.app", "", 300), + EnsureDesktop: true, + }) + + require.NoError(t, err) + require.Equal(t, "tpl-weknora", result.StandardTemplateID) + require.Empty(t, result.DesktopTemplateID) + require.Equal(t, int32(1), client.ensureDesktopCalls.Load()) + require.Equal(t, 1, len(result.Templates)) +} + +func TestQueryTemplatesReplaceDesktopFailureIsFatal(t *testing.T) { + stored := e2bCfg("key-a", "https://api.e2b.app", "e2b.app", "tpl-desktop", 300) + repo := &fakeConfigRepo{entity: &types.TenantSandboxConfigEntity{ + ID: "cfg-a", TenantID: 7, SandboxType: "e2b", Config: stored, + }} + client := &stubProviderClient{ + templates: []sandbox.RemoteTemplate{ + {ID: "tpl-desktop", Name: "weknora-desktop", Status: "ready", Desktop: true}, + }, + ensureDesktopErr: stderrors.New("rebuild refused"), + } + svc := newTestConfigService(t, repo, client, stubAgentRepo{}) + + _, err := svc.QueryTemplates(context.Background(), 7, SandboxTemplateQueryInput{ + ConfigID: "cfg-a", + ReplaceDesktop: true, + }) + require.Error(t, err) + require.Equal(t, int32(1), client.replaceDesktopCalls.Load()) +} + // Provisioning only becomes idempotent once the build shows up in the // provider's catalog, so overlapping requests have to share one attempt or the // cluster ends up with a template per click. @@ -787,6 +903,29 @@ func TestUpdateRefusesTemplateChangeWhenSkillSnapshotExists(t *testing.T) { require.Nil(t, repo.updated) } +func TestUpdateRefusesDesktopEnabledChangeWhenSkillSnapshotExists(t *testing.T) { + t.Setenv("SYSTEM_AES_KEY", strings.Repeat("k", 32)) + stored := e2bCfg("key-a", "https://api.e2b.app", "e2b.app", "t1", 300) + stored.SkillImage = &types.SkillImageConfig{SnapshotID: "snap-1"} + repo := &fakeConfigRepo{entity: &types.TenantSandboxConfigEntity{ + ID: "cfg-a", + TenantID: 7, + Name: "prod", + SandboxType: "e2b", + Config: stored, + }} + svc := newTestConfigService(t, repo, &stubProviderClient{}, stubAgentRepo{}) + + incoming := e2bCfg("key-a", "https://api.e2b.app", "e2b.app", "t1", 300) + incoming.DesktopEnabled = true + _, err := svc.Update(context.Background(), 7, "cfg-a", UpdateSandboxConfigInput{ + Name: "prod", + Config: incoming, + }) + require.ErrorIs(t, err, ErrSkillSnapshotBlocksTemplateChange) + require.Nil(t, repo.updated) +} + func TestUpdateRefusesIdentityChangeWhenSkillSnapshotExists(t *testing.T) { t.Setenv("SYSTEM_AES_KEY", strings.Repeat("k", 32)) stored := e2bCfg("key-a", "https://api.e2b.app", "e2b.app", "t1", 300) diff --git a/internal/application/service/tenant_skill_install.go b/internal/application/service/tenant_skill_install.go index d51b114d9..6c0898c1d 100644 --- a/internal/application/service/tenant_skill_install.go +++ b/internal/application/service/tenant_skill_install.go @@ -1010,7 +1010,10 @@ const skillCacheBudgetMB = 256 // files, and the wipe takes the base image's own input/output directories with // it, so the same command restores them with the ownership the session account // needs — the only step of an install that runs with the privileges to do it. -// That half is strict; failing it fails the install. +// /run/desktop goes with it: start-desktop.sh writes the websockify Basic +// password there, and a snapshot that kept it would reuse that password on +// every later sandbox (Cube's host-port NAT has no other gate). That half is +// strict; failing it fails the install. // // The package download caches are the other half, and they are retained rather // than wiped: pip, uv, npm and pnpm caches are first trimmed with the tools' @@ -1044,7 +1047,7 @@ func cleanImageScratchCommand() string { inputRoot := sandbox.ShellQuote(sandbox.SessionInputRoot) outputRoot := sandbox.ShellQuote(sandbox.SessionOutputRoot) var b strings.Builder - b.WriteString("rm -rf /workspace/* /tmp/* /workspace/.[!.]* || true") + b.WriteString("rm -rf /workspace/* /tmp/* /workspace/.[!.]* /run/desktop || true") fmt.Fprintf(&b, "; mkdir -p %s %s && chmod 775 %s %s; status=$?", inputRoot, outputRoot, inputRoot, outputRoot, diff --git a/internal/application/service/tenant_skill_install_test.go b/internal/application/service/tenant_skill_install_test.go index c0aa90d06..687a7d438 100644 --- a/internal/application/service/tenant_skill_install_test.go +++ b/internal/application/service/tenant_skill_install_test.go @@ -273,8 +273,9 @@ func TestCacheBudgetGuardKeepsSmallWipesBig(t *testing.T) { func TestCleanImageScratchCommandShape(t *testing.T) { cmd := cleanImageScratchCommand() - require.Contains(t, cmd, "rm -rf /workspace/* /tmp/* /workspace/.[!.]* || true", - "the scratch wipe covers /tmp too: installers leave archives and locks there") + require.Contains(t, cmd, "rm -rf /workspace/* /tmp/* /workspace/.[!.]* /run/desktop || true", + "the scratch wipe covers /tmp and /run/desktop: a leftover websockify "+ + "secret would be reused by every sandbox booted from the snapshot") require.Contains(t, cmd, "mkdir -p") require.Contains(t, cmd, "status=$?") require.Contains(t, cmd, "exit $status") diff --git a/internal/container/container.go b/internal/container/container.go index d6b4b225b..a355fa631 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -325,6 +325,13 @@ func BuildContainer(container *dig.Container) *dig.Container { // the frontend terminal panel. First-use provisioning takes a sandbox // config ID already resolved by the WebSocket handler (own or shared agent). must(container.Provide(service.NewSandboxTerminalService)) + // One-shot desktop handshake tickets. Falls back to an in-process store + // when Redis is absent (Lite mode), same as the binding store. + must(container.Provide(service.NewSandboxDesktopTicketStore)) + must(container.Provide(service.NewSandboxDesktopLastStore)) + // Desktop relay. It rides SandboxTerminalService's resolution path so the + // desktop always lands on the session's existing sandbox. + must(container.Provide(service.NewSandboxDesktopService)) logger.Debugf(ctx, "[Container] Registering task enqueuer...") redisAvailable := os.Getenv("REDIS_ADDR") != "" diff --git a/internal/handler/sandbox_config.go b/internal/handler/sandbox_config.go index 9ec3c2ccf..5667879ff 100644 --- a/internal/handler/sandbox_config.go +++ b/internal/handler/sandbox_config.go @@ -32,12 +32,15 @@ type sandboxTemplateQueryRequest struct { ConfigID string `json:"config_id,omitempty"` EnsureStandard bool `json:"ensure_standard"` ReplaceStandard bool `json:"replace_standard"` + EnsureDesktop bool `json:"ensure_desktop"` + ReplaceDesktop bool `json:"replace_desktop"` } // QueryTemplates returns the templates visible through an unsaved workspace -// connection. ensure_standard starts a build only when the cluster has no -// usable WeKnora template; replace_standard rebuilds that template so a -// new spec (DNS, image) can take effect. replace_standard requires config_id. +// connection. ensure_standard / ensure_desktop start a build only when that +// WeKnora template is missing (from the published Hub image); replace_standard +// / replace_desktop rebuild it so a new spec (DNS, image) can take effect. +// Replace requires config_id. func (h *SandboxConfigHandler) QueryTemplates(c *gin.Context) { var req sandboxTemplateQueryRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -50,6 +53,8 @@ func (h *SandboxConfigHandler) QueryTemplates(c *gin.Context) { ConfigID: req.ConfigID, EnsureStandard: req.EnsureStandard, ReplaceStandard: req.ReplaceStandard, + EnsureDesktop: req.EnsureDesktop, + ReplaceDesktop: req.ReplaceDesktop, }) if err != nil { if respondSandboxConfigRefusal(c, err) { diff --git a/internal/handler/session/handler.go b/internal/handler/session/handler.go index 98de36dbb..fb1c82cf3 100644 --- a/internal/handler/session/handler.go +++ b/internal/handler/session/handler.go @@ -14,6 +14,7 @@ import ( "github.com/Tencent/WeKnora/internal/types/interfaces" secutils "github.com/Tencent/WeKnora/internal/utils" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) // Handler handles all HTTP requests related to conversation sessions @@ -49,6 +50,12 @@ type Handler struct { // selected agent so the sandbox is created with the same config a // conversation turn would use. terminalService *service.SandboxTerminalService + desktopService *service.SandboxDesktopService + desktopTickets service.SandboxDesktopTicketStore + desktopLast service.SandboxDesktopLastStore + // redis backs the distributed desktop slot. Nil in Lite mode, where the + // in-process limiter is the correct degradation. + redis *redis.Client } // NewHandler creates a new instance of Handler with all necessary dependencies @@ -76,6 +83,10 @@ func NewHandler( memberService interfaces.TenantMemberService, terminalService *service.SandboxTerminalService, browserSkill *browserskill.Manager, + desktopService *service.SandboxDesktopService, + desktopTickets service.SandboxDesktopTicketStore, + desktopLast service.SandboxDesktopLastStore, + rdb *redis.Client, ) *Handler { return &Handler{ browserSkill: browserSkill, @@ -99,6 +110,10 @@ func NewHandler( userService: userService, memberService: memberService, terminalService: terminalService, + desktopService: desktopService, + desktopTickets: desktopTickets, + desktopLast: desktopLast, + redis: rdb, attachmentProcessor: NewAttachmentProcessor( fileService, documentReader, diff --git a/internal/handler/session/sandbox_desktop_rfb.go b/internal/handler/session/sandbox_desktop_rfb.go new file mode 100644 index 000000000..b0c1ef86d --- /dev/null +++ b/internal/handler/session/sandbox_desktop_rfb.go @@ -0,0 +1,353 @@ +// Package session provides RFB (VNC) client-stream parsing for the sandbox desktop relay. +// +// The relay is transparent — it forwards bytes without rewriting them — but it +// still has to know where client messages start and end, because idle +// detection is by message opcode, not by byte count. Byte counting was +// rejected: the XFCE panel clock, websockify's --heartbeat 30, and +// ContinuousUpdates all keep bytes flowing forever, so a sandbox would never +// be judged idle and its TTL would be refreshed indefinitely. +// +// Everything here is a pure function or a small state machine with no I/O, so +// the parts that are expensive to debug in production are cheap to test. +package session + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "math" + "sync/atomic" +) + +// rfbClientHandshakeBytes is how many bytes the CLIENT sends before the +// message stream begins, under RFB 3.8 with security type None: +// +// ProtocolVersion 12 ("RFB 003.008\n") +// security-type 1 (1 = None) +// ClientInit 1 (shared-flag) +// +// RFB 3.3 would be 13 (the server picks the security type), which is why the +// version assertion below is fail-closed rather than informational. +const rfbClientHandshakeBytes = 14 + +// rfbProtocolVersion38 is the only server version this relay accepts. +var rfbProtocolVersion38 = []byte("RFB 003.008\n") + +// rfbSecurityTypeNone is the only security type x11vnc should offer: it runs +// with -nopw, and authentication lives one hop out, on websockify. +const rfbSecurityTypeNone byte = 1 + +// rfbMaxClientMessageBytes bounds one buffered client message. The largest +// legitimate one is a paste (ClientCutText); 1 MiB is far beyond any real +// clipboard and keeps a malformed length header from pinning memory. +const rfbMaxClientMessageBytes = 1 << 20 + +// rfbProtocolVersionBytes is the length of the server's opening message, and +// the most the relay can read before the upgrade: under RFB 3.8 the server +// sends these 12 bytes and then waits for the CLIENT's version before it +// offers anything else (RFC 6143 §7.1.1). The relay must not answer — that +// handshake belongs to noVNC — so reading further here only blocks. +const rfbProtocolVersionBytes = 12 + +// rfbValidateServerVersion is the assertion that still runs before the +// browser socket is upgraded, so a mismatch is an HTTP status code rather +// than a close frame. +func rfbValidateServerVersion(version []byte) error { + if !bytes.Equal(version, rfbProtocolVersion38) { + return fmt.Errorf( + "rfb: server speaks %q, want %q; the client handshake byte count "+ + "depends on this", version, rfbProtocolVersion38) + } + return nil +} + +// rfbValidateSecurityTypes keeps the "x11vnc offered None and only None" +// guarantee. It can no longer run pre-upgrade (see rfbProtocolVersionBytes), +// so the relay applies it to the first server message instead. +func rfbValidateSecurityTypes(securityTypes []byte) error { + if len(securityTypes) != 1 || securityTypes[0] != rfbSecurityTypeNone { + return fmt.Errorf( + "rfb: server offered security types %v, want exactly [%d] (None); "+ + "anything else means start-desktop.sh did not run as expected", + securityTypes, rfbSecurityTypeNone) + } + return nil +} + +// rfbValidateServerHandshake enforces both fail-closed assertions at once, +// for callers that already hold the whole handshake. +func rfbValidateServerHandshake(version []byte, securityTypes []byte) error { + if err := rfbValidateServerVersion(version); err != nil { + return err + } + return rfbValidateSecurityTypes(securityTypes) +} + +// rfbServerStream applies the security-type assertion to the sandbox→browser +// direction, which is the only place left to make it once the relay stopped +// trying to impersonate the client. +// +// The zero value expects the byte right after the server's ProtocolVersion. +// It inspects exactly one message and then gets out of the way. +type rfbServerStream struct { + buf []byte + done bool +} + +// Observe feeds server bytes and reports the first (and only) violation. +func (s *rfbServerStream) Observe(chunk []byte) error { + if s.done || len(chunk) == 0 { + return nil + } + s.buf = append(s.buf, chunk...) + // number-of-security-types, then that many single-byte types. + count := int(s.buf[0]) + if count == 0 { + // Zero is RFB's "connection failed" form, carrying a reason string + // rather than an offer. Either way there is no usable desktop here. + s.done = true + s.buf = nil + return errors.New("rfb: server offered no security types") + } + if len(s.buf) < 1+count { + return nil + } + types := append([]byte(nil), s.buf[1:1+count]...) + s.done = true + s.buf = nil + return rfbValidateSecurityTypes(types) +} + +// rfbClientMessageSize reports the total byte length of the client message at +// the head of buf. +// +// known - false for an opcode this relay does not model. The caller MUST +// NOT guess a length; see rfbClientStream.Consume. +// complete - true when buf already holds the whole message. +// size - meaningful only when complete is true. +// +// Message layouts are RFC 6143 §7.5 plus the two pseudo-messages libvncserver +// advertises and noVNC therefore sends (150 EnableContinuousUpdates, 248 +// ClientFence). +func rfbClientMessageSize(buf []byte) (size int, complete bool, known bool) { + if len(buf) == 0 { + return 0, false, true + } + switch buf[0] { + case 0: // SetPixelFormat + return fixedSize(buf, 20) + case 2: // SetEncodings: 4-byte header + 4 bytes per encoding + if len(buf) < 4 { + return 0, false, true + } + n := int(binary.BigEndian.Uint16(buf[2:4])) + return fixedSize(buf, 4+4*n) + case 3: // FramebufferUpdateRequest + return fixedSize(buf, 10) + case 4: // KeyEvent + return fixedSize(buf, 8) + case 5: // PointerEvent + return fixedSize(buf, 6) + case 6: // ClientCutText: 8-byte header + payload + if len(buf) < 8 { + return 0, false, true + } + return fixedSize(buf, 8+rfbClientCutTextPayload(buf[4:8])) + case 150: // EnableContinuousUpdates + return fixedSize(buf, 10) + case 248: // ClientFence: 9-byte header + payload + if len(buf) < 9 { + return 0, false, true + } + return fixedSize(buf, 9+int(buf[8])) + case 251: // SetDesktopSize: 8-byte header + 16 bytes per screen + // Modelled for completeness, but it should never arrive: the + // frontend pins resizeSession:false in SandboxDesktop.vue. That is a + // parser contract, not a UX preference — do not turn it on without + // re-reading this case. + if len(buf) < 8 { + return 0, false, true + } + return fixedSize(buf, 8+16*int(buf[6])) + default: + return 0, false, false + } +} + +// rfbClientCutTextPayload is the payload byte count of a ClientCutText +// message. RFC 6143 uses an unsigned length. The Extended Clipboard +// pseudo-encoding reuses the same opcode with a signed length: negative +// means abs(n) payload bytes. noVNC writes toUnsigned32bit(-data.length); +// reading that as unsigned on amd64 is ~4e9 and Disable()'s idle detection. +func rfbClientCutTextPayload(lengthField []byte) int { + n := int32(binary.BigEndian.Uint32(lengthField)) + if n >= 0 { + return int(n) + } + if n == math.MinInt32 { + // -MinInt32 does not fit in int32; not a real clipboard. + return rfbMaxClientMessageBytes + } + return int(-n) +} + +func fixedSize(buf []byte, want int) (int, bool, bool) { + if want < 0 || want > rfbMaxClientMessageBytes { + // An implausible declared length: treat it like an unknown opcode + // rather than buffering toward it. + return 0, false, false + } + if len(buf) < want { + return want, false, true + } + return want, true, true +} + +// rfbOpcodeCountsAsActivity reports whether a client message means a human +// touched the desktop. +// +// FramebufferUpdateRequest (3) is deliberately excluded: noVNC emits it in +// response to the server's own updates, so a static desktop with a running +// clock produces a steady stream of them. +func rfbOpcodeCountsAsActivity(opcode byte) bool { + switch opcode { + case 4, 5, 6: // KeyEvent, PointerEvent, ClientCutText + return true + default: + return false + } +} + +// rfbClientStream tracks one browser→sandbox direction: it skips the 14-byte +// handshake, then splits the message stream and reports user activity. +// +// The zero value is ready to use. It is NOT safe for concurrent use; the relay +// feeds it from a single read goroutine. +type rfbClientStream struct { + handshakeSeen int + buf []byte + disabled atomic.Bool +} + +// ParsingEnabled reports whether opcode-based activity detection is still +// live. Once it goes false the connection's idle judgement falls back to the +// frontend's activity POST for good. Safe to call from the activity POST +// while Consume runs on the relay goroutine. +func (s *rfbClientStream) ParsingEnabled() bool { return !s.disabled.Load() } + +// Disable stops parsing. Exported to the package so the relay can also switch +// to the fallback for reasons outside this file. +func (s *rfbClientStream) Disable() { + s.disabled.Store(true) + s.buf = nil +} + +// Consume feeds one chunk of client bytes and reports whether it contained at +// least one activity message. +// +// Chunk boundaries carry no meaning: a WebSocket frame may hold several +// messages, and one message may span frames. Both cases are covered by tests. +func (s *rfbClientStream) Consume(chunk []byte) bool { + if s.disabled.Load() || len(chunk) == 0 { + return false + } + // Skip whatever remains of the handshake. + if s.handshakeSeen < rfbClientHandshakeBytes { + need := rfbClientHandshakeBytes - s.handshakeSeen + if len(chunk) <= need { + s.handshakeSeen += len(chunk) + return false + } + s.handshakeSeen = rfbClientHandshakeBytes + chunk = chunk[need:] + } + + s.buf = append(s.buf, chunk...) + activity := false + for len(s.buf) > 0 { + size, complete, known := rfbClientMessageSize(s.buf) + if !known { + // Never guess a length: one wrong split loses framing for the + // life of the connection. + s.Disable() + return false + } + if !complete { + break + } + if rfbOpcodeCountsAsActivity(s.buf[0]) { + activity = true + } + s.buf = s.buf[size:] + } + // Keep the tail bounded even while waiting for the rest of a message. + if len(s.buf) > rfbMaxClientMessageBytes { + s.Disable() + return false + } + return activity +} + +var errRFBHandshakeIncomplete = errors.New("rfb: server handshake ended early") + +// rfbReadServerVersion drains the server's 12-byte ProtocolVersion from the +// sandbox connection, asserts it, and returns everything it read so the +// caller can replay it to the browser. +// +// It deliberately stops there. Waiting for the security-type list as well +// deadlocks: the server does not send it until the client answers with its +// own version, and the client here is noVNC, which has not been connected +// yet. That bug looked exactly like a dead upstream — the dial 101'd and the +// read then timed out — while the sandbox was serving other clients fine. +func rfbReadServerVersion(next func() ([]byte, error)) ([]byte, error) { + var buf []byte + for attempt := 0; attempt < 64; attempt++ { + if len(buf) >= rfbProtocolVersionBytes { + if err := rfbValidateServerVersion(buf[:rfbProtocolVersionBytes]); err != nil { + return nil, err + } + return buf, nil + } + chunk, err := next() + if err != nil { + return nil, err + } + buf = append(buf, chunk...) + } + return nil, errRFBHandshakeIncomplete +} + +// rfbReadServerHandshake drains the server's opening bytes from the sandbox +// connection, runs the two fail-closed assertions, and returns everything it +// read so the caller can replay it to the browser. +// +// It runs BEFORE the browser socket is upgraded. That ordering is the whole +// reason the assertions can be answered with an HTTP status code instead of a +// close frame the browser shows as a generic disconnect. +// +// next returns the payload of one binary frame from the sandbox, or an error. +func rfbReadServerHandshake(next func() ([]byte, error)) ([]byte, error) { + var buf []byte + // Bound the read: a peer that never completes the handshake must not keep + // us here. 64 frames is far more than the two or three this ever takes. + for attempt := 0; attempt < 64; attempt++ { + // ProtocolVersion(12) + number-of-security-types(1) is the minimum + // before the type list length is even knowable. + if len(buf) >= 13 { + need := 13 + int(buf[12]) + if len(buf) >= need { + if err := rfbValidateServerHandshake(buf[:12], buf[13:need]); err != nil { + return nil, err + } + return buf, nil + } + } + chunk, err := next() + if err != nil { + return nil, err + } + buf = append(buf, chunk...) + } + return nil, errRFBHandshakeIncomplete +} diff --git a/internal/handler/session/sandbox_desktop_rfb_test.go b/internal/handler/session/sandbox_desktop_rfb_test.go new file mode 100644 index 000000000..5fe73c567 --- /dev/null +++ b/internal/handler/session/sandbox_desktop_rfb_test.go @@ -0,0 +1,293 @@ +package session + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRFBValidateServerHandshake(t *testing.T) { + t.Run("accepts 3.8 with None only", func(t *testing.T) { + require.NoError(t, rfbValidateServerHandshake([]byte("RFB 003.008\n"), []byte{1})) + }) + t.Run("rejects 3.3", func(t *testing.T) { + // 3.3 puts 13 bytes on the client side, not 14. Miscounting by one + // loses framing permanently. + err := rfbValidateServerHandshake([]byte("RFB 003.003\n"), []byte{1}) + require.Error(t, err) + }) + t.Run("rejects extra security types", func(t *testing.T) { + // x11vnc runs with -nopw, so None must be the only offer. Anything + // else means the start script did not run as expected, and relaying + // on would hand the authentication semantics to an unknown path. + err := rfbValidateServerHandshake([]byte("RFB 003.008\n"), []byte{1, 2}) + require.Error(t, err) + }) + t.Run("rejects VNC-Auth only", func(t *testing.T) { + require.Error(t, rfbValidateServerHandshake([]byte("RFB 003.008\n"), []byte{2})) + }) + t.Run("rejects empty security list", func(t *testing.T) { + require.Error(t, rfbValidateServerHandshake([]byte("RFB 003.008\n"), nil)) + }) +} + +func TestRFBReadServerVersionStopsAtTwelveBytes(t *testing.T) { + // RFB 3.8 §7.1.1: the server sends its 12-byte ProtocolVersion and then + // waits for the CLIENT's version before offering security types. The + // relay never answers — noVNC does — so a reader that waits for 13 bytes + // hangs until its deadline. Verified against the live sandbox: silent + // client, zero further bytes in 10s; after replying, types arrive in + // 0.25s. + reads := 0 + prelude, err := rfbReadServerVersion(func() ([]byte, error) { + reads++ + if reads > 1 { + return nil, errRFBHandshakeIncomplete + } + return []byte("RFB 003.008\n"), nil + }) + require.NoError(t, err) + require.Equal(t, []byte("RFB 003.008\n"), prelude) + require.Equal(t, 1, reads, "a second read would block until the deadline") +} + +func TestRFBReadServerVersionRejectsWrongVersion(t *testing.T) { + _, err := rfbReadServerVersion(func() ([]byte, error) { + return []byte("RFB 003.003\n"), nil + }) + require.Error(t, err) +} + +func TestRFBReadServerVersionKeepsCoalescedBytes(t *testing.T) { + // Nothing stops a server from coalescing; whatever arrived must reach + // the browser. + one := append([]byte("RFB 003.008\n"), 1, 1) + prelude, err := rfbReadServerVersion(func() ([]byte, error) { return one, nil }) + require.NoError(t, err) + require.Equal(t, one, prelude) +} + +func TestRFBServerStreamValidatesSecurityTypes(t *testing.T) { + // The "exactly [None]" assertion cannot run before the upgrade any more, + // so the relay checks it on the first server message instead. + t.Run("accepts None only", func(t *testing.T) { + var s rfbServerStream + require.NoError(t, s.Observe([]byte{1, 1})) + // Later traffic is no longer inspected. + require.NoError(t, s.Observe([]byte{0, 0, 0, 0})) + }) + t.Run("accepts a split list", func(t *testing.T) { + var s rfbServerStream + require.NoError(t, s.Observe([]byte{1})) + require.NoError(t, s.Observe([]byte{1})) + }) + t.Run("rejects VNC-Auth", func(t *testing.T) { + var s rfbServerStream + require.Error(t, s.Observe([]byte{1, 2})) + }) + t.Run("rejects extra types", func(t *testing.T) { + var s rfbServerStream + require.Error(t, s.Observe([]byte{2, 1, 2})) + }) + t.Run("rejects a zero-length offer", func(t *testing.T) { + // count 0 is the RFB failure form, not an offer. + var s rfbServerStream + require.Error(t, s.Observe([]byte{0})) + }) +} + +func TestRFBReadServerHandshake(t *testing.T) { + // The two assertions must run BEFORE the browser socket is upgraded, so + // a failure is still an HTTP status code rather than a close frame. That + // means reading the server's opening bytes off the sandbox connection and + // replaying them afterwards, which is what prelude is for. + t.Run("returns the prelude for replay", func(t *testing.T) { + frames := [][]byte{[]byte("RFB 003.008\n"), {1, 1}} + i := 0 + prelude, err := rfbReadServerHandshake(func() ([]byte, error) { + if i >= len(frames) { + return nil, errRFBHandshakeIncomplete + } + f := frames[i] + i++ + return f, nil + }) + require.NoError(t, err) + require.Equal(t, append([]byte("RFB 003.008\n"), 1, 1), prelude) + }) + + t.Run("keeps trailing bytes that belong to the message stream", func(t *testing.T) { + // The server may coalesce the handshake with its first real message. + // Those extra bytes must survive into the prelude, or the browser + // never sees them. + one := append([]byte("RFB 003.008\n"), 1, 1, 0xAA, 0xBB) + prelude, err := rfbReadServerHandshake(func() ([]byte, error) { return one, nil }) + require.NoError(t, err) + require.Equal(t, one, prelude) + }) + + t.Run("rejects RFB 3.3", func(t *testing.T) { + _, err := rfbReadServerHandshake(func() ([]byte, error) { + return append([]byte("RFB 003.003\n"), 1, 1), nil + }) + require.Error(t, err) + }) + + t.Run("rejects a non-None security type", func(t *testing.T) { + _, err := rfbReadServerHandshake(func() ([]byte, error) { + return append([]byte("RFB 003.008\n"), 1, 2), nil + }) + require.Error(t, err) + }) + + t.Run("propagates a read failure", func(t *testing.T) { + _, err := rfbReadServerHandshake(func() ([]byte, error) { + return nil, errRFBHandshakeIncomplete + }) + require.ErrorIs(t, err, errRFBHandshakeIncomplete) + }) +} + +func TestRFBClientMessageSize(t *testing.T) { + cases := []struct { + name string + buf []byte + size int + complete bool + known bool + }{ + {"SetPixelFormat", append([]byte{0}, make([]byte, 19)...), 20, true, true}, + {"SetPixelFormat short", []byte{0, 0, 0}, 20, false, true}, + {"SetEncodings 2 encodings", append([]byte{2, 0, 0, 2}, make([]byte, 8)...), 12, true, true}, + {"SetEncodings header short", []byte{2, 0}, 0, false, true}, + {"FramebufferUpdateRequest", append([]byte{3}, make([]byte, 9)...), 10, true, true}, + {"KeyEvent", append([]byte{4}, make([]byte, 7)...), 8, true, true}, + {"PointerEvent", append([]byte{5}, make([]byte, 5)...), 6, true, true}, + {"ClientCutText 5 bytes", append([]byte{6, 0, 0, 0, 0, 0, 0, 5}, make([]byte, 5)...), 13, true, true}, + // noVNC extendedClipboardNotify: length = toUnsigned32bit(-4). + { + "ClientCutText extended 4 bytes", + append([]byte{6, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFC}, 1, 2, 3, 4), + 12, true, true, + }, + {"EnableContinuousUpdates", append([]byte{150}, make([]byte, 9)...), 10, true, true}, + {"ClientFence 4 bytes", append([]byte{248, 0, 0, 0, 0, 0, 0, 0, 4}, make([]byte, 4)...), 13, true, true}, + {"SetDesktopSize 1 screen", append([]byte{251, 0, 0, 0, 0, 0, 1, 0}, make([]byte, 16)...), 24, true, true}, + {"unknown opcode", []byte{99, 1, 2, 3}, 0, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + size, complete, known := rfbClientMessageSize(tc.buf) + require.Equal(t, tc.known, known) + require.Equal(t, tc.complete, complete) + if tc.complete { + require.Equal(t, tc.size, size) + } + }) + } +} + +func TestRFBOpcodeCountsAsActivity(t *testing.T) { + // Only real user input counts. FramebufferUpdateRequest (3) is driven by + // the server's own updates, so counting it would make a wallpaper with a + // clock look like a person at the keyboard — the whole reason byte + // counting was rejected. + require.True(t, rfbOpcodeCountsAsActivity(4)) // KeyEvent + require.True(t, rfbOpcodeCountsAsActivity(5)) // PointerEvent + require.True(t, rfbOpcodeCountsAsActivity(6)) // ClientCutText + require.False(t, rfbOpcodeCountsAsActivity(0)) // SetPixelFormat + require.False(t, rfbOpcodeCountsAsActivity(2)) // SetEncodings + require.False(t, rfbOpcodeCountsAsActivity(3)) // FramebufferUpdateRequest + require.False(t, rfbOpcodeCountsAsActivity(150)) + require.False(t, rfbOpcodeCountsAsActivity(248)) + require.False(t, rfbOpcodeCountsAsActivity(251)) +} + +func TestRFBClientStreamSkipsHandshakeThenDetectsActivity(t *testing.T) { + s := &rfbClientStream{} + + // 14 handshake bytes, deliberately split across three chunks so the + // counter cannot assume one frame per read. + require.False(t, s.Consume([]byte("RFB 003."))) + require.False(t, s.Consume([]byte("008\n"))) + require.False(t, s.Consume([]byte{1, 1})) + require.True(t, s.ParsingEnabled()) + + // FramebufferUpdateRequest: parsed, not activity. + require.False(t, s.Consume(append([]byte{3}, make([]byte, 9)...))) + // PointerEvent: activity. + require.True(t, s.Consume(append([]byte{5}, make([]byte, 5)...))) +} + +func TestRFBClientStreamHandlesSplitMessages(t *testing.T) { + s := &rfbClientStream{} + require.False(t, s.Consume(append([]byte("RFB 003.008\n"), 1, 1))) + + // One KeyEvent delivered one byte at a time. + require.False(t, s.Consume([]byte{4})) + for i := 0; i < 6; i++ { + require.False(t, s.Consume([]byte{0})) + } + require.True(t, s.Consume([]byte{0}), "the final byte completes the KeyEvent") +} + +func TestRFBClientStreamHandlesCoalescedMessages(t *testing.T) { + s := &rfbClientStream{} + require.False(t, s.Consume(append([]byte("RFB 003.008\n"), 1, 1))) + + // SetPixelFormat (no activity) followed by KeyEvent (activity) in one read. + chunk := append(append([]byte{0}, make([]byte, 19)...), append([]byte{4}, make([]byte, 7)...)...) + require.True(t, s.Consume(chunk)) +} + +func TestRFBClientStreamFailsClosedOnUnknownOpcode(t *testing.T) { + // Guessing a length would desync the stream forever. The contract is: + // stop parsing, and let the frontend's activity POST take over. + s := &rfbClientStream{} + require.False(t, s.Consume(append([]byte("RFB 003.008\n"), 1, 1))) + require.True(t, s.ParsingEnabled()) + + require.False(t, s.Consume([]byte{99, 1, 2, 3})) + require.False(t, s.ParsingEnabled(), "unknown opcode must disable parsing for this connection") + + // Once disabled it stays disabled, and never reports activity again. + require.False(t, s.Consume(append([]byte{4}, make([]byte, 7)...))) + require.False(t, s.ParsingEnabled()) +} + +func TestRFBClientStreamKeepsParsingExtendedClipboard(t *testing.T) { + // Extended Clipboard reuses ClientCutText with a negative length field. + // Reading that as unsigned (~4e9) used to Disable() the idle parser for + // the rest of the connection, so a paste (or the caps handshake) left + // idle detection on the frontend POST fallback. + s := &rfbClientStream{} + require.False(t, s.Consume(append([]byte("RFB 003.008\n"), 1, 1))) + + notify := append([]byte{6, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFC}, 0, 0, 0, 0) + require.True(t, s.Consume(notify), "extended clipboard is user activity") + require.True(t, s.ParsingEnabled(), "a signed cut-text length must not disable parsing") + + require.True(t, s.Consume(append([]byte{5}, make([]byte, 5)...)), + "PointerEvent after an extended paste must still count as activity") +} + +func TestRFBClientMessageSizeExtendedClipboardHeaderOnly(t *testing.T) { + // Incomplete body: wait for 4 payload bytes, do not treat the length as + // unknown and Disable(). + size, complete, known := rfbClientMessageSize([]byte{6, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFC}) + require.True(t, known) + require.False(t, complete) + require.Equal(t, 12, size) +} + +func TestRFBClientStreamDoesNotGrowUnbounded(t *testing.T) { + // A truncated ClientCutText claiming a huge length must not let a client + // pin arbitrary memory in the relay. + s := &rfbClientStream{} + require.False(t, s.Consume(append([]byte("RFB 003.008\n"), 1, 1))) + // ClientCutText header claiming ~16 MiB, with no body. 0xFFFFFFFF is a + // signed -1 (extended clipboard of 1 byte) and must NOT take this path. + s.Consume([]byte{6, 0, 0, 0, 0x00, 0xFF, 0xFF, 0xFF}) + require.False(t, s.ParsingEnabled(), + "an implausible message length must fail closed rather than buffer") +} diff --git a/internal/handler/session/sandbox_desktop_ws.go b/internal/handler/session/sandbox_desktop_ws.go new file mode 100644 index 000000000..b04bd9486 --- /dev/null +++ b/internal/handler/session/sandbox_desktop_ws.go @@ -0,0 +1,872 @@ +// Package session provides the sandbox desktop WebSocket relay. +// +// Browser (noVNC, no credentials) +// +// ⇄ this handler ticket auth, 14-byte handshake skip, opcode relay +// ⇄ sandbox gateway e2b-traffic-access-token + Authorization: Basic +// ⇄ websockify :6080 ⇄ x11vnc :5900 (127.0.0.1, -nopw) ⇄ Xvfb ⇄ XFCE +// +// Both credentials exist only on the backend→sandbox hop. The browser holds +// neither, which is the whole reason traffic is relayed rather than sent +// direct. +package session + +import ( + "context" + stderrors "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/redis/go-redis/v9" + + "github.com/Tencent/WeKnora/internal/application/service" + "github.com/Tencent/WeKnora/internal/common/redislock" + "github.com/Tencent/WeKnora/internal/errors" + "github.com/Tencent/WeKnora/internal/logger" + "github.com/Tencent/WeKnora/internal/middleware" + "github.com/Tencent/WeKnora/internal/sandbox" + "github.com/Tencent/WeKnora/internal/types" +) + +const ( + // desktopBrowserReadLimit bounds one client frame. 4096 (the terminal's + // value) is not enough: a ClientCutText paste exceeds it easily, and + // gorilla closes the connection on overrun rather than truncating. + desktopBrowserReadLimit = 64 * 1024 + + // desktopSandboxReadLimit bounds one framebuffer-update frame. websockify + // buffers 65536 by default, so 64 KiB would sit exactly on the edge. + desktopSandboxReadLimit = 1024 * 1024 + + desktopReadTimeout = 120 * time.Second + // desktopHandshakeTimeout bounds the pre-upgrade RFB hello. The relay + // read timeout (120s) was reused here and held the Redis slot for two + // minutes whenever x11vnc was slow; every retry became DESKTOP_BUSY. + desktopHandshakeTimeout = 10 * time.Second + desktopWriteTimeout = 10 * time.Second + desktopPingInterval = 30 * time.Second +) + +// Close reasons. SANDBOX_REBUILT is desktop-specific and load-bearing: a +// skill install marks the binding stale, and a desktop reconnect with no +// AgentQA in flight rebuilds the sandbox immediately +// (session_lifecycle.go:190). The user did nothing; someone else clicked +// install in settings. Reconnecting silently into a blank desktop would hide +// that their /workspace scratch is gone. +const ( + desktopErrNotBound = "SANDBOX_NOT_BOUND" + desktopErrPaused = "SANDBOX_PAUSED" + desktopErrUnsupported = "DESKTOP_UNSUPPORTED" + desktopErrStartFailed = "DESKTOP_START_FAILED" + desktopErrBusy = "DESKTOP_BUSY" + desktopErrRebuilt = "SANDBOX_REBUILT" + desktopErrUpstream = "DESKTOP_UPSTREAM" + desktopErrIdle = "IDLE_DISCONNECTED" + desktopErrAuth = "AUTH_REVOKED" + desktopErrInternal = "INTERNAL" +) + +// desktopUpgrader mirrors terminalUpgrader's CheckOrigin decision and the +// reasoning behind it: authentication travels in an explicit one-shot ticket +// validated before the upgrade, and the endpoint uses no ambient credentials +// (no cookies), so a cross-origin tab cannot piggyback a victim session. A +// strict same-origin check would also break the vite dev proxy, which +// rewrites Host while Origin stays on the dev server. +// +// Buffers are 8x the terminal's because RFB framebuffer updates would +// otherwise be chopped into a great many small writes. +var desktopUpgrader = websocket.Upgrader{ + ReadBufferSize: 32 * 1024, + WriteBufferSize: 32 * 1024, + CheckOrigin: func(*http.Request) bool { return true }, + Subprotocols: []string{"binary"}, +} + +// IssueSandboxDesktopTicket mints a one-shot handshake ticket. The access JWT +// stays on this authenticated POST and never reaches the WS URL. +func (h *Handler) IssueSandboxDesktopTicket(c *gin.Context) { + ctx := c.Request.Context() + sessionID := desktopSessionIDParam(c) + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session id is required"}) + return + } + if _, err := h.sessionService.GetOwnedSession(ctx, sessionID); err != nil { + if stderrors.Is(err, errors.ErrSessionNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + logger.ErrorWithFields(ctx, err, map[string]interface{}{"session_id": sessionID}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load session"}) + return + } + userID, ok := types.UserIDFromContext(ctx) + if !ok || userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + tenantID, ok := types.TenantIDFromContext(ctx) + if !ok || tenantID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + accessToken := sandboxTerminalBearerToken(c) + if accessToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: access token required"}) + return + } + record, err := h.userService.GetAccessTokenByValue(ctx, accessToken) + if err != nil || record == nil || strings.TrimSpace(record.ID) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + if err := service.AssertAccessTokenStillActive(record, userID, time.Now()); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + ticket, err := h.desktopTickets.Issue(ctx, service.SandboxDesktopTicketClaims{ + UserID: userID, TenantID: tenantID, SessionID: sessionID, TokenID: record.ID, + }) + if err != nil { + logger.ErrorWithFields(ctx, err, map[string]interface{}{"session_id": sessionID}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue ticket"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "ticket": ticket, + "expires_in": int(service.DefaultSandboxDesktopTicketTTL.Seconds()), + }}) +} + +// SandboxDesktopWS relays the browser's RFB stream to the session sandbox. +// +// Ticket, auth, and the exclusive slot stay HTTP because those run before +// Ensure. Overlay states from Ensure (SANDBOX_NOT_BOUND / SANDBOX_PAUSED / +// DESKTOP_UNSUPPORTED / DESKTOP_START_FAILED) must upgrade and then close +// with a reason: the JS WebSocket API never exposes a failed handshake's +// HTTP status, so a 409 here becomes a generic error overlay instead of +// the confirm card. Handshake prelude failures stay HTTP — by then the +// sandbox is already live and the frontend must not show "create sandbox". +func (h *Handler) SandboxDesktopWS(c *gin.Context) { + sessionID := desktopSessionIDParam(c) + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session id is required"}) + return + } + + // 1. One-shot ticket. Auth reads the incoming request; workspace identity + // is attached next. Do not capture c.Request.Context() before + // AttachAuthenticatedUser — that snapshot has no tenant, and + // EnsureSessionDesktop then fails with "missing workspace context" + // (mapped to DESKTOP_UPSTREAM). SandboxTerminalWS binds ctx after attach + // for the same reason. + claims, err := h.desktopTickets.Consume(c.Request.Context(), c.Query("ticket")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid or expired ticket"}) + return + } + if claims.SessionID != sessionID { + c.JSON(http.StatusForbidden, gin.H{"error": "ticket is not valid for this session"}) + return + } + user, err := h.checkTerminalAuth(c.Request.Context(), service.SandboxTerminalTicketClaims{ + UserID: claims.UserID, TenantID: claims.TenantID, + SessionID: claims.SessionID, TokenID: claims.TokenID, + }, true) + if err != nil { + if stderrors.Is(err, service.ErrTerminalAuthDenied) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + logger.ErrorWithFields(c.Request.Context(), err, map[string]interface{}{"session_id": sessionID}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to validate ticket"}) + return + } + if !middleware.AttachAuthenticatedUser( + c, h.tenantService, h.memberService, h.config, user, claims.TenantID, + ) { + return + } + ctx := c.Request.Context() + + // 2. One desktop per session, across replicas. Unlike the terminal's + // 5-per-session in-process counter, this must be distributed: two + // browsers on one desktop means two people fighting over one keyboard. + releaseSlot, acquired, slotHold, err := h.acquireDesktopSlot(ctx, sessionID) + if err != nil { + logger.ErrorWithFields(ctx, err, map[string]interface{}{"session_id": sessionID}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reserve desktop"}) + return + } + if !acquired { + c.JSON(http.StatusConflict, gin.H{"error": desktopErrBusy}) + return + } + defer releaseSlot() + + // 3. Start, dial. Provisioning is opt-in per connect, because it creates + // (or resumes and re-bills) a microVM and this is a GET. A bare + // handshake — panel opened, tab restored, background reconnect — + // stays lookup-only: a running sandbox is attached, a paused one + // reports SANDBOX_PAUSED, and a missing one reports SANDBOX_NOT_BOUND. + // Only a confirmed click sets provision=1. Use the request context so + // a browser that gives up the handshake (and every DESKTOP_BUSY retry) + // cancels this work and releases the slot. The relay context is + // detached only after the browser socket is upgraded. + ensureCtx := ctx + if slotHold != nil { + var cancelEnsure context.CancelFunc + ensureCtx, cancelEnsure = context.WithCancel(ctx) + stopEnsureWatch := context.AfterFunc(slotHold, cancelEnsure) + defer stopEnsureWatch() + defer cancelEnsure() + } + allowProvision := terminalFlagParam(c.Query("provision")) + provisionID := h.terminalProvisionConfigID(ensureCtx, c, allowProvision) + desktop, err := h.desktopService.EnsureSessionDesktop(ensureCtx, sessionID, provisionID) + if err != nil { + if slotHold != nil && slotHold.Err() != nil { + c.JSON(http.StatusConflict, gin.H{"error": desktopErrBusy}) + return + } + _, code := desktopErrorStatus(err) + logger.Warnf(ctx, "[sandbox-desktop] open failed session=%s provision=%t code=%s: %v", + sessionID, allowProvision, code, err) + browserConn, upErr := desktopUpgrader.Upgrade(c.Writer, c.Request, nil) + if upErr != nil { + return + } + writeDesktopProtocolClose(browserConn, code) + return + } + + // 4. Read and validate the server's ProtocolVersion while an HTTP status + // code is still expressible: not RFB 003.008 means the client side is + // 13 bytes rather than 14 and the opcode parser would be off by one + // forever. Whatever was read is replayed after the upgrade. + // + // Only the version. The server withholds the security-type list until + // the client answers with its own version, and that answer is noVNC's + // to send — waiting for it here deadlocks until the deadline and looks + // exactly like a dead upstream. The "None and only None" assertion + // moved onto the relay (see desktopRelay.serverStream). + // + // A mute upstream is answered with 502 and nothing else. Tearing the + // stack down from here and redialling was tried and made it worse: the + // browser waits out a second Ensure inside the same upgrade, and the + // healing belongs in EnsureSessionDesktop, which runs before the slot + // matters. + prelude, err := readDesktopServerHandshake(ctx, desktop.Conn) + if err != nil { + _ = desktop.Conn.Close() + logger.Warnf(ctx, "[sandbox-desktop] bad server handshake session=%s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": desktopErrUpstream}) + return + } + + // 5. Only now upgrade the browser socket. + browserConn, err := desktopUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + _ = desktop.Conn.Close() + return + } + relayCtx, cancelRelay := context.WithCancel(context.WithoutCancel(ctx)) + // Replay the handshake the browser's noVNC is waiting for. + _ = browserConn.SetWriteDeadline(time.Now().Add(desktopWriteTimeout)) + if err := browserConn.WriteMessage(websocket.BinaryMessage, prelude); err != nil { + cancelRelay() + _ = browserConn.Close() + _ = desktop.Conn.Close() + return + } + + authClaims := service.SandboxTerminalTicketClaims{ + UserID: claims.UserID, TenantID: claims.TenantID, + SessionID: claims.SessionID, TokenID: claims.TokenID, + } + relay := &desktopRelay{ + browser: browserConn, + sandbox: desktop.Conn, + ctx: relayCtx, + cancel: cancelRelay, + sessionID: sessionID, + idleDisconnect: desktopIdleDisconnect(desktop), + authCheck: func(checkCtx context.Context) error { + _, err := h.checkTerminalAuth(checkCtx, authClaims, false) + return err + }, + } + // Anything the server coalesced onto its version is already the start of + // the security-type list, and the pump will never see it again. + if len(prelude) > rfbProtocolVersionBytes { + if err := relay.serverStream.Observe(prelude[rfbProtocolVersionBytes:]); err != nil { + logger.Warnf(ctx, "[sandbox-desktop] %v session=%s", err, sessionID) + relay.closeWith(desktopErrUpstream) + return + } + } + + // A skill install can rebuild the sandbox under a live session + // (session_lifecycle.go:190). Closing with SANDBOX_REBUILT tells the + // frontend the /workspace scratch is gone instead of silently showing + // a blank desktop. The previous sandbox ID lives in Redis so a reconnect + // on another replica still sees the rebuild. + if h.desktopSandboxRebuilt(ctx, sessionID, desktop.SandboxID) { + relay.closeWith(desktopErrRebuilt) + return + } + + if slotHold != nil { + stopRelayWatch := context.AfterFunc(slotHold, func() { + relay.closeWith(desktopErrBusy) + }) + defer stopRelayWatch() + } + + logger.Infof(ctx, + "[sandbox-desktop][audit] open session=%s sandbox=%s user=%s tenant=%d backend=%s", + sessionID, desktop.SandboxID, claims.UserID, claims.TenantID, desktop.Backend) + + if desktop.StartTTLRefresh != nil { + desktop.StartTTLRefresh(relayCtx) + } + + activeDesktopRelays.Store(sessionID, relay) + defer activeDesktopRelays.Delete(sessionID) + relay.run() +} + +// desktopErrorStatus maps an EnsureSessionDesktop failure to an HTTP status +// and a stable code the frontend switches on. +func desktopErrorStatus(err error) (int, string) { + switch { + case stderrors.Is(err, service.ErrDesktopUnsupported), + stderrors.Is(err, sandbox.ErrDesktopUnsupported): + return http.StatusNotImplemented, desktopErrUnsupported + case stderrors.Is(err, service.ErrDesktopStartFailed): + return http.StatusServiceUnavailable, desktopErrStartFailed + case stderrors.Is(err, sandbox.ErrNoLiveSessionSandbox): + return http.StatusConflict, desktopErrNotBound + case stderrors.Is(err, sandbox.ErrSandboxPaused): + return http.StatusConflict, desktopErrPaused + default: + return http.StatusBadGateway, desktopErrUpstream + } +} + +// desktopIdleDisconnect is the same workspace window the terminal bridge +// uses. The desktop must not hard-code the 15-minute default: an admin who +// raised TerminalIdleDisconnectSec would otherwise see the two tabs disagree. +func desktopIdleDisconnect(d *service.SessionDesktop) time.Duration { + if d != nil && d.IdleDisconnect > 0 { + return d.IdleDisconnect + } + return sandbox.DefaultTerminalIdleDisconnect +} + +func writeDesktopProtocolClose(conn *websocket.Conn, code string) { + if conn == nil { + return + } + reason := strings.TrimSpace(code) + if reason == "" { + reason = desktopErrInternal + } + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason), + time.Now().Add(desktopWriteTimeout), + ) + _ = conn.Close() +} + +func desktopSessionIDParam(c *gin.Context) string { + if id := strings.TrimSpace(c.Param("id")); id != "" { + return id + } + return strings.TrimSpace(c.Param("session_id")) +} + +func readDesktopServerHandshake(ctx context.Context, conn *websocket.Conn) ([]byte, error) { + if conn == nil { + return nil, stderrors.New("desktop handshake: nil conn") + } + handshakeDone := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = conn.Close() + case <-handshakeDone: + } + }() + defer close(handshakeDone) + _ = conn.SetReadDeadline(time.Now().Add(desktopHandshakeTimeout)) + return rfbReadServerVersion(func() ([]byte, error) { + msgType, payload, rerr := conn.ReadMessage() + if rerr != nil { + return nil, rerr + } + if msgType != websocket.BinaryMessage { + return nil, nil + } + return payload, nil + }) +} + +// acquireDesktopSlot reserves the session's single desktop slot across all +// replicas. +// +// The terminal's per-session cap is a process-local counter, which is +// harmless there (a second shell is a cosmetic problem) and unacceptable here +// (a second desktop is two people on one keyboard). Redis SET NX with a +// renewed lease is the cross-replica version; a crashed replica is covered by +// the lease expiry, not by the release call. +// +// Redis absent means Lite mode, which is one process, so the in-process +// limiter is the correct degradation there. +func (h *Handler) acquireDesktopSlot( + ctx context.Context, sessionID string, +) (release func(), acquired bool, hold context.Context, err error) { + if h.redis == nil { + release, ok := sessionDesktopLimiter.acquire(sessionID) + return release, ok, nil, nil + } + key := desktopSlotKey(sessionID) + token, err := redislock.NewToken() + if err != nil { + return func() {}, false, nil, err + } + got, err := redislock.TryAcquire(ctx, h.redis, key, token, desktopSlotLease) + if err != nil { + return func() {}, false, nil, err + } + if !got { + return func() {}, false, nil, nil + } + + hold, cancelHold := context.WithCancelCause(context.Background()) + renewCtx, stopRenew := context.WithCancel(context.WithoutCancel(ctx)) + go renewDesktopSlot(renewCtx, cancelHold, h.redis, key, token) + + var once sync.Once + return func() { + once.Do(func() { + stopRenew() + cancelHold(nil) + relCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _, _ = redislock.Release(relCtx, h.redis, key, token) + cancel() + }) + }, true, hold, nil +} + +func renewDesktopSlot( + ctx context.Context, + cancelHold context.CancelCauseFunc, + client redis.UniversalClient, + key, token string, +) { + ticker := time.NewTicker(desktopSlotRenew) + defer ticker.Stop() + lastOK := time.Now() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + ok, rerr := redislock.Renew(ctx, client, key, token, desktopSlotLease) + if rerr != nil { + if stderrors.Is(rerr, context.Canceled) && ctx.Err() != nil { + return + } + logger.Warnf(ctx, "[sandbox-desktop] slot renew failed key=%s: %v", key, rerr) + if time.Since(lastOK) >= desktopSlotLease { + cancelHold(rerr) + return + } + continue + } + if !ok { + cancelHold(redislock.ErrLockOwnershipLost) + return + } + lastOK = time.Now() + } + } +} + +var ( + // desktopSlotLease outlives a network blip; desktopSlotRenew keeps it + // alive while the relay runs. Tests shorten both so a stolen or expired + // key is observed without waiting on the production 90s lease. + desktopSlotLease = 90 * time.Second + desktopSlotRenew = 30 * time.Second +) + +func desktopSlotKey(sessionID string) string { + return "weknora:desktop-slot:" + sessionID +} + +// desktopLimiter caps the session to one desktop within this process. +type desktopLimiter struct { + mu sync.Mutex + open map[string]bool +} + +var sessionDesktopLimiter = &desktopLimiter{open: make(map[string]bool)} + +func (l *desktopLimiter) acquire(sessionID string) (func(), bool) { + l.mu.Lock() + defer l.mu.Unlock() + if l.open[sessionID] { + return func() {}, false + } + l.open[sessionID] = true + released := false + return func() { + l.mu.Lock() + defer l.mu.Unlock() + if !released { + released = true + delete(l.open, sessionID) + } + }, true +} + +// desktopRelay forwards frames in both directions and judges idleness from +// the client message stream. +type desktopRelay struct { + browser *websocket.Conn + sandbox *websocket.Conn + ctx context.Context + cancel context.CancelFunc + sessionID string + + stream rfbClientStream + + // serverStream carries the security-type assertion that used to run + // before the upgrade. See rfbProtocolVersionBytes for why it had to move. + serverStream rfbServerStream + + mu sync.Mutex + lastActivity time.Time + + // idleDisconnect is how long the desktop may go without a client + // KeyEvent / PointerEvent / ClientCutText before the relay closes. It + // reuses the workspace's terminal setting: both exist to let a sandbox + // reach its provider TTL once the human walked away. + idleDisconnect time.Duration + + // authCheck re-validates the handshake identity on the same cadence as + // the terminal. Handshake is a snapshot; this is what closes the window + // after logout, token revocation, or being removed from the workspace. + // Nil disables the watcher. rejectExpired must stay false: axios rotates + // access tokens without revoking the row the ticket was bound to. + authCheck func(context.Context) error +} + +func (r *desktopRelay) run() { + defer r.cancel() + defer func() { _ = r.browser.Close() }() + defer func() { _ = r.sandbox.Close() }() + + r.browser.SetReadLimit(desktopBrowserReadLimit) + r.sandbox.SetReadLimit(desktopSandboxReadLimit) + r.browser.SetPongHandler(func(string) error { + return r.browser.SetReadDeadline(time.Now().Add(desktopReadTimeout)) + }) + r.sandbox.SetPongHandler(func(string) error { + return r.sandbox.SetReadDeadline(time.Now().Add(desktopReadTimeout)) + }) + r.touch() + + done := make(chan struct{}) + var once sync.Once + finish := func() { once.Do(func() { close(done) }) } + + // The sandbox pump is not the goroutine run() waits on. If it just + // returns, pumpBrowserToSandbox stays blocked on a live browser read + // until desktopReadTimeout, and the Redis slot stays held (DESKTOP_BUSY + // on every reconnect). Closing both hops unblocks that read immediately. + go func() { + defer finish() + defer r.closePeers() + r.pumpSandboxToBrowser() + }() + go r.watchIdle(done) + go r.watchContext(done) + go r.heartbeat(done) + go r.watchAuth(done) + + r.pumpBrowserToSandbox() + finish() +} + +// pumpBrowserToSandbox forwards client frames and feeds the opcode parser. +// +// Note what is NOT here: ping/pong forwarding. gorilla does not surface +// received pings as messages, so each hop keeps its own liveness. That is the +// correct behaviour, and it is written down because it looks like an omission. +func (r *desktopRelay) pumpBrowserToSandbox() { + for { + _ = r.browser.SetReadDeadline(time.Now().Add(desktopReadTimeout)) + msgType, payload, err := r.browser.ReadMessage() + if err != nil { + return + } + if msgType != websocket.BinaryMessage { + continue + } + parsing := r.stream.ParsingEnabled() + if r.stream.Consume(payload) { + r.touch() + } + if parsing && !r.stream.ParsingEnabled() { + logger.Warnf(r.ctx, + "[sandbox-desktop] RFB client parser disabled; falling back to reported activity session=%s", + r.sessionID) + } + _ = r.sandbox.SetWriteDeadline(time.Now().Add(desktopWriteTimeout)) + if err := r.sandbox.WriteMessage(websocket.BinaryMessage, payload); err != nil { + return + } + } +} + +func (r *desktopRelay) pumpSandboxToBrowser() { + for { + _ = r.sandbox.SetReadDeadline(time.Now().Add(desktopReadTimeout)) + msgType, payload, err := r.sandbox.ReadMessage() + if err != nil { + return + } + if msgType != websocket.BinaryMessage { + continue + } + if err := r.serverStream.Observe(payload); err != nil { + logger.Warnf(r.ctx, "[sandbox-desktop] %v session=%s", err, r.sessionID) + r.closeWith(desktopErrUpstream) + return + } + _ = r.browser.SetWriteDeadline(time.Now().Add(desktopWriteTimeout)) + if err := r.browser.WriteMessage(websocket.BinaryMessage, payload); err != nil { + return + } + } +} + +func (r *desktopRelay) touch() { + r.mu.Lock() + r.lastActivity = time.Now() + r.mu.Unlock() +} + +func (r *desktopRelay) idleFor(now time.Time) time.Duration { + r.mu.Lock() + defer r.mu.Unlock() + return now.Sub(r.lastActivity) +} + +func (r *desktopRelay) isIdle(now time.Time) bool { + if r.idleDisconnect <= 0 { + return false + } + return r.idleFor(now) >= r.idleDisconnect +} + +// watchIdle closes the relay once nobody has touched the desktop for +// idleDisconnect. closePeers cancels relayCtx, which stops the +// StartTTLRefresh loop started after upgrade — that is what finally lets +// the sandbox pause on its own provider timeout. +func (r *desktopRelay) watchIdle(done <-chan struct{}) { + if r.idleDisconnect <= 0 { + return + } + // Check often enough that the reported idle time is roughly honest, + // cheaply enough that it is free. + interval := r.idleDisconnect / 10 + if interval < time.Second { + interval = time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-r.ctx.Done(): + return + case now := <-ticker.C: + if r.isIdle(now) { + r.closeWith(desktopErrIdle) + return + } + } + } +} + +// watchContext unblocks run() when the relay ctx is canceled from outside +// the pumps — notably when the Redis slot hold is lost. closePeers is what +// unblocks ReadMessage; canceling ctx alone would sit on desktopReadTimeout. +func (r *desktopRelay) watchContext(done <-chan struct{}) { + select { + case <-done: + case <-r.ctx.Done(): + r.closePeers() + } +} + +// heartbeat keeps the browser hop alive. Each hop pings independently: this +// relay does not forward ping/pong, because gorilla does not surface received +// pings as messages. See pumpBrowserToSandbox. +func (r *desktopRelay) heartbeat(done <-chan struct{}) { + ticker := time.NewTicker(desktopPingInterval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-r.ctx.Done(): + return + case <-ticker.C: + _ = r.browser.WriteControl(websocket.PingMessage, nil, + time.Now().Add(desktopWriteTimeout)) + _ = r.sandbox.WriteControl(websocket.PingMessage, nil, + time.Now().Add(desktopWriteTimeout)) + } + } +} + +// watchAuth re-validates the minting identity while the desktop is open. +// Denied (logout / revoke / membership loss) tears the relay down; any other +// error is a lookup failure and is retried, matching the terminal so a +// database hiccup cannot disconnect every open desktop at once. +func (r *desktopRelay) watchAuth(done <-chan struct{}) { + if r == nil || r.authCheck == nil { + return + } + ticker := time.NewTicker(terminalAuthRecheckInterval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-r.ctx.Done(): + return + case <-ticker.C: + checkCtx, cancel := context.WithTimeout(r.ctx, terminalAuthRecheckTimeout) + err := r.authCheck(checkCtx) + cancel() + if err == nil { + continue + } + if r.ctx.Err() != nil { + return + } + if stderrors.Is(err, service.ErrTerminalAuthDenied) { + r.closeWith(desktopErrAuth) + return + } + logger.Warnf(r.ctx, "[sandbox-desktop] auth recheck failed session=%s: %v", + r.sessionID, err) + } + } +} + +// closeWith sends a close frame carrying a stable reason code, so the +// frontend can show the right message instead of silently reconnecting. +func (r *desktopRelay) closeWith(reason string) { + if r != nil && r.browser != nil { + _ = r.browser.WriteControl(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason), + time.Now().Add(desktopWriteTimeout)) + } + r.closePeers() +} + +// closePeers tears down both hops. gorilla's Close unblocks a ReadMessage +// on the other side, which is what lets run() return (and release the +// desktop slot) instead of sitting on desktopReadTimeout. +func (r *desktopRelay) closePeers() { + if r == nil { + return + } + if r.cancel != nil { + r.cancel() + } + if r.browser != nil { + _ = r.browser.Close() + } + if r.sandbox != nil { + _ = r.sandbox.Close() + } +} + +// TouchFromFrontend records the activity signal the browser POSTs. It is the +// only source once the opcode parser has fallen back (unknown opcode). While +// the parser is healthy this is a no-op: a client that kept posting would +// otherwise ride the TTL without generating KeyEvent/PointerEvent. +func (r *desktopRelay) TouchFromFrontend() { + if r == nil || r.stream.ParsingEnabled() { + return + } + r.touch() +} + +// ReportSandboxDesktopActivity records browser-side key/mouse activity. +// +// It is the fallback the RFB parser degrades to on an unknown opcode, and a +// no-op when the parser is healthy. It is deliberately not the primary +// signal: a client that simply stops posting would keep the sandbox alive +// for free. +func (h *Handler) ReportSandboxDesktopActivity(c *gin.Context) { + sessionID := desktopSessionIDParam(c) + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session id is required"}) + return + } + if _, err := h.sessionService.GetOwnedSession(c.Request.Context(), sessionID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + touchDesktopRelay(sessionID) + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// activeDesktopRelays lets the activity endpoint reach the live relay. It is +// process-local by nature: the POST and the WebSocket ride the same sticky +// connection in practice, and a miss only costs one skipped touch. +var activeDesktopRelays sync.Map // sessionID -> *desktopRelay + +func touchDesktopRelay(sessionID string) { + if value, ok := activeDesktopRelays.Load(sessionID); ok { + if relay, ok := value.(*desktopRelay); ok { + relay.TouchFromFrontend() + } + } +} + +// desktopSandboxRebuilt records the sandbox this session's desktop just +// attached to and reports whether it differs from the previous one. +func (h *Handler) desktopSandboxRebuilt(ctx context.Context, sessionID, sandboxID string) bool { + if h == nil || h.desktopLast == nil { + return false + } + sessionID = strings.TrimSpace(sessionID) + sandboxID = strings.TrimSpace(sandboxID) + if sessionID == "" || sandboxID == "" { + return false + } + prev, err := h.desktopLast.Get(ctx, sessionID) + if err != nil { + logger.Warnf(ctx, "[sandbox-desktop] last-sandbox get failed session=%s: %v", sessionID, err) + } + if setErr := h.desktopLast.Set(ctx, sessionID, sandboxID); setErr != nil { + logger.Warnf(ctx, "[sandbox-desktop] last-sandbox set failed session=%s: %v", sessionID, setErr) + } + return prev != "" && prev != sandboxID +} diff --git a/internal/handler/session/sandbox_desktop_ws_test.go b/internal/handler/session/sandbox_desktop_ws_test.go new file mode 100644 index 000000000..8217ab915 --- /dev/null +++ b/internal/handler/session/sandbox_desktop_ws_test.go @@ -0,0 +1,438 @@ +package session + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gorilla/websocket" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/internal/application/service" + "github.com/Tencent/WeKnora/internal/sandbox" +) + +func TestDesktopRelayIdleFiresWithoutOpcodeActivity(t *testing.T) { + // The control this whole design exists for: a desktop nobody is touching + // must go idle even though bytes keep flowing (XFCE clock, websockify + // heartbeat, ContinuousUpdates). + relay := &desktopRelay{sessionID: "sess-1", idleDisconnect: 50 * time.Millisecond} + relay.touch() + + // Feed traffic that is NOT activity: handshake, then repeated + // FramebufferUpdateRequest. + relay.stream.Consume(append([]byte("RFB 003.008\n"), 1, 1)) + for i := 0; i < 20; i++ { + require.False(t, relay.stream.Consume(append([]byte{3}, make([]byte, 9)...))) + } + + time.Sleep(80 * time.Millisecond) + require.True(t, relay.idleFor(time.Now()) >= 50*time.Millisecond) + require.True(t, relay.isIdle(time.Now())) +} + +func TestDesktopRelayNotIdleAfterPointerEvent(t *testing.T) { + relay := &desktopRelay{sessionID: "sess-1", idleDisconnect: time.Minute} + relay.stream.Consume(append([]byte("RFB 003.008\n"), 1, 1)) + if relay.stream.Consume(append([]byte{5}, make([]byte, 5)...)) { + relay.touch() + } + require.False(t, relay.isIdle(time.Now())) +} + +func TestDesktopRelayFrontendReportKeepsAliveAfterParserFallback(t *testing.T) { + // After an unknown opcode the parser is off for good, so the frontend POST + // is the only remaining activity source. + relay := &desktopRelay{sessionID: "sess-1", idleDisconnect: time.Minute} + relay.stream.Consume(append([]byte("RFB 003.008\n"), 1, 1)) + relay.stream.Consume([]byte{99, 0, 0}) + require.False(t, relay.stream.ParsingEnabled()) + + relay.lastActivity = time.Now().Add(-2 * time.Minute) + require.True(t, relay.isIdle(time.Now())) + relay.TouchFromFrontend() + require.False(t, relay.isIdle(time.Now())) +} + +func TestDesktopRelayFrontendReportIsIgnoredWhileParserHealthy(t *testing.T) { + relay := &desktopRelay{sessionID: "sess-1", idleDisconnect: time.Minute} + relay.stream.Consume(append([]byte("RFB 003.008\n"), 1, 1)) + require.True(t, relay.stream.ParsingEnabled()) + + relay.lastActivity = time.Now().Add(-2 * time.Minute) + require.True(t, relay.isIdle(time.Now())) + relay.TouchFromFrontend() + require.True(t, relay.isIdle(time.Now()), + "a healthy parser must ignore the activity POST so a client cannot keep the TTL by posting") +} + +func TestDesktopSandboxRebuiltUsesStore(t *testing.T) { + h := &Handler{desktopLast: service.NewSandboxDesktopLastStore(nil)} + ctx := context.Background() + require.False(t, h.desktopSandboxRebuilt(ctx, "sess-1", "sbx-a")) + require.True(t, h.desktopSandboxRebuilt(ctx, "sess-1", "sbx-b")) + require.False(t, h.desktopSandboxRebuilt(ctx, "sess-1", "sbx-b")) +} + +func TestDesktopSlotIsExclusivePerSession(t *testing.T) { + first, ok := sessionDesktopLimiter.acquire("sess-excl") + require.True(t, ok) + _, ok = sessionDesktopLimiter.acquire("sess-excl") + require.False(t, ok, "a second desktop on one session means two people on one keyboard") + first() + third, ok := sessionDesktopLimiter.acquire("sess-excl") + require.True(t, ok, "releasing must free the slot") + third() +} + +func TestDesktopSlotReleaseIsIdempotent(t *testing.T) { + release, ok := sessionDesktopLimiter.acquire("sess-idem") + require.True(t, ok) + release() + release() + next, ok := sessionDesktopLimiter.acquire("sess-idem") + require.True(t, ok) + next() +} + +func withDesktopSlotTiming(t *testing.T, lease, renew time.Duration) { + t.Helper() + origLease, origRenew := desktopSlotLease, desktopSlotRenew + desktopSlotLease, desktopSlotRenew = lease, renew + t.Cleanup(func() { + desktopSlotLease, desktopSlotRenew = origLease, origRenew + }) +} + +func newDesktopSlotHandler(t *testing.T) (*Handler, *miniredis.Miniredis, *redis.Client) { + t.Helper() + mini := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mini.Addr()}) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + return &Handler{redis: client}, mini, client +} + +func TestDesktopSlotRenewSurvivesTransientRedisError(t *testing.T) { + withDesktopSlotTiming(t, 400*time.Millisecond, 50*time.Millisecond) + h, mini, client := newDesktopSlotHandler(t) + ctx := context.Background() + + release, got, hold, err := h.acquireDesktopSlot(ctx, "sess-blip") + require.NoError(t, err) + require.True(t, got) + require.NotNil(t, hold) + defer release() + + mini.SetError("timeout") + time.Sleep(70 * time.Millisecond) + mini.SetError("") + for i := 0; i < 6; i++ { + time.Sleep(70 * time.Millisecond) + mini.FastForward(80 * time.Millisecond) + } + + select { + case <-hold.Done(): + t.Fatal("a redis blip must not drop the desktop slot") + default: + } + require.NotEmpty(t, client.Get(ctx, desktopSlotKey("sess-blip")).Val()) + + _, got, _, err = h.acquireDesktopSlot(ctx, "sess-blip") + require.NoError(t, err) + require.False(t, got, "the original relay must still own the slot after a blip") +} + +func TestDesktopSlotCancelsHoldWhenRedisStaysDownPastLease(t *testing.T) { + withDesktopSlotTiming(t, 200*time.Millisecond, 40*time.Millisecond) + h, mini, _ := newDesktopSlotHandler(t) + ctx := context.Background() + + release, got, hold, err := h.acquireDesktopSlot(ctx, "sess-down") + require.NoError(t, err) + require.True(t, got) + defer release() + + mini.SetError("timeout") + select { + case <-hold.Done(): + case <-time.After(time.Second): + t.Fatal("redis down for a full lease must drop the hold; " + + "otherwise a recovered replica would still claim exclusivity") + } +} + +func TestDesktopSlotCancelsHoldWhenOwnershipIsStolen(t *testing.T) { + withDesktopSlotTiming(t, 400*time.Millisecond, 40*time.Millisecond) + h, _, client := newDesktopSlotHandler(t) + ctx := context.Background() + + release, got, hold, err := h.acquireDesktopSlot(ctx, "sess-stolen") + require.NoError(t, err) + require.True(t, got) + defer release() + + require.NoError(t, client.Set(ctx, desktopSlotKey("sess-stolen"), "intruder", time.Minute).Err()) + select { + case <-hold.Done(): + case <-time.After(300 * time.Millisecond): + t.Fatal("losing the redis key must cancel the hold so the relay can drop") + } + + second, got, secondHold, err := h.acquireDesktopSlot(ctx, "sess-stolen") + require.NoError(t, err) + require.False(t, got, "the thief still owns the key; we must not overwrite it") + _ = second + _ = secondHold +} + +func TestDesktopSlotCancelsHoldWhenKeyExpires(t *testing.T) { + withDesktopSlotTiming(t, 400*time.Millisecond, 40*time.Millisecond) + h, _, client := newDesktopSlotHandler(t) + ctx := context.Background() + + release, got, hold, err := h.acquireDesktopSlot(ctx, "sess-expired") + require.NoError(t, err) + require.True(t, got) + defer release() + + require.NoError(t, client.Del(ctx, desktopSlotKey("sess-expired")).Err()) + select { + case <-hold.Done(): + case <-time.After(300 * time.Millisecond): + t.Fatal("an expired slot must cancel the hold; otherwise a second client can join") + } + + second, got, secondHold, err := h.acquireDesktopSlot(ctx, "sess-expired") + require.NoError(t, err) + require.True(t, got, "after the first hold is lost the session must accept one new desktop") + defer second() + _ = secondHold +} + +func TestDesktopIdleDisconnectFollowsWorkspaceTerminalSetting(t *testing.T) { + require.Equal(t, time.Hour, desktopIdleDisconnect(&service.SessionDesktop{ + IdleDisconnect: time.Hour, + })) + require.Equal(t, sandbox.DefaultTerminalIdleDisconnect, desktopIdleDisconnect(&service.SessionDesktop{})) + require.Equal(t, sandbox.DefaultTerminalIdleDisconnect, desktopIdleDisconnect(nil)) +} + +func TestDesktopHandshakeTimeoutIsShorterThanRelayReadTimeout(t *testing.T) { + // Reusing the 120s relay read deadline for the pre-upgrade RFB hello held + // the Redis slot for two minutes; retries became DESKTOP_BUSY. The hello + // is 12 bytes as soon as x11vnc accepts, so a short bound is enough. + require.Greater(t, desktopReadTimeout, desktopHandshakeTimeout) + require.LessOrEqual(t, desktopHandshakeTimeout, 15*time.Second) +} + +func TestDesktopErrorStatusMapsLookupFailures(t *testing.T) { + status, code := desktopErrorStatus(sandbox.ErrNoLiveSessionSandbox) + require.Equal(t, http.StatusConflict, status) + require.Equal(t, desktopErrNotBound, code) + + status, code = desktopErrorStatus(sandbox.ErrSandboxPaused) + require.Equal(t, http.StatusConflict, status) + require.Equal(t, desktopErrPaused, code) + + status, code = desktopErrorStatus(service.ErrDesktopUnsupported) + require.Equal(t, http.StatusNotImplemented, status) + require.Equal(t, desktopErrUnsupported, code) + + status, code = desktopErrorStatus(service.ErrDesktopStartFailed) + require.Equal(t, http.StatusServiceUnavailable, status) + require.Equal(t, desktopErrStartFailed, code) +} + +func TestSandboxDesktopWSLookupIsOptInProvision(t *testing.T) { + // Opening the panel is a GET. Creating or resuming a microVM must stay + // behind an explicit provision=1, the same contract as the terminal tab. + src, err := os.ReadFile("sandbox_desktop_ws.go") + require.NoError(t, err) + text := string(src) + require.Contains(t, text, `allowProvision := terminalFlagParam(c.Query("provision"))`) + require.Contains(t, text, `h.terminalProvisionConfigID(ensureCtx, c, allowProvision)`) + require.NotContains(t, text, `terminalProvisionConfigID(ctx, c, true)`) + // The JS WebSocket API never exposes a failed handshake's HTTP status. + // Overlay states (not bound / paused) have to ride a close reason after + // upgrade, or the panel shows a generic error instead of the confirm card. + require.Contains(t, text, "writeDesktopProtocolClose") +} + +func TestDesktopRelayTearsDownWhenAuthIsRevoked(t *testing.T) { + prev := terminalAuthRecheckInterval + terminalAuthRecheckInterval = 20 * time.Millisecond + t.Cleanup(func() { terminalAuthRecheckInterval = prev }) + + browserClient, sandboxClient, runReturned, _ := startDesktopRelay(t, func(context.Context) error { + return service.ErrTerminalAuthDenied + }) + + select { + case <-runReturned: + case <-time.After(2 * time.Second): + t.Fatal("run() still blocked after auth was revoked; the desktop would keep relaying") + } + + require.NoError(t, browserClient.SetReadDeadline(time.Now().Add(2*time.Second))) + _, _, err := browserClient.ReadMessage() + var closeErr *websocket.CloseError + require.ErrorAs(t, err, &closeErr) + require.Equal(t, desktopErrAuth, closeErr.Text) + + require.NoError(t, sandboxClient.SetReadDeadline(time.Now().Add(2*time.Second))) + _, _, err = sandboxClient.ReadMessage() + require.Error(t, err, "sandbox hop must close with the browser when auth is revoked") +} + +func TestDesktopRelaySurvivesTransientAuthLookupFailure(t *testing.T) { + prev := terminalAuthRecheckInterval + terminalAuthRecheckInterval = 10 * time.Millisecond + t.Cleanup(func() { terminalAuthRecheckInterval = prev }) + + var checks atomic.Int32 + browserClient, _, runReturned, _ := startDesktopRelay(t, func(context.Context) error { + checks.Add(1) + return errors.New("db down") + }) + + require.Eventually(t, func() bool { + return checks.Load() >= 3 + }, 2*time.Second, 10*time.Millisecond, "watcher must keep retrying") + + select { + case <-runReturned: + t.Fatal("a lookup failure must not tear the desktop down") + case <-time.After(50 * time.Millisecond): + } + + require.NoError(t, browserClient.SetReadDeadline(time.Now().Add(150*time.Millisecond))) + _, _, err := browserClient.ReadMessage() + require.Error(t, err) + netErr, ok := err.(interface{ Timeout() bool }) + require.True(t, ok && netErr.Timeout(), + "expected a read timeout (desktop still open), got %v", err) +} + +func TestSandboxDesktopWSWiresLiveAuthRecheck(t *testing.T) { + src, err := os.ReadFile("sandbox_desktop_ws.go") + require.NoError(t, err) + text := string(src) + require.Contains(t, text, "authCheck:") + require.Contains(t, text, "checkTerminalAuth(checkCtx, authClaims, false)") + require.Contains(t, text, "go r.watchAuth(done)") + require.Contains(t, text, "go r.watchContext(done)") +} + +func TestSandboxDesktopWSStartsTTLRefreshOnRelayCtx(t *testing.T) { + src, err := os.ReadFile("sandbox_desktop_ws.go") + require.NoError(t, err) + text := string(src) + require.Contains(t, text, "desktop.StartTTLRefresh(relayCtx)") +} + +func TestSandboxDesktopWSDoesNotRefreshProviderTTL(t *testing.T) { + // The relay must not run its own SetTimeout loop. Cube/E2B refresh is + // started on relayCtx via StartTTLRefresh after upgrade. + src, err := os.ReadFile("sandbox_desktop_ws.go") + require.NoError(t, err) + text := string(src) + require.NotContains(t, text, "ttlRefresh func") + require.NotContains(t, text, "go r.refreshTTL") + require.NotContains(t, text, "func (r *desktopRelay) refreshTTL") +} + +func TestDesktopRelaySandboxDropReleasesWithoutReadTimeout(t *testing.T) { + // The Redis desktop slot lives until run() returns. If the sandbox hop + // dies and run() waits out desktopReadTimeout on the still-open browser + // read, every reconnect is DESKTOP_BUSY for up to two minutes. + browserClient, sandboxClient, runReturned, _ := startDesktopRelay(t, nil) + + require.NoError(t, sandboxClient.Close()) + + select { + case <-runReturned: + case <-time.After(2 * time.Second): + t.Fatal("run() still blocked after the sandbox hop dropped; " + + "the desktop slot would stay held for desktopReadTimeout") + } + + require.NoError(t, browserClient.SetReadDeadline(time.Now().Add(2*time.Second))) + _, _, err := browserClient.ReadMessage() + require.Error(t, err, "the browser hop must close so noVNC disconnects and can reconnect") +} + +func TestDesktopRelayUnblocksWhenContextCanceled(t *testing.T) { + // cancel() alone does not unblock gorilla ReadMessage. watchContext must + // closePeers or run() sits on desktopReadTimeout and the Redis slot stays held. + _, _, runReturned, cancel := startDesktopRelay(t, nil) + cancel() + + select { + case <-runReturned: + case <-time.After(2 * time.Second): + t.Fatal("run() still blocked after ctx cancel; watchContext must close both hops") + } +} + +func startDesktopRelay(t *testing.T, authCheck func(context.Context) error) ( + browserClient, sandboxClient *websocket.Conn, runReturned <-chan struct{}, cancel context.CancelFunc, +) { + t.Helper() + + browserSrvConn := make(chan *websocket.Conn, 1) + sandboxSrvConn := make(chan *websocket.Conn, 1) + browserSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := desktopUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + browserSrvConn <- conn + })) + t.Cleanup(browserSrv.Close) + sandboxSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := desktopUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + sandboxSrvConn <- conn + })) + t.Cleanup(sandboxSrv.Close) + + var err error + browserClient, _, err = websocket.DefaultDialer.Dial( + "ws"+strings.TrimPrefix(browserSrv.URL, "http"), nil) + require.NoError(t, err) + t.Cleanup(func() { _ = browserClient.Close() }) + sandboxClient, _, err = websocket.DefaultDialer.Dial( + "ws"+strings.TrimPrefix(sandboxSrv.URL, "http"), nil) + require.NoError(t, err) + t.Cleanup(func() { _ = sandboxClient.Close() }) + + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + t.Cleanup(cancel) + relay := &desktopRelay{ + browser: <-browserSrvConn, + sandbox: <-sandboxSrvConn, + ctx: ctx, + cancel: cancel, + sessionID: "sess-pump", + idleDisconnect: 0, + authCheck: authCheck, + } + done := make(chan struct{}) + go func() { + defer close(done) + relay.run() + }() + return browserClient, sandboxClient, done, cancel +} diff --git a/internal/router/router.go b/internal/router/router.go index c8918dcd7..e8be2a7df 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -181,6 +181,7 @@ func NewRouter(params RouterParams) *gin.Engine { // auth headers on the WS handshake, so this must precede the global Auth // middleware). The ticket is minted by an authenticated POST. RegisterSandboxTerminalRoutes(r, params.SessionHandler) + RegisterSandboxDesktopRoutes(r, params.SessionHandler) r.GET("/api/v1/local-browser/extension", params.SessionHandler.BrowserSkillExtension) r.POST("/api/v1/local-browser/extension/authorize", params.SessionHandler.BrowserSkillAuthorize) r.POST("/api/v1/local-browser/internal", params.SessionHandler.BrowserSkillInternal) diff --git a/internal/router/routes_chat.go b/internal/router/routes_chat.go index cdcf39eac..5f543b7c6 100644 --- a/internal/router/routes_chat.go +++ b/internal/router/routes_chat.go @@ -65,6 +65,8 @@ func RegisterSessionRoutes( sessions.DELETE("/:id/attachments/:attachment_id", handler.DeleteTemporaryDocument) sessions.POST("/:session_id/stop", handler.StopSession) sessions.POST("/:session_id/sandbox/terminal-ticket", handler.IssueSandboxTerminalTicket) + sessions.POST("/:session_id/sandbox/desktop-ticket", handler.IssueSandboxDesktopTicket) + sessions.POST("/:session_id/sandbox/desktop/activity", handler.ReportSandboxDesktopActivity) sessions.GET("/:id/local-browser", handler.BrowserSkillConnection) sessions.POST("/:session_id/local-browser", handler.BrowserSkillConnection) // Mid-run message injection: append a user message to the turn that is @@ -148,3 +150,16 @@ func RegisterChatRoutes(r *gin.RouterGroup, handler *session.Handler, g *rbacGua func RegisterSandboxTerminalRoutes(r *gin.Engine, sessionHandler *session.Handler) { r.GET("/api/v1/sessions/:id/sandbox/terminal", sessionHandler.SandboxTerminalWS) } + +// RegisterSandboxDesktopRoutes registers the desktop relay WebSocket. +// +// Registered BEFORE the global auth middleware for the same reason as the +// terminal: a browser WebSocket handshake cannot carry Authorization, so a +// one-shot session-bound ticket travels in the query string. Unlike the +// terminal's JWT the desktop ticket is an opaque random string consumed with +// GETDEL, so a leaked URL is worth one handshake at most. +// +// The wildcard is :id to match /sessions/:id in the same radix tree. +func RegisterSandboxDesktopRoutes(r *gin.Engine, sessionHandler *session.Handler) { + r.GET("/api/v1/sessions/:id/sandbox/desktop", sessionHandler.SandboxDesktopWS) +} diff --git a/internal/sandbox/capabilities.go b/internal/sandbox/capabilities.go index a3aee0807..a64d9e5e9 100644 --- a/internal/sandbox/capabilities.go +++ b/internal/sandbox/capabilities.go @@ -15,6 +15,8 @@ package sandbox import ( "context" "time" + + "github.com/gorilla/websocket" ) // SessionShellExecutor executes ad-hoc shell commands inside a session- @@ -150,6 +152,45 @@ type SessionTerminalProvider interface { SessionTerminalManager() SessionTerminalManager } +// SessionDesktopConn is one dialled desktop leg. +// +// SandboxID is the sandbox this connection actually reached. It is returned +// rather than looked up afterwards because a skill install landing between +// the dial and the lookup would report the new sandbox for a connection held +// on the old one, and the handler's rebuild check would silently miss. +type SessionDesktopConn struct { + Conn *websocket.Conn + SandboxID string + + // StartTTLRefresh extends the provider idle timeout for as long as ctx + // is live. The WebSocket handler must pass the relay ctx (WithoutCancel), + // not the HTTP request ctx used to dial. Nil when the backend has no + // timeout to refresh (Docker). + StartTTLRefresh func(ctx context.Context) +} + +// SessionDesktopManager relays a WebSocket to the graphical desktop of the +// sandbox bound to a session. Like the terminal it is provider-neutral: the +// WebSocket handler bridges browser RFB frames to it without knowing whether +// E2B or Cube serves the session. +type SessionDesktopManager interface { + // OpenSessionDesktop dials the desktop port of the session's currently + // bound sandbox. It is lookup-only by design: the caller (the desktop + // service) has already provisioned and started the desktop through the + // normal execution path, so a missing binding here means the sandbox + // disappeared between the two steps, not "please create one". + // ErrNoLiveSessionSandbox says exactly that; a backend that cannot relay + // desktops returns ErrDesktopUnsupported. + OpenSessionDesktop(ctx context.Context, sessionID string, opts RemoteDesktopOptions) (*SessionDesktopConn, error) +} + +// SessionDesktopProvider is implemented by managers that MAY offer a +// graphical desktop. The accessor returns nil when the current runtime cannot +// honour the capability. +type SessionDesktopProvider interface { + SessionDesktopManager() SessionDesktopManager +} + // SessionTurnHolder marks the start and end of one chat turn on a session's // sandbox. While the turn is open, a stale image mark waits: the first // resolve of the turn may rebuild, later resolves of the same turn keep the diff --git a/internal/sandbox/cube_remote_client.go b/internal/sandbox/cube_remote_client.go index 81510fa37..ac40b3960 100644 --- a/internal/sandbox/cube_remote_client.go +++ b/internal/sandbox/cube_remote_client.go @@ -18,6 +18,7 @@ import ( "time" "github.com/Tencent/WeKnora/internal/logger" + "github.com/gorilla/websocket" cubesandbox "github.com/tencentcloud/CubeSandbox/sdk/go" ) @@ -29,6 +30,16 @@ type CubeRemoteClient struct { client *cubesandbox.Client sandboxDomain string httpTimeout time.Duration + + // wsDialer reaches non-envd data-plane ports (the desktop's websockify + // on 6080). It is nil only if construction could not attach a pool + // dialer, which DialDesktop reports as unsupported. + wsDialer *WebsocketDialer + + // inboundTokens is the same registry wsDialer reads. Cube's SDK already + // stamps envd HTTP with Sandbox.TrafficAccessToken, so exec never needed + // a Put here; DialDesktop does. + inboundTokens *InboundTokenRegistry } // NewCubeRemoteClient constructs a Cube-backed RemoteSandboxClient using the @@ -87,6 +98,11 @@ func NewCubeRemoteClientWithPool( } opts := []cubesandbox.ClientOption{cubesandbox.WithHTTPClient(httpClient)} + // Built from routingConfig for the same reason RoundTripperFor is: Type + // is normalised to cube there, so gatewayEndpointFor reads the Cube + // fields even if the caller left Type unset. + wsDialer := pool.WebsocketDialerFor(&routingConfig) + return &CubeRemoteClient{ config: config, client: cubesandbox.NewClient( @@ -95,6 +111,8 @@ func NewCubeRemoteClientWithPool( ), sandboxDomain: config.CubeSandboxDomain, httpTimeout: httpTimeout, + wsDialer: wsDialer, + inboundTokens: pool.InboundTokens(), }, nil } @@ -153,6 +171,9 @@ func (c *CubeRemoteClient) Capabilities() RemoteSandboxCapabilities { SupportsVolumes: false, // envd exposes an interactive PTY service that the Cube SDK wraps. SupportsTerminals: true, + // CubeProxy routes any {port}-{id}.{domain} authority, so 6080 + // (websockify) is reachable with the same inbound token as envd. + SupportsDesktop: true, } } @@ -183,11 +204,14 @@ func (c *CubeRemoteClient) ListTemplates(ctx context.Context) ([]RemoteTemplate, // Cube only reports a name when the template carries an alias, so fall // back to the image before falling back to the opaque ID: recognising // our own template is what keeps EnsureStandardTemplate idempotent. - standard := isStandardTemplate(name) || isStandardTemplateImage(item.ImageInfo) + standard, desktop := classifyWeKnoraTemplate(name, item.ImageInfo) if name == "" { - if standard { + switch { + case desktop: + name = DesktopTemplateName + case standard: name = StandardTemplateName - } else { + default: name = item.TemplateID } } @@ -199,6 +223,7 @@ func (c *CubeRemoteClient) ListTemplates(ctx context.Context) ([]RemoteTemplate, Image: item.ImageInfo, CreatedAt: item.CreatedAt, Standard: standard, + Desktop: desktop, Error: strings.TrimSpace(item.LastError), InstanceType: item.InstanceType, NetworkType: item.NetworkType, @@ -306,6 +331,89 @@ func (c *CubeRemoteClient) ReplaceStandardTemplate(ctx context.Context) (*Remote return c.buildStandardTemplate(ctx) } +// EnsureDesktopTemplate returns the cluster's WeKnora desktop template, +// rebuilding a failed one or building it when absent. A cluster may hold +// both the CLI and desktop templates; the admin picks which ID a config boots. +func (c *CubeRemoteClient) EnsureDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) { + items, err := c.ListTemplates(ctx) + if err != nil { + return nil, err + } + var failed *RemoteTemplate + for i := range items { + if !items[i].Desktop { + continue + } + if !IsTemplateBuildFailed(items[i].Status) { + return &items[i], nil + } + if failed == nil { + failed = &items[i] + } + } + if failed != nil { + return c.rebuildDesktopTemplate(ctx, *failed) + } + return c.buildDesktopTemplate(ctx) +} + +// ReplaceDesktopTemplate applies the current spec to the cluster's desktop +// template. Same persist-then-delete contract as ReplaceStandardTemplate. +func (c *CubeRemoteClient) ReplaceDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) { + items, err := c.ListTemplates(ctx) + if err != nil { + return nil, err + } + var current *RemoteTemplate + for i := range items { + if !items[i].Desktop || strings.TrimSpace(items[i].ID) == "" { + continue + } + item := items[i] + if current == nil { + current = &item + continue + } + if IsTemplateBuildFailed(current.Status) && !IsTemplateBuildFailed(item.Status) { + current = &item + } + } + if current != nil { + rebuilt, err := c.tryRebuildDesktopTemplate(ctx, *current) + if err == nil { + return rebuilt, nil + } + logger.Warnf(ctx, + "cube in-place rebuild of desktop template %s failed: %v; building a replacement", + current.ID, err) + } + return c.buildDesktopTemplate(ctx) +} + +// DeleteSupersededDesktopTemplates drops desktop templates other than keepID. +func (c *CubeRemoteClient) DeleteSupersededDesktopTemplates(ctx context.Context, keepID string) error { + keepID = strings.TrimSpace(keepID) + if keepID == "" { + return cubeInvalidRequest("DeleteSupersededDesktopTemplates", "template ID is required", nil) + } + items, err := c.ListTemplates(ctx) + if err != nil { + return err + } + for _, item := range items { + if !item.Desktop || strings.TrimSpace(item.ID) == "" || item.ID == keepID { + continue + } + logger.Infof(ctx, "cube deleting superseded desktop template %s", item.ID) + if err := c.client.DeleteTemplate(ctx, item.ID); err != nil { + if normalized := normalizeCubeError("DeleteTemplate", err); !IsRemoteNotFound(normalized) { + logger.Warnf(ctx, "cube delete of replaced desktop template %s failed: %v", item.ID, err) + } + } + } + return nil +} + // DeleteSupersededStandardTemplates drops WeKnora templates other than keepID. func (c *CubeRemoteClient) DeleteSupersededStandardTemplates(ctx context.Context, keepID string) error { keepID = strings.TrimSpace(keepID) @@ -399,6 +507,71 @@ func (c *CubeRemoteClient) buildStandardTemplate(ctx context.Context) (*RemoteTe }, nil } +func (c *CubeRemoteClient) rebuildDesktopTemplate( + ctx context.Context, + current RemoteTemplate, +) (*RemoteTemplate, error) { + rebuilt, err := c.tryRebuildDesktopTemplate(ctx, current) + if err != nil { + logger.Warnf(ctx, "cube rebuild of desktop template %s failed: %v", current.ID, err) + return ¤t, nil + } + return rebuilt, nil +} + +func (c *CubeRemoteClient) tryRebuildDesktopTemplate( + ctx context.Context, + current RemoteTemplate, +) (*RemoteTemplate, error) { + logger.Infof(ctx, "cube rebuilding desktop template %s in place (%s)", + current.ID, current.Status) + job, err := c.client.RebuildTemplate(ctx, current.ID, c.desktopTemplateSpec()) + if err != nil { + return nil, normalizeCubeError("RebuildTemplate", err) + } + rebuilt := current + rebuilt.Status = normalizeCubeTemplateStatus(job.Status) + if rebuilt.Status == "" { + rebuilt.Status = "building" + } + rebuilt.Error = strings.TrimSpace(job.ErrorMessage) + rebuilt.Desktop = true + rebuilt.Standard = false + if strings.TrimSpace(job.TemplateID) != "" { + rebuilt.ID = job.TemplateID + } + return &rebuilt, nil +} + +func (c *CubeRemoteClient) buildDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) { + job, err := c.client.BuildTemplate(ctx, cubesandbox.BuildTemplateOptions{ + Image: DefaultCubeDesktopTemplateImage, + Extra: c.desktopTemplateSpec(), + }) + if err != nil { + logger.Warnf(ctx, "cube desktop template create failed: %v", err) + return &RemoteTemplate{ + Name: DesktopTemplateName, + Status: "failed", + Image: DefaultCubeDesktopTemplateImage, + Desktop: true, + Error: err.Error(), + }, nil + } + status := normalizeCubeTemplateStatus(job.Status) + if status == "" { + status = "building" + } + return &RemoteTemplate{ + ID: job.TemplateID, + Name: DesktopTemplateName, + Status: status, + Image: DefaultCubeDesktopTemplateImage, + Desktop: true, + Error: strings.TrimSpace(job.ErrorMessage), + }, nil +} + // normalizeCubeTemplateStatus projects Cube's catalog verbs onto the same // ready/building/failed set the settings UI and the other backends already use. // Cube reports an in-progress create-from-image as RUNNING, which would @@ -424,6 +597,16 @@ func (c *CubeRemoteClient) standardTemplateSpec() map[string]any { return cubeStandardTemplateSpec(dns) } +// templateSpec picks the spec this config's template is built from. +// Config.DesktopEnabled is the single switch. +func (c *CubeRemoteClient) desktopTemplateSpec() map[string]any { + var dns []string + if c != nil && c.config != nil { + dns, _ = NormalizeCubeDNSServers(c.config.CubeDNSServers) + } + return cubeDesktopTemplateSpec(dns) +} + // cubeStandardTemplateSpec is the single definition of how the WeKnora template // is built. Both the first build and every rebuild send it verbatim — the // rebuild endpoint takes a raw payload rather than BuildTemplateOptions, and @@ -448,6 +631,43 @@ func cubeStandardTemplateSpec(dns []string) map[string]any { return spec } +// cubeDesktopTemplateSpec is the desktop sibling of cubeStandardTemplateSpec. +// It is a separate function rather than a flag on that one so a change to the +// standard template can never silently alter the desktop's exposed ports. +// +// 6080 is deliberately not in exposedPorts. Cube maps that list through eBPF +// static NAT on the host NIC, which bypasses CubeProxy. WeKnora already +// reaches websockify the same way it reaches envd: CubeProxy Host +// "{port}-{id}.{domain}" plus the inbound token. Publishing 6080 on the host +// would let anyone who can read /run/desktop/secret (the agent Execs as +// root) skip the ticket relay, idle disconnect, and audit trail. websockify +// Basic auth stays as defence in depth on the overlay path. +func cubeDesktopTemplateSpec(dns []string) map[string]any { + spec := map[string]any{ + "image": DefaultCubeDesktopTemplateImage, + "name": DesktopTemplateName, + // The standard 1G is too small once XFCE is installed. + "writableLayerSize": "8G", + "exposedPorts": []uint16{CubeEnvdPort}, + // The build probe still goes to envd: websockify is lazily started + // and is deliberately not running at template-build time. + "probePort": uint16(CubeEnvdPort), + "probePath": CubeEnvdHealthPath, + "allowInternetAccess": true, + // Cubebox runs the image ENTRYPOINT (cube-entrypoint.sh). That + // script tees envd onto /var/log/envd.log; on the packed desktop + // rootfs the write fails and the :49983 probe is connection + // refused. Starting envd as PID 1 is equivalent (no user CMD) and + // was verified READY on the 2026-09-10 cluster. + "command": []string{"/usr/bin/envd"}, + "args": []string{"-port", strconv.Itoa(CubeEnvdPort), "-isnotfc"}, + } + if len(dns) > 0 { + spec["dns"] = append([]string(nil), dns...) + } + return spec +} + func (c *CubeRemoteClient) Create( ctx context.Context, request RemoteCreateRequest, @@ -527,6 +747,7 @@ func (c *CubeRemoteClient) Create( return nil, errors.New("cube api: create sandbox: empty sandboxID") } logCubeSandboxCreated(ctx, c, sb, network.AllowPublicTraffic) + c.registerInboundToken(sb.SandboxID, sb.TrafficAccessToken) return &cubeRemoteHandle{ sb: sb, metadata: cloneMetadata(request.Metadata), @@ -556,6 +777,7 @@ func (c *CubeRemoteClient) Connect( if sb.TrafficAccessToken == "" { sb.TrafficAccessToken = request.TrafficAccessToken } + c.registerInboundToken(sb.SandboxID, sb.TrafficAccessToken) return &cubeRemoteHandle{sb: sb}, nil } @@ -1224,6 +1446,13 @@ func cubeCredentialPresence(value string) string { return "present" } +func (c *CubeRemoteClient) registerInboundToken(sandboxID, token string) { + if c == nil || c.inboundTokens == nil { + return + } + c.inboundTokens.Put(sandboxID, token) +} + // toCubeEgressRules maps the neutral L7 rules onto the SDK's shape. Deny rules // are forwarded too: sending them is what makes the target reachable by // CubeEgress, which is the only component that can answer a request-level 403. @@ -1256,10 +1485,55 @@ func toCubeEgressRules(rules []RemoteCubeEgressRule) []cubesandbox.Rule { return out } +// DialDesktop opens a WebSocket to websockify inside the sandbox. +// +// It goes through the pool-owned dialer rather than building a URL here, so +// the inbound-token registry, the gateway target, and the SSRF guard all come +// from the one place that knows which pool this client belongs to. +func (c *CubeRemoteClient) DialDesktop( + ctx context.Context, + handle RemoteSandboxHandle, + opts RemoteDesktopOptions, +) (*websocket.Conn, error) { + if c == nil || c.wsDialer == nil { + return nil, &RemoteError{ + Kind: RemoteErrorKindUnsupported, + Provider: SandboxTypeCube, + Op: "DialDesktop", + Message: "client was built without a gateway pool", + } + } + conn, err := dialSandboxDesktop(ctx, SandboxTypeCube, c.wsDialer, handle, opts) + if err != nil { + return nil, err + } + return conn, nil +} + +// StartDesktopTTLRefresh extends the Cube sandbox idle timeout while the +// desktop relay is open. ctx must be the relay lifetime (WithoutCancel), not +// DialDesktop's request context. +func (c *CubeRemoteClient) StartDesktopTTLRefresh(ctx context.Context, handle RemoteSandboxHandle) { + if c == nil { + return + } + sb, err := cubeHandleSandbox("StartDesktopTTLRefresh", handle) + if err != nil { + return + } + ttl := cubeSandboxTTL(c.config) + startTerminalTTLRefresh(ctx, nil, ttl, func(rctx context.Context) error { + return sb.SetTimeout(rctx, ttl) + }) +} + var ( - _ RemoteSandboxClient = (*CubeRemoteClient)(nil) - _ RemoteSnapshotManager = (*CubeRemoteClient)(nil) - _ RemoteTemplateCatalog = (*CubeRemoteClient)(nil) - _ RemoteSandboxHandle = (*cubeRemoteHandle)(nil) - _ RemoteInboundTokenCarrier = (*cubeRemoteHandle)(nil) + _ RemoteSandboxClient = (*CubeRemoteClient)(nil) + _ RemoteSnapshotManager = (*CubeRemoteClient)(nil) + _ RemoteTemplateCatalog = (*CubeRemoteClient)(nil) + _ RemoteDesktopTemplateCatalog = (*CubeRemoteClient)(nil) + _ RemoteDesktopManager = (*CubeRemoteClient)(nil) + _ RemoteDesktopTTLRefresher = (*CubeRemoteClient)(nil) + _ RemoteSandboxHandle = (*cubeRemoteHandle)(nil) + _ RemoteInboundTokenCarrier = (*cubeRemoteHandle)(nil) ) diff --git a/internal/sandbox/cube_remote_client_test.go b/internal/sandbox/cube_remote_client_test.go index 08fb345f2..fa5510690 100644 --- a/internal/sandbox/cube_remote_client_test.go +++ b/internal/sandbox/cube_remote_client_test.go @@ -36,6 +36,7 @@ func TestCubeRemoteClientProviderAndCapabilities(t *testing.T) { SupportsFilesystemEnumeration: true, SupportsSnapshots: true, SupportsTerminals: true, + SupportsDesktop: true, }, client.Capabilities()) } @@ -668,3 +669,37 @@ func TestNormalizeCubeError(t *testing.T) { }) } } + +func TestCubeDesktopTemplateSpecDoesNotNatWebsockify(t *testing.T) { + spec := cubeDesktopTemplateSpec(nil) + + require.Equal(t, DefaultCubeDesktopTemplateImage, spec["image"]) + require.Equal(t, DesktopTemplateName, spec["name"]) + // envd stays in exposedPorts so the template probe can reach :49983. + // 6080 must not: Cube NATs that list onto the host NIC and bypasses + // CubeProxy. The desktop relay dials 6080 through CubeProxy instead. + require.Equal(t, []uint16{CubeEnvdPort}, spec["exposedPorts"]) + require.Equal(t, uint16(CubeEnvdPort), spec["probePort"]) + require.Equal(t, CubeEnvdHealthPath, spec["probePath"]) + // 1G (the standard value) is too small once XFCE is installed. + require.Equal(t, "8G", spec["writableLayerSize"]) + require.Equal(t, true, spec["allowInternetAccess"]) + require.Equal(t, []string{"/usr/bin/envd"}, spec["command"]) + require.Equal(t, []string{"-port", "49983", "-isnotfc"}, spec["args"]) +} + +func TestCubeStandardTemplateSpecStillExposesOnlyEnvd(t *testing.T) { + // Host ports are a finite resource (CubeVS allocates 20000-29999). Neither + // the CLI nor the desktop template may NAT extra guest ports onto the host. + spec := cubeStandardTemplateSpec(nil) + require.Equal(t, []uint16{CubeEnvdPort}, spec["exposedPorts"]) +} + +func TestDesktopReadyCmdDoesNotUseSS(t *testing.T) { + // e2b.WaitForPort generates `ss -tln`, and iproute2 is not in the image: + // ss exits 127, the loop never terminates, and the template build hangs + // instead of failing. Keep the python3 probe. + require.NotContains(t, desktopReadyCmd, "ss ") + require.Contains(t, desktopReadyCmd, "python3") + require.Contains(t, desktopReadyCmd, "6080") +} diff --git a/internal/sandbox/cube_template_catalog_test.go b/internal/sandbox/cube_template_catalog_test.go index b744790a2..246fd00fb 100644 --- a/internal/sandbox/cube_template_catalog_test.go +++ b/internal/sandbox/cube_template_catalog_test.go @@ -64,6 +64,67 @@ func TestCubeRemoteClientListTemplatesRecognisesStandardByImage(t *testing.T) { require.Equal(t, "tpl-other", templates[1].Name) } +func TestCubeRemoteClientListTemplatesRecognisesDesktopByImage(t *testing.T) { + client := newCubeTemplateClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/snapshots" { + writeJSON(w, http.StatusOK, []map[string]any{}) + return + } + require.Equal(t, "/templates", r.URL.Path) + writeJSON(w, http.StatusOK, []map[string]any{ + { + "templateID": "tpl-cli", + "status": "READY", + "imageInfo": DefaultCubeTemplateImage, + }, + { + "templateID": "tpl-desktop", + "status": "READY", + "imageInfo": DefaultCubeDesktopTemplateImage, + }, + }) + }) + + templates, err := client.ListTemplates(context.Background()) + require.NoError(t, err) + require.Len(t, templates, 2) + require.True(t, templates[0].Standard) + require.False(t, templates[0].Desktop) + require.Equal(t, StandardTemplateName, templates[0].Name) + require.True(t, templates[1].Desktop) + require.False(t, templates[1].Standard) + require.Equal(t, DesktopTemplateName, templates[1].Name) +} + +func TestCubeRemoteClientEnsureDesktopTemplateBuildsWhenAbsent(t *testing.T) { + var payload map[string]any + client := newCubeTemplateClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, []map[string]any{{ + "templateID": "tpl-cli", + "status": "READY", + "imageInfo": DefaultCubeTemplateImage, + "name": StandardTemplateName, + }}) + case http.MethodPost: + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + writeJSON(w, http.StatusAccepted, map[string]any{ + "templateID": "tpl-desk", + "status": "PENDING", + }) + } + }) + + template, err := client.EnsureDesktopTemplate(context.Background()) + require.NoError(t, err) + require.Equal(t, "tpl-desk", template.ID) + require.True(t, template.Desktop) + require.False(t, template.Standard) + require.Equal(t, DefaultCubeDesktopTemplateImage, payload["image"]) + require.Equal(t, DesktopTemplateName, payload["name"]) +} + func TestCubeRemoteClientListTemplatesMapsCatalogMetadata(t *testing.T) { allowInternet := true client := newCubeTemplateClient(t, cubeCatalogHandler( diff --git a/internal/sandbox/cube_terminal_test.go b/internal/sandbox/cube_terminal_test.go index 94436bdf5..4433007e0 100644 --- a/internal/sandbox/cube_terminal_test.go +++ b/internal/sandbox/cube_terminal_test.go @@ -3,6 +3,7 @@ package sandbox import ( "bytes" "context" + "net/http" "sync" "testing" "time" @@ -99,6 +100,62 @@ func TestCubeOpenTerminalRefreshesSandboxTTL(t *testing.T) { }, 2*time.Second, 20*time.Millisecond, "expected SetTimeout while the PTY is open") } +func TestCubeDialDesktopRefreshesSandboxTTL(t *testing.T) { + // Desktop traffic is websockify on 6080, not envd, so CubeMaster never + // sees it. SetTimeout must follow the relay lifetime (WithoutCancel), + // not DialDesktop's request ctx — that ctx is cancelled on handshake + // abort and must not take the refresh loop with it. + prevMin := terminalTTLRefreshMin + terminalTTLRefreshMin = 40 * time.Millisecond + t.Cleanup(func() { terminalTTLRefreshMin = prevMin }) + + mock := newCubeMockServer(t) + var seen http.Request + proxy := desktopEchoServer(t, &seen) + t.Cleanup(proxy.Close) + + cfg := testConfig(t, mock) + cfg.CubeProxyURL = proxy.URL + cfg.CubeSandboxTTL = 3 * time.Second + cfg.CubeHTTPTimeout = 5 * time.Second + policy := OutboundURLPolicy{AllowPrivate: true} + pool := NewSandboxGatewayTransportPoolWithPolicy( + NewGuardedTransportWithPolicy(policy), + policy, + ) + client, err := NewCubeRemoteClientWithPool(cfg, pool) + require.NoError(t, err) + + dialCtx, dialCancel := context.WithCancel(context.Background()) + handle, err := client.Create(dialCtx, RemoteCreateRequest{ + TemplateID: "template-a", + Timeout: RemoteTimeoutPolicy{ + Mode: RemoteTimeoutExplicit, + Value: time.Minute, + Action: RemoteOnTimeoutKill, + }, + }) + require.NoError(t, err) + + conn, err := client.DialDesktop(dialCtx, handle, RemoteDesktopOptions{}) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + dialCancel() + + require.Never(t, func() bool { + return mock.timeoutPOSTs.Load() >= 1 + }, 150*time.Millisecond, 20*time.Millisecond, + "DialDesktop must not bind SetTimeout to the request context") + + ttlCtx, ttlCancel := context.WithCancel(context.Background()) + t.Cleanup(ttlCancel) + client.StartDesktopTTLRefresh(ttlCtx, handle) + + require.Eventually(t, func() bool { + return mock.timeoutPOSTs.Load() >= 1 + }, 2*time.Second, 20*time.Millisecond, "expected SetTimeout on the relay lifetime ctx") +} + func TestCubeOpenTerminalReattachesByPID(t *testing.T) { mock := newCubeMockServer(t) client := pooledCubeClient(t, mock, 5*time.Second) diff --git a/internal/sandbox/desktop.go b/internal/sandbox/desktop.go new file mode 100644 index 000000000..5a5fe327b --- /dev/null +++ b/internal/sandbox/desktop.go @@ -0,0 +1,140 @@ +// Package sandbox provides graphical desktop (VNC over WebSocket) capability. +// +// The desktop is an optional, provider-neutral capability alongside +// RemoteTerminalManager. It differs from the terminal in one structural way: +// the terminal rides envd's PTY service, while the desktop is a raw WebSocket +// to a non-envd port (websockify on 6080) routed through the same gateway. +// That is why it needs its own capability bit and its own dialer. +package sandbox + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "strings" + + "github.com/gorilla/websocket" +) + +// ErrDesktopUnsupported is returned when the session's backend cannot relay a +// desktop, or the config's template is not a desktop image. +var ErrDesktopUnsupported = errors.New("sandbox: desktop is not supported by this backend") + +// RemoteDesktopOptions carries the neutral parameters for dialling a desktop. +type RemoteDesktopOptions struct { + // BasicAuthUser / BasicAuthPassword authenticate against websockify's + // BasicHTTPAuth plugin. The password is read out of the sandbox at + // DesktopSecretPath and MUST NOT be sent to the browser: it is a + // backend-to-sandbox credential only. + // An empty user falls back to DesktopBasicAuthUser. + BasicAuthUser string + BasicAuthPassword string +} + +// dialSandboxDesktop is the provider-neutral half of DialDesktop. Both +// adapters call it so the credential shape and the error mapping cannot drift. +func dialSandboxDesktop( + ctx context.Context, + provider RemoteProvider, + dialer *WebsocketDialer, + handle RemoteSandboxHandle, + opts RemoteDesktopOptions, +) (*websocket.Conn, error) { + if handle == nil || strings.TrimSpace(handle.ID()) == "" { + return nil, &RemoteError{ + Kind: RemoteErrorKindInvalidRequest, Provider: provider, + Op: "DialDesktop", Message: "handle is required", + } + } + header := http.Header{} + // Cube exec/PTY attach this from the SDK sandbox object, so a terminal + // still works when the pool registry is empty. DialDesktop used to read + // only the registry, which Cube Create/Connect never wrote, and CubeProxy + // answered 401 on 6080 while envd on 49983 was fine. + if token := InboundTokenOf(handle); token != "" { + header.Set(InboundTokenHeader, token) + } + if opts.BasicAuthPassword != "" { + user := opts.BasicAuthUser + if user == "" { + user = DesktopBasicAuthUser + } + header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString( + []byte(user+":"+opts.BasicAuthPassword))) + } + conn, resp, err := dialer.Dial( + ctx, handle.ID(), DesktopWebsockifyPort, DesktopWebsockifyPath, header) + if err != nil { + kind := RemoteErrorKindUnavailable + status := 0 + if resp != nil { + status = resp.StatusCode + if status == http.StatusUnauthorized || status == http.StatusForbidden { + kind = RemoteErrorKindAuthentication + } + } + return nil, &RemoteError{ + Kind: kind, Provider: provider, Op: "DialDesktop", + Message: "dial websockify", StatusCode: status, Cause: err, + } + } + return conn, nil +} + +// RemoteDesktopManager is the optional capability interface for backends that +// can relay a WebSocket to the desktop port of a running sandbox. Mirrors +// RemoteTerminalManager: advertised via RemoteSandboxCapabilities +// (SupportsDesktop) and narrowed with DesktopManagerFrom. +type RemoteDesktopManager interface { + // DialDesktop opens a WebSocket to websockify inside the sandbox behind + // handle. The returned connection carries raw RFB bytes in binary frames. + DialDesktop(ctx context.Context, handle RemoteSandboxHandle, opts RemoteDesktopOptions) (*websocket.Conn, error) +} + +// DesktopManagerFrom narrows a client to its desktop capability. +// +// Both signals must agree, exactly as in TerminalManagerFrom: the type +// assertion finds the method, and SupportsDesktop is the advertised +// capability. Either one alone is not enough — a decorator can carry the +// method while the inner backend cannot serve it. +func DesktopManagerFrom(client RemoteSandboxClient) (RemoteDesktopManager, bool) { + if client == nil { + return nil, false + } + mgr, ok := client.(RemoteDesktopManager) + if !ok { + return nil, false + } + if !client.Capabilities().SupportsDesktop { + return nil, false + } + return mgr, true +} + +// RemoteDesktopTTLRefresher extends a desktop backend with provider idle-TTL +// refresh. DialDesktop must not start that loop: its ctx is the HTTP request, +// which is cancelled on handshake abort. The WebSocket handler starts refresh +// on the relay ctx (WithoutCancel) after upgrade, matching OpenTerminal. +type RemoteDesktopTTLRefresher interface { + StartDesktopTTLRefresh(ctx context.Context, handle RemoteSandboxHandle) +} + +// DesktopTTLRefresherFrom narrows a client to desktop TTL refresh. +// +// SupportsTimeoutRefresh is the advertised capability (Docker is false: it +// has no SetTimeout). The type assertion finds the method through wrappers +// such as Langfuse, the same two-signal pattern as DesktopManagerFrom. +func DesktopTTLRefresherFrom(client RemoteSandboxClient) (RemoteDesktopTTLRefresher, bool) { + if client == nil { + return nil, false + } + refresher, ok := client.(RemoteDesktopTTLRefresher) + if !ok { + return nil, false + } + if !client.Capabilities().SupportsTimeoutRefresh { + return nil, false + } + return refresher, true +} diff --git a/internal/sandbox/desktop_scripts.go b/internal/sandbox/desktop_scripts.go new file mode 100644 index 000000000..69d6999e3 --- /dev/null +++ b/internal/sandbox/desktop_scripts.go @@ -0,0 +1,67 @@ +// Package sandbox provides the shell fed to the desktop sandbox over Exec. +// +// These live as real .sh files rather than Go string literals so they keep +// shell tooling (shellcheck, `sh -n`, an editor that knows the language) and +// so a diff reads as shell instead of as an escaped blob. They are sent as +// the command body rather than invoked by path on purpose: unlike +// DesktopStartScript they are not baked into the image, so they also reach +// sandboxes booted from an older desktop template. +package sandbox + +import ( + "regexp" + "strings" + + _ "embed" +) + +//go:embed desktopscripts/ensure.sh +var desktopEnsureScript string + +//go:embed desktopscripts/reset-listeners.sh +var desktopResetListenersScript string + +// DesktopEnsureUnsupportedExit is ensure.sh when DesktopStartScript is missing. +// POSIX sh also exits 2 on syntax errors; pair with the stderr marker. +const DesktopEnsureUnsupportedExit = 2 + +// DesktopEnsureUnsupportedMarker is written to stderr immediately before +// that exit 2. Go must see both or the failure is a broken wrapper, not +// a BYO image without a desktop. +const DesktopEnsureUnsupportedMarker = "WEKNORA_DESKTOP_UNSUPPORTED" + +var desktopReadyLine = regexp.MustCompile(`^READY ([A-Za-z0-9]{32})$`) + +// DesktopEnsureCmd starts the desktop listeners (or takes the image script's +// fast path) and prints READY on stdout. Invoking DesktopStartScript +// by path hangs the Exec: envd waits for stdout/stderr to close, and the +// daemons keep those pipes. This wrapper points them at a log file. +func DesktopEnsureCmd() string { return desktopEnsureScript } + +// DesktopResetListenersCmd drops a half-started desktop so the next +// DesktopEnsureCmd run rebuilds it instead of taking its fast path. +func DesktopResetListenersCmd() string { return desktopResetListenersScript } + +// ParseDesktopEnsureSecret requires stdout to be exactly one READY line. +// ensure.sh promises that; a last-non-empty-line parser would accept +// "garbage\nREADY " and dial, hiding the leak behind a 401/502. +// A single trailing newline (printf) is allowed; nothing else is. +func ParseDesktopEnsureSecret(stdout string) (string, bool) { + s := strings.ReplaceAll(stdout, "\r\n", "\n") + s = strings.TrimSuffix(s, "\n") + m := desktopReadyLine.FindStringSubmatch(s) + if m == nil { + return "", false + } + return m[1], true +} + +// IsDesktopEnsureUnsupported is the BYO-image miss: exit 2 plus the +// marker. A bare 2 is a shell/wrapper failure and must not become +// ErrDesktopUnsupported. +func IsDesktopEnsureUnsupported(result *ExecuteResult) bool { + if result == nil || result.Killed || result.ExitCode != DesktopEnsureUnsupportedExit { + return false + } + return strings.Contains(result.Stderr, DesktopEnsureUnsupportedMarker) +} diff --git a/internal/sandbox/desktop_test.go b/internal/sandbox/desktop_test.go new file mode 100644 index 000000000..85f3f8ba2 --- /dev/null +++ b/internal/sandbox/desktop_test.go @@ -0,0 +1,218 @@ +package sandbox + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" +) + +type desktopFakeClient struct { + *fakeRemoteClient + dialed bool + ttlStarted int +} + +func (c *desktopFakeClient) DialDesktop( + context.Context, RemoteSandboxHandle, RemoteDesktopOptions, +) (*websocket.Conn, error) { + c.dialed = true + return nil, nil +} + +func (c *desktopFakeClient) StartDesktopTTLRefresh(context.Context, RemoteSandboxHandle) { + c.ttlStarted++ +} + +func TestDesktopManagerFromRequiresBothSignals(t *testing.T) { + // Methods present but capability off: a provider that grew the method + // before the deployment was ready must still read as unsupported. + off := &desktopFakeClient{fakeRemoteClient: newFakeRemoteClient(SandboxTypeCube)} + off.capabilities.SupportsDesktop = false + _, ok := DesktopManagerFrom(off) + require.False(t, ok, "capability flag alone must be able to veto") + + // Capability on but no methods: the plain fake has no DialDesktop. + plain := newFakeRemoteClient(SandboxTypeCube) + plain.capabilities.SupportsDesktop = true + _, ok = DesktopManagerFrom(plain) + require.False(t, ok, "type assertion alone must be able to veto") + + on := &desktopFakeClient{fakeRemoteClient: newFakeRemoteClient(SandboxTypeCube)} + on.capabilities.SupportsDesktop = true + mgr, ok := DesktopManagerFrom(on) + require.True(t, ok) + require.NotNil(t, mgr) +} + +func TestDesktopManagerFromNilClient(t *testing.T) { + _, ok := DesktopManagerFrom(nil) + require.False(t, ok) +} + +func TestDesktopTTLRefresherFromRequiresTimeoutCapability(t *testing.T) { + off := &desktopFakeClient{fakeRemoteClient: newFakeRemoteClient(SandboxTypeCube)} + off.capabilities.SupportsTimeoutRefresh = false + _, ok := DesktopTTLRefresherFrom(off) + require.False(t, ok, "Docker-like backends have the method via wrappers but no timeout to refresh") + + on := &desktopFakeClient{fakeRemoteClient: newFakeRemoteClient(SandboxTypeCube)} + on.capabilities.SupportsTimeoutRefresh = true + refresher, ok := DesktopTTLRefresherFrom(on) + require.True(t, ok) + require.NotNil(t, refresher) +} + +func startDesktopScript(t *testing.T) string { + t.Helper() + body, err := os.ReadFile(filepath.Join("..", "..", "docker", "scripts", "start-desktop.sh")) + require.NoError(t, err) + return string(body) +} + +// shellCode drops comment lines so an assertion about what a script *does* +// cannot be satisfied (or broken) by prose explaining it. +func shellCode(src string) string { + var code []string + for _, line := range strings.Split(src, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + code = append(code, line) + } + return strings.Join(code, "\n") +} + +func TestStartDesktopScriptFastPathRequiresVNCPort(t *testing.T) { + // 6080-only "already ready" lets websockify sit up while x11vnc is dead. + // DialDesktop then 101s, RFB hello never comes, and the session slot is + // held until the handshake read times out — every retry is DESKTOP_BUSY. + src := startDesktopScript(t) + require.Contains(t, src, "port_open 6080") + require.Contains(t, src, "port_open 5900") +} + +func TestStartDesktopScriptClosesLockFDInEveryDaemon(t *testing.T) { + // Reproduced against wechatopenai/weknora-sandbox:main-desktop: Xvfb, + // xfce4-session, dbus-launch, x11vnc and websockify all inherited fd 9 + // and kept holding the flock after the script exited. Killing x11vnc and + // websockify then left Xvfb/XFCE holding it, so the next run blocked on + // flock forever — Exec timed out at 60s (exit=-1) and 6080 never returned. + code := shellCode(startDesktopScript(t)) + // spawn() carries both `9>&-` and the private stdio; the two daemons that + // cannot use it (dbus --fork, x11vnc -bg) spell it out. + require.Contains(t, code, `"$@" >"/run/desktop/$name.log" 2>&1 9>&- &`) + for _, launch := range []string{ + "spawn xvfb Xvfb :0", + "spawn xfce4-session xfce4-session", + "spawn websockify websockify --auth-plugin", + "dbus-daemon --system --fork >/run/desktop/dbus.log 2>&1 9>&-", + ">/run/desktop/x11vnc.log 2>&1 9>&-", + } { + require.Contains(t, code, launch, + "every daemon must close fd 9 and own its stdio") + } +} + +func TestParseDesktopEnsureSecretAcceptsExactReadyLine(t *testing.T) { + const secret = "abcdefghijklmnopqrstuvwxyz012345" + got, ok := ParseDesktopEnsureSecret("READY " + secret + "\n") + require.True(t, ok) + require.Equal(t, secret, got) + got, ok = ParseDesktopEnsureSecret("READY " + secret) + require.True(t, ok) + require.Equal(t, secret, got) +} + +func TestParseDesktopEnsureSecretRejectsNoise(t *testing.T) { + const line = "READY abcdefghijklmnopqrstuvwxyz012345\n" + _, ok := ParseDesktopEnsureSecret(line + "extra\n") + require.False(t, ok, "suffix after READY is a leak") + _, ok = ParseDesktopEnsureSecret("started\n" + line) + require.False(t, ok, "prefix before READY must not be ignored; last-line parse would dial with a polluted buffer") + _, ok = ParseDesktopEnsureSecret("\n" + line) + require.False(t, ok) + _, ok = ParseDesktopEnsureSecret("started\n") + require.False(t, ok) + _, ok = ParseDesktopEnsureSecret("READY short\n") + require.False(t, ok) + _, ok = ParseDesktopEnsureSecret("") + require.False(t, ok) +} + +func TestIsDesktopEnsureUnsupportedRequiresMarker(t *testing.T) { + require.False(t, IsDesktopEnsureUnsupported(nil)) + require.False(t, IsDesktopEnsureUnsupported(&ExecuteResult{ + ExitCode: 2, Stderr: "Syntax error: end of file unexpected", + }), "POSIX sh uses exit 2 for syntax errors") + require.True(t, IsDesktopEnsureUnsupported(&ExecuteResult{ + ExitCode: 2, Stderr: DesktopEnsureUnsupportedMarker + "\n", + })) + require.False(t, IsDesktopEnsureUnsupported(&ExecuteResult{ + ExitCode: 2, Killed: true, Stderr: DesktopEnsureUnsupportedMarker, + })) + require.False(t, IsDesktopEnsureUnsupported(&ExecuteResult{ + ExitCode: 1, Stderr: DesktopEnsureUnsupportedMarker, + })) +} + +func TestDesktopEnsureCmdDetachesDaemonStdio(t *testing.T) { + // envd's exec waits for stdout and stderr to close. Invoking + // DesktopStartScript by path let the backgrounded daemons hold those + // pipes for the life of the sandbox, so the Exec hit its 60s timeout + // (exit=-1 -> DESKTOP_START_FAILED) while the desktop was actually up. + cmd := DesktopEnsureCmd() + code := shellCode(cmd) + require.Contains(t, cmd, DesktopStartScript) + require.Contains(t, code, ">\"$log\" 2>&1") + require.Contains(t, code, "tail -c") + require.Contains(t, code, `printf 'READY %s\n' "$secret"`) + require.Contains(t, code, "WEKNORA_DESKTOP_UNSUPPORTED") + require.Contains(t, code, "exit 2") + require.NotContains(t, code, "pkill") +} + +func TestDesktopEnsureCmdProbesNeverDial(t *testing.T) { + code := shellCode(DesktopEnsureCmd()) + require.Contains(t, code, "/proc/net/tcp") + require.Contains(t, code, "5900") + require.Contains(t, code, "6080") + for _, dialish := range []string{"connect_ex", "create_connection", "nc -z", "ss -", "curl"} { + require.NotContains(t, code, dialish) + } + require.NotContains(t, shellCode(startDesktopScript(t)), "connect_ex") +} + +func TestStartDesktopScriptGuardsListenersOnPortNotPgrep(t *testing.T) { + // Reproduced in the desktop image: x11vnc -bg leaves a zombie when PID 1 + // does not reap it, pgrep counts zombies as running, and the x11vnc step + // becomes a permanent no-op. websockify keeps answering on 6080, so + // DialDesktop 101s and the RFB hello never arrives. + code := shellCode(startDesktopScript(t)) + require.Contains(t, code, "port_open 5900 || \\") + require.Contains(t, code, "port_open 6080 || \\") + require.NotContains(t, code, "pgrep -x x11vnc") + require.NotContains(t, code, "pgrep -f \"websockify") + // The processes with no port of their own need the zombie-aware helper. + require.Contains(t, code, "proc_alive Xvfb") + require.Contains(t, code, "proc_alive xfce4-session") + require.NotContains(t, code, "pgrep -x Xvfb") + require.NotContains(t, code, "pgrep -x xfce4-session") +} + +func TestDesktopResetListenersCmdClearsLeakedLock(t *testing.T) { + // Older images still leak the flock fd, so the reset has to unwedge them + // without an image rebuild. + reset := shellCode(DesktopResetListenersCmd()) + require.Contains(t, reset, "x11vnc") + require.Contains(t, reset, "[w]ebsockify") + require.Contains(t, reset, "/run/desktop/.lock") + // Xvfb and XFCE are expensive and never the reason 6080 is mute. + require.NotContains(t, reset, "Xvfb") + require.NotContains(t, reset, "xfce4-session") +} diff --git a/internal/sandbox/desktopscripts/ensure.sh b/internal/sandbox/desktopscripts/ensure.sh new file mode 100644 index 000000000..2c77c657d --- /dev/null +++ b/internal/sandbox/desktopscripts/ensure.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# One Exec: prove the image has a desktop, start it, print the websockify secret. +# +# exit 2 — start-desktop.sh missing. stderr MUST contain +# WEKNORA_DESKTOP_UNSUPPORTED; POSIX sh also uses 2 for +# syntax errors, so Go ignores a bare 2. +# exit 1 — start failed or listeners never came up +# exit 0 — stdout is exactly one line: READY <32 alnum> +# +# Daemons must not inherit this Exec's pipes (envd waits for them to close) +# or fd 9 (see start-desktop.sh). Reset/retry lives in Go, not here: a 60s +# timeout kill cannot run the rest of this script. +set -u + +if [ ! -x /usr/local/bin/start-desktop.sh ]; then + echo WEKNORA_DESKTOP_UNSUPPORTED >&2 + exit 2 +fi + +export LANG="${LANG:-C.UTF-8}" +export LC_ALL="${LC_ALL:-C.UTF-8}" + +mkdir -p /run/desktop +log=/run/desktop/start-desktop.log + +/usr/local/bin/start-desktop.sh >"$log" 2>&1 +rc=$? +if [ "$rc" -ne 0 ]; then + tail -c 2000 "$log" >&2 + exit 1 +fi + +listening() { + hex=$(printf '%04X' "$1") + cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | awk -v hex="$hex" ' + $4 == "0A" { + n = split($2, addr, ":") + if (toupper(addr[n]) == hex) { found = 1 } + } + END { exit !found } + ' +} + +if ! listening 5900 || ! listening 6080; then + tail -c 2000 "$log" >&2 + exit 1 +fi + +secret=$(cat /run/desktop/secret 2>/dev/null) || true +case "$secret" in + *[!A-Za-z0-9]*|"") + echo "desktop secret missing or malformed" >&2 + exit 1 + ;; +esac +# start-desktop.sh writes 32 bytes; reject anything else so Go can require +# ^READY ([A-Za-z0-9]{32})$ without a second round trip. +if [ "${#secret}" -ne 32 ]; then + echo "desktop secret length ${#secret}" >&2 + exit 1 +fi + +printf 'READY %s\n' "$secret" diff --git a/internal/sandbox/desktopscripts/reset-listeners.sh b/internal/sandbox/desktopscripts/reset-listeners.sh new file mode 100644 index 000000000..50932bb06 --- /dev/null +++ b/internal/sandbox/desktopscripts/reset-listeners.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Tear down a half-started desktop so the next start-desktop.sh rebuilds it. +# +# Xvfb and the XFCE session are deliberately left alone: they are expensive to +# restart and never the reason 6080 is mute. +# +# Sent over Exec by SandboxDesktopService, not baked into the image, so it +# also applies to sandboxes running an older desktop image. +set -u + +pkill -x x11vnc >/dev/null 2>&1 || true +pkill -f '[w]ebsockify.*6080' >/dev/null 2>&1 || true + +# Images built before the 9>&- fix leak the flock fd into every daemon the +# script spawns, so killing x11vnc/websockify leaves Xvfb and xfce4-session +# holding the lock and the next start-desktop.sh blocks on flock until its +# Exec times out (exit=-1, 6080 never returns). Unlinking the lock file gives +# that run a fresh inode to lock. Safe here: the per-sandbox secret the lock +# protects is written once and already exists by the time anything is reset. +if [ -e /run/desktop/.lock ]; then + for holder in /proc/[0-9]*/fd/*; do + [ -e "$holder" ] || continue + case "$(readlink "$holder" 2>/dev/null)" in + */run/desktop/.lock) + rm -f /run/desktop/.lock + break + ;; + esac + done +fi + +exit 0 diff --git a/internal/sandbox/e2b_exec_timeout_test.go b/internal/sandbox/e2b_exec_timeout_test.go index e60548b04..1ace27efa 100644 --- a/internal/sandbox/e2b_exec_timeout_test.go +++ b/internal/sandbox/e2b_exec_timeout_test.go @@ -65,7 +65,7 @@ func TestE2BExecTimeoutBudgets(t *testing.T) { client, err := newE2BRemoteClient(&Config{ E2BAPIKey: "test", E2BAPIURL: server.URL, E2BHTTPTimeout: 100 * time.Millisecond, - }, e2bTimeoutTestTransport{target: target}, NewInboundTokenRegistry()) + }, e2bTimeoutTestTransport{target: target}, NewInboundTokenRegistry(), nil) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() diff --git a/internal/sandbox/e2b_remote_client.go b/internal/sandbox/e2b_remote_client.go index 183b3227e..e1f8b6aa6 100644 --- a/internal/sandbox/e2b_remote_client.go +++ b/internal/sandbox/e2b_remote_client.go @@ -16,6 +16,7 @@ import ( "time" "connectrpc.com/connect" + "github.com/gorilla/websocket" e2b "github.com/matiasinsaurralde/go-e2b" "github.com/Tencent/WeKnora/internal/logger" @@ -26,6 +27,16 @@ type E2BRemoteClient struct { client *e2b.Client inboundTokens *InboundTokenRegistry + // wsDialer reaches non-envd data-plane ports (the desktop's websockify + // on 6080). It is nil on the non-pool construction paths, which is what + // DialDesktop reports as unsupported. + wsDialer *WebsocketDialer + + // desktopEnabled mirrors Config.DesktopEnabled so template building can + // pick buildDesktopTemplate without re-reading a config the client + // otherwise does not keep. + desktopEnabled bool + templateID string timeout time.Duration } @@ -47,9 +58,9 @@ func NewE2BRemoteClientWithTransport( transport *http.Transport, ) (*E2BRemoteClient, error) { if transport == nil { - return newE2BRemoteClient(cfg, nil, NewInboundTokenRegistry()) + return newE2BRemoteClient(cfg, nil, NewInboundTokenRegistry(), nil) } - return newE2BRemoteClient(cfg, transport, NewInboundTokenRegistry()) + return newE2BRemoteClient(cfg, transport, NewInboundTokenRegistry(), nil) } // NewE2BRemoteClientWithPool builds the client on top of the shared gateway @@ -62,15 +73,17 @@ func NewE2BRemoteClientWithPool( pool *SandboxGatewayTransportPool, ) (*E2BRemoteClient, error) { if pool == nil { - return newE2BRemoteClient(cfg, nil, NewInboundTokenRegistry()) + return newE2BRemoteClient(cfg, nil, NewInboundTokenRegistry(), nil) } - return newE2BRemoteClient(cfg, pool.RoundTripperFor(cfg), pool.InboundTokens()) + return newE2BRemoteClient(cfg, pool.RoundTripperFor(cfg), pool.InboundTokens(), + pool.WebsocketDialerFor(cfg)) } func newE2BRemoteClient( cfg *Config, transport http.RoundTripper, inboundTokens *InboundTokenRegistry, + wsDialer *WebsocketDialer, ) (*E2BRemoteClient, error) { if cfg == nil { return nil, errors.New("e2b remote client config is required") @@ -114,10 +127,12 @@ func newE2BRemoteClient( ttl = DefaultE2BSandboxTTL } return &E2BRemoteClient{ - client: client, - inboundTokens: inboundTokens, - templateID: strings.TrimSpace(cfg.E2BTemplate), - timeout: ttl, + client: client, + inboundTokens: inboundTokens, + wsDialer: wsDialer, + desktopEnabled: cfg.DesktopEnabled, + templateID: strings.TrimSpace(cfg.E2BTemplate), + timeout: ttl, }, nil } @@ -174,6 +189,9 @@ func (c *E2BRemoteClient) Capabilities() RemoteSandboxCapabilities { SupportsVolumes: false, // envd exposes an interactive PTY service that go-e2b wraps. SupportsTerminals: true, + // The gateway routes any {port}-{id}.{domain} authority, so 6080 + // (websockify) is reachable with the same inbound token as envd. + SupportsDesktop: true, } } @@ -205,12 +223,16 @@ func (c *E2BRemoteClient) ListTemplates(ctx context.Context) ([]RemoteTemplate, } else if len(item.Aliases) > 0 && strings.TrimSpace(item.Aliases[0]) != "" { name = strings.TrimSpace(item.Aliases[0]) } - standard := isStandardTemplate(name) + standard, desktop := classifyWeKnoraTemplate(name, "") for _, candidate := range append(append([]string(nil), item.Aliases...), item.Names...) { - if isStandardTemplate(candidate) { - standard = true + s, d := classifyWeKnoraTemplate(candidate, "") + if d { + standard, desktop = false, true break } + if s { + standard = true + } } status := normalizeE2BTemplateBuildStatus(item.BuildStatus) // E2B's template list keeps reporting a pending build status after the @@ -219,7 +241,7 @@ func (c *E2BRemoteClient) ListTemplates(ctx context.Context) ([]RemoteTemplate, // forever. Limit these extra requests to the standard template; polling // every public template would turn one catalog refresh into an // unbounded N+1 request pattern. - if standard && isE2BTemplateBuildPending(status) { + if (standard || desktop) && isE2BTemplateBuildPending(status) { if reconciled := c.reconcileE2BTemplateStatus(ctx, item); reconciled != "" { status = reconciled } @@ -232,6 +254,7 @@ func (c *E2BRemoteClient) ListTemplates(ctx context.Context) ([]RemoteTemplate, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, Standard: standard, + Desktop: desktop, }) } return result, nil @@ -371,6 +394,53 @@ func (c *E2BRemoteClient) ReplaceStandardTemplate(ctx context.Context) (*RemoteT return c.buildStandardTemplate(ctx) } +// EnsureDesktopTemplate returns the cluster's WeKnora desktop template, +// building it when absent. It is a sibling of EnsureStandardTemplate: a +// cluster may hold both, and the admin picks which ID a config boots. +func (c *E2BRemoteClient) EnsureDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) { + items, err := c.ListTemplates(ctx) + if err != nil { + return nil, err + } + for i := range items { + if items[i].Desktop && !IsTemplateBuildFailed(items[i].Status) { + return &items[i], nil + } + } + return c.buildDesktopTemplate(ctx) +} + +// ReplaceDesktopTemplate starts a new desktop-template build from the current +// spec. Same persist-then-delete contract as ReplaceStandardTemplate. +func (c *E2BRemoteClient) ReplaceDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) { + return c.buildDesktopTemplate(ctx) +} + +// DeleteSupersededDesktopTemplates drops desktop templates other than keepID. +func (c *E2BRemoteClient) DeleteSupersededDesktopTemplates(ctx context.Context, keepID string) error { + keepID = strings.TrimSpace(keepID) + if keepID == "" { + return e2bInvalidRequest("DeleteSupersededDesktopTemplates", "template ID is required", nil) + } + items, err := c.ListTemplates(ctx) + if err != nil { + return err + } + for _, item := range items { + if !item.Desktop || strings.TrimSpace(item.ID) == "" || item.ID == keepID { + continue + } + logger.Infof(ctx, "e2b deleting superseded desktop template %s", item.ID) + if err := c.client.DeleteTemplate(ctx, item.ID); err != nil { + var notFound *e2b.TemplateNotFoundError + if !errors.As(err, ¬Found) { + logger.Warnf(ctx, "e2b delete of replaced desktop template %s failed: %v", item.ID, err) + } + } + } + return nil +} + // DeleteSupersededStandardTemplates drops WeKnora templates other than keepID. func (c *E2BRemoteClient) DeleteSupersededStandardTemplates(ctx context.Context, keepID string) error { keepID = strings.TrimSpace(keepID) @@ -430,6 +500,51 @@ func (c *E2BRemoteClient) buildStandardTemplate(ctx context.Context) (*RemoteTem }, nil } +// desktopReadyCmd is the replacement for e2b.WaitForPort(6080). +// +// The SDK helper generates `while ! ss -tln | grep -q ':6080 '; ...` +// (template_builder.go:183-186), and ss comes from iproute2, which +// python slim does not ship and Dockerfile.sandbox does not install. +// ss then exits 127 — non-zero — so the loop never terminates and the ready +// probe hangs instead of failing. +// +// This is deliberately the same probe as port_open() in +// docker/scripts/start-desktop.sh: one implementation, so the script and the +// template can never disagree about what "ready" means. Do not "improve" it +// back to the SDK helper. +const desktopReadyCmd = `while ! python3 -c "import socket,sys;s=socket.socket();` + + `s.settimeout(1);sys.exit(0 if s.connect_ex(('127.0.0.1',6080))==0 else 1)"; ` + + `do sleep 0.2; done` + +// buildDesktopTemplate builds the XFCE/x11vnc/websockify template. It is a +// sibling of buildStandardTemplate, never a mode of it. +// +// No SetStartCmd: the desktop is lazily started on first connect, because +// E2B runs envd as init and never executes the image CMD. desktopReadyCmd is kept for the day that decision +// is revisited. +func (c *E2BRemoteClient) buildDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) { + builder := e2b.NewTemplate(). + FromImage(DefaultDesktopDockerImage). + RunCmd(e2bPtyPromptOverrideCmd) + build, err := builder.BuildInBackground(ctx, c.client, e2b.BuildConfig{ + Name: DesktopTemplateName, + Tags: []string{DefaultE2BTemplateTag}, + // XFCE plus a framebuffer needs more than the standard 2 / 2048. + CPUCount: 4, + MemoryMB: 4096, + }) + if err != nil { + return nil, normalizeE2BError("EnsureDesktopTemplate", err) + } + return &RemoteTemplate{ + ID: build.TemplateID, + Name: DesktopTemplateName, + Status: "building", + Image: DefaultDesktopDockerImage, + Desktop: true, + }, nil +} + func (c *E2BRemoteClient) Create( ctx context.Context, request RemoteCreateRequest, @@ -1351,10 +1466,61 @@ func e2bRemoteEntryType(fileType string) RemoteDirEntryType { } } +// DialDesktop opens a WebSocket to websockify inside the sandbox. +// +// It goes through the pool-owned dialer rather than building a URL here, so +// the inbound-token registry, the gateway target, and the SSRF guard all come +// from the one place that knows which pool this client belongs to. +func (c *E2BRemoteClient) DialDesktop( + ctx context.Context, + handle RemoteSandboxHandle, + opts RemoteDesktopOptions, +) (*websocket.Conn, error) { + if c == nil || c.wsDialer == nil { + return nil, &RemoteError{ + Kind: RemoteErrorKindUnsupported, + Provider: SandboxTypeE2B, + Op: "DialDesktop", + Message: "client was built without a gateway pool", + } + } + conn, err := dialSandboxDesktop(ctx, SandboxTypeE2B, c.wsDialer, handle, opts) + if err != nil { + return nil, err + } + return conn, nil +} + +// StartDesktopTTLRefresh extends the E2B sandbox idle timeout while the +// desktop relay is open. ctx must be the relay lifetime (WithoutCancel), not +// DialDesktop's request context. +func (c *E2BRemoteClient) StartDesktopTTLRefresh(ctx context.Context, handle RemoteSandboxHandle) { + if c == nil { + return + } + sbx, err := e2bHandleSandbox("StartDesktopTTLRefresh", handle) + if err != nil { + return + } + timeoutSeconds, terr := e2bTimeoutSeconds( + RemoteTimeoutPolicy{Mode: RemoteTimeoutServerDefault}, + c.timeout, + ) + if terr != nil || timeoutSeconds <= 0 { + return + } + startTerminalTTLRefresh(ctx, nil, c.timeout, func(rctx context.Context) error { + return sbx.SetTimeoutWithContext(rctx, timeoutSeconds) + }) +} + var ( - _ RemoteSandboxClient = (*E2BRemoteClient)(nil) - _ RemoteSnapshotManager = (*E2BRemoteClient)(nil) - _ RemoteTemplateCatalog = (*E2BRemoteClient)(nil) - _ RemoteSandboxHandle = (*e2bRemoteHandle)(nil) - _ RemoteInboundTokenCarrier = (*e2bRemoteHandle)(nil) + _ RemoteSandboxClient = (*E2BRemoteClient)(nil) + _ RemoteSnapshotManager = (*E2BRemoteClient)(nil) + _ RemoteTemplateCatalog = (*E2BRemoteClient)(nil) + _ RemoteDesktopTemplateCatalog = (*E2BRemoteClient)(nil) + _ RemoteDesktopManager = (*E2BRemoteClient)(nil) + _ RemoteDesktopTTLRefresher = (*E2BRemoteClient)(nil) + _ RemoteSandboxHandle = (*e2bRemoteHandle)(nil) + _ RemoteInboundTokenCarrier = (*e2bRemoteHandle)(nil) ) diff --git a/internal/sandbox/e2b_remote_client_test.go b/internal/sandbox/e2b_remote_client_test.go index bc7ee5738..31395b145 100644 --- a/internal/sandbox/e2b_remote_client_test.go +++ b/internal/sandbox/e2b_remote_client_test.go @@ -45,6 +45,7 @@ type e2bMockServer struct { connectCount atomic.Int32 infoCount atomic.Int32 deleteCount atomic.Int32 + timeoutPOSTs atomic.Int32 nextID atomic.Int64 createBody map[string]any @@ -165,6 +166,17 @@ func (m *e2bMockServer) handle(w http.ResponseWriter, r *http.Request) { "envdAccessToken": "reconnected-token-" + id, }) + case strings.HasPrefix(r.URL.Path, "/sandboxes/") && + strings.HasSuffix(r.URL.Path, "/timeout") && + r.Method == http.MethodPost: + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/sandboxes/"), "/timeout") + if _, ok := m.sandboxes[id]; !ok { + http.NotFound(w, r) + return + } + m.timeoutPOSTs.Add(1) + w.WriteHeader(http.StatusNoContent) + case strings.HasPrefix(r.URL.Path, "/sandboxes/") && !strings.HasSuffix(r.URL.Path, "/connect") && r.Method == http.MethodGet: @@ -374,6 +386,7 @@ func TestE2BRemoteClientProviderAndCapabilities(t *testing.T) { SupportsSnapshots: true, SupportsVolumes: false, SupportsTerminals: true, + SupportsDesktop: true, }, client.Capabilities()) } @@ -798,6 +811,124 @@ func TestE2BBuildStandardTemplateOverridesPtyPrompt(t *testing.T) { require.Equal(t, e2bPtyPromptOverrideCmd, startBody.Steps[0].Args[0]) } +func TestE2BRemoteClientListTemplatesMarksDesktopSibling(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/templates", r.URL.Path) + writeJSON(w, http.StatusOK, []map[string]any{ + { + "templateID": "tpl-cli", + "names": []string{"weknora"}, + "buildStatus": "ready", + }, + { + "templateID": "tpl-desktop", + "names": []string{"weknora-desktop"}, + "buildStatus": "ready", + }, + }) + })) + t.Cleanup(server.Close) + + client, err := NewE2BRemoteClient(&Config{ + E2BAPIKey: "key-test", E2BAPIURL: server.URL, E2BSandboxTTL: time.Minute, + DesktopEnabled: true, + }) + require.NoError(t, err) + + templates, err := client.ListTemplates(context.Background()) + require.NoError(t, err) + require.Len(t, templates, 2) + require.True(t, templates[0].Standard) + require.False(t, templates[0].Desktop) + require.True(t, templates[1].Desktop) + require.False(t, templates[1].Standard) +} + +func TestE2BEnsureStandardTemplateIgnoresDesktopEnabled(t *testing.T) { + var startBody struct { + FromImage string `json:"fromImage"` + } + var postedName string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/templates": + writeJSON(w, http.StatusOK, []map[string]any{}) + case r.Method == http.MethodPost && r.URL.Path == "/v3/templates": + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + postedName, _ = body["alias"].(string) + if postedName == "" { + postedName, _ = body["name"].(string) + } + writeJSON(w, http.StatusAccepted, map[string]any{ + "templateID": "tpl-cli", + "buildID": "build-cli", + "names": []string{StandardTemplateName}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v2/templates/tpl-cli/builds/build-cli": + require.NoError(t, json.NewDecoder(r.Body).Decode(&startBody)) + w.WriteHeader(http.StatusAccepted) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client, err := NewE2BRemoteClient(&Config{ + E2BAPIKey: "key-test", E2BAPIURL: server.URL, E2BSandboxTTL: time.Minute, + DesktopEnabled: true, + }) + require.NoError(t, err) + + got, err := client.EnsureStandardTemplate(context.Background()) + require.NoError(t, err) + require.Equal(t, "tpl-cli", got.ID) + require.True(t, got.Standard) + require.False(t, got.Desktop) + require.Equal(t, DefaultDockerImage, startBody.FromImage) + require.NotEqual(t, DesktopTemplateName, postedName) +} + +func TestE2BEnsureDesktopTemplateBuildsDesktopImage(t *testing.T) { + var startBody struct { + FromImage string `json:"fromImage"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/templates": + writeJSON(w, http.StatusOK, []map[string]any{{ + "templateID": "tpl-cli", + "names": []string{"weknora"}, + "buildStatus": "ready", + }}) + case r.Method == http.MethodPost && r.URL.Path == "/v3/templates": + writeJSON(w, http.StatusAccepted, map[string]any{ + "templateID": "tpl-desk", + "buildID": "build-desk", + "names": []string{DesktopTemplateName}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v2/templates/tpl-desk/builds/build-desk": + require.NoError(t, json.NewDecoder(r.Body).Decode(&startBody)) + w.WriteHeader(http.StatusAccepted) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client, err := NewE2BRemoteClient(&Config{ + E2BAPIKey: "key-test", E2BAPIURL: server.URL, E2BSandboxTTL: time.Minute, + }) + require.NoError(t, err) + + got, err := client.EnsureDesktopTemplate(context.Background()) + require.NoError(t, err) + require.Equal(t, "tpl-desk", got.ID) + require.True(t, got.Desktop) + require.False(t, got.Standard) + require.Equal(t, DefaultDesktopDockerImage, startBody.FromImage) +} + func TestNormalizeE2BTemplateBuildStatus(t *testing.T) { tests := map[string]string{ "READY": "ready", @@ -813,6 +944,66 @@ func TestNormalizeE2BTemplateBuildStatus(t *testing.T) { } } +func TestE2BDialDesktopRefreshesSandboxTTL(t *testing.T) { + prevMin := terminalTTLRefreshMin + terminalTTLRefreshMin = 40 * time.Millisecond + t.Cleanup(func() { terminalTTLRefreshMin = prevMin }) + + mock := newE2BMockServer(t) + var seen http.Request + proxy := desktopEchoServer(t, &seen) + t.Cleanup(proxy.Close) + + cfg := &Config{ + Type: SandboxTypeE2B, + E2BAPIKey: "key-test", + E2BAPIURL: mock.URL(), + E2BTemplate: "template-a", + E2BSandboxTTL: 3 * time.Second, + E2BProxyURL: proxy.URL, + E2BSandboxDomain: "e2b.app", + AllowPrivateEndpoints: true, + } + policy := OutboundURLPolicy{AllowPrivate: true} + pool := NewSandboxGatewayTransportPoolWithPolicy( + NewGuardedTransportWithPolicy(policy), + policy, + ) + client, err := NewE2BRemoteClientWithPool(cfg, pool) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + handle, err := client.Create(ctx, RemoteCreateRequest{ + TemplateID: "template-a", + Timeout: RemoteTimeoutPolicy{ + Mode: RemoteTimeoutExplicit, + Value: time.Minute, + Action: RemoteOnTimeoutPause, + }, + }) + require.NoError(t, err) + + conn, err := client.DialDesktop(ctx, handle, RemoteDesktopOptions{}) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + cancel() + + require.Never(t, func() bool { + return mock.timeoutPOSTs.Load() >= 1 + }, 150*time.Millisecond, 20*time.Millisecond, + "DialDesktop must not bind SetTimeout to the request context") + + ttlCtx, ttlCancel := context.WithCancel(context.Background()) + t.Cleanup(ttlCancel) + client.StartDesktopTTLRefresh(ttlCtx, handle) + + require.Eventually(t, func() bool { + return mock.timeoutPOSTs.Load() >= 1 + }, 2*time.Second, 20*time.Millisecond, "expected SetTimeout on the relay lifetime ctx") +} + func TestE2BRemoteClientCreateWritesMetadataAndPauseLifecycle(t *testing.T) { mock := newE2BMockServer(t) client := newTestE2BRemoteClient(t, mock) diff --git a/internal/sandbox/langfuse.go b/internal/sandbox/langfuse.go index 02421b231..3ce592c2d 100644 --- a/internal/sandbox/langfuse.go +++ b/internal/sandbox/langfuse.go @@ -5,6 +5,7 @@ import ( "unicode/utf8" "github.com/Tencent/WeKnora/internal/tracing/langfuse" + "github.com/gorilla/websocket" ) const sandboxSpanPreviewRunes = 256 @@ -308,8 +309,45 @@ func (c *langfuseRemoteClient) OpenTerminal( return session, err } +// DialDesktop forwards the desktop capability so wrapping does not hide +// RemoteDesktopManager from DesktopManagerFrom. +// +// It lives on the base decorator (not on a dedicated one like +// langfuseSnapshotClient) so *every* wrapping shape exposes the capability: +// langfuseSnapshotClient embeds this type, so it inherits the method. +// Whether a backend actually supports desktops stays delegated to the inner +// client's SupportsDesktop flag, which the *From helpers check. +func (c *langfuseRemoteClient) DialDesktop( + ctx context.Context, + handle RemoteSandboxHandle, + opts RemoteDesktopOptions, +) (*websocket.Conn, error) { + inner, ok := c.inner.(RemoteDesktopManager) + if !ok { + return nil, &RemoteError{ + Kind: RemoteErrorKindUnsupported, + Op: "DialDesktop", + Message: "inner client has no desktop manager", + } + } + ctx, span := startSandboxSpan(ctx, "sandbox.dial_desktop", sandboxHandleOut(handle), nil) + conn, err := inner.DialDesktop(ctx, handle, opts) + span.Finish(sandboxHandleOut(handle), nil, err) + return conn, err +} + +func (c *langfuseRemoteClient) StartDesktopTTLRefresh(ctx context.Context, handle RemoteSandboxHandle) { + inner, ok := c.inner.(RemoteDesktopTTLRefresher) + if !ok { + return + } + inner.StartDesktopTTLRefresh(ctx, handle) +} + var ( - _ RemoteSandboxClient = (*langfuseRemoteClient)(nil) - _ RemoteSnapshotManager = (*langfuseSnapshotClient)(nil) - _ RemoteTerminalManager = (*langfuseRemoteClient)(nil) + _ RemoteSandboxClient = (*langfuseRemoteClient)(nil) + _ RemoteSnapshotManager = (*langfuseSnapshotClient)(nil) + _ RemoteTerminalManager = (*langfuseRemoteClient)(nil) + _ RemoteDesktopManager = (*langfuseRemoteClient)(nil) + _ RemoteDesktopTTLRefresher = (*langfuseRemoteClient)(nil) ) diff --git a/internal/sandbox/langfuse_test.go b/internal/sandbox/langfuse_test.go index 8d69701f7..23af8ce3f 100644 --- a/internal/sandbox/langfuse_test.go +++ b/internal/sandbox/langfuse_test.go @@ -68,6 +68,31 @@ func TestWrapLangfuseRemoteClientPreservesTerminalCapability(t *testing.T) { require.NotNil(t, session) } +func TestWrapLangfuseRemoteClientPreservesDesktopCapability(t *testing.T) { + inner := &desktopFakeClient{fakeRemoteClient: newFakeRemoteClient(SandboxTypeCube)} + inner.capabilities.SupportsDesktop = true + + wrapped := wrapLangfuseRemoteClient(inner) + mgr, ok := DesktopManagerFrom(wrapped) + require.True(t, ok, "wrapping must not hide RemoteDesktopManager from DesktopManagerFrom") + require.NotNil(t, mgr) + + _, err := mgr.DialDesktop(context.Background(), nil, RemoteDesktopOptions{}) + require.NoError(t, err) + require.True(t, inner.dialed, "the decorator must delegate, not short-circuit") +} + +func TestWrapLangfuseRemoteClientPreservesDesktopTTLRefresh(t *testing.T) { + inner := &desktopFakeClient{fakeRemoteClient: newFakeRemoteClient(SandboxTypeCube)} + inner.capabilities.SupportsTimeoutRefresh = true + + wrapped := wrapLangfuseRemoteClient(inner) + refresher, ok := DesktopTTLRefresherFrom(wrapped) + require.True(t, ok, "wrapping must not hide RemoteDesktopTTLRefresher") + refresher.StartDesktopTTLRefresh(context.Background(), nil) + require.Equal(t, 1, inner.ttlStarted) +} + func TestWrapLangfuseRemoteClientDoesNotInventTerminalSupport(t *testing.T) { // Docker advertises no terminal support; wrapping must not fake it. inner := newFakeRemoteClient(SandboxTypeDocker) diff --git a/internal/sandbox/remote_client.go b/internal/sandbox/remote_client.go index d6a191ddc..fa2498de2 100644 --- a/internal/sandbox/remote_client.go +++ b/internal/sandbox/remote_client.go @@ -438,6 +438,14 @@ type RemoteSandboxCapabilities struct { // reject terminal features with an unsupported-backend error instead of // failing after the WebSocket is upgraded. SupportsTerminals bool + + // SupportsDesktop is true when the provider can relay a WebSocket to a + // non-envd data-plane port inside the sandbox, which is what the VNC + // desktop needs (websockify on 6080). It is separate from + // SupportsTerminals: the terminal rides envd's PTY service, the desktop + // rides a raw port through the gateway. Docker is false — not because it + // cannot, but because it is not scheduled. + SupportsDesktop bool } // RemoteSandboxClient is the contract SessionBoundManager talks to. All diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index a86d98e73..ac3e74ef2 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -62,6 +62,42 @@ const ( // envd, and the Docker backend never needs one. DefaultCubeTemplateImage = "wechatopenai/weknora-sandbox:main-cube" + // DefaultDesktopDockerImage is the XFCE/x11vnc/websockify variant of + // DefaultDockerImage (target "desktop" of docker/Dockerfile.sandbox). + // E2B desktop templates are built from it. The Docker backend does not + // consume this tag yet. + DefaultDesktopDockerImage = "wechatopenai/weknora-sandbox:main-desktop" + + // DefaultCubeDesktopTemplateImage is DefaultDesktopDockerImage plus Cube + // envd (target "desktop-cube"). amd64 only, same reason as the cube target. + DefaultCubeDesktopTemplateImage = "wechatopenai/weknora-sandbox:main-desktop-cube" + + // DesktopWebsockifyPort is websockify inside the sandbox. WeKnora dials + // it through the provider gateway (Host "{port}-{id}.{domain}"), not by + // publishing the port on the host NIC. x11vnc stays on 127.0.0.1:5900 + // with no RFB password; the Basic-auth check lives on websockify. + DesktopWebsockifyPort = 6080 + + // DesktopWebsockifyPath is websockify's WebSocket endpoint. The image + // hosts no HTML UI, so GET / answers 405 by design. + DesktopWebsockifyPath = "/websockify" + + // DesktopStartScript is the idempotent lazy-start entry point baked into + // the desktop images. It is re-run on every desktop connect: that is what + // heals a sandbox after pause/resume. + DesktopStartScript = "/usr/local/bin/start-desktop.sh" + + // DesktopSecretPath holds the per-sandbox websockify Basic-auth password. + // It is read by the backend and never sent to the browser. + DesktopSecretPath = "/run/desktop/secret" + + // DesktopBasicAuthUser is the fixed username half of that credential. + DesktopBasicAuthUser = "weknora" + + // The two commands the backend Execs around DesktopStartScript live in + // desktop_scripts.go as embedded .sh files: + // DesktopEnsureCmd and DesktopResetListenersCmd. + // CubeEnvdPort is the port envd listens on inside a Cube sandbox. It carries // the readiness probe as well as every exec and filesystem call, and the // data plane addresses sandboxes as "49983-{id}.{domain}". @@ -93,6 +129,10 @@ const ( DefaultE2BSandboxTTL = 5 * time.Minute // DefaultE2BHTTPTimeout bounds a single HTTP call to the E2B API. DefaultE2BHTTPTimeout = 30 * time.Second + // DefaultE2BSandboxDomain is go-e2b's built-in sandbox routing domain. + // Named E2B configs may omit sandbox_domain (config_required.go); envd + // then uses this value, and DialDesktop must too. + DefaultE2BSandboxDomain = "e2b.app" ) // Common errors @@ -111,12 +151,12 @@ var ( // Unlike Execute, these entry points never provision: creating a sandbox // needs the agent's config-pin context, which they do not carry. ErrNoLiveSessionSandbox = errors.New("session has no live sandbox") - // ErrSandboxPaused is returned by lookup-only terminal opens when the - // session has a bound sandbox that is not confirmed running (paused, - // transitioning, or missing from the provider list). Connect would - // resume a paused instance and start billing again; the UI must get - // an explicit click first. Distinct from ErrNoLiveSessionSandbox, - // which means there is no binding to resume. + // ErrSandboxPaused is returned by lookup-only terminal and desktop + // opens when the session has a bound sandbox that is not confirmed + // running (paused, transitioning, or missing from the provider list). + // Connect / Exec would resume a paused instance and start billing + // again; the UI must get an explicit click first. Distinct from + // ErrNoLiveSessionSandbox, which means there is no binding to resume. ErrSandboxPaused = errors.New("session sandbox is paused") // ErrTerminalUnsupported is returned when the active backend cannot // stream PTYs (Docker, a disabled manager). Distinct from @@ -254,6 +294,11 @@ type Config struct { // is treated as DefaultTerminalIdleDisconnect at use time. TerminalIdleDisconnect time.Duration + // DesktopEnabled mirrors TenantSandboxConfig.DesktopEnabled: this + // config's template is a desktop image. The desktop endpoint refuses + // with DESKTOP_UNSUPPORTED when it is false, before touching a sandbox. + DesktopEnabled bool + // AllowPrivateEndpoints is the per-workspace outbound policy for this // connection. Link-local addresses are blocked regardless. AllowPrivateEndpoints bool diff --git a/internal/sandbox/session_manager.go b/internal/sandbox/session_manager.go index deb357967..712bf69b2 100644 --- a/internal/sandbox/session_manager.go +++ b/internal/sandbox/session_manager.go @@ -226,9 +226,9 @@ func (m *SessionBoundManager) GetType() SandboxType { return m.activeType } -// TerminalIdleDisconnect is how long an open PTY may sit idle before the -// WebSocket is closed. Missing or out-of-range workspace values are clamped -// onto the built-in default so a stored 0 still disconnects. +// TerminalIdleDisconnect is how long an open PTY or desktop relay may sit +// idle before the WebSocket is closed. Missing or out-of-range workspace +// values are clamped onto the built-in default so a stored 0 still disconnects. func (m *SessionBoundManager) TerminalIdleDisconnect() time.Duration { if m == nil || m.config == nil { return DefaultTerminalIdleDisconnect @@ -701,6 +701,11 @@ type ShellExecOptions struct { // AllowSkillsRoot separately permits a work_dir under the skills image root; // it is not a filesystem boundary for commands running as root. AsRoot bool + // SkipWorkspacePrep omits prepareSessionDirs. Desktop maintenance + // commands use absolute paths and do not write /workspace; the image + // already has that layout. Never set this from a model-authored tool + // such as shell_exec — agents still need the workspace contract. + SkipWorkspacePrep bool } // ExecShellCommand runs a shell one-liner inside the session's persistent @@ -765,14 +770,20 @@ func (m *SessionBoundManager) ExecShellCommandWithOptions( user := DefaultSandboxExecUser if opts.AsRoot { user = "root" - // Installation owns the skill directory and does not depend on a - // writable session workspace (which is cleaned before snapshotting). - if err := m.prepareSessionDirs(ctx, handle, user, workDir); err != nil { - return nil, err - } - } else { - if err := m.prepareSessionDirs(ctx, handle, user, SessionInputRoot, SessionOutputRoot, workDir); err != nil { - return nil, err + } + if !opts.SkipWorkspacePrep { + if opts.AsRoot { + // Installation owns the skill directory and does not depend on a + // writable session workspace (which is cleaned before snapshotting). + if err := m.prepareSessionDirs(ctx, handle, user, workDir); err != nil { + return nil, err + } + } else { + if err := m.prepareSessionDirs( + ctx, handle, user, SessionInputRoot, SessionOutputRoot, workDir, + ); err != nil { + return nil, err + } } } @@ -851,19 +862,9 @@ func (m *SessionBoundManager) OpenSessionTerminal( return nil, ErrTerminalUnsupported } if !opts.AllowResume { - state, bound, err := m.peekBoundSandboxState(ctx, sessionID) - if err != nil { + if err := m.RequireRunningSessionSandbox(ctx, sessionID); err != nil { return nil, err } - if !bound { - return nil, ErrNoLiveSessionSandbox - } - // Only a List-confirmed running sandbox is safe to Connect: - // paused/transitioning Connect resumes (and re-bills), and a - // list miss with a stale binding is not "no sandbox". - if state != RemoteStateRunning { - return nil, ErrSandboxPaused - } } handle, found, err := m.lookupSessionHandle(ctx, sessionID) if err != nil { @@ -877,6 +878,94 @@ func (m *SessionBoundManager) OpenSessionTerminal( var _ SessionTerminalProvider = (*SessionBoundManager)(nil) +// SessionDesktopManager advertises the graphical-desktop capability while a +// real remote backend is active, the provider can relay a data-plane +// WebSocket (E2B and Cube do; Docker is not scheduled), and this config's +// base image is a desktop template. DesktopEnabled is the stored bit that +// survives skill snapshots replacing template_id with a UUID; without it the +// tab would Exec ensure.sh on a CLI image and only then report unsupported. +func (m *SessionBoundManager) SessionDesktopManager() SessionDesktopManager { + if m == nil || m.remoteDisabled() { + return nil + } + if m.config == nil || !m.config.DesktopEnabled { + return nil + } + if _, ok := DesktopManagerFrom(m.client); !ok { + return nil + } + return m +} + +// RequireRunningSessionSandbox reports ErrNoLiveSessionSandbox or +// ErrSandboxPaused without Connect. Lookup-only terminal and desktop opens +// use it so opening a panel cannot resume (and re-bill) a paused instance. +// Only a List-confirmed running sandbox is safe: paused/transitioning +// Connect resumes, and a list miss with a stale binding is not "no sandbox". +func (m *SessionBoundManager) RequireRunningSessionSandbox( + ctx context.Context, + sessionID string, +) error { + if m == nil { + return ErrNoLiveSessionSandbox + } + state, bound, err := m.peekBoundSandboxState(ctx, sessionID) + if err != nil { + return err + } + if !bound { + return ErrNoLiveSessionSandbox + } + if state != RemoteStateRunning { + return ErrSandboxPaused + } + return nil +} + +// OpenSessionDesktop dials the desktop of the sandbox currently bound to the +// session. +// +// Unlike OpenSessionTerminal there is no AllowResume branch: by the time this +// runs, SandboxDesktopService has already driven the same +// resolveSandboxForExecution path a chat turn uses, so the sandbox is live +// and — critically — its inbound token is registered on THIS replica. +// Reaching here with no binding means the sandbox went away in between, +// which is an error, not a reason to provision. +func (m *SessionBoundManager) OpenSessionDesktop( + ctx context.Context, + sessionID string, + opts RemoteDesktopOptions, +) (*SessionDesktopConn, error) { + if m.SessionDesktopManager() == nil { + return nil, ErrDesktopUnsupported + } + desktop, ok := DesktopManagerFrom(m.client) + if !ok { + return nil, ErrDesktopUnsupported + } + handle, found, err := m.lookupSessionHandle(ctx, sessionID) + if err != nil { + return nil, err + } + if !found { + return nil, ErrNoLiveSessionSandbox + } + conn, err := desktop.DialDesktop(ctx, handle, opts) + if err != nil { + return nil, err + } + out := &SessionDesktopConn{Conn: conn, SandboxID: handle.ID()} + if refresher, ok := DesktopTTLRefresherFrom(m.client); ok { + bound := handle + out.StartTTLRefresh = func(ttlCtx context.Context) { + refresher.StartDesktopTTLRefresh(ttlCtx, bound) + } + } + return out, nil +} + +var _ SessionDesktopProvider = (*SessionBoundManager)(nil) + // Cleanup marks the manager closed. Session sandboxes are not force-deleted // here: their lifecycle is authoritative in the binding store and would // leak to any other WeKnora replica if this replica reaped them on shutdown. diff --git a/internal/sandbox/session_manager_desktop_test.go b/internal/sandbox/session_manager_desktop_test.go new file mode 100644 index 000000000..f9cd670ed --- /dev/null +++ b/internal/sandbox/session_manager_desktop_test.go @@ -0,0 +1,135 @@ +package sandbox + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func newSessionManagerDesktopTestHarness(t *testing.T) (*SessionBoundManager, *desktopFakeClient) { + return newSessionManagerDesktopHarness(t, true) +} + +func newSessionManagerDesktopHarness(t *testing.T, desktopEnabled bool) (*SessionBoundManager, *desktopFakeClient) { + t.Helper() + + client := &desktopFakeClient{fakeRemoteClient: newFakeRemoteClient(SandboxTypeCube)} + client.capabilities.SupportsDesktop = true + cfg := DefaultConfig() + cfg.CubeTemplate = "tpl-test" + cfg.DesktopEnabled = desktopEnabled + mgr, err := NewSessionBoundManager(SessionBoundManagerConfig{ + Config: cfg, + Client: client, + Store: NewMemorySessionSandboxBindingStore(), + Checker: &fakeSessionExistenceChecker{exists: true}, + SkipHealthProbe: true, + }) + require.NoError(t, err) + return mgr, client +} + +func TestOpenSessionDesktopNoLiveSandbox(t *testing.T) { + ctx := terminalTestContext() + mgr, inner := newSessionManagerDesktopTestHarness(t) + + _, err := mgr.OpenSessionDesktop(ctx, "sess-nobinding", RemoteDesktopOptions{}) + require.ErrorIs(t, err, ErrNoLiveSessionSandbox) + require.False(t, inner.dialed, "must not dial when there is nothing bound") +} + +func TestRequireRunningSessionSandboxRefusesPausedWithoutConnect(t *testing.T) { + ctx := terminalTestContext() + mgr, inner := newSessionManagerDesktopTestHarness(t) + + _, err := mgr.ExecShellCommand(ctx, "sess-paused", "true", "", time.Second, nil) + require.NoError(t, err) + pauseAllFakeSandboxes(t, inner.fakeRemoteClient) + connectsBefore := fakeConnectCount(t, inner.fakeRemoteClient) + + err = mgr.RequireRunningSessionSandbox(ctx, "sess-paused") + require.ErrorIs(t, err, ErrSandboxPaused) + require.Equal(t, connectsBefore, fakeConnectCount(t, inner.fakeRemoteClient), + "lookup-only desktop must List a paused sandbox instead of Connect, which would resume it") +} + +func TestRequireRunningSessionSandboxAcceptsRunning(t *testing.T) { + ctx := terminalTestContext() + mgr, _ := newSessionManagerDesktopTestHarness(t) + + _, err := mgr.ExecShellCommand(ctx, "sess-live", "true", "", time.Second, nil) + require.NoError(t, err) + + require.NoError(t, mgr.RequireRunningSessionSandbox(ctx, "sess-live")) +} + +func TestRequireRunningSessionSandboxNoBindingIsNotPaused(t *testing.T) { + ctx := terminalTestContext() + mgr, _ := newSessionManagerDesktopTestHarness(t) + + err := mgr.RequireRunningSessionSandbox(ctx, "sess-missing") + require.ErrorIs(t, err, ErrNoLiveSessionSandbox) + require.NotErrorIs(t, err, ErrSandboxPaused) +} + +func TestOpenSessionDesktopReportsDialedSandboxID(t *testing.T) { + // The ID must be the one actually dialled, not the result of a second + // lookup: a skill install landing between the two would make the + // handler's rebuild check miss. + ctx := terminalTestContext() + mgr, inner := newSessionManagerDesktopTestHarness(t) + + _, err := mgr.ExecShellCommand(ctx, "sess-live", "true", "", time.Second, nil) + require.NoError(t, err) + + inner.mu.Lock() + require.Len(t, inner.sandboxes, 1) + var dialedID string + for id := range inner.sandboxes { + dialedID = id + } + inner.mu.Unlock() + + got, err := mgr.OpenSessionDesktop(ctx, "sess-live", RemoteDesktopOptions{}) + require.NoError(t, err) + require.Equal(t, dialedID, got.SandboxID) + require.True(t, inner.dialed, "DialDesktop must reach the inner client through wrapLangfuse") +} + +func TestOpenSessionDesktopWiresTTLRefreshThroughLangfuse(t *testing.T) { + ctx := terminalTestContext() + mgr, inner := newSessionManagerDesktopTestHarness(t) + inner.capabilities.SupportsTimeoutRefresh = true + + _, err := mgr.ExecShellCommand(ctx, "sess-live", "true", "", time.Second, nil) + require.NoError(t, err) + + got, err := mgr.OpenSessionDesktop(ctx, "sess-live", RemoteDesktopOptions{}) + require.NoError(t, err) + require.NotNil(t, got.StartTTLRefresh, "wrapping must not hide StartDesktopTTLRefresh") + + got.StartTTLRefresh(ctx) + require.Equal(t, 1, inner.ttlStarted) +} + +func TestSessionDesktopManagerNilWhenDesktopDisabled(t *testing.T) { + // The provider can relay a desktop, but this config boots a CLI image. + // Advertising the capability would let the tab Exec ensure.sh and wake + // the sandbox only to learn the script is missing. + mgr, _ := newSessionManagerDesktopHarness(t, false) + require.Nil(t, mgr.SessionDesktopManager(), + "DesktopEnabled=false must hide the capability before any sandbox exists") +} + +func TestOpenSessionDesktopDisabledDoesNotDial(t *testing.T) { + ctx := terminalTestContext() + mgr, inner := newSessionManagerDesktopHarness(t, false) + + _, err := mgr.ExecShellCommand(ctx, "sess-live", "true", "", time.Second, nil) + require.NoError(t, err) + + _, err = mgr.OpenSessionDesktop(ctx, "sess-live", RemoteDesktopOptions{}) + require.ErrorIs(t, err, ErrDesktopUnsupported) + require.False(t, inner.dialed, "must not dial websockify when the config is not a desktop image") +} diff --git a/internal/sandbox/session_manager_test.go b/internal/sandbox/session_manager_test.go index f41f427f9..781b5266e 100644 --- a/internal/sandbox/session_manager_test.go +++ b/internal/sandbox/session_manager_test.go @@ -222,6 +222,54 @@ func TestExecShellCommandWithOptionsSelectsMaintenanceBootstrap(t *testing.T) { require.Equal(t, workspaceBootstrapCommand(skillDir), execs[2].Command) } +func TestExecShellCommandSkipWorkspacePrepOmitsBootstrap(t *testing.T) { + ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(10000)) + mgr, client := newSessionManagerExecTestHarness(t) + + _, err := mgr.ExecShellCommandWithOptions(ctx, "sess-1", "echo hi", ShellExecOptions{ + SkipWorkspacePrep: true, + Timeout: time.Second, + }) + require.NoError(t, err) + + client.mu.Lock() + execs := append([]RemoteExecRequest(nil), client.execRequests...) + client.mu.Unlock() + require.Len(t, execs, 1, "desktop-style exec must not mkdir /workspace first") + require.Equal(t, "echo hi", execs[0].Command) + require.Equal(t, SessionWorkspaceRoot, execs[0].WorkDir) + require.Equal(t, DefaultSandboxExecUser, execs[0].User) +} + +func TestExecShellCommandSkipWorkspacePrepStillRejectsWorkDir(t *testing.T) { + ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(10000)) + mgr, _ := newSessionManagerExecTestHarness(t) + + _, err := mgr.ExecShellCommandWithOptions(ctx, "sess-1", "echo hi", ShellExecOptions{ + SkipWorkspacePrep: true, + WorkDir: "/etc", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "outside allowed roots") +} + +func TestExecShellCommandWithoutSkipStillBootstraps(t *testing.T) { + ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(10000)) + mgr, client := newSessionManagerExecTestHarness(t) + + _, err := mgr.ExecShellCommandWithOptions(ctx, "sess-1", "echo hi", ShellExecOptions{}) + require.NoError(t, err) + + client.mu.Lock() + execs := append([]RemoteExecRequest(nil), client.execRequests...) + client.mu.Unlock() + require.Len(t, execs, 2) + require.Equal(t, + workspaceBootstrapCommand(SessionInputRoot, SessionOutputRoot, SessionWorkspaceRoot), + execs[0].Command) + require.Equal(t, "echo hi", execs[1].Command) +} + func TestExecShellCommandKeepsOrdinaryRemoteRequest(t *testing.T) { ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(10000)) mgr, client := newSessionManagerExecTestHarness(t) @@ -486,3 +534,17 @@ func TestSessionBoundManagerOpenSessionTerminalUnsupportedBackend(t *testing.T) require.ErrorIs(t, err, ErrTerminalUnsupported) require.NotErrorIs(t, err, ErrNoLiveSessionSandbox) } + +func TestSessionDesktopManagerNilWhenBackendLacksCapability(t *testing.T) { + // A backend that cannot relay desktops must not advertise the capability; + // the frontend greys the tab out from this signal alone, before any + // sandbox exists. + mgr, _ := newSessionManagerExecTestHarness(t) + require.Nil(t, mgr.SessionDesktopManager()) +} + +func TestOpenSessionDesktopUnsupportedBackend(t *testing.T) { + mgr, _ := newSessionManagerExecTestHarness(t) + _, err := mgr.OpenSessionDesktop(context.Background(), "sess-1", RemoteDesktopOptions{}) + require.ErrorIs(t, err, ErrDesktopUnsupported) +} diff --git a/internal/sandbox/template_catalog.go b/internal/sandbox/template_catalog.go index 669566220..2281e03d3 100644 --- a/internal/sandbox/template_catalog.go +++ b/internal/sandbox/template_catalog.go @@ -5,7 +5,15 @@ import ( "strings" ) -const StandardTemplateName = "weknora" +const ( + // StandardTemplateName is the provider-side name of the CLI template. + StandardTemplateName = "weknora" + + // DesktopTemplateName is the provider-side name of the desktop template. + // It is a sibling of StandardTemplateName, not a replacement: a config + // uses one or the other, decided by Config.DesktopEnabled. + DesktopTemplateName = "weknora-desktop" +) // DefaultE2BTemplateTag is the tag E2B resolves when a sandbox is created from a // bare template name or ID. Builds must carry it to be spawnable at all. @@ -28,6 +36,10 @@ type RemoteTemplate struct { CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` Standard bool `json:"standard"` + // Desktop marks the XFCE sibling of Standard. The two coexist in one + // cluster; the admin picks which ID this config boots. Not a second + // boot-target field on the config. + Desktop bool `json:"desktop,omitempty"` // Error carries the provider's own explanation for a failed build. Without // it a failed template is a red badge with no way to tell a registry // credential problem from an out-of-disk node. @@ -57,13 +69,56 @@ type RemoteTemplateCatalog interface { DeleteSupersededStandardTemplates(ctx context.Context, keepID string) error } +// RemoteDesktopTemplateCatalog is the desktop sibling of RemoteTemplateCatalog. +// Cube and E2B implement it; Docker does not (SupportsDesktop stays false). +type RemoteDesktopTemplateCatalog interface { + // EnsureDesktopTemplate returns the cluster's WeKnora desktop template, + // building it when absent. + EnsureDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) + // ReplaceDesktopTemplate applies the current spec to the desktop + // template. Callers persist a READY replacement first, then + // DeleteSupersededDesktopTemplates. + ReplaceDesktopTemplate(ctx context.Context) (*RemoteTemplate, error) + // DeleteSupersededDesktopTemplates removes desktop templates other than + // keepID after that ID is spawnable and stored on the config. + DeleteSupersededDesktopTemplates(ctx context.Context, keepID string) error +} + func isStandardTemplate(name string) bool { + return isTemplateName(name, StandardTemplateName) +} + +func isDesktopTemplate(name string) bool { + return isTemplateName(name, DesktopTemplateName) +} + +func isTemplateName(name, want string) bool { trimmed := strings.Trim(strings.TrimSpace(name), "/") - if strings.EqualFold(trimmed, StandardTemplateName) { + if strings.EqualFold(trimmed, want) { return true } parts := strings.Split(trimmed, "/") - return len(parts) > 1 && strings.EqualFold(parts[len(parts)-1], StandardTemplateName) + return len(parts) > 1 && strings.EqualFold(parts[len(parts)-1], want) +} + +// classifyWeKnoraTemplate decides whether a catalog entry is our CLI template, +// our desktop sibling, or neither. Name wins over image: a template aliased +// weknora-desktop is desktop even if the image repository matches the CLI +// image, and a nameless Cube template falls back to the image tag. +func classifyWeKnoraTemplate(name, image string) (standard, desktop bool) { + if isDesktopTemplate(name) { + return false, true + } + if isStandardTemplate(name) { + return true, false + } + if isDesktopTemplateImage(image) { + return false, true + } + if isStandardTemplateImage(image) { + return true, false + } + return false, false } // isStandardTemplateImage recognises our template by the image it was built @@ -76,6 +131,27 @@ func isStandardTemplateImage(image string) bool { return candidate != "" && candidate == normalizeImageRepository(DefaultDockerImage) } +func isDesktopTemplateImage(image string) bool { + if !isStandardTemplateImage(image) { + return false + } + tag := strings.ToLower(imageTag(image)) + return tag == "main-desktop" || strings.HasSuffix(tag, "-desktop") || strings.HasSuffix(tag, "-desktop-cube") +} + +func imageTag(image string) string { + ref := strings.TrimSpace(image) + if at := strings.Index(ref, "@"); at >= 0 { + ref = ref[:at] + } + slash := strings.LastIndex(ref, "/") + colon := strings.LastIndex(ref, ":") + if colon > slash { + return ref[colon+1:] + } + return "" +} + // normalizeImageRepository reduces an image reference to its repository path so // that "docker.io/wechatopenai/weknora-sandbox:latest", // "wechatopenai/weknora-sandbox@sha256:…" and the bare name all compare equal. diff --git a/internal/sandbox/template_catalog_test.go b/internal/sandbox/template_catalog_test.go index a255ae6cb..d2fc7d47c 100644 --- a/internal/sandbox/template_catalog_test.go +++ b/internal/sandbox/template_catalog_test.go @@ -11,4 +11,53 @@ func TestIsStandardTemplateRecognizesProviderScopedName(t *testing.T) { if isStandardTemplate("weknora-custom") { t.Fatal("custom template must not be treated as the standard template") } + if isStandardTemplate(DesktopTemplateName) { + t.Fatal("the desktop sibling must not be classified as the CLI standard template") + } +} + +func TestIsDesktopTemplateRecognizesProviderScopedName(t *testing.T) { + for _, name := range []string{"weknora-desktop", "team/weknora-desktop", "project-b89e/WeKnora-Desktop"} { + if !isDesktopTemplate(name) { + t.Fatalf("expected %q to identify the WeKnora desktop template", name) + } + } + if isDesktopTemplate("weknora") { + t.Fatal("the CLI template must not be classified as desktop") + } + if isDesktopTemplate("weknora-desktop-custom") { + t.Fatal("a similarly prefixed custom name must not be the desktop template") + } +} + +func TestClassifyWeKnoraTemplatePrefersNameOverImage(t *testing.T) { + standard, desktop := classifyWeKnoraTemplate(DesktopTemplateName, DefaultDockerImage) + if standard || !desktop { + t.Fatalf( + "named desktop template must be desktop even if the image repo matches CLI, got standard=%v desktop=%v", + standard, desktop, + ) + } + standard, desktop = classifyWeKnoraTemplate(StandardTemplateName, DefaultDesktopDockerImage) + if !standard || desktop { + t.Fatalf( + "named CLI template must stay CLI even if the image tag is desktop, got standard=%v desktop=%v", + standard, desktop, + ) + } +} + +func TestClassifyWeKnoraTemplateNamelessImageUsesTag(t *testing.T) { + standard, desktop := classifyWeKnoraTemplate("", DefaultDockerImage) + if !standard || desktop { + t.Fatalf("CLI image with no name must be standard, got standard=%v desktop=%v", standard, desktop) + } + standard, desktop = classifyWeKnoraTemplate("", DefaultDesktopDockerImage) + if standard || !desktop { + t.Fatalf("desktop image with no name must be desktop, got standard=%v desktop=%v", standard, desktop) + } + standard, desktop = classifyWeKnoraTemplate("", DefaultCubeDesktopTemplateImage) + if standard || !desktop { + t.Fatalf("Cube desktop image with no name must be desktop, got standard=%v desktop=%v", standard, desktop) + } } diff --git a/internal/sandbox/tenant_config.go b/internal/sandbox/tenant_config.go index 506a363cc..f49bfe8ff 100644 --- a/internal/sandbox/tenant_config.go +++ b/internal/sandbox/tenant_config.go @@ -67,6 +67,7 @@ func ResolveEffectiveConfig( overrideSeconds(&effective.TerminalIdleDisconnect, tenantCfg.TerminalIdleDisconnectSec) effective.TerminalIdleDisconnect = EffectiveTerminalIdleDisconnect(effective.TerminalIdleDisconnect) effective.AllowPrivateEndpoints = tenantCfg.AllowPrivateEndpoints + effective.DesktopEnabled = tenantCfg.DesktopEnabled effective.Network = resolveNetworkPolicy(tenantCfg.Network) if tenantCfg.EnvVars != nil { effective.EnvVars = cloneMetadata(tenantCfg.EnvVars) diff --git a/internal/sandbox/tenant_config_test.go b/internal/sandbox/tenant_config_test.go index e10a44dc7..ef04edfdb 100644 --- a/internal/sandbox/tenant_config_test.go +++ b/internal/sandbox/tenant_config_test.go @@ -411,6 +411,25 @@ func TestResolveEffectiveConfigTerminalIdleFallsBackToBuiltIn(t *testing.T) { require.Equal(t, DefaultTerminalIdleDisconnect, got.TerminalIdleDisconnect) } +func TestResolveEffectiveConfigCarriesDesktopEnabled(t *testing.T) { + tenantCfg := completeCubeTenantConfig() + tenantCfg.DesktopEnabled = true + + effective, err := ResolveEffectiveConfig(tenantCfg, DefaultConfig()) + require.NoError(t, err) + require.True(t, effective.DesktopEnabled, + "DesktopEnabled must reach the runtime Config; the desktop endpoint reads it") +} + +func TestResolveEffectiveConfigDesktopDisabledByDefault(t *testing.T) { + tenantCfg := completeCubeTenantConfig() + + effective, err := ResolveEffectiveConfig(tenantCfg, DefaultConfig()) + require.NoError(t, err) + require.False(t, effective.DesktopEnabled, + "desktop must be explicit opt-in: the image costs +1.5GB per sandbox") +} + // Tuning fields fall back to the built-in constants, never to the deployment's: // "inherits nothing" would be a much weaker rule with an exception here. func TestResolveEffectiveConfigTuningFallsBackToBuiltIns(t *testing.T) { diff --git a/internal/sandbox/websocket_dialer.go b/internal/sandbox/websocket_dialer.go new file mode 100644 index 000000000..a07ca29d8 --- /dev/null +++ b/internal/sandbox/websocket_dialer.go @@ -0,0 +1,177 @@ +// Package sandbox provides WebSocket dialling for sandbox data-plane ports. +// +// This is the ws:// sibling of gateway_transport.go's RoundTripperFor, and it +// exists for the same reason: sandbox data-plane traffic is addressed as +// "{port}-{sandboxID}.{domain}" but must be dialled at the configured gateway, +// with the per-sandbox inbound token attached. +// +// It is a POOL METHOD, never a package-level function taking cfg. The process +// runs two SandboxGatewayTransportPools with two InboundTokenRegistries +// (tenant_resolver.go), selected by cfg.AllowPrivateEndpoints. A free function +// holding only cfg cannot know which registry to read; reading the wrong one +// returns an empty token and the gateway answers 401 — a symptom identical to +// the replica-affinity failure, and nearly impossible to tell apart in +// production. +package sandbox + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +// WebsocketDialer dials one data-plane port of one sandbox over +// WebSocket. Build it with SandboxGatewayTransportPool.WebsocketDialerFor. +type WebsocketDialer struct { + // tokens is the registry of the pool that built this dialer. + tokens *InboundTokenRegistry + policy OutboundURLPolicy + + // target is the gateway "host:port" every dial actually connects to, + // regardless of the authority in the URL. Empty means "no gateway + // configured": dial the authority directly, matching what + // RoundTripperFor does when parseProxyURL fails. + target string + + // scheme is ws or wss, derived from the gateway's http/https. + scheme string + + // sandboxDomain is the routing domain the gateway matches on Host. + sandboxDomain string +} + +// WebsocketDialerFor returns the dialer a client built from cfg must use to +// reach a sandbox data-plane port. It is the ws:// sibling of RoundTripperFor +// and MUST stay the only place that knows how: same gateway target, same +// SafeDialControlForPolicy guard, same inbound-token registry. +func (p *SandboxGatewayTransportPool) WebsocketDialerFor(cfg *Config) *WebsocketDialer { + if p == nil { + return nil + } + gatewayURL, _ := gatewayEndpointFor(cfg) + d := &WebsocketDialer{ + tokens: p.inboundTokens, + policy: p.policy, + scheme: "wss", + sandboxDomain: sandboxDomainFor(cfg), + } + if host, port, scheme, ok := parseProxyURL(gatewayURL); ok { + d.target = net.JoinHostPort(host, strconv.Itoa(port)) + if scheme == "http" { + d.scheme = "ws" + } + } + return d +} + +// sandboxDomainFor reads the active provider's sandbox routing domain, +// mirroring gatewayEndpointFor's per-provider read: a stale sub-struct left +// behind by an earlier provider switch must not route today's traffic. +func sandboxDomainFor(cfg *Config) string { + if cfg == nil { + return "" + } + switch cfg.Type { + case SandboxTypeE2B: + if domain := strings.TrimSpace(cfg.E2BSandboxDomain); domain != "" { + return domain + } + // Same fallback go-e2b applies when ClientConfig.SandboxDomain is + // empty. Cube cannot do this: its domain is required and has no SDK + // default. + return DefaultE2BSandboxDomain + default: + return strings.TrimSpace(cfg.CubeSandboxDomain) + } +} + +// Dial opens a WebSocket to port/path of sandboxID. +// +// extraHeader carries credentials the caller owns — for the desktop that is +// Authorization: Basic for websockify, and the handle's inbound traffic token +// when the registry has not been written yet (Cube Create/Connect historically +// skipped that Put). The registry fills the inbound header only if missing. +// +// The negotiated subprotocol is pinned to "binary" and verified after the +// handshake. If an upstream ever selected base64, the bytes on the sandbox +// side would be base64 text and every byte offset the RFB parser computes +// would be wrong — silently, and only under load. +func (d *WebsocketDialer) Dial( + ctx context.Context, + sandboxID string, + port int, + path string, + extraHeader http.Header, +) (*websocket.Conn, *http.Response, error) { + if d == nil { + return nil, nil, errors.New("sandbox: websocket dialer is not configured") + } + sandboxID = strings.TrimSpace(sandboxID) + if sandboxID == "" { + return nil, nil, errors.New("sandbox: websocket dial requires a sandbox ID") + } + if d.sandboxDomain == "" { + return nil, nil, errors.New( + "sandbox: websocket dial requires a configured sandbox domain") + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + authority := fmt.Sprintf("%d-%s.%s", port, sandboxID, d.sandboxDomain) + target := &url.URL{Scheme: d.scheme, Host: authority, Path: path} + + header := http.Header{} + for key, values := range extraHeader { + for _, value := range values { + header.Add(key, value) + } + } + // Same rule as gatewaySplitTransport.withInboundToken: a caller-supplied + // header (the handle's traffic token on DialDesktop) wins over a stale + // registry copy. Fill only when missing so Cube/E2B HTTP and WS agree. + if header.Get(InboundTokenHeader) == "" && d.tokens != nil { + if token := d.tokens.Get(sandboxID); token != "" { + header.Set(InboundTokenHeader, token) + } + } + + netDialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + Control: SafeDialControlForPolicy(d.policy), + } + dialer := &websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + // Only "binary" — see the doc comment. + Subprotocols: []string{"binary"}, + NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Ignore addr and dial the gateway, exactly as + // newGatewayDataTransportWithPolicy does for HTTP. + if d.target != "" { + addr = d.target + } + return netDialer.DialContext(ctx, network, addr) + }, + } + dialer.NetDialTLSContext = nil // let the TLS handshake use NetDialContext + + conn, resp, err := dialer.DialContext(ctx, target.String(), header) + if err != nil { + return nil, resp, fmt.Errorf("sandbox: dial %s: %w", authority, err) + } + if sub := conn.Subprotocol(); sub != "binary" { + _ = conn.Close() + return nil, resp, fmt.Errorf( + "sandbox: upstream selected subprotocol %q, want binary", sub) + } + return conn, resp, nil +} diff --git a/internal/sandbox/websocket_dialer_test.go b/internal/sandbox/websocket_dialer_test.go new file mode 100644 index 000000000..8d07329c8 --- /dev/null +++ b/internal/sandbox/websocket_dialer_test.go @@ -0,0 +1,258 @@ +package sandbox + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + cubesandbox "github.com/tencentcloud/CubeSandbox/sdk/go" +) + +// desktopEchoServer is a websockify stand-in: it records the handshake +// request and accepts the upgrade. +func desktopEchoServer(t *testing.T, seen *http.Request) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{ + CheckOrigin: func(*http.Request) bool { return true }, + Subprotocols: []string{"binary"}, + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *seen = *r.Clone(r.Context()) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + _, _, _ = conn.NextReader() + })) +} + +func TestWebsocketDialerAttachesInboundTokenAndExtraHeaders(t *testing.T) { + var seen http.Request + srv := desktopEchoServer(t, &seen) + defer srv.Close() + + pool := NewSandboxGatewayTransportPoolWithPolicy(nil, OutboundURLPolicy{AllowPrivate: true}) + pool.InboundTokens().Put("sbx-1", "tok-abc") + + cfg := &Config{ + Type: SandboxTypeCube, + CubeProxyURL: srv.URL, + CubeSandboxDomain: "cube.app", + AllowPrivateEndpoints: true, + } + dialer := pool.WebsocketDialerFor(cfg) + require.NotNil(t, dialer) + + extra := http.Header{} + extra.Set("Authorization", "Basic dGVzdDp0ZXN0") + + conn, resp, err := dialer.Dial(context.Background(), "sbx-1", 6080, "/websockify", extra) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + + // Gateway credential, injected from THIS pool's registry. + require.Equal(t, "tok-abc", seen.Header.Get(InboundTokenHeader)) + // websockify credential, passed through from the caller. + require.Equal(t, "Basic dGVzdDp0ZXN0", seen.Header.Get("Authorization")) + // Host header is what the gateway routes on. + require.Equal(t, "6080-sbx-1.cube.app", seen.Host) + require.Equal(t, "/websockify", seen.URL.Path) + // binary is the only acceptable subprotocol: base64 would turn the RFB + // byte stream into text and desync the handshake counter in Task 9. + require.Equal(t, "binary", conn.Subprotocol()) +} + +func TestWebsocketDialerUsesItsOwnPoolRegistry(t *testing.T) { + // The process runs two pools with two registries. A dialer must read the + // one its own pool owns; reading the other yields an empty token and a + // gateway 401 that looks exactly like the replica-affinity bug. + var seen http.Request + srv := desktopEchoServer(t, &seen) + defer srv.Close() + + publicPool := NewSandboxGatewayTransportPool(nil) + privatePool := NewSandboxGatewayTransportPoolWithPolicy(nil, OutboundURLPolicy{AllowPrivate: true}) + publicPool.InboundTokens().Put("sbx-1", "public-token") + privatePool.InboundTokens().Put("sbx-1", "private-token") + + cfg := &Config{ + Type: SandboxTypeCube, + CubeProxyURL: srv.URL, + CubeSandboxDomain: "cube.app", + AllowPrivateEndpoints: true, + } + conn, _, err := privatePool.WebsocketDialerFor(cfg). + Dial(context.Background(), "sbx-1", 6080, "/websockify", nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + require.Equal(t, "private-token", seen.Header.Get(InboundTokenHeader)) +} + +func TestWebsocketDialerBlocksPrivateAddressWhenPolicyForbids(t *testing.T) { + // httptest listens on 127.0.0.1, which the default policy must refuse. + var seen http.Request + srv := desktopEchoServer(t, &seen) + defer srv.Close() + + pool := NewSandboxGatewayTransportPool(nil) // DefaultOutboundURLPolicy + pool.InboundTokens().Put("sbx-1", "tok") + + cfg := &Config{ + Type: SandboxTypeCube, + CubeProxyURL: srv.URL, + CubeSandboxDomain: "cube.app", + } + _, _, err := pool.WebsocketDialerFor(cfg). + Dial(context.Background(), "sbx-1", 6080, "/websockify", nil) + require.Error(t, err, "SSRF guard must refuse a loopback gateway under the default policy") +} + +func TestWebsocketDialerRejectsEmptySandboxDomain(t *testing.T) { + pool := NewSandboxGatewayTransportPoolWithPolicy(nil, OutboundURLPolicy{AllowPrivate: true}) + cfg := &Config{Type: SandboxTypeCube, CubeProxyURL: "http://127.0.0.1:1", AllowPrivateEndpoints: true} + _, _, err := pool.WebsocketDialerFor(cfg). + Dial(context.Background(), "sbx-1", 6080, "/websockify", nil) + require.Error(t, err) + require.Contains(t, strings.ToLower(err.Error()), "domain") +} + +func TestWebsocketDialerE2BEmptyDomainUsesSDKDefault(t *testing.T) { + // E2B Cloud configs may omit sandbox_domain: go-e2b fills e2b.app for + // envd, and the desktop Host header has to match that same default. + var seen http.Request + srv := desktopEchoServer(t, &seen) + defer srv.Close() + + pool := NewSandboxGatewayTransportPoolWithPolicy(nil, OutboundURLPolicy{AllowPrivate: true}) + pool.InboundTokens().Put("sbx-1", "tok") + + cfg := &Config{ + Type: SandboxTypeE2B, + E2BProxyURL: srv.URL, + AllowPrivateEndpoints: true, + } + conn, _, err := pool.WebsocketDialerFor(cfg). + Dial(context.Background(), "sbx-1", 6080, "/websockify", nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.Equal(t, "6080-sbx-1.e2b.app", seen.Host) +} + +func TestWebsocketDialerE2BConfiguredDomainWinsOverDefault(t *testing.T) { + var seen http.Request + srv := desktopEchoServer(t, &seen) + defer srv.Close() + + pool := NewSandboxGatewayTransportPoolWithPolicy(nil, OutboundURLPolicy{AllowPrivate: true}) + pool.InboundTokens().Put("sbx-1", "tok") + + cfg := &Config{ + Type: SandboxTypeE2B, + E2BProxyURL: srv.URL, + E2BSandboxDomain: "sandbox.internal", + AllowPrivateEndpoints: true, + } + conn, _, err := pool.WebsocketDialerFor(cfg). + Dial(context.Background(), "sbx-1", 6080, "/websockify", nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.Equal(t, "6080-sbx-1.sandbox.internal", seen.Host) +} + +func TestE2BDialDesktopWithoutPoolIsUnsupported(t *testing.T) { + // NewE2BRemoteClientWithTransport has no pool, so it has no dialer. It + // must say "unsupported" rather than nil-panic. + client, err := NewE2BRemoteClientWithTransport(&Config{ + Type: SandboxTypeE2B, E2BAPIKey: "k", E2BTemplate: "tpl", + }, nil) + require.NoError(t, err) + + _, derr := client.DialDesktop(context.Background(), nil, RemoteDesktopOptions{}) + require.Error(t, derr) + var remoteErr *RemoteError + require.ErrorAs(t, derr, &remoteErr) + require.Equal(t, RemoteErrorKindUnsupported, remoteErr.Kind) + require.Equal(t, "DialDesktop", remoteErr.Op) +} + +func TestCubeDialDesktopReachesWebsockifyPath(t *testing.T) { + var seen http.Request + srv := desktopEchoServer(t, &seen) + defer srv.Close() + + pool := NewSandboxGatewayTransportPoolWithPolicy(nil, OutboundURLPolicy{AllowPrivate: true}) + pool.InboundTokens().Put("sbx-9", "tok-9") + + cfg := &Config{ + Type: SandboxTypeCube, + CubeAPIURL: "http://127.0.0.1:33000", + CubeProxyURL: srv.URL, + CubeSandboxDomain: "cube.app", + AllowPrivateEndpoints: true, + } + client, err := NewCubeRemoteClientWithPool(cfg, pool) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + conn, err := client.DialDesktop(ctx, + &cubeRemoteHandle{sb: &cubesandbox.Sandbox{SandboxID: "sbx-9"}}, + RemoteDesktopOptions{BasicAuthUser: "weknora", BasicAuthPassword: "s3cret"}) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + require.Equal(t, "/websockify", seen.URL.Path) + require.Equal(t, "6080-sbx-9.cube.app", seen.Host) + require.Equal(t, "tok-9", seen.Header.Get(InboundTokenHeader)) + user, pass, ok := seen.BasicAuth() + require.True(t, ok) + require.Equal(t, "weknora", user) + require.Equal(t, "s3cret", pass) +} + +func TestCubeDialDesktopUsesHandleTokenWhenRegistryEmpty(t *testing.T) { + // Cube exec/PTY attach the traffic token from the SDK sandbox object, so + // the terminal keeps working after a restart. DialDesktop used to read + // only the pool registry, which Cube Create/Connect never wrote — CubeProxy + // then 401'd the websockify handshake while envd still answered. + var seen http.Request + srv := desktopEchoServer(t, &seen) + defer srv.Close() + + pool := NewSandboxGatewayTransportPoolWithPolicy(nil, OutboundURLPolicy{AllowPrivate: true}) + + cfg := &Config{ + Type: SandboxTypeCube, + CubeAPIURL: "http://127.0.0.1:33000", + CubeProxyURL: srv.URL, + CubeSandboxDomain: "cube.app", + AllowPrivateEndpoints: true, + } + client, err := NewCubeRemoteClientWithPool(cfg, pool) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + conn, err := client.DialDesktop(ctx, + &cubeRemoteHandle{sb: &cubesandbox.Sandbox{ + SandboxID: "sbx-9", + TrafficAccessToken: "from-handle", + }}, + RemoteDesktopOptions{BasicAuthUser: "weknora", BasicAuthPassword: "s3cret"}) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + require.Equal(t, "from-handle", seen.Header.Get(InboundTokenHeader), + "websockify dial must carry the handle's traffic token even when the registry is empty") + user, pass, ok := seen.BasicAuth() + require.True(t, ok) + require.Equal(t, "weknora", user) + require.Equal(t, "s3cret", pass) +} diff --git a/internal/types/config_redaction_sandbox_test.go b/internal/types/config_redaction_sandbox_test.go index bf4382716..cb88ec9a3 100644 --- a/internal/types/config_redaction_sandbox_test.go +++ b/internal/types/config_redaction_sandbox_test.go @@ -236,6 +236,21 @@ func TestMergeSandboxConfigForUpdateNilIncoming(t *testing.T) { require.Nil(t, MergeSandboxConfigForUpdate(nil, &TenantSandboxConfig{})) } +func TestMergeSandboxConfigForUpdatePreservesDesktopEnabled(t *testing.T) { + incoming := &TenantSandboxConfig{SandboxType: "cube", DesktopEnabled: true} + existing := &TenantSandboxConfig{SandboxType: "cube"} + merged := MergeSandboxConfigForUpdate(incoming, existing) + require.True(t, merged.DesktopEnabled) +} + +func TestMergeSandboxConfigForUpdateClearsDesktopEnabled(t *testing.T) { + incoming := &TenantSandboxConfig{SandboxType: "cube", DesktopEnabled: false} + existing := &TenantSandboxConfig{SandboxType: "cube", DesktopEnabled: true} + merged := MergeSandboxConfigForUpdate(incoming, existing) + require.False(t, merged.DesktopEnabled, + "selecting the CLI template must persist desktop_enabled=false, not keep a stale true") +} + func TestMergeSandboxConfigForUpdatePreservesSkillImage(t *testing.T) { existing := &TenantSandboxConfig{ E2B: &E2BSandboxConfig{APIKey: "old-e2b"}, diff --git a/internal/types/tenant.go b/internal/types/tenant.go index 914f0eae6..d86873dd6 100644 --- a/internal/types/tenant.go +++ b/internal/types/tenant.go @@ -633,12 +633,20 @@ type TenantSandboxConfig struct { // program's built-in default. DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"` - // TerminalIdleDisconnectSec is how long an interactive terminal may go - // without keystrokes or PTY output before WeKnora closes the connection - // so the sandbox can pause on its provider TTL. 0 uses the built-in - // default (15 minutes). Not an identity field. + // TerminalIdleDisconnectSec is how long an interactive terminal or + // desktop may go without user activity before WeKnora closes the + // connection so the sandbox can pause on its provider TTL. Terminal + // counts keystrokes and PTY output; desktop counts mouse and keyboard. + // 0 uses the built-in default (15 minutes). Not an identity field. TerminalIdleDisconnectSec int `json:"terminal_idle_disconnect_sec,omitempty"` + // DesktopEnabled declares that this config's base template is a desktop + // image (XFCE + x11vnc + websockify). It is NOT a second template: a + // config has exactly one boot target, and skill snapshots stack on top of + // this base generation after generation. Flipping it changes the base, so + // any installed skills must be rebuilt from the new one. + DesktopEnabled bool `json:"desktop_enabled,omitempty"` + // AllowPrivateEndpoints permits this workspace config to reach RFC1918 or // loopback cluster endpoints. Link-local/cloud-metadata addresses remain // blocked. It is explicit in the UI instead of hidden in process env. diff --git a/scripts/build_images.sh b/scripts/build_images.sh index c436c5549..5add7ba45 100755 --- a/scripts/build_images.sh +++ b/scripts/build_images.sh @@ -220,6 +220,7 @@ build_sandbox_image() { # 而 Docker 后端只在本地缺失时才去拉,本地不打这个标签就等于白构建。 docker build \ --platform $PLATFORM \ + --build-arg TARGETPLATFORM=$PLATFORM \ -f docker/Dockerfile.sandbox \ --target sandbox \ -t wechatopenai/weknora-sandbox:latest \ @@ -238,17 +239,54 @@ build_sandbox_image() { docker build \ --platform linux/amd64 \ + --build-arg TARGETPLATFORM=linux/amd64 \ + --build-arg TARGETARCH=amd64 \ -f docker/Dockerfile.sandbox \ --target cube \ -t wechatopenai/weknora-sandbox:latest-cube \ -t wechatopenai/weknora-sandbox:main-cube \ . + if [ $? -ne 0 ]; then + log_error "沙箱镜像 Cube 变体构建失败" + return 1 + fi + + # Desktop variant: XFCE + x11vnc + websockify. Tagged for E2B template + # builds; the Docker backend does not consume this image yet. + log_info "构建沙箱镜像桌面变体 (weknora-sandbox:main-desktop)..." + + docker build \ + --platform $PLATFORM \ + --build-arg TARGETPLATFORM=$PLATFORM \ + -f docker/Dockerfile.sandbox \ + --target desktop \ + -t wechatopenai/weknora-sandbox:latest-desktop \ + -t wechatopenai/weknora-sandbox:main-desktop \ + . + + if [ $? -ne 0 ]; then + log_error "沙箱镜像桌面变体构建失败" + return 1 + fi + + log_info "构建沙箱镜像桌面 Cube 变体 (weknora-sandbox:main-desktop-cube)..." + + docker build \ + --platform linux/amd64 \ + --build-arg TARGETPLATFORM=linux/amd64 \ + --build-arg TARGETARCH=amd64 \ + -f docker/Dockerfile.sandbox \ + --target desktop-cube \ + -t wechatopenai/weknora-sandbox:latest-desktop-cube \ + -t wechatopenai/weknora-sandbox:main-desktop-cube \ + . + if [ $? -eq 0 ]; then log_success "沙箱镜像构建成功" return 0 else - log_error "沙箱镜像 Cube 变体构建失败" + log_error "沙箱镜像桌面 Cube 变体构建失败" return 1 fi } @@ -337,8 +375,12 @@ clean_images() { docker rmi wechatopenai/weknora-ui:latest 2>/dev/null || true docker rmi wechatopenai/weknora-sandbox:latest 2>/dev/null || true docker rmi wechatopenai/weknora-sandbox:latest-cube 2>/dev/null || true + docker rmi wechatopenai/weknora-sandbox:latest-desktop 2>/dev/null || true + docker rmi wechatopenai/weknora-sandbox:latest-desktop-cube 2>/dev/null || true docker rmi wechatopenai/weknora-sandbox:main 2>/dev/null || true docker rmi wechatopenai/weknora-sandbox:main-cube 2>/dev/null || true + docker rmi wechatopenai/weknora-sandbox:main-desktop 2>/dev/null || true + docker rmi wechatopenai/weknora-sandbox:main-desktop-cube 2>/dev/null || true docker image prune -f diff --git a/website-docs/01-getting-started/01-introduction.md b/website-docs/01-getting-started/01-introduction.md index 9019b1485..b76b98448 100644 --- a/website-docs/01-getting-started/01-introduction.md +++ b/website-docs/01-getting-started/01-introduction.md @@ -125,7 +125,7 @@ flowchart TB | docreader | Python / gRPC | `docreader/` | 50051(仅容器网络内) | 文件转 Markdown、网页抓取、图片提取 | | postgres | ParadeDB(PostgreSQL 17 + BM25/向量扩展) | 镜像 `paradedb/paradedb` | 5432 | 主数据库 + 默认混合检索引擎(`RETRIEVE_DRIVER=postgres`) | | redis | Redis 7 | — | 6379 | 流管理(SSE 恢复)、Asynq 任务队列 | -| sandbox | Python 3.11 + Node 20 | `docker/Dockerfile.sandbox` | — | Agent Skills 的会话沙箱容器镜像 | +| sandbox | Python 3.12 + Node 20 | `docker/Dockerfile.sandbox` | — | Agent Skills 的会话沙箱容器镜像 | | 可选:qdrant / milvus / weaviate / doris | — | `docker-compose.yml` profiles | 6334 / 19530 / 9035 / 9030 | 替代或叠加的向量检索引擎(`RETRIEVE_DRIVER`) | | 可选:opensearch | — | 仅 `docker-compose.dev.yml` | 9200 | 开发环境用;生产需自备集群 | | 可选:elasticsearch / tencent_vectordb | — | 不随 compose 提供 | — | 代码支持,但需自行部署后用 `RETRIEVE_DRIVER` 接入 | diff --git a/website-docs/01-getting-started/02-installation.md b/website-docs/01-getting-started/02-installation.md index ef375ab32..35dbf5918 100644 --- a/website-docs/01-getting-started/02-installation.md +++ b/website-docs/01-getting-started/02-installation.md @@ -130,7 +130,7 @@ make dev-logs / dev-status / dev-stop / dev-restart | `docker/Dockerfile.app` | `wechatopenai/weknora-app` | 两阶段:`golang:1.26-bookworm` 编译(`make build-prod`,默认 `WITH_ANYDOC=1` 链接进程内 office 解析引擎,注入版本信息,预下载 DuckDB 扩展 `cmd/download/duckdb`)→ `debian:12.12-slim` 运行层(含 `migrate` 迁移工具、python3/node/uvx(供 stdio MCP 与 Skills 使用)、ffmpeg(ASR)、gosu 降权)。入口 `scripts/docker-entrypoint.sh`:修复挂载目录属主;若挂载了 docker.sock,按 socket GID 把 appuser 加入对应组(compose `group_add` 在 gosu 后无效),再以 appuser 运行 `./WeKnora`。`EXPOSE 8080` | | `docker/Dockerfile.docreader` | `wechatopenai/weknora-docreader` | Python 3.10 + uv 依赖锁定;生成 protobuf;运行层安装 LibreOffice、OpenJDK 17、antiword、Playwright(webkit)与 `grpc_health_probe`。轻量版不含 PaddleOCR。`EXPOSE 50051`。支持 `APT_MIRROR` 构建参数 | | `docker/Dockerfile.odl-hybrid` | `weknora-odl-hybrid:local` | 安装 `opendataloader-pdf[hybrid]`(Docling),监听 5002,默认 `--no-ocr`;仅本地构建不发布 | -| `docker/Dockerfile.sandbox` | `wechatopenai/weknora-sandbox` | Python 3.11-slim + Node 20 + jq,默认 `root` 执行,保留 `user`(UID 1000) 供显式选择,Agent Skills 会话沙箱镜像 | +| `docker/Dockerfile.sandbox` | `wechatopenai/weknora-sandbox` | Python 3.12-slim + Node 20 + jq,默认 `root` 执行,保留 `user`(UID 1000) 供显式选择,Agent Skills 会话沙箱镜像 | | `frontend/Dockerfile` | `wechatopenai/weknora-ui` | 两阶段:digest 锁定的 `node:24-bookworm-slim`(`$BUILDPLATFORM`,避免多架构 CI 用 QEMU 跑 Vite)内 `npm ci` + `npm run build`(`VITE_IS_DOCKER` / `VITE_FRONTEND_COMMIT`),可选 `NPM_REGISTRY` / `NODE_MAX_OLD_SPACE_SIZE`;运行层为按 digest 固定的 `nginx:1.30.3-alpine`(兼容 CentOS 7 旧内核)。无需宿主机预构建 `dist/` | 从源码构建全部镜像: