diff --git a/.claude/skills/cloudflared-tunnel/SKILL.md b/.claude/skills/cloudflared-tunnel/SKILL.md deleted file mode 100644 index 53e5a0e..0000000 --- a/.claude/skills/cloudflared-tunnel/SKILL.md +++ /dev/null @@ -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: " [ ...] [--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**: ` [ ...] [--run]` - - Example: `myproject app.:3000 api.:3001` -- **Legacy single-ingress**: ` [--run]` - - Example: `myproject app. 3000` - -If no ingress pairs provided, suggest defaults: -- Tunnel name: derived from the project or sandbox name -- Hostname: `.: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 `.json` credentials file lives only on the host that created it. Without the JSON, `cloudflared tunnel run ` 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-.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: - → 0.0.0.0: (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. diff --git a/.devcontainer/.example.env b/.devcontainer/.example.env index 4165a5b..8c41f9b 100644 --- a/.devcontainer/.example.env +++ b/.devcontainer/.example.env @@ -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, diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c3f6274..e76bc2e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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/* diff --git a/.devcontainer/docker-compose.claude-host.yml b/.devcontainer/docker-compose.claude-host.yml deleted file mode 100644 index bcfa9f8..0000000 --- a/.devcontainer/docker-compose.claude-host.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.cloudflared.yml b/.devcontainer/docker-compose.cloudflared.yml deleted file mode 100644 index 2801138..0000000 --- a/.devcontainer/docker-compose.cloudflared.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.codex-host.yml b/.devcontainer/docker-compose.codex-host.yml deleted file mode 100644 index e71e6aa..0000000 --- a/.devcontainer/docker-compose.codex-host.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.deepagents-host.yml b/.devcontainer/docker-compose.deepagents-host.yml deleted file mode 100644 index 3239270..0000000 --- a/.devcontainer/docker-compose.deepagents-host.yml +++ /dev/null @@ -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 _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 diff --git a/.devcontainer/docker-compose.git.yml b/.devcontainer/docker-compose.git.yml deleted file mode 100644 index 5c6226c..0000000 --- a/.devcontainer/docker-compose.git.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.opencode-host.yml b/.devcontainer/docker-compose.opencode-host.yml deleted file mode 100644 index 9cc7f60..0000000 --- a/.devcontainer/docker-compose.opencode-host.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.pi-host.yml b/.devcontainer/docker-compose.pi-host.yml deleted file mode 100644 index 8723dd2..0000000 --- a/.devcontainer/docker-compose.pi-host.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.postgres.yml b/.devcontainer/docker-compose.postgres.yml deleted file mode 100644 index c6be848..0000000 --- a/.devcontainer/docker-compose.postgres.yml +++ /dev/null @@ -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: diff --git a/.devcontainer/docker-compose.ssh-generate.yml b/.devcontainer/docker-compose.ssh-generate.yml deleted file mode 100644 index c837e9b..0000000 --- a/.devcontainer/docker-compose.ssh-generate.yml +++ /dev/null @@ -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: diff --git a/.devcontainer/docker-compose.ssh.yml b/.devcontainer/docker-compose.ssh.yml deleted file mode 100644 index a33fd03..0000000 --- a/.devcontainer/docker-compose.ssh.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.sshd.yml b/.devcontainer/docker-compose.sshd.yml deleted file mode 100644 index 9b2d85d..0000000 --- a/.devcontainer/docker-compose.sshd.yml +++ /dev/null @@ -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 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index d5b6685..313709a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -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:-} diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index a57c65c..a88399c 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -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 + chown -R sandbox:sandbox "$OPENCODE_STATE" 2>/dev/null || true 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/. 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 \ diff --git a/install/cloudflared-tunnel.sh b/install/cloudflared-tunnel.sh deleted file mode 100755 index b612ab6..0000000 --- a/install/cloudflared-tunnel.sh +++ /dev/null @@ -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 [--run] -# -# Multi-ingress: -# cloudflared-tunnel.sh [ ...] [--run] -# -# Examples (replace with your own Cloudflare-managed domain): -# cloudflared-tunnel.sh myproject app. 3000 -# cloudflared-tunnel.sh myproject app.:3000 api.:3001 -# cloudflared-tunnel.sh myproject app.:3000 api.: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 [ ...] [--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: - # 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 [...]" - -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 diff --git a/scripts/install.sh b/scripts/install.sh index a26d6b1..cc31359 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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" < "$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