diff --git a/README.md b/README.md index a685f0c..18700a8 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ It lets you continue the same coding session from any Sandpi client. The Web app and first-party native clients for iOS, iPadOS, Android, OpenHarmony, Windows and macOS use the same Sandpi product UI and API. Every -client stays lightweight: the coding-agent harness, terminal, files and shared -Playwright browser live in the cloud, alongside a persistent Workspace Volume. +client stays lightweight: the coding-agent harness and terminal run in the +cloud, with files stored on a persistent Workspace Volume. You can close your laptop, switch devices or disconnect a client without ending your coding session. @@ -33,10 +33,6 @@ Codex is the first supported coding agent.
A Codex Session alongside its persistent Workspace files.
- - -A human and coding agent working with the same cloud Browser.
- Environment-scoped runtime, Workspace, agent and security settings.
@@ -51,7 +47,6 @@ Codex is the first supported coding agent. | Durable sessions | Native session state and the Workspace live outside the browser. Refreshes, client disconnects and runtime recovery do not erase the session. | | Focused isolation | Create one Environment per project, task or concern. Each gets its own Sandbox, Workspace, coding-agent account, network policy and credentials. | | Multiple coding plans | Connect different Environments to different Codex/ChatGPT accounts, or keep work separated while using the same account. | -| Shared browser debugging | A human and coding agent take exclusive turns using one persistent browser profile. Take control is the only manual-input path; human control is a real headed browser without Playwright or an exposed CDP endpoint. | | Controlled outbound access | Restrict sandbox egress by destination and inject supported credentials only into matching traffic, instead of placing service secrets in the repository or browser. | | Workspace protection | Create manual or scheduled Workspace backups with retention and restore them through Sandbox0 Volume snapshots. | | Encrypted persisted state | Sandbox0 encrypts persisted Environment rootfs checkpoint objects and default S0FS Workspace Volume objects at the application layer before object storage. | @@ -64,7 +59,7 @@ Environment ├── Sandbox and persistent Workspace Volume ├── one native coding-agent harness and provider account ├── network policy and egress credentials -├── runtime resources, terminal, shared Browser and metrics +├── runtime resources, terminal and metrics ├── durable Automation Schedules and Webhooks └── many native coding-agent Sessions ``` @@ -120,9 +115,9 @@ coding-agent configuration into a new or existing Sandpi Environment. See explicit refresh feedback; fast source, GitHub-like Markdown and CSV views; image, audio, video, PDF and PPTX previews; on-demand Monaco editing; and Git changes -- Shared Browser with exclusive human/agent control, a full-size adaptive - read-only agent view, and one Take control path into the interactive headed - browser +- Version-matched Playwright Agent Skill materialization when the Environment + image provides the official `playwright-cli`; Sandpi does not wrap it in a + second automation protocol - Environment terminal, runtime metrics, configurable idle pause, and manual Sandbox pause/restart recovery controls - Environment Schedules with one-time or human-friendly recurring timing, @@ -150,10 +145,7 @@ harnesses and clients can be added as independent integrations. - A Sandbox0 deployment API key with Sandbox and Volume access plus `credentialsource:read`, `credentialsource:write` and `credentialsource:delete` -- A current Sandbox0 `coding-agent` template with the official Playwright CLI, - Chromium, TigerVNC and Openbox. Sandpi retains an Xvfb/x11vnc compatibility - path for older template images. At least 2 GiB of Sandbox memory is - recommended for interactive Browser work alongside a coding agent. +- A current Sandbox0 `coding-agent` template with the official Playwright CLI - Docker Engine with Compose v2 for the container workflow Optional subscription quota mode also requires `usage:read`. @@ -219,7 +211,7 @@ For Kubernetes deployment, see ## OpenAPI contract The generated [OpenAPI 3.0.3 contract](./openapi.yaml) covers Sandpi's HTTP, -SSE, WebSocket and embedded Browser surfaces. Generate and verify it with: +SSE and WebSocket surfaces. Generate and verify it with: ```bash npm run openapi:generate @@ -266,22 +258,18 @@ Sandpi server ───────── PostgreSQL Sandbox0 ├── Sandbox + native Codex app-server ├── persistent Workspace Volume - ├── exclusive Playwright or headed Browser transport + shared profile + ├── official Playwright CLI and version-matched Agent Skill ├── terminal and runtime metrics ├── network policy and credential injection └── Workspace snapshots ``` - Sandpi clients talk only to Sandpi. They receive neither the Sandbox0 - deployment API key nor a direct Sandbox0 endpoint. For the Web app, Sandpi - authenticates and proxies the agent-owned Playwright Dashboard or the - human-owned VNC stream. The Dashboard is only a read-only screencast renderer: - Sandpi removes its controls and rejects client-side navigation, tab mutation, - input, capture and recording requests. Both transports use one persistent - profile, but only one owner is active: taking control stops Playwright and - launches a headed browser without CDP; returning control stops that browser - before Playwright resumes. Environment-local URLs shown in chat remain inert - until the user takes control. + deployment API key nor a direct Sandbox0 endpoint. Sandpi materializes the + official Playwright Agent Skill when `playwright-cli` is available, but it + does not start a browser, manage a profile, proxy a Dashboard or VNC stream, + or define a second browser-control protocol. Environment-local URLs shown in + chat remain inert until a dedicated Preview surface exists. - Sandpi uses Sandbox0 through the official JavaScript SDK; it does not read a Sandbox0 database, internal metering endpoint or ClickHouse credential. - Sandbox0 owns Sandbox lifecycle, Volumes, network enforcement, credential @@ -320,11 +308,9 @@ Sandbox0 - Sessions inside one Environment share one mutable Workspace and harness account. They are not isolated checkouts. Use separate Environments when work must not affect each other. -- Interactive Browser takeover requires the current Sandbox0 `coding-agent` - image. Older compatible images can still use scaled Xvfb/x11vnc takeover; - recreate the Environment with v0.4 or newer for a human desktop that follows - the full Browser panel size. Agent-only Playwright remains available on older - compatible images. +- Sandpi currently exposes neither an Environment Browser nor an application + Preview tab. Playwright remains available to the coding agent when the + Environment separately provides a compatible browser executable. - Built-in administrator mode is for a trusted single-user deployment. Use OIDC and a proper network/TLS boundary for public or multi-user deployments. - The `/api/v1` contract is versioned but may still change between pre-1.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index cf2c8d9..63d84ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ Sandpi 客户端继续同一个 coding session。 Web 应用与 iOS、iPadOS、Android、OpenHarmony、Windows 和 macOS 第一方原生客户端 共用同一套 Sandpi 产品 UI 和 API。所有客户端都保持轻量:coding-agent harness、 -终端、文件和共享 Playwright 浏览器都在云端运行,并挂载持久化 Workspace Volume。 +终端运行在云端,文件则保存在持久化 Workspace Volume 中。 你可以关闭电脑、切换设备或断开客户端,而不会结束 coding session。 目前第一个支持的 coding agent 是 Codex。 @@ -31,10 +31,6 @@ Web 应用与 iOS、iPadOS、Android、OpenHarmony、Windows 和 macOS 第一方Codex Session 与持久化 Workspace 文件并排显示。
- - -Human 与 coding agent 使用同一个云端 Browser。
- Environment 级 runtime、Workspace、agent 和安全设置。
@@ -49,7 +45,6 @@ Web 应用与 iOS、iPadOS、Android、OpenHarmony、Windows 和 macOS 第一方 | 持久化 Session | 原生 Session 状态和 Workspace 不在浏览器里。页面刷新、客户端断线和 runtime 恢复都不会让 Session 消失。 | | 更专注的隔离 | 可以按项目、任务或关注点创建独立 Environment。每个 Environment 都有自己的 Sandbox、Workspace、coding-agent 账号、网络策略和凭证。 | | 多个 coding plan | 不同 Environment 可以连接不同的 Codex/ChatGPT 订阅账号;即使使用同一个账号,也可以把不同工作彼此隔离。 | -| 人与 Agent 共享浏览器 | Human 和 coding agent 使用同一个官方 Playwright browser session,共享 tab 和登录 profile。 | | 可控的出站访问 | 按目标限制 Sandbox 出站流量,并只向匹配的请求注入受支持的凭证,避免把服务密钥放进仓库或浏览器。 | | Workspace 防丢失 | 通过 Sandbox0 Volume snapshot 手动或定时备份 Workspace,设置保留数量并按需恢复。 | | 持久化数据加密 | Sandbox0 在写入对象存储前,对 Environment rootfs checkpoint 对象和默认 S0FS Workspace Volume 对象做应用层加密。 | @@ -62,7 +57,7 @@ Environment ├── Sandbox 和持久化 Workspace Volume ├── 一个原生 coding-agent harness 和 provider 账号 ├── 网络策略和出站凭证 -├── runtime 资源、终端、共享 Browser 和指标 +├── runtime 资源、终端和指标 ├── 持久化 Automation Schedules 和 Webhooks └── 多个原生 coding-agent Session ``` @@ -109,8 +104,8 @@ Environment。如果多个 Session 本来就应该共享文件、工具和执行 - 以预览为主的实时 Workspace 文件浏览器,支持可调整宽度、可折叠的文件树和明确的 刷新反馈,提供快速源码、GitHub 风格 Markdown、CSV 视图,图片、音频、视频、 PDF、PPTX 预览,按需加载的 Monaco 编辑器,以及 Git 变更 -- Human 与 coding agent 共用的官方 Playwright Browser,支持多 Tab、加载反馈以及 - 本地持久化的桌面自适应、响应式和移动端 viewport 模式 +- 当 Environment image 提供官方 `playwright-cli` 时,按其版本 materialize + Playwright Agent Skill;Sandpi 不额外定义一套自动化协议 - Environment 终端、runtime 指标、可配置 idle pause,以及用于故障恢复的 Sandbox 手动 pause/restart 控制 - Environment Schedules,支持一次性或易读的周期设置、高级 Cron、IANA @@ -135,8 +130,7 @@ Sandpi 仍处于 pre-1.0 阶段,目前只实现了 Codex harness。Web 应用 - 具备 Sandbox、Volume 访问权限以及 `credentialsource:read`、 `credentialsource:write`、`credentialsource:delete` 权限的 Sandbox0 deployment API key -- 一个包含官方 Playwright CLI 和 Chromium 的当前 Sandbox0 - `coding-agent` template +- 一个包含官方 Playwright CLI 的当前 Sandbox0 `coding-agent` template - 使用容器流程时需要 Docker Engine 和 Compose v2 可选的订阅 quota 模式还需要 `usage:read`。 @@ -197,8 +191,8 @@ Kubernetes 部署请参阅 ## OpenAPI 契约 -生成的 [OpenAPI 3.0.3 契约](./openapi.yaml) 覆盖 Sandpi 的 HTTP、SSE、 -WebSocket 和内置 Browser 接口。使用以下命令生成并校验: +生成的 [OpenAPI 3.0.3 契约](./openapi.yaml) 覆盖 Sandpi 的 HTTP、SSE 和 +WebSocket 接口。使用以下命令生成并校验: ```bash npm run openapi:generate @@ -241,17 +235,17 @@ Sandpi server ───────── PostgreSQL Sandbox0 ├── Sandbox + 原生 Codex app-server ├── 持久化 Workspace Volume - ├── 官方 Playwright CLI、Dashboard 和共享 profile + ├── 官方 Playwright CLI 和版本匹配的 Agent Skill ├── 终端和 runtime 指标 ├── 网络策略和凭证注入 └── Workspace snapshot ``` - Sandpi 客户端只与 Sandpi 通信,不会收到 Sandbox0 deployment API key,也不会直接 - 访问 Sandbox0 endpoint。对于 Web 应用,Sandpi 会对官方 Playwright Dashboard 的 - HTTP 和 WebSocket 流量进行鉴权与代理。内置 tab 与 agent 共享同一个 Playwright - profile:human 可以在其中完成交互式登录,然后把同一份已登录 Browser 交还给 - agent 继续操作。Browser 中的 loopback URL 解析到 Environment sandbox 内部。 + 访问 Sandbox0 endpoint。当 `playwright-cli` 可用时,Sandpi 会 materialize 官方 + Playwright Agent Skill,但不会启动 browser、管理 profile、代理 Dashboard/VNC, + 也不会定义第二套 browser-control 协议。Environment-local URL 在专用 Preview + surface 实现前保持不可点击。 - Sandpi 只通过官方 JavaScript SDK 使用 Sandbox0,不读取 Sandbox0 数据库、内部 metering endpoint 或 ClickHouse 凭证。 - Sandbox0 负责 Sandbox 生命周期、Volume、网络执行、凭证注入和 usage truth。 @@ -274,8 +268,8 @@ Sandbox0 不会在可能改变网络策略或凭证边界的情况下静默新建替代 Sandbox。 - 同一个 Environment 中的 Session 共享可变 Workspace 和 harness 账号,它们不是互相 隔离的 checkout。工作之间不能互相影响时,请创建不同 Environment。 -- Browser 依赖当前 Sandbox0 `coding-agent` image。旧 Environment 需要重新创建, - 才能获得 Playwright CLI 和 Chromium 依赖。 +- Sandpi 当前既不提供 Environment Browser,也不提供应用 Preview tab。只有在 + Environment 另外提供兼容 browser executable 时,coding agent 才能使用 Playwright。 - 内置管理员模式只适用于受信任的单用户部署。公开或多用户部署应使用 OIDC,并配置 正确的网络与 TLS 边界。 - `/api/v1` 已经版本化,但 pre-1.0 版本之间仍可能调整契约。 diff --git a/docs/architecture/native-session-authority.md b/docs/architecture/native-session-authority.md index 8e6963d..b2a0a7e 100644 --- a/docs/architecture/native-session-authority.md +++ b/docs/architecture/native-session-authority.md @@ -80,8 +80,8 @@ user-owned `/workspace/.agents/skills` tree. Both Skills contain only stable navigation and trust-boundary instructions. `sandpi-environment` loads current product behavior from the public, -application-owned `https://sandpi.ai/llms.txt` and delegates exact Browser -commands to the locally installed Playwright Skill. `sandpi-cli` routes CLI and +application-owned `https://sandpi.ai/llms.txt` and delegates exact Playwright +commands to the locally installed upstream Skill. `sandpi-cli` routes CLI and migration tasks to their canonical repository documentation instead of duplicating commands. Sandpi reconciles these small assets with the server release, while ordinary guidance changes at the linked sources without @@ -220,8 +220,8 @@ exhausted restart window. Recovery ownership remains held while a newly observed attempt races initialization, so a `session is not running` response is reconciled again instead of leaving a terminal event stream idle. -Files, the Web IDE, its watcher, Terminal and Browser are Environment -capabilities rather than Codex capabilities. They enter a shared PostgreSQL +Files, the Web IDE, its watcher and Terminal are Environment capabilities +rather than Codex capabilities. They enter a shared PostgreSQL advisory lock keyed by Environment, which permits concurrent user access while excluding pause, delete and harness recovery. Their warm path executes the requested native operation directly with no health probe. Only a native wake-up @@ -230,11 +230,11 @@ one retry. A Workspace portal repair releases shared admission and owns the exclusive lifecycle lock because rebuilding FUSE can pause the Sandbox. Successful access records a fresh idle window but never changes the credential-hydrated Codex epoch. It therefore cannot start a Supervisor or wait -for app-server initialization. Live Terminal and Browser WebSockets use -protocol ping/pong only to verify transport health. Throttled shared-lock -touches extend an already-running Environment only after Terminal input or -Browser client control traffic; a passive open connection does not defer idle -pause. Neither path can project a paused Sandbox back to running. The UI also +for app-server initialization. The live Terminal WebSocket uses protocol +ping/pong only to verify transport health. Throttled shared-lock touches extend +an already-running Environment only after Terminal input; a passive open +connection does not defer idle pause. This path cannot project a paused Sandbox +back to running. The UI also changes its long-running conversation status after two seconds to explain that an idle checkpoint may be restoring and that Files and Terminal remain independently available. @@ -257,149 +257,27 @@ state, and open documents so changes missed while hidden are not lost. A file operation already initiated by an explicit foreground action may finish after the visibility transition. -## Shared Environment browser - -The Browser has one persistent profile and one active owner. The protected -Sandbox0 AppService is the single owner record: its `SANDPI_BROWSER_OWNER` -environment value is `agent` or `human`, and its monotonically increasing -revision fences restarts and handoffs. Sandpi does not copy Browser ownership -into PostgreSQL or use a Workspace file as a second source of truth. `GET` and -`PUT /api/v1/environments/{environmentId}/browser/control` expose that state -and whether the current runtime supports headed-browser takeover. Sandpi probes -that image capability only when the Browser control surface is first opened and -caches it for the Sandbox runtime generation, so Environment provisioning and -the normal cold-start path do not pay for the check. Older runtimes present a -disabled recreate-required Take control action instead of failing after a -handoff attempt. The wording is deliberately generic: taking control is not -synonymous with finishing a login, and a user may keep control for any -interactive task. - -Agent control embeds the official Playwright Dashboard. Playwright remains -authoritative for automation, pages, tabs, snapshots and interaction; Sandpi -does not define an MCP browser tool, CDP contract, general automation RPC or -replacement CLI. Sandpi invokes only the official `playwright-cli`. Codex -Workspace preparation materializes the bundled Agent Skill once per installed -Playwright package version and puts a Sandpi-managed `playwright-cli` guard -first on the Supervisor `PATH`. The AppService starts the Dashboard, then -prewarms the persistent session after the port is listening so Browser assets -and Chromium startup overlap. It periodically recovers an ordinary browser -exit without another Sandbox0 control API call. - -Both owners use `/workspace/.sandpi/browser/profile`. On first use, the agent -transport moves a compatible legacy Playwright persistent profile to that -fixed path. The profile contains cookies and site storage and is part of the -Workspace Volume and its backups; Sandpi stores none of that data or a parallel -page model in PostgreSQL. Live tabs are best effort across a process handoff, -so the agent always lists tabs and takes a new snapshot after control returns. - -Human control replaces the same AppService rather than starting a second -service. Its lazy process first closes the Playwright daemon and validates the -profile lock, then runs one headed browser as the unprivileged -`sandbox-browser` user on a TigerVNC X server and Openbox. It prefers an -operator-supplied `google-chrome-stable` binary when one exists and otherwise -uses the bundled Chrome for Testing executable. Chrome starts maximized and -keeps that window-manager state as the X desktop changes size. The bundled -browser runs under the image's normal Linux browser-sandbox setup; Sandpi does -not force `--no-sandbox`. The launch has no headless, automation or -remote-debugging flag and exposes no CDP port. -TigerVNC binds only to loopback and accepts the noVNC client's requested panel -size; a small WebSocket-to-TCP bridge publishes `/vnc` through the existing -protected AppService. The client keeps viewport scaling enabled as a fallback -for older images, where Sandpi uses the legacy fixed-size Xvfb/x11vnc runtime. -The Web client dynamically loads noVNC only in human mode. This mode is -suitable for sites that require real human interaction, but it cannot promise -that a third-party identity provider will accept every browser build or future -policy. - -Returning control replaces the AppService with the agent command. The human -process receives a graceful stop so Chrome can flush the profile before the -agent transport reopens it. A persistent `human-owner` file is an enforcement -derivative: the supported coding-agent `playwright-cli` wrapper refuses to run -while it exists, including after a Sandbox pause, and the agent transport -removes it only after the authoritative AppService owner has changed. Sandpi's -Browser session and viewport APIs also return conflict while human control is -active. This is a coordination boundary for the supported agent -path, not isolation against a hostile root process; a root process could invoke -the underlying executable or start another browser. A hard adversarial boundary -would require moving the browser into a separately privileged sidecar or -Sandbox. - -Only one browser renderer runs during a handoff. The coding-agent image omits -Playwright's otherwise redundant headless-shell payload. The v0.4 image adds -TigerVNC while temporarily retaining Xvfb/x11vnc for rollout compatibility; -the current human path runs one combined X/VNC server instead of separate Xvfb -and x11vnc processes. Sandpi reuses the existing `ws` transport dependency and -loads the noVNC client on demand. Two GiB or more of Sandbox memory is -recommended when a headed browser and coding agent are used together. - -Sandpi treats the embedded official Dashboard only as a read-only rendering -backend. It starts `playwright-cli show` pinned to the shared `default` session, -projects the current Sandpi theme tokens into the frame and removes the native -session sidebar, tab controls, interaction toolbar, screenshot and recording -actions, browser chrome and omnibox. Sandpi exposes no replacement tab strip, -new-tab action, navigation control or viewport-mode selector. The iframe is -not focusable and does not receive pointer input, so Take control is the only -human-input entry point. - -This boundary is enforced behind the presentation layer as well. The Browser -WebSocket proxy parses Dashboard client requests and forwards only visibility -lifecycle updates plus one initial page-source selection for a newly connected -viewer. It rejects navigation, tab mutation, mouse, keyboard, screenshot, -annotation and recording methods before they reach Playwright. Page-source -selection changes only which existing screencast the hidden viewer renders; it -does not mutate a page. Playwright remains authoritative for agent automation, -pages and profiles, while Sandpi owns the sole manual takeover action. - -The embedded shell measures the available screen at one-to-one CSS pixels and -sends bounded, debounced viewport updates through Sandpi's authenticated API. -Sandpi deduplicates an already-applied viewport within one Sandbox runtime -generation and coalesces intermediate updates while one official -`playwright-cli resize` command is running. The browser image uses the entire -Inspector stage and preserves the frame aspect ratio without a second saved UI -mode. - -The Dashboard adapter is injected by Sandpi's authenticated HTML proxy; it does -not edit the installed Playwright package. The Dashboard root remains hidden -until the pinned session, a page and the expected screen surface are present. -If a future Dashboard markup change prevents that recognition, Sandpi keeps the -viewer closed and reports startup failure instead of revealing upstream -controls. This is deliberately fail closed. - -An Environment resume can leave Chromium `Singleton*` symlinks on the Workspace -Volume. Before either owner opens the fixed profile, Sandpi verifies that a -lock names another Sandbox host or a dead local PID and removes only the three -ephemeral singleton symlinks. It never deletes profile data. Sandpi reuses an -identical AppService instead of rewriting it on each mount. A forced retry -increments the service revision; an HTTP authorization rejection refreshes -only cached coordinates. Missing human-mode dependencies produce a template -compatibility error before ownership changes, while agent recovery continues -to distinguish missing Playwright dependencies from a failed browser start. - -Chat output may still identify HTTP or HTTPS URLs on `localhost`, `127.0.0.1` -or `::1` as Environment-local references, including scheme-less forms that are -normalized to HTTP for display. They are intentionally inert in agent mode so -the client cannot navigate the shared Browser through a second path. A user -must Take control and navigate in the headed VNC Chrome window, subject to the -Environment's network policy. - -Sandbox0 currently exposes the owner-specific Browser transport through an -app-service ingress rather than a private port-tunnel API. That public DNS name is transport, not the user -authorization boundary: Sandpi derives a per-Environment HMAC request token, -Sandbox0 stores only its SHA-256 verifier, and the upstream URL and request -token remain server-only. The token is scoped to the Sandpi Environment rather -than exposed or persisted in the browser. After Sandpi authenticates the user -and authorizes Environment ownership, the protected ingress may perform -Sandbox0-native auto-resume. This avoids a separate control API wake-up command -without exposing the ingress credential to the browser. -Every Dashboard asset and Browser WebSocket upgrade first crosses Sandpi login, -ownership and lifecycle admission, then Sandpi forwards it with the protected -header. The client receives only the authenticated Sandpi proxy path. Static -Dashboard paths and socket identifiers are allowlisted; human mode reserves the -single `vnc` socket id. Rewritten HTML remains uncached, while static Dashboard -assets use bounded private browser caching. The WebSocket relay bounds both -directions, preserves binary VNC ordering, and coalesces only superseded -Playwright screencast frames. A live downstream WebSocket heartbeat keeps the -already-running Environment active. +## Playwright CLI boundary + +Sandpi does not expose an Environment Browser or application Preview tab, +create a browser AppService, manage a browser profile, or proxy Playwright +Dashboard or VNC traffic. Those product surfaces must remain separate when +they are introduced: Preview will route to services inside the Sandbox, while +Browser will own any human-agent browser-sharing contract. + +Playwright itself remains harness-side. During Codex Workspace preparation, +Sandpi detects the official `playwright-cli`, reads its installed package +version and materializes the matching upstream Agent Skill. The version +marker lives under `/workspace/.sandpi/playwright`; installation sets +`PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`, so Sandpi neither downloads nor starts a +browser as a side effect of preparing the harness. The Environment image or a +future dedicated Browser component must provide any browser executable and +runtime that an agent command needs. + +Chat output still recognizes HTTP or HTTPS URLs on `localhost`, `127.0.0.1` +and `::1` as Sandbox-local references, including scheme-less forms normalized +to HTTP. They remain inert so a client cannot accidentally resolve them on +the user's device before the dedicated Preview route exists. ## Start, resume and event routing @@ -861,8 +739,8 @@ projection. Its transitions automatically append and close clears the current field. Completed idle, quota, and explicit manual pauses set this projection with distinct reasons; temporary Sandbox0 pauses used for Workspace or Supervisor repair are not mislabeled. Manual Pause and Restart -both use the same lifecycle lock and suspend retained harness and Browser -coordinates before changing Sandbox0 state. The Metrics endpoint queries +both use the same lifecycle lock and suspend retained harness coordinates +before changing Sandbox0 state. The Metrics endpoint queries intervals overlapping the exact Sandbox0 metrics window, and the Inspector shades them across every runtime chart so intentional checkpoint gaps remain distinguishable from collector failures. Sandbox0 aggregation points retain diff --git a/docs/architecture/openapi-contract.md b/docs/architecture/openapi-contract.md index eb12bb5..e2657b9 100644 --- a/docs/architecture/openapi-contract.md +++ b/docs/architecture/openapi-contract.md @@ -34,14 +34,14 @@ is required by CI. The npm package includes the generated file. Sandpi does not commit generated platform SDKs; those can be produced later from a released contract. -## Streaming and proxy transports +## Streaming transports OpenAPI describes HTTP request/response operations directly. Sandpi retains the same paths for its non-JSON transports and adds explicit extensions: - `x-sandpi-sse` lists the named events on the native Session event stream. -- `x-sandpi-websocket` describes Workspace IDE, Browser transport and terminal - WebSocket message directions. +- `x-sandpi-websocket` describes Workspace IDE and Terminal WebSocket message + directions. - `x-sandpi-native-schema` marks payloads whose extensible fields remain owned by the pinned native harness protocol. @@ -51,28 +51,13 @@ currently expanded shallow directories. The server always watches clients treat server change messages as invalidations, not as a durable file-event log. -The Browser Dashboard HTTP bodies and server-to-client WebSocket events remain -opaque authenticated proxy protocols; they are not normalized into a second -Sandpi page model. Under agent ownership, the WebSocket proxy nevertheless -parses client requests so it can forward only visibility lifecycle updates and -one initial screencast-source selection. Navigation, tab mutation, input, -capture and recording requests are rejected. `GET` and -`PUT .../browser/control` expose the current owner (`agent` or `human`), -transport, revision and whether the Environment runtime supports -headed-browser takeover. Agent ownership proxies a read-only official -Playwright Dashboard renderer; human ownership proxies an ordered binary VNC -stream from a headed browser. Both use one persistent profile, and Sandpi -rejects the inactive owner's operations. The contract marks these operations -with `x-sandpi-shared-browser`. - -Dashboard HTML, redirects and control responses remain `no-store`. -Fingerprint-named Dashboard assets use a bounded private immutable cache, and -Sandpi preserves their explicit cache policy through the final response hook. -The embedded Browser remains mounted for a short grace period after the -Inspector closes, while large unmodified assets stream through the proxy -instead of being buffered in full. Sandpi's own content-addressed Next assets -are public immutable resources; stable HTML and Monaco loader paths retain -revalidation semantics. +Sandpi does not currently publish Environment Browser or application Preview +transport paths. Sandbox-loopback URLs displayed by the client remain inert +and are not represented as OpenAPI operations until a dedicated Preview +contract exists. + +Sandpi's own content-addressed Next assets are public immutable resources; +stable HTML and Monaco loader paths retain revalidation semantics. ## Authentication diff --git a/docs/images/sandpi-session-browser.png b/docs/images/sandpi-session-browser.png deleted file mode 100644 index e2f9866..0000000 Binary files a/docs/images/sandpi-session-browser.png and /dev/null differ diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5e8b766..24e900c 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -2120,17 +2120,6 @@ test("preserves conversation, Inspector, and settings state while a Turn is runn ); }, ); - await page.route( - (url) => - url.pathname === - `/api/v1/environments/${encodeURIComponent(environment.id)}/browser/`, - async (route) => { - await route.fulfill({ - contentType: "text/html", - body: "Official Playwright Dashboard fixture
- `, - }); - }, - ); - await page.goto( `/?environment=${encodeURIComponent(environment.id)}&new=1`, ); @@ -2728,11 +2624,9 @@ test("keeps New Session header operations aligned with the conversation", async await expect( inspectorViews.getByRole("button", { name: "Metrics", exact: true }), ).toBeVisible(); - const browserView = inspectorViews.getByRole("button", { - name: "Browser", - exact: true, - }); - await expect(browserView).toBeVisible(); + await expect( + inspectorViews.getByRole("button", { name: "Browser", exact: true }), + ).toHaveCount(0); await expect( inspectorViews.getByRole("button", { name: "Activity", exact: true }), ).toHaveCount(0); @@ -2740,88 +2634,16 @@ test("keeps New Session header operations aligned with the conversation", async header.getByRole("button", { name: "Close inspector" }), ).toHaveAttribute("aria-pressed", "true"); - await browserView.click(); - await expect( - page - .frameLocator('iframe[title="Shared Environment browser"]') - .getByText("Official Playwright Dashboard fixture"), - ).toBeVisible(); - expect(browserSessionStarts).toBe(0); - await expect( - page.getByRole("button", { name: "New tab", exact: true }), - ).toHaveCount(0); - await expect(page.getByRole("combobox", { name: "Browser viewport" })) - .toHaveCount(0); - await expect.poll(() => browserViewports.at(-1)).toEqual({ - width: 640, - height: 700, - }); - - const browserFrame = page.locator( - 'iframe[title="Shared Environment browser"]', - ); - const browserPanel = page.locator(".browser-panel"); - const filesPanel = page.locator(".files-panel"); - await expect(browserFrame).toHaveCount(1); - await expect(browserFrame).toHaveAttribute("aria-hidden", "true"); - await expect(browserFrame).toHaveAttribute("tabindex", "-1"); - await expect(browserFrame).toHaveCSS("pointer-events", "none"); - const browserStage = page.locator(".environment-browser-stage"); - await expect.poll(async () => { - const [frame, stage] = await Promise.all([ - browserFrame.boundingBox(), - browserStage.boundingBox(), - ]); - return frame && stage - ? { - width: Math.round(frame.width - stage.width), - height: Math.round(frame.height - stage.height), - } - : undefined; - }).toEqual({ width: 0, height: 0 }); - await expect(browserPanel).toBeVisible(); - await expect(filesPanel).toBeHidden(); - await inspectorViews - .getByRole("button", { name: "Files", exact: true }) - .click(); - await expect(browserFrame).toHaveCount(1); - await expect(browserFrame).toBeHidden(); - await expect(browserPanel).toBeHidden(); - await expect(filesPanel).toBeVisible(); - await browserView.click(); - await expect(browserFrame).toBeVisible(); - await expect(browserPanel).toBeVisible(); - await expect(filesPanel).toBeHidden(); - expect(browserSessionStarts).toBe(0); - - await page - .getByRole("button", { name: "Take control", exact: true }) - .click(); - await expect.poll(() => browserControlUpdates).toEqual(["human"]); - await expect(browserFrame).toHaveCount(0); - await expect(page.locator(".environment-browser-vnc")).toHaveCount(1); - await page - .getByRole("button", { name: "Return to agent", exact: true }) - .click(); - await expect.poll(() => browserControlUpdates).toEqual(["human", "agent"]); - await expect(browserFrame).toBeVisible(); - await expect( - page - .frameLocator('iframe[title="Shared Environment browser"]') - .getByText("Official Playwright Dashboard fixture"), - ).toBeVisible(); - await header.getByRole("button", { name: "Close inspector" }).click(); await expect(inspectorViews).toBeHidden(); await expect( header.getByRole("button", { name: "Open inspector" }), ).toHaveAttribute("aria-pressed", "false"); - await expect(browserFrame).toHaveCount(1); - await expect(browserFrame).toBeHidden(); await header.getByRole("button", { name: "Open inspector" }).click(); - await expect(browserFrame).toBeVisible(); - expect(browserSessionStarts).toBe(0); + await expect( + inspectorViews.getByRole("button", { name: "Files", exact: true }), + ).toHaveClass(/is-active/); await page.setViewportSize({ width: 390, height: 844 }); await expect(inspectorViews).toBeHidden(); @@ -5423,17 +5245,11 @@ test("opens Environment files while keeping loopback links inert", async ({ const directoryListingsReleased = new PromisePause the Sandbox when it is not needed, or restart it - to recover from broken processes, terminals, Browser - connections, or Workspace portals. + to recover from broken processes, terminals, or + Workspace portals.
- Running Turns, terminals, Browser pages, and live - connections may be interrupted. Workspace files, - native Session history, and the Browser profile remain - durable. + Running Turns, terminals, and live connections may be + interrupted. Workspace files and native Session + history remain durable. {
assert.match(html, /href="https:\/\/example\.com\/diagram\.png"/);
});
-test("normalizes scheme-less loopback targets as inert references", () => {
+test("normalizes scheme-less sandbox targets as inert references", () => {
const html = renderToStaticMarkup(
createElement(MarkdownContent, {
content: "[App](localhost:3000)",
}),
);
- assert.match(html, /]+data-browser-url="http:\/\/localhost:3000\/"/);
+ assert.match(
+ html,
+ /]+data-sandbox-loopback-url="http:\/\/localhost:3000\/"/,
+ );
assert.match(html, />App<\/code>/);
});
diff --git a/src/components/markdown-content.tsx b/src/components/markdown-content.tsx
index 19c6a8e..cc40365 100644
--- a/src/components/markdown-content.tsx
+++ b/src/components/markdown-content.tsx
@@ -12,7 +12,7 @@ import ReactMarkdown, {
} from "react-markdown";
import remarkGfm from "remark-gfm";
-import { sandboxLoopbackUrl } from "@/lib/environment-browser";
+import { sandboxLoopbackUrl } from "@/lib/sandbox-loopback-url";
import { resolveWorkspaceMarkdownPath } from "@/lib/workspace-file-presentation";
interface MarkdownContentProps {
@@ -95,13 +95,13 @@ function MarkdownContentView({
);
}
- const browserUrl = sandboxLoopbackUrl(href);
- if (browserUrl) {
+ const sandboxUrl = sandboxLoopbackUrl(href);
+ if (sandboxUrl) {
return (
{children}
diff --git a/src/lib/environment-browser-vnc.test.ts b/src/lib/environment-browser-vnc.test.ts
deleted file mode 100644
index 1813d20..0000000
--- a/src/lib/environment-browser-vnc.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import { configureEnvironmentBrowserRfb } from "./environment-browser-vnc";
-
-test("requests the human-control panel size with scaling as a legacy fallback", () => {
- const rfb = {
- scaleViewport: false,
- resizeSession: false,
- focusOnClick: false,
- showDotCursor: false,
- qualityLevel: 0,
- compressionLevel: 0,
- };
-
- configureEnvironmentBrowserRfb(rfb);
-
- assert.deepEqual(rfb, {
- scaleViewport: true,
- resizeSession: true,
- focusOnClick: true,
- showDotCursor: true,
- qualityLevel: 6,
- compressionLevel: 2,
- });
-});
diff --git a/src/lib/environment-browser-vnc.ts b/src/lib/environment-browser-vnc.ts
deleted file mode 100644
index a9ab205..0000000
--- a/src/lib/environment-browser-vnc.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export interface EnvironmentBrowserRfbClient {
- scaleViewport: boolean;
- resizeSession: boolean;
- focusOnClick: boolean;
- showDotCursor: boolean;
- qualityLevel: number;
- compressionLevel: number;
-}
-
-export function configureEnvironmentBrowserRfb(
- rfb: EnvironmentBrowserRfbClient,
-) {
- // Remote resize fills the human-control panel when TigerVNC is available.
- // Scaling stays enabled so older Xvfb/x11vnc Environments remain usable.
- rfb.scaleViewport = true;
- rfb.resizeSession = true;
- rfb.focusOnClick = true;
- rfb.showDotCursor = true;
- rfb.qualityLevel = 6;
- rfb.compressionLevel = 2;
-}
diff --git a/src/lib/environment-browser.test.ts b/src/lib/environment-browser.test.ts
deleted file mode 100644
index f92b31d..0000000
--- a/src/lib/environment-browser.test.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import {
- BROWSER_DASHBOARD_READY_MESSAGE,
- BROWSER_DASHBOARD_SESSION_NAME,
- BROWSER_DASHBOARD_SESSION_READY_MESSAGE,
- BROWSER_DASHBOARD_THEME_TOKEN_MAP,
- BROWSER_DASHBOARD_VIEWPORT_LIMITS,
- BROWSER_DASHBOARD_VIEWPORT_MESSAGE,
- isBrowserDashboardReadyMessage,
- isBrowserDashboardSessionReadyMessage,
- isBrowserDashboardViewport,
- isBrowserDashboardViewportMessage,
- resolveBrowserDashboardViewport,
- sandboxLoopbackUrl,
-} from "./environment-browser";
-
-test("recognizes only the embedded Dashboard ready message", () => {
- assert.equal(
- isBrowserDashboardReadyMessage({
- type: BROWSER_DASHBOARD_READY_MESSAGE,
- }),
- true,
- );
- assert.equal(
- isBrowserDashboardReadyMessage({
- type: "sandpi:browser-dashboard-theme",
- }),
- false,
- );
- assert.equal(isBrowserDashboardReadyMessage(null), false);
- assert.equal(
- isBrowserDashboardSessionReadyMessage({
- type: BROWSER_DASHBOARD_SESSION_READY_MESSAGE,
- }),
- true,
- );
- assert.equal(
- isBrowserDashboardSessionReadyMessage({
- type: BROWSER_DASHBOARD_READY_MESSAGE,
- }),
- false,
- );
- assert.equal(BROWSER_DASHBOARD_SESSION_NAME, "default");
- assert.deepEqual(BROWSER_DASHBOARD_THEME_TOKEN_MAP["--canvas"], [
- "--color-canvas-default",
- ]);
- assert.deepEqual(BROWSER_DASHBOARD_THEME_TOKEN_MAP["--line"], [
- "--color-border-default",
- "--vscode-panel-border",
- ]);
-});
-
-test("accepts only bounded integer Dashboard viewport messages", () => {
- assert.equal(
- isBrowserDashboardViewportMessage({
- type: BROWSER_DASHBOARD_VIEWPORT_MESSAGE,
- width: 519,
- height: 759,
- }),
- true,
- );
- assert.equal(
- isBrowserDashboardViewport({
- width: BROWSER_DASHBOARD_VIEWPORT_LIMITS.minWidth,
- height: BROWSER_DASHBOARD_VIEWPORT_LIMITS.minHeight,
- }),
- true,
- );
- assert.equal(
- isBrowserDashboardViewport({
- width: BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxWidth,
- height: BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxHeight,
- }),
- true,
- );
- assert.equal(
- isBrowserDashboardViewportMessage({
- type: BROWSER_DASHBOARD_VIEWPORT_MESSAGE,
- width: 518.5,
- height: 759,
- }),
- false,
- );
- assert.equal(
- isBrowserDashboardViewportMessage({
- type: BROWSER_DASHBOARD_VIEWPORT_MESSAGE,
- width: BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxWidth + 1,
- height: 759,
- }),
- false,
- );
- assert.equal(
- isBrowserDashboardViewportMessage({
- type: BROWSER_DASHBOARD_READY_MESSAGE,
- width: 519,
- height: 759,
- }),
- false,
- );
-});
-
-test("fits the Browser viewport to the available screen bounds", () => {
- assert.deepEqual(
- resolveBrowserDashboardViewport({ width: 1_000, height: 700 }),
- { width: 1_000, height: 700 },
- );
- assert.deepEqual(
- resolveBrowserDashboardViewport({ width: 319.4, height: 239.4 }),
- { width: 320, height: 240 },
- );
- assert.deepEqual(
- resolveBrowserDashboardViewport({ width: 4_000, height: 3_000 }),
- { width: 3_840, height: 2_160 },
- );
-});
-
-test("accepts HTTP loopback URLs that the Environment browser can reach", () => {
- assert.equal(
- sandboxLoopbackUrl("http://localhost:3000/dashboard?mode=debug#result"),
- "http://localhost:3000/dashboard?mode=debug#result",
- );
- assert.equal(
- sandboxLoopbackUrl("https://127.0.0.1:8443"),
- "https://127.0.0.1:8443/",
- );
- assert.equal(
- sandboxLoopbackUrl("http://[::1]:4173/"),
- "http://[::1]:4173/",
- );
- assert.equal(
- sandboxLoopbackUrl("localhost:3000/dashboard"),
- "http://localhost:3000/dashboard",
- );
- assert.equal(
- sandboxLoopbackUrl("127.0.0.1:8080/health"),
- "http://127.0.0.1:8080/health",
- );
-});
-
-test("rejects external, credential-bearing and non-HTTP URLs", () => {
- assert.equal(sandboxLoopbackUrl("https://example.com"), undefined);
- assert.equal(
- sandboxLoopbackUrl("http://user:password@localhost:3000"),
- undefined,
- );
- assert.equal(sandboxLoopbackUrl("file:///workspace/index.html"), undefined);
- assert.equal(sandboxLoopbackUrl("localhost.example.com:3000"), undefined);
- assert.equal(sandboxLoopbackUrl("not a url"), undefined);
-});
diff --git a/src/lib/environment-browser.ts b/src/lib/environment-browser.ts
deleted file mode 100644
index 2f09a44..0000000
--- a/src/lib/environment-browser.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-const SANDBOX_LOOPBACK_HOSTS = new Set([
- "localhost",
- "127.0.0.1",
- "[::1]",
-]);
-
-export const BROWSER_DASHBOARD_READY_MESSAGE =
- "sandpi:browser-dashboard-ready";
-export const BROWSER_DASHBOARD_SESSION_READY_MESSAGE =
- "sandpi:browser-dashboard-session-ready";
-export const BROWSER_DASHBOARD_THEME_MESSAGE =
- "sandpi:browser-dashboard-theme";
-export const BROWSER_DASHBOARD_VIEWPORT_MESSAGE =
- "sandpi:browser-dashboard-viewport";
-export const BROWSER_DASHBOARD_SESSION_NAME = "default";
-export const BROWSER_DASHBOARD_VIEWPORT_LIMITS = {
- minWidth: 320,
- maxWidth: 3_840,
- minHeight: 240,
- maxHeight: 2_160,
-} as const;
-
-export const BROWSER_DASHBOARD_THEME_TOKEN_MAP = {
- "--canvas": ["--color-canvas-default"],
- "--sidebar": ["--color-canvas-inset", "--color-canvas-subtle"],
- "--panel": ["--color-canvas-overlay"],
- "--panel-strong": ["--color-btn-bg"],
- "--ink": ["--color-fg-default"],
- "--ink-soft": ["--color-fg-muted"],
- "--ink-faint": ["--color-fg-subtle"],
- "--line": ["--color-border-default", "--vscode-panel-border"],
- "--line-soft": ["--color-border-muted"],
- "--hover": ["--color-neutral-subtle"],
- "--selected": ["--color-neutral-muted"],
- "--green": ["--color-success-fg"],
- "--green-soft": ["--color-success-subtle"],
- "--amber": ["--color-attention-fg"],
- "--amber-soft": ["--color-attention-subtle"],
- "--red": ["--color-danger-fg"],
- "--red-soft": ["--color-danger-subtle"],
- "--blue": ["--color-accent-emphasis", "--color-accent-fg"],
- "--blue-soft": ["--color-accent-muted", "--color-accent-subtle"],
- "--shadow-lg": ["--color-overlay-shadow"],
-} as const;
-
-export type BrowserDashboardTheme = "system" | "light" | "dark";
-export type BrowserDashboardResolvedTheme = "light" | "dark";
-export type EnvironmentBrowserOwner = "agent" | "human";
-export type EnvironmentBrowserTransport = "playwright" | "vnc";
-
-export interface EnvironmentBrowserOwnership {
- owner: EnvironmentBrowserOwner;
- transport: EnvironmentBrowserTransport;
- revision: number;
-}
-
-export interface EnvironmentBrowserControl extends EnvironmentBrowserOwnership {
- takeoverAvailable: boolean;
-}
-
-export interface BrowserDashboardThemeMessage {
- type: typeof BROWSER_DASHBOARD_THEME_MESSAGE;
- theme: BrowserDashboardTheme;
- resolvedTheme: BrowserDashboardResolvedTheme;
- tokens: Record;
-}
-
-export interface BrowserDashboardViewport {
- width: number;
- height: number;
-}
-
-export interface BrowserDashboardViewportMessage
- extends BrowserDashboardViewport {
- type: typeof BROWSER_DASHBOARD_VIEWPORT_MESSAGE;
-}
-
-
-export function resolveBrowserDashboardViewport(
- bounds: BrowserDashboardViewport,
-): BrowserDashboardViewport {
- const clamp = (value: number, minimum: number, maximum: number) =>
- Math.min(maximum, Math.max(minimum, Math.round(value)));
- return {
- width: clamp(
- bounds.width,
- BROWSER_DASHBOARD_VIEWPORT_LIMITS.minWidth,
- BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxWidth,
- ),
- height: clamp(
- bounds.height,
- BROWSER_DASHBOARD_VIEWPORT_LIMITS.minHeight,
- BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxHeight,
- ),
- };
-}
-
-export function isBrowserDashboardViewport(
- value: unknown,
-): value is BrowserDashboardViewport {
- if (typeof value !== "object" || value === null) return false;
- const viewport = value as Record;
- return (
- typeof viewport.width === "number" &&
- Number.isInteger(viewport.width) &&
- viewport.width >= BROWSER_DASHBOARD_VIEWPORT_LIMITS.minWidth &&
- viewport.width <= BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxWidth &&
- typeof viewport.height === "number" &&
- Number.isInteger(viewport.height) &&
- viewport.height >= BROWSER_DASHBOARD_VIEWPORT_LIMITS.minHeight &&
- viewport.height <= BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxHeight
- );
-}
-
-export function isBrowserDashboardViewportMessage(
- value: unknown,
-): value is BrowserDashboardViewportMessage {
- return (
- typeof value === "object" &&
- value !== null &&
- "type" in value &&
- value.type === BROWSER_DASHBOARD_VIEWPORT_MESSAGE &&
- isBrowserDashboardViewport(value)
- );
-}
-
-export function isBrowserDashboardReadyMessage(
- value: unknown,
-): value is { type: typeof BROWSER_DASHBOARD_READY_MESSAGE } {
- return (
- typeof value === "object" &&
- value !== null &&
- "type" in value &&
- value.type === BROWSER_DASHBOARD_READY_MESSAGE
- );
-}
-
-export function isBrowserDashboardSessionReadyMessage(
- value: unknown,
-): value is { type: typeof BROWSER_DASHBOARD_SESSION_READY_MESSAGE } {
- return (
- typeof value === "object" &&
- value !== null &&
- "type" in value &&
- value.type === BROWSER_DASHBOARD_SESSION_READY_MESSAGE
- );
-}
-
-/**
- * Identifies URLs that resolve inside the Environment rather than on the
- * Sandpi user's device. The message renderer keeps these links inert until a
- * user explicitly takes Browser control.
- */
-export function sandboxLoopbackUrl(value: string | undefined) {
- if (!value) return undefined;
- const trimmed = value.trim();
- const candidate =
- /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(
- trimmed,
- )
- ? `http://${trimmed}`
- : trimmed;
- let url: URL;
- try {
- url = new URL(candidate);
- } catch {
- return undefined;
- }
- if (
- (url.protocol !== "http:" && url.protocol !== "https:") ||
- !SANDBOX_LOOPBACK_HOSTS.has(url.hostname.toLowerCase()) ||
- url.username ||
- url.password
- ) {
- return undefined;
- }
- return url.toString();
-}
diff --git a/src/lib/local-ui-preferences.test.ts b/src/lib/local-ui-preferences.test.ts
index 9256317..588926f 100644
--- a/src/lib/local-ui-preferences.test.ts
+++ b/src/lib/local-ui-preferences.test.ts
@@ -83,12 +83,12 @@ test("falls back safely for malformed local UI preferences", () => {
);
});
-test("keeps Browser as a durable Inspector tab", () => {
+test("migrates the removed Browser Inspector tab to Files", () => {
assert.equal(
normalizeLocalUiPreferences({
workspace: { inspectorTab: "browser" },
}).workspace.inspectorTab,
- "browser",
+ "files",
);
});
diff --git a/src/lib/local-ui-preferences.ts b/src/lib/local-ui-preferences.ts
index cbbc154..5ff60a2 100644
--- a/src/lib/local-ui-preferences.ts
+++ b/src/lib/local-ui-preferences.ts
@@ -15,7 +15,7 @@ export const LOCAL_UI_PREFERENCES_STORAGE_KEY =
export const LOCAL_UI_PREFERENCES_CHANGED_EVENT =
"sandpi:local-ui-preferences-changed";
-export type LocalInspectorTab = "files" | "browser" | "activity" | "metrics";
+export type LocalInspectorTab = "files" | "activity" | "metrics";
export type LocalCodexSessionActivityFilter =
"all" | "issues" | "external" | "commands" | "files" | "agents" | "system";
@@ -67,7 +67,7 @@ export const DEFAULT_LOCAL_UI_PREFERENCES: SandpiLocalUiPreferences = {
let cachedRawPreferences: string | null | undefined;
let cachedPreferences = DEFAULT_LOCAL_UI_PREFERENCES;
-const INSPECTOR_TABS = ["files", "browser", "activity", "metrics"] as const;
+const INSPECTOR_TABS = ["files", "activity", "metrics"] as const;
const CODEX_SESSION_ACTIVITY_FILTERS = [
"all",
"issues",
diff --git a/src/lib/operation-ui.ts b/src/lib/operation-ui.ts
index 12fa3af..c265229 100644
--- a/src/lib/operation-ui.ts
+++ b/src/lib/operation-ui.ts
@@ -126,7 +126,6 @@ const copy = {
label: "Environment inspector",
views: "Inspector views",
files: "Files",
- browser: "Browser",
metrics: "Metrics",
settings: "Settings",
openEnvironmentSettings: "Open Environment settings",
@@ -135,18 +134,6 @@ const copy = {
resizeValue: (percent: number) => `${percent}% of the workspace`,
resizeHelp: "Drag to resize Inspector. Double-click to reset.",
loadingView: (view: string) => `Loading ${view.toLowerCase()}…`,
- browserTitle: "Shared Environment browser",
- browserStarting: "Starting the shared Environment browser…",
- browserUnavailable:
- "The shared Environment browser did not become ready. Retry after the Environment finishes resuming.",
- browserRetry: "Retry",
- browserLoading: "Loading…",
- browserTakeControl: "Take control",
- browserTakeControlUnavailable: "Take control · recreate Environment",
- browserReturnToAgent: "Return to agent",
- browserHumanControl: "Human control",
- browserSwitchingControl: "Switching control…",
- browserHumanStarting: "Starting the interactive browser…",
workspaceFiles: "Workspace files",
openNewView: "Open in Sandpi Cloud IDE (coming later)",
binaryFilePreview: "Binary files cannot be previewed here.",
@@ -281,7 +268,6 @@ const copy = {
label: "环境检查器",
views: "检查器视图",
files: "文件",
- browser: "Browser",
metrics: "指标",
settings: "设置",
openEnvironmentSettings: "打开 Environment 设置",
@@ -290,18 +276,6 @@ const copy = {
resizeValue: (percent: number) => `占工作区 ${percent}%`,
resizeHelp: "拖动以调整检查器宽度,双击恢复默认比例。",
loadingView: (view: string) => `正在加载${view}…`,
- browserTitle: "共享 Environment 浏览器",
- browserStarting: "正在启动共享 Environment 浏览器…",
- browserUnavailable:
- "共享 Environment 浏览器未能就绪,请等待 Environment 恢复完成后重试。",
- browserRetry: "重试",
- browserLoading: "正在加载…",
- browserTakeControl: "接管浏览器",
- browserTakeControlUnavailable: "接管浏览器 · 需重建 Environment",
- browserReturnToAgent: "交还给 Agent",
- browserHumanControl: "用户正在接管",
- browserSwitchingControl: "正在切换控制权…",
- browserHumanStarting: "正在启动交互式浏览器…",
workspaceFiles: "Workspace 文件",
openNewView: "在 Sandpi Cloud IDE 中打开(后续支持)",
binaryFilePreview: "暂不支持在这里预览二进制文件。",
diff --git a/src/lib/sandbox-loopback-url.test.ts b/src/lib/sandbox-loopback-url.test.ts
new file mode 100644
index 0000000..caaebfc
--- /dev/null
+++ b/src/lib/sandbox-loopback-url.test.ts
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { sandboxLoopbackUrl } from "./sandbox-loopback-url";
+
+test("accepts HTTP loopback URLs that resolve inside a Sandbox", () => {
+ assert.equal(
+ sandboxLoopbackUrl("http://localhost:3000/dashboard?mode=debug#result"),
+ "http://localhost:3000/dashboard?mode=debug#result",
+ );
+ assert.equal(
+ sandboxLoopbackUrl("https://127.0.0.1:8443"),
+ "https://127.0.0.1:8443/",
+ );
+ assert.equal(
+ sandboxLoopbackUrl("http://[::1]:4173/"),
+ "http://[::1]:4173/",
+ );
+ assert.equal(
+ sandboxLoopbackUrl("localhost:3000/dashboard"),
+ "http://localhost:3000/dashboard",
+ );
+ assert.equal(
+ sandboxLoopbackUrl("127.0.0.1:8080/health"),
+ "http://127.0.0.1:8080/health",
+ );
+});
+
+test("rejects external, credential-bearing and non-HTTP URLs", () => {
+ assert.equal(sandboxLoopbackUrl("https://example.com"), undefined);
+ assert.equal(
+ sandboxLoopbackUrl("http://user:password@localhost:3000"),
+ undefined,
+ );
+ assert.equal(sandboxLoopbackUrl("file:///workspace/index.html"), undefined);
+ assert.equal(sandboxLoopbackUrl("localhost.example.com:3000"), undefined);
+ assert.equal(sandboxLoopbackUrl("not a url"), undefined);
+});
diff --git a/src/lib/sandbox-loopback-url.ts b/src/lib/sandbox-loopback-url.ts
new file mode 100644
index 0000000..558dd53
--- /dev/null
+++ b/src/lib/sandbox-loopback-url.ts
@@ -0,0 +1,36 @@
+const SANDBOX_LOOPBACK_HOSTS = new Set([
+ "localhost",
+ "127.0.0.1",
+ "[::1]",
+]);
+
+/**
+ * Identifies URLs that resolve inside a Sandbox rather than on the user's
+ * device. Until Sandpi exposes a dedicated Preview surface, these links stay
+ * inert so they cannot accidentally navigate to a service on the local host.
+ */
+export function sandboxLoopbackUrl(value: string | undefined) {
+ if (!value) return undefined;
+ const trimmed = value.trim();
+ const candidate =
+ /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(
+ trimmed,
+ )
+ ? `http://${trimmed}`
+ : trimmed;
+ let url: URL;
+ try {
+ url = new URL(candidate);
+ } catch {
+ return undefined;
+ }
+ if (
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
+ !SANDBOX_LOOPBACK_HOSTS.has(url.hostname.toLowerCase()) ||
+ url.username ||
+ url.password
+ ) {
+ return undefined;
+ }
+ return url.toString();
+}
diff --git a/src/server/api-schemas.ts b/src/server/api-schemas.ts
index b86db40..c1ddd2b 100644
--- a/src/server/api-schemas.ts
+++ b/src/server/api-schemas.ts
@@ -2,9 +2,6 @@ import { z } from "zod";
import { SANDPI_PAID_PLAN_IDS } from "@/lib/billing";
import type { SandpiPreferences } from "@/lib/types";
-import {
- BROWSER_DASHBOARD_VIEWPORT_LIMITS,
-} from "@/lib/environment-browser";
import {
ENVIRONMENT_SANDBOX_MEMORY_MAX_MIB,
ENVIRONMENT_SANDBOX_MEMORY_MIN_MIB,
@@ -24,21 +21,6 @@ import {
} from "@/server/harnesses/codex/input-images";
import { networkPolicySchema } from "@/server/network-policy-schema";
-export const environmentBrowserViewportSchema = z
- .object({
- width: z
- .number()
- .int()
- .min(BROWSER_DASHBOARD_VIEWPORT_LIMITS.minWidth)
- .max(BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxWidth),
- height: z
- .number()
- .int()
- .min(BROWSER_DASHBOARD_VIEWPORT_LIMITS.minHeight)
- .max(BROWSER_DASHBOARD_VIEWPORT_LIMITS.maxHeight),
- })
- .strict();
-
export const workspaceFileSearchQuerySchema = z
.string()
.trim()
@@ -506,17 +488,6 @@ export const sessionForkSchema = z
.object({ title: z.string().trim().min(1).max(200).optional() })
.default({});
-export const browserSessionSchema = z
- .object({ force: z.boolean().optional() })
- .strict();
-
-export const browserControlSchema = z
- .object({
- owner: z.enum(["agent", "human"]),
- force: z.boolean().optional(),
- })
- .strict();
-
export const preferencesSchema: z.ZodType = z.object({
general: z.object({
language: z.enum(["en", "zh-CN"]),
diff --git a/src/server/cache-policy.test.ts b/src/server/cache-policy.test.ts
index dfa58d9..b41207a 100644
--- a/src/server/cache-policy.test.ts
+++ b/src/server/cache-policy.test.ts
@@ -6,31 +6,14 @@ import {
staticWebCacheControl,
} from "./cache-policy";
-test("preserves explicit Browser asset caching while protecting control responses", () => {
- const browser =
- "/api/v1/environments/env-1/browser/assets/index-CyWAfh-p.js";
- assert.equal(shouldApplyApiNoStore(browser, true), false);
- assert.equal(shouldApplyApiNoStore(browser, false), true);
+test("protects sensitive API responses", () => {
assert.equal(
- shouldApplyApiNoStore(
- "/api/v1/environments/env-1/browser/session",
- false,
- ),
+ shouldApplyApiNoStore("/api/v1/environments/env-1/workspace-backups"),
true,
);
assert.equal(
- shouldApplyApiNoStore(
- "/api/v1/environments/env-1/browser/control",
- true,
- ),
- true,
- );
- assert.equal(
- shouldApplyApiNoStore(
- "/api/v1/environments/env-1/workspace-backups",
- true,
- ),
- true,
+ shouldApplyApiNoStore("/api/v1/environments/env-1/metrics"),
+ false,
);
});
diff --git a/src/server/cache-policy.ts b/src/server/cache-policy.ts
index 22f3bf4..04d4af5 100644
--- a/src/server/cache-policy.ts
+++ b/src/server/cache-policy.ts
@@ -11,22 +11,11 @@ const API_NO_STORE_PATHS = [
"/webhooks",
"/egress-credentials",
"/billing",
- "/browser/control",
] as const;
-/**
- * Sensitive API responses are never stored implicitly. Browser Dashboard
- * handlers own their cache policy because HTML/control responses are private
- * and uncacheable while fingerprinted assets are safe to retain.
- */
-export function shouldApplyApiNoStore(
- requestUrl: string,
- hasExplicitCacheControl: boolean,
-) {
- if (API_NO_STORE_PATHS.some((candidate) => requestUrl.includes(candidate))) {
- return true;
- }
- return requestUrl.includes("/browser") && !hasExplicitCacheControl;
+/** Sensitive API responses are never stored implicitly. */
+export function shouldApplyApiNoStore(requestUrl: string) {
+ return API_NO_STORE_PATHS.some((candidate) => requestUrl.includes(candidate));
}
/**
diff --git a/src/server/environments/browser-dashboard-embed.test.ts b/src/server/environments/browser-dashboard-embed.test.ts
deleted file mode 100644
index 38de140..0000000
--- a/src/server/environments/browser-dashboard-embed.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-import vm from "node:vm";
-
-import {
- BROWSER_DASHBOARD_EMBED_MARKER,
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- BROWSER_DASHBOARD_EMBED_STYLE,
- BROWSER_DASHBOARD_WEBSOCKET_COMPAT_SCRIPT,
- embedBrowserDashboard,
-} from "./browser-dashboard-embed";
-
-test("embeds once before the Dashboard head closes", () => {
- const html =
- "Playwright Dashboard ";
- const embedded = embedBrowserDashboard(html);
-
- assert.ok(embedded.indexOf(BROWSER_DASHBOARD_EMBED_MARKER) > 0);
- assert.ok(
- embedded.indexOf(BROWSER_DASHBOARD_EMBED_MARKER) <
- embedded.indexOf(""),
- );
- assert.equal(
- embedBrowserDashboard(embedded).match(
- new RegExp(BROWSER_DASHBOARD_EMBED_MARKER, "g"),
- )?.length,
- 2,
- );
-});
-
-test("leaves an unrecognized Dashboard document untouched", () => {
- const html = "Dashboard unavailable ";
- assert.equal(embedBrowserDashboard(html), html);
-});
-
-test("resolves relative Dashboard WebSocket URLs for older WebViews", () => {
- const opened: Array<{ url: string; protocols?: string | string[] }> = [];
- class NativeWebSocket {
- static readonly CONNECTING = 0;
-
- constructor(url: string, protocols?: string | string[]) {
- opened.push({ url, protocols });
- }
- }
- const window = {
- location: {
- href: "https://sandpi.ai/api/v1/environments/env-1/browser/index.html",
- },
- WebSocket: NativeWebSocket,
- };
- const context = vm.createContext({ URL, window });
-
- vm.runInContext(BROWSER_DASHBOARD_WEBSOCKET_COMPAT_SCRIPT, context);
- const CompatibleWebSocket = window.WebSocket;
- new CompatibleWebSocket("/api/v1/environments/env-1/browser/ws/socket");
- new CompatibleWebSocket("wss://browser.example/socket", ["v1"]);
-
- assert.deepEqual(opened, [
- {
- url: "wss://sandpi.ai/api/v1/environments/env-1/browser/ws/socket",
- protocols: undefined,
- },
- {
- url: "wss://browser.example/socket",
- protocols: ["v1"],
- },
- ]);
- assert.equal(CompatibleWebSocket.CONNECTING, NativeWebSocket.CONNECTING);
- assert.equal(
- (
- CompatibleWebSocket as typeof NativeWebSocket & {
- __sandpiRelativeUrlCompatibility?: boolean;
- }
- ).__sandpiRelativeUrlCompatibility,
- true,
- );
-
- vm.runInContext(BROWSER_DASHBOARD_WEBSOCKET_COMPAT_SCRIPT, context);
- assert.equal(window.WebSocket, CompatibleWebSocket);
-});
-
-test("selects the shared default session before announcing readiness", () => {
- assert.match(BROWSER_DASHBOARD_EMBED_SCRIPT, /"sessionName":"default"/);
- assert.match(BROWSER_DASHBOARD_EMBED_SCRIPT, /\.session-chip-name/);
- assert.match(
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- /\[role="option"\]\[aria-selected="true"\]/,
- );
- assert.match(BROWSER_DASHBOARD_EMBED_SCRIPT, /firstTab\.click\(\)/);
- assert.match(
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- /sandpi:browser-dashboard-session-ready/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- /screenBounds\.width > 0/,
- );
- assert.match(BROWSER_DASHBOARD_EMBED_SCRIPT, /screenBounds\.height > 0/);
- assert.match(
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- /sandpi-browser-dashboard-ready/,
- );
- assert.doesNotMatch(BROWSER_DASHBOARD_EMBED_SCRIPT, /setTimeout/);
-});
-
-test("reports a bounded viewport that matches the visible screen", () => {
- assert.match(BROWSER_DASHBOARD_EMBED_SCRIPT, /new ResizeObserver/);
- assert.match(
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- /new ResizeObserver\(\(\) => \{[\s\S]*selectDefaultSession\(\)/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- /observedScreen\.getBoundingClientRect\(\)/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_SCRIPT,
- /sandpi:browser-dashboard-viewport/,
- );
- assert.match(BROWSER_DASHBOARD_EMBED_SCRIPT, /desiredViewport\?\.width/);
- assert.doesNotMatch(BROWSER_DASHBOARD_EMBED_SCRIPT, /viewportMode|mobile/);
-});
-
-test("keeps the embedded Dashboard hidden until the read-only shape is ready", () => {
- assert.match(
- BROWSER_DASHBOARD_EMBED_STYLE,
- /#root \{[\s\S]*visibility: hidden/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_STYLE,
- /sandpi-browser-dashboard-ready #root[\s\S]*visibility: visible/,
- );
-});
-
-test("removes every native Dashboard interaction surface", () => {
- assert.match(
- BROWSER_DASHBOARD_EMBED_STYLE,
- /\.dashboard-main > \.toolbar/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_STYLE,
- /\.browser-window > \.browser-chrome/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_STYLE,
- /\.dashboard-view > :not\(\.dashboard-main\)/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_STYLE,
- /\.split-view-sidebar[\s\S]*display: none !important/,
- );
- assert.match(
- BROWSER_DASHBOARD_EMBED_STYLE,
- /\.screen,[\s\S]*\.screen-overlay[\s\S]*pointer-events: none !important/,
- );
- assert.doesNotMatch(BROWSER_DASHBOARD_EMBED_SCRIPT, /commandMessage|newTab/);
-});
diff --git a/src/server/environments/browser-dashboard-embed.ts b/src/server/environments/browser-dashboard-embed.ts
deleted file mode 100644
index be7d676..0000000
--- a/src/server/environments/browser-dashboard-embed.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-import {
- BROWSER_DASHBOARD_READY_MESSAGE,
- BROWSER_DASHBOARD_SESSION_NAME,
- BROWSER_DASHBOARD_SESSION_READY_MESSAGE,
- BROWSER_DASHBOARD_THEME_MESSAGE,
- BROWSER_DASHBOARD_THEME_TOKEN_MAP,
- BROWSER_DASHBOARD_VIEWPORT_LIMITS,
- BROWSER_DASHBOARD_VIEWPORT_MESSAGE,
-} from "@/lib/environment-browser";
-
-export const BROWSER_DASHBOARD_EMBED_MARKER =
- "data-sandpi-browser-dashboard";
-
-export const BROWSER_DASHBOARD_EMBED_STYLE = `
-`;
-
-function scriptJson(value: unknown) {
- return JSON.stringify(value).replaceAll("<", "\\u003c");
-}
-
-const browserDashboardEmbedConfig = scriptJson({
- readyMessage: BROWSER_DASHBOARD_READY_MESSAGE,
- sessionName: BROWSER_DASHBOARD_SESSION_NAME,
- sessionReadyMessage: BROWSER_DASHBOARD_SESSION_READY_MESSAGE,
- themeMessage: BROWSER_DASHBOARD_THEME_MESSAGE,
- tokenMap: BROWSER_DASHBOARD_THEME_TOKEN_MAP,
- viewportLimits: BROWSER_DASHBOARD_VIEWPORT_LIMITS,
- viewportMessage: BROWSER_DASHBOARD_VIEWPORT_MESSAGE,
-});
-
-export const BROWSER_DASHBOARD_WEBSOCKET_COMPAT_SCRIPT = `
- (() => {
- const NativeWebSocket = window.WebSocket;
- if (
- !NativeWebSocket ||
- NativeWebSocket.__sandpiRelativeUrlCompatibility
- ) {
- return;
- }
-
- class SandpiBrowserWebSocket extends NativeWebSocket {
- constructor(url, protocols) {
- let resolvedUrl = url;
- if (
- typeof url === "string" &&
- !/^[a-z][a-z0-9+.-]*:/i.test(url)
- ) {
- const target = new URL(url, window.location.href);
- target.protocol =
- target.protocol === "https:" ? "wss:" : "ws:";
- resolvedUrl = target.toString();
- }
- if (protocols === undefined) super(resolvedUrl);
- else super(resolvedUrl, protocols);
- }
- }
-
- Object.defineProperty(
- SandpiBrowserWebSocket,
- "__sandpiRelativeUrlCompatibility",
- { value: true },
- );
- window.WebSocket = SandpiBrowserWebSocket;
- })();
-`;
-
-export const BROWSER_DASHBOARD_EMBED_SCRIPT = `
-`;
-
-export const BROWSER_DASHBOARD_EMBED_MARKUP =
- BROWSER_DASHBOARD_EMBED_SCRIPT + BROWSER_DASHBOARD_EMBED_STYLE;
-
-export function embedBrowserDashboard(html: string) {
- if (
- html.includes(BROWSER_DASHBOARD_EMBED_MARKER) ||
- !/<\/head\s*>/i.test(html)
- ) {
- return html;
- }
- return html.replace(
- /<\/head\s*>/i,
- `${BROWSER_DASHBOARD_EMBED_MARKUP}\n`,
- );
-}
diff --git a/src/server/environments/browser-dashboard-protocol.test.ts b/src/server/environments/browser-dashboard-protocol.test.ts
deleted file mode 100644
index 9007b12..0000000
--- a/src/server/environments/browser-dashboard-protocol.test.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import {
- BrowserDashboardReadOnlyGate,
- browserDashboardRejectedResponse,
-} from "./browser-dashboard-protocol";
-
-function request(id: number, method: string, params: unknown) {
- return Buffer.from(JSON.stringify({ id, method, params }));
-}
-
-test("allows only read-only Dashboard lifecycle messages", () => {
- const gate = new BrowserDashboardReadOnlyGate();
-
- assert.deepEqual(
- gate.inspect(request(1, "setVisible", { visible: false }), false),
- { action: "forward", method: "setVisible" },
- );
- assert.deepEqual(
- gate.inspect(
- request(2, "selectTab", {
- browser: "browser-guid",
- context: "context-guid",
- page: "page-guid",
- }),
- false,
- ),
- { action: "forward", method: "selectTab" },
- );
- assert.deepEqual(
- gate.inspect(request(3, "setVisible", { visible: true }), false),
- { action: "forward", method: "setVisible" },
- );
-});
-
-test("rejects all Dashboard page mutations", () => {
- for (const method of [
- "newTab",
- "closeTab",
- "closeSession",
- "navigate",
- "back",
- "forward",
- "reload",
- "mousemove",
- "mousedown",
- "mouseup",
- "wheel",
- "keydown",
- "keyup",
- "screenshot",
- "startRecording",
- "stopRecording",
- "submitAnnotation",
- ]) {
- const decision = new BrowserDashboardReadOnlyGate().inspect(
- request(9, method, {}),
- false,
- );
- assert.deepEqual(decision, {
- action: "reject",
- method,
- requestId: 9,
- reason: "blocked",
- });
- if (decision.action !== "reject") assert.fail("mutation was forwarded");
- assert.match(
- browserDashboardRejectedResponse(decision) ?? "",
- /view-only/,
- );
- }
-});
-
-test("allows the hidden adapter to select a source only once", () => {
- const gate = new BrowserDashboardReadOnlyGate();
- const first = request(1, "selectTab", {
- browser: "browser-guid",
- context: "context-guid",
- page: "page-one",
- });
- const second = request(2, "selectTab", {
- browser: "browser-guid",
- context: "context-guid",
- page: "page-two",
- });
-
- assert.equal(gate.inspect(first, false).action, "forward");
- assert.deepEqual(gate.inspect(second, false), {
- action: "reject",
- method: "selectTab",
- requestId: 2,
- reason: "source_already_selected",
- });
-});
-
-test("rejects malformed, oversized and binary Dashboard messages", () => {
- const gate = new BrowserDashboardReadOnlyGate();
-
- assert.deepEqual(gate.inspect(Buffer.from("not json"), false), {
- action: "reject",
- reason: "invalid_json",
- });
- assert.deepEqual(gate.inspect(Buffer.from("binary"), true), {
- action: "reject",
- reason: "binary_message",
- });
- assert.deepEqual(
- gate.inspect(request(1, "setVisible", { visible: true, extra: true }), false),
- {
- action: "reject",
- method: "setVisible",
- requestId: 1,
- reason: "blocked",
- },
- );
- assert.deepEqual(
- gate.inspect(Buffer.alloc(16 * 1024 + 1, 1), false),
- { action: "reject", reason: "message_too_large" },
- );
-});
diff --git a/src/server/environments/browser-dashboard-protocol.ts b/src/server/environments/browser-dashboard-protocol.ts
deleted file mode 100644
index b1dc60c..0000000
--- a/src/server/environments/browser-dashboard-protocol.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import type { RawData } from "ws";
-
-const MAX_BROWSER_DASHBOARD_CLIENT_MESSAGE_BYTES = 16 * 1024;
-const MAX_BROWSER_DASHBOARD_IDENTIFIER_LENGTH = 512;
-
-export type BrowserDashboardClientMessageDecision =
- | {
- action: "forward";
- method: "selectTab" | "setVisible";
- }
- | {
- action: "reject";
- method?: string;
- requestId?: number;
- reason: string;
- };
-
-/**
- * Allows only the two Dashboard messages required to maintain a live,
- * read-only screencast. Every page mutation remains behind Take control.
- */
-export class BrowserDashboardReadOnlyGate {
- private sourceSelected = false;
-
- inspect(
- data: RawData,
- isBinary: boolean,
- ): BrowserDashboardClientMessageDecision {
- if (isBinary) {
- return { action: "reject", reason: "binary_message" };
- }
- const body = rawDataBuffer(data);
- if (body.byteLength > MAX_BROWSER_DASHBOARD_CLIENT_MESSAGE_BYTES) {
- return { action: "reject", reason: "message_too_large" };
- }
- let parsed: unknown;
- try {
- parsed = JSON.parse(body.toString("utf8")) as unknown;
- } catch {
- return { action: "reject", reason: "invalid_json" };
- }
- if (!isRecord(parsed)) {
- return { action: "reject", reason: "invalid_request" };
- }
- const requestId =
- Number.isSafeInteger(parsed.id) && Number(parsed.id) > 0
- ? Number(parsed.id)
- : undefined;
- const method =
- typeof parsed.method === "string" ? parsed.method : undefined;
- if (!requestId || !method || !isRecord(parsed.params)) {
- return {
- action: "reject",
- ...(method ? { method } : {}),
- ...(requestId ? { requestId } : {}),
- reason: "invalid_request",
- };
- }
-
- if (
- method === "setVisible" &&
- hasOnlyKeys(parsed.params, ["visible"]) &&
- typeof parsed.params.visible === "boolean"
- ) {
- return { action: "forward", method };
- }
-
- if (
- method === "selectTab" &&
- !this.sourceSelected &&
- hasOnlyKeys(parsed.params, ["browser", "context", "page"]) &&
- validIdentifier(parsed.params.browser) &&
- validIdentifier(parsed.params.context) &&
- validIdentifier(parsed.params.page)
- ) {
- this.sourceSelected = true;
- return { action: "forward", method };
- }
-
- return {
- action: "reject",
- method,
- requestId,
- reason: method === "selectTab" ? "source_already_selected" : "blocked",
- };
- }
-}
-
-export function browserDashboardRejectedResponse(
- decision: Extract,
-) {
- if (!decision.requestId) return undefined;
- return JSON.stringify({
- id: decision.requestId,
- error:
- "The Environment Browser is view-only. Take control before interacting with the page.",
- });
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function hasOnlyKeys(
- value: Record,
- expected: readonly string[],
-) {
- const keys = Object.keys(value).sort();
- const expectedKeys = [...expected].sort();
- return (
- keys.length === expectedKeys.length &&
- keys.every((key, index) => key === expectedKeys[index])
- );
-}
-
-function validIdentifier(value: unknown) {
- return (
- typeof value === "string" &&
- value.length > 0 &&
- value.length <= MAX_BROWSER_DASHBOARD_IDENTIFIER_LENGTH
- );
-}
-
-function rawDataBuffer(data: RawData) {
- if (Buffer.isBuffer(data)) return data;
- if (Array.isArray(data)) return Buffer.concat(data);
- if (data instanceof ArrayBuffer) return Buffer.from(data);
- throw new TypeError("Unsupported Dashboard WebSocket message type");
-}
diff --git a/src/server/environments/browser-service.test.ts b/src/server/environments/browser-service.test.ts
deleted file mode 100644
index 07fc220..0000000
--- a/src/server/environments/browser-service.test.ts
+++ /dev/null
@@ -1,690 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import type { BrowserDashboardViewport } from "@/lib/environment-browser";
-import {
- EnvironmentBrowserService,
- dashboardAssetCacheControl,
- dashboardAssetPath,
- dashboardProxyPrefix,
- dashboardRedirectLocation,
- rewriteDashboardCss,
- rewriteDashboardHtml,
-} from "./browser-service";
-import type { EnvironmentRuntimeAccessService } from "./runtime-access-service";
-import type {
- EnvironmentRuntimeRecord,
- RuntimeAdapter,
-} from "@/server/runtime/types";
-import { HttpError } from "@/server/http-error";
-
-const runtimeRecord: EnvironmentRuntimeRecord = {
- id: "environment-browser",
- sandboxId: "sandbox-browser",
- workspaceVolumeId: "volume-browser",
- runtimeGeneration: 1,
- decoder: {
- supervisorCursor: 0,
- tailBase64: "",
- runtimeGeneration: 1,
- },
-};
-
-test("reuses protected coordinates but admits every HTTP and WebSocket request", async () => {
- let admissions = 0;
- let dashboardEnsures = 0;
- const dashboardRestarts: boolean[] = [];
- const runtimeAccess = {
- async withRuntimeAccess(
- userId: string,
- environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- assert.equal(userId, "user-browser");
- assert.equal(environmentId, runtimeRecord.id);
- admissions += 1;
- return operation(runtimeRecord);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const runtime = {
- async isEnvironmentBrowserTakeoverAvailable() {
- return true;
- },
- async ensureEnvironmentBrowserService(
- runtime: EnvironmentRuntimeRecord,
- restart = false,
- ) {
- assert.equal(runtime, runtimeRecord);
- dashboardEnsures += 1;
- dashboardRestarts.push(restart);
- return {
- owner: "agent" as const,
- transport: "playwright" as const,
- revision: 0,
- publicUrl: "https://dashboard.example.invalid/generated",
- requestHeaders: { "X-Sandpi-Browser-Proxy": "secret" },
- };
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- assert.deepEqual(
- await service.control("user-browser", runtimeRecord.id),
- {
- owner: "agent",
- transport: "playwright",
- revision: 0,
- takeoverAvailable: true,
- },
- );
-
- assert.deepEqual(
- await service.httpUpstream(
- "user-browser",
- runtimeRecord.id,
- "assets/app.js",
- ),
- {
- url: "https://dashboard.example.invalid/assets/app.js",
- headers: { "X-Sandpi-Browser-Proxy": "secret" },
- },
- );
- assert.deepEqual(
- await service.httpUpstream("user-browser", runtimeRecord.id, "index.html"),
- {
- url: "https://dashboard.example.invalid/index.html",
- headers: { "X-Sandpi-Browser-Proxy": "secret" },
- },
- );
- assert.deepEqual(
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined),
- {
- url: "https://dashboard.example.invalid/",
- headers: { "X-Sandpi-Browser-Proxy": "secret" },
- },
- );
- assert.deepEqual(
- await service.websocketUpstream(
- "user-browser",
- runtimeRecord.id,
- "socket-guid",
- ),
- {
- url: "wss://dashboard.example.invalid/socket-guid",
- headers: { "X-Sandpi-Browser-Proxy": "secret" },
- },
- );
- assert.equal(admissions, 6);
- assert.equal(dashboardEnsures, 1);
- assert.deepEqual(dashboardRestarts, [false]);
-});
-
-test("reuses the session probe and restarts the Dashboard only on forced recovery", async () => {
- const dashboardRestarts: boolean[] = [];
- const operations: string[] = [];
- let sessionEnsures = 0;
- let browserRestarted = true;
- const runtimeAccess = {
- async withRuntimeAccess(
- _userId: string,
- _environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- return operation(runtimeRecord);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const runtime = {
- async ensureEnvironmentBrowserSession() {
- operations.push("session");
- sessionEnsures += 1;
- return browserRestarted;
- },
- async ensureEnvironmentBrowserService(
- _runtime: EnvironmentRuntimeRecord,
- restart = false,
- ) {
- operations.push("dashboard");
- dashboardRestarts.push(restart);
- return {
- owner: "agent" as const,
- transport: "playwright" as const,
- revision: Number(restart),
- publicUrl: "https://dashboard.example.invalid",
- requestHeaders: {},
- };
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- await service.ensureSession("user-browser", runtimeRecord.id);
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
- await service.httpUpstream("user-browser", runtimeRecord.id, "index.html");
-
- browserRestarted = false;
- await service.ensureSession("user-browser", runtimeRecord.id);
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
- await service.ensureSession("user-browser", runtimeRecord.id, true);
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
-
- browserRestarted = true;
- await service.ensureSession("user-browser", runtimeRecord.id, true);
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
-
- assert.equal(sessionEnsures, 3);
- assert.deepEqual(dashboardRestarts, [false, true, true]);
- assert.deepEqual(operations, [
- "dashboard",
- "session",
- "dashboard",
- "session",
- "dashboard",
- "session",
- ]);
-});
-
-test("refreshes invalidated coordinates without restarting the AppService", async () => {
- const dashboardRestarts: boolean[] = [];
- const runtimeAccess = {
- async withRuntimeAccess(
- _userId: string,
- _environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- return operation(runtimeRecord);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const runtime = {
- async ensureEnvironmentBrowserService(
- _runtime: EnvironmentRuntimeRecord,
- restart = false,
- ) {
- dashboardRestarts.push(restart);
- return {
- owner: "agent" as const,
- transport: "playwright" as const,
- revision: 0,
- publicUrl: "https://dashboard.example.invalid",
- requestHeaders: {},
- };
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
- service.invalidate(runtimeRecord.id);
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
-
- assert.deepEqual(dashboardRestarts, [false, false]);
-});
-
-test("admits only the transport owned by the current Browser controller", async () => {
- let owner: "agent" | "human" = "agent";
- let revision = 0;
- const runtimeAccess = {
- async withRuntimeAccess(
- _userId: string,
- _environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- return operation(runtimeRecord);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const control = () => ({
- owner,
- transport: owner === "human" ? ("vnc" as const) : ("playwright" as const),
- revision,
- });
- const runtime = {
- async isEnvironmentBrowserTakeoverAvailable() {
- return true;
- },
- async updateEnvironmentBrowserControl(
- _runtime: EnvironmentRuntimeRecord,
- input: { owner: "agent" | "human" },
- ) {
- owner = input.owner;
- revision += 1;
- return control();
- },
- async ensureEnvironmentBrowserService() {
- return {
- ...control(),
- publicUrl: "https://browser.example.invalid",
- requestHeaders: {},
- };
- },
- async ensureEnvironmentBrowserSession() {
- throw new Error("human control must reject before Playwright runs");
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- assert.equal(
- (await service.control("user-browser", runtimeRecord.id)).owner,
- "agent",
- );
- assert.equal(
- (
- await service.updateControl("user-browser", runtimeRecord.id, {
- owner: "human",
- })
- ).transport,
- "vnc",
- );
- assert.deepEqual(
- await service.websocketUpstream("user-browser", runtimeRecord.id, "vnc"),
- { url: "wss://browser.example.invalid/vnc", headers: {} },
- );
- await assert.rejects(
- service.httpUpstream("user-browser", runtimeRecord.id, undefined),
- (error) =>
- error instanceof HttpError &&
- error.code === "environment_browser_under_human_control",
- );
- await assert.rejects(
- service.ensureSession("user-browser", runtimeRecord.id),
- (error) =>
- error instanceof HttpError &&
- error.code === "environment_browser_under_human_control",
- );
-
- await service.updateControl("user-browser", runtimeRecord.id, {
- owner: "agent",
- });
- await assert.rejects(
- service.websocketUpstream("user-browser", runtimeRecord.id, "vnc"),
- (error) =>
- error instanceof HttpError &&
- error.code === "environment_browser_under_agent_control",
- );
- assert.equal(
- (
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined)
- ).url,
- "https://browser.example.invalid/",
- );
-});
-
-test("linearizes the first Browser install with a concurrent takeover", async () => {
- let owner: "agent" | "human" = "agent";
- let revision = 0;
- let releaseInstall: (() => void) | undefined;
- const installBlocked = new Promise((resolve) => {
- releaseInstall = resolve;
- });
- let installStarted: (() => void) | undefined;
- const started = new Promise((resolve) => {
- installStarted = resolve;
- });
- const events: string[] = [];
- const runtimeAccess = {
- async withRuntimeAccess(
- _userId: string,
- _environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- return operation(runtimeRecord);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const current = () => ({
- owner,
- transport: owner === "human" ? ("vnc" as const) : ("playwright" as const),
- revision,
- publicUrl: "https://browser.example.invalid",
- requestHeaders: {},
- });
- const runtime = {
- async ensureEnvironmentBrowserService() {
- events.push("install-start");
- installStarted?.();
- await installBlocked;
- events.push("install-finish");
- return current();
- },
- async updateEnvironmentBrowserControl(
- _runtime: EnvironmentRuntimeRecord,
- input: { owner: "agent" | "human" },
- ) {
- events.push("takeover");
- owner = input.owner;
- revision += 1;
- return current();
- },
- async isEnvironmentBrowserTakeoverAvailable() {
- events.push("capability");
- return true;
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- const initial = service.control("user-browser", runtimeRecord.id);
- await started;
- const takeover = service.updateControl("user-browser", runtimeRecord.id, {
- owner: "human",
- });
- await Promise.resolve();
- assert.deepEqual(events, ["install-start"]);
-
- releaseInstall?.();
- assert.equal((await initial).owner, "agent");
- assert.equal((await takeover).owner, "human");
- assert.deepEqual(events, ["install-start", "install-finish", "takeover"]);
- assert.equal(
- (await service.control("user-browser", runtimeRecord.id)).owner,
- "human",
- );
-});
-
-test("caches takeover capability within one runtime generation", async () => {
- let currentRuntime = runtimeRecord;
- let available = false;
- let capabilityChecks = 0;
- const runtimeAccess = {
- async withRuntimeAccess(
- _userId: string,
- _environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- return operation(currentRuntime);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const runtime = {
- async ensureEnvironmentBrowserService() {
- return {
- owner: "agent" as const,
- transport: "playwright" as const,
- revision: 0,
- publicUrl: "https://dashboard.example.invalid",
- requestHeaders: {},
- };
- },
- async isEnvironmentBrowserTakeoverAvailable() {
- capabilityChecks += 1;
- return available;
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- assert.equal(
- (await service.control("user-browser", runtimeRecord.id))
- .takeoverAvailable,
- false,
- );
- assert.equal(
- (await service.control("user-browser", runtimeRecord.id))
- .takeoverAvailable,
- false,
- );
- assert.equal(capabilityChecks, 1);
-
- available = true;
- currentRuntime = {
- ...runtimeRecord,
- runtimeGeneration: runtimeRecord.runtimeGeneration + 1,
- };
- assert.equal(
- (await service.control("user-browser", runtimeRecord.id))
- .takeoverAvailable,
- true,
- );
- assert.equal(capabilityChecks, 2);
-});
-
-test("deduplicates viewport updates within one runtime generation", async () => {
- const calls: Array<{
- runtime: EnvironmentRuntimeRecord;
- width: number;
- height: number;
- }> = [];
- const runtimeAccess = {
- async withRuntimeAccess(
- userId: string,
- environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- assert.equal(userId, "user-browser");
- assert.equal(environmentId, runtimeRecord.id);
- return operation(runtimeRecord);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const runtime = {
- async ensureEnvironmentBrowserService() {
- return {
- owner: "agent" as const,
- transport: "playwright" as const,
- revision: 0,
- publicUrl: "https://dashboard.example.invalid",
- requestHeaders: {},
- };
- },
- async resizeEnvironmentBrowserViewport(
- runtime: EnvironmentRuntimeRecord,
- viewport: { width: number; height: number },
- ) {
- calls.push({ runtime, ...viewport });
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- await service.resizeViewport("user-browser", runtimeRecord.id, {
- width: 519,
- height: 759,
- });
- await service.resizeViewport("user-browser", runtimeRecord.id, {
- width: 519,
- height: 759,
- });
-
- assert.deepEqual(calls, [
- {
- runtime: runtimeRecord,
- width: 519,
- height: 759,
- },
- ]);
-});
-
-test("coalesces intermediate viewport updates while one resize is running", async () => {
- const calls: BrowserDashboardViewport[] = [];
- let releaseFirst: (() => void) | undefined;
- const firstResize = new Promise((resolve) => {
- releaseFirst = resolve;
- });
- const runtimeAccess = {
- async withRuntimeAccess(
- _userId: string,
- _environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- return operation(runtimeRecord);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const runtime = {
- async ensureEnvironmentBrowserService() {
- return {
- owner: "agent" as const,
- transport: "playwright" as const,
- revision: 0,
- publicUrl: "https://dashboard.example.invalid",
- requestHeaders: {},
- };
- },
- async resizeEnvironmentBrowserViewport(
- _runtime: EnvironmentRuntimeRecord,
- viewport: BrowserDashboardViewport,
- ) {
- calls.push(viewport);
- if (calls.length === 1) await firstResize;
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- const first = service.resizeViewport("user-browser", runtimeRecord.id, {
- width: 500,
- height: 700,
- });
- const middle = service.resizeViewport("user-browser", runtimeRecord.id, {
- width: 600,
- height: 700,
- });
- const latest = service.resizeViewport("user-browser", runtimeRecord.id, {
- width: 700,
- height: 700,
- });
-
- await new Promise((resolve) => setImmediate(resolve));
- assert.deepEqual(calls, [{ width: 500, height: 700 }]);
- releaseFirst?.();
- await Promise.all([first, middle, latest]);
- assert.deepEqual(calls, [
- { width: 500, height: 700 },
- { width: 700, height: 700 },
- ]);
-});
-
-test("refreshes Dashboard coordinates after the runtime generation changes", async () => {
- let currentRuntime = runtimeRecord;
- let dashboardEnsures = 0;
- const runtimeAccess = {
- async withRuntimeAccess(
- _userId: string,
- _environmentId: string,
- operation: (runtime: EnvironmentRuntimeRecord) => Promise,
- ) {
- return operation(currentRuntime);
- },
- } as unknown as EnvironmentRuntimeAccessService;
- const runtime = {
- async ensureEnvironmentBrowserService() {
- dashboardEnsures += 1;
- return {
- owner: "agent" as const,
- transport: "playwright" as const,
- revision: 0,
- publicUrl: "https://dashboard.example.invalid",
- requestHeaders: {},
- };
- },
- } as unknown as RuntimeAdapter;
- const service = new EnvironmentBrowserService(runtimeAccess, runtime);
-
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
- currentRuntime = {
- ...runtimeRecord,
- runtimeGeneration: runtimeRecord.runtimeGeneration + 1,
- };
- await service.httpUpstream("user-browser", runtimeRecord.id, undefined);
-
- assert.equal(dashboardEnsures, 2);
-});
-
-test("maps only official Dashboard static paths", () => {
- assert.equal(dashboardAssetPath(undefined), "/");
- assert.equal(dashboardAssetPath("index.html"), "/index.html");
- assert.equal(
- dashboardAssetPath("assets/index-BY2S1tHT.css"),
- "/assets/index-BY2S1tHT.css",
- );
- assert.throws(() => dashboardAssetPath("../credential"));
- assert.throws(() => dashboardAssetPath("other/runtime.json"));
-});
-
-test("caches only static Dashboard assets with bounded private freshness", () => {
- assert.equal(
- dashboardAssetCacheControl(undefined, "public, max-age=14400"),
- "private, no-store",
- );
- assert.equal(
- dashboardAssetCacheControl("index.html", "public, max-age=14400"),
- "private, no-store",
- );
- assert.equal(
- dashboardAssetCacheControl(
- "assets/index-BY2S1tHT.css",
- "public, max-age=14400",
- ),
- "private, max-age=86400, immutable",
- );
- assert.equal(
- dashboardAssetCacheControl(
- "playwright-logo.svg",
- "public, max-age=999999",
- ),
- "private, max-age=86400",
- );
- assert.equal(
- dashboardAssetCacheControl("assets/runtime.js", null),
- "private, max-age=3600",
- );
-});
-
-test("rewrites the official Dashboard redirect and root-relative assets", () => {
- const prefix = dashboardProxyPrefix("environment one");
- assert.equal(
- dashboardRedirectLocation("/index.html?ws=socket-guid", prefix),
- "/api/v1/environments/environment%20one/browser/index.html?ws=api%2Fv1%2Fenvironments%2Fenvironment%2520one%2Fbrowser%2Fws%2Fsocket-guid",
- );
- assert.equal(
- rewriteDashboardHtml(
- '',
- prefix,
- ),
- '',
- );
- assert.equal(
- rewriteDashboardCss(
- '@font-face{src:url(/assets/codicon.ttf)}.external{src:url("https://example.com/font.woff2")}',
- prefix,
- ),
- '@font-face{src:url(/api/v1/environments/environment%20one/browser/assets/codicon.ttf)}.external{src:url("https://example.com/font.woff2")}',
- );
-});
-
-test("embeds Sandpi layout and theme control into the official Dashboard", () => {
- const rewritten = rewriteDashboardHtml(
- `
-
-
-
-
-
-
-`,
- "/api/v1/environments/environment/browser",
- );
-
- assert.match(rewritten, /data-sandpi-browser-dashboard/);
- assert.match(rewritten, /sandpi:browser-dashboard-ready/);
- assert.match(rewritten, /sandpi:browser-dashboard-theme/);
- assert.match(
- rewritten,
- /#root > \.split-view\.horizontal\.sidebar-first > \.split-view-sidebar/,
- );
- assert.match(rewritten, /\.browser-window \{/);
- assert.match(rewritten, /width: 100% !important/);
- assert.match(rewritten, /--color-canvas-default/);
- assert.match(
- rewritten,
- /src="\/api\/v1\/environments\/environment\/browser\/assets\/index\.js"/,
- );
- assert.match(
- rewritten,
- /href="\/api\/v1\/environments\/environment\/browser\/assets\/index\.css"/,
- );
-});
-
-test("rejects redirects that are not Dashboard socket handoffs", () => {
- assert.equal(
- dashboardRedirectLocation("https://example.com/index.html?ws=socket", "/browser"),
- "/browser/index.html?ws=browser%2Fws%2Fsocket",
- );
- assert.equal(
- dashboardRedirectLocation("/elsewhere?ws=socket", "/browser"),
- undefined,
- );
- assert.equal(
- dashboardRedirectLocation("/index.html?ws=../../socket", "/browser"),
- undefined,
- );
-});
diff --git a/src/server/environments/browser-service.ts b/src/server/environments/browser-service.ts
deleted file mode 100644
index b8cd0fa..0000000
--- a/src/server/environments/browser-service.ts
+++ /dev/null
@@ -1,573 +0,0 @@
-import type { EnvironmentRuntimeAccessService } from "./runtime-access-service";
-import { embedBrowserDashboard } from "./browser-dashboard-embed";
-import { HttpError } from "@/server/http-error";
-import type {
- EnvironmentRuntimeRecord,
- RuntimeAdapter,
- RuntimeBrowserUpstream,
-} from "@/server/runtime/types";
-import type {
- BrowserDashboardViewport,
- EnvironmentBrowserControl,
- EnvironmentBrowserOwner,
- EnvironmentBrowserOwnership,
-} from "@/lib/environment-browser";
-
-export interface BrowserDashboardUpstream {
- url: string;
- headers: Record;
-}
-
-interface CachedBrowserDashboard {
- runtimeGeneration: number;
- restartRevision: number;
- pending: Promise;
-}
-
-interface CachedBrowserSession {
- runtimeGeneration: number;
- pending: Promise;
-}
-
-interface CachedBrowserTakeoverCapability {
- runtimeGeneration: number;
- pending: Promise;
-}
-
-interface BrowserViewportState {
- runtimeGeneration: number;
- applied?: BrowserDashboardViewport;
- inFlight?: BrowserDashboardViewport;
- queued?: BrowserDashboardViewport;
- pending?: Promise;
-}
-
-const DASHBOARD_SOCKET_ID = /^[A-Za-z0-9-]{1,128}$/;
-const DASHBOARD_ASSET_PATH =
- /^(?:index\.html|playwright-logo\.svg|assets\/[A-Za-z0-9._-]+)$/;
-const DASHBOARD_FINGERPRINTED_ASSET =
- /^assets\/.+-[A-Za-z0-9_-]{6,}\.[A-Za-z0-9]+$/;
-const DASHBOARD_MAX_BROWSER_CACHE_SECONDS = 24 * 60 * 60;
-
-/**
- * Owns lifecycle, authorization and the exclusive human/agent handoff for one
- * Environment browser profile. Page automation remains in Playwright, while
- * human input remains an opaque VNC transport.
- */
-export class EnvironmentBrowserService {
- private readonly dashboards = new Map();
- private readonly dashboardRestartRevisions = new Map();
- private readonly appliedDashboardRestartRevisions = new Map();
- private readonly sessions = new Map();
- private readonly takeoverCapabilities = new Map<
- string,
- CachedBrowserTakeoverCapability
- >();
- private readonly viewports = new Map();
- private readonly owners = new Map();
- private readonly serviceOperations = new Map>();
-
- constructor(
- private readonly runtimeAccess: EnvironmentRuntimeAccessService,
- private readonly runtime: RuntimeAdapter,
- ) {}
-
- async ensureSession(
- userId: string,
- environmentId: string,
- force = false,
- ) {
- if (force) this.restartDashboard(environmentId);
- // Sandbox0 does not reliably admit updateServices and cmd concurrently.
- // Serialize only the short service configuration; Chromium startup then
- // overlaps the Dashboard's ingress and health-check startup.
- requireAgentControl(await this.dashboard(userId, environmentId));
- await this.runtimeAccess.withRuntimeAccess(
- userId,
- environmentId,
- (runtime) => this.ensureRuntimeSession(runtime, force),
- );
- }
-
- async resizeViewport(
- userId: string,
- environmentId: string,
- viewport: BrowserDashboardViewport,
- ) {
- const owner = this.owners.get(environmentId);
- if (owner) {
- requireAgentControl({ owner });
- } else {
- requireAgentControl(await this.dashboard(userId, environmentId));
- }
- await this.runtimeAccess.withRuntimeAccess(
- userId,
- environmentId,
- (runtime) => this.queueViewportResize(runtime, viewport),
- );
- }
-
- async httpUpstream(
- userId: string,
- environmentId: string,
- assetPath: string | undefined,
- ): Promise {
- const dashboard = await this.dashboard(userId, environmentId);
- requireAgentControl(dashboard);
- const target = new URL(dashboard.publicUrl);
- target.pathname = dashboardAssetPath(assetPath);
- target.search = "";
- target.hash = "";
- return {
- url: target.toString(),
- headers: { ...dashboard.requestHeaders },
- };
- }
-
- async websocketUpstream(
- userId: string,
- environmentId: string,
- socketId: string,
- ): Promise {
- if (!DASHBOARD_SOCKET_ID.test(socketId)) {
- throw new HttpError(
- 400,
- "invalid_environment_browser_socket",
- "Invalid Environment browser socket id.",
- );
- }
- const dashboard = await this.dashboard(userId, environmentId);
- if (socketId === "vnc") {
- requireHumanControl(dashboard);
- } else {
- requireAgentControl(dashboard);
- }
- const target = new URL(dashboard.publicUrl);
- target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
- target.pathname = `/${socketId}`;
- target.search = "";
- target.hash = "";
- return {
- url: target.toString(),
- headers: { ...dashboard.requestHeaders },
- };
- }
-
- invalidate(environmentId: string) {
- // Transport failures and lifecycle transitions invalidate coordinates,
- // but the Sandbox-native AppService already owns process recovery. Do not
- // rewrite and restart a healthy recovered Dashboard merely because its old
- // WebSocket closed.
- this.dashboards.delete(environmentId);
- this.viewports.delete(environmentId);
- this.owners.delete(environmentId);
- }
-
- async control(
- userId: string,
- environmentId: string,
- ): Promise {
- const ownership = await this.dashboard(
- userId,
- environmentId,
- );
- return this.withTakeoverCapability(userId, environmentId, ownership);
- }
-
- async updateControl(
- userId: string,
- environmentId: string,
- input: { owner: EnvironmentBrowserOwner; force?: boolean },
- ) {
- return this.serializeServiceOperation(environmentId, async () => {
- const control = await this.runtimeAccess.withRuntimeAccess(
- userId,
- environmentId,
- async (runtime) => {
- const ownership =
- await this.runtime.updateEnvironmentBrowserControl(runtime, input);
- const takeoverAvailable =
- ownership.owner === "human"
- ? this.cacheTakeoverCapability(runtime, true)
- : await this.takeoverCapabilityForRuntime(runtime);
- return publicBrowserControl(ownership, takeoverAvailable);
- },
- );
- this.invalidateControlState(environmentId);
- this.owners.set(environmentId, control.owner);
- return control;
- });
- }
-
- private async withTakeoverCapability(
- userId: string,
- environmentId: string,
- ownership: EnvironmentBrowserOwnership,
- ): Promise {
- if (ownership.owner === "human") {
- return publicBrowserControl(ownership, true);
- }
- const takeoverAvailable = await this.serializeServiceOperation(
- environmentId,
- () =>
- this.runtimeAccess.withRuntimeAccess(
- userId,
- environmentId,
- (runtime) => this.takeoverCapabilityForRuntime(runtime),
- ),
- );
- return publicBrowserControl(ownership, takeoverAvailable);
- }
-
- private async takeoverCapabilityForRuntime(
- runtime: EnvironmentRuntimeRecord,
- ) {
- const cached = this.takeoverCapabilities.get(runtime.id);
- if (cached?.runtimeGeneration === runtime.runtimeGeneration) {
- return cached.pending;
- }
- const pending = this.runtime.isEnvironmentBrowserTakeoverAvailable(runtime);
- const entry = { runtimeGeneration: runtime.runtimeGeneration, pending };
- this.takeoverCapabilities.set(runtime.id, entry);
- try {
- return await pending;
- } catch (error) {
- if (this.takeoverCapabilities.get(runtime.id) === entry) {
- this.takeoverCapabilities.delete(runtime.id);
- }
- throw error;
- }
- }
-
- private cacheTakeoverCapability(
- runtime: EnvironmentRuntimeRecord,
- available: boolean,
- ) {
- this.takeoverCapabilities.set(runtime.id, {
- runtimeGeneration: runtime.runtimeGeneration,
- pending: Promise.resolve(available),
- });
- return available;
- }
-
- private async dashboard(userId: string, environmentId: string) {
- // A fresh AppService install and an ownership handoff are both full-spec
- // replacements. Linearize them so a concurrent first mount cannot restore
- // an agent-owned service after human takeover has committed.
- if (
- !this.dashboards.has(environmentId) ||
- this.serviceOperations.has(environmentId)
- ) {
- return this.ensureDashboard(userId, environmentId);
- }
-
- // Every request still crosses ownership and lifecycle admission. The cache
- // only avoids rewriting an identical AppService and is fenced by Sandbox0's
- // authoritative runtime generation.
- const cached = await this.runtimeAccess.withRuntimeAccess<
- RuntimeBrowserUpstream | undefined
- >(
- userId,
- environmentId,
- async (runtime) => {
- const restartRevision =
- this.dashboardRestartRevisions.get(environmentId) ?? 0;
- const entry = this.dashboards.get(environmentId);
- if (
- entry?.runtimeGeneration === runtime.runtimeGeneration &&
- entry.restartRevision === restartRevision
- ) {
- return entry.pending;
- }
- return undefined;
- },
- );
- if (cached) return cached;
-
- // Runtime-generation changes are rare. Re-admit after entering the service
- // queue so the replacement cannot race an ownership update.
- return this.ensureDashboard(userId, environmentId);
- }
-
- private ensureDashboard(
- userId: string,
- environmentId: string,
- ): Promise {
- return this.serializeServiceOperation(
- environmentId,
- () =>
- this.runtimeAccess.withRuntimeAccess(
- userId,
- environmentId,
- async (runtime) => {
- const restartRevision =
- this.dashboardRestartRevisions.get(environmentId) ?? 0;
- const cached = this.dashboards.get(environmentId);
- if (
- cached?.runtimeGeneration === runtime.runtimeGeneration &&
- cached.restartRevision === restartRevision
- ) {
- return cached.pending;
- }
-
- const restart =
- restartRevision >
- (this.appliedDashboardRestartRevisions.get(environmentId) ?? 0);
- const pending = this.runtime.ensureEnvironmentBrowserService(
- runtime,
- restart,
- );
- const entry: CachedBrowserDashboard = {
- runtimeGeneration: runtime.runtimeGeneration,
- restartRevision,
- pending,
- };
- this.dashboards.set(environmentId, entry);
- try {
- const dashboard = await pending;
- this.owners.set(environmentId, dashboard.owner);
- if (this.dashboards.get(environmentId) === entry) {
- this.appliedDashboardRestartRevisions.set(
- environmentId,
- restartRevision,
- );
- }
- return dashboard;
- } catch (error) {
- if (this.dashboards.get(environmentId) === entry) {
- this.dashboards.delete(environmentId);
- }
- throw error;
- }
- },
- ),
- );
- }
-
- private serializeServiceOperation(
- environmentId: string,
- operation: () => Promise,
- ): Promise {
- const previous = this.serviceOperations.get(environmentId);
- const pending = (previous
- ? previous.catch(() => undefined)
- : Promise.resolve()
- ).then(operation);
- this.serviceOperations.set(environmentId, pending);
- void pending
- .finally(() => {
- if (this.serviceOperations.get(environmentId) === pending) {
- this.serviceOperations.delete(environmentId);
- }
- })
- .catch(() => undefined);
- return pending;
- }
-
- private restartDashboard(environmentId: string) {
- this.invalidate(environmentId);
- this.dashboardRestartRevisions.set(
- environmentId,
- (this.dashboardRestartRevisions.get(environmentId) ?? 0) + 1,
- );
- }
-
- private invalidateControlState(environmentId: string) {
- this.invalidate(environmentId);
- this.sessions.delete(environmentId);
- this.dashboardRestartRevisions.delete(environmentId);
- this.appliedDashboardRestartRevisions.delete(environmentId);
- }
-
- private async ensureRuntimeSession(
- runtime: EnvironmentRuntimeRecord,
- force: boolean,
- ) {
- const cached = this.sessions.get(runtime.id);
- if (
- !force &&
- cached?.runtimeGeneration === runtime.runtimeGeneration
- ) {
- await cached.pending;
- return false;
- }
-
- const pending = this.runtime.ensureEnvironmentBrowserSession(runtime);
- const entry: CachedBrowserSession = {
- runtimeGeneration: runtime.runtimeGeneration,
- pending,
- };
- this.sessions.set(runtime.id, entry);
- try {
- return await pending;
- } catch (error) {
- if (this.sessions.get(runtime.id) === entry) {
- this.sessions.delete(runtime.id);
- }
- throw error;
- }
- }
-
- private queueViewportResize(
- runtime: EnvironmentRuntimeRecord,
- viewport: BrowserDashboardViewport,
- ) {
- let state = this.viewports.get(runtime.id);
- if (!state || state.runtimeGeneration !== runtime.runtimeGeneration) {
- state = { runtimeGeneration: runtime.runtimeGeneration };
- this.viewports.set(runtime.id, state);
- }
-
- const target = state.queued ?? state.inFlight ?? state.applied;
- if (sameViewport(target, viewport)) {
- return state.pending ?? Promise.resolve();
- }
- state.queued = viewport;
- if (!state.pending) {
- const current = state;
- current.pending = this.flushViewportResizes(runtime, current)
- .catch((error) => {
- current.queued = undefined;
- throw error;
- })
- .finally(() => {
- if (this.viewports.get(runtime.id) === current) {
- current.pending = undefined;
- current.inFlight = undefined;
- }
- });
- }
- return state.pending!;
- }
-
- private async flushViewportResizes(
- runtime: EnvironmentRuntimeRecord,
- state: BrowserViewportState,
- ) {
- while (state.queued) {
- const viewport = state.queued;
- state.queued = undefined;
- if (sameViewport(state.applied, viewport)) continue;
- state.inFlight = viewport;
- await this.runtime.resizeEnvironmentBrowserViewport(runtime, viewport);
- state.applied = viewport;
- state.inFlight = undefined;
- }
- }
-}
-
-function publicBrowserControl(
- ownership: EnvironmentBrowserOwnership,
- takeoverAvailable: boolean,
-): EnvironmentBrowserControl {
- return {
- owner: ownership.owner,
- transport: ownership.transport,
- revision: ownership.revision,
- takeoverAvailable,
- };
-}
-
-function sameViewport(
- left: BrowserDashboardViewport | undefined,
- right: BrowserDashboardViewport,
-) {
- return left?.width === right.width && left.height === right.height;
-}
-
-function requireAgentControl(control: { owner: EnvironmentBrowserOwner }) {
- if (control.owner === "agent") return;
- throw new HttpError(
- 409,
- "environment_browser_under_human_control",
- "The Environment browser is under human control. Return it to the agent before using Playwright.",
- );
-}
-
-function requireHumanControl(control: { owner: EnvironmentBrowserOwner }) {
- if (control.owner === "human") return;
- throw new HttpError(
- 409,
- "environment_browser_under_agent_control",
- "Take control of the Environment browser before opening the interactive viewer.",
- );
-}
-
-export function dashboardProxyPrefix(environmentId: string) {
- return `/api/v1/environments/${encodeURIComponent(environmentId)}/browser`;
-}
-
-export function dashboardAssetPath(value: string | undefined) {
- const normalized = (value ?? "").replace(/^\/+|\/+$/g, "");
- if (normalized === "") return "/";
- if (!DASHBOARD_ASSET_PATH.test(normalized)) {
- throw new HttpError(
- 404,
- "environment_browser_asset_not_found",
- "Playwright Dashboard asset not found.",
- );
- }
- return `/${normalized}`;
-}
-
-export function dashboardAssetCacheControl(
- assetPath: string | undefined,
- upstreamCacheControl: string | null,
-) {
- const normalized = assetPath?.replace(/^\/+|\/+$/g, "");
- if (!normalized || normalized === "index.html") {
- return "private, no-store";
- }
- const upstreamMaxAge = /(?:^|,)\s*max-age=(\d+)/i.exec(
- upstreamCacheControl ?? "",
- )?.[1];
- const fingerprinted = DASHBOARD_FINGERPRINTED_ASSET.test(normalized);
- const maxAge = fingerprinted
- ? DASHBOARD_MAX_BROWSER_CACHE_SECONDS
- : Math.min(
- upstreamMaxAge ? Number(upstreamMaxAge) : 60 * 60,
- DASHBOARD_MAX_BROWSER_CACHE_SECONDS,
- );
- const immutable = fingerprinted ? ", immutable" : "";
- return `private, max-age=${maxAge}${immutable}`;
-}
-
-export function dashboardRedirectLocation(
- location: string | null,
- proxyPrefix: string,
-) {
- if (!location) return undefined;
- let target: URL;
- try {
- target = new URL(location, "https://playwright-dashboard.invalid");
- } catch {
- return undefined;
- }
- const socketId = target.searchParams.get("ws") ?? "";
- if (
- target.pathname !== "/index.html" ||
- !DASHBOARD_SOCKET_ID.test(socketId)
- ) {
- return undefined;
- }
- const dashboardSocketPath = `${proxyPrefix.replace(
- /^\/+/,
- "",
- )}/ws/${socketId}`;
- return `${proxyPrefix}/index.html?ws=${encodeURIComponent(dashboardSocketPath)}`;
-}
-
-export function rewriteDashboardHtml(html: string, proxyPrefix: string) {
- const rewritten = html.replace(
- /((?:src|href)=["'])\/(?!\/)/g,
- `$1${proxyPrefix}/`,
- );
- return embedBrowserDashboard(rewritten);
-}
-
-export function rewriteDashboardCss(css: string, proxyPrefix: string) {
- return css.replace(
- /url\((\s*)(["']?)\/(?!\/)/g,
- `url($1$2${proxyPrefix}/`,
- );
-}
diff --git a/src/server/environments/browser-websocket-relay.test.ts b/src/server/environments/browser-websocket-relay.test.ts
deleted file mode 100644
index da479cd..0000000
--- a/src/server/environments/browser-websocket-relay.test.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import type { RawData } from "ws";
-
-import {
- BrowserWebSocketDownstreamRelay,
- isPlaywrightDashboardFrameMessage,
-} from "./browser-websocket-relay";
-
-function message(method: string, value: number) {
- return Buffer.from(
- JSON.stringify({ method, params: { value } }),
- "utf8",
- );
-}
-
-test("recognizes only Playwright text frame events", () => {
- assert.equal(
- isPlaywrightDashboardFrameMessage(message("frame", 1), false),
- true,
- );
- assert.equal(
- isPlaywrightDashboardFrameMessage(message("tabs", 1), false),
- false,
- );
- assert.equal(
- isPlaywrightDashboardFrameMessage(message("frame", 1), true),
- false,
- );
- assert.equal(
- isPlaywrightDashboardFrameMessage(
- Buffer.from('{"id":1,"result":{"method":"frame"}}'),
- false,
- ),
- false,
- );
-});
-
-test("coalesces stale frames while preserving control messages", () => {
- const sent: string[] = [];
- const completions: Array<(error?: Error) => void> = [];
- const relay = new BrowserWebSocketDownstreamRelay({
- maxQueuedBytes: 1024 * 1024,
- send(data: RawData, _isBinary, callback) {
- sent.push(Buffer.from(data as Buffer).toString("utf8"));
- completions.push(callback);
- },
- onOverflow() {
- assert.fail("relay unexpectedly overflowed");
- },
- onSendError(error) {
- assert.fail(error.message);
- },
- });
-
- relay.enqueue(message("frame", 1), false);
- relay.enqueue(message("frame", 2), false);
- relay.enqueue(message("frame", 3), false);
- relay.enqueue(message("tabs", 4), false);
-
- assert.deepEqual(
- sent.map((value) => JSON.parse(value).params.value),
- [1],
- );
- completions.shift()?.();
- assert.deepEqual(
- sent.map((value) => JSON.parse(value).params.value),
- [1, 4],
- );
- completions.shift()?.();
- assert.deepEqual(
- sent.map((value) => JSON.parse(value).params.value),
- [1, 4, 3],
- );
- completions.shift()?.();
- assert.deepEqual(relay.stats(), {
- receivedFrames: 3,
- forwardedFrames: 2,
- receivedFrameBytes:
- message("frame", 1).byteLength +
- message("frame", 2).byteLength +
- message("frame", 3).byteLength,
- coalescedFrames: 1,
- peakQueuedBytes:
- message("frame", 3).byteLength + message("tabs", 4).byteLength,
- });
-});
-
-test("bounds queued control traffic and reports send failures", () => {
- let overflows = 0;
- let sendError = "";
- const relay = new BrowserWebSocketDownstreamRelay({
- maxQueuedBytes: 64,
- send(_data, _isBinary, callback) {
- callback(new Error("downstream failed"));
- },
- onOverflow() {
- overflows += 1;
- },
- onSendError(error) {
- sendError = error.message;
- },
- });
-
- relay.enqueue(message("tabs", 1), false);
- assert.equal(sendError, "downstream failed");
-
- const blocked = new BrowserWebSocketDownstreamRelay({
- maxQueuedBytes: 4,
- send() {
- assert.fail("oversized traffic must not be sent");
- },
- onOverflow() {
- overflows += 1;
- },
- onSendError() {
- assert.fail("overflow is not a send error");
- },
- });
- blocked.enqueue(Buffer.from("oversized"), false);
- assert.equal(overflows, 1);
-});
diff --git a/src/server/environments/browser-websocket-relay.ts b/src/server/environments/browser-websocket-relay.ts
deleted file mode 100644
index 9db247a..0000000
--- a/src/server/environments/browser-websocket-relay.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-import { Buffer } from "node:buffer";
-
-import type { RawData } from "ws";
-
-interface RelayMessage {
- data: RawData;
- isBinary: boolean;
- isFrame: boolean;
- size: number;
-}
-
-export interface BrowserWebSocketRelayStats {
- receivedFrames: number;
- forwardedFrames: number;
- receivedFrameBytes: number;
- coalescedFrames: number;
- peakQueuedBytes: number;
-}
-
-interface BrowserWebSocketRelayOptions {
- maxQueuedBytes: number;
- send: (
- data: RawData,
- isBinary: boolean,
- callback: (error?: Error) => void,
- ) => void;
- onOverflow: () => void;
- onSendError: (error: Error) => void;
-}
-
-/**
- * Preserves every Dashboard control message while bounding screencast latency.
- * When the downstream cannot keep up, only the newest unsent `frame` event is
- * retained; RPC results, session events and errors keep their original order.
- */
-export class BrowserWebSocketDownstreamRelay {
- private readonly controls: RelayMessage[] = [];
- private latestFrame: RelayMessage | undefined;
- private queuedBytes = 0;
- private sending = false;
- private sendFrameBeforeNextControl = false;
- private closed = false;
- private receivedFrames = 0;
- private forwardedFrames = 0;
- private receivedFrameBytes = 0;
- private coalescedFrames = 0;
- private peakQueuedBytes = 0;
-
- constructor(private readonly options: BrowserWebSocketRelayOptions) {}
-
- enqueue(data: RawData, isBinary: boolean) {
- if (this.closed) return;
- const message = {
- data,
- isBinary,
- isFrame: isPlaywrightDashboardFrameMessage(data, isBinary),
- size: websocketRawDataSize(data),
- };
- if (message.size > this.options.maxQueuedBytes) {
- this.overflow();
- return;
- }
-
- if (message.isFrame) {
- this.receivedFrames += 1;
- this.receivedFrameBytes += message.size;
- if (this.latestFrame) {
- this.queuedBytes -= this.latestFrame.size;
- this.coalescedFrames += 1;
- }
- this.latestFrame = message;
- if (this.sending && this.controls.length > 0) {
- this.sendFrameBeforeNextControl = true;
- }
- } else {
- this.controls.push(message);
- }
- this.queuedBytes += message.size;
- this.peakQueuedBytes = Math.max(
- this.peakQueuedBytes,
- this.queuedBytes,
- );
- if (this.queuedBytes > this.options.maxQueuedBytes) {
- this.overflow();
- return;
- }
- this.flush();
- }
-
- close() {
- this.closed = true;
- this.controls.length = 0;
- this.latestFrame = undefined;
- this.queuedBytes = 0;
- }
-
- stats(): BrowserWebSocketRelayStats {
- return {
- receivedFrames: this.receivedFrames,
- forwardedFrames: this.forwardedFrames,
- receivedFrameBytes: this.receivedFrameBytes,
- coalescedFrames: this.coalescedFrames,
- peakQueuedBytes: this.peakQueuedBytes,
- };
- }
-
- private flush() {
- if (this.closed || this.sending) return;
- let message: RelayMessage | undefined;
- if (
- this.latestFrame &&
- (this.sendFrameBeforeNextControl || this.controls.length === 0)
- ) {
- message = this.takeLatestFrame();
- this.sendFrameBeforeNextControl = false;
- } else {
- message = this.controls.shift();
- if (message && this.latestFrame) {
- this.sendFrameBeforeNextControl = true;
- }
- }
- if (!message) return;
- this.queuedBytes -= message.size;
- this.sending = true;
- try {
- this.options.send(message.data, message.isBinary, (error) => {
- this.sending = false;
- if (error) {
- this.close();
- this.options.onSendError(error);
- return;
- }
- if (message.isFrame) this.forwardedFrames += 1;
- this.flush();
- });
- } catch (error) {
- this.sending = false;
- this.close();
- this.options.onSendError(
- error instanceof Error ? error : new Error("WebSocket send failed"),
- );
- }
- }
-
- private takeLatestFrame() {
- const frame = this.latestFrame;
- this.latestFrame = undefined;
- return frame;
- }
-
- private overflow() {
- this.close();
- this.options.onOverflow();
- }
-}
-
-export function isPlaywrightDashboardFrameMessage(
- data: RawData,
- isBinary: boolean,
-) {
- if (isBinary) return false;
- const prefix = websocketRawDataPrefix(data, 128);
- // Playwright's protocol serializer emits event envelopes with `method`
- // first. If that upstream shape changes, fail open and preserve the message.
- return /^\s*\{\s*"method"\s*:\s*"frame"\s*,/.test(prefix);
-}
-
-export function websocketRawDataSize(data: RawData) {
- if (Array.isArray(data)) {
- return data.reduce((size, chunk) => size + chunk.byteLength, 0);
- }
- return data.byteLength;
-}
-
-function websocketRawDataPrefix(data: RawData, maximumBytes: number) {
- if (Array.isArray(data)) {
- let remaining = maximumBytes;
- const chunks: Buffer[] = [];
- for (const chunk of data) {
- if (remaining <= 0) break;
- const prefix = chunk.subarray(0, remaining);
- chunks.push(prefix);
- remaining -= prefix.byteLength;
- }
- return Buffer.concat(chunks).toString("utf8");
- }
- if (data instanceof ArrayBuffer) {
- return Buffer.from(data, 0, Math.min(data.byteLength, maximumBytes)).toString(
- "utf8",
- );
- }
- return data.subarray(0, maximumBytes).toString("utf8");
-}
diff --git a/src/server/openapi/build.ts b/src/server/openapi/build.ts
index 966a89c..9578206 100644
--- a/src/server/openapi/build.ts
+++ b/src/server/openapi/build.ts
@@ -60,11 +60,6 @@ const TAGS: OpenAPIV3.TagObject[] = [
name: "Workspace IDE",
description: "Cross-client editor snapshots, mutations and invalidations.",
},
- {
- name: "Browser",
- description:
- "The embedded, shared human-and-agent Playwright Browser session.",
- },
{ name: "Metrics", description: "Environment runtime metrics." },
{ name: "Terminal", description: "The shared Environment terminal." },
{ name: "Preferences", description: "Authenticated viewer preferences." },
@@ -107,7 +102,7 @@ export async function buildOpenApi(): Promise {
title: "Sandpi API",
version: packageJson.version,
description:
- "The public Sandpi server contract. JSON endpoints use a data envelope and errors use an error envelope. Unix timestamps are seconds. Browser-cookie authentication is deployment-scoped; native harness payloads and opaque Browser dashboard frames are identified explicitly.",
+ "The public Sandpi server contract. JSON endpoints use a data envelope and errors use an error envelope. Unix timestamps are seconds. Browser-cookie authentication is deployment-scoped, and native harness payloads are identified explicitly.",
license: {
name: "Apache-2.0",
url: "https://www.apache.org/licenses/LICENSE-2.0",
@@ -200,7 +195,6 @@ function normalizeMethods(method: string | string[]): string[] {
function finalizeOpenApi(document: OpenAPIV3.Document): OpenAPIV3.Document {
normalizeTransportResponses(document);
- addBrowserAssetProxy(document);
pruneUnusedSchemas(document);
sortOpenApiCollections(document);
return document;
@@ -302,63 +296,6 @@ function normalizeTransportResponses(document: OpenAPIV3.Document) {
}
}
-function addBrowserAssetProxy(document: OpenAPIV3.Document) {
- const operation: OpenAPIV3.OperationObject & Record = {
- operationId: "getEnvironmentBrowserDashboardAsset",
- summary: "Load an embedded Browser dashboard asset",
- description:
- "Transparent, authenticated proxy for the built-in shared Playwright Browser dashboard. The asset protocol is owned by the pinned dashboard and is not a JSON application API.",
- tags: ["Browser"],
- parameters: [
- {
- name: "environmentId",
- in: "path",
- required: true,
- schema: { type: "string", minLength: 1 },
- },
- {
- name: "assetPath",
- in: "path",
- required: true,
- description: "A slash-containing dashboard asset path.",
- schema: { type: "string", minLength: 1 },
- "x-sandpi-greedy": true,
- } as OpenAPIV3.ParameterObject,
- ],
- responses: {
- "200": {
- description: "Dashboard asset.",
- content: {
- "application/octet-stream": {
- schema: { type: "string", format: "binary" },
- },
- },
- },
- "3XX": {
- description: "Dashboard-relative redirect.",
- headers: {
- Location: {
- schema: { type: "string" },
- },
- },
- },
- default: {
- description: "Opaque dashboard response or Sandpi error.",
- content: {
- "*/*": {
- schema: { type: "string", format: "binary" },
- },
- },
- },
- },
- "x-sandpi-shared-browser": true,
- "x-sandpi-proxy-protocol": "opaque-playwright-dashboard",
- };
- document.paths[
- "/api/v1/environments/{environmentId}/browser/{assetPath}"
- ] = { get: operation };
-}
-
function pruneUnusedSchemas(document: OpenAPIV3.Document) {
const schemas = document.components?.schemas;
if (!schemas) return;
diff --git a/src/server/openapi/contracts.ts b/src/server/openapi/contracts.ts
index bded39e..2be0071 100644
--- a/src/server/openapi/contracts.ts
+++ b/src/server/openapi/contracts.ts
@@ -4,8 +4,6 @@ import { ENVIRONMENT_METRIC_RANGES_SECONDS } from "@/lib/environment-metrics";
import { WORKSPACE_ROOT } from "@/lib/workspace-path-policy";
import {
billingCheckoutSchema,
- browserControlSchema,
- browserSessionSchema,
codexComposerUploadSchema,
codexHookUpdateSchema,
codexMcpServerConfigurationSchema,
@@ -15,7 +13,6 @@ import {
codexRateLimitResetSchema,
codexSkillConfigurationSchema,
codexSkillPutSchema,
- environmentBrowserViewportSchema,
environmentCreateSchema,
environmentOrderSchema,
environmentProvisioningSchema,
@@ -195,15 +192,6 @@ const workspaceRawFile = z.object({
kind: z.enum(["binary", "text"]),
});
-const browserDescription =
- "Sandpi's built-in Browser keeps one shared profile with one active owner. Agent ownership exposes a view-only, automatically sized screencast; every human interaction requires Take control and the headed VNC transport. A human can later return the same profile to the agent, and Sandpi rejects agent-side Browser operations while human control is active. Control responses report whether the Environment image supports headed-browser takeover.";
-const browserControl = z.object({
- owner: z.enum(["agent", "human"]),
- transport: z.enum(["playwright", "vnc"]),
- revision: z.number().int().nonnegative(),
- takeoverAvailable: z.boolean(),
-});
-
export const openApiRouteContracts: readonly OpenApiRouteContract[] = [
defineContract({
method: "GET",
@@ -271,31 +259,6 @@ export const openApiRouteContracts: readonly OpenApiRouteContract[] = [
response: { 302: redirect },
},
}),
- defineContract({
- method: "GET",
- url: "/api/v1/environments/:environmentId/browser/control",
- schema: {
- operationId: "getEnvironmentBrowserControl",
- summary: "Get the shared Browser owner",
- description: browserDescription,
- tags: ["Browser"],
- response: { 200: dataEnvelope(browserControl) },
- "x-sandpi-shared-browser": true,
- },
- }),
- defineContract({
- method: "PUT",
- url: "/api/v1/environments/:environmentId/browser/control",
- schema: {
- operationId: "updateEnvironmentBrowserControl",
- summary: "Transfer control of the shared Browser",
- description: browserDescription,
- tags: ["Browser"],
- body: browserControlSchema,
- response: { 200: dataEnvelope(browserControl) },
- "x-sandpi-shared-browser": true,
- },
- }),
defineContract({
method: "POST",
url: "/api/v1/auth/device/complete",
@@ -1685,74 +1648,6 @@ export const openApiRouteContracts: readonly OpenApiRouteContract[] = [
},
},
}),
- defineContract({
- method: "POST",
- url: "/api/v1/environments/:environmentId/browser/session",
- schema: {
- operationId: "ensureEnvironmentBrowserSession",
- summary: "Ensure the shared Environment Browser session",
- description: browserDescription,
- tags: ["Browser"],
- body: browserSessionSchema,
- response: { 204: noContent },
- "x-sandpi-optional-request-body": true,
- "x-sandpi-shared-browser": true,
- },
- }),
- defineContract({
- method: "POST",
- url: "/api/v1/environments/:environmentId/browser/viewport",
- schema: {
- operationId: "resizeEnvironmentBrowserViewport",
- summary: "Resize the shared Browser viewport",
- description: browserDescription,
- tags: ["Browser"],
- body: environmentBrowserViewportSchema,
- response: { 204: noContent },
- "x-sandpi-shared-browser": true,
- },
- }),
- defineContract({
- method: "GET",
- url: "/api/v1/environments/:environmentId/browser/ws/:dashboardSocketId",
- schema: {
- operationId: "connectEnvironmentBrowserDashboard",
- summary: "Connect the embedded Browser dashboard WebSocket",
- description: browserDescription,
- tags: ["Browser"],
- response: { 101: noContent },
- "x-sandpi-websocket": {
- protocol: "opaque-environment-browser",
- direction: "bidirectional",
- },
- "x-sandpi-shared-browser": true,
- },
- }),
- defineContract({
- method: "GET",
- url: "/api/v1/environments/:environmentId/browser",
- schema: {
- operationId: "getEnvironmentBrowserDashboard",
- summary: "Load the embedded shared Browser dashboard",
- description: browserDescription,
- tags: ["Browser"],
- querystring: z.looseObject({ embed: z.literal("1").optional() }),
- response: { 200: z.string(), 302: redirect },
- "x-sandpi-content-type": "text/html",
- "x-sandpi-proxy-protocol": "opaque-playwright-dashboard",
- "x-sandpi-shared-browser": true,
- },
- }),
- defineContract({
- method: "GET",
- url: "/api/v1/environments/:environmentId/browser/",
- schema: { hide: true },
- }),
- defineContract({
- method: "GET",
- url: "/api/v1/environments/:environmentId/browser/*",
- schema: { hide: true },
- }),
defineContract({
method: "GET",
url: "/api/v1/environments/:environmentId/metrics/current",
diff --git a/src/server/openapi/openapi.test.ts b/src/server/openapi/openapi.test.ts
index cd01391..a220450 100644
--- a/src/server/openapi/openapi.test.ts
+++ b/src/server/openapi/openapi.test.ts
@@ -24,7 +24,7 @@ test("OpenAPI publishes every supported operation with a unique id", async () =>
const operations = allOperations(document);
const operationIds = operations.map((operation) => operation.operationId);
- assert.equal(operations.length, 128);
+ assert.equal(operations.length, 121);
assert.ok(operationIds.every(Boolean));
assert.equal(new Set(operationIds).size, operationIds.length);
assert.ok(Object.keys(document.paths).every((path) => !path.includes(":")));
@@ -73,55 +73,18 @@ test("OpenAPI publishes every supported operation with a unique id", async () =>
]);
});
-test("OpenAPI preserves the shared Browser and streaming semantics", async () => {
+test("OpenAPI excludes the removed Browser API and preserves streaming semantics", async () => {
const { document } = await builtOpenApi;
const cloudSync = operation(document, "/api/v1/sync", "get");
assert.equal(cloudSync.responses["304"] !== undefined, true);
assert.match(cloudSync.description ?? "", /database-only/i);
assert.equal(
- document.paths?.["/api/v1/environments/{environmentId}/browser/open"],
- undefined,
+ Object.keys(document.paths).some((path) => path.includes("/browser")),
+ false,
);
- const browserDashboard = operation(
- document,
- "/api/v1/environments/{environmentId}/browser",
- "get",
- );
- assert.equal(browserDashboard["x-sandpi-shared-browser"], true);
- assert.match(browserDashboard.description ?? "", /one active owner/i);
- assert.match(browserDashboard.description ?? "", /view-only/i);
- assert.match(browserDashboard.description ?? "", /Take control/i);
-
- const browserControl = operation(
- document,
- "/api/v1/environments/{environmentId}/browser/control",
- "put",
- );
- assert.equal(browserControl["x-sandpi-shared-browser"], true);
- const controlBody = browserControl.requestBody;
- assert.ok(controlBody && !("$ref" in controlBody));
- const controlSchema = controlBody.content["application/json"]?.schema;
- assert.ok(controlSchema && !("$ref" in controlSchema));
- assert.deepEqual(controlSchema.required, ["owner"]);
- assert.deepEqual(controlSchema.properties?.owner, {
- type: "string",
- enum: ["agent", "human"],
- });
- const controlResponse = browserControl.responses["200"];
- assert.ok(controlResponse && !("$ref" in controlResponse));
- const controlResponseSchema =
- controlResponse.content?.["application/json"]?.schema;
- assert.ok(controlResponseSchema && !("$ref" in controlResponseSchema));
- const controlDataSchema = controlResponseSchema.properties?.data;
- assert.ok(controlDataSchema && !("$ref" in controlDataSchema));
- assert.deepEqual(controlDataSchema.properties?.takeoverAvailable, {
- type: "boolean",
- });
- assert.ok(controlDataSchema.required?.includes("takeoverAvailable"));
for (const [path, method] of [
- ["/api/v1/environments/{environmentId}/browser/session", "post"],
["/api/v1/sessions/{sessionId}/review", "post"],
["/api/v1/sessions/{sessionId}/fork", "post"],
[
@@ -146,7 +109,6 @@ test("OpenAPI preserves the shared Browser and streaming semantics", async () =>
for (const path of [
"/api/v1/environments/{environmentId}/ide/events",
- "/api/v1/environments/{environmentId}/browser/ws/{dashboardSocketId}",
"/api/v1/environments/{environmentId}/terminal",
]) {
assert.ok(operation(document, path, "get")["x-sandpi-websocket"]);
diff --git a/src/server/runtime/environment-browser-runtime.ts b/src/server/runtime/environment-browser-runtime.ts
deleted file mode 100644
index 5342f5f..0000000
--- a/src/server/runtime/environment-browser-runtime.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-export const ENVIRONMENT_BROWSER_PROFILE_PATH =
- "/workspace/.sandpi/browser/profile";
-export const ENVIRONMENT_BROWSER_HUMAN_LOCK_PATH =
- "/workspace/.sandpi/browser/human-owner";
-
-export const PLAYWRIGHT_CLI_ENVIRONMENT = {
- HOME: "/workspace",
- PLAYWRIGHT_BROWSERS_PATH: "/opt/ms-playwright",
- PLAYWRIGHT_MCP_BROWSER: "chromium",
- PLAYWRIGHT_MCP_ISOLATED: "false",
- PLAYWRIGHT_MCP_SANDBOX: "false",
- PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1",
- NO_UPDATE_NOTIFIER: "1",
-} as const;
-
-const DASHBOARD_READY_SCRIPT = String.raw`
-const net = require("node:net");
-
-const port = Number(process.argv[1]);
-const deadline = Date.now() + 30_000;
-const connect = () => {
- const socket = net.connect({ host: "127.0.0.1", port });
- socket.once("connect", () => {
- socket.end();
- process.exit(0);
- });
- socket.once("error", () => {
- socket.destroy();
- if (Date.now() >= deadline) process.exit(1);
- setTimeout(connect, 25);
- });
-};
-connect();
-`;
-
-const VNC_WEBSOCKET_BRIDGE_SCRIPT = String.raw`
-const net = require("node:net");
-const { WebSocketServer, WebSocket } = require("/opt/coding-agents/node_modules/ws");
-
-const listenPort = Number(process.argv[1]);
-const vncPort = Number(process.argv[2]);
-const maximumBufferedBytes = 8 * 1024 * 1024;
-const server = new WebSocketServer({
- host: "0.0.0.0",
- port: listenPort,
- perMessageDeflate: false,
-});
-
-server.on("connection", (socket, request) => {
- if (request.url !== "/vnc") {
- socket.close(1008, "Unknown browser transport");
- return;
- }
-
- const upstream = net.connect({ host: "127.0.0.1", port: vncPort });
- const close = () => {
- upstream.destroy();
- if (socket.readyState === WebSocket.OPEN) socket.close();
- };
- upstream.on("data", (data) => {
- if (socket.readyState !== WebSocket.OPEN) return;
- if (socket.bufferedAmount > maximumBufferedBytes) {
- socket.close(1009, "Browser transport queue exceeded");
- upstream.destroy();
- return;
- }
- socket.send(data, { binary: true }, (error) => {
- if (error) close();
- });
- });
- upstream.once("error", close);
- upstream.once("close", () => {
- if (socket.readyState === WebSocket.OPEN) socket.close();
- });
- socket.on("message", (data) => {
- if (!upstream.destroyed) upstream.write(data);
- });
- socket.once("error", close);
- socket.once("close", () => upstream.destroy());
-});
-`;
-
-const encoded = (value: string) =>
- Buffer.from(value, "utf8").toString("base64");
-
-export const PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64 = encoded(
- DASHBOARD_READY_SCRIPT,
-);
-export const VNC_WEBSOCKET_BRIDGE_SCRIPT_BASE64 = encoded(
- VNC_WEBSOCKET_BRIDGE_SCRIPT,
-);
-
-export function playwrightDashboardStartScript(port: number) {
- return String.raw`set -eu
-profile=${ENVIRONMENT_BROWSER_PROFILE_PATH}
-human_lock=${ENVIRONMENT_BROWSER_HUMAN_LOCK_PATH}
-mkdir -p "$(dirname "$profile")"
-rm -f "$human_lock"
-
-recover_profile() {
- node -e 'eval(Buffer.from(process.env.SANDPI_PLAYWRIGHT_LOCK_RECOVERY_SCRIPT_BASE64, "base64").toString("utf8"))' "$1"
-}
-
-migrate_legacy_profile() {
- test -e "$profile" && return 0
- legacy="$(find /workspace/.cache/ms-playwright/daemon -mindepth 2 -maxdepth 2 -type d -name ud-default-chrome-for-testing 2>/dev/null | head -n 1)"
- test -n "$legacy" || return 0
- playwright-cli close >/dev/null 2>&1 || true
- attempts=0
- while :; do
- recovery_status=0
- recover_profile "$legacy" || recovery_status="$?"
- test "$recovery_status" -eq 12 || break
- test "$attempts" -lt 40
- sleep 0.25
- attempts=$((attempts + 1))
- done
- test "$recovery_status" -eq 0 || return 1
- mv "$legacy" "$profile"
-}
-
-ensure_browser() {
- playwright-cli tab-list >/dev/null 2>&1 && return 0
- if test -d "$profile"; then
- recovery_status=0
- recover_profile "$profile" || recovery_status="$?"
- test "$recovery_status" -eq 0 || return 1
- else
- test ! -e "$profile" || return 1
- fi
- browser_error="$(playwright-cli open about:blank --browser chromium --profile="$profile" 2>&1)" && return 0
- printf '%s\n' "$browser_error" >&2
- recovery_status=0
- recover_profile "$profile" || recovery_status="$?"
- test "$recovery_status" -eq 0 || return 1
- playwright-cli open about:blank --browser chromium --profile="$profile"
-}
-
-wait_for_dashboard() {
- node -e 'eval(Buffer.from(process.env.SANDPI_PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64, "base64").toString("utf8"))' "$1"
-}
-
-migrate_legacy_profile
-prewarm_browser() {
- wait_for_dashboard ${port} || return 1
- until ensure_browser; do sleep 0.25; done
- while :; do
- sleep 15
- ensure_browser || true
- done
-}
-prewarm_browser &
-exec playwright-cli -s=default show --host 0.0.0.0 --port ${port}`;
-}
-
-export function humanBrowserStartScript(port: number) {
- return String.raw`set -eu
-profile=${ENVIRONMENT_BROWSER_PROFILE_PATH}
-human_lock=${ENVIRONMENT_BROWSER_HUMAN_LOCK_PATH}
-browser_user="${"${SANDPI_BROWSER_USER:-sandbox-browser}"}"
-display=:99
-vnc_port=5900
-pids=""
-
-stop_process() {
- pid="$1"
- kill "$pid" 2>/dev/null || true
- attempts=0
- while kill -0 "$pid" 2>/dev/null && test "$attempts" -lt 50; do
- state="$(awk '{print $3}' "/proc/$pid/stat" 2>/dev/null || true)"
- test "$state" != Z || break
- sleep 0.1
- attempts=$((attempts + 1))
- done
- kill -KILL "$pid" 2>/dev/null || true
- wait "$pid" 2>/dev/null || true
-}
-
-cleanup() {
- trap - EXIT INT TERM HUP
- for pid in $pids; do stop_process "$pid"; done
- sync -f /workspace 2>/dev/null || sync
-}
-trap cleanup EXIT INT TERM HUP
-
-mkdir -p "$(dirname "$profile")"
-chmod 711 "$(dirname "$(dirname "$profile")")" "$(dirname "$profile")"
-install -m 600 /dev/null "$human_lock"
-playwright-cli close >/dev/null 2>&1 || true
-mkdir -p "$profile"
-test ! -L "$profile"
-attempts=0
-while :; do
- recovery_status=0
- node -e 'eval(Buffer.from(process.env.SANDPI_PLAYWRIGHT_LOCK_RECOVERY_SCRIPT_BASE64, "base64").toString("utf8"))' "$profile" || recovery_status="$?"
- test "$recovery_status" -eq 12 || break
- test "$attempts" -lt 40
- sleep 0.25
- attempts=$((attempts + 1))
-done
-test "$recovery_status" -eq 0
-chown -R "$browser_user:$browser_user" "$profile"
-
-browser="$(command -v google-chrome-stable || command -v google-chrome || true)"
-if test -z "$browser"; then
- browser="$(find /opt/ms-playwright -type f -path '*/chrome-linux*/chrome' -perm -111 2>/dev/null | sort | tail -n 1)"
-fi
-test -n "$browser"
-
-mkdir -p /tmp/sandpi-browser-openbox
-using_tigervnc=false
-if command -v Xtigervnc >/dev/null; then
- using_tigervnc=true
- HOME=/tmp/sandpi-browser-openbox Xtigervnc "$display" \
- -geometry 1440x900 \
- -depth 24 \
- -rfbport "$vnc_port" \
- -localhost \
- -SecurityTypes None \
- -AlwaysShared \
- -AcceptSetDesktopSize \
- -ac \
- -nolisten tcp >/tmp/sandpi-browser-tigervnc.log 2>&1 &
- display_pid="$!"
-else
- HOME=/tmp/sandpi-browser-openbox Xvfb "$display" -screen 0 1440x900x24 -nolisten tcp -ac +extension RANDR >/tmp/sandpi-browser-xvfb.log 2>&1 &
- display_pid="$!"
-fi
-pids="$display_pid $pids"
-attempts=0
-while test ! -S /tmp/.X11-unix/X99; do
- kill -0 "$display_pid"
- test "$attempts" -lt 100
- sleep 0.05
- attempts=$((attempts + 1))
-done
-DISPLAY="$display" HOME=/tmp/sandpi-browser-openbox openbox >/tmp/sandpi-browser-openbox.log 2>&1 &
-pids="$! $pids"
-if test "$using_tigervnc" = false; then
- DISPLAY="$display" x11vnc -display "$display" -rfbport "$vnc_port" -localhost -forever -shared -nopw -noxdamage -repeat -quiet >/tmp/sandpi-browser-x11vnc.log 2>&1 &
- pids="$! $pids"
-fi
-
-setpriv --reuid="$browser_user" --regid="$browser_user" --init-groups env DISPLAY="$display" HOME="/home/$browser_user" "$browser" \
- --user-data-dir="$profile" \
- --no-first-run \
- --no-default-browser-check \
- --password-store=basic \
- --disable-dev-shm-usage \
- --start-maximized \
- --restore-last-session >/tmp/sandpi-browser-chrome.log 2>&1 &
-pids="$! $pids"
-
-node -e 'eval(Buffer.from(process.env.SANDPI_PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64, "base64").toString("utf8"))' "$vnc_port"
-node -e 'eval(Buffer.from(process.env.SANDPI_VNC_WEBSOCKET_BRIDGE_SCRIPT_BASE64, "base64").toString("utf8"))' ${port} "$vnc_port" &
-bridge_pid="$!"
-pids="$bridge_pid $pids"
-wait "$bridge_pid"`;
-}
-
-export const HUMAN_BROWSER_PREFLIGHT_SCRIPT = String.raw`set -eu
-command -v openbox >/dev/null
-if ! command -v Xtigervnc >/dev/null; then
- command -v Xvfb >/dev/null
- command -v x11vnc >/dev/null
-fi
-command -v node >/dev/null
-command -v setpriv >/dev/null
-browser_user="${"${SANDPI_BROWSER_USER:-sandbox-browser}"}"
-id "$browser_user" >/dev/null
-node -e 'require("/opt/coding-agents/node_modules/ws")'
-browser="$(command -v google-chrome-stable || command -v google-chrome || true)"
-if test -z "$browser"; then
- browser="$(find /opt/ms-playwright -type f -path '*/chrome-linux*/chrome' -perm -111 2>/dev/null | sort | tail -n 1)"
-fi
-test -n "$browser"`;
-
-export const PLAYWRIGHT_CLI_GUARD_SCRIPT = String.raw`#!/bin/sh
-if test -e ${ENVIRONMENT_BROWSER_HUMAN_LOCK_PATH}; then
- printf '%s\n' 'The Environment browser is under human control. Return it to the agent before using Playwright.' >&2
- exit 75
-fi
-exec /usr/local/bin/playwright-cli "$@"
-`;
diff --git a/src/server/runtime/playwright-browser-recovery.test.ts b/src/server/runtime/playwright-browser-recovery.test.ts
deleted file mode 100644
index a48cf71..0000000
--- a/src/server/runtime/playwright-browser-recovery.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import assert from "node:assert/strict";
-import { spawn, spawnSync } from "node:child_process";
-import { lstat, mkdtemp, readlink, realpath, symlink } from "node:fs/promises";
-import { hostname, tmpdir } from "node:os";
-import path from "node:path";
-import test from "node:test";
-
-import {
- isPlaywrightBrowserDependencyUnavailable,
- isPlaywrightBrowserNotOpen,
- PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT,
- playwrightProfilePathFromInUseError,
- playwrightStaleProfileLockRecoveryCommand,
-} from "./playwright-browser-recovery";
-
-const profilePath =
- "/workspace/.cache/ms-playwright/daemon/8af22c44f40455cc/ud-default-chrome-for-testing";
-const sharedProfilePath = "/workspace/.sandpi/browser/profile";
-
-test("accepts only the default Playwright profile from an in-use error", () => {
- assert.equal(
- playwrightProfilePathFromInUseError(
- `Error: Browser is already in use for ${profilePath}, use --isolated`,
- ),
- profilePath,
- );
- assert.equal(
- playwrightProfilePathFromInUseError(
- "Browser is already in use for /workspace/private, use --isolated",
- ),
- undefined,
- );
- assert.equal(
- playwrightStaleProfileLockRecoveryCommand("/workspace/private"),
- undefined,
- );
- assert.deepEqual(
- playwrightStaleProfileLockRecoveryCommand(profilePath)?.slice(0, 2),
- ["node", "-e"],
- );
- assert.equal(
- playwrightProfilePathFromInUseError(
- `Error: Browser is already in use for ${sharedProfilePath}, use --isolated`,
- ),
- sharedProfilePath,
- );
- assert.deepEqual(
- playwrightStaleProfileLockRecoveryCommand(sharedProfilePath)?.slice(0, 2),
- ["node", "-e"],
- );
-});
-
-test("recognizes a stopped browser separately from missing dependencies", () => {
- assert.equal(
- isPlaywrightBrowserNotOpen({
- exitCode: 1,
- stderr: "Error: Browser 'default' is not open.",
- }),
- true,
- );
- assert.equal(
- isPlaywrightBrowserDependencyUnavailable({
- exitCode: 127,
- stderr: "playwright-cli: command not found",
- }),
- true,
- );
- assert.equal(
- isPlaywrightBrowserDependencyUnavailable({
- exitCode: 1,
- stderr:
- "Failed to launch chromium because executable doesn't exist at /opt/ms-playwright/chromium/chrome",
- }),
- true,
- );
- assert.equal(
- isPlaywrightBrowserDependencyUnavailable({
- exitCode: 1,
- stderr: "Browser is already in use for a profile",
- }),
- false,
- );
-});
-
-test("removes only stale Chromium singleton symlinks", async (context) => {
- const profile = await realpath(
- await mkdtemp(path.join(tmpdir(), "sandpi-browser-profile-")),
- );
- context.after(async () => {
- await import("node:fs/promises").then(({ rm }) =>
- rm(profile, { recursive: true, force: true }),
- );
- });
- const locks = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
- await Promise.all(
- locks.map((name) =>
- symlink(
- name === "SingletonLock" ? "retired-sandbox-271" : `stale-${name}`,
- path.join(profile, name),
- ),
- ),
- );
-
- const recovered = spawnSync(
- process.execPath,
- ["-e", PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT, profile],
- { encoding: "utf8" },
- );
- assert.equal(recovered.status, 0, recovered.stderr);
- for (const name of locks) {
- await assert.rejects(lstat(path.join(profile, name)), {
- code: "ENOENT",
- });
- }
- if (process.platform !== "linux") return;
-
- const browser = spawn(
- process.execPath,
- [
- "-e",
- "setInterval(() => undefined, 60_000)",
- "--",
- `--user-data-dir=${profile}`,
- ],
- { stdio: "ignore" },
- );
- context.after(() => {
- browser.kill();
- });
- await new Promise((resolve, reject) => {
- browser.once("spawn", resolve);
- browser.once("error", reject);
- });
- assert.ok(browser.pid);
- await Promise.all(
- locks.map((name) =>
- symlink(
- name === "SingletonLock"
- ? `${hostname()}-${browser.pid}`
- : `live-${name}`,
- path.join(profile, name),
- ),
- ),
- );
- const refused = spawnSync(
- process.execPath,
- ["-e", PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT, profile],
- { encoding: "utf8" },
- );
- assert.equal(refused.status, 12, refused.stderr);
- assert.equal(
- await readlink(path.join(profile, "SingletonLock")),
- `${hostname()}-${browser.pid}`,
- );
-
- const browserExited = new Promise((resolve) =>
- browser.once("exit", () => resolve()),
- );
- browser.kill();
- await browserExited;
- const reusedPidLock = `${hostname()}-${process.pid}`;
- await import("node:fs/promises").then(({ unlink }) =>
- unlink(path.join(profile, "SingletonLock")),
- );
- await symlink(reusedPidLock, path.join(profile, "SingletonLock"));
- const recoveredReusedPid = spawnSync(
- process.execPath,
- ["-e", PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT, profile],
- { encoding: "utf8" },
- );
- assert.equal(recoveredReusedPid.status, 0, recoveredReusedPid.stderr);
- for (const name of locks) {
- await assert.rejects(lstat(path.join(profile, name)), {
- code: "ENOENT",
- });
- }
-});
diff --git a/src/server/runtime/playwright-browser-recovery.ts b/src/server/runtime/playwright-browser-recovery.ts
deleted file mode 100644
index 8e4988b..0000000
--- a/src/server/runtime/playwright-browser-recovery.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-const PLAYWRIGHT_DEFAULT_PROFILE_PATH =
- /^(?:\/workspace\/\.sandpi\/browser\/profile|\/workspace\/\.cache\/ms-playwright\/daemon\/[a-f0-9]{16}\/ud-default-chrome-for-testing)$/;
-const PLAYWRIGHT_PROFILE_IN_USE =
- /Browser is already in use for ([^,\r\n]+)/;
-
-export interface PlaywrightCliResult {
- exitCode?: number;
- stderr: string;
-}
-
-export const PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT = String.raw`
-const fs = require("node:fs");
-const os = require("node:os");
-const path = require("node:path");
-
-const profilePath = process.argv[1];
-const lockNames = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
-
-try {
- if (
- !fs.lstatSync(profilePath).isDirectory() ||
- fs.realpathSync.native(profilePath) !== profilePath
- ) {
- process.exit(10);
- }
-
- const lockPath = path.join(profilePath, "SingletonLock");
- let lockTarget;
- try {
- lockTarget = fs.readlinkSync(lockPath);
- } catch (error) {
- if (error && error.code === "ENOENT") process.exit(0);
- throw error;
- }
-
- const lockMatch = /^(.*)-([1-9]\d*)$/.exec(lockTarget);
- if (!lockMatch) process.exit(11);
- const lockHost = lockMatch[1];
- const lockPid = Number(lockMatch[2]);
- if (lockHost === os.hostname()) {
- try {
- process.kill(lockPid, 0);
- } catch (error) {
- if (error && error.code === "EPERM") process.exit(12);
- if (!error || error.code !== "ESRCH") throw error;
- }
- try {
- const command = fs
- .readFileSync("/proc/" + lockPid + "/cmdline", "utf8")
- .split("\0");
- if (command.includes("--user-data-dir=" + profilePath)) {
- process.exit(12);
- }
- } catch (error) {
- if (!error || error.code !== "ENOENT") process.exit(12);
- }
- }
-
- const staleLocks = [];
- for (const name of lockNames) {
- const candidate = path.join(profilePath, name);
- try {
- if (!fs.lstatSync(candidate).isSymbolicLink()) process.exit(13);
- staleLocks.push(candidate);
- } catch (error) {
- if (!error || error.code !== "ENOENT") throw error;
- }
- }
- if (fs.readlinkSync(lockPath) !== lockTarget) process.exit(14);
- for (const candidate of staleLocks) fs.unlinkSync(candidate);
-} catch {
- process.exit(15);
-}
-`;
-
-export function playwrightProfilePathFromInUseError(stderr: string) {
- const profilePath = PLAYWRIGHT_PROFILE_IN_USE.exec(stderr)?.[1];
- return profilePath && PLAYWRIGHT_DEFAULT_PROFILE_PATH.test(profilePath)
- ? profilePath
- : undefined;
-}
-
-export function playwrightStaleProfileLockRecoveryCommand(
- profilePath: string,
-) {
- if (!PLAYWRIGHT_DEFAULT_PROFILE_PATH.test(profilePath)) return undefined;
- return [
- "node",
- "-e",
- PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT,
- profilePath,
- ];
-}
-
-export function isPlaywrightBrowserNotOpen(result: PlaywrightCliResult) {
- return /Browser ['"]?default['"]? is not open/i.test(result.stderr);
-}
-
-export function isPlaywrightBrowserDependencyUnavailable(
- result: PlaywrightCliResult,
-) {
- return (
- result.exitCode === 127 ||
- /(?:playwright-cli|spawn playwright-cli).*(?:command )?not found|spawn playwright-cli ENOENT|executable doesn't exist at|Executable doesn't exist/i.test(
- result.stderr,
- )
- );
-}
diff --git a/src/server/runtime/sandbox0.test.ts b/src/server/runtime/sandbox0.test.ts
index b36d124..b3b0652 100644
--- a/src/server/runtime/sandbox0.test.ts
+++ b/src/server/runtime/sandbox0.test.ts
@@ -1,6 +1,4 @@
import assert from "node:assert/strict";
-import { spawnSync } from "node:child_process";
-import { createHash } from "node:crypto";
import test from "node:test";
import { zstdCompressSync } from "node:zlib";
@@ -267,18 +265,9 @@ test("claims exactly one Environment Sandbox around its shared Workspace Volume"
credentialBindings: [],
},
);
- const provisionedServices = (
- ((claimInput?.config ?? {}) as Record)
- .services as Array>
- );
- assert.equal(provisionedServices.length, 1);
- assert.equal(provisionedServices[0]?.id, "sandpi-browser-dashboard");
- assert.deepEqual(
- (provisionedServices[0]?.runtime as { command: string[] }).command.slice(
- 0,
- 2,
- ),
- ["sh", "-c"],
+ assert.equal(
+ "services" in ((claimInput?.config ?? {}) as Record),
+ false,
);
assert.deepEqual(allocations, [
{
@@ -1131,627 +1120,6 @@ test("preserves unrelated services and installs a constrained MCP OAuth callback
assert.equal(JSON.stringify(replacement).includes("publishable"), false);
});
-test("publishes the agent Browser transport behind a server-only hashed header", async () => {
- let replacement: Array> = [];
- let serviceUpdates = 0;
- let loseFirstUpdateResponse = true;
- let currentServices: Array> = [
- {
- id: "preview",
- displayName: "Preview",
- port: 3000,
- runtime: { type: "manual" },
- ingress: {
- _public: true,
- routes: [{ id: "preview", pathPrefix: "/", resume: true }],
- },
- publishable: true,
- publicUrl: "https://preview.example.invalid",
- },
- ];
- const runtime = runtimeWithClient({
- sandboxes: {
- sandbox(sandboxId: string) {
- assert.equal(sandboxId, "sandbox-environment");
- return {
- async getServices() {
- return {
- sandboxId,
- services: currentServices,
- };
- },
- async updateServices(services: Array>) {
- serviceUpdates += 1;
- replacement = services;
- currentServices = services.map((service) => ({
- ...service,
- healthCheck: undefined,
- runtime: service.runtime
- ? {
- ...(service.runtime as Record),
- _function: undefined,
- }
- : undefined,
- ingress: {
- ...(service.ingress as Record),
- routes: (
- (service.ingress as {
- routes?: Array>;
- }).routes ?? []
- ).map((route) => ({
- ...route,
- rewritePrefix: undefined,
- timeoutSeconds: undefined,
- auth: route.auth
- ? {
- ...(route.auth as Record),
- bearerTokenSha256: undefined,
- }
- : undefined,
- })),
- },
- publishable: true,
- publicUrl:
- service.id === "sandpi-browser-dashboard"
- ? "https://browser.example.invalid"
- : "https://preview.example.invalid",
- }));
- if (loseFirstUpdateResponse) {
- loseFirstUpdateResponse = false;
- throw new APIError({
- statusCode: 500,
- code: "unexpected_response",
- message: "Internal Server Error",
- });
- }
- return { sandboxId, services: currentServices };
- },
- };
- },
- },
- });
- const coordinates: EnvironmentRuntimeRecord = {
- id: environment.id,
- sandboxId: environment.sandboxId,
- workspaceVolumeId: environment.workspaceVolumeId,
- runtimeGeneration: 1,
- decoder: {
- supervisorCursor: 0,
- tailBase64: "",
- runtimeGeneration: 1,
- },
- };
-
- const dashboard =
- await runtime.ensureEnvironmentBrowserService(coordinates);
-
- assert.equal(dashboard.publicUrl, "https://browser.example.invalid");
- const requestToken = dashboard.requestHeaders["X-Sandpi-Browser-Proxy"];
- assert.ok(requestToken);
- const service = replacement.find(
- (candidate) => candidate.id === "sandpi-browser-dashboard",
- ) as {
- port: number;
- runtime: {
- type: string;
- command: string[];
- cwd: string;
- envVars: Record;
- };
- ingress: {
- _public: boolean;
- routes: Array<{
- methods: string[];
- auth: {
- mode: string;
- headerName: string;
- headerValueSha256: string;
- };
- resume: boolean;
- }>;
- };
- };
- assert.equal(service.port, 43_420);
- assert.deepEqual(service.runtime.command.slice(0, 2), ["sh", "-c"]);
- assert.match(
- service.runtime.command[2] ?? "",
- /profile=\/workspace\/\.sandpi\/browser\/profile.*migrate_legacy_profile\(\).*playwright-cli close.*ensure_browser\(\).*playwright-cli tab-list.*--profile="\$profile".*prewarm_browser/s,
- );
- assert.doesNotMatch(
- service.runtime.command[2] ?? "",
- /browser_is_running|pgrep/,
- );
- assert.match(
- service.runtime.command[2] ?? "",
- /wait_for_dashboard\(\).*SANDPI_PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64.*wait_for_dashboard 43420 \|\| return 1.*until ensure_browser.*sleep 15.*exec playwright-cli -s=default show --host 0\.0\.0\.0 --port 43420/s,
- );
- assert.equal(
- spawnSync("sh", ["-n", "-c", service.runtime.command[2] ?? ""]).status,
- 0,
- );
- assert.equal(service.runtime.cwd, "/workspace");
- assert.equal(service.runtime.envVars.PLAYWRIGHT_MCP_BROWSER, "chromium");
- assert.equal(service.runtime.envVars.PLAYWRIGHT_MCP_ISOLATED, "false");
- assert.equal(service.runtime.envVars.PLAYWRIGHT_MCP_SANDBOX, "false");
- assert.equal(service.runtime.envVars.NO_UPDATE_NOTIFIER, "1");
- assert.equal(
- spawnSync(process.execPath, ["--check"], {
- input: Buffer.from(
- service.runtime.envVars
- .SANDPI_PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64,
- "base64",
- ).toString("utf8"),
- }).status,
- 0,
- );
- assert.match(
- Buffer.from(
- service.runtime.envVars
- .SANDPI_PLAYWRIGHT_LOCK_RECOVERY_SCRIPT_BASE64,
- "base64",
- ).toString("utf8"),
- /SingletonLock/,
- );
- assert.ok(
- [...service.runtime.command, ...Object.values(service.runtime.envVars)]
- .reduce((bytes, value) => bytes + Buffer.byteLength(value), 0) <
- 16 * 1024,
- );
- assert.equal(service.runtime.envVars.SANDPI_BROWSER_OWNER, "agent");
- assert.equal(service.runtime.envVars.SANDPI_BROWSER_SESSION_REVISION, "0");
- assert.equal(service.ingress._public, true);
- assert.deepEqual(service.ingress.routes[0]?.methods, ["GET"]);
- assert.equal(service.ingress.routes[0]?.resume, true);
- assert.deepEqual(service.ingress.routes[0]?.auth, {
- mode: "header",
- headerName: "X-Sandpi-Browser-Proxy",
- headerValueSha256: createHash("sha256")
- .update(requestToken, "utf8")
- .digest("hex"),
- });
- assert.equal(JSON.stringify(replacement).includes(requestToken), false);
- assert.equal(replacement[0]?.id, "preview");
-
- const reused = await runtime.ensureEnvironmentBrowserService(coordinates);
- assert.equal(reused.publicUrl, dashboard.publicUrl);
- assert.equal(serviceUpdates, 1);
-
- await runtime.ensureEnvironmentBrowserService(coordinates, true);
- assert.equal(serviceUpdates, 2);
- const restarted = replacement.find(
- (candidate) => candidate.id === "sandpi-browser-dashboard",
- ) as {
- runtime: { envVars: Record };
- };
- assert.equal(restarted.runtime.envVars.SANDPI_BROWSER_SESSION_REVISION, "1");
-});
-
-test("uses the AppService spec as the Browser owner handoff fence", async () => {
- let capabilityChecks = 0;
- let capabilityCommand: {
- command?: string[];
- envVars?: Record;
- } | undefined;
- let preflights = 0;
- let preflightCommand: {
- command?: string[];
- envVars?: Record;
- } | undefined;
- let serviceUpdates = 0;
- let currentServices: Array> = [];
- const runtime = runtimeWithClient({
- sandboxes: {
- sandbox(sandboxId: string) {
- return {
- async getServices() {
- return { sandboxId, services: currentServices };
- },
- async cmd(
- alias: string,
- options: {
- command?: string[];
- envVars?: Record;
- },
- ) {
- if (alias === "browser-takeover-capability") {
- capabilityChecks += 1;
- capabilityCommand = options;
- } else if (alias === "browser-takeover-preflight") {
- preflights += 1;
- preflightCommand = options;
- } else {
- assert.equal(alias, "browser-agent-control-guard");
- }
- return { exitCode: 0, stdout: "", stderr: "" };
- },
- async updateServices(services: Array>) {
- serviceUpdates += 1;
- currentServices = services.map((service) => ({
- ...service,
- publicUrl: "https://browser.example.invalid",
- }));
- return { sandboxId, services: currentServices };
- },
- };
- },
- },
- });
- const coordinates: EnvironmentRuntimeRecord = {
- id: environment.id,
- sandboxId: environment.sandboxId,
- workspaceVolumeId: environment.workspaceVolumeId,
- runtimeGeneration: 1,
- decoder: {
- supervisorCursor: 0,
- tailBase64: "",
- runtimeGeneration: 1,
- },
- };
-
- assert.equal(
- await runtime.isEnvironmentBrowserTakeoverAvailable(coordinates),
- true,
- );
- assert.equal(capabilityChecks, 1);
- assert.match(
- String(capabilityCommand?.command?.at(-1)),
- /command -v Xtigervnc[\s\S]+command -v Xvfb[\s\S]+command -v x11vnc/,
- );
- assert.doesNotMatch(
- String(capabilityCommand?.command?.at(-1)),
- /playwright_guard/,
- );
- assert.equal(capabilityCommand?.envVars, undefined);
-
- const initial = await runtime.ensureEnvironmentBrowserService(coordinates);
- assert.deepEqual(
- {
- owner: initial.owner,
- transport: initial.transport,
- revision: initial.revision,
- },
- {
- owner: "agent",
- transport: "playwright",
- revision: 0,
- },
- );
- const human = await runtime.updateEnvironmentBrowserControl(coordinates, {
- owner: "human",
- });
- assert.deepEqual(human, {
- owner: "human",
- transport: "vnc",
- revision: 1,
- });
- assert.equal(preflights, 1);
- assert.equal(serviceUpdates, 2);
- const takeoverPreparation = String(preflightCommand?.command?.at(-1));
- assert.match(
- takeoverPreparation,
- /playwright_guard=\/workspace\/\.sandpi\/bin\/playwright-cli[\s\S]+command -v Xtigervnc/,
- );
- assert.match(
- Buffer.from(
- preflightCommand?.envVars?.SANDPI_PLAYWRIGHT_CLI_GUARD_BASE64 ?? "",
- "base64",
- ).toString("utf8"),
- /human control.*exec \/usr\/local\/bin\/playwright-cli/s,
- );
-
- const humanService = currentServices[0] as {
- runtime: { command: string[]; envVars: Record };
- };
- const humanCommand = humanService.runtime.command[2] ?? "";
- assert.equal(humanService.runtime.envVars.SANDPI_BROWSER_OWNER, "human");
- assert.equal(
- humanService.runtime.envVars.SANDPI_BROWSER_SESSION_REVISION,
- "1",
- );
- assert.match(
- humanCommand,
- /command -v Xtigervnc[\s\S]+Xtigervnc[\s\S]+AcceptSetDesktopSize[\s\S]+Xvfb[\s\S]+openbox[\s\S]+x11vnc[\s\S]+setpriv/,
- );
- assert.match(humanCommand, /google-chrome-stable.*chrome-linux/s);
- assert.match(humanCommand, /--start-maximized/);
- assert.doesNotMatch(humanCommand, /--window-size/);
- assert.doesNotMatch(
- humanCommand,
- /--headless|--remote-debugging|--enable-automation|--no-sandbox/,
- );
- assert.equal(spawnSync("sh", ["-n", "-c", humanCommand]).status, 0);
- assert.equal(
- spawnSync(process.execPath, ["--check"], {
- input: Buffer.from(
- humanService.runtime.envVars
- .SANDPI_VNC_WEBSOCKET_BRIDGE_SCRIPT_BASE64,
- "base64",
- ).toString("utf8"),
- }).status,
- 0,
- );
-
- const upstream = await runtime.ensureEnvironmentBrowserService(coordinates);
- assert.equal(upstream.owner, "human");
- assert.equal(serviceUpdates, 2);
-
- const agent = await runtime.updateEnvironmentBrowserControl(coordinates, {
- owner: "agent",
- });
- assert.deepEqual(agent, {
- owner: "agent",
- transport: "playwright",
- revision: 2,
- });
- assert.equal(preflights, 1);
- assert.equal(serviceUpdates, 3);
-});
-
-test("reports an unavailable headed-browser runtime without changing owner", async () => {
- let serviceUpdates = 0;
- const runtime = runtimeWithClient({
- sandboxes: {
- sandbox(sandboxId: string) {
- return {
- async getServices() {
- return { sandboxId, services: [] };
- },
- async cmd(alias: string) {
- assert.match(alias, /^browser-takeover-/);
- return { exitCode: 1, stdout: "", stderr: "missing runtime" };
- },
- async updateServices() {
- serviceUpdates += 1;
- return { sandboxId, services: [] };
- },
- };
- },
- },
- });
- const coordinates: EnvironmentRuntimeRecord = {
- id: environment.id,
- sandboxId: environment.sandboxId,
- workspaceVolumeId: environment.workspaceVolumeId,
- runtimeGeneration: 1,
- decoder: {
- supervisorCursor: 0,
- tailBase64: "",
- runtimeGeneration: 1,
- },
- };
-
- assert.equal(
- await runtime.isEnvironmentBrowserTakeoverAvailable(coordinates),
- false,
- );
- await assert.rejects(
- runtime.updateEnvironmentBrowserControl(coordinates, { owner: "human" }),
- (error) =>
- error instanceof HttpError &&
- error.code === "environment_browser_takeover_unavailable",
- );
- assert.equal(serviceUpdates, 0);
-});
-
-test("uses only official Playwright CLI commands for Browser recovery and resize", async () => {
- const commands: Array<{
- alias: string;
- command: string[];
- cwd: string;
- envVars: Record;
- }> = [];
- let browserRunning = false;
- const runtime = runtimeWithClient({
- sandboxes: {
- sandbox() {
- return {
- async cmd(
- alias: string,
- options: {
- command: string[];
- cwd: string;
- envVars: Record;
- },
- ) {
- commands.push({ alias, ...options });
- const operation = options.command[1];
- if (operation === "tab-list") {
- return {
- exitCode: browserRunning ? 0 : 1,
- stdout: "",
- stderr: browserRunning
- ? ""
- : "Error: Browser 'default' is not open.",
- };
- }
- if (operation === "open") browserRunning = true;
- return { exitCode: 0, stdout: "", stderr: "" };
- },
- };
- },
- },
- });
- const coordinates: EnvironmentRuntimeRecord = {
- id: environment.id,
- sandboxId: environment.sandboxId,
- workspaceVolumeId: environment.workspaceVolumeId,
- runtimeGeneration: 1,
- decoder: {
- supervisorCursor: 0,
- tailBase64: "",
- runtimeGeneration: 1,
- },
- };
-
- assert.equal(
- await runtime.ensureEnvironmentBrowserSession(coordinates),
- true,
- );
- await runtime.resizeEnvironmentBrowserViewport(coordinates, {
- width: 519,
- height: 759,
- });
-
- assert.deepEqual(
- commands.map((entry) => entry.command),
- [
- ["playwright-cli", "tab-list"],
- [
- "playwright-cli",
- "open",
- "about:blank",
- "--browser",
- "chromium",
- "--profile=/workspace/.sandpi/browser/profile",
- ],
- ["playwright-cli", "resize", "519", "759"],
- ],
- );
- for (const command of commands) {
- assert.equal(command.alias, "playwright-cli");
- assert.equal(command.cwd, "/workspace");
- assert.equal(command.envVars.HOME, "/workspace");
- assert.equal(command.envVars.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD, "1");
- }
-});
-
-test("recovers one stale persistent browser profile lock", async () => {
- const commands: Array<{ alias: string; command: string[] }> = [];
- let openAttempts = 0;
- const profilePath = "/workspace/.sandpi/browser/profile";
- const runtime = runtimeWithClient({
- sandboxes: {
- sandbox() {
- return {
- async cmd(alias: string, options: { command: string[] }) {
- commands.push({ alias, command: options.command });
- if (options.command[0] === "node") {
- assert.equal(options.command.at(-1), profilePath);
- return { exitCode: 0, stdout: "", stderr: "" };
- }
- const operation = options.command[1];
- if (operation === "tab-list") {
- return {
- exitCode: 1,
- stdout: "",
- stderr: "Error: Browser 'default' is not open.",
- };
- }
- if (operation === "open" && openAttempts++ === 0) {
- return {
- exitCode: 1,
- stdout: "",
- stderr: `Error: Browser is already in use for ${profilePath}, use --isolated`,
- };
- }
- return { exitCode: 0, stdout: "", stderr: "" };
- },
- };
- },
- },
- });
- const coordinates: EnvironmentRuntimeRecord = {
- id: environment.id,
- sandboxId: environment.sandboxId,
- workspaceVolumeId: environment.workspaceVolumeId,
- runtimeGeneration: 1,
- decoder: {
- supervisorCursor: 0,
- tailBase64: "",
- runtimeGeneration: 1,
- },
- };
-
- assert.equal(
- await runtime.ensureEnvironmentBrowserSession(coordinates),
- true,
- );
-
- assert.deepEqual(
- commands.map(({ alias, command }) => [alias, command[0], command[1]]),
- [
- ["playwright-cli", "playwright-cli", "tab-list"],
- ["playwright-cli", "playwright-cli", "open"],
- ["playwright-profile-lock-recovery", "node", "-e"],
- ["playwright-cli", "playwright-cli", "open"],
- ],
- );
-});
-
-test("distinguishes missing Playwright dependencies from failed recovery", async () => {
- const coordinates: EnvironmentRuntimeRecord = {
- id: environment.id,
- sandboxId: environment.sandboxId,
- workspaceVolumeId: environment.workspaceVolumeId,
- runtimeGeneration: 1,
- decoder: {
- supervisorCursor: 0,
- tailBase64: "",
- runtimeGeneration: 1,
- },
- };
- const missing = runtimeWithClient({
- sandboxes: {
- sandbox() {
- return {
- async cmd() {
- return {
- exitCode: 127,
- stdout: "",
- stderr: "playwright-cli: command not found",
- };
- },
- };
- },
- },
- });
- await assert.rejects(
- missing.ensureEnvironmentBrowserSession(coordinates),
- (error) =>
- error instanceof HttpError &&
- error.code === "environment_browser_dependency_unavailable",
- );
-
- const profilePath = "/workspace/.sandpi/browser/profile";
- const unrecoverable = runtimeWithClient({
- sandboxes: {
- sandbox() {
- return {
- async cmd(
- alias: string,
- options: { command: string[] },
- ) {
- if (alias === "playwright-profile-lock-recovery") {
- return { exitCode: 12, stdout: "", stderr: "" };
- }
- if (options.command[1] === "tab-list") {
- return {
- exitCode: 1,
- stdout: "",
- stderr: "Error: Browser 'default' is not open.",
- };
- }
- if (options.command[1] === "open") {
- return {
- exitCode: 1,
- stdout: "",
- stderr: `Error: Browser is already in use for ${profilePath}, use --isolated`,
- };
- }
- return { exitCode: 0, stdout: "", stderr: "" };
- },
- };
- },
- },
- });
- await assert.rejects(
- unrecoverable.ensureEnvironmentBrowserSession(coordinates),
- (error) =>
- error instanceof HttpError &&
- error.code === "environment_browser_recovery_failed",
- );
-});
-
test("creates, restores and deletes native snapshots for the Environment Workspace Volume", async () => {
const calls: Array<{ operation: string; volumeId: string; value: unknown }> = [];
const runtime = runtimeWithClient({
@@ -2762,7 +2130,6 @@ test("starts one Environment-scoped Codex app-server without unsupported plugin
assert.deepEqual(sessions[0]?.spec.env, {
HOME: "/workspace",
CODEX_HOME: "/workspace/.sandpi/harnesses/codex",
- PATH: "/workspace/.sandpi/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
});
assert.deepEqual(sessions[0]?.spec.lifecycle, {
restart: {
@@ -2786,18 +2153,7 @@ test("starts one Environment-scoped Codex app-server without unsupported plugin
const preparationCommand = String(preparation?.command?.at(-1));
assert.equal(
Object.keys(preparation?.envVars ?? {}).length,
- SANDPI_MANAGED_SKILL_ASSETS.length * 2 + 1,
- );
- assert.match(
- Buffer.from(
- preparation?.envVars?.SANDPI_PLAYWRIGHT_CLI_GUARD_BASE64 ?? "",
- "base64",
- ).toString("utf8"),
- /human control.*exec \/usr\/local\/bin\/playwright-cli/s,
- );
- assert.match(
- preparationCommand,
- /playwright_guard=\/workspace\/\.sandpi\/bin\/playwright-cli/,
+ SANDPI_MANAGED_SKILL_ASSETS.length * 2,
);
for (const [index, asset] of SANDPI_MANAGED_SKILL_ASSETS.entries()) {
assert.equal(
@@ -2830,6 +2186,9 @@ test("starts one Environment-scoped Codex app-server without unsupported plugin
command.command
?.at(-1)
?.includes("skills=/workspace/.sandpi/skills") &&
+ command.command
+ ?.at(-1)
+ ?.includes("playwright=/workspace/.sandpi/playwright") &&
command.command
?.at(-1)
?.includes('install_managed_file "$managed_skill/SKILL.md"') &&
diff --git a/src/server/runtime/sandbox0.ts b/src/server/runtime/sandbox0.ts
index b1db3b1..d03bbdc 100644
--- a/src/server/runtime/sandbox0.ts
+++ b/src/server/runtime/sandbox0.ts
@@ -1,7 +1,7 @@
import { isUtf8 } from "node:buffer";
-import { createHash, createHmac, randomUUID } from "node:crypto";
+import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
-import { isDeepStrictEqual, promisify } from "node:util";
+import { promisify } from "node:util";
import { zstdDecompress } from "node:zlib";
import {
@@ -15,11 +15,6 @@ import {
type SandboxMetrics,
} from "sandbox0";
-import type {
- BrowserDashboardViewport,
- EnvironmentBrowserOwner,
- EnvironmentBrowserOwnership,
-} from "@/lib/environment-browser";
import type {
Environment,
EnvironmentResourceMetrics,
@@ -70,7 +65,6 @@ import {
type RecoveredCodexEnvironmentRuntime,
type RuntimeCredentialSourceMetadata,
type RuntimeAdapter,
- type RuntimeBrowserUpstream,
type RuntimeCodexSkillFile,
type RuntimeCodexEventStreamHandle,
type RuntimeUsageWindowPage,
@@ -96,24 +90,6 @@ import {
requireWorkspaceFileRevision,
workspaceFileRevision,
} from "./workspace-edit";
-import {
- isPlaywrightBrowserDependencyUnavailable,
- isPlaywrightBrowserNotOpen,
- PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT,
- playwrightProfilePathFromInUseError,
- playwrightStaleProfileLockRecoveryCommand,
- type PlaywrightCliResult,
-} from "./playwright-browser-recovery";
-import {
- HUMAN_BROWSER_PREFLIGHT_SCRIPT,
- humanBrowserStartScript,
- ENVIRONMENT_BROWSER_PROFILE_PATH,
- PLAYWRIGHT_CLI_ENVIRONMENT,
- PLAYWRIGHT_CLI_GUARD_SCRIPT,
- PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64,
- playwrightDashboardStartScript,
- VNC_WEBSOCKET_BRIDGE_SCRIPT_BASE64,
-} from "./environment-browser-runtime";
import { reconcileTerminalReplayCursor } from "./terminal-replay";
import {
terminalCommandUpdate,
@@ -134,17 +110,6 @@ const SANDBOX_LIST_PAGE_SIZE = 100;
const TERMINAL_EVENT_RETENTION_BYTES = 4 * 1024 * 1024;
const ENVIRONMENT_CODEX_HOME = "/workspace/.sandpi/harnesses/codex";
const WORKSPACE_CODEX_LAYOUT_MARKER = `${ENVIRONMENT_CODEX_HOME}/.sandpi-layout-environment-v1`;
-const ENVIRONMENT_PLAYWRIGHT_GUARD_PATH =
- `${WORKSPACE_INTERNAL_ROOT}/bin/playwright-cli`;
-const ENVIRONMENT_AGENT_PATH = [
- `${WORKSPACE_INTERNAL_ROOT}/bin`,
- "/usr/local/sbin",
- "/usr/local/bin",
- "/usr/sbin",
- "/usr/bin",
- "/sbin",
- "/bin",
-].join(":");
const ENVIRONMENT_CODEX_AUTH_FILE = CODEX_ENVIRONMENT_CREDENTIAL_PATH;
// Sandpi exposes native Skills and MCP servers, but it has no host surface for
// Codex Apps or plugin-install approvals. Keep their discovery tools out of
@@ -155,21 +120,8 @@ const MCP_OAUTH_CALLBACK_SERVICE_ID = "sandpi-codex-mcp-oauth";
const MCP_OAUTH_CALLBACK_ROUTE_ID = "oauth-callback";
const MCP_OAUTH_CALLBACK_RATE_LIMIT_RPS = 5;
const MCP_OAUTH_CALLBACK_RATE_LIMIT_BURST = 10;
-const BROWSER_DASHBOARD_SERVICE_ID = "sandpi-browser-dashboard";
-const BROWSER_DASHBOARD_ROUTE_ID = "dashboard";
-const BROWSER_DASHBOARD_PORT = 43_420;
-const BROWSER_DASHBOARD_AUTH_HEADER = "X-Sandpi-Browser-Proxy";
-const PLAYWRIGHT_CLI_TIMEOUT_SECONDS = 120;
const PLAYWRIGHT_AGENT_SKILL_VERSION_MARKER =
- `${WORKSPACE_INTERNAL_ROOT}/browser/playwright-cli-agent-skill-package-version`;
-const PLAYWRIGHT_CLI_GUARD_BASE64 = Buffer.from(
- PLAYWRIGHT_CLI_GUARD_SCRIPT,
- "utf8",
-).toString("base64");
-const PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT_BASE64 = Buffer.from(
- PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT,
- "utf8",
-).toString("base64");
+ `${WORKSPACE_INTERNAL_ROOT}/playwright/playwright-cli-agent-skill-package-version`;
const DEVICE_CODEX_HOME = "/dev/shm/sandpi-codex-device";
const DEVICE_CODEX_AUTH_FILE = `${DEVICE_CODEX_HOME}/auth.json`;
const CODEX_AUTH_MAX_BYTES = 4 * 1024 * 1024;
@@ -246,7 +198,6 @@ function validDate(value: unknown) {
export class Sandbox0Runtime implements RuntimeAdapter {
readonly mode = "sandbox0" as const;
private readonly client: Client;
- private readonly browserProxyKey: Buffer;
private readonly workspaceGitCache = new Map<
string,
{
@@ -257,10 +208,6 @@ export class Sandbox0Runtime implements RuntimeAdapter {
>();
constructor(options: { apiHost: string; apiKey: string }) {
- this.browserProxyKey = createHash("sha256")
- .update("sandpi/browser-dashboard/v1\0", "utf8")
- .update(options.apiKey, "utf8")
- .digest();
this.client = new Client({
token: options.apiKey,
baseUrl: options.apiHost,
@@ -373,16 +320,6 @@ export class Sandbox0Runtime implements RuntimeAdapter {
input.environment.networkPolicy,
input.credentials,
),
- services: [
- environmentBrowserService(
- browserDashboardRequestToken(
- this.browserProxyKey,
- input.environment.id,
- ),
- "agent",
- 0,
- ),
- ],
},
},
);
@@ -650,243 +587,6 @@ export class Sandbox0Runtime implements RuntimeAdapter {
}
}
- async updateEnvironmentBrowserControl(
- runtime: EnvironmentRuntimeRecord,
- input: { owner: EnvironmentBrowserOwner; force?: boolean },
- ): Promise {
- const requestToken = browserDashboardRequestToken(
- this.browserProxyKey,
- runtime.id,
- );
- try {
- const sandbox = this.client.sandboxes.sandbox(runtime.sandboxId);
- const existing = await sandbox.getServices();
- const currentService = existing.services.find(
- (service) => service.id === BROWSER_DASHBOARD_SERVICE_ID,
- );
- const currentControl = environmentBrowserControl(currentService);
- if (currentControl.owner === input.owner && !input.force) {
- return currentControl;
- }
- if (input.owner === "human") {
- // Install the managed CLI fence before the authoritative owner
- // changes. A running agent already has this directory first in PATH,
- // so its next Playwright command is denied even before VNC starts.
- const preflight = await runHumanBrowserPreflight(sandbox, true);
- if (preflight.exitCode !== 0) {
- throw new HttpError(
- 503,
- "environment_browser_takeover_unavailable",
- "This Environment does not include the interactive browser runtime. Recreate it with the current coding-agent template.",
- );
- }
- }
-
- const desiredService = environmentBrowserService(
- requestToken,
- input.owner,
- currentControl.revision + 1,
- );
- const services = existing.services
- .filter((service) => service.id !== BROWSER_DASHBOARD_SERVICE_ID)
- .map(sandboxAppServiceFromView);
- services.push(desiredService);
- let updated;
- try {
- updated = await sandbox.updateServices(services);
- } catch (error) {
- const confirmed = await sandbox.getServices().catch(() => undefined);
- const confirmedService = confirmed?.services.find(
- (service) => service.id === BROWSER_DASHBOARD_SERVICE_ID,
- );
- if (
- !confirmed ||
- !confirmedService?.publicUrl ||
- !sandboxAppServiceConfigurationMatches(
- confirmedService,
- desiredService,
- )
- ) {
- throw error;
- }
- updated = confirmed;
- }
- const service = updated.services.find(
- (candidate) => candidate.id === BROWSER_DASHBOARD_SERVICE_ID,
- );
- if (!service?.publicUrl) {
- throw new HttpError(
- 503,
- "environment_browser_exposure_unavailable",
- "Sandbox0 did not publish the protected Environment browser service.",
- );
- }
- if (input.owner === "agent") {
- // The guard is a persistent enforcement derivative, not owner state.
- // Clear it only after Sandbox0 stores the agent-owned AppService.
- await sandbox
- .cmd("browser-agent-control-guard", {
- command: [
- "sh",
- "-c",
- "rm -f /workspace/.sandpi/browser/human-owner",
- ],
- cwd: "/workspace",
- wait: true,
- ttlSec: 30,
- })
- .catch(() => undefined);
- }
- return environmentBrowserControl(service);
- } catch (error) {
- if (error instanceof HttpError) throw error;
- throw translateSandbox0Error(error);
- }
- }
-
- async isEnvironmentBrowserTakeoverAvailable(
- runtime: EnvironmentRuntimeRecord,
- ): Promise {
- try {
- const sandbox = this.client.sandboxes.sandbox(runtime.sandboxId);
- const preflight = await runHumanBrowserPreflight(sandbox, false);
- return preflight.exitCode === 0;
- } catch (error) {
- if (error instanceof HttpError) throw error;
- throw translateSandbox0Error(error);
- }
- }
-
- async ensureEnvironmentBrowserService(
- runtime: EnvironmentRuntimeRecord,
- restart = false,
- ): Promise {
- const requestToken = browserDashboardRequestToken(
- this.browserProxyKey,
- runtime.id,
- );
- try {
- const sandbox = this.client.sandboxes.sandbox(runtime.sandboxId);
- const existing = await sandbox.getServices();
- const currentService = existing.services.find(
- (service) => service.id === BROWSER_DASHBOARD_SERVICE_ID,
- );
- const currentControl = environmentBrowserControl(currentService);
- const desiredService = environmentBrowserService(
- requestToken,
- currentControl.owner,
- currentService && restart
- ? currentControl.revision + 1
- : currentControl.revision,
- );
- if (
- currentService?.publicUrl &&
- sandboxAppServiceConfigurationMatches(
- currentService,
- desiredService,
- )
- ) {
- return {
- ...currentControl,
- publicUrl: currentService.publicUrl,
- requestHeaders: {
- [BROWSER_DASHBOARD_AUTH_HEADER]: requestToken,
- },
- };
- }
- const services = existing.services
- .filter((service) => service.id !== BROWSER_DASHBOARD_SERVICE_ID)
- .map(sandboxAppServiceFromView);
- services.push(desiredService);
- let updated;
- try {
- updated = await sandbox.updateServices(services);
- } catch (error) {
- // A service-spec replacement may be committed before an upstream
- // gateway loses the response. Confirm the canonical stored value so
- // the first Browser mount does not surface a false failure or rewrite
- // the same service again.
- const confirmed = await sandbox.getServices().catch(() => undefined);
- const confirmedService = confirmed?.services.find(
- (service) => service.id === BROWSER_DASHBOARD_SERVICE_ID,
- );
- if (
- !confirmedService?.publicUrl ||
- !sandboxAppServiceConfigurationMatches(
- confirmedService,
- desiredService,
- )
- ) {
- throw error;
- }
- return {
- ...environmentBrowserControl(confirmedService),
- publicUrl: confirmedService.publicUrl,
- requestHeaders: {
- [BROWSER_DASHBOARD_AUTH_HEADER]: requestToken,
- },
- };
- }
- const service = updated.services.find(
- (service) => service.id === BROWSER_DASHBOARD_SERVICE_ID,
- );
- if (!service?.publicUrl) {
- throw new HttpError(
- 503,
- "environment_browser_exposure_unavailable",
- "Sandbox0 did not publish the protected Environment browser service.",
- );
- }
- return {
- ...environmentBrowserControl(service),
- publicUrl: service.publicUrl,
- requestHeaders: {
- [BROWSER_DASHBOARD_AUTH_HEADER]: requestToken,
- },
- };
- } catch (error) {
- if (error instanceof HttpError) throw error;
- throw translateSandbox0Error(error);
- }
- }
-
- async ensureEnvironmentBrowserSession(
- runtime: EnvironmentRuntimeRecord,
- ): Promise {
- try {
- const sandbox = this.client.sandboxes.sandbox(runtime.sandboxId);
- const tabs = await runPlaywrightCli(sandbox, ["tab-list"]);
- if (tabs.exitCode === 0) return false;
- if (!isPlaywrightBrowserNotOpen(tabs)) {
- requirePlaywrightCliSuccess(tabs);
- }
- await openPlaywrightBrowser(sandbox, "about:blank");
- return true;
- } catch (error) {
- if (error instanceof HttpError) throw error;
- throw translateSandbox0Error(error);
- }
- }
-
- async resizeEnvironmentBrowserViewport(
- runtime: EnvironmentRuntimeRecord,
- viewport: BrowserDashboardViewport,
- ): Promise {
- try {
- const sandbox = this.client.sandboxes.sandbox(runtime.sandboxId);
- requirePlaywrightCliSuccess(
- await runPlaywrightCli(sandbox, [
- "resize",
- String(viewport.width),
- String(viewport.height),
- ]),
- );
- } catch (error) {
- if (error instanceof HttpError) throw error;
- throw translateSandbox0Error(error);
- }
- }
-
async createEnvironmentWorkspaceBackup(
runtime: EnvironmentRuntimeRecord,
input: { name: string; description: string },
@@ -1023,7 +723,6 @@ export class Sandbox0Runtime implements RuntimeAdapter {
env: {
HOME: "/workspace",
CODEX_HOME: ENVIRONMENT_CODEX_HOME,
- PATH: ENVIRONMENT_AGENT_PATH,
},
io: { mode: "pipes" },
lifecycle: {
@@ -2976,205 +2675,6 @@ function sandboxAppServiceFromView(
};
}
-function sandboxAppServiceConfigurationMatches(
- current: Sandbox0AppServiceView,
- desired: Sandbox0AppService,
-) {
- return isDeepStrictEqual(
- sandboxAppServiceComparableConfiguration(current),
- sandboxAppServiceComparableConfiguration(desired),
- );
-}
-
-function sandboxAppServiceComparableConfiguration(
- service: Sandbox0AppService | Sandbox0AppServiceView,
-) {
- // Generated SDK response models retain optional keys with undefined values,
- // while hand-authored request models omit those keys. They serialize to the
- // same API contract and must not trigger an AppService restart.
- return JSON.parse(JSON.stringify(sandboxAppServiceFromView(service)));
-}
-
-function environmentBrowserControl(
- service: Sandbox0AppServiceView | undefined,
-): EnvironmentBrowserOwnership {
- const value = Number(
- service?.runtime?.envVars?.SANDPI_BROWSER_SESSION_REVISION ?? 0,
- );
- const revision = Number.isSafeInteger(value) && value >= 0 ? value : 0;
- const owner =
- service?.runtime?.envVars?.SANDPI_BROWSER_OWNER === "human"
- ? "human"
- : "agent";
- return {
- owner,
- transport: owner === "human" ? "vnc" : "playwright",
- revision,
- };
-}
-
-function browserDashboardRequestToken(
- browserProxyKey: Buffer,
- environmentId: string,
-) {
- return createHmac("sha256", browserProxyKey)
- .update(environmentId, "utf8")
- .digest("base64url");
-}
-
-function environmentBrowserService(
- requestToken: string,
- owner: EnvironmentBrowserOwner,
- sessionRevision: number,
-): Sandbox0AppService {
- return {
- id: BROWSER_DASHBOARD_SERVICE_ID,
- displayName: "Sandpi Browser",
- port: BROWSER_DASHBOARD_PORT,
- runtime: {
- type: models.SandboxAppServiceRuntimeTypeEnum.Cmd,
- // The protected ingress lazily starts exactly one owner-specific
- // transport. Replacing this service is the ownership handoff fence.
- command: [
- "sh",
- "-c",
- owner === "human"
- ? humanBrowserStartScript(BROWSER_DASHBOARD_PORT)
- : playwrightDashboardStartScript(BROWSER_DASHBOARD_PORT),
- ],
- cwd: "/workspace",
- envVars: {
- ...PLAYWRIGHT_CLI_ENVIRONMENT,
- SANDPI_BROWSER_OWNER: owner,
- SANDPI_BROWSER_USER: "sandbox-browser",
- SANDPI_PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64:
- PLAYWRIGHT_DASHBOARD_READY_SCRIPT_BASE64,
- SANDPI_PLAYWRIGHT_LOCK_RECOVERY_SCRIPT_BASE64:
- PLAYWRIGHT_STALE_PROFILE_LOCK_RECOVERY_SCRIPT_BASE64,
- SANDPI_VNC_WEBSOCKET_BRIDGE_SCRIPT_BASE64:
- VNC_WEBSOCKET_BRIDGE_SCRIPT_BASE64,
- SANDPI_BROWSER_SESSION_REVISION: String(sessionRevision),
- },
- },
- ingress: {
- _public: true,
- routes: [
- {
- id: BROWSER_DASHBOARD_ROUTE_ID,
- pathPrefix: "/",
- methods: ["GET"],
- auth: {
- mode: models.SandboxAppServiceRouteAuthModeEnum.Header,
- headerName: BROWSER_DASHBOARD_AUTH_HEADER,
- headerValueSha256: createHash("sha256")
- .update(requestToken, "utf8")
- .digest("hex"),
- },
- // Sandpi authenticates and authorizes every proxy request before it
- // adds the secret header. Let this protected ingress perform the
- // Sandbox-native auto-resume instead of issuing a separate control
- // API command solely to wake the Environment.
- resume: true,
- },
- ],
- },
- };
-}
-
-function runPlaywrightCli(
- sandbox: ReturnType,
- args: string[],
-) {
- return sandbox.cmd("playwright-cli", {
- command: ["playwright-cli", ...args],
- cwd: "/workspace",
- envVars: { ...PLAYWRIGHT_CLI_ENVIRONMENT },
- wait: true,
- ttlSec: PLAYWRIGHT_CLI_TIMEOUT_SECONDS,
- });
-}
-
-function runHumanBrowserPreflight(
- sandbox: ReturnType,
- installGuard: boolean,
-) {
- return sandbox.cmd(
- installGuard
- ? "browser-takeover-preflight"
- : "browser-takeover-capability",
- {
- command: [
- "sh",
- "-c",
- installGuard
- ? `${environmentPlaywrightGuardInstallScript()}\n${HUMAN_BROWSER_PREFLIGHT_SCRIPT}`
- : HUMAN_BROWSER_PREFLIGHT_SCRIPT,
- ],
- cwd: "/workspace",
- ...(installGuard
- ? {
- envVars: {
- SANDPI_PLAYWRIGHT_CLI_GUARD_BASE64:
- PLAYWRIGHT_CLI_GUARD_BASE64,
- },
- }
- : {}),
- wait: true,
- ttlSec: 30,
- },
- );
-}
-
-async function openPlaywrightBrowser(
- sandbox: ReturnType,
- url: string,
-) {
- const args = [
- "open",
- url,
- "--browser",
- "chromium",
- `--profile=${ENVIRONMENT_BROWSER_PROFILE_PATH}`,
- ];
- let result = await runPlaywrightCli(sandbox, args);
- if (result.exitCode === 0) return;
-
- const profilePath = playwrightProfilePathFromInUseError(result.stderr);
- const recoveryCommand =
- profilePath &&
- playwrightStaleProfileLockRecoveryCommand(profilePath);
- if (recoveryCommand) {
- const recovery = await sandbox.cmd("playwright-profile-lock-recovery", {
- command: recoveryCommand,
- cwd: "/workspace",
- envVars: { ...PLAYWRIGHT_CLI_ENVIRONMENT },
- wait: true,
- ttlSec: PLAYWRIGHT_CLI_TIMEOUT_SECONDS,
- });
- if (recovery.exitCode === 0) {
- result = await runPlaywrightCli(sandbox, args);
- if (result.exitCode === 0) return;
- }
- }
- requirePlaywrightCliSuccess(result);
-}
-
-function requirePlaywrightCliSuccess(result: PlaywrightCliResult) {
- if (result.exitCode === 0) return;
- if (isPlaywrightBrowserDependencyUnavailable(result)) {
- throw new HttpError(
- 503,
- "environment_browser_dependency_unavailable",
- "This Environment does not include a compatible Playwright CLI and Chromium. Recreate it with the current coding-agent template.",
- );
- }
- throw new HttpError(
- 503,
- "environment_browser_recovery_failed",
- "The shared Environment browser could not be recovered. Retry after the Environment finishes resuming.",
- );
-}
-
function mcpOAuthCallbackService(port: number): Sandbox0AppService {
return {
id: MCP_OAUTH_CALLBACK_SERVICE_ID,
@@ -3238,31 +2738,6 @@ function codexRuntimeEpochChanged(
return new HttpError(409, "codex_runtime_epoch_changed", message);
}
-/** Reconciles the one managed CLI entrypoint shared by Codex and takeover. */
-function environmentPlaywrightGuardInstallScript() {
- return `set -eu
-internal=${WORKSPACE_INTERNAL_ROOT}
-bin=${WORKSPACE_INTERNAL_ROOT}/bin
-playwright_guard=${ENVIRONMENT_PLAYWRIGHT_GUARD_PATH}
-test ! -L "$internal"
-install -d -m 711 "$internal"
-test ! -L "$bin"
-install -d -m 700 "$bin"
-test ! -L "$playwright_guard"
-if [ -e "$playwright_guard" ]; then test -f "$playwright_guard"; fi
-temporary="$playwright_guard.tmp.$PPID.$$"
-test ! -e "$temporary"
-printf '%s' "$SANDPI_PLAYWRIGHT_CLI_GUARD_BASE64" | base64 -d > "$temporary"
-chmod 700 "$temporary"
-if [ -f "$playwright_guard" ] && cmp -s "$playwright_guard" "$temporary"; then
- rm -f "$temporary"
-else
- mv -f "$temporary" "$playwright_guard"
-fi
-chmod 700 "$playwright_guard"`;
-}
-
-/** Encodes release-owned skills and renders one shared Workspace reconciler. */
function managedSkillPreparation() {
const envVars: Record = {};
const installCommands = SANDPI_MANAGED_SKILL_ASSETS.map((asset, index) => {
@@ -3293,25 +2768,21 @@ async function prepareEnvironmentCodexHome(
sandbox: ReturnType,
) {
const managedSkills = managedSkillPreparation();
- const preparationEnvVars = {
- ...managedSkills.envVars,
- SANDPI_PLAYWRIGHT_CLI_GUARD_BASE64: PLAYWRIGHT_CLI_GUARD_BASE64,
- };
const command = `set -eu
internal=${WORKSPACE_INTERNAL_ROOT}
harnesses=/workspace/.sandpi/harnesses
home=${ENVIRONMENT_CODEX_HOME}
marker=${WORKSPACE_CODEX_LAYOUT_MARKER}
-browser=/workspace/.sandpi/browser
+playwright=/workspace/.sandpi/playwright
skills=${SANDPI_MANAGED_SKILL_ROOT}
playwright_skill_marker=${PLAYWRIGHT_AGENT_SKILL_VERSION_MARKER}
test ! -L "$internal"
test ! -L "$harnesses"
-test ! -L "$browser"
+test ! -L "$playwright"
test ! -L "$skills"
install -d -m 711 "$internal"
install -d -m 700 "$harnesses" "$skills"
-install -d -m 711 "$browser"
+install -d -m 711 "$playwright"
test ! -L "$home"
install -d -m 700 "$home"
install_managed_file() {
@@ -3331,7 +2802,6 @@ install_managed_file() {
fi
}
${managedSkills.installCommands}
-${environmentPlaywrightGuardInstallScript()}
if [ -f "$marker" ]; then
test "$(cat "$marker")" = environment_v1
else
@@ -3361,7 +2831,7 @@ sync -f /workspace 2>/dev/null || sync`;
const result = await sandbox.cmd("prepare-environment-codex-home", {
command: ["/bin/sh", "-lc", command],
cwd: "/workspace",
- envVars: preparationEnvVars,
+ envVars: managedSkills.envVars,
ttlSec: 60,
});
if (result.exitCode !== undefined && result.exitCode !== 0) {
diff --git a/src/server/runtime/sandpi-managed-skills.test.ts b/src/server/runtime/sandpi-managed-skills.test.ts
index cc3db50..df17702 100644
--- a/src/server/runtime/sandpi-managed-skills.test.ts
+++ b/src/server/runtime/sandpi-managed-skills.test.ts
@@ -43,19 +43,22 @@ test("loads every release-owned Sandpi skill and interface", () => {
);
});
-test("keeps the public guide aligned with shared Browser, lifecycle, and canonical references", () => {
+test("keeps the public guide aligned with Playwright, lifecycle, and canonical references", () => {
const guide = readFileSync(
new URL("../../../public/llms.txt", import.meta.url),
"utf8",
);
- assert.match(guide, /human-agent shared Environment Browser/i);
- assert.match(guide, /one active owner/);
- assert.match(guide, /must not launch a second browser, attach through CDP/);
- assert.doesNotMatch(guide, /complete an interactive login/i);
+ assert.match(guide, /## Playwright CLI/);
+ assert.match(guide, /materializes its version-matched upstream Agent Skill/);
+ assert.match(
+ guide,
+ /neither an Environment Browser nor an application Preview tab/,
+ );
+ assert.match(guide, /browser executable must be provisioned separately/);
assert.match(guide, /soft TTL and hard TTL to zero/);
assert.match(guide, /does not idle-pause an Environment while/);
- assert.match(guide, /Process memory, sockets, live Browser pages/);
+ assert.match(guide, /Process memory, sockets, terminal process state/);
assert.match(guide, /\/cli\/README\.md/);
assert.match(guide, /docs\/local-environment-migration\.md/);
assert.match(guide, /docs\/architecture\/cli\.md/);
diff --git a/src/server/runtime/types.ts b/src/server/runtime/types.ts
index f6176e3..70eaef1 100644
--- a/src/server/runtime/types.ts
+++ b/src/server/runtime/types.ts
@@ -15,11 +15,6 @@ import type {
EnvironmentCredentialResolverKind,
EnvironmentEgressCredential,
} from "@/lib/environment-credentials";
-import type {
- BrowserDashboardViewport,
- EnvironmentBrowserOwner,
- EnvironmentBrowserOwnership,
-} from "@/lib/environment-browser";
import type { UnixTimestamp } from "@/lib/time";
import type {
CodexDecoderState,
@@ -55,15 +50,6 @@ export interface RuntimeCodexSkillFile {
executable: boolean;
}
-/**
- * Server-only connection details for the owner-specific Browser transport.
- * Sandpi API handlers must never serialize the protected upstream headers.
- */
-export interface RuntimeBrowserUpstream extends EnvironmentBrowserOwnership {
- publicUrl: string;
- requestHeaders: Record;
-}
-
export interface ProvisionedEnvironment {
sandboxId: string;
workspaceVolumeId: string;
@@ -212,24 +198,6 @@ export interface RuntimeAdapter {
runtime: EnvironmentRuntimeRecord,
input: { port: number },
): Promise;
- updateEnvironmentBrowserControl(
- runtime: EnvironmentRuntimeRecord,
- input: { owner: EnvironmentBrowserOwner; force?: boolean },
- ): Promise;
- isEnvironmentBrowserTakeoverAvailable(
- runtime: EnvironmentRuntimeRecord,
- ): Promise;
- ensureEnvironmentBrowserService(
- runtime: EnvironmentRuntimeRecord,
- restart?: boolean,
- ): Promise;
- ensureEnvironmentBrowserSession(
- runtime: EnvironmentRuntimeRecord,
- ): Promise;
- resizeEnvironmentBrowserViewport(
- runtime: EnvironmentRuntimeRecord,
- viewport: BrowserDashboardViewport,
- ): Promise;
createEnvironmentWorkspaceBackup(
runtime: EnvironmentRuntimeRecord,
input: { name: string; description: string },
diff --git a/src/server/runtime/unconfigured.ts b/src/server/runtime/unconfigured.ts
index ef7f3e7..0306124 100644
--- a/src/server/runtime/unconfigured.ts
+++ b/src/server/runtime/unconfigured.ts
@@ -26,11 +26,6 @@ export class UnconfiguredRuntime implements RuntimeAdapter {
deleteEnvironmentCredentialSource = unavailable;
updateEnvironmentMemory = unavailable;
ensureEnvironmentMcpOAuthCallbackService = unavailable;
- updateEnvironmentBrowserControl = unavailable;
- isEnvironmentBrowserTakeoverAvailable = unavailable;
- ensureEnvironmentBrowserService = unavailable;
- ensureEnvironmentBrowserSession = unavailable;
- resizeEnvironmentBrowserViewport = unavailable;
createEnvironmentWorkspaceBackup = unavailable;
deleteEnvironmentWorkspaceBackup = unavailable;
restoreEnvironmentWorkspaceBackup = unavailable;
diff --git a/src/server/server.ts b/src/server/server.ts
index 3798af0..0fe9ce6 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -1,7 +1,6 @@
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { Buffer, isUtf8 } from "node:buffer";
-import { Readable, Transform } from "node:stream";
import fastifyCompress from "@fastify/compress";
import fastifyCookie from "@fastify/cookie";
@@ -15,7 +14,7 @@ import Fastify, {
type FastifyRequest,
} from "fastify";
import type { Pool } from "pg";
-import WebSocket, { type RawData } from "ws";
+import WebSocket from "ws";
import { ZodError, z } from "zod";
import type { SandpiDeploymentSummary } from "@/lib/types";
@@ -46,22 +45,6 @@ import { EnvironmentEgressCredentialService } from "@/server/environments/egress
import { EnvironmentService } from "@/server/environments/service";
import { EnvironmentLifecycleService } from "@/server/environments/lifecycle-service";
import { EnvironmentRuntimeAccessService } from "@/server/environments/runtime-access-service";
-import {
- EnvironmentBrowserService,
- dashboardAssetCacheControl,
- dashboardProxyPrefix,
- dashboardRedirectLocation,
- rewriteDashboardCss,
- rewriteDashboardHtml,
-} from "@/server/environments/browser-service";
-import {
- BrowserWebSocketDownstreamRelay,
- websocketRawDataSize,
-} from "@/server/environments/browser-websocket-relay";
-import {
- BrowserDashboardReadOnlyGate,
- browserDashboardRejectedResponse,
-} from "@/server/environments/browser-dashboard-protocol";
import {
EnvironmentScheduleService,
type EnvironmentScheduleConfiguration,
@@ -114,8 +97,6 @@ import { SandpiStore } from "@/server/store";
import { TerminalInputQueue } from "@/server/terminal-input-queue";
import {
billingCheckoutSchema,
- browserControlSchema,
- browserSessionSchema,
codexComposerUploadSchema,
codexHookUpdateSchema,
codexMcpServerConfigurationSchema,
@@ -125,7 +106,6 @@ import {
codexRateLimitResetSchema,
codexSkillConfigurationSchema,
codexSkillPutSchema,
- environmentBrowserViewportSchema,
environmentCreateSchema,
environmentOrderSchema,
environmentProvisioningSchema,
@@ -250,7 +230,6 @@ export async function createSandpiServer(
const runtimeAccess = new EnvironmentRuntimeAccessService(store, runtime, {
quotaGate: billingQuota,
});
- const browser = new EnvironmentBrowserService(runtimeAccess, runtime);
const codex = new CodexService(store, runtime, app.log, codexAuth, {
runtimeQuotaGate: billingQuota,
});
@@ -300,7 +279,6 @@ export async function createSandpiServer(
lifecycle.setBeforePause(async (environmentId) => {
await codex.flushEnvironmentCredentials(environmentId);
codex.suspendEnvironmentWorker(environmentId);
- browser.invalidate(environmentId);
});
sandboxUsage?.setPauseForQuota((environmentId) =>
lifecycle.pauseForQuota(environmentId),
@@ -411,7 +389,6 @@ export async function createSandpiServer(
store,
runtime,
runtimeAccess,
- browser,
codex,
codexAuth,
environments,
@@ -708,7 +685,6 @@ export function registerApiRoutes(
store: SandpiStore;
runtime: RuntimeAdapter;
runtimeAccess: EnvironmentRuntimeAccessService;
- browser: EnvironmentBrowserService;
codex: CodexService;
codexAuth: CodexEnvironmentAuthService;
environments: EnvironmentService;
@@ -763,10 +739,7 @@ export function registerApiRoutes(
};
app.addHook("onSend", async (request, reply, payload) => {
if (
- shouldApplyApiNoStore(
- request.url,
- reply.hasHeader("Cache-Control"),
- )
+ shouldApplyApiNoStore(request.url)
) {
reply.header("Cache-Control", "no-store");
}
@@ -2321,102 +2294,6 @@ export function registerApiRoutes(
}
},
);
- app.get<{ Params: { environmentId: string } }>(
- "/api/v1/environments/:environmentId/browser/control",
- async (request) => ({
- data: await services.browser.control(
- request.principal.userId,
- request.params.environmentId,
- ),
- }),
- );
- app.put<{ Params: { environmentId: string }; Body: unknown }>(
- "/api/v1/environments/:environmentId/browser/control",
- async (request) => {
- const input = browserControlSchema.parse(request.body);
- return {
- data: await services.browser.updateControl(
- request.principal.userId,
- request.params.environmentId,
- input,
- ),
- };
- },
- );
- app.post<{ Params: { environmentId: string }; Body: unknown }>(
- "/api/v1/environments/:environmentId/browser/session",
- async (request, reply) => {
- const input = browserSessionSchema.parse(request.body ?? {});
- await services.browser.ensureSession(
- request.principal.userId,
- request.params.environmentId,
- input.force ?? false,
- );
- return reply.status(204).send();
- },
- );
- app.post<{ Params: { environmentId: string }; Body: unknown }>(
- "/api/v1/environments/:environmentId/browser/viewport",
- async (request, reply) => {
- const viewport = environmentBrowserViewportSchema.parse(request.body);
- await services.browser.resizeViewport(
- request.principal.userId,
- request.params.environmentId,
- viewport,
- );
- return reply.status(204).send();
- },
- );
- app.get<{
- Params: { environmentId: string; dashboardSocketId: string };
- }>(
- "/api/v1/environments/:environmentId/browser/ws/:dashboardSocketId",
- { websocket: true },
- async (socket, request) => {
- await proxyEnvironmentBrowserWebSocket(
- socket,
- request,
- services.browser,
- () =>
- services.runtimeAccess.touchRunningRuntimeActivity(
- request.params.environmentId,
- ),
- );
- },
- );
- // Next's development rewrite can remove the trailing slash before proxying
- // this request. Serve both root spellings directly so the iframe cannot loop
- // between the frontend proxy and a permanent slash redirect.
- app.get<{ Params: { environmentId: string } }>(
- "/api/v1/environments/:environmentId/browser",
- async (request, reply) =>
- proxyEnvironmentBrowserAsset(
- services.browser,
- request,
- reply,
- undefined,
- ),
- );
- app.get<{ Params: { environmentId: string } }>(
- "/api/v1/environments/:environmentId/browser/",
- async (request, reply) =>
- proxyEnvironmentBrowserAsset(
- services.browser,
- request,
- reply,
- undefined,
- ),
- );
- app.get<{ Params: { environmentId: string; "*": string } }>(
- "/api/v1/environments/:environmentId/browser/*",
- async (request, reply) =>
- proxyEnvironmentBrowserAsset(
- services.browser,
- request,
- reply,
- request.params["*"],
- ),
- );
app.get<{ Params: { environmentId: string } }>(
"/api/v1/environments/:environmentId/metrics/current",
async (request) => {
@@ -2892,311 +2769,6 @@ async function streamHarnessEvents(
if (!reply.raw.destroyed) reply.raw.end();
}
-const BROWSER_DASHBOARD_PROXY_TIMEOUT_MS = 130_000;
-const BROWSER_DASHBOARD_MAX_ASSET_BYTES = 8 * 1024 * 1024;
-const BROWSER_DASHBOARD_MAX_QUEUED_CLIENT_WS_BYTES = 1024 * 1024;
-const BROWSER_DASHBOARD_MAX_QUEUED_DOWNSTREAM_WS_BYTES = 8 * 1024 * 1024;
-
-async function proxyEnvironmentBrowserAsset(
- browser: EnvironmentBrowserService,
- request: FastifyRequest<{ Params: { environmentId: string } }>,
- reply: FastifyReply,
- assetPath: string | undefined,
-) {
- const environmentId = request.params.environmentId;
- let upstream = await browser.httpUpstream(
- request.principal.userId,
- environmentId,
- assetPath,
- );
- let response = await fetchBrowserDashboardAsset(upstream);
- if (response.status === 401 || response.status === 403) {
- browser.invalidate(environmentId);
- upstream = await browser.httpUpstream(
- request.principal.userId,
- environmentId,
- assetPath,
- );
- response = await fetchBrowserDashboardAsset(upstream);
- }
-
- const prefix = dashboardProxyPrefix(environmentId);
- if (response.status >= 300 && response.status < 400) {
- const location = dashboardRedirectLocation(
- response.headers.get("location"),
- prefix,
- );
- if (!location) {
- throw new HttpError(
- 502,
- "environment_browser_proxy_invalid",
- "The Playwright Dashboard returned an invalid redirect.",
- );
- }
- return reply
- .status(response.status)
- .header("Cache-Control", "private, no-store")
- .header("Location", location)
- .send();
- }
-
- const contentLength = Number(response.headers.get("content-length") ?? 0);
- if (
- Number.isFinite(contentLength) &&
- contentLength > BROWSER_DASHBOARD_MAX_ASSET_BYTES
- ) {
- throw new HttpError(
- 502,
- "environment_browser_asset_too_large",
- "The Playwright Dashboard asset is too large.",
- );
- }
- const contentType = response.headers.get("content-type");
- if (contentType) reply.header("Content-Type", contentType);
- reply.header(
- "Cache-Control",
- response.ok
- ? dashboardAssetCacheControl(
- assetPath,
- response.headers.get("cache-control"),
- )
- : "private, no-store",
- );
- const normalizedAssetPath = assetPath?.replace(/^\/+|\/+$/g, "");
- if (
- normalizedAssetPath === "index.html" ||
- normalizedAssetPath?.endsWith(".css")
- ) {
- const body = await readBrowserDashboardBody(response);
- const payload =
- normalizedAssetPath === "index.html"
- ? rewriteDashboardHtml(body.toString("utf8"), prefix)
- : rewriteDashboardCss(body.toString("utf8"), prefix);
- return reply.status(response.status).send(payload);
- }
-
- if (!response.body) {
- return reply.status(response.status).send();
- }
- let streamedBytes = 0;
- const bounded = new Transform({
- transform(chunk: Buffer, _encoding, callback) {
- streamedBytes += chunk.byteLength;
- if (streamedBytes > BROWSER_DASHBOARD_MAX_ASSET_BYTES) {
- callback(
- new HttpError(
- 502,
- "environment_browser_asset_too_large",
- "The Playwright Dashboard asset is too large.",
- ),
- );
- return;
- }
- callback(null, chunk);
- },
- });
- return reply
- .status(response.status)
- .send(
- Readable.fromWeb(
- response.body as unknown as import("node:stream/web").ReadableStream,
- ).pipe(bounded),
- );
-}
-
-async function readBrowserDashboardBody(response: Response) {
- const body = Buffer.from(await response.arrayBuffer());
- if (body.byteLength > BROWSER_DASHBOARD_MAX_ASSET_BYTES) {
- throw new HttpError(
- 502,
- "environment_browser_asset_too_large",
- "The Playwright Dashboard asset is too large.",
- );
- }
- return body;
-}
-
-async function fetchBrowserDashboardAsset(upstream: {
- url: string;
- headers: Record;
-}) {
- try {
- return await fetch(upstream.url, {
- method: "GET",
- headers: upstream.headers,
- redirect: "manual",
- signal: AbortSignal.timeout(BROWSER_DASHBOARD_PROXY_TIMEOUT_MS),
- });
- } catch {
- throw new HttpError(
- 502,
- "environment_browser_proxy_unavailable",
- "The Playwright Dashboard is temporarily unavailable.",
- );
- }
-}
-
-async function proxyEnvironmentBrowserWebSocket(
- socket: WebSocket,
- request: FastifyRequest<{
- Params: { environmentId: string; dashboardSocketId: string };
- }>,
- browser: EnvironmentBrowserService,
- touchRuntime: () => Promise,
-) {
- const clientGate = new BrowserDashboardReadOnlyGate();
- const queued: Array<{ data: RawData; isBinary: boolean }> = [];
- let queuedBytes = 0;
- let upstream: WebSocket | undefined;
- let downstreamClosed = false;
- const downstreamRelay = new BrowserWebSocketDownstreamRelay({
- maxQueuedBytes: BROWSER_DASHBOARD_MAX_QUEUED_DOWNSTREAM_WS_BYTES,
- send(data, isBinary, callback) {
- if (socket.readyState !== WebSocket.OPEN) {
- callback(new Error("Dashboard downstream is closed"));
- return;
- }
- socket.send(data, { binary: isBinary }, callback);
- },
- onOverflow() {
- if (socket.readyState === WebSocket.OPEN) {
- socket.close(1009, "Dashboard downstream queue exceeded");
- }
- },
- onSendError(error) {
- request.log.debug(
- { err: error, environmentId: request.params.environmentId },
- "Playwright Dashboard downstream send failed",
- );
- upstream?.terminate();
- if (socket.readyState === WebSocket.OPEN) {
- socket.close(1011, "Dashboard downstream unavailable");
- }
- },
- });
- const heartbeat = new RuntimeWebSocketHeartbeat(socket, touchRuntime, {
- pingIntervalMs: 30_000,
- activityTouchIntervalMs: 30_000,
- onActivityTouchError: (error) => {
- request.log.debug(
- { err: error, environmentId: request.params.environmentId },
- "Environment browser activity could not extend idle access",
- );
- },
- });
- heartbeat.start();
-
- socket.on("message", (data, isBinary) => {
- const decision = clientGate.inspect(data, isBinary);
- if (decision.action === "reject") {
- request.log.debug(
- {
- environmentId: request.params.environmentId,
- method: decision.method,
- reason: decision.reason,
- },
- "Blocked interactive Playwright Dashboard client message",
- );
- const response = browserDashboardRejectedResponse(decision);
- if (response && socket.readyState === WebSocket.OPEN) {
- socket.send(response);
- }
- return;
- }
- if (upstream?.readyState === WebSocket.OPEN) {
- heartbeat.markActivity();
- upstream.send(data, { binary: isBinary });
- return;
- }
- queuedBytes += websocketRawDataSize(data);
- if (queuedBytes > BROWSER_DASHBOARD_MAX_QUEUED_CLIENT_WS_BYTES) {
- socket.close(1009, "Dashboard connection queue exceeded");
- return;
- }
- queued.push({ data, isBinary });
- heartbeat.markActivity();
- });
- socket.once("close", () => {
- downstreamClosed = true;
- heartbeat.stop();
- downstreamRelay.close();
- request.log.debug(
- {
- environmentId: request.params.environmentId,
- ...downstreamRelay.stats(),
- },
- "Playwright Dashboard downstream relay closed",
- );
- upstream?.close();
- });
- socket.once("error", () => {
- heartbeat.stop();
- downstreamRelay.close();
- upstream?.terminate();
- });
-
- try {
- const target = await browser.websocketUpstream(
- request.principal.userId,
- request.params.environmentId,
- request.params.dashboardSocketId,
- );
- if (downstreamClosed) return;
- upstream = new WebSocket(target.url, {
- headers: target.headers,
- handshakeTimeout: BROWSER_DASHBOARD_PROXY_TIMEOUT_MS,
- });
- upstream.once("open", () => {
- for (const message of queued) {
- if (upstream?.readyState !== WebSocket.OPEN) break;
- upstream.send(message.data, { binary: message.isBinary });
- }
- queued.length = 0;
- queuedBytes = 0;
- });
- upstream.on("message", (data, isBinary) => {
- downstreamRelay.enqueue(data, isBinary);
- });
- upstream.once("close", (code, reason) => {
- heartbeat.stop();
- downstreamRelay.close();
- if (socket.readyState === WebSocket.OPEN) {
- socket.close(websocketCloseCode(code), reason.toString().slice(0, 123));
- }
- });
- upstream.once("error", (error) => {
- heartbeat.stop();
- // HTTP auth failures and runtime-generation fencing refresh stale
- // coordinates. A transient socket failure must not force the next
- // Browser mount through another regional control API lookup.
- request.log.warn(
- { err: error, environmentId: request.params.environmentId },
- "Playwright Dashboard WebSocket upstream failed",
- );
- if (socket.readyState === WebSocket.OPEN) {
- socket.close(1011, "Dashboard upstream unavailable");
- }
- });
- } catch (error) {
- heartbeat.stop();
- request.log.warn(
- { err: error, environmentId: request.params.environmentId },
- "Playwright Dashboard WebSocket setup failed",
- );
- if (socket.readyState === WebSocket.OPEN) {
- socket.close(1011, "Dashboard unavailable");
- }
- }
-}
-
-function websocketCloseCode(code: number) {
- return code >= 1_000 &&
- code < 5_000 &&
- ![1_004, 1_005, 1_006, 1_015].includes(code)
- ? code
- : 1_011;
-}
-
async function authenticateRequest(
request: FastifyRequest,
oidcIdentity?: OidcIdentityService,
diff --git a/src/types/novnc.d.ts b/src/types/novnc.d.ts
deleted file mode 100644
index 4984b02..0000000
--- a/src/types/novnc.d.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-declare module "@novnc/novnc/lib/rfb" {
- interface RfbOptions {
- shared?: boolean;
- }
-
- interface RfbEvents {
- connect: CustomEvent>;
- disconnect: CustomEvent<{ clean: boolean }>;
- securityfailure: CustomEvent<{ status: number; reason?: string }>;
- }
-
- export default class Rfb extends EventTarget {
- constructor(target: Element, url: string, options?: RfbOptions);
-
- viewOnly: boolean;
- focusOnClick: boolean;
- scaleViewport: boolean;
- resizeSession: boolean;
- showDotCursor: boolean;
- qualityLevel: number;
- compressionLevel: number;
-
- addEventListener(
- type: T,
- listener: (event: RfbEvents[T]) => void,
- ): void;
- removeEventListener(
- type: T,
- listener: (event: RfbEvents[T]) => void,
- ): void;
- disconnect(): void;
- }
-}