From 7a048ecc5c39ec911ec62925cd337013cbc2171f Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Fri, 2 Oct 2026 18:31:22 +1000 Subject: [PATCH] feat: add a sandbox image with desktops agents can start ghcr.io/nitrictech/sugabots-sandbox and docker.io/nitrictech/sugabots-sandbox, built with `bun run build:sandbox` and published with each release: Debian with the tools agents reach for when they work on code, and sugabots-desktop, which starts desktops on their own displays, served over VNC, plain or as a WebSocket. Each desktop has a Plank dock with Browser, Terminal and Files, marking the apps with windows open. Its browser is Chromium, started on the desktop and driven by Playwright MCP over CDP, so it lives across the MCP sessions that drive it; `desktop` starts a desktop alone, `session` with its browser. Nix is installed for the agents' user, without root, with nixpkgs following a NixOS release's branch and nixpkgs-unstable the unstable one, so agents can add software the image lacks: `nix profile install nixpkgs#ffmpeg`, or `nix shell` for one command. A pull request that changes the image gets a preview of it, tagged pr- and deleted when the pull request closes. --- .github/workflows/publish.yml | 55 +++++ .github/workflows/sandbox-preview.yml | 115 ++++++++++ CONTRIBUTING.md | 1 + docker/sandbox/Dockerfile | 90 ++++++++ docker/sandbox/dock/chromium.desktop | 7 + docker/sandbox/dock/lxterminal.desktop | 7 + docker/sandbox/dock/pcmanfm.desktop | 7 + docker/sandbox/dock/plank-theme/dock.theme | 35 +++ docker/sandbox/dock/plank.dconf | 13 ++ docker/sandbox/dock/plank/chromium.dockitem | 2 + docker/sandbox/dock/plank/lxterminal.dockitem | 2 + docker/sandbox/dock/plank/pcmanfm.dockitem | 2 + docker/sandbox/nix/nix.conf | 12 + docker/sandbox/nix/registry.json | 13 ++ docker/sandbox/sugabots-desktop | 207 ++++++++++++++++++ docker/sandbox/wallpaper.svg | 26 +++ package.json | 3 +- 17 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sandbox-preview.yml create mode 100644 docker/sandbox/Dockerfile create mode 100644 docker/sandbox/dock/chromium.desktop create mode 100644 docker/sandbox/dock/lxterminal.desktop create mode 100644 docker/sandbox/dock/pcmanfm.desktop create mode 100644 docker/sandbox/dock/plank-theme/dock.theme create mode 100644 docker/sandbox/dock/plank.dconf create mode 100644 docker/sandbox/dock/plank/chromium.dockitem create mode 100644 docker/sandbox/dock/plank/lxterminal.dockitem create mode 100644 docker/sandbox/dock/plank/pcmanfm.dockitem create mode 100644 docker/sandbox/nix/nix.conf create mode 100644 docker/sandbox/nix/registry.json create mode 100755 docker/sandbox/sugabots-desktop create mode 100644 docker/sandbox/wallpaper.svg diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d793a81e..23d98cb7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -68,3 +68,58 @@ jobs: annotations: ${{ steps.meta.outputs.annotations }} cache-from: type=gha cache-to: type=gha,mode=max + + sandbox-image: + name: Publish the sandbox image to GHCR and Docker Hub + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-qemu-action@v4 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Tagged as the app is: latest is the newest release's, and a push to + # main is edge and sha-. Sandbox providers pull it: OpenSandbox + # for each sandbox, E2B to build a workspace's template. The package must + # be public for them to. Cloudflare can't pull from GHCR, so it pulls the + # same image from Docker Hub. + - id: meta + uses: docker/metadata-action@v6 + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + with: + images: | + ghcr.io/${{ github.repository_owner }}/sugabots-sandbox + docker.io/nitrictech/sugabots-sandbox + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=edge,branch=main + type=sha,enable=${{ github.ref == 'refs/heads/main' }} + + - uses: docker/build-push-action@v7 + with: + context: docker/sandbox + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} diff --git a/.github/workflows/sandbox-preview.yml b/.github/workflows/sandbox-preview.yml new file mode 100644 index 00000000..02153d22 --- /dev/null +++ b/.github/workflows/sandbox-preview.yml @@ -0,0 +1,115 @@ +name: Sandbox image preview + +# A pull request that changes the sandbox image gets it built and published as +# ghcr.io//sugabots-sandbox:pr-, to try before it's merged, and +# says how on the pull request. Closing the pull request deletes it. Pull +# requests from forks get none: their workflows can't publish packages. + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + paths: + - "docker/sandbox/**" + - ".github/workflows/sandbox-preview.yml" + +concurrency: + group: sandbox-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/sugabots-sandbox + TAG: pr-${{ github.event.pull_request.number }} + +jobs: + publish: + name: Publish the pull request's sandbox image + if: | + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + pull-requests: write + + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-qemu-action@v4 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Both platforms, as a release has, so it runs on Apple silicon too. + - id: build + uses: docker/build-push-action@v7 + with: + context: docker/sandbox + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.IMAGE }}:${{ env.TAG }} + cache-from: type=gha,scope=sandbox-image + cache-to: type=gha,scope=sandbox-image,mode=max + + # One comment per pull request, updated on each push, found by its marker. + - name: Say how to try it + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + DIGEST: ${{ steps.build.outputs.digest }} + SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + marker="" + body="$(cat </dev/null + else + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -f body="$body" >/dev/null + fi + + cleanup: + name: Delete the pull request's sandbox image + if: | + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + + permissions: + packages: write + + steps: + - name: Delete the image version tagged for the pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PACKAGE: orgs/${{ github.repository_owner }}/packages/container/sugabots-sandbox + run: | + set -euo pipefail + version_id=$(gh api "/$PACKAGE/versions?per_page=100" --paginate \ + --jq ".[] | select(.metadata.container.tags[]? == \"$TAG\") | .id" | head -n1) + if [ -z "$version_id" ]; then + echo "No image version is tagged $TAG; nothing to delete." + exit 0 + fi + echo "Deleting package version $version_id ($TAG)" + gh api --method DELETE "/$PACKAGE/versions/$version_id" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f4e1368..64ca1b27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ bun run check # lint, typecheck, and test bun run build # build all packages bun run format # apply Biome formatting fixes bun run db:studio # inspect the database with Drizzle Studio +bun run build:sandbox # build the sandbox image, ghcr.io/nitrictech/sugabots-sandbox:latest ``` The API exports traces and logs to any OTLP/HTTP collector set by the tracing diff --git a/docker/sandbox/Dockerfile b/docker/sandbox/Dockerfile new file mode 100644 index 00000000..fb60bd5b --- /dev/null +++ b/docker/sandbox/Dockerfile @@ -0,0 +1,90 @@ +# The default sandbox image: Debian with what agents reach for when they work +# on code, and desktops with a browser they drive through Playwright MCP. +# Commands run without a screen; `sugabots-desktop` starts a desktop, each on +# its own display. +# +# bun run build:sandbox +# +# Builds ghcr.io/nitrictech/sugabots-sandbox:latest, where releases publish it +# too, and to docker.io/nitrictech/sugabots-sandbox: the image OpenSandbox +# providers use unless a workspace sets another, and the one E2B templates are +# built from. The agents' user is the provider's to +# make: OpenSandbox's when it makes a sandbox, and E2B's own `user`, uid 1000, +# so this image has none of its own to clash with them. + +ARG PLAYWRIGHT_MCP_VERSION=0.0.83 + +# The desktop's wallpaper, rendered at the display's size, so the renderer +# doesn't ship in the image. +FROM debian:trixie-slim AS wallpaper +RUN apt-get update \ + && apt-get install -y --no-install-recommends librsvg2-bin \ + && rm -rf /var/lib/apt/lists/* +COPY wallpaper.svg /wallpaper.svg +RUN rsvg-convert --width 1280 --height 800 /wallpaper.svg --output /wallpaper.png + +# The dock's icons, from Papirus, without the rest of its 380 MB. +FROM debian:trixie-slim AS dock-icons +RUN apt-get update \ + && apt-get install -y --no-install-recommends papirus-icon-theme \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir /icons \ + && cd /usr/share/icons/Papirus/64x64/apps \ + && cp -L chromium.svg /icons/browser.svg \ + && cp -L utilities-terminal.svg /icons/terminal.svg \ + && cp -L system-file-manager.svg /icons/files.svg + +FROM debian:trixie-slim + +ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 + +# The tools agents use on a repository, then the desktop: a virtual display, +# a window manager and dock, a browser, a file manager, and what computer-use +# tools drive them with. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash ca-certificates curl wget git openssh-client less file procps \ + unzip zip xz-utils jq ripgrep fd-find tree \ + build-essential pkg-config \ + python3 python3-pip python3-venv \ + nodejs npm \ + xvfb x11vnc x11-utils openbox plank dconf-gsettings-backend dconf-cli xcompmgr xterm \ + xfonts-base dbus-x11 hsetroot \ + lxterminal pcmanfm adwaita-icon-theme librsvg2-common \ + nix-bin \ + chromium xdotool scrot xclip fonts-dejavu fonts-noto-color-emoji \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && rm -rf /var/lib/apt/lists/* + +# The browser agents drive: Playwright MCP, on Debian's Chromium rather than +# a browser Playwright downloads. +ARG PLAYWRIGHT_MCP_VERSION +RUN npm install --global --omit=dev "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}" \ + && npm cache clean --force + +COPY --from=wallpaper /wallpaper.png /usr/share/sugabots-desktop/wallpaper.png +COPY --from=dock-icons /icons /usr/share/sugabots-desktop/icons +# The dock's launchers stand in for the apps' own, so the dock matches their +# windows to them, and its settings are the system's dconf defaults. +COPY dock/*.desktop /usr/share/applications/ +COPY dock/plank/ /usr/share/sugabots-desktop/plank/ +COPY dock/plank-theme/dock.theme /usr/share/plank/themes/Sugabots/dock.theme +COPY dock/plank.dconf /etc/dconf/db/local.d/00-plank +RUN mkdir -p /etc/dconf/profile \ + && printf 'user-db:user\nsystem-db:local\n' >/etc/dconf/profile/user \ + && dconf update +# Nix, for software beyond this image: `nix profile install nixpkgs#ffmpeg`, +# or `nix shell nixpkgs#ffmpeg` for one command. The agents' user owns the +# store, so it installs without root. `nixpkgs` follows a NixOS release and +# `nixpkgs-unstable` its newer versions, by branch, so the image never needs +# moving forward; whatever pins exact versions records the commit it used. +COPY nix/nix.conf nix/registry.json /etc/nix/ +# Nix looks for its state here, and without it falls back to a store in the +# user's home that needs namespaces the agents' user can't make. +RUN mkdir -p /nix/store /nix/var/nix && chown -R 1000:1000 /nix + +COPY sugabots-desktop /usr/local/bin/sugabots-desktop + +RUN mkdir -p /workspace && chmod 755 /usr/local/bin/sugabots-desktop + +WORKDIR /workspace diff --git a/docker/sandbox/dock/chromium.desktop b/docker/sandbox/dock/chromium.desktop new file mode 100644 index 00000000..39d2db6a --- /dev/null +++ b/docker/sandbox/dock/chromium.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=Browser +Comment=The agent's browser +Exec=sugabots-desktop open browser +Icon=/usr/share/sugabots-desktop/icons/browser.svg +StartupWMClass=sugabots-browser diff --git a/docker/sandbox/dock/lxterminal.desktop b/docker/sandbox/dock/lxterminal.desktop new file mode 100644 index 00000000..e346fb94 --- /dev/null +++ b/docker/sandbox/dock/lxterminal.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=Terminal +Comment=A shell in the workspace +Exec=sugabots-desktop open terminal +Icon=/usr/share/sugabots-desktop/icons/terminal.svg +StartupWMClass=lxterminal diff --git a/docker/sandbox/dock/pcmanfm.desktop b/docker/sandbox/dock/pcmanfm.desktop new file mode 100644 index 00000000..d182cb7f --- /dev/null +++ b/docker/sandbox/dock/pcmanfm.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=Files +Comment=The workspace's files +Exec=sugabots-desktop open files +Icon=/usr/share/sugabots-desktop/icons/files.svg +StartupWMClass=pcmanfm diff --git a/docker/sandbox/dock/plank-theme/dock.theme b/docker/sandbox/dock/plank-theme/dock.theme new file mode 100644 index 00000000..6957e323 --- /dev/null +++ b/docker/sandbox/dock/plank-theme/dock.theme @@ -0,0 +1,35 @@ +# The dock's look: a rounded, light, see-through bar the icons sit inside, +# in the style of macOS's. Sizes are tenths of the icon size. +[PlankTheme] +TopRoundness=4 +BottomRoundness=4 +LineWidth=1 +OuterStrokeColor=255;;255;;255;;110 +FillStartColor=245;;245;;247;;150 +FillEndColor=235;;235;;240;;150 +InnerStrokeColor=255;;255;;255;;0 + +[PlankDockTheme] +HorizPadding=1.5 +TopPadding=1.2 +BottomPadding=1.2 +ItemPadding=2.5 +IndicatorSize=4 +IconShadowSize=1 +UrgentBounceHeight=1.6666666666666667 +LaunchBounceHeight=0.625 +FadeOpacity=1 +ClickTime=300 +UrgentBounceTime=600 +LaunchBounceTime=600 +ActiveTime=300 +SlideTime=300 +FadeTime=250 +HideTime=250 +GlowSize=30 +GlowTime=10000 +GlowPulseTime=2000 +UrgentHueShift=150 +ItemMoveTime=450 +CascadeHide=true +BadgeColor=0;;0;;0;;0 diff --git a/docker/sandbox/dock/plank.dconf b/docker/sandbox/dock/plank.dconf new file mode 100644 index 00000000..1d3ed833 --- /dev/null +++ b/docker/sandbox/dock/plank.dconf @@ -0,0 +1,13 @@ +# The dock's settings, as the system's defaults: Plank reads them from dconf, +# by path, so they need no write at runtime. +[net/launchpad/plank/docks/dock1] +dock-items=['chromium.dockitem', 'lxterminal.dockitem', 'pcmanfm.dockitem'] +position='bottom' +alignment='center' +hide-mode='none' +icon-size=48 +zoom-enabled=true +zoom-percent=140 +lock-items=true +show-dock-item=false +theme='Sugabots' diff --git a/docker/sandbox/dock/plank/chromium.dockitem b/docker/sandbox/dock/plank/chromium.dockitem new file mode 100644 index 00000000..2884b731 --- /dev/null +++ b/docker/sandbox/dock/plank/chromium.dockitem @@ -0,0 +1,2 @@ +[PlankDockItemPreferences] +Launcher=file:///usr/share/applications/chromium.desktop diff --git a/docker/sandbox/dock/plank/lxterminal.dockitem b/docker/sandbox/dock/plank/lxterminal.dockitem new file mode 100644 index 00000000..e9644667 --- /dev/null +++ b/docker/sandbox/dock/plank/lxterminal.dockitem @@ -0,0 +1,2 @@ +[PlankDockItemPreferences] +Launcher=file:///usr/share/applications/lxterminal.desktop diff --git a/docker/sandbox/dock/plank/pcmanfm.dockitem b/docker/sandbox/dock/plank/pcmanfm.dockitem new file mode 100644 index 00000000..bc3af9d3 --- /dev/null +++ b/docker/sandbox/dock/plank/pcmanfm.dockitem @@ -0,0 +1,2 @@ +[PlankDockItemPreferences] +Launcher=file:///usr/share/applications/pcmanfm.desktop diff --git a/docker/sandbox/nix/nix.conf b/docker/sandbox/nix/nix.conf new file mode 100644 index 00000000..027ffb30 --- /dev/null +++ b/docker/sandbox/nix/nix.conf @@ -0,0 +1,12 @@ +# Nix for the agents' user alone: no daemon and no build users, since that +# user (uid 1000 on every provider) owns /nix and has no root. +build-users-group = +experimental-features = nix-command flakes +# Nix's build sandbox needs namespaces the agents' user can't make. Packages +# come built from the binary cache, so it only matters for one built here. +sandbox = false +substituters = https://cache.nixos.org +trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= +# Only the nixpkgs entries in /etc/nix/registry.json, not the global +# registry, which Nix would fetch from a host sandboxes don't reach. +flake-registry = diff --git a/docker/sandbox/nix/registry.json b/docker/sandbox/nix/registry.json new file mode 100644 index 00000000..8e73871a --- /dev/null +++ b/docker/sandbox/nix/registry.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "flakes": [ + { + "from": { "type": "indirect", "id": "nixpkgs" }, + "to": { "type": "github", "owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05" } + }, + { + "from": { "type": "indirect", "id": "nixpkgs-unstable" }, + "to": { "type": "github", "owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-unstable" } + } + ] +} diff --git a/docker/sandbox/sugabots-desktop b/docker/sandbox/sugabots-desktop new file mode 100755 index 00000000..3f096825 --- /dev/null +++ b/docker/sandbox/sugabots-desktop @@ -0,0 +1,207 @@ +#!/bin/bash +# Desktops in this sandbox, each on its own display, so agents working at the +# same time never click on each other's windows. +# +# sugabots-desktop start [display] start a bare desktop, print its DISPLAY +# sugabots-desktop stop display stop it, and its browser +# sugabots-desktop desktop name profile output the desktop for `name`, without a browser +# sugabots-desktop session name profile output the desktop for `name`, with its browser +# sugabots-desktop viewer name the viewer port of `name`'s desktop, if running +# sugabots-desktop open browser|terminal|files what the dock opens, on $DISPLAY +# +# `desktop` is what Sugabots runs when a person opens an agent's desktop, and +# `session` when the agent uses its browser. Both start the named desktop, or +# find it running: a display with a window manager and a dock, served to VNC +# viewers. `session` also starts the browser on it, or finds it running, and +# prints `display=N viewer=PORT browser=PORT started=yes|no`, where `started` +# says whether the browser had to start; `desktop` prints +# `display=N viewer=PORT started=yes|no` for the desktop. +# +# The browser is Chromium driven by Playwright MCP, shown on the desktop so +# people watching see what the agent does, with its profile under `profile` +# and its downloads and screenshots in `output`. It lives until the desktop +# stops, across the MCP sessions that drive it, and the dock's Browser starts +# it if the agent hasn't. Programs that stopped, as they do when a paused +# sandbox comes back, start again on the same display. +# +# Display N is served on port 5900+N to a VNC viewer, over plain VNC or a +# WebSocket, which x11vnc accepts on the same port. Its browser's MCP server +# is on port 8930+N, and the browser's DevTools on port 9230+N. +set -euo pipefail + +# Set here rather than relied on from the image: E2B runs commands without +# the image's ENV. +export LANG="${LANG:-C.UTF-8}" + +state="${TMPDIR:-/tmp}/sugabots-desktop" +mkdir -p "$state/sessions" + +free_display() { + local n=1 + while [ -e "/tmp/.X11-unix/X$n" ] || [ -e "/tmp/.X$n-lock" ] || [ -n "$(grep -lx "$n" "$state"/sessions/* 2>/dev/null)" ]; do + n=$((n + 1)) + done + echo "$n" +} + +# Whether every program of display N's `part`, desktop or browser, is running. +running() { + local pids="$state/$1.$2.pids" + [ -f "$pids" ] || return 1 + while read -r pid; do kill -0 "$pid" 2>/dev/null || return 1; done <"$pids" +} + +desktop() { + local n="$1" log="$state/$1.log" pids="$state/$1.desktop.pids" + export DISPLAY=":$n" + rm -f "/tmp/.X$n-lock" "/tmp/.X11-unix/X$n" + # -ac: any program in this sandbox may draw on it. -s 0: no screensaver, + # which would blank the screen after ten minutes without keyboard or mouse + # input, and agents drive the browser through Playwright, not the X server. + Xvfb "$DISPLAY" -screen 0 1280x800x24 -ac -s 0 -nolisten tcp >>"$log" 2>&1 & + echo $! >"$pids" + for _ in $(seq 50); do [ -e "/tmp/.X11-unix/X$n" ] && break; sleep 0.1; done + # The dock matches windows to its apps through a service on the session bus. + eval "$(dbus-launch --sh-syntax)" + echo "$DBUS_SESSION_BUS_PID" >>"$pids" + hsetroot -fill /usr/share/sugabots-desktop/wallpaper.png >>"$log" 2>&1 + openbox >>"$log" 2>&1 & + echo $! >>"$pids" + # For the dock's shadows and see-through background. + xcompmgr -n >>"$log" 2>&1 & + echo $! >>"$pids" + # The dock's items are read from the home it runs with. + mkdir -p "$HOME/.config/plank/dock1/launchers" + cp -n /usr/share/sugabots-desktop/plank/*.dockitem "$HOME/.config/plank/dock1/launchers/" + plank >>"$log" 2>&1 & + echo $! >>"$pids" + # No password: the ports are reached only through Sugabots. + x11vnc -display "$DISPLAY" -nopw -forever -shared -quiet -rfbport $((5900 + n)) >>"$log" 2>&1 & + echo $! >>"$pids" +} + +browser() { + local n="$1" profile="$2" output="$3" log="$state/$1.log" pids="$state/$1.browser.pids" + local devtools=$((9230 + n)) + mkdir -p "$profile" "$output" + # Started here rather than by Playwright MCP, which closes a browser it + # launched when its last client disconnects: at the end of every turn. + # Maximized, so it stops at the dock. --test-type leaves out the bar + # warning that --no-sandbox is on, which a container needs. The profile is + # found through XDG_CONFIG_HOME rather than --user-data-dir, which would + # put its path in the windows' names, and the dock would no longer know + # them as the Browser's. + DISPLAY=":$n" XDG_CONFIG_HOME="$profile" chromium --no-sandbox --test-type --start-maximized \ + --class=sugabots-browser \ + --remote-debugging-address=127.0.0.1 --remote-debugging-port="$devtools" \ + --no-first-run --no-default-browser-check about:blank >>"$log" 2>&1 & + echo $! >"$pids" + for _ in $(seq 100); do + curl -sf -o /dev/null "http://127.0.0.1:$devtools/json/version" && break + sleep 0.1 + done + playwright-mcp --host 0.0.0.0 --port $((8930 + n)) --allowed-hosts '*' \ + --cdp-endpoint "http://127.0.0.1:$devtools" \ + --output-dir "$output" --file-paths absolute >>"$log" 2>&1 & + echo $! >>"$pids" + for _ in $(seq 100); do + curl -s -o /dev/null "http://localhost:$((8930 + n))/mcp" && break + sleep 0.1 + done +} + +stop_part() { + local pids="$state/$1.$2.pids" + [ -f "$pids" ] || return 0 + xargs -r kill <"$pids" 2>/dev/null || true + rm -f "$pids" +} + +stop() { + local n="${1:?which display}" + stop_part "$n" browser + stop_part "$n" desktop +} + +# The display of the desktop named `name`, running, with where its browser +# keeps its profile and output recorded for the dock. Sets `desktop_started`. +named_desktop() { + local name="$1" profile="$2" output="$3" + local file="$state/sessions/$name" + if [ -f "$file" ]; then n="$(cat "$file")"; else n="$(free_display)"; echo "$n" >"$file"; fi + printf '%s\n%s\n' "$profile" "$output" >"$state/$n.browser-dirs" + desktop_started=no + if ! running "$n" desktop; then + # The browser can't outlive the display it was on. + stop "$n" + desktop "$n" + desktop_started=yes + fi +} + +desktop_only() { + named_desktop "${1:?name}" "${2:?profile directory}" "${3:?output directory}" + echo "display=$n viewer=$((5900 + n)) started=$desktop_started" +} + +session() { + named_desktop "${1:?name}" "${2:?profile directory}" "${3:?output directory}" + local started=no + if ! running "$n" browser; then + stop_part "$n" browser + browser "$n" "$2" "$3" + started=yes + fi + echo "display=$n viewer=$((5900 + n)) browser=$((8930 + n)) started=$started" +} + +viewer() { + local file="$state/sessions/${1:?name}" + [ -f "$file" ] || exit 3 + local n + n="$(cat "$file")" + running "$n" desktop || exit 3 + echo $((5900 + n)) +} + +# What the dock opens on the desktop it runs on. +open() { + local n="${DISPLAY#:}" + case "${1:-}" in + browser) + local profile output + { read -r profile; read -r output; } <"$state/$n.browser-dirs" + if running "$n" browser; then + # The same profile reaches the running browser, which opens the window. + XDG_CONFIG_HOME="$profile" exec chromium --no-sandbox --test-type --new-window + fi + stop_part "$n" browser + browser "$n" "$profile" "$output" + ;; + terminal) + exec lxterminal --working-directory=/workspace + ;; + files) exec pcmanfm /workspace ;; + *) + echo "usage: sugabots-desktop open browser|terminal|files" >&2 + exit 2 + ;; + esac +} + +case "${1:-}" in +start) + n="${2:-$(free_display)}" + desktop "$n" + echo "DISPLAY=:$n" + ;; +stop) stop "${2:-}" ;; +desktop) desktop_only "${2:-}" "${3:-}" "${4:-}" ;; +session) session "${2:-}" "${3:-}" "${4:-}" ;; +viewer) viewer "${2:-}" ;; +open) open "${2:-}" ;; +*) + echo "usage: sugabots-desktop start [display] | stop display | desktop name profile output | session name profile output | viewer name | open browser|terminal|files" >&2 + exit 2 + ;; +esac diff --git a/docker/sandbox/wallpaper.svg b/docker/sandbox/wallpaper.svg new file mode 100644 index 00000000..790e031f --- /dev/null +++ b/docker/sandbox/wallpaper.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json index 270f62f8..37bc4c20 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "db:push": "bun run --cwd packages/core db:push", "db:migrate": "bun run --cwd packages/core db:migrate", "db:studio": "bun run --cwd packages/core db:studio", - "db:seed": "node --env-file-if-exists=.env packages/server/src/seed.ts" + "db:seed": "node --env-file-if-exists=.env packages/server/src/seed.ts", + "build:sandbox": "docker build -t ghcr.io/nitrictech/sugabots-sandbox:latest docker/sandbox" }, "devDependencies": { "@biomejs/biome": "2.5.12",