Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ This project uses itself as its own sandbox — the AI agent runs **inside the o
- The workspace is mounted at `/<project-name>` inside the container (derived from the host project directory name)
- Outbound network access is restricted to the domains whitelisted in `opencode-sandbox-config.yaml`
- Host environment variables are forwarded as configured in the `env-passthrough` section — in particular `GH_TOKEN` for GitHub CLI access
- `docker` and `podman` are **not available** inside the container — `ocs-rebuild-container` and `ocs-start-container` cannot be run here; ask the user to run them on the host
- `docker` CLI is available inside the container (installed via `mise.toml`) and communicates with the **host Docker daemon** via the mounted socket (`docker-in-docker: true`) — containers it creates are siblings on the host, not nested children; bind-mount paths in `docker run -v` must use host-side absolute paths
- `podman` is **not available** inside the container
- `ocs-rebuild-container` and `ocs-start-container` cannot be run here (they manage the sandbox container itself from the host) — ask the user to run them on the host
- The `gh` CLI is available and authenticated via `GH_TOKEN` for reading and commenting on PRs and issues, but **not** for pushing code or creating branches — do not attempt `git push` or `git fetch`
- SSH is not available inside the container — `git push` and `git fetch` will fail; do not modify `git remote` URLs
- `shellcheck` is available for linting
Expand Down Expand Up @@ -42,11 +44,16 @@ No tests, no formatter, no typecheck, no CI workflows.
The config file uses a YAML subset deliberately chosen to be parseable without any external dependencies. No `yq`, `python`, or other tools are required on the host.

The supported subset is intentionally narrow:
- Top-level keys only (section headers): `key:`
- Top-level scalar values: `key: value` (no leading whitespace, has a value)
- Top-level section headers: `key:` (no leading whitespace, no value)
- List items one level deep: ` - value`
- Map entries one level deep: ` key: value`
- Line comments (`#`) and blank lines

The parser distinguishes scalars from section headers by whether a value is present after the colon. A top-level scalar clears the current section context; entries that follow it are not attributed to any section until the next section header appears.

To add a new top-level scalar: declare a `cfg_<name>` variable before the parse loop, add a `case` arm inside the loop, and add the corresponding behaviour in the "Post-parse: apply scalar flags" block after the loop.

Anything outside this subset — anchors, multi-line strings, nested structures, typed values — is silently ignored by the parser in `ocs-rebuild-container`. Do not add configuration that relies on YAML features beyond the above. If richer configuration is ever needed, switch to a proper YAML parser (`yq`) rather than extending the bash parser.

### bash 3.2 compatibility (macOS)
Expand Down
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Starts the sandbox for the current project. Each invocation creates a fresh cont
- Sources `opencode-sandbox-pre-start-container.sh` from the project root, if it exists (see [Hooks](#hooks))
- Forwards whitelisted host environment variables into the container (as configured in `opencode-sandbox-config.yaml`)
- Mounts your project root as `/<dirname>` inside the container (e.g. a project at `/home/user/my-project` is mounted at `/my-project`)
- Mounts any additional directories configured in the `volume-mounts` section of `opencode-sandbox-config.yaml`
- Exposes OpenCode on `http://127.0.0.1:4096`
- Press `Ctrl+C` to stop and remove the container

Expand Down Expand Up @@ -149,7 +150,7 @@ All outbound traffic is routed via the proxy automatically through the standard

## Configuration — `opencode-sandbox-config.yaml`

The `opencode-sandbox-config.yaml` file in your project root controls the project name, outbound network access, and environment variables. It is safe to commit.
The `opencode-sandbox-config.yaml` file in your project root controls the project name, outbound network access, environment variables, and extra volume mounts. It is safe to commit.

> **Note:** Only a narrow YAML subset is supported: top-level keys, one-level-deep list items (`- value`), and one-level-deep map entries (`key: value`). Anchors, multi-line strings, nested structures, and other YAML features are not supported.

Expand All @@ -171,6 +172,11 @@ env-passthrough:

env:
GITHUB_REPOSITORY: my-org/my-repo

volume-mounts:
/shared-data: /home/user/shared-data

# docker-in-docker: true
```

**`sandbox-name`** — human-readable project identifier (required):
Expand Down Expand Up @@ -200,10 +206,73 @@ env:
- Values are literal — no shell expansion
- A rebuild is required after adding or removing entries

**`volume-mounts`** — additional host directories to mount into the container:
- Format is `CONTAINER_DIR: HOST_DIR` — both paths must be absolute
- Use this to give OpenCode access to directories outside the project root (e.g. shared data, local package caches, credential files)
- The host path is mounted read-write; use with care as OpenCode can modify the contents
- A rebuild is required after adding or removing entries

**`docker-in-docker`** — mount the host Docker socket into the container, allowing OpenCode to build and run containers:
- Set to `true` to enable; omit or set to `false` to disable
- When enabled, `/var/run/docker.sock` is automatically mounted into the container — no manual `volume-mounts` entry needed
- The container entrypoint dynamically creates a `docker` group matching the socket's GID at startup, so the `dev` user can access the socket regardless of host OS or runtime (Colima, Podman, Docker Desktop, etc.)
- A rebuild is required after changing this setting

```yaml
docker-in-docker: true
```

> **Note:** Enabling `docker-in-docker` only mounts the socket — it does not install Docker CLI tooling. You must add `docker` and/or `docker-compose` to your `mise.toml` so they are available inside the container (see [Docker-in-Docker setup](#docker-in-docker-setup) below).

`ocs-rebuild-container` reads this file to generate derived build artifacts — **a rebuild is required after changes**. The file is required; `ocs-rebuild-container` fails if it is missing.

---

## Docker-in-Docker setup

To let OpenCode build and run containers, you need two things:

1. **Socket access** — set `docker-in-docker: true` in `opencode-sandbox-config.yaml`
2. **CLI tooling** — install `docker` (CLI) and optionally `docker-compose` via `mise.toml`

### `opencode-sandbox-config.yaml`

```yaml
sandbox-name: my-project

docker-in-docker: true

http-domain-whitelist:
- .github.com
# ... other domains your project needs
```

### `mise.toml`

```toml
[tools]
"github:anomalyco/opencode" = "latest"

# Docker CLI — communicates with the host Docker daemon via the mounted socket
"aqua:docker/cli" = "latest"

# Docker Compose plugin (provides the `docker compose` subcommand)
"aqua:docker/compose" = "latest"
```

After updating `mise.toml`, rebuild the container:

```bash
ocs-rebuild-container
ocs-start-container
```

Once running, OpenCode can execute `docker` and `docker compose` commands that operate against the **host Docker daemon** — containers it starts are siblings on the host, not nested children. Keep this in mind when referencing mounted paths: paths must be valid on the **host**, not inside the sandbox container.

> **Example:** if your project is at `/home/user/my-project` on the host, bind-mount paths in `docker run -v` commands must use that host path, not the container-internal path `/<dirname>`.

---

## Hooks

### `opencode-sandbox-pre-start-container.sh`
Expand Down Expand Up @@ -270,6 +339,7 @@ Each project gets its own isolated container named `opencode-sandbox-<SANDBOX_ID
├── host-ports.txt # Extracted from host-ports at build time
├── env-passthrough.txt # Extracted from env-passthrough at build time
├── env.txt # Extracted from env at build time
├── volume-mounts.txt # Extracted from volume-mounts at build time
├── docker-build.log # Docker build output (created during build)
└── opencode-state/ # Persistent OpenCode state (mounted into the container)
```
Expand Down
47 changes: 44 additions & 3 deletions bin/ocs-rebuild-container
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,47 @@ if [[ -f "${CONFIG_FILE}" ]]; then
HOST_PORTS="${OPENCODE_SANDBOX_DIR}/host-ports.txt"
ENV_PASSTHROUGH="${OPENCODE_SANDBOX_DIR}/env-passthrough.txt"
ENV_STATIC="${OPENCODE_SANDBOX_DIR}/env.txt"
VOLUME_MOUNTS="${OPENCODE_SANDBOX_DIR}/volume-mounts.txt"
true > "${SQUID_WHITELIST}"
true > "${HOST_PORTS}"
true > "${ENV_PASSTHROUGH}"
true > "${ENV_STATIC}"
true > "${VOLUME_MOUNTS}"

# ---------------------------------------------------------------------------
# Scalar flags — top-level "key: value" entries (not sections).
# Add new top-level scalars here; their values are set during the parse loop.
# ---------------------------------------------------------------------------
cfg_docker_in_docker="false"

section=""
while IFS= read -r line || [[ -n "${line}" ]]; do
# Skip blank lines and comment lines (trimmed)
trimmed="${line#"${line%%[![:space:]]*}"}"
[[ -z "${trimmed}" || "${trimmed}" == "#"* ]] && continue
# Top-level section header: "key:" with no value and no leading whitespace
if [[ "${line}" =~ ^([a-z-]+):$ ]]; then
section="${BASH_REMATCH[1]}"; continue

# Top-level key: no leading whitespace, matches "key:" or "key: value"
if [[ "${line}" =~ ^([a-z-]+):[[:space:]]*(.*) ]]; then
top_key="${BASH_REMATCH[1]}"
top_value="${BASH_REMATCH[2]}"
# Strip inline comments and trailing whitespace from value
top_value="${top_value%%#*}"
top_value="${top_value%"${top_value##*[! ]}"}"

if [[ -z "${top_value}" ]]; then
# No value — this is a section header
section="${top_key}"
else
# Has a value — top-level scalar; clear section context
section=""
case "${top_key}" in
docker-in-docker) cfg_docker_in_docker="${top_value}" ;;
# sandbox-name is handled by shared; other top-level scalars (e.g. sandbox-name) are intentionally ignored here
esac
fi
continue
fi

# List item: " - value"
if [[ "${line}" =~ ^[[:space:]]+-[[:space:]]+(.+)$ ]]; then
value="${BASH_REMATCH[1]}"
Expand All @@ -65,21 +93,34 @@ if [[ -f "${CONFIG_FILE}" ]]; then
esac
continue
fi

# Map entry: " key: value"
if [[ "${line}" =~ ^[[:space:]]+([^[:space:]:]+):[[:space:]]+(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
case "${section}" in
env-passthrough) echo "${key}=${value}" >> "${ENV_PASSTHROUGH}" ;;
env) echo "${key}=${value}" >> "${ENV_STATIC}" ;;
volume-mounts) echo "${key}=${value}" >> "${VOLUME_MOUNTS}" ;;
esac
continue
fi
done < "${CONFIG_FILE}"

# ---------------------------------------------------------------------------
# Post-parse: apply scalar flags
# Add handling for new top-level scalars here.
# ---------------------------------------------------------------------------
if [[ "${cfg_docker_in_docker}" == "true" ]]; then
echo "/var/run/docker.sock=/var/run/docker.sock" >> "${VOLUME_MOUNTS}"
echo " docker-in-docker: enabled — docker socket mount injected"
fi

echo " Extracted $(wc -l < "${SQUID_WHITELIST}") whitelisted domain(s) into squid-whitelist.txt"
echo " Extracted $(wc -l < "${HOST_PORTS}") host TCP port(s) into host-ports.txt"
echo " Extracted $(wc -l < "${ENV_PASSTHROUGH}") env var name(s) into env-passthrough.txt"
echo " Extracted $(wc -l < "${ENV_STATIC}") static env var(s) into env.txt"
echo " Extracted $(wc -l < "${VOLUME_MOUNTS}") volume mount(s) into volume-mounts.txt"
else
echo " Missing required config file: ${CONFIG_FILE}" >&2
echo " Create opencode-sandbox-config.yaml (use ocs-init template) and rebuild." >&2
Expand Down
24 changes: 24 additions & 0 deletions bin/ocs-start-container
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ if [[ -f "${ENV_STATIC_FILE}" ]]; then
done < "${ENV_STATIC_FILE}"
fi

# Build -v flags for extra volume mounts from volume-mounts.txt (format: CONTAINER_DIR=HOST_DIR)
volume_flags=()
volume_log=()
VOLUME_MOUNTS_FILE="${OPENCODE_SANDBOX_DIR}/volume-mounts.txt"
if [[ -f "${VOLUME_MOUNTS_FILE}" ]]; then
while IFS= read -r entry || [[ -n "${entry}" ]]; do
[[ -z "${entry}" ]] && continue
container_dir="${entry%%=*}"
host_dir="${entry#*=}"
# Expand environment variables in the host path (e.g. $HOME, ${HOME})
host_dir="$(eval echo "${host_dir}")"
volume_flags+=(-v "${host_dir}:${container_dir}")
volume_log+=(" ${host_dir} -> ${container_dir}")
done < "${VOLUME_MOUNTS_FILE}"
fi

# Print full config summary before handing off to docker
# shellcheck disable=SC2153 # PORT, LOCALHOST, and CONTAINER_NAME are defined in shared
echo " container: ${CONTAINER_NAME}"
Expand Down Expand Up @@ -101,6 +117,13 @@ if [[ -f "${HOST_PORTS_FILE}" ]]; then
fi
fi
echo ""
echo " extra volume mounts:"
if [[ "${#volume_log[@]}" -gt 0 ]]; then
for entry in "${volume_log[@]}"; do echo "${entry}"; done
else
echo " (none)"
fi
echo ""
echo "---"
echo ""

Expand Down Expand Up @@ -152,6 +175,7 @@ fi
-p "${LOCALHOST}:${PORT}:4096" \
-v "${ROOT_DIR}:/$(basename "${ROOT_DIR}")" \
-v "${OPENCODE_STATE_DIR}:/home/dev/.local/share/opencode" \
"${volume_flags[@]+"${volume_flags[@]}"}" \
"${env_flags[@]+"${env_flags[@]}"}" \
"${CONTAINER_NAME}"

Expand Down
11 changes: 11 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ export NO_PROXY="${_no_proxy_hosts}"
OPENCODE_SERVER_PASSWORD=$(cat /opencode-password)
export OPENCODE_SERVER_PASSWORD

# ---------------------------------------------------------------------------
# Docker socket: match GID of mounted socket so dev user can access it
# ---------------------------------------------------------------------------
if [[ -S /var/run/docker.sock ]]; then
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
if ! getent group "${DOCKER_GID}" > /dev/null 2>&1; then
groupadd -g "${DOCKER_GID}" docker
fi
usermod -aG "${DOCKER_GID}" dev
fi

# ---------------------------------------------------------------------------
# Start OpenCode as the dev user (gosu drops root, env is inherited)
# ---------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions init-templates/opencode-sandbox-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,13 @@ env-passthrough:
# Static env vars set in the container. Safe to commit — no secrets here.
env:
# GITHUB_REPOSITORY: my-org/my-repo

# Additional directories to mount into the container.
# Format: CONTAINER_DIR: HOST_DIR
# Both paths must be absolute. A rebuild is required after adding or removing entries.
volume-mounts:
# /shared-data: /home/user/shared-data

# Enable docker-in-docker: mounts the host Docker socket into the container,
# allowing the agent to build and run containers. Requires a rebuild.
# docker-in-docker: true
3 changes: 2 additions & 1 deletion mise.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[tools]
"github:anomalyco/opencode" = "latest"
shellcheck = "latest"
github-cli = "2.91.0"
github-cli = "2.91.0"
"aqua:docker/cli" = "latest"
6 changes: 6 additions & 0 deletions opencode-sandbox-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ env:
GH_REPO: github.com/comsysto/opencode-sandbox
GH_HOST: github.com

volume-mounts:
# /test: "$HOME/test"

# Enable docker-in-docker: mounts the host Docker socket into the container,
# allowing the agent to build and run containers. Requires a rebuild.
docker-in-docker: true