From 817253c39e9a6dc153a0e1c5a0ae7c6161d90316 Mon Sep 17 00:00:00 2001 From: okrammer <1805495+okrammer@users.noreply.github.com> Date: Tue, 19 May 2026 15:24:19 +0000 Subject: [PATCH] feat: add volume-mounts and docker-in-docker support - Parse volume-mounts section in opencode-sandbox-config.yaml and write to volume-mounts.txt during ocs-rebuild-container - Parse docker-in-docker scalar flag; when true, inject the Docker socket as an entry in volume-mounts.txt automatically - Extend YAML parser in ocs-rebuild-container to handle top-level scalars (key: value) alongside section headers (key:), clearing section context on scalar entries; add cfg_ / post-parse pattern for future scalars - ocs-start-container reads volume-mounts.txt and builds -v flags for each entry; expands env vars in host paths (e.g. $HOME); prints mount summary - entrypoint.sh: dynamically match docker group GID of mounted socket so dev user can access it regardless of host runtime (Colima, Podman, etc.) - Add volume-mounts and docker-in-docker entries to init-templates config - Add docker-cli to mise.toml for this repo's own sandbox - docs: add Docker-in-Docker setup section to README with mise.toml example - docs: update AGENTS.md to reflect docker CLI availability and clarify podman/ocs-* constraints --- AGENTS.md | 11 +++- README.md | 72 ++++++++++++++++++++- bin/ocs-rebuild-container | 47 +++++++++++++- bin/ocs-start-container | 24 +++++++ entrypoint.sh | 11 ++++ init-templates/opencode-sandbox-config.yaml | 10 +++ mise.toml | 3 +- opencode-sandbox-config.yaml | 6 ++ 8 files changed, 177 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c0da586..6aa23d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,9 @@ This project uses itself as its own sandbox — the AI agent runs **inside the o - The workspace is mounted at `/` 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 @@ -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_` 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) diff --git a/README.md b/README.md index 44866d7..a8c048f 100644 --- a/README.md +++ b/README.md @@ -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 `/` 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 @@ -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. @@ -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): @@ -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 `/`. + +--- + ## Hooks ### `opencode-sandbox-pre-start-container.sh` @@ -270,6 +339,7 @@ Each project gets its own isolated container named `opencode-sandbox- "${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]}" @@ -65,6 +93,7 @@ if [[ -f "${CONFIG_FILE}" ]]; then esac continue fi + # Map entry: " key: value" if [[ "${line}" =~ ^[[:space:]]+([^[:space:]:]+):[[:space:]]+(.+)$ ]]; then key="${BASH_REMATCH[1]}" @@ -72,14 +101,26 @@ if [[ -f "${CONFIG_FILE}" ]]; then 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 diff --git a/bin/ocs-start-container b/bin/ocs-start-container index 31937f7..9146ff9 100755 --- a/bin/ocs-start-container +++ b/bin/ocs-start-container @@ -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}" @@ -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 "" @@ -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}" diff --git a/entrypoint.sh b/entrypoint.sh index 7d107ce..7d418d3 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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) # --------------------------------------------------------------------------- diff --git a/init-templates/opencode-sandbox-config.yaml b/init-templates/opencode-sandbox-config.yaml index e928dda..5261b09 100644 --- a/init-templates/opencode-sandbox-config.yaml +++ b/init-templates/opencode-sandbox-config.yaml @@ -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 diff --git a/mise.toml b/mise.toml index decd0a0..cf1a76d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,5 @@ [tools] "github:anomalyco/opencode" = "latest" shellcheck = "latest" -github-cli = "2.91.0" \ No newline at end of file +github-cli = "2.91.0" +"aqua:docker/cli" = "latest" \ No newline at end of file diff --git a/opencode-sandbox-config.yaml b/opencode-sandbox-config.yaml index e0c39db..3d0689f 100644 --- a/opencode-sandbox-config.yaml +++ b/opencode-sandbox-config.yaml @@ -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