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:
1 parent
94326bc6ac
commit
c6a30a8f96
18 files changed
+34
-907
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user