This repository has been archived on 2026-10-10. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
opencode-sandbox/README.md
2026-09-28 12:01:20 +02:00

9.6 KiB

OpenCode Sandbox

A rootless Podman container for running OpenCode with a pinned toolchain — Java, Node, pnpm, git — without touching your host machine's environment.

What this gives you

  • A hash-pinned, integrity-verified build of OpenCode (not "latest" — an exact version, checked against a SHA256 captured at build time).
  • JDK 25 (LTS), Node 24 (LTS), Go, Rust, pnpm, git, curl, openssh-client.
  • Language servers for Go, Rust, TypeScript/JavaScript, Java, and Kotlin, ready for OpenCode's built-in LSP client (see LSP support below).
  • The current directory mounted read-write as /workspace.
  • OpenCode's config, data, and session state shared across every workdir/container, so logging in once carries over everywhere.
  • Every container joined to a shared Podman network so it can reach other dev containers by name.

Requirements

  • Podman (rootless).
  • On macOS: a running podman machine — make will start a stopped one automatically, but if you've never run Podman before you need to initialize one first:
    podman machine init
    
  • GNU Make (macOS's bundled Make 3.81 works fine; the Makefile deliberately avoids features added in 3.82+).

Usage

make build      # build (or rebuild) the container image
make opencode   # launch the OpenCode TUI in the current directory
make shell      # open a bash shell in the container instead
make install    # build, then install a global `opencode-sandbox` shortcut (see below)

Run make opencode or make shell from any project directory — the image is shared, so there's nothing per-project to set up.

make install

Installs a shim at ~/.local/bin/opencode-sandbox that runs make opencode against this repo's Makefile, using whatever directory you're standing in — not this repo — as /workspace. Once installed, opencode-sandbox works as a global command from any directory, no cd into this repo required. (Only opencode gets a shortcut; make shell still has to be run from here.)

It also makes sure ~/.local/bin is on PATH for future shells, based on your login shell ($SHELL):

  • zsh → appended to ~/.zshrc (or ~/.zshenv if that's the only one of the two that already exists; ~/.zshrc is created if neither does)
  • bash → ~/.bash_profile on macOS, ~/.bashrc on Linux (created if missing)
  • fish → added via fish_add_path (no config file edit needed)
  • anything else → appended to ~/.profile

The edit is wrapped in # >>> opencode-sandbox >>> markers so re-running make install never duplicates it, and is skipped entirely if ~/.local/bin is already on PATH.

LSP support

OpenCode's built-in LSP client is off by default — turn it on per-project with "lsp": true in that project's opencode.json (or configure individual servers there; see OpenCode's own docs for the object form). Once enabled, this image is ready for it:

Language Server How it gets there
Go gopls Built from source at image-build time (go install), pinned via GOPLS_VERSION in the Containerfile.
Rust rust-analyzer Installed as a rustup component at image-build time — OpenCode has no auto-download fallback for this one, so it has to already be on PATH.
TypeScript / JavaScript typescript + typescript-language-server Installed as global npm packages, pinned via TYPESCRIPT_VERSION/TS_LS_VERSION. Pinned to TypeScript's last JavaScript-based release (6.0.x) — TypeScript 7 is a native Go rewrite that dropped tsserver.js, which typescript-language-server needs to attach to. NODE_PATH is set so the global typescript package resolves even from a project with no local node_modules.
Java jdtls Pre-baked — OpenCode has no auto-download fallback for Java at all, so this has to be on PATH. Fetched straight from Eclipse's own download server (not GitHub Releases; architecture-independent, pure JVM). No bin/jdtls ships upstream, so scripts/jdtls is a hand-written launcher wrapper — the same convention every other jdtls consumer (nvim-jdtls, mason.nvim, coc-java) uses — which also manages a persistent, per-project -data workspace dir under OpenCode's host-persisted data dir.
Kotlin kotlin-lsp Pre-baked — JetBrains' official server (github.com/Kotlin/kotlin-lsp), fetched per-arch and hash-verified by hand (JetBrains publishes no checksum). Ships its own bundled JetBrains Runtime, so it needs none of this image's JDK. Symlinked as both kotlin-lsp and kotlin-language-server, since it's unconfirmed which name OpenCode's Kotlin integration expects.

Bumping a pinned LSP version means editing the corresponding ARG/FROM tag in the Containerfile and rebuilding (make build).

How it's put together

Containerfile — multi-stage build on debian:trixie-slim:

  • JDK and Node are copied in from their official upstream images (eclipse-temurin:25-jdk-noble, node:24-trixie-slim) rather than apt-installed, keeping the final image slim while staying on a mainstream glibc distro (Alpine/musl is avoided — it breaks JVM and native Node addon compatibility).
  • pnpm is installed via its own standalone installer script, pinned to a specific version.
  • OpenCode is downloaded directly from its canonical GitHub releases (github.com/anomalyco/opencode) for the container's architecture, and its SHA256 is checked against a hash pinned in the Containerfile before it's installed — there's no maintainer-published signature for OpenCode releases, so this hash is captured by hand and is the actual root of trust here. Bump OPENCODE_VERSION and re-run the sha256sum steps documented in the Containerfile's comments whenever you upgrade it.
  • A few build-time smoke tests (ldd, java -version, node --version, etc.) fail the build immediately if anything is broken, rather than surfacing at first make opencode.

Makefile — four targets:

  • build builds the image.

  • opencode and shell both run the container with the same flags: --userns=keep-id (so files created in the container are owned by your real host user, not root — verified: rootless Podman's user-namespace remapping means id shows root inside the container's own namespace view, but the file lands on the host owned by your actual UID/GID), the current directory bind-mounted to /workspace, and OpenCode's three XDG directories bind-mounted from your home directory:

    Host path Container path Contents
    ~/.config/opencode /home/dev/.config/opencode settings, plugins, themes
    ~/.local/share/opencode /home/dev/.local/share/opencode auth, database, snapshots
    ~/.local/state/opencode /home/dev/.local/state/opencode session/model history, locks

    Because these are real host paths shared by every container regardless of workdir, logging into OpenCode once carries over to every project you use the sandbox in.

  • Both run targets join a shared, auto-created Podman network (opencode-sandbox-net) under a --network-alias derived from the current directory's name, so a container started from another dev-container setup on the same network can reach this one by that name (and vice versa).

  • Both run targets also cap the container to --memory=3072m --memory-swap=3072m --cpus=4. Without this, a heavy JVM build tool (Gradle's daemon plus its separate Kotlin-compiler daemon are the worst offenders — both auto-size their heap off "available" memory with no idea they're sharing a fixed-size VM) can consume the entire podman machine's RAM. That starves the VM's own guest kernel, which stalls the processes Podman itself needs to stay responsive — so the whole machine hangs, not just the container. The cap forces a contained OOM kill instead. Sized to leave headroom below the VM's own allocation for the VM/podman/gvproxy itself — adjust both together if you resize the machine (podman machine set --memory <MB> --cpus <N> podman-machine-default, machine must be stopped first). The Containerfile also sets a default GRADLE_OPTS=-Xmx1024m so the Gradle daemon itself doesn't try to claim the whole container ceiling; a project whose Kotlin-compiler daemon also needs bounding should set kotlin.daemon.jvmargs in its own gradle.properties. These defaults can be overridden per-invocation by exporting OPENCODE_SANDBOX_MEMORY, OPENCODE_SANDBOX_MEMORY_SWAP, and/or OPENCODE_SANDBOX_CPUS before running make opencode/make shell (e.g. OPENCODE_SANDBOX_MEMORY=4096m OPENCODE_SANDBOX_CPUS=6 make shell).

macOS-specific notes

  • Podman on macOS runs everything inside a Linux VM (podman machine, backend libkrun/applehv, virtiofs file sharing). The Makefile detects this and starts a stopped machine automatically before build, opencode, or shell. This machine has a fixed CPU/memory allocation (podman machine inspect) — bump it (see above) if JVM-heavy builds are still tight even with the container's own cap.
  • SELinux volume-relabeling suffixes (:Z/:z) are intentionally omitted — macOS's host filesystem has no SELinux concept, so they'd be a no-op.
  • Known virtiofs limitation: inotify/file-watch events from the host don't propagate into the container. Not a blocker for normal use, just worth knowing if a tool inside the container tries to watch /workspace for live host-side changes.

Connecting to other dev containers

Any other container you attach to the opencode-sandbox-net network (podman network connect opencode-sandbox-net <container>, or by starting it with --network opencode-sandbox-net) can resolve and reach this sandbox by its workdir-derived alias, and vice versa.