task: simplify overlays, sync sandbox UID to host (minimal)

Replace the host-group-membership workaround in entrypoint.sh with a
real UID/GID sync — on boot the sandbox user's UID/GID is rewritten to
match the bind-mounted repo's owner, so anything the sandbox creates
in-container is owned by the host user on the host (no chown handoff).

Delete the 11 in-tree compose overlays (claude/codex/deepagents/opencode/
pi-host.yml, ssh.yml, ssh-generate.yml, sshd.yml, git.yml, postgres.yml,
cloudflared.yml), the /cloudflared-tunnel skill, install/cloudflared-tunnel.sh,
the SANDBOX_PASSWORD plumbing, the *_HOST_BIND_MOUNT skip-clauses in
entrypoint.sh, the git-worktree resolver block, and the host-UID
overlay-stripping block in scripts/install.sh.

config.json's composeOverrides[] stays as the extension point for
downstream packs.

Doc / skill text references to deleted things are deferred to a
follow-up PR.

Closes #283

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryaneggzandClaude Opus 4.7 committed 2026-05-14 02:25:14 +00:00
1 parent 94326bc6ac
commit c6a30a8f96
18 files changed
+34 -907

No files matched your search

-189
View File
@@ -1,189 +0,0 @@
---
name: cloudflared-tunnel
description: |
Create and configure a Cloudflare tunnel: named tunnel, ingress config,
DNS routing, and optional immediate run. Idempotent — safe to re-run
on an existing tunnel.
TRIGGER when: asked to create, set up, or configure a Cloudflare tunnel,
or expose a local service via Cloudflare.
argument-hint: "<tunnel-name> <hostname:port> [<hostname:port> ...] [--run]"
---
# Cloudflared Tunnel
Create a named Cloudflare tunnel, write multi-ingress config, route DNS, and optionally start it. Supports multiple hostname:port pairs for routing several services through one tunnel.
## Instructions
### Step 1 — Parse arguments
Arguments received: `$ARGUMENTS`
- **TUNNEL_NAME**: first positional arg (required)
- **INGRESS_PAIRS**: all `hostname:port` args (at least one required)
- **RUN_AFTER**: `true` if `--run` flag is present, otherwise `false`
Supports two formats:
- **Multi-ingress**: `<tunnel-name> <hostname:port> [<hostname:port> ...] [--run]`
- Example: `myproject app.<your-domain>:3000 api.<your-domain>:3001`
- **Legacy single-ingress**: `<tunnel-name> <hostname> <port> [--run]`
- Example: `myproject app.<your-domain> 3000`
If no ingress pairs provided, suggest defaults:
- Tunnel name: derived from the project or sandbox name
- Hostname: `<tunnel-name>.<your-domain>:3000` (operator supplies domain)
### Step 2 — Check prerequisites
```bash
command -v cloudflared >/dev/null 2>&1 && echo "cloudflared: installed" || echo "cloudflared: NOT FOUND"
```
If cloudflared is not installed, **stop** and tell the user to install it via the system package manager or rebuild the sandbox with `INSTALL_CLOUDFLARED=true`.
```bash
ls ~/.cloudflared/cert.pem 2>/dev/null && echo "cert.pem: found" || echo "cert.pem: NOT FOUND"
```
If `cert.pem` is missing, tell the user they need to authenticate first:
```
Run: cloudflared login
This opens a browser to authenticate with your Cloudflare account.
```
**Do not proceed** until both prerequisites are met.
### Step 3 — Create tunnel (idempotent)
Check if the tunnel already exists:
```bash
cloudflared tunnel list 2>/dev/null | grep "$TUNNEL_NAME" || echo "NOT FOUND"
```
If the tunnel exists, extract its ID:
```bash
TUNNEL_ID=$(cloudflared tunnel list --output json | jq -r ".[] | select(.name==\"$TUNNEL_NAME\") | .id")
echo "Tunnel ID: $TUNNEL_ID"
```
If the tunnel does not exist, create it:
```bash
cloudflared tunnel create "$TUNNEL_NAME"
TUNNEL_ID=$(cloudflared tunnel list --output json | jq -r ".[] | select(.name==\"$TUNNEL_NAME\") | .id")
echo "Tunnel ID: $TUNNEL_ID"
```
### Step 3b — Reconstruct credentials JSON if missing
A tunnel created on a different host shows up in `cloudflared tunnel list` (account-scoped), but its `<tunnel-id>.json` credentials file lives only on the host that created it. Without the JSON, `cloudflared tunnel run <name>` fails to start, and `--token`-based runs silently ignore the local `--url` flag (so ingress never applies — Cloudflare returns 530s even though connections register).
Fix: rebuild the JSON from the account-level token. The token-printing CLI gives back a base64 blob with `{a: AccountTag, s: TunnelSecret, t: TunnelID}` — pipe it directly to `jq` (do **not** assign to a variable named `*TOKEN*` — `.claude/hooks/deny-env-dump.sh` blocks `echo "$VAR_TOKEN"` patterns).
```bash
CREDS_FILE="$HOME/.cloudflared/${TUNNEL_ID}.json"
if [ ! -f "$CREDS_FILE" ]; then
echo "Creds JSON missing — reconstructing from token"
cloudflared tunnel token "$TUNNEL_NAME" \
| base64 -d \
| jq '{AccountTag: .a, TunnelSecret: .s, TunnelID: .t}' \
> "$CREDS_FILE"
chmod 600 "$CREDS_FILE"
fi
```
### Step 4 — Write config (multi-ingress)
Write the ingress config to `~/.cloudflared/config-<tunnel-name>.yml`:
```bash
CREDS_FILE="$HOME/.cloudflared/${TUNNEL_ID}.json"
CONFIG_FILE="$HOME/.cloudflared/config-${TUNNEL_NAME}.yml"
# Build multi-ingress YAML
{
echo "tunnel: $TUNNEL_ID"
echo "credentials-file: $CREDS_FILE"
echo ""
echo "ingress:"
for pair in "${INGRESS_PAIRS[@]}"; do
hostname="${pair%%:*}"
port="${pair##*:}"
echo " - hostname: $hostname"
echo " service: http://0.0.0.0:$port"
done
echo " - service: http_status:404"
} > "$CONFIG_FILE"
echo "Config written: $CONFIG_FILE"
```
### Step 5 — Route DNS (all hostnames)
Route DNS for **each** hostname in the ingress:
```bash
for pair in "${INGRESS_PAIRS[@]}"; do
hostname="${pair%%:*}"
cloudflared tunnel route dns --overwrite-dns "$TUNNEL_NAME" "$hostname" 2>/dev/null && \
echo "DNS route set for $hostname" || \
echo "DNS route already correct for $hostname"
done
```
### Step 5b — Restart tunnel if already running
Config changes require a tunnel restart — cloudflared does NOT hot-reload:
```bash
pkill -f "cloudflared.*run $TUNNEL_NAME" 2>/dev/null && \
echo "Killed existing tunnel process" || true
```
### Step 6 — Report
```
Tunnel '$TUNNEL_NAME' is configured!
Tunnel ID: $TUNNEL_ID
Config: $CONFIG_FILE
Creds: $CREDS_FILE
Ingress rules:
<hostname> → 0.0.0.0:<port> (for each pair)
To run:
cloudflared tunnel --config $CONFIG_FILE run $TUNNEL_NAME
```
### Step 7 — Optionally run
If `--run` was passed, start the tunnel in background:
```bash
nohup cloudflared tunnel --config "$CONFIG_FILE" run "$TUNNEL_NAME" &>/tmp/cloudflared-${TUNNEL_NAME}.log &
echo "Tunnel running (PID: $!, log: /tmp/cloudflared-${TUNNEL_NAME}.log)"
```
### Step 8 — Verify (agent-browser)
After tunnel starts, verify each hostname is reachable. For each ingress pair, ensure the hostname resolves inside the container (add to `/etc/hosts` if needed via Cloudflare DNS-over-HTTPS), then health-check:
```bash
for pair in "${INGRESS_PAIRS[@]}"; do
hostname="${pair%%:*}"
# Ensure DNS resolves inside container
if ! getent hosts "$hostname" &>/dev/null; then
IP=$(curl -sf "https://cloudflare-dns.com/dns-query?name=${hostname}&type=A" \
-H "accept: application/dns-json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['Answer'][0]['data'])" 2>/dev/null)
[ -n "$IP" ] && echo "$IP $hostname" | sudo tee -a /etc/hosts >/dev/null
fi
STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://${hostname}/" 2>/dev/null)
echo "$hostname → $STATUS"
done
```
Report pass/fail for each hostname. If any returns 000 or 502, check `/tmp/cloudflared-${TUNNEL_NAME}.log` for errors.
-5
View File
@@ -16,11 +16,6 @@
# Example: openharness shell $SANDBOX_NAME
SANDBOX_NAME=oh-local
# Password for the sandbox Linux user. ONLY honored when the sshd overlay
# is active (docker-compose.sshd.yml). Without that overlay, this value
# is unused — the default is fine.
SANDBOX_PASSWORD=changeme
# ─── Timezone ───────────────────────────────────────────────────────
# Sets the container's timezone. Affects cron schedules, log timestamps,
+1 -1
View File
@@ -4,7 +4,7 @@ ENV TZ=America/Denver
# ─── System packages ────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server build-essential ca-certificates curl wget sudo gosu git jq gnupg \
openssh-client build-essential ca-certificates curl wget sudo gosu git jq gnupg \
lsb-release nano ripgrep tmux unzip bash-completion procps less \
iproute2 zsh \
&& rm -rf /var/lib/apt/lists/*
@@ -1,54 +0,0 @@
# Claude Host Auth Mount Overlay
# ================================
# Purpose: Bind-mount the host's ~/.claude directory AND ~/.claude.json
# file into the sandbox, so the sandboxed claude CLI inherits host auth
# tokens, memory, MCP config, project state, theme, and onboarding state.
# Developers running multiple sandboxes no longer re-onboard or reselect
# text style.
#
# Two mounts, two concerns:
# - ~/.claude/ (dir) — credentials, memory, projects, sessions
# - ~/.claude.json (file) — theme, numStartups, tip history,
# per-project trust, MCP server list
#
# Pre-flight (BOTH must hold or boot will misbehave):
# 1. $HOST_CLAUDE_DIR (default ~/.claude) must exist as a directory
# 2. $HOST_CLAUDE_JSON (default ~/.claude.json) must exist as a file
# If either target is missing, Docker auto-creates it as a directory
# owned by root — `claude` then fails to parse JSON or read credentials.
#
# Requirements: Host UID MUST equal the sandbox UID (1000). The
# ~/.claude/.credentials.json file is mode 0600 (owner-only), so the
# group-membership trick used elsewhere in entrypoint.sh does NOT grant
# access. If host UID ≠ 1000, `claude` will fail to read credentials
# despite the bind-mount succeeding.
#
# Security tradeoff: this mounts OAuth credentials and memory into a
# container that may execute untrusted agent code. Enable only for trusted
# workflows. The overlay is opt-in (not default).
#
# Bleed-through: ~/.claude/projects/ sessions and ~/.claude.json entries
# (per-project trust, MCP config, tip counters) from other host work will
# be visible inside the sandbox. Acknowledged cost of full-mount convenience.
#
# Permissions: Read-write (sandbox writes host memory/projects/tip counters
# live). Replaces: claude-auth named volume from base docker-compose.yml at
# the /home/sandbox/.claude target path. The named volume declaration stays
# in the base compose — it becomes orphaned (unmounted) while this overlay
# is active and is reinstated when the overlay is removed.
#
# Entrypoint coordination: sets CLAUDE_HOST_BIND_MOUNT=1 so entrypoint.sh
# skips its recursive chown on /home/sandbox/.claude (which would otherwise
# rewrite host file ownership). The chown loop only matches directories,
# so ~/.claude.json (a file) is untouched regardless.
#
# Usage: enable via config.json composeOverrides[]:
# ".devcontainer/docker-compose.claude-host.yml"
services:
sandbox:
volumes:
- ${HOST_CLAUDE_DIR:-~/.claude}:/home/sandbox/.claude
- ${HOST_CLAUDE_JSON:-~/.claude.json}:/home/sandbox/.claude.json
environment:
- CLAUDE_HOST_BIND_MOUNT=1
@@ -1,15 +0,0 @@
# Cloudflare Tunnel + Browser Overlay
# ====================================
# Purpose: Install Cloudflared (tunnel CLI) and headless Chromium browser.
# Use case: Expose sandbox services to the internet via Cloudflare tunnels; enable browser automation
# Provides environment vars:
# - INSTALL_CLOUDFLARED=true: Installs cloudflared during entrypoint (binary at /usr/local/bin/cloudflared)
# - INSTALL_BROWSER=true: Installs Chromium and browser automation tools (agent-browser)
# Usage: docker compose -f docker-compose.yml -f docker-compose.cloudflared.yml up
# Dependencies: Requires .devcontainer/install/cloudflared.sh and browser installation scripts
services:
sandbox:
environment:
- INSTALL_CLOUDFLARED=true
- INSTALL_BROWSER=true
@@ -1,54 +0,0 @@
# Codex Host Auth Mount Overlay
# ==============================
# Purpose: Bind-mount the host's ~/.codex directory into the sandbox, so
# the sandboxed codex CLI inherits host OAuth tokens, session history,
# memories, and config. Developers running multiple sandboxes no longer
# re-authenticate codex per sandbox.
#
# One mount, one concern:
# - ~/.codex/ (dir) — auth.json (OAuth tokens), config.toml, sessions,
# memories, history, rules, plugins, caches
#
# Unlike claude, codex has no sidecar ~/.codex.json file — all state lives
# under the directory.
#
# Pre-flight:
# $HOST_CODEX_DIR (default ~/.codex) must exist as a directory.
# If the target is missing, Docker auto-creates it as a directory owned
# by root — `codex` then fails to read/write auth.json.
#
# Requirements: Host UID MUST equal the sandbox UID (1000). The
# ~/.codex/auth.json file is mode 0600 (owner-only), so the
# group-membership trick used elsewhere in entrypoint.sh does NOT grant
# access. If host UID ≠ 1000, `codex` will fail to read credentials
# despite the bind-mount succeeding.
#
# Security tradeoff: this mounts OAuth credentials and session history
# into a container that may execute untrusted agent code. Enable only for
# trusted workflows. OAuth login is the expected default auth path for
# codex in this harness, so the overlay is enabled by default in
# config.json.
#
# Bleed-through: ~/.codex/sessions/, history.jsonl, and memories/ from
# other host work will be visible inside the sandbox. Acknowledged cost
# of full-mount convenience.
#
# Permissions: Read-write (sandbox writes host sessions/memories live).
# Replaces: codex-auth named volume from base docker-compose.yml at the
# /home/sandbox/.codex target path. The named volume declaration stays
# in the base compose — it becomes orphaned (unmounted) while this
# overlay is active and is reinstated when the overlay is removed.
#
# Entrypoint coordination: sets CODEX_HOST_BIND_MOUNT=1 so entrypoint.sh
# skips its recursive chown on /home/sandbox/.codex (which would otherwise
# rewrite host file ownership).
#
# Usage: enable via config.json composeOverrides[]:
# ".devcontainer/docker-compose.codex-host.yml"
services:
sandbox:
volumes:
- ${HOST_CODEX_DIR:-~/.codex}:/home/sandbox/.codex
environment:
- CODEX_HOST_BIND_MOUNT=1
@@ -1,68 +0,0 @@
# DeepAgents Host State Mount Overlay
# =====================================
# Purpose: Bind-mount the host's ~/.deepagents directory into the sandbox,
# so the sandboxed `deepagents` CLI inherits host provider keys, model
# config, memory, skills, and sessions. Trusted local users running
# multiple sandboxes can reuse a single source of DeepAgents state instead
# of re-pasting provider keys into each sandbox's named volume.
#
# One mount, one concern:
# - ~/.deepagents/ (dir) — .env (provider keys), config.toml (model/CLI
# defaults), memory, skills, sessions
#
# Pre-flight:
# $HOST_DEEPAGENTS_DIR (default ~/.deepagents) must exist as a directory
# on the host. If the target is missing, Docker auto-creates it as a
# directory owned by root — `deepagents` then fails to read/write its
# provider env file. `scripts/install.sh` pre-creates `~/.deepagents`
# alongside `~/.claude`, `~/.codex`, and `~/.pi` so this is satisfied
# by default for installer-driven setups.
#
# Requirements: Host UID MUST equal the sandbox UID (1000). Provider key
# files under `~/.deepagents/.env` are typically mode 0600 (owner-only),
# so the group-membership trick used elsewhere in entrypoint.sh does NOT
# grant access. If host UID ≠ 1000, `deepagents` will fail to read the
# provider env file despite the bind-mount succeeding;
# `scripts/install.sh` strips this overlay from `composeOverrides` when
# the host UID is not 1000 so non-UID-1000 hosts fall back to the
# `deepagents-auth` named volume cleanly.
#
# Security tradeoff: this mounts provider API keys, agent memory, and
# session history into a container that may execute untrusted agent code
# AND has the host Docker socket bind-mounted by default. With
# `--shell-allow-list all` (operator opt-in via `RALPH_DEEPAGENTS_FLAGS`),
# DeepAgents could shell out and reach sibling containers or the host
# Docker daemon. Enable the host overlay only for trusted workflows.
# Provider keys are NOT OAuth tokens — they typically have no expiry and
# no per-request scope, so a leak from this overlay has a wider blast
# radius than `claude-host` or `codex-host`. The overlay is opt-in: it is
# NOT listed in the default `config.json` `composeOverrides`.
#
# Bleed-through: ~/.deepagents/sessions/, memory, and skills from other
# host work will be visible inside the sandbox. Acknowledged cost of
# full-mount convenience.
#
# Permissions: Read-write (sandbox writes host sessions/memory live).
# Replaces: deepagents-auth named volume from base docker-compose.yml at
# the /home/sandbox/.deepagents target path. The named volume declaration
# stays in the base compose — it becomes orphaned (unmounted) while this
# overlay is active and is reinstated when the overlay is removed. To
# return to the named volume, remove this overlay from
# `config.json` `composeOverrides` and rerun `make sandbox`
# (or `make destroy && make sandbox` if the container is already up).
# Resetting the `deepagents-auth` named volume only (without touching
# host `~/.deepagents`) is `docker volume rm <project>_deepagents-auth`.
#
# Entrypoint coordination: sets DEEPAGENTS_HOST_BIND_MOUNT=1 so
# entrypoint.sh skips its recursive chown on /home/sandbox/.deepagents
# (which would otherwise rewrite host file ownership).
#
# Usage: enable via config.json composeOverrides[]:
# ".devcontainer/docker-compose.deepagents-host.yml"
services:
sandbox:
volumes:
- ${HOST_DEEPAGENTS_DIR:-~/.deepagents}:/home/sandbox/.deepagents
environment:
- DEEPAGENTS_HOST_BIND_MOUNT=1
-11
View File
@@ -1,11 +0,0 @@
# Git Integration Overlay
# ========================
# Purpose: Mount the host's .git directory into the sandbox for multi-repo git operations.
# Requirements: GIT_COMMON_DIR env var MUST be set to the main repo's .git/ path (e.g. /path/to/repo/.git)
# Usage: docker compose -f docker-compose.yml -f docker-compose.git.yml up
# Provides: Sandbox can access the main repo's git history and push/pull with host credentials
services:
sandbox:
volumes:
- ${GIT_COMMON_DIR:?Set GIT_COMMON_DIR to the main repo .git/ path}:/home/sandbox/.git-main
@@ -1,47 +0,0 @@
# OpenCode Host Auth Mount Overlay
# =================================
# Purpose: Bind-mount the host's ~/.local/share/opencode directory into
# the sandbox, so the sandboxed `opencode` CLI inherits host provider
# auth tokens and session state. Developers running multiple sandboxes no
# longer re-authenticate OpenCode per sandbox.
#
# One mount, one concern:
# - ~/.local/share/opencode/ (dir) — auth.json (provider OAuth/API-key
# credentials), sessions, app state
#
# Pre-flight:
# $HOST_OPENCODE_DIR (default ~/.local/share/opencode) must exist as a
# directory. If the target is missing, Docker auto-creates it as a
# directory owned by root — `opencode` then fails to read/write auth.json.
#
# Requirements: Host UID MUST equal the sandbox UID (1000). Provider auth
# files may be owner-only, so the group-membership trick used elsewhere
# in entrypoint.sh does NOT grant access. If host UID != 1000, `opencode`
# may fail to read credentials despite the bind-mount succeeding.
#
# Security tradeoff: this mounts provider credentials and session history
# into a container that may execute untrusted agent code. Enable only for
# trusted workflows. OpenAI OAuth through OpenCode's /connect flow is the
# expected default auth path for opencode in this harness, so the overlay
# is enabled by default in config.json.
#
# Permissions: Read-write (sandbox writes host sessions/provider state).
# Replaces: opencode-auth named volume from base docker-compose.yml at the
# /home/sandbox/.local/share/opencode target path. The named volume
# declaration stays in the base compose — it becomes orphaned (unmounted)
# while this overlay is active and is reinstated when the overlay is
# removed.
#
# Entrypoint coordination: sets OPENCODE_HOST_BIND_MOUNT=1 so
# entrypoint.sh skips recursive chown on /home/sandbox/.local/share/opencode
# (which would otherwise rewrite host file ownership).
#
# Usage: enable via config.json composeOverrides[]:
# ".devcontainer/docker-compose.opencode-host.yml"
services:
sandbox:
volumes:
- ${HOST_OPENCODE_DIR:-~/.local/share/opencode}:/home/sandbox/.local/share/opencode
environment:
- OPENCODE_HOST_BIND_MOUNT=1
-49
View File
@@ -1,49 +0,0 @@
# Pi Host Auth Mount Overlay
# ============================
# Purpose: Bind-mount the host's ~/.pi directory into the sandbox, so the
# sandboxed `pi` CLI inherits host OAuth tokens, agent identities, and
# session state. Developers running multiple sandboxes no longer
# re-authenticate Pi Agent per sandbox.
#
# One mount, one concern:
# - ~/.pi/ (dir) — agent/auth.json (OAuth tokens), identities, sessions
#
# Pre-flight:
# $HOST_PI_DIR (default ~/.pi) must exist as a directory.
# If the target is missing, Docker auto-creates it as a directory owned
# by root — `pi` then fails to read/write auth.json.
#
# Requirements: Host UID MUST equal the sandbox UID (1000). Pi's
# auth.json files are mode 0600 (owner-only), so the group-membership
# trick used elsewhere in entrypoint.sh does NOT grant access. If host
# UID ≠ 1000, `pi` will fail to read credentials despite the bind-mount
# succeeding.
#
# Security tradeoff: this mounts OAuth credentials and session history
# into a container that may execute untrusted agent code. Enable only
# for trusted workflows. OAuth login is the expected default auth path
# for pi in this harness, so the overlay is enabled by default in
# config.json.
#
# Bleed-through: sessions and identities from other host work will be
# visible inside the sandbox. Acknowledged cost of full-mount convenience.
#
# Permissions: Read-write (sandbox writes host sessions live).
# Replaces: pi-auth named volume from base docker-compose.yml at the
# /home/sandbox/.pi target path. The named volume declaration stays in
# the base compose — it becomes orphaned (unmounted) while this overlay
# is active and is reinstated when the overlay is removed.
#
# Entrypoint coordination: sets PI_HOST_BIND_MOUNT=1 so entrypoint.sh
# skips its recursive chown on /home/sandbox/.pi (which would otherwise
# rewrite host file ownership).
#
# Usage: enable via config.json composeOverrides[]:
# ".devcontainer/docker-compose.pi-host.yml"
services:
sandbox:
volumes:
- ${HOST_PI_DIR:-~/.pi}:/home/sandbox/.pi
environment:
- PI_HOST_BIND_MOUNT=1
-49
View File
@@ -1,49 +0,0 @@
# PostgreSQL Service Overlay
# ===========================
# Purpose: Run PostgreSQL 16 as a sibling service for sandbox database operations.
# Default credentials: user=sandbox, password=sandbox, database=sandbox
# Network: Sandbox and postgres share isolated network (devnet) for inter-service communication
# Storage: pgdata volume persists database across container restarts
# Health: Includes healthcheck; sandbox waits for postgres to be ready before starting
# Environment vars injected into sandbox: DATABASE_URL, PGHOST, PGUSER, PGPASSWORD, PGDATABASE
# Usage: docker compose -f docker-compose.yml -f docker-compose.postgres.yml up
# Connection: psql postgresql://sandbox:sandbox@postgres:5432/sandbox (from inside sandbox)
services:
postgres:
image: postgres:16-alpine
container_name: ${SANDBOX_NAME:-openharness}-postgres
environment:
POSTGRES_USER: sandbox
POSTGRES_PASSWORD: sandbox
POSTGRES_DB: sandbox
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sandbox"]
interval: 5s
timeout: 5s
retries: 5
networks:
- devnet
sandbox:
depends_on:
postgres:
condition: service_healthy
environment:
- DATABASE_URL=postgresql://sandbox:sandbox@postgres:5432/sandbox
- PGHOST=postgres
- PGUSER=sandbox
- PGPASSWORD=sandbox
- PGDATABASE=sandbox
networks:
- devnet
networks:
devnet:
name: ${SANDBOX_NAME:-openharness}-devnet
driver: bridge
volumes:
pgdata:
@@ -1,15 +0,0 @@
# SSH Generation Overlay
# =======================
# Purpose: Create and persist isolated SSH keys in the sandbox volume (isolated from host).
# Use case: When sandbox needs its own keypair (CI systems, deployment keys, container-local auth)
# vs. docker-compose.ssh.yml: Mount existing host SSH keys into sandbox
# Usage: docker compose -f docker-compose.yml -f docker-compose.ssh-generate.yml up
# Provides: Persistent ssh-keys volume; entrypoint generates keys if missing on first run
services:
sandbox:
volumes:
- ssh-keys:/home/sandbox/.ssh
volumes:
ssh-keys:
-12
View File
@@ -1,12 +0,0 @@
# SSH Key Mount Overlay
# ======================
# Purpose: Mount the host's SSH keys into sandbox for git operations, deployments, and remote access.
# Requirements: HOST_SSH_DIR env var (defaults to ~/.ssh)
# Permissions: Read-only (:ro) — sandbox cannot modify host SSH keys
# Usage: docker compose -f docker-compose.yml -f docker-compose.ssh.yml up
# Provides: Sandbox can use host SSH identity for authenticated git push/pull and remote connections
services:
sandbox:
volumes:
- ${HOST_SSH_DIR:-~/.ssh}:/home/sandbox/.ssh:ro
-14
View File
@@ -1,14 +0,0 @@
# SSH Server Overlay
# ===================
# Purpose: Run sshd as the main process, replacing the sleep-infinity default.
# Enables: Remote SSH access to the sandbox container (host port 2222 → container port 22)
# Auth: Entrypoint auto-configures password auth (SANDBOX_PASSWORD env var) and host keys on startup
# Requirements: Add to config.json composeOverrides to activate
# Usage: docker compose -f docker-compose.yml -f docker-compose.sshd.yml up
# Provides: SSH access at ssh -p 2222 sandbox@localhost with password from SANDBOX_PASSWORD
services:
sandbox:
ports:
- "2222:22"
command: /usr/sbin/sshd -D
+8 -35
View File
@@ -1,40 +1,14 @@
# Main Sandbox Docker Compose Configuration
# ===========================================
# Purpose: Define the base sandbox container and core mounts/environment.
# This is the primary compose file; all optional features are separate overlay files.
# Base sandbox compose. The base ships with zero in-tree overlays;
# agent auth and tool state persist in named volumes managed by Docker
# (claude-auth, codex-auth, pi-auth, opencode-auth, deepagents-auth,
# cloudflared-auth, gh-config), and the bind-mounted repo's UID is
# synced into the sandbox user by entrypoint.sh on first boot.
#
# Core volumes:
# - ..:/home/sandbox/harness — Project root (bind-mount)
# - /var/run/docker.sock:/var/run/docker.sock — Docker daemon access (nested docker)
# - claude-auth:/home/sandbox/.claude — Persistent Claude CLI auth
# - codex-auth:/home/sandbox/.codex — Persistent Codex CLI auth
# - pi-auth:/home/sandbox/.pi — Persistent Pi Agent (oh/pi CLI) auth
# - opencode-auth:/home/sandbox/.local/share/opencode — Persistent OpenCode auth
# - deepagents-auth:/home/sandbox/.deepagents — Persistent DeepAgents CLI state
# (provider keys, model config, memory, skills, sessions). v1 persists
# ONLY this path; repo-local .deepagents/ under the bind-mounted project
# root is project data and follows normal git review/ignore rules — do
# not place secrets there.
# - cloudflared-auth:/home/sandbox/.cloudflared — Cloudflare credentials
# - gh-config:/home/sandbox/.config/gh — GitHub CLI config
#
# Core env vars (see config.json for overlay toggles):
# - TZ: Timezone (default America/Denver)
# - GIT_USER_NAME, GIT_USER_EMAIL: Git commit author
# - GH_TOKEN: GitHub token for `gh auth` automation
# - INSTALL_AGENT_BROWSER: Set to "true" to install Chromium (opt-in)
# - SANDBOX_PASSWORD: SSH password if using docker-compose.sshd.yml
#
# Entrypoint: /usr/local/bin/entrypoint.sh — Initializes git config, installs tools, runs onboarding
# Default command: sleep infinity — Container stays alive for manual interaction
#
# Usage:
# Basic: docker compose -f .devcontainer/docker-compose.yml up -d
# With overlays: docker compose -f .devcontainer/docker-compose.yml \
# -f .devcontainer/docker-compose.postgres.yml \
# -f .devcontainer/docker-compose.cloudflared.yml up -d
#
# Overlays in config.json composeOverrides are auto-loaded by `make sandbox` and `scripts/install.sh` via jq.
# Downstream harness packs and Pi extensions can register additional
# overlays by appending paths to composeOverrides[] in config.json;
# `make sandbox` and `scripts/install.sh` pick those up via jq.
name: ${SANDBOX_NAME:-openharness}
@@ -61,7 +35,6 @@ services:
- SANDBOX_NAME=${SANDBOX_NAME:-openharness}
- TZ=${TZ:-America/Denver}
- CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=true
- SANDBOX_PASSWORD=${SANDBOX_PASSWORD:-changeme}
- GIT_USER_NAME=${GIT_USER_NAME:-}
- GIT_USER_EMAIL=${GIT_USER_EMAIL:-}
- GH_TOKEN=${GH_TOKEN:-}
+21 -71
View File
@@ -13,25 +13,8 @@ if [ -S "$SOCK" ]; then
fi
# Fix ownership of mounted volumes (created as root by Docker).
# Skip .claude / .codex / .pi / .deepagents / opencode when their host-mount
# overlays are active — they're host bind-mounts and chown would rewrite
# host file ownership (see docker-compose.{claude,codex,pi,deepagents,opencode}-host.yml).
# Harness packs that introduce their own host-mount overlays should add
# their own skip clauses or run a *-entrypoint-hook.sh.
for dir in .claude .codex .pi .deepagents .cloudflared .config/gh .ssh; do
if [ -d "/home/sandbox/$dir" ]; then
if [ "$dir" = ".claude" ] && [ "${CLAUDE_HOST_BIND_MOUNT:-0}" = "1" ]; then
continue
fi
if [ "$dir" = ".codex" ] && [ "${CODEX_HOST_BIND_MOUNT:-0}" = "1" ]; then
continue
fi
if [ "$dir" = ".pi" ] && [ "${PI_HOST_BIND_MOUNT:-0}" = "1" ]; then
continue
fi
if [ "$dir" = ".deepagents" ] && [ "${DEEPAGENTS_HOST_BIND_MOUNT:-0}" = "1" ]; then
continue
fi
chown -R sandbox:sandbox "/home/sandbox/$dir" 2>/dev/null || true
[ "$dir" = ".ssh" ] && chmod 700 "/home/sandbox/$dir" 2>/dev/null || true
fi
@@ -56,36 +39,42 @@ done
OPENCODE_STATE="/home/sandbox/.local/share/opencode"
if [ -d "$OPENCODE_STATE" ]; then
if [ "${OPENCODE_HOST_BIND_MOUNT:-0}" != "1" ]; then
chown -R sandbox:sandbox "$OPENCODE_STATE" 2>/dev/null || true
fi
fi
# ─── Host UID reconciliation ────────────────────────────────────────
# The repo is bind-mounted at /home/sandbox/harness with its host UID/GID.
# If the host user's UID differs from the container sandbox user (1000),
# pnpm / git operations would fail with EACCES. Add sandbox to the host
# owning group so it can write via group permissions (repo is mode 775).
# To avoid permission drift between host and container (host edits files
# created in-container and vice versa; git authorship stays consistent),
# we sync the sandbox user's UID/GID to the host owner. After the sync,
# anything sandbox creates inside the container is owned by the host
# user on the host filesystem — no chown handoff needed.
HARNESS_DIR="/home/sandbox/harness"
if [ -d "$HARNESS_DIR" ]; then
HOST_UID=$(stat -c '%u' "$HARNESS_DIR")
HOST_GID=$(stat -c '%g' "$HARNESS_DIR")
SANDBOX_UID=$(id -u sandbox)
SANDBOX_GID=$(id -g sandbox)
if [ "$HOST_GID" != "$SANDBOX_GID" ]; then
if getent group "$HOST_GID" >/dev/null 2>&1; then
EXISTING_GROUP=$(getent group "$HOST_GID" | cut -d: -f1)
if [ "$EXISTING_GROUP" != "sandbox" ]; then
usermod -g "$HOST_GID" sandbox 2>/dev/null || true
fi
else
groupmod -g "$HOST_GID" sandbox 2>/dev/null || true
fi
fi
if [ "$HOST_UID" != "$SANDBOX_UID" ]; then
if ! getent group "$HOST_GID" >/dev/null 2>&1; then
groupadd -g "$HOST_GID" hostuser 2>/dev/null || true
fi
HOST_GROUP=$(getent group "$HOST_GID" | cut -d: -f1)
if [ -n "$HOST_GROUP" ] && ! id -nG sandbox | tr ' ' '\n' | grep -qx "$HOST_GROUP"; then
usermod -aG "$HOST_GROUP" sandbox
echo "[entrypoint] sandbox added to host group $HOST_GROUP (gid=$HOST_GID) for bind-mount write access"
fi
usermod -u "$HOST_UID" sandbox 2>/dev/null || true
find /home/sandbox -xdev -uid "$SANDBOX_UID" -exec chown -h "$HOST_UID:$HOST_GID" {} + 2>/dev/null || true
echo "[entrypoint] sandbox UID synced to host ($SANDBOX_UID → $HOST_UID, $SANDBOX_GID → $HOST_GID)"
fi
fi
# ─── Attach banner wiring (idempotent) ──────────────────────────────
# Source install/banner.sh from the sandbox user's .bashrc so every
# interactive shell (docker exec, SSH via sshd overlay, VS Code) shows
# interactive shell (docker exec, VS Code) shows
# sandbox + onboarding status. Safe to run on every boot.
BASHRC="/home/sandbox/.bashrc"
if [ -f "$BASHRC" ] && ! grep -q 'source.*install/banner.sh' "$BASHRC"; then
@@ -93,28 +82,7 @@ if [ -f "$BASHRC" ] && ! grep -q 'source.*install/banner.sh' "$BASHRC"; then
echo "[entrypoint] attach banner wired into .bashrc"
fi
# ─── Git worktree resolution ────────────────────────────────────────
# When the sandbox runs from a git worktree, .git is a file (not a dir)
# pointing to the main repo's .git/worktrees/<name>. The main .git/ is
# outside the bind mount, so we mount it separately at /home/sandbox/.git-main
# (via docker-compose.git.yml) and rewrite the .git file to resolve inside
# the container.
HARNESS="/home/sandbox/harness"
GIT_MAIN="/home/sandbox/.git-main"
if [ -f "$HARNESS/.git" ] && [ -d "$GIT_MAIN" ]; then
WORKTREE_NAME=$(sed -n 's|.*worktrees/||p' "$HARNESS/.git")
if [ -n "$WORKTREE_NAME" ] && [ -d "$GIT_MAIN/worktrees/$WORKTREE_NAME" ]; then
echo "gitdir: $GIT_MAIN/worktrees/$WORKTREE_NAME" > "$HARNESS/.git"
chown sandbox:sandbox "$HARNESS/.git"
chown -R sandbox:sandbox "$GIT_MAIN" 2>/dev/null || true
gosu sandbox git config --global --add safe.directory "$HARNESS"
echo "[entrypoint] git worktree resolved → $GIT_MAIN/worktrees/$WORKTREE_NAME"
fi
elif [ -d "$HARNESS/.git" ]; then
# Regular repo (not a worktree) — just fix ownership
chown -R sandbox:sandbox "$HARNESS/.git" 2>/dev/null || true
fi
# ─── GitHub CLI auth via PAT (optional) ─────────────────────────────
# When GH_TOKEN is provided, persist it into ~/.config/gh/hosts.yml on
@@ -200,24 +168,6 @@ if [ -n "${GH_TOKEN:-}" ] && gosu sandbox env -u GH_TOKEN -u GITHUB_TOKEN gh aut
fi
fi
# ─── SSH server setup (only when sshd overlay is active) ──────────
if echo "$@" | grep -q sshd; then
# Set password for SSH login from env var
echo "sandbox:${SANDBOX_PASSWORD:-changeme}" | chpasswd 2>/dev/null || true
# Configure sshd for password + environment auth
mkdir -p /run/sshd
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config 2>/dev/null || true
sed -i 's/#PermitUserEnvironment no/PermitUserEnvironment yes/' /etc/ssh/sshd_config 2>/dev/null || true
# Generate SSH host keys if missing
if [ ! -f /etc/ssh/ssh_host_rsa_key ]; then
ssh-keygen -A
fi
# Generate SSH keypair if none exists
if [ -d "/home/sandbox/.ssh" ] && [ ! -f "/home/sandbox/.ssh/id_ed25519" ]; then
gosu sandbox ssh-keygen -t ed25519 -f /home/sandbox/.ssh/id_ed25519 -N "" -C "sandbox@$(hostname)" 2>/dev/null || true
fi
fi
# ─── pnpm install at harness root ──────────────────────────────────
# Root package.json declares deps that aren't bundled into Pi or any
# global CLI: `croner` for scripts/cron-runtime.ts, plus the four sibling
@@ -247,7 +197,7 @@ fi
# inside the system-cron tmux session; logs tee to /tmp/system-cron.log.
CRONS_DIR="$HARNESS/crons"
mkdir -p "$CRONS_DIR"
chown -R sandbox:sandbox "$CRONS_DIR" 2>/dev/null || true
# Bind-mounted; sandbox UID is synced to host UID above, so no chown.
if [ -f "$HARNESS/scripts/cron-runtime.ts" ] && command -v tmux &>/dev/null; then
if ! gosu sandbox tmux has-session -t system-cron 2>/dev/null; then
gosu sandbox tmux new-session -d -s system-cron \
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# ─── Cloudflared Tunnel Setup ──────────────────────────────────────
# Creates a named tunnel, configures ingress, and routes DNS.
#
# Prerequisites:
# - cloudflared installed (system package manager or INSTALL_CLOUDFLARED=true sandbox rebuild)
# - User authenticated: cloudflared login (opens browser, saves cert.pem)
#
# Usage:
# Single ingress:
# cloudflared-tunnel.sh <tunnel-name> <hostname> <local-port> [--run]
#
# Multi-ingress:
# cloudflared-tunnel.sh <tunnel-name> <hostname:port> [<hostname:port> ...] [--run]
#
# Examples (replace <your-domain> with your own Cloudflare-managed domain):
# cloudflared-tunnel.sh myproject app.<your-domain> 3000
# cloudflared-tunnel.sh myproject app.<your-domain>:3000 api.<your-domain>:3001
# cloudflared-tunnel.sh myproject app.<your-domain>:3000 api.<your-domain>:3001 --run
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m'
banner() { printf "\n${CYAN}==> %s${NC}\n" "$*"; }
ok() { printf "${GREEN} ✓ %s${NC}\n" "$*"; }
die() { printf "${RED}ERROR: %s${NC}\n" "$*" >&2; exit 1; }
TUNNEL_NAME="${1:?Usage: cloudflared-tunnel.sh <tunnel-name> <hostname:port> [<hostname:port> ...] [--run]}"
shift
# ─── Parse arguments: collect hostname:port pairs, detect --run ──
declare -a INGRESS_PAIRS=()
RUN_AFTER=false
for arg in "$@"; do
if [[ "$arg" == "--run" ]]; then
RUN_AFTER=true
elif [[ "$arg" == *:* ]]; then
INGRESS_PAIRS+=("$arg")
else
# Legacy single-ingress mode: <hostname> <port>
# Next arg should be the port
if [[ ${#INGRESS_PAIRS[@]} -eq 0 && -z "${_LEGACY_HOST:-}" ]]; then
_LEGACY_HOST="$arg"
elif [[ -n "${_LEGACY_HOST:-}" ]]; then
INGRESS_PAIRS+=("${_LEGACY_HOST}:${arg}")
unset _LEGACY_HOST
fi
fi
done
[[ ${#INGRESS_PAIRS[@]} -eq 0 ]] && die "No hostname:port pairs provided. Usage: cloudflared-tunnel.sh <tunnel-name> <hostname:port> [...]"
CFLARED_DIR="$HOME/.cloudflared"
# ─── Check prerequisites ─────────────────────────────────────────
command -v cloudflared >/dev/null 2>&1 || die "cloudflared is not installed. Install it via your system package manager or rebuild the sandbox with INSTALL_CLOUDFLARED=true."
if [ ! -f "$CFLARED_DIR/cert.pem" ]; then
banner "Authenticating with Cloudflare"
printf " Run: cloudflared login\n"
printf " This opens a browser to authenticate with your Cloudflare account.\n"
cloudflared login
fi
[ -f "$CFLARED_DIR/cert.pem" ] || die "No cert.pem found after login. Authentication may have failed."
# ─── Create tunnel (idempotent) ───────────────────────────────────
banner "Creating tunnel: $TUNNEL_NAME"
if cloudflared tunnel list | grep -q "$TUNNEL_NAME"; then
ok "Tunnel '$TUNNEL_NAME' already exists"
TUNNEL_ID=$(cloudflared tunnel list --output json | jq -r ".[] | select(.name==\"$TUNNEL_NAME\") | .id")
else
cloudflared tunnel create "$TUNNEL_NAME"
TUNNEL_ID=$(cloudflared tunnel list --output json | jq -r ".[] | select(.name==\"$TUNNEL_NAME\") | .id")
ok "Tunnel '$TUNNEL_NAME' created (ID: $TUNNEL_ID)"
fi
# ─── Write config (multi-ingress) ────────────────────────────────
CREDS_FILE="$CFLARED_DIR/${TUNNEL_ID}.json"
CONFIG_FILE="$CFLARED_DIR/config-${TUNNEL_NAME}.yml"
banner "Writing config: $CONFIG_FILE (${#INGRESS_PAIRS[@]} ingress rules)"
{
printf "tunnel: %s\n" "$TUNNEL_ID"
printf "credentials-file: %s\n" "$CREDS_FILE"
printf "\ningress:\n"
for pair in "${INGRESS_PAIRS[@]}"; do
hostname="${pair%%:*}"
port="${pair##*:}"
printf " - hostname: %s\n service: http://0.0.0.0:%s\n" "$hostname" "$port"
done
printf " - service: http_status:404\n"
} > "$CONFIG_FILE"
ok "Config written"
# ─── Route DNS ───────────────────────────────────────────────────
for pair in "${INGRESS_PAIRS[@]}"; do
hostname="${pair%%:*}"
banner "Routing DNS: $hostname -> tunnel $TUNNEL_NAME"
cloudflared tunnel route dns --overwrite-dns "$TUNNEL_NAME" "$hostname" 2>/dev/null && \
ok "DNS route set for $hostname" || \
ok "DNS route already correct for $hostname"
done
# ─── Summary ─────────────────────────────────────────────────────
printf "\n${GREEN}Tunnel '$TUNNEL_NAME' is configured!${NC}\n"
printf "\n"
printf " ${CYAN}Tunnel ID${NC}: $TUNNEL_ID\n"
printf " ${CYAN}Config${NC}: $CONFIG_FILE\n"
printf " ${CYAN}Creds${NC}: $CREDS_FILE\n"
printf "\n"
printf " ${CYAN}Ingress rules${NC}:\n"
for pair in "${INGRESS_PAIRS[@]}"; do
hostname="${pair%%:*}"
port="${pair##*:}"
printf " %s → localhost:%s\n" "$hostname" "$port"
done
printf "\n"
printf " ${CYAN}To run${NC}:\n"
printf " cloudflared tunnel --config $CONFIG_FILE run $TUNNEL_NAME\n"
printf "\n"
# ─── Optionally run ──────────────────────────────────────────────
if [[ "$RUN_AFTER" == true ]]; then
banner "Starting tunnel: $TUNNEL_NAME"
exec cloudflared tunnel --config "$CONFIG_FILE" run "$TUNNEL_NAME"
fi
+4 -88
View File
@@ -105,7 +105,6 @@ Env vars:
OH_INSTALL_REF Git ref (tag/SHA) to clone instead of main
OH_ASSUME_YES Set to 1 for --yes
SANDBOX_NAME Skip the "Container name" prompt
SANDBOX_PASSWORD Skip the credential prompt
Examples:
curl -fsSL https://oh.mifune.dev/install.sh | bash
@@ -269,10 +268,6 @@ DEFAULT_NAME=$(basename "$REPO_DIR")
prompt_input SANDBOX_NAME "Container name" "$DEFAULT_NAME"
ok "Name: $SANDBOX_NAME"
# SANDBOX_PASSWORD is only consumed by the opt-in sshd overlay; use a
# silent default rather than prompting every install.
SANDBOX_PASSWORD="${SANDBOX_PASSWORD:-changeme}"
# Single-quoting handles names containing shell metacharacters; literal
# single quotes are escaped.
mkdir -p "$REPO_DIR/.devcontainer"
@@ -285,7 +280,6 @@ else
__GIT_NAME="$(git config --get user.name 2>/dev/null || true)"
__GIT_EMAIL="$(git config --get user.email 2>/dev/null || true)"
__SN_ESC="${SANDBOX_NAME//\'/\'\\\'\'}"
__SP_ESC="${SANDBOX_PASSWORD//\'/\'\\\'\'}"
cat > "$REPO_DIR/.devcontainer/.env" <<ENVEOF
# ─── Open Harness — Generated environment ─────────────────────────────────
@@ -295,9 +289,6 @@ else
# ─── Sandbox ─────────────────────────────────────────────────────────────────
SANDBOX_NAME='$__SN_ESC'
# Only honored when sshd overlay is active (docker-compose.sshd.yml).
SANDBOX_PASSWORD='$__SP_ESC'
# ─── Timezone ────────────────────────────────────────────────────────────────
TZ=$__TZ
@@ -326,7 +317,7 @@ ENVEOF
unset __GE_ESC
fi
unset __SN_ESC __SP_ESC __TZ __GIT_NAME __GIT_EMAIL
unset __SN_ESC __TZ __GIT_NAME __GIT_EMAIL
ok "Wrote .devcontainer/.env"
# ─── Auto-detect host gh token ────────────────────────────────────────
@@ -348,87 +339,12 @@ ENVEOF
fi
fi
# ─── Pre-create host auth source dirs ────────────────────────────────
# Default config enables host-bind overlays (claude-host.yml, codex-host.yml,
# pi-host.yml, opencode-host.yml) that bind host agent state into the container.
# The DeepAgents host overlay (deepagents-host.yml) is opt-in but we still
# pre-create ~/.deepagents so the overlay works the moment a user enables it.
# Two preconditions (each documented in the overlay headers):
# 1. Host UID == 1000 (credential files are mode 0600 — group-membership
# trick in entrypoint.sh cannot bypass owner-only reads).
# 2. Host source dir pre-exists. Otherwise docker auto-creates it as
# root, and the sandbox user (UID 1000) gets EACCES on first write.
#
# This block satisfies (2) by creating the dirs as the running user.
# (1) is checked below and surfaced as a warning — the user must opt
# out by hand if their host UID isn't 1000.
banner "Preparing host auth dirs for sandbox bind-mounts"
for d in .claude .codex .pi .deepagents .local/share/opencode; do
if [ ! -d "$HOME/$d" ]; then
mkdir -p "$HOME/$d"
ok "Created ~/$d (empty — first-time auth will populate it)"
else
ok "~/$d already exists — host auth will share into the sandbox"
fi
done
if [ ! -e "$HOME/.claude.json" ]; then
printf '{}\n' > "$HOME/.claude.json"
ok "Created ~/.claude.json (empty)"
fi
__HOST_UID="$(id -u)"
if [ "$__HOST_UID" != "1000" ]; then
__OH_CONFIG="$REPO_DIR/config.json"
if command -v jq >/dev/null 2>&1 && [ -f "$__OH_CONFIG" ]; then
# Filter *-host.yml entries out of composeOverrides. Mode-0600
# credential files in host agent state dirs can't be read by the
# sandbox user (UID 1000) when the host UID differs. Drop the
# overlays so the base named volumes
# take over; entrypoint.sh chowns those to UID 1000.
__OH_TMP="$(mktemp)"
if jq '.composeOverrides |= map(select(test("-host\\.yml$") | not))' \
"$__OH_CONFIG" > "$__OH_TMP" 2>/dev/null; then
if ! cmp -s "$__OH_CONFIG" "$__OH_TMP"; then
mv "$__OH_TMP" "$__OH_CONFIG"
ok "Host UID $__HOST_UID ≠ 1000 — disabled host-bind overlays in $__OH_CONFIG"
ok "Auth will live in named volumes; first run of each agent inside the sandbox will authenticate"
else
rm -f "$__OH_TMP"
ok "Host UID $__HOST_UID ≠ 1000 — host-bind overlays already disabled"
fi
else
rm -f "$__OH_TMP"
warn "jq failed to rewrite $__OH_CONFIG — falling back to manual instructions below."
__OH_FALLBACK=1
fi
unset __OH_TMP
else
__OH_FALLBACK=1
fi
if [ "${__OH_FALLBACK:-0}" = "1" ]; then
warn "Host UID is $__HOST_UID — sandbox user is UID 1000."
warn "Credential files in host agent state dirs may be mode 0600;"
warn "the sandbox WILL NOT be able to read them despite the bind-mount."
warn ""
warn "Install jq, OR edit $REPO_DIR/config.json"
warn "and remove these overlays from composeOverrides (any that you"
warn "have enabled — deepagents-host.yml is opt-in by default):"
warn " .devcontainer/docker-compose.claude-host.yml"
warn " .devcontainer/docker-compose.codex-host.yml"
warn " .devcontainer/docker-compose.pi-host.yml"
warn " .devcontainer/docker-compose.opencode-host.yml"
warn " .devcontainer/docker-compose.deepagents-host.yml"
warn ""
warn "The base named volumes will take over"
warn "and entrypoint.sh chowns them to UID 1000 on boot."
fi
unset __OH_CONFIG __OH_FALLBACK
fi
unset __HOST_UID
# ─── 5. Bring up the sandbox ─────────────────────────────────────────
banner "Building and starting sandbox"
printf "${CYAN}==> Building image — ~10 min on cold cache, ~30s on warm cache. Compose output below.${NC}\n"
# Base ships with zero in-tree overlays. Downstream packs (e.g. Pi
# extensions, BYO harness packs) can register their own overlays by
# appending paths to composeOverrides[] in config.json.
COMPOSE_FILES="-f .devcontainer/docker-compose.yml"
if command -v jq >/dev/null 2>&1 && [ -f "$REPO_DIR/config.json" ]; then
while IFS= read -r override; do