diff --git a/README.md b/README.md index 34f7644..41af274 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ harnesses and clients can be added as independent integrations. `credentialsource:read`, `credentialsource:write` and `credentialsource:delete` - A current Sandbox0 `coding-agent` template with the official Playwright CLI, - Chromium, Xvfb, Openbox and x11vnc. At least 2 GiB of Sandbox memory is + 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. - Docker Engine with Compose v2 for the container workflow @@ -317,8 +318,10 @@ Sandbox0 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. Recreate an older Environment to pick up its headed-browser runtime; - agent-only Playwright remains available on older compatible images. + 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. - 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/docs/architecture/native-session-authority.md b/docs/architecture/native-session-authority.md index 17d10e7..2f78fea 100644 --- a/docs/architecture/native-session-authority.md +++ b/docs/architecture/native-session-authority.md @@ -295,15 +295,21 @@ 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 Xvfb and Openbox. It prefers an operator-supplied -`google-chrome-stable` binary when one exists and otherwise uses the bundled -Chrome for Testing executable. The launch has no headless, automation or -remote-debugging flag and exposes no CDP port. x11vnc binds only to loopback; a -small WebSocket-to-TCP bridge publishes `/vnc` through the existing protected -AppService. 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. +`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 @@ -319,10 +325,12 @@ 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, adds only Xvfb, -Openbox, x11vnc and the already-used `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. +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 adapts only the embedded Dashboard shell: it binds the Dashboard to the shared `default` session explicitly, projects the current Sandpi theme tokens diff --git a/src/components/environment-browser-vnc.tsx b/src/components/environment-browser-vnc.tsx index 017d942..c431cc3 100644 --- a/src/components/environment-browser-vnc.tsx +++ b/src/components/environment-browser-vnc.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import { apiUrl } from "@/lib/api-client"; +import { configureEnvironmentBrowserRfb } from "@/lib/environment-browser-vnc"; interface EnvironmentBrowserVncProps { environmentId: string; @@ -37,12 +38,7 @@ export function EnvironmentBrowserVnc({ ); endpoint.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:"; rfb = new Rfb(element, endpoint.toString(), { shared: true }); - rfb.scaleViewport = true; - rfb.resizeSession = false; - rfb.focusOnClick = true; - rfb.showDotCursor = true; - rfb.qualityLevel = 6; - rfb.compressionLevel = 2; + configureEnvironmentBrowserRfb(rfb); rfb.addEventListener("connect", () => { connected = true; onReady(); diff --git a/src/lib/environment-browser-vnc.test.ts b/src/lib/environment-browser-vnc.test.ts new file mode 100644 index 0000000..1813d20 --- /dev/null +++ b/src/lib/environment-browser-vnc.test.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..a9ab205 --- /dev/null +++ b/src/lib/environment-browser-vnc.ts @@ -0,0 +1,21 @@ +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/server/runtime/environment-browser-runtime.ts b/src/server/runtime/environment-browser-runtime.ts index f67c7a2..acd8104 100644 --- a/src/server/runtime/environment-browser-runtime.ts +++ b/src/server/runtime/environment-browser-runtime.ts @@ -208,20 +208,38 @@ fi test -n "$browser" mkdir -p /tmp/sandpi-browser-openbox -HOME=/tmp/sandpi-browser-openbox Xvfb "$display" -screen 0 1440x900x24 -nolisten tcp -ac +extension RANDR >/tmp/sandpi-browser-xvfb.log 2>&1 & -xvfb_pid="$!" -pids="$xvfb_pid $pids" +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 "$xvfb_pid" + 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" -DISPLAY="$display" x11vnc -display "$display" -rfbport "$vnc_port" -localhost -forever -shared -nopw -noxdamage -repeat -quiet >/tmp/sandpi-browser-x11vnc.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" \ @@ -229,7 +247,7 @@ setpriv --reuid="$browser_user" --regid="$browser_user" --init-groups env DISPLA --no-default-browser-check \ --password-store=basic \ --disable-dev-shm-usage \ - --window-size=1440,900 \ + --start-maximized \ --restore-last-session >/tmp/sandpi-browser-chrome.log 2>&1 & pids="$! $pids" @@ -241,9 +259,11 @@ wait "$bridge_pid"`; } export const HUMAN_BROWSER_PREFLIGHT_SCRIPT = String.raw`set -eu -command -v Xvfb >/dev/null command -v openbox >/dev/null -command -v x11vnc >/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}"}" diff --git a/src/server/runtime/sandbox0.test.ts b/src/server/runtime/sandbox0.test.ts index eacf57f..7a039ad 100644 --- a/src/server/runtime/sandbox0.test.ts +++ b/src/server/runtime/sandbox0.test.ts @@ -1395,7 +1395,7 @@ test("uses the AppService spec as the Browser owner handoff fence", async () => assert.equal(capabilityChecks, 1); assert.match( String(capabilityCommand?.command?.at(-1)), - /command -v Xvfb[\s\S]+command -v x11vnc/, + /command -v Xtigervnc[\s\S]+command -v Xvfb[\s\S]+command -v x11vnc/, ); assert.doesNotMatch( String(capabilityCommand?.command?.at(-1)), @@ -1429,7 +1429,7 @@ test("uses the AppService spec as the Browser owner handoff fence", async () => const takeoverPreparation = String(preflightCommand?.command?.at(-1)); assert.match( takeoverPreparation, - /playwright_guard=\/workspace\/\.sandpi\/bin\/playwright-cli[\s\S]+command -v Xvfb/, + /playwright_guard=\/workspace\/\.sandpi\/bin\/playwright-cli[\s\S]+command -v Xtigervnc/, ); assert.match( Buffer.from( @@ -1448,11 +1448,16 @@ test("uses the AppService spec as the Browser owner handoff fence", async () => humanService.runtime.envVars.SANDPI_BROWSER_SESSION_REVISION, "1", ); - assert.match(humanCommand, /Xvfb.*openbox.*x11vnc.*setpriv/s); + 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/, + /--headless|--remote-debugging|--enable-automation|--no-sandbox/, ); assert.equal(spawnSync("sh", ["-n", "-c", humanCommand]).status, 0); assert.equal(