Skip to content

Commit 85cbdd3

Browse files
feat(sandbox): ship Chromium in the image and drop browser control
1 parent 77b6318 commit 85cbdd3

17 files changed

Lines changed: 40 additions & 198 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ docker save oc-forge-sandbox:latest -o forge-sandbox.tar
564564
msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest
565565
```
566566

567-
The default image includes Node.js (NodeSource current channel), pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a full Docker Engine (docker-ce, CLI, containerd, Buildx, and Compose from Docker's official apt repo) that runs natively inside the microVM — `docker run`, `docker build`, and `docker compose` all work in-sandbox. The daemon is started on demand by `forge-dockerd-start` (msb boots its own `agentd` as PID 1 and ignores the image's entrypoint, so nothing runs dockerd at boot); `/var/lib/docker` is backed by a dedicated block device because overlayfs cannot run on a virtiofs mount. The built image is roughly 1.65 GB. Chromium and Browser Control are an opt-in image feature: set `sandbox.imageFeatures.browserControl` to `true`, then run `Build sandbox template` from the command palette to rebuild and load the configured image tag.
567+
The default image includes Node.js (NodeSource current channel), pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a full Docker Engine (docker-ce, CLI, containerd, Buildx, and Compose from Docker's official apt repo) that runs natively inside the microVM — `docker run`, `docker build`, and `docker compose` all work in-sandbox. The daemon is started on demand by `forge-dockerd-start` (msb boots its own `agentd` as PID 1 and ignores the image's entrypoint, so nothing runs dockerd at boot); `/var/lib/docker` is backed by a dedicated block device because overlayfs cannot run on a virtiofs mount. The built image is roughly 1.65 GB. The image also ships the current Playwright Chromium build as `chromium` (Google publishes no linux/arm64 Chrome build, so Chromium is the arm64 equivalent).
568568

569569
The `container/Dockerfile` ships with the plugin package. If the image is missing when OpenCode starts, Forge shows a warning toast with a "Build sandbox template" command in the palette. You can also trigger the build from the command palette at any time by searching for `Build sandbox template`, which opens a confirmation dialog and runs the build/save/load sequence automatically. The dialog stays open for the duration and shows a live progress bar, the current Docker step, elapsed time, and streamed build output; on failure it keeps the last lines of Docker output so the cause is visible. A first build takes several minutes. Closing the dialog does not cancel the build — it finishes in the background and reports with a toast.
570570

‎container/Dockerfile‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,27 +78,38 @@ ENV HOME=/opt/forge \
7878
RUN mkdir -p /opt/forge/.cache /opt/forge/.local/share/pnpm/store /opt/forge/.npm \
7979
&& chmod -R 0777 /opt/forge
8080

81-
# fallow CLI — dead-code analysis tool — installed globally with the same pnpm
82-
# setup used by the rest of the sandbox image. Binaries are linked into
83-
# /usr/local/bin so they are on PATH for arbitrary container UIDs.
81+
# fallow CLI — dead-code analysis tool — and the current Playwright Chromium
82+
# browser, installed globally with the same pnpm setup used by the rest of the
83+
# sandbox image. Binaries are linked into /usr/local/bin so they are on PATH for
84+
# arbitrary container UIDs.
8485
#
85-
# The trailing chmod is load-bearing: this global install runs as root and populates the
86+
# Chromium comes from playwright-core because Google publishes no linux/arm64
87+
# Chrome build, so on arm64 hosts it is the closest current Chrome build
88+
# available; `install --with-deps` pulls the system libraries Chromium needs at
89+
# runtime.
90+
#
91+
# The trailing chmod is load-bearing: these global installs run as root and populate the
8692
# pnpm store (store/v10/{files,index,projects}) with root-owned 0755 dirs, AFTER the earlier
8793
# `chmod -R 0777 /opt/forge`. Because the container runs as root but agent commands run as the
8894
# image's `agent` user via `msb exec`, that UID could not write into the root-owned store and
8995
# `pnpm install` failed with EACCES when registering the project — which previously drove the
9096
# agent to relocate the store onto the msb-mounted project directory (huge, slow file transfers).
9197
# Re-asserting 0777 here, as the last build step that touches /opt/forge, keeps the store
9298
# writable by any exec UID. Any future build step that runs pnpm as root must do the same.
99+
RUN pnpm add -g fallow@latest --global-bin-dir /usr/local/bin \
100+
&& pnpm add -g playwright-core@latest --global-bin-dir /usr/local/bin \
101+
&& package_root="$(readlink -f "$(find "$(pnpm root -g)" -maxdepth 4 -path '*/node_modules/playwright-core' -print -quit)")" \
102+
&& node "$package_root/cli.js" install --with-deps chromium \
103+
&& ln -s "$(node --input-type=module -e "import { chromium } from 'file://$package_root/index.mjs'; console.log(chromium.executablePath())")" /usr/local/bin/chromium \
104+
&& chmod -R 0777 /opt/forge
93105

94106
# Docker Engine from Docker's official apt repository. The microVM boots a real
95107
# kernel, so the daemon runs natively — no privileged-container or nested-
96108
# virtualization tricks. `/var/lib/docker` is mounted as a dedicated msb block
97109
# device and public egress is the default, so `docker run` and registry pulls
98110
# work out of the box. The `agent` user joins the `docker` group for socket
99111
# access without sudo; starting the daemon itself stays root-only via the
100-
# passwordless sudo granted above. Installed before the INSTALL_BROWSER_CONTROL
101-
# toggle so rebuilding with that flag on does not re-fetch the Docker stack.
112+
# passwordless sudo granted above.
102113
RUN install -m 0755 -d /etc/apt/keyrings \
103114
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
104115
&& chmod a+r /etc/apt/keyrings/docker.asc \
@@ -166,19 +177,9 @@ tail -n 20 "$log" 2>/dev/null >&2
166177
exit 1
167178
EOF
168179

169-
ARG INSTALL_BROWSER_CONTROL=false
170-
171-
RUN pnpm add -g fallow@latest --global-bin-dir /usr/local/bin \
172-
&& if [ "$INSTALL_BROWSER_CONTROL" = "true" ]; then \
173-
pnpm add -g @opencode-ai/browser-control@latest --global-bin-dir /usr/local/bin \
174-
&& package_link="$(find "$(pnpm root -g)" -maxdepth 4 -type l -path '*/node_modules/@opencode-ai/browser-control' -print -quit)" \
175-
&& package_root="$(readlink -f "$package_link")" \
176-
&& package_modules="$(dirname "$(dirname "$package_root")")" \
177-
&& node "$package_modules/playwright-core/cli.js" install --with-deps chromium \
178-
&& ln -s "$package_root/extension/dist" /opt/browser-control-extension \
179-
&& ln -s "$(node --input-type=module -e "import { chromium } from 'file://$package_modules/playwright-core/index.mjs'; console.log(chromium.executablePath())")" /usr/local/bin/chromium; \
180-
fi \
181-
&& chmod -R 0777 /opt/forge
180+
# fallow CLI — dead-code analysis tool — installed globally with the same pnpm
181+
# setup used by the rest of the sandbox image. Binaries are linked into
182+
# /usr/local/bin so they are on PATH for arbitrary container UIDs.
182183

183184
# Allow git to operate on the msb-mounted project directory regardless of owner UID.
184185
RUN git config --system --add safe.directory '*'

‎docs/api/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ docker save oc-forge-sandbox:latest -o forge-sandbox.tar
566566
msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest
567567
```
568568

569-
The default image includes Node.js (NodeSource current channel), pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a full Docker Engine (docker-ce, CLI, containerd, Buildx, and Compose from Docker's official apt repo) that runs natively inside the microVM — `docker run`, `docker build`, and `docker compose` all work in-sandbox. The daemon is started on demand by `forge-dockerd-start` (msb boots its own `agentd` as PID 1 and ignores the image's entrypoint, so nothing runs dockerd at boot); `/var/lib/docker` is backed by a dedicated block device because overlayfs cannot run on a virtiofs mount. The built image is roughly 1.65 GB. Chromium and Browser Control are an opt-in image feature: set `sandbox.imageFeatures.browserControl` to `true`, then run `Build sandbox template` from the command palette to rebuild and load the configured image tag.
569+
The default image includes Node.js (NodeSource current channel), pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a full Docker Engine (docker-ce, CLI, containerd, Buildx, and Compose from Docker's official apt repo) that runs natively inside the microVM — `docker run`, `docker build`, and `docker compose` all work in-sandbox. The daemon is started on demand by `forge-dockerd-start` (msb boots its own `agentd` as PID 1 and ignores the image's entrypoint, so nothing runs dockerd at boot); `/var/lib/docker` is backed by a dedicated block device because overlayfs cannot run on a virtiofs mount. The built image is roughly 1.65 GB. The image also ships the current Playwright Chromium build as `chromium` (Google publishes no linux/arm64 Chrome build, so Chromium is the arm64 equivalent).
570570

571571
The `container/Dockerfile` ships with the plugin package. If the image is missing when OpenCode starts, Forge shows a warning toast with a "Build sandbox template" command in the palette. You can also trigger the build from the command palette at any time by searching for `Build sandbox template`, which opens a confirmation dialog and runs the build/save/load sequence automatically. The dialog stays open for the duration and shows a live progress bar, the current Docker step, elapsed time, and streamed build output; on failure it keeps the last lines of Docker output so the cause is visible. A first build takes several minutes. Closing the dialog does not cancel the build — it finishes in the background and reports with a toast.
572572

‎docs/api/_media/configuration.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,6 @@ See [Sandbox](sandbox.md) for detailed behavior and security notes.
247247
| `sandbox.enabled` | `true` | Enable sandboxed execution. When enabled and the msb CLI or host virtualization is unavailable, sandbox startup fails rather than falling back to the host; set `false` to run worktree-only. |
248248
| `sandbox.mode` | `"msb"` | Sandbox mode. `msb` is currently the only supported mode. A stale `"mode": "sbx"` from an older install is reported as a migration warning in the log and, when running in the TUI, as a toast. |
249249
| `sandbox.image` | `"oc-forge-sandbox:latest"` | msb image reference used for sandboxed execution. |
250-
| `sandbox.imageFeatures.browserControl` | `false` | Include Chromium, the Browser Control CLI/MCP server, and its extension when building the bundled sandbox image. Rebuild the image after changing it. |
251250
| `sandbox.resources.memory` | `"8g"` | Memory the sandbox gets (`msb create -m`). Fixed for the sandbox's life; there is no autoscaling, so size it for the heaviest command it will run or that command is OOM-killed. |
252251
| `sandbox.resources.cpus` | `"4"` | CPU count the sandbox gets (`msb create -c`; integer-only). Fixed for the sandbox's life. |
253252
| `sandbox.resources.dockerDisk` | `"16g"` | Size of the dedicated block device backing the sandbox's in-VM Docker Engine data dir (`/var/lib/docker`, `--mount-named ...:kind=disk,size=<size>`). The disk is sparse, so the generous default costs no real disk up front. |

‎docs/api/_media/sandbox.md‎

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -26,51 +26,18 @@ msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest
2626

2727
`msb load` registers the archive under the tag Forge looks up (`sandbox.image`, default `oc-forge-sandbox:latest`); list loaded images with `msb images --format json`.
2828

29-
The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and Docker Engine (see [Nested Docker](#nested-docker)).
29+
The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, Chromium, and Docker Engine (see [Nested Docker](#nested-docker)).
3030

3131
The sandbox image grants the `agent` user passwordless sudo, so loops can install whatever software they need at runtime. Commands arrive via `msb exec` without `-u`, so they run as the image's `USER agent` (keeping host-mapped worktree files owned by the host user); system-wide installs use an explicit `sudo` prefix, for example `sudo apt-get install ruby`.
3232

33-
### Browser Control (opt-in)
33+
### Chromium
3434

35-
Chromium and Browser Control add a substantial browser payload, so they are excluded by default. Enable them for future image builds:
36-
37-
```jsonc
38-
{
39-
"sandbox": {
40-
"imageFeatures": {
41-
"browserControl": true
42-
}
43-
}
44-
}
45-
```
46-
47-
Then run `Build sandbox template` from the command palette. Changing the option does not modify an already-loaded image; rebuild it explicitly. The equivalent manual Docker build is:
35+
The image ships the current Playwright Chromium build as `chromium`. Google publishes no linux/arm64 Chrome build, so Chromium is the arm64 equivalent of a current Chrome. Launch it headless with the usual sandbox flags:
4836

4937
```bash
50-
docker build \
51-
--build-arg INSTALL_BROWSER_CONTROL=true \
52-
-t oc-forge-sandbox:latest \
53-
container/
54-
docker save oc-forge-sandbox:latest -o forge-sandbox.tar
55-
msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest
56-
```
57-
58-
The resulting image provides `browser-control`, `browser-control-mcp`, Chromium as `chromium`, and the unpacked extension at `/opt/browser-control-extension`.
59-
60-
Browser Control connects to a browser in the same sandbox. Launch Chromium with the packaged extension path resolved to its installation directory:
61-
62-
```bash
63-
extension="$(readlink -f /opt/browser-control-extension)"
64-
xvfb-run -a chromium \
65-
--no-sandbox \
66-
--disable-dev-shm-usage \
67-
--disable-extensions-except="$extension" \
68-
--load-extension="$extension" \
69-
--user-data-dir=/opt/forge/.browser-control-profile
38+
chromium --no-sandbox --disable-dev-shm-usage --headless
7039
```
7140

72-
It cannot control a browser running on the host because sandbox networking cannot reach the host loopback interface.
73-
7441
## How It Works
7542

7643
1. A sandbox loop uses its isolated git worktree. A host-session sandbox instead uses the project root selected from the TUI.

‎docs/configuration.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,6 @@ See [Sandbox](sandbox.md) for detailed behavior and security notes.
247247
| `sandbox.enabled` | `true` | Enable sandboxed execution. When enabled and the msb CLI or host virtualization is unavailable, sandbox startup fails rather than falling back to the host; set `false` to run worktree-only. |
248248
| `sandbox.mode` | `"msb"` | Sandbox mode. `msb` is currently the only supported mode. A stale `"mode": "sbx"` from an older install is reported as a migration warning in the log and, when running in the TUI, as a toast. |
249249
| `sandbox.image` | `"oc-forge-sandbox:latest"` | msb image reference used for sandboxed execution. |
250-
| `sandbox.imageFeatures.browserControl` | `false` | Include Chromium, the Browser Control CLI/MCP server, and its extension when building the bundled sandbox image. Rebuild the image after changing it. |
251250
| `sandbox.resources.memory` | `"8g"` | Memory the sandbox gets (`msb create -m`). Fixed for the sandbox's life; there is no autoscaling, so size it for the heaviest command it will run or that command is OOM-killed. |
252251
| `sandbox.resources.cpus` | `"4"` | CPU count the sandbox gets (`msb create -c`; integer-only). Fixed for the sandbox's life. |
253252
| `sandbox.resources.dockerDisk` | `"16g"` | Size of the dedicated block device backing the sandbox's in-VM Docker Engine data dir (`/var/lib/docker`, `--mount-named ...:kind=disk,size=<size>`). The disk is sparse, so the generous default costs no real disk up front. |

‎docs/sandbox.md‎

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -26,51 +26,18 @@ msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest
2626

2727
`msb load` registers the archive under the tag Forge looks up (`sandbox.image`, default `oc-forge-sandbox:latest`); list loaded images with `msb images --format json`.
2828

29-
The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and Docker Engine (see [Nested Docker](#nested-docker)).
29+
The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, Chromium, and Docker Engine (see [Nested Docker](#nested-docker)).
3030

3131
The sandbox image grants the `agent` user passwordless sudo, so loops can install whatever software they need at runtime. Commands arrive via `msb exec` without `-u`, so they run as the image's `USER agent` (keeping host-mapped worktree files owned by the host user); system-wide installs use an explicit `sudo` prefix, for example `sudo apt-get install ruby`.
3232

33-
### Browser Control (opt-in)
33+
### Chromium
3434

35-
Chromium and Browser Control add a substantial browser payload, so they are excluded by default. Enable them for future image builds:
36-
37-
```jsonc
38-
{
39-
"sandbox": {
40-
"imageFeatures": {
41-
"browserControl": true
42-
}
43-
}
44-
}
45-
```
46-
47-
Then run `Build sandbox template` from the command palette. Changing the option does not modify an already-loaded image; rebuild it explicitly. The equivalent manual Docker build is:
35+
The image ships the current Playwright Chromium build as `chromium`. Google publishes no linux/arm64 Chrome build, so Chromium is the arm64 equivalent of a current Chrome. Launch it headless with the usual sandbox flags:
4836

4937
```bash
50-
docker build \
51-
--build-arg INSTALL_BROWSER_CONTROL=true \
52-
-t oc-forge-sandbox:latest \
53-
container/
54-
docker save oc-forge-sandbox:latest -o forge-sandbox.tar
55-
msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest
56-
```
57-
58-
The resulting image provides `browser-control`, `browser-control-mcp`, Chromium as `chromium`, and the unpacked extension at `/opt/browser-control-extension`.
59-
60-
Browser Control connects to a browser in the same sandbox. Launch Chromium with the packaged extension path resolved to its installation directory:
61-
62-
```bash
63-
extension="$(readlink -f /opt/browser-control-extension)"
64-
xvfb-run -a chromium \
65-
--no-sandbox \
66-
--disable-dev-shm-usage \
67-
--disable-extensions-except="$extension" \
68-
--load-extension="$extension" \
69-
--user-data-dir=/opt/forge/.browser-control-profile
38+
chromium --no-sandbox --disable-dev-shm-usage --headless
7039
```
7140

72-
It cannot control a browser running on the host because sandbox networking cannot reach the host loopback interface.
73-
7441
## How It Works
7542

7643
1. A sandbox loop uses its isolated git worktree. A host-session sandbox instead uses the project root selected from the TUI.

‎forge-config.jsonc‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,7 @@
104104
"sandbox": {
105105
"enabled": true,
106106
"mode": "msb",
107-
"image": "oc-forge-sandbox:latest",
108-
"imageFeatures": {
109-
"browserControl": false
110-
}
107+
"image": "oc-forge-sandbox:latest"
111108
// Mount the source project directory read-only at its identical host path. Defaults to true.
112109
// "mountProjectReadonly": true,
113110
// Network access configuration. msb egress is allow-by-default: with no hosts configured,

‎src/index.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,6 @@ export function createForgePlugin(config: PluginConfig): Plugin {
353353
...(sandboxMountConfigs.length > 0 ? { customMounts: sandboxMountConfigs } : {}),
354354
...(config.sandbox?.network ? { network: config.sandbox.network } : {}),
355355
buildContextDir: resolveBundledContainerDir(),
356-
browserControl: config.sandbox?.imageFeatures?.browserControl === true,
357356
...(config.sandbox?.resources ? { resources: config.sandbox.resources } : {}),
358357
}, logger, defaultGitService)
359358
logger.log('Sandbox manager initialized')
@@ -382,7 +381,6 @@ export function createForgePlugin(config: PluginConfig): Plugin {
382381
if (sandboxManager && forgeClient) {
383382
const sandboxImage = config.sandbox?.image ?? DEFAULT_SANDBOX_IMAGE
384383
const buildContextDir = resolveBundledContainerDir()
385-
const browserControl = config.sandbox?.imageFeatures?.browserControl === true
386384
void (async () => {
387385
try {
388386
const available = await runtime.checkAvailable()
@@ -412,7 +410,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
412410
directory,
413411
logger,
414412
title: 'Sandbox template not found',
415-
message: `Sandbox template "${sandboxImage}" is missing. Build it from the command palette: "Build sandbox template", or run: ${formatTemplateBuildCommands(buildContextDir, sandboxImage, { browserControl })}`,
413+
message: `Sandbox template "${sandboxImage}" is missing. Build it from the command palette: "Build sandbox template", or run: ${formatTemplateBuildCommands(buildContextDir, sandboxImage)}`,
416414
variant: 'warning',
417415
duration: 10_000,
418416
})

0 commit comments

Comments
 (0)