feat: add screenshot capture and R2 upload support for sandbox agents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-22 00:36:17 +00:00
parent 93f927c1c1
commit c93b2e287c
6 changed files with 125 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
iptables \
gosu \
sudo \
unzip \
# Rust native build deps (for cargo check)
pkg-config \
cmake \
@@ -147,6 +148,32 @@ RUN curl -fsSL https://claude.ai/install.sh | bash && \
# --- codex agent ---
RUN npm i -g @openai/codex
# ── Install bun (globally accessible) ─────────────────────────────────────────
ENV BUN_INSTALL=/opt/bun
RUN curl -fsSL https://bun.sh/install | bash \
&& ln -s /opt/bun/bin/bun /usr/local/bin/bun \
&& ln -s /opt/bun/bin/bunx /usr/local/bin/bunx \
&& chmod -R a+rX /opt/bun
# ── Playwright + Chromium (for screenshots) ──────────────────────────────────
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
RUN bun add -g @playwright/test \
&& bunx playwright install chromium --with-deps \
&& chmod -R a+rwX /opt/bun/install/ \
&& rm -rf /tmp/bunx-* \
&& chmod -R a+rX /opt/playwright-browsers \
&& rm -rf /var/lib/apt/lists/*
# ── AWS CLI (for S3-compatible uploads to R2) ─────────────────────────────────
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
# Pre-configure AWS CLI for R2: credentials come from env vars automatically
# (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), just set the defaults.
ENV AWS_DEFAULT_REGION=auto
# Entrypoint that registers the dynamic UID in /etc/passwd so sudo works.
# workmux passes --user UID:GID directly, bypassing network-init.sh.
RUN cat <<'ENTRY' > /usr/local/bin/entrypoint.sh

View File

@@ -7,3 +7,4 @@ frontend/bun.lock
frontend/dist/
frontend/.vite/
public/
.env

View File

@@ -17,10 +17,26 @@ nerdfont: false
sandbox:
image: windmill-sandbox
# Forward R2/AWS credentials into sandbox containers (for screenshot uploads).
# The actual values come from dev-dashboard/.env, sourced by dev.sh/run.sh.
env_passthrough:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- R2_ENDPOINT
- R2_BUCKET
- R2_PUBLIC_URL
extra_mounts:
# Codex agent credentials
- host_path: ~/.codex
guest_path: /tmp/.codex
writable: true
# EE repo access (optional — only needed for enterprise features)
- host_path: ~/windmill-ee-private
writable: true
- host_path: ~/windmill-ee-private__worktrees
writable: true
EOF
# 3. (Optional) Build sandbox image — only needed for agent-yolo profile
@@ -91,17 +107,28 @@ nerdfont: false
sandbox:
image: windmill-sandbox
env_passthrough:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- R2_ENDPOINT
- R2_BUCKET
- R2_PUBLIC_URL
extra_mounts:
- host_path: ~/.codex
guest_path: /tmp/.codex
writable: true
- host_path: ~/windmill-ee-private
writable: true
- host_path: ~/windmill-ee-private__worktrees
writable: true
```
**Fields:**
- **`nerdfont`** — Set to `true` if your terminal uses a Nerd Font (adds icons to `workmux list` output). Default `false`.
- **`sandbox.image`** — Docker image used for `agent-yolo` sandboxed worktrees. Must be pre-built with `workmux sandbox build` or pulled with `workmux sandbox pull`.
- **`sandbox.extra_mounts`** — Additional bind mounts into sandbox containers. The example above mounts Codex credentials so the Codex agent can authenticate from inside the container.
- **`sandbox.env_passthrough`** — Host env vars to forward into sandbox containers (global config only). Used here for R2 screenshot upload credentials.
- **`sandbox.extra_mounts`** — Additional bind mounts into sandbox containers. Mounts Codex credentials and the EE repo for enterprise features.
To build the sandbox image (from the Windmill repo root):
@@ -155,6 +182,26 @@ Open http://localhost:5112 in your browser.
The frontend dev server is hardcoded to port `5112` and proxies `/api/*` and `/ws/*` to the backend.
### Screenshot uploads (optional)
Sandbox agents can take screenshots of the frontend UI with Playwright and upload them to a Cloudflare R2 bucket for use in PR descriptions. To enable this, create a `dev-dashboard/.env` file (already gitignored):
```bash
# Cloudflare R2 credentials — get from:
# Dashboard → R2 → Manage R2 API Tokens → Create API Token (Object Read & Write, scoped to your bucket)
AWS_ACCESS_KEY_ID=<your-r2-access-key>
AWS_SECRET_ACCESS_KEY=<your-r2-secret-key>
# Account ID is on the R2 overview page (right sidebar)
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
R2_BUCKET=windmill-screenshots
# Enable public access on the bucket (Settings → Public access → r2.dev subdomain)
R2_PUBLIC_URL=https://pub-<hash>.r2.dev
```
When these are set, `dev.sh`/`run.sh` source the file and the env vars are inlined onto the `workmux sandbox agent` command. The workmux global config's `env_passthrough` (see [above](#workmux-global-config)) forwards them into the container. The agent's system prompt automatically includes screenshot instructions when R2 is configured.
## API
| Method | Endpoint | Description |

View File

@@ -87,23 +87,59 @@ export { readEnvLocal } from "./env";
function buildSandboxSystemPrompt(env: Record<string, string>): string {
const backendPort = env.BACKEND_PORT || "8000";
const frontendPort = env.FRONTEND_PORT || "3000";
const hasR2 = !!(process.env.R2_ENDPOINT && process.env.R2_BUCKET && process.env.R2_PUBLIC_URL);
console.log(`[workmux:buildSandboxSystemPrompt] hasR2=${hasR2}`);
const lines: string[] = [
"You are running inside a sandboxed container with full permissions.",
`This worktree is configured with the following ports:`,
`- Backend: port ${backendPort}. Start with: cd backend && PORT=${backendPort} DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill cargo watch -x run`,
`- Frontend: port ${frontendPort}. Start with: cd frontend && REMOTE=http://localhost:${backendPort} npm run dev -- --port ${frontendPort} --host 0.0.0.0`,
];
if (hasR2) {
lines.push(
`--- Screenshots ---`,
`You can take screenshots of the frontend UI and upload them to R2 for use in PR descriptions.`,
`1) Take a screenshot: PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers bunx playwright screenshot --browser chromium http://localhost:${frontendPort}/path/to/page /tmp/screenshot.png`,
`2) Upload to R2: aws s3 cp /tmp/screenshot.png "s3://$R2_BUCKET/$(git rev-parse --abbrev-ref HEAD)/screenshot.png" --endpoint-url "$R2_ENDPOINT"`,
`3) The public URL will be: $R2_PUBLIC_URL/<branch>/screenshot.png`,
`4) Include screenshots in PR descriptions as markdown images: ![description]($R2_PUBLIC_URL/<branch>/screenshot.png)`,
);
}
return lines.join(" ");
}
/** Env vars to forward into the sandbox container (via workmux env_passthrough). */
const SANDBOX_ENV_PASSTHROUGH = [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"R2_ENDPOINT",
"R2_BUCKET",
"R2_PUBLIC_URL",
];
/** Build an inline env prefix (e.g. "KEY=val KEY2=val2 ") from process.env. */
function buildEnvPrefix(): string {
const parts: string[] = [];
for (const key of SANDBOX_ENV_PASSTHROUGH) {
const val = process.env[key];
if (val) {
// Shell-escape the value (single quotes, escaping inner single quotes)
const escaped = val.replace(/'/g, "'\\''");
parts.push(`${key}='${escaped}'`);
}
}
return parts.length > 0 ? parts.join(" ") + " " : "";
}
function buildSandboxAgentCmd(env: Record<string, string>, agent: Agent): string {
const prompt = buildSandboxSystemPrompt(env);
const innerEscaped = prompt.replace(/["\\$`]/g, "\\$&");
const envPrefix = buildEnvPrefix();
if (agent === "codex") {
return `workmux sandbox agent -- codex --yolo -c '"developer_instructions=${innerEscaped}"'`;
return `${envPrefix}workmux sandbox agent -- codex --yolo -c '"developer_instructions=${innerEscaped}"'`;
}
return `workmux sandbox agent -- claude --dangerously-skip-permissions --append-system-prompt '"${innerEscaped}"'`;
return `${envPrefix}workmux sandbox agent -- claude --dangerously-skip-permissions --append-system-prompt '"${innerEscaped}"'`;
}
export async function addWorktree(
@@ -163,7 +199,7 @@ export async function addWorktree(
for (let i = paneIds.length - 1; i >= 1; i--) {
Bun.spawnSync(["tmux", "kill-pane", "-t", `${windowTarget}.${paneIds[i]}`]);
}
// Build and send agent command for sandbox
// Build and send agent command for sandbox (env vars are inlined as a prefix)
const agentCmd = buildSandboxAgentCmd(env, agent);
console.log(`[workmux] sending command to ${windowTarget}.0:\n${agentCmd}`);
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, agentCmd, "Enter"]);

View File

@@ -2,6 +2,11 @@
set -euo pipefail
cd "$(dirname "$0")"
# Load env vars (R2 credentials, etc.) if present
if [ -f .env ]; then
set -a; source .env; set +a
fi
cleanup() {
kill $BE_PID $FE_PID 2>/dev/null || true
}

View File

@@ -2,6 +2,11 @@
set -euo pipefail
cd "$(dirname "$0")"
# Load env vars (R2 credentials, etc.) if present
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Build frontend
cd frontend
bun run build