diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index e6afaab..a60ac86 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -8,8 +8,12 @@ name: Build and Push Container Image on: workflow_call: inputs: + ref: + description: 'Commit to check out, build and tag (defaults to the triggering commit; the release workflow passes its version-bump commit)' + required: false + type: string harness: - description: 'Harness to build: claude, opencode, qwencode, or codex' + description: 'Harness to build: claude, opencode, qwencode, codex, or omp' required: true type: string harness_version: @@ -39,6 +43,7 @@ on: - opencode - qwencode - codex + - omp harness_version: description: 'Harness version (leave empty to use the version file)' required: false @@ -66,6 +71,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 with: + ref: ${{ inputs.ref || github.sha }} fetch-depth: 0 - name: Resolve harness parameters @@ -88,6 +94,10 @@ jobs: IMAGE_NAME="guimou/cxbox" VERSION_FILE="CODEX_VERSION" ;; + omp) + IMAGE_NAME="guimou/ompbox" + VERSION_FILE="OMP_VERSION" + ;; *) echo "Unknown harness: ${{ inputs.harness }}" >&2 exit 1 @@ -141,6 +151,8 @@ jobs: id: meta uses: docker/metadata-action@v6 with: + # Read ref/sha from the checkout (inputs.ref), not from the event + context: git images: ${{ env.REGISTRY }}/${{ steps.harness.outputs.image_name }} tags: | type=raw,value=${{ inputs.image_tag || steps.harness.outputs.version }},enable=${{ inputs.image_tag != '' || steps.harness.outputs.version != '' }} @@ -188,7 +200,8 @@ jobs: NOTES="${NOTES}$(git log --pretty=format:'- %s (%h)' --reverse)" fi + # Tag the checked-out commit (inputs.ref when given), not github.sha gh release create "$TAG" \ --title "$TAG" \ --notes "$NOTES" \ - --target "${{ github.sha }}" + --target "$(git rev-parse HEAD)" diff --git a/.github/workflows/build-pod.yml b/.github/workflows/build-pod.yml index 440b11c..a0f1917 100644 --- a/.github/workflows/build-pod.yml +++ b/.github/workflows/build-pod.yml @@ -24,6 +24,7 @@ on: - 'ocbox' - 'qcbox' - 'cxbox' + - 'ompbox' - 'lib/**' - '.github/workflows/build-pod.yml' workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2f30e7..7e4e012 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,17 @@ name: Release # A weekly scheduled run (and the manual "rebuild all" dispatch) forces a # base rebuild to pick up Fedora package updates, then rebuilds every harness # as a -N suffixed release of its current pinned version. +# +# The scheduled run (and a dispatch with bump_versions) first runs the bump +# job: it looks up the latest upstream version of every harness, commits the +# changed version pin files straight to main, and the rest of the run builds +# from that commit, so a bumped harness is released as a plain {box}-v{version} +# and the others as -N rebuilds. The push is made with a write-enabled deploy +# key (secret BUMP_DEPLOY_KEY) because on a personal repository the "PR for +# main" ruleset can only be bypassed by deploy keys, not by the Actions app; +# the bump commit carries [skip ci] so that push does not start a second +# release run (the release happens in this same run). Setup: see +# docs/development.md, "Release workflow". on: push: @@ -21,6 +32,7 @@ on: - 'OPENCODE_VERSION' - 'QWENCODE_VERSION' - 'CODEX_VERSION' + - 'OMP_VERSION' # Base image inputs (rebuild the base, then all harnesses) - 'Dockerfile.base' - 'os-packages.txt' @@ -34,10 +46,12 @@ on: - 'firewall-domains-opencode.txt' - 'firewall-domains-qwencode.txt' - 'firewall-domains-codex.txt' + - 'firewall-domains-omp.txt' - 'ccbox' - 'ocbox' - 'qcbox' - 'cxbox' + - 'ompbox' schedule: # Weekly refresh of the base (Fedora updates) and all harnesses - cron: '0 4 * * 1' @@ -48,9 +62,115 @@ on: required: false type: boolean default: true + bump_versions: + description: 'Bump the version pin files to the latest upstream releases first (as the weekly run does)' + required: false + type: boolean + default: false + +# One release run at a time: a manual dispatch must not bump and push while +# the weekly run is doing the same +concurrency: + group: release + cancel-in-progress: false jobs: + # Bump the version pin files to the latest upstream releases (scheduled runs + # and dispatches with bump_versions). Always runs so the downstream jobs can + # rely on its outputs: `sha` is the commit to build (the bump commit, or the + # triggering commit when nothing changed) and `bumped` the space-separated + # list of bumped harness ids. + bump: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + sha: ${{ steps.bump.outputs.sha }} + bumped: ${{ steps.bump.outputs.bumped }} + + steps: + - name: Checkout main + uses: actions/checkout@v7 + with: + ref: main + # Deploy key (bypasses the "PR for main" ruleset); when the secret is + # unset checkout falls back to the workflow token, which can only + # read, and the bump step below fails with a clear message + ssh-key: ${{ secrets.BUMP_DEPLOY_KEY }} + + - name: Bump version pin files + id: bump + env: + GH_TOKEN: ${{ github.token }} + HAVE_DEPLOY_KEY: ${{ secrets.BUMP_DEPLOY_KEY != '' }} + run: | + if [ "${{ github.event_name }}" != "schedule" ] && \ + ! { [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.bump_versions }}" = "true" ]; }; then + echo "No version bump requested (${{ github.event_name }})" + echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT + echo "bumped=" >> $GITHUB_OUTPUT + exit 0 + fi + if [ "$HAVE_DEPLOY_KEY" != "true" ]; then + echo "::error::Secret BUMP_DEPLOY_KEY is not set: the bump job cannot push to main. See docs/development.md (Release workflow) for the deploy-key setup." + exit 1 + fi + + # Latest upstream version of a harness: the npm dist-tag "latest", or + # the latest (non-prerelease) GitHub release + npm_latest() { curl -fsSL "https://registry.npmjs.org/$1/latest" | jq -r '.version'; } + github_latest() { gh api "repos/$1/releases/latest" --jq '.tag_name' | sed 's/^v//'; } + + BUMPED="" + SUMMARY="" + bump() { + local harness="$1" title="$2" version_file="$3" latest="$4" + local current + current=$(tr -d '[:space:]' < "$version_file") + if ! echo "$latest" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.]+)?$'; then + echo "::warning::${title}: could not resolve the latest version ('${latest}'), keeping ${current}" + return + fi + # Only ever move forward (an unpublished upstream release must not + # downgrade the pin) + if [ "$latest" = "$current" ] || [ "$(printf '%s\n' "$current" "$latest" | sort -V | tail -1)" != "$latest" ]; then + echo "${title}: ${current} is current" + return + fi + echo "${title}: ${current} -> ${latest}" + echo "$latest" > "$version_file" + BUMPED="${BUMPED} ${harness}" + SUMMARY="${SUMMARY}"$'\n'"- ${title}: ${current} -> ${latest}" + } + + bump claude "Claude Code" CLAUDE_VERSION "$(npm_latest @anthropic-ai/claude-code)" + bump opencode "OpenCode" OPENCODE_VERSION "$(npm_latest opencode-ai)" + bump qwencode "Qwen Code" QWENCODE_VERSION "$(npm_latest @qwen-code/qwen-code)" + bump codex "Codex CLI" CODEX_VERSION "$(npm_latest @openai/codex)" + bump omp "Oh My Pi" OMP_VERSION "$(github_latest can1357/oh-my-pi)" + + BUMPED="${BUMPED# }" + if [ -z "$BUMPED" ]; then + echo "All version pins are current" + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "bumped=" >> $GITHUB_OUTPUT + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CLAUDE_VERSION OPENCODE_VERSION QWENCODE_VERSION CODEX_VERSION OMP_VERSION + # [skip ci]: this run builds the bump commit itself; the push must not + # start a second release run + git commit -q -m "chore: bump harness versions [skip ci]" -m "${SUMMARY#$'\n'}" + git push origin HEAD:main + echo "Pushed $(git rev-parse --short HEAD) to main (bumped: ${BUMPED})" + + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "bumped=${BUMPED}" >> $GITHUB_OUTPUT + detect: + needs: bump runs-on: ubuntu-latest permissions: contents: read @@ -63,10 +183,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 with: + ref: ${{ needs.bump.outputs.sha }} fetch-depth: 0 - name: Detect harnesses to release id: detect + env: + BUMPED: ${{ needs.bump.outputs.bumped }} run: | # Scheduled runs and manual "rebuild all" dispatches refresh everything REBUILD_ALL=false @@ -108,9 +231,13 @@ jobs: fi local git_tag image_tag - # A version bump releases the plain version; any other relevant - # change releases a -N suffixed rebuild of the current version - if [ "$REBUILD_ALL" != "true" ] && git diff HEAD~1 HEAD -- "$version_file" | grep -q '^[+-][0-9]'; then + # A version bump (by the bump job or a pushed commit) releases the + # plain version; any other relevant change releases a -N suffixed + # rebuild of the current version + if echo " ${BUMPED} " | grep -q " ${harness} "; then + git_tag="${box}-v${version}" + image_tag="${version}" + elif [ "$REBUILD_ALL" != "true" ] && git diff HEAD~1 HEAD -- "$version_file" | grep -q '^[+-][0-9]'; then git_tag="${box}-v${version}" image_tag="${version}" elif [ "$SHARED_CHANGED" = "true" ] || echo "$CHANGED" | grep -qE "^(${launcher}|${overlay})$"; then @@ -145,6 +272,7 @@ jobs: add_harness opencode ocbox OPENCODE_VERSION ocbox firewall-domains-opencode.txt add_harness qwencode qcbox QWENCODE_VERSION qcbox firewall-domains-qwencode.txt add_harness codex cxbox CODEX_VERSION cxbox firewall-domains-codex.txt + add_harness omp ompbox OMP_VERSION ompbox firewall-domains-omp.txt echo "matrix=${MATRIX}" >> $GITHUB_OUTPUT echo "force_base=${FORCE_BASE}" >> $GITHUB_OUTPUT @@ -165,7 +293,7 @@ jobs: # One independent job per harness: build FROM the base tag, push, tag, release build: - needs: [detect, base] + needs: [bump, detect, base] if: needs.detect.outputs.any == 'true' permissions: contents: write @@ -175,6 +303,7 @@ jobs: include: ${{ fromJSON(needs.detect.outputs.matrix) }} uses: ./.github/workflows/build-and-push.yml with: + ref: ${{ needs.bump.outputs.sha }} harness: ${{ matrix.harness }} image_tag: ${{ matrix.image_tag }} base_tag: ${{ needs.base.outputs.base_tag }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 376479d..442179a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: - 'ocbox' - 'qcbox' - 'cxbox' + - 'ompbox' - 'lib/**' - 'tests/**' - 'init-firewall.sh' @@ -26,7 +27,7 @@ jobs: uses: actions/checkout@v7 - name: shellcheck - run: shellcheck ccbox ocbox qcbox cxbox lib/box-common.sh init-firewall.sh tests/render-test.sh k8s/entrypoint.sh k8s/gen-egress-firewall.sh + run: shellcheck ccbox ocbox qcbox cxbox ompbox lib/box-common.sh init-firewall.sh tests/render-test.sh k8s/entrypoint.sh k8s/gen-egress-firewall.sh - name: Render the kustomizations run: | diff --git a/.gitignore b/.gitignore index 4610f1c..6fe7985 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Qwen Code local settings .qwen/settings.json +# Oh My Pi project-level config/state (written when modelRoleStorage=project) +.omp/ + # Playwright MCP logs .playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index 71bc45e..4bda6a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ # AGENTS.md -This file provides guidance to AI coding agents (Claude Code, OpenCode, Qwen Code, Codex CLI) when working with code in this repository. +This file provides guidance to AI coding agents (Claude Code, OpenCode, Qwen Code, Codex CLI, Oh My Pi) when working with code in this repository. ## Project Overview Containerized AI coding harness development environments for Fedora. Runs in Podman rootless mode with SELinux support and optional network firewall restrictions. -One repo produces four images/launchers from a shared base image, a harness Dockerfile and a launcher engine: +One repo produces five images/launchers from a shared base image, a harness Dockerfile and a launcher engine: | Launcher | Harness | Image | Version pin file | Firewall overlay | |----------|---------|-------|------------------|------------------| @@ -14,12 +14,13 @@ One repo produces four images/launchers from a shared base image, a harness Dock | `ocbox` | OpenCode | `quay.io/guimou/ocbox` | `OPENCODE_VERSION` | `firewall-domains-opencode.txt` | | `qcbox` | Qwen Code | `quay.io/guimou/qcbox` | `QWENCODE_VERSION` | `firewall-domains-qwencode.txt` | | `cxbox` | Codex CLI | `quay.io/guimou/cxbox` | `CODEX_VERSION` | `firewall-domains-codex.txt` | +| `ompbox` | Oh My Pi | `quay.io/guimou/ompbox` | `OMP_VERSION` | `firewall-domains-omp.txt` | -The image is built in two stages. `Dockerfile.base` holds everything harness-independent (Fedora, OS packages, runtimes, tools) and is published once as `quay.io/guimou/codebox-base`, tagged by the content of its inputs (`Dockerfile.base`, `os-packages.txt`, `init-firewall.sh`). `Dockerfile` starts `FROM ${BASE_IMAGE}` and only adds the harness, selected via the `HARNESS` build arg (`claude` / `opencode` / `qwencode` / `codex`) and its version via `HARNESS_VERSION`. Each harness build is therefore small and independent of the others. +The image is built in two stages. `Dockerfile.base` holds everything harness-independent (Fedora, OS packages, runtimes, tools) and is published once as `quay.io/guimou/codebox-base`, tagged by the content of its inputs (`Dockerfile.base`, `os-packages.txt`, `init-firewall.sh`). `Dockerfile` starts `FROM ${BASE_IMAGE}` and only adds the harness, selected via the `HARNESS` build arg (`claude` / `opencode` / `qwencode` / `codex` / `omp`) and its version via `HARNESS_VERSION`. Each harness build is therefore small and independent of the others. ## Run -By default, the container image is pulled from `quay.io/guimou/ccbox` (or `ocbox`/`qcbox`/`cxbox`). +By default, the container image is pulled from `quay.io/guimou/ccbox` (or `ocbox`/`qcbox`/`cxbox`/`ompbox`). ```bash # Launch with latest image from registry @@ -52,6 +53,9 @@ By default, the container image is pulled from `quay.io/guimou/ccbox` (or `ocbox # Mount ~/.codex/auth.json (API key or ChatGPT OAuth, shared across projects) ./cxbox --with-credentials +# Mount ~/.omp/.env and ~/.omp/agent/.env (dotenv API keys, shared across projects) +./ompbox --with-credentials + # Enable agent teams (experimental) ./ccbox --with-teams @@ -67,13 +71,15 @@ By default, the container image is pulled from `quay.io/guimou/ccbox` (or `ocbox # Open a bash shell in the container instead of the harness (same mounts, for troubleshooting) ./ccbox --shell -# OpenCode, Qwen Code and Codex work the same way (same common flags) +# OpenCode, Qwen Code, Codex and Oh My Pi work the same way (same common flags) ./ocbox ./ocbox --opencode-version ./qcbox ./qcbox --qwen-version ./cxbox ./cxbox --codex-version +./ompbox +./ompbox --omp-version ``` `--with-teams`, `--with-tmux`, and `--safe-mode` are Claude Code specific (ccbox only). @@ -103,12 +109,12 @@ For local development, you can build the image locally: - `Dockerfile` - Harness image built `FROM ${BASE_IMAGE}`, parameterized by `HARNESS`/`HARNESS_VERSION` build args - `os-packages.txt` - DNF packages to install (one per line) - `firewall-domains.txt` - Allowed network domains common to all harnesses (one per line) -- `firewall-domains-{claude,opencode,qwencode,codex}.txt` - Harness-specific allowed domains, concatenated with the common file at build time into `/etc/codebox/firewall-domains.txt` +- `firewall-domains-{claude,opencode,qwencode,codex,omp}.txt` - Harness-specific allowed domains, concatenated with the common file at build time into `/etc/codebox/firewall-domains.txt` - `init-firewall.sh` - Firewall initialization script (iptables/ipset) -- `lib/box-common.sh` - Shared launcher engine (sourced by all four launchers): runtime-neutral session spec (`add_mount` / `add_env`) plus two runtime backends that render it, Podman (`podman run`, workstation) and Apptainer (`apptainer exec` on a SIF, long-lived pod; selected with `--runtime` / `CODEBOX_RUNTIME`) +- `lib/box-common.sh` - Shared launcher engine (sourced by all five launchers): runtime-neutral session spec (`add_mount` / `add_env`) plus two runtime backends that render it, Podman (`podman run`, workstation) and Apptainer (`apptainer exec` on a SIF, long-lived pod; selected with `--runtime` / `CODEBOX_RUNTIME`) - `tests/render-test.sh` + `tests/golden/` - Golden test of the rendered command line for every launcher (stub runtime; run it, and re-record when a change to mounts/env is intended) -- `ccbox` / `ocbox` / `qcbox` / `cxbox` - Host launch scripts (thin wrappers defining harness identity, mounts, and env passthrough) -- `CLAUDE_VERSION` / `OPENCODE_VERSION` / `QWENCODE_VERSION` / `CODEX_VERSION` - Version pin files (overridden by `--claude-version` / `--opencode-version` / `--qwen-version` / `--codex-version`) +- `ccbox` / `ocbox` / `qcbox` / `cxbox` / `ompbox` - Host launch scripts (thin wrappers defining harness identity, mounts, and env passthrough) +- `CLAUDE_VERSION` / `OPENCODE_VERSION` / `QWENCODE_VERSION` / `CODEX_VERSION` / `OMP_VERSION` - Version pin files (overridden by `--claude-version` / `--opencode-version` / `--qwen-version` / `--codex-version` / `--omp-version`) - `k8s/` - Running in a long-lived pod on Kubernetes/OpenShift: `Containerfile` + `entrypoint.sh` (CentOS Stream 9 pod image `quay.io/guimou/codebox-pod`: Apptainer, tmux, the launchers; no dev tooling, that is in the SIF), `cluster/` (SCC + ClusterRole, cluster-admin, once), `base/` + `overlays/example/` (kustomize: service account, PVC, Deployment; the overlay sets namespace, RWX storage class, optional env Secret), `gen-egress-firewall.sh` (OVN EgressFirewall from the firewall domain lists, the pod-level replacement for `--with-firewall`) - `docs/` - User-facing documentation: `usage.md`, `architecture.md`, `development.md`, `kubernetes.md` (README holds only the minimum and links here — keep them in sync when changing behavior) @@ -238,7 +244,7 @@ The launchers also run inside a long-lived pod (`docs/kubernetes.md`): the pod i ## CI/CD -- `.github/workflows/release.yml` runs on pushes to main touching image/launcher files (plus a weekly schedule and a manual "rebuild all" dispatch). It detects which harnesses are affected: a version-file bump releases that harness as `{box}-v{version}`; a change to shared files (`Dockerfile`, `Dockerfile.base`, os-packages.txt, common firewall list, init-firewall.sh, `lib/`) rebuilds all harnesses as `{box}-v{version}-N`; a change to a harness-specific file (launcher, firewall overlay) rebuilds only that harness. If at least one harness needs a build, the base image is built first (once, skipped when its content tag already exists), then each harness builds in its own independent job (`fail-fast: false`). +- `.github/workflows/release.yml` runs on pushes to main touching image/launcher files (plus a weekly schedule and a manual "rebuild all" dispatch). The weekly run (or a dispatch with `bump_versions`) first bumps the version pin files to the latest upstream releases (npm `latest` dist-tags, GitHub latest release for Oh My Pi), commits them to main with the `BUMP_DEPLOY_KEY` deploy key (the only actor that can bypass the PR ruleset on a personal repo; `[skip ci]` on that commit) and builds from that commit in the same run, so bumped harnesses get plain `{box}-v{version}` releases. It detects which harnesses are affected: a version-file bump releases that harness as `{box}-v{version}`; a change to shared files (`Dockerfile`, `Dockerfile.base`, os-packages.txt, common firewall list, init-firewall.sh, `lib/`) rebuilds all harnesses as `{box}-v{version}-N`; a change to a harness-specific file (launcher, firewall overlay) rebuilds only that harness. If at least one harness needs a build, the base image is built first (once, skipped when its content tag already exists), then each harness builds in its own independent job (`fail-fast: false`). - `.github/workflows/build-base.yml` is the reusable base build: it computes the base content tag, skips if that tag exists in `quay.io/guimou/codebox-base` (unless forced), otherwise builds `Dockerfile.base` and pushes `{tag}` and `latest`. - `.github/workflows/tests.yml` runs shellcheck and the launcher golden test (`tests/render-test.sh`) on pull requests and on pushes touching launcher files. - `.github/workflows/build-pod.yml` builds `k8s/Containerfile` and pushes `quay.io/guimou/codebox-pod` (`latest` + commit SHA) when the pod image inputs or the launchers change on main. @@ -246,7 +252,7 @@ The launchers also run inside a long-lived pod (`docs/kubernetes.md`): the pod i ## Architecture -- **Registries**: `quay.io/guimou/ccbox`, `quay.io/guimou/ocbox`, `quay.io/guimou/qcbox`, `quay.io/guimou/cxbox` (CI/CD published), built on `quay.io/guimou/codebox-base` +- **Registries**: `quay.io/guimou/ccbox`, `quay.io/guimou/ocbox`, `quay.io/guimou/qcbox`, `quay.io/guimou/cxbox`, `quay.io/guimou/ompbox` (CI/CD published), built on `quay.io/guimou/codebox-base` - **Base**: `quay.io/guimou/codebox-base` (built from `quay.io/fedora/fedora:44`) - **User**: `coder` (UID 1000) for `--userns=keep-id` compatibility - **Mounts**: @@ -258,6 +264,7 @@ The launchers also run inside a long-lived pod (`docs/kubernetes.md`): the pod i - `~/.local/share/opencode/auth.json` → `/home/coder/.local/share/opencode/auth.json` (read-write, only with `ocbox --with-credentials`) - `~/.qwen/oauth_creds.json` → `/home/coder/.qwen/oauth_creds.json` (read-write, only with `qcbox --with-credentials`) - `~/.codex/auth.json` → `/home/coder/.codex/auth.json` (read-write, only with `cxbox --with-credentials`) + - `~/.omp/.env` and `~/.omp/agent/.env` → same paths under `/home/coder/.omp/` (read-only, only with `ompbox --with-credentials`) - `~/.config/gcloud` → `/home/coder/.config/gcloud` (read-only, only with `--with-gcloud`) - `~/.gitconfig` → `/home/coder/.gitconfig` (read-only, only with `--with-gitconfig`) - npm global prefix → `/home/coder/.npm-global` (read-only, auto-detected) @@ -265,10 +272,10 @@ The launchers also run inside a long-lived pod (`docs/kubernetes.md`): the pod i - `/etc/localtime` (for timezone sync) - **SELinux**: Uses `:z` volume labels for shared relabeling (supports multi-session) - **Firewall**: Optional, requires `NET_ADMIN` and `NET_RAW` capabilities -- **Project Isolation**: Each project gets its own history and session data (`~/.claude/ccbox-projects/` for ccbox, `~/.local/share/ocbox-projects/` for ocbox, `~/.qwen/qcbox-projects/` for qcbox, `~/.codex/cxbox-projects/` for cxbox) +- **Project Isolation**: Each project gets its own history and session data (`~/.claude/ccbox-projects/` for ccbox, `~/.local/share/ocbox-projects/` for ocbox, `~/.qwen/qcbox-projects/` for qcbox, `~/.codex/cxbox-projects/` for cxbox, `~/.omp/ompbox-projects/` for ompbox) - **Multi-Session**: Multiple sessions can run simultaneously per project, each with a unique container name (`{box}-{project}-{hash}-{session-id}`) -- **Per-harness mounts**: the mounts listed above are ccbox's (`.credentials.json` is opt-in via `ccbox --with-credentials`). ocbox mounts `~/.config/opencode` (shared config), a per-project data dir as `~/.local/share/opencode`, and a shared `~/.cache/opencode`; the shared `auth.json` is opt-in via `ocbox --with-credentials` (mounted on top of the per-project data dir). qcbox mounts shared `~/.qwen/{settings.json,QWEN.md}` plus per-project `projects/` (chat transcripts, enables `qwen --resume`), `tmp/`, and `file-history/` dirs; `oauth_creds.json` is opt-in via `qcbox --with-credentials`. cxbox mounts a per-project data dir as the entire `~/.codex` (sessions, state DB, memories, goals) and copies the shared `config.toml` into it at launch (refreshed when the host file is newer; not bind-mounted because Codex saves config edits via rename, which fails on a file mount); `auth.json` is opt-in via `cxbox --with-credentials` (mounted on top of the per-project data dir). Workspace, credentials (`--with-credentials`, per harness), gcloud (opt-in `--with-gcloud`), gitconfig (opt-in `--with-gitconfig`), clipboard, audio, timezone, npm-global, and GitHub token mounts are common to all launchers (handled by `lib/box-common.sh` and the harness wrappers). -- **Credentials / what actually reaches the container**: `--with-credentials` controls **only** the dedicated credential store file. The main config is **always** mounted, so any key stored there (an `"env"` block or a provider `apiKey`/`envKey`) is passed regardless of the flag. Each launcher also forwards host env vars matching its prefix list (see `ENV_PASSTHROUGH_REGEX` in each wrapper): ccbox `ANTHROPIC_*`/`CLAUDE_CODE_*`/`CLAUDE_AX_*`/`CLAUDE_ENABLE_*`/`CLAUDE_AUTOCOMPACT_*`, ocbox `OPENCODE_*`/`ANTHROPIC_*`/`OPENAI_*`/`OPENROUTER_*`/`GEMINI_*`/`GOOGLE_*`/`AZURE_*`/`DEEPSEEK_*`/`MISTRAL_*`/`XAI_*`/`GROQ_*`, qcbox `QWEN_*`/`OPENAI_*`/`DASHSCOPE_*`/`BAILIAN_*`/`MODELSCOPE_*`/`OPENROUTER_*`/`ANTHROPIC_*`/`GEMINI_*`/`GOOGLE_*`, cxbox `CODEX_*`/`OPENAI_*`/`OPENROUTER_*`/`ANTHROPIC_*`/`GEMINI_*`/`GOOGLE_*`/`AZURE_*`/`DEEPSEEK_*`/`MISTRAL_*`/`XAI_*`/`GROQ_*`, plus specific vars (e.g. `AWS_*`). So a container is only credential-free if *neither* the always-mounted config *nor* a forwarded env var carries a key. Per-project overrides that stay out of the shared home dir: ccbox `.claude/settings{,.local}.json`, ocbox `opencode.json` in the project root, qcbox `.qwen/settings.json` + `.qwen/.env` in the project, cxbox `AGENTS.md` in the project root (via the workspace mount). Full per-launcher tables live in `docs/usage.md` (API Provider Configuration + Credentials) and `docs/architecture.md` (Per-Harness Mounts). +- **Per-harness mounts**: the mounts listed above are ccbox's (`.credentials.json` is opt-in via `ccbox --with-credentials`). ocbox mounts `~/.config/opencode` (shared config), a per-project data dir as `~/.local/share/opencode`, and a shared `~/.cache/opencode`; the shared `auth.json` is opt-in via `ocbox --with-credentials` (mounted on top of the per-project data dir). qcbox mounts shared `~/.qwen/{settings.json,QWEN.md}` plus per-project `projects/` (chat transcripts, enables `qwen --resume`), `tmp/`, and `file-history/` dirs; `oauth_creds.json` is opt-in via `qcbox --with-credentials`. cxbox mounts a per-project data dir as the entire `~/.codex` (sessions, state DB, memories, goals) and copies the shared `config.toml` into it at launch (refreshed when the host file is newer; not bind-mounted because Codex saves config edits via rename, which fails on a file mount); `auth.json` is opt-in via `cxbox --with-credentials` (mounted on top of the per-project data dir). ompbox mounts a per-project agent dir as `~/.omp/agent` (agent.db, sessions, blobs, history, plus a `config.yml` copy seeded and refreshed from the shared file like cxbox's `config.toml`), bind-mounts the shared config directories (`commands rules prompts instructions skills hooks tools extensions`) read-write from the host `~/.omp/agent`, and bind-mounts the shared config files (`SYSTEM.md APPEND_SYSTEM.md RULES.md TITLE_SYSTEM.md AGENTS.md models.yml mcp.json keybindings.json`) read-only when present on the host; `~/.omp/.env` and `~/.omp/agent/.env` are opt-in via `ompbox --with-credentials` (both read-only). Workspace, credentials (`--with-credentials`, per harness), gcloud (opt-in `--with-gcloud`), gitconfig (opt-in `--with-gitconfig`), clipboard, audio, timezone, npm-global, and GitHub token mounts are common to all launchers (handled by `lib/box-common.sh` and the harness wrappers). +- **Credentials / what actually reaches the container**: `--with-credentials` controls **only** the dedicated credential store file. The main config is **always** mounted, so any key stored there (an `"env"` block or a provider `apiKey`/`envKey`) is passed regardless of the flag. Each launcher also forwards host env vars matching its prefix list (see `ENV_PASSTHROUGH_REGEX` in each wrapper): ccbox `ANTHROPIC_*`/`CLAUDE_CODE_*`/`CLAUDE_AX_*`/`CLAUDE_ENABLE_*`/`CLAUDE_AUTOCOMPACT_*`, ocbox `OPENCODE_*`/`ANTHROPIC_*`/`OPENAI_*`/`OPENROUTER_*`/`GEMINI_*`/`GOOGLE_*`/`AZURE_*`/`DEEPSEEK_*`/`MISTRAL_*`/`XAI_*`/`GROQ_*`, qcbox `QWEN_*`/`OPENAI_*`/`DASHSCOPE_*`/`BAILIAN_*`/`MODELSCOPE_*`/`OPENROUTER_*`/`ANTHROPIC_*`/`GEMINI_*`/`GOOGLE_*`, cxbox `CODEX_*`/`OPENAI_*`/`OPENROUTER_*`/`ANTHROPIC_*`/`GEMINI_*`/`GOOGLE_*`/`AZURE_*`/`DEEPSEEK_*`/`MISTRAL_*`/`XAI_*`/`GROQ_*`, ompbox `OMP_*`/`PI_*` (except the state-relocating `PI_CODING_AGENT_DIR`/`PI_CONFIG_DIR`/`PI_CONFIG_FILES`/`PI_PROFILE`/`OMP_PROFILE`/`PI_INSTALL_DIR`)/`ANTHROPIC_*`/`OPENAI_*`/`OPENROUTER_*`/`GEMINI_*`/`GOOGLE_*`/`AZURE_*`/`DEEPSEEK_*`/`MISTRAL_*`/`XAI_*`/`GROQ_*`/`QWEN_*`/`DASHSCOPE_*`/`COPILOT_*`, plus specific vars (e.g. `AWS_*`). So a container is only credential-free if *neither* the always-mounted config *nor* a forwarded env var carries a key. Per-project overrides that stay out of the shared home dir: ccbox `.claude/settings{,.local}.json`, ocbox `opencode.json` in the project root, qcbox `.qwen/settings.json` + `.qwen/.env` in the project, cxbox `AGENTS.md` in the project root (via the workspace mount), ompbox `.omp/` in the project root plus `AGENTS.md`. Full per-launcher tables live in `docs/usage.md` (API Provider Configuration + Credentials) and `docs/architecture.md` (Per-Harness Mounts). ## Clipboard Support diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41039f4..31582fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,8 @@ Everything you need to work on this project lives in the documentation: ## Guidelines -- Keep `Dockerfile.base` free of any reference to a harness: it is built once and shared by the four harness images. Harness-specific layers go in `Dockerfile`. -- Put launcher behavior common to all harnesses in `lib/box-common.sh`; keep the `ccbox`/`ocbox`/`qcbox`/`cxbox` wrappers limited to harness identity and hooks. +- Keep `Dockerfile.base` free of any reference to a harness: it is built once and shared by the five harness images. Harness-specific layers go in `Dockerfile`. +- Put launcher behavior common to all harnesses in `lib/box-common.sh`; keep the `ccbox`/`ocbox`/`qcbox`/`cxbox`/`ompbox` wrappers limited to harness identity and hooks. - Declare mounts and environment with `add_mount` / `add_optional_mount` / `add_env`, never by appending runtime flags directly: the runtime backend renders them. -- Run `shellcheck ccbox ocbox qcbox cxbox lib/box-common.sh init-firewall.sh tests/render-test.sh k8s/entrypoint.sh k8s/gen-egress-firewall.sh` and `tests/render-test.sh` before submitting; re-record the golden files (`tests/render-test.sh record`) when a change to the rendered command line is intended, and include that diff in the PR. +- Run `shellcheck ccbox ocbox qcbox cxbox ompbox lib/box-common.sh init-firewall.sh tests/render-test.sh k8s/entrypoint.sh k8s/gen-egress-firewall.sh` and `tests/render-test.sh` before submitting; re-record the golden files (`tests/render-test.sh record`) when a change to the rendered command line is intended, and include that diff in the PR. - Update the relevant file under `docs/` when your change affects usage, architecture, or the development workflow. diff --git a/Dockerfile b/Dockerfile index 5147533..a74ee93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,11 @@ # Containerized AI coding harness development environment - HARNESS image # -# One Dockerfile builds four images, selected via the HARNESS build arg: +# One Dockerfile builds five images, selected via the HARNESS build arg: # HARNESS=claude -> ccbox (Claude Code) # HARNESS=opencode -> ocbox (OpenCode) # HARNESS=qwencode -> qcbox (Qwen Code) # HARNESS=codex -> cxbox (Codex CLI) +# HARNESS=omp -> ompbox (Oh My Pi) # # Everything harness-independent lives in Dockerfile.base and is consumed # here through BASE_IMAGE (a published quay.io/guimou/codebox-base tag, or a @@ -17,7 +18,11 @@ ARG BASE_IMAGE=quay.io/guimou/codebox-base:latest FROM ${BASE_IMAGE} -# Which harness to install: claude | opencode | qwencode | codex +# Provided by podman/buildah on multi-arch builds; falls back to `uname -m` +# below for engines that do not set it (e.g. a plain `docker build`). +ARG TARGETARCH + +# Which harness to install: claude | opencode | qwencode | codex | omp ARG HARNESS=claude # Harness version (empty = latest, or a specific version like "2.1.226") ARG HARNESS_VERSION="" @@ -89,6 +94,20 @@ RUN set -eu; \ # as the whole ~/.codex (config.toml / auth.json are mounted on top) mkdir -p /home/coder/.codex && \ chown -R coder:coder /home/coder/.codex ;; \ + omp) \ + arch="${TARGETARCH:-$(uname -m)}"; \ + case "$arch" in amd64|x86_64) asset=omp-linux-x64 ;; arm64|aarch64) asset=omp-linux-arm64 ;; *) echo "Unsupported arch: $arch" >&2; exit 1 ;; esac; \ + if [ -n "${HARNESS_VERSION}" ]; then base="https://github.com/can1357/oh-my-pi/releases/download/v${HARNESS_VERSION}"; \ + else base="https://github.com/can1357/oh-my-pi/releases/latest/download"; fi; \ + curl -fsSL "${base}/${asset}" -o /usr/local/bin/omp && \ + curl -fsSL "${base}/SHA256SUMS.txt" -o /tmp/omp.sums && \ + awk -v a="$asset" '{sub(/^\*/,"",$2)} $2==a{print $1" /usr/local/bin/omp"}' /tmp/omp.sums | sha256sum -c - && rm -f /tmp/omp.sums && \ + chmod 0755 /usr/local/bin/omp && \ + # Mount targets: per-project agent dir + the two opt-in dotenv files (empty placeholders) + mkdir -p /home/coder/.omp/agent && touch /home/coder/.omp/.env /home/coder/.omp/agent/.env && \ + chown -R coder:coder /home/coder/.omp && \ + # System defaults: pin version (no startup update check); loaded via PI_CONFIG_FILES + mkdir -p /etc/codebox && printf 'startup:\n checkUpdate: false\n' > /etc/codebox/omp-config.yml ;; \ *) echo "Unknown HARNESS: ${HARNESS}" >&2; exit 1 ;; \ esac @@ -103,6 +122,21 @@ RUN if [ "${HARNESS}" = "claude" ]; then \ fi; \ fi +# System defaults overlay for Oh My Pi (pins startup.checkUpdate off); other +# harnesses ignore this env var. Set unconditionally since ENV cannot be +# scoped to one arm of the case above. +ENV PI_CONFIG_FILES=/etc/codebox/omp-config.yml + +# Oh My Pi extracts its native addons (~340 MB) into ~/.omp/natives on first +# run; do it at build time (omp harness only). `omp config path` also creates +# ~/.omp/agent/agent.db* and ~/.omp/logs; wipe the agent dir afterwards so the +# launcher's per-project mount target starts clean. +RUN if [ "${HARNESS}" = "omp" ]; then \ + set -e; \ + omp config path >/dev/null; \ + rm -rf /home/coder/.omp/agent/* && touch /home/coder/.omp/agent/.env; \ + fi + # Set working directory to workspace WORKDIR /workspace diff --git a/Dockerfile.base b/Dockerfile.base index eabfa90..d85213b 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -128,6 +128,7 @@ ENV PATH="/home/coder/.npm-global/bin:/home/coder/.local/bin:${PATH}" # Disable auto-updaters in container (version controlled via image build). # Each harness only reads its own variable; setting all is harmless. -# (Codex has no such env var: cxbox passes -c check_for_update_on_startup=false.) +# (Codex has no such env var: cxbox passes -c check_for_update_on_startup=false; +# Oh My Pi is pinned via the PI_CONFIG_FILES overlay baked in Dockerfile.) ENV DISABLE_AUTOUPDATER=1 ENV OPENCODE_DISABLE_AUTOUPDATE=1 diff --git a/OMP_VERSION b/OMP_VERSION new file mode 100644 index 0000000..b40a900 --- /dev/null +++ b/OMP_VERSION @@ -0,0 +1 @@ +18.1.14 diff --git a/README.md b/README.md index 0513138..adf0f78 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ccbox / ocbox / qcbox / cxbox +# ccbox / ocbox / qcbox / cxbox / ompbox Opinionated, containerized AI coding harness environments for Fedora. @@ -6,7 +6,7 @@ Opinionated, containerized AI coding harness environments for Fedora. ## What is this? -This project is my personal take on running AI coding harnesses inside a container. One repo produces four images and four launchers, sharing the same base environment: +This project is my personal take on running AI coding harnesses inside a container. One repo produces five images and five launchers, sharing the same base environment: | Launcher | Harness | Image | |----------|---------|-------| @@ -14,8 +14,9 @@ This project is my personal take on running AI coding harnesses inside a contain | `ocbox` | [OpenCode](https://opencode.ai) | `quay.io/guimou/ocbox` | | `qcbox` | [Qwen Code](https://github.com/QwenLM/qwen-code) | `quay.io/guimou/qcbox` | | `cxbox` | [Codex CLI](https://github.com/openai/codex) | `quay.io/guimou/cxbox` | +| `ompbox` | [Oh My Pi](https://github.com/can1357/oh-my-pi) | `quay.io/guimou/ompbox` | -All four provide: +All five provide: - **Isolation** - Only the current project directory is mounted; each project gets its own history and session data - **Multi-session** - Run multiple sessions simultaneously in the same project @@ -50,9 +51,12 @@ chmod +x ~/.local/bin/qcbox # and curl -fsSL https://raw.githubusercontent.com/guimou/ccbox/main/cxbox -o ~/.local/bin/cxbox chmod +x ~/.local/bin/cxbox +# and +curl -fsSL https://raw.githubusercontent.com/guimou/ccbox/main/ompbox -o ~/.local/bin/ompbox +chmod +x ~/.local/bin/ompbox ``` -With this install the image tag defaults to `latest`; use `--claude-version` (or `--opencode-version` / `--qwen-version` / `--codex-version`) to pin one. +With this install the image tag defaults to `latest`; use `--claude-version` (or `--opencode-version` / `--qwen-version` / `--codex-version` / `--omp-version`) to pin one. ### Option 2: Clone the repository @@ -64,6 +68,7 @@ ln -sf "$(pwd)/ccbox/ccbox" ~/.local/bin/ccbox ln -sf "$(pwd)/ccbox/ocbox" ~/.local/bin/ocbox ln -sf "$(pwd)/ccbox/qcbox" ~/.local/bin/qcbox ln -sf "$(pwd)/ccbox/cxbox" ~/.local/bin/cxbox +ln -sf "$(pwd)/ccbox/ompbox" ~/.local/bin/ompbox ``` Either way, make sure `~/.local/bin` is in your PATH. Run `ccbox --install` for OS and shell-specific instructions. @@ -77,6 +82,7 @@ ccbox # Run Claude Code in the current directory ocbox # Run OpenCode qcbox # Run Qwen Code cxbox # Run Codex CLI +ompbox # Run Oh My Pi ``` The container image is pulled automatically on first run. A few common flags: @@ -96,9 +102,9 @@ API keys and provider settings are forwarded from host environment variables (e. - `--with-gcloud` to mount `~/.config/gcloud` (e.g. for Vertex AI), - `--with-gitconfig` to mount `~/.gitconfig`, -- `--with-credentials` to mount the harness credential store file — `~/.claude/.credentials.json` (ccbox), `~/.local/share/opencode/auth.json` (ocbox), `~/.qwen/oauth_creds.json` (qcbox), `~/.codex/auth.json` (cxbox). +- `--with-credentials` to mount the harness credential store file — `~/.claude/.credentials.json` (ccbox), `~/.local/share/opencode/auth.json` (ocbox), `~/.qwen/oauth_creds.json` (qcbox), `~/.codex/auth.json` (cxbox), `~/.omp/.env` and `~/.omp/agent/.env` (ompbox, read-only dotenv API keys). -Note this does **not** cover the always-mounted main config (`~/.claude/settings.json`, `~/.config/opencode/opencode.json`, `~/.qwen/settings.json`, `~/.codex/config.toml`): any key stored in those is passed regardless of `--with-credentials`. GitHub token injection (`GH_TOKEN`) is unaffected. +Note this does **not** cover the always-mounted main config (`~/.claude/settings.json`, `~/.config/opencode/opencode.json`, `~/.qwen/settings.json`, `~/.codex/config.toml`, `~/.omp/agent/config.yml`): any key stored in those is passed regardless of `--with-credentials`. GitHub token injection (`GH_TOKEN`) is unaffected. OAuth logins done with `/login` inside an ompbox container stay per project (see [docs/usage.md](docs/usage.md#credentials)). ## Kubernetes / OpenShift diff --git a/cxbox b/cxbox index dfcf6c5..0c7dd06 100755 --- a/cxbox +++ b/cxbox @@ -51,6 +51,7 @@ WITH_CREDENTIALS=false # CODEX_HOME is deliberately NOT forwarded: inside the container Codex must use # /home/coder/.codex (the mount target), whatever the host value is. ENV_PASSTHROUGH_REGEX='^(CODEX_|OPENAI_|OPENROUTER_|ANTHROPIC_|GEMINI_|GOOGLE_|AZURE_|DEEPSEEK_|MISTRAL_|XAI_|GROQ_)' +ENV_PASSTHROUGH_EXCLUDE_REGEX='^CODEX_HOME$' ENV_PASSTHROUGH_VARS=( AWS_REGION AWS_PROFILE AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_BEARER_TOKEN_BEDROCK diff --git a/docs/architecture.md b/docs/architecture.md index b120b3e..229957b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -One repository produces four container images and four launchers from a shared base image, a harness Dockerfile and a launcher engine: +One repository produces five container images and five launchers from a shared base image, a harness Dockerfile and a launcher engine: | Launcher | Harness | Image | Version pin file | Firewall overlay | |----------|---------|-------|------------------|------------------| @@ -8,6 +8,7 @@ One repository produces four container images and four launchers from a shared b | `ocbox` | [OpenCode](https://opencode.ai) | `quay.io/guimou/ocbox` | `OPENCODE_VERSION` | `firewall-domains-opencode.txt` | | `qcbox` | [Qwen Code](https://github.com/QwenLM/qwen-code) | `quay.io/guimou/qcbox` | `QWENCODE_VERSION` | `firewall-domains-qwencode.txt` | | `cxbox` | [Codex CLI](https://github.com/openai/codex) | `quay.io/guimou/cxbox` | `CODEX_VERSION` | `firewall-domains-codex.txt` | +| `ompbox` | [Oh My Pi](https://github.com/can1357/oh-my-pi) | `quay.io/guimou/ompbox` | `OMP_VERSION` | `firewall-domains-omp.txt` | ## Design Principles @@ -17,16 +18,16 @@ One repository produces four container images and four launchers from a shared b - **Host integration where it helps** — clipboard/display, audio, timezone, gcloud credentials (read-only, opt-in via `--with-gcloud`), host gitconfig (read-only, opt-in via `--with-gitconfig`), harness credentials (read-write, opt-in via `--with-credentials`), GitHub token, and host-installed global npm packages (read-only) are connected from the host. - **Rootless and SELinux-friendly** — Podman rootless with `--userns=keep-id`, `:z` volume labels on Linux. -## One Base, Four Images +## One Base, Five Images The image is built in two stages, from two Dockerfiles: -**`Dockerfile.base` → `quay.io/guimou/codebox-base`.** All harness-independent layers: Fedora 44 base, OS packages, language runtimes, dev tools, Rust toolchain, firewall script, `coder` user and environment setup. It never references a harness. CI builds it once and tags it by content — the short SHA of the last commit touching its inputs (`Dockerfile.base`, `os-packages.txt`, `init-firewall.sh`) — plus `latest`. A harness-only change therefore never rebuilds the base, and a base change builds it exactly once for all four harnesses. Because the four images start from the *same* base tag, they always share an identical package set. +**`Dockerfile.base` → `quay.io/guimou/codebox-base`.** All harness-independent layers: Fedora 44 base, OS packages, language runtimes, dev tools, Rust toolchain, firewall script, `coder` user and environment setup. It never references a harness. CI builds it once and tags it by content — the short SHA of the last commit touching its inputs (`Dockerfile.base`, `os-packages.txt`, `init-firewall.sh`) — plus `latest`. A harness-only change therefore never rebuilds the base, and a base change builds it exactly once for all five harnesses. Because the five images start from the *same* base tag, they always share an identical package set. -**`Dockerfile` → the four harness images.** Starts `FROM ${BASE_IMAGE}` and is parameterized by three build args: +**`Dockerfile` → the five harness images.** Starts `FROM ${BASE_IMAGE}` and is parameterized by three build args: - `BASE_IMAGE` — the base to build on (CI pins the content tag; the launcher passes a local `codebox-base:latest` or the published `latest`). -- `HARNESS` — `claude`, `opencode`, `qwencode`, or `codex`; selects which single CLI is installed and which firewall overlay is baked in. +- `HARNESS` — `claude`, `opencode`, `qwencode`, `codex`, or `omp`; selects which single CLI is installed and which firewall overlay is baked in. - `HARNESS_VERSION` — the harness version to install (empty means latest). The harness stage is small and fast, and each harness build is independent of the others. It: @@ -37,10 +38,11 @@ The harness stage is small and fast, and each harness build is independent of th - `opencode` → `npm install -g opencode-ai@` - `qwencode` → `npm install -g @qwen-code/qwen-code@`, plus a baked `/etc/qwen-code/settings.json` that disables auto-update and Qwen's own sandbox (to avoid sandbox-in-container nesting) - `codex` → `npm install -g @openai/codex@` (npm wrapper that resolves to a per-platform native binary). Codex has no system-level config, so the `cxbox` launcher disables Codex's own sandbox (`sandbox_mode="danger-full-access"`, the container is the sandbox and bubblewrap/Landlock cannot nest in it) and the startup update check via `-c` overrides + - `omp` → no npm: the prebuilt release binary (`omp-linux-{x64,arm64}`) is downloaded from GitHub Releases and checksum-verified against `SHA256SUMS.txt`. Oh My Pi's native addons (~340 MB) are extracted at build time (`omp config path`), not on first container run, and the startup update check is disabled through a baked config overlay loaded via `PI_CONFIG_FILES` Auto-updaters are disabled for all harnesses — versions are controlled by the pin files and image builds. -## One Engine, Four Launchers +## One Engine, Five Launchers The launchers are thin wrappers around a shared engine, `lib/box-common.sh`: @@ -59,31 +61,31 @@ The wrappers locate the engine relative to their (symlink-resolved) location, su - **Container name**: `{box}-{project}-{hash}-{session-id}` — multiple sessions can run simultaneously in the same project, sharing project data - **Firewall**: optional (`--with-firewall`), iptables/ipset allowlist, requires `NET_ADMIN`/`NET_RAW` (added automatically), Linux only -> **Breaking change:** the container user and home folder were renamed from `claude` to `coder`. Images built before this change use `/home/claude`; rebuild or re-pull (`ccbox --build` / re-pull from the registry) so mounts line up with `/home/coder`. Host-side data is unaffected — `~/.claude/`, `~/.local/share/ocbox-projects/`, `~/.qwen/qcbox-projects/`, and `~/.codex/` are unchanged; only the in-container home path moved. +> **Breaking change:** the container user and home folder were renamed from `claude` to `coder`. Images built before this change use `/home/claude`; rebuild or re-pull (`ccbox --build` / re-pull from the registry) so mounts line up with `/home/coder`. Host-side data is unaffected — `~/.claude/`, `~/.local/share/ocbox-projects/`, `~/.qwen/qcbox-projects/`, `~/.codex/`, and `~/.omp/` are unchanged; only the in-container home path moved. ## The Project Isolation Problem -Every project mounts at `/workspace` inside its container. Harnesses that key their internal state by project *path* (OpenCode, Qwen Code, Codex) would therefore collide: every project would look like the same project. +Every project mounts at `/workspace` inside its container. Harnesses that key their internal state by project *path* (OpenCode, Qwen Code, Codex, Oh My Pi) would therefore collide: every project would look like the same project. The launchers solve this **host-side**: each project gets its own host directory (keyed by `{sanitized-name}_{md5-hash-of-path}`) which is mounted *as* the harness's data/state directory inside the container. Shared items are file-mounted on top; the credential file is only shared when `--with-credentials` is passed. ## Per-Harness Mounts -Common to all launchers (handled by the engine): workspace, clipboard/display, PulseAudio, timezone, npm-global (ro), GitHub token env. Opt-in host mounts: `--with-gcloud` (gcloud, ro), `--with-gitconfig` (gitconfig, ro), `--with-credentials` (the harness credential file, rw: `.credentials.json` for ccbox, `auth.json` for ocbox, `oauth_creds.json` for qcbox, `auth.json` for cxbox). +Common to all launchers (handled by the engine): workspace, clipboard/display, PulseAudio, timezone, npm-global (ro), GitHub token env. Opt-in host mounts: `--with-gcloud` (gcloud, ro), `--with-gitconfig` (gitconfig, ro), `--with-credentials` (the harness credential file, rw: `.credentials.json` for ccbox, `auth.json` for ocbox, `oauth_creds.json` for qcbox, `auth.json` for cxbox; ro for ompbox: `.env` and `agent/.env`). ### What credentials can reach the container Three independent channels; only one is gated by a flag. A container is credential-free only if **all three** carry no key: -| Channel | Always or opt-in? | ccbox | ocbox | qcbox | cxbox | -|---------|-------------------|-------|-------|-------|-------| -| **Credential store file** (OAuth session / API key written by the harness) | **Opt-in** — only with `--with-credentials` | `~/.claude/.credentials.json` | `~/.local/share/opencode/auth.json` | `~/.qwen/oauth_creds.json` | `~/.codex/auth.json` | -| **Main config** (the settings/config the harness needs to run; always mounted so the harness behaves correctly) | **Always** | `~/.claude/settings.json`, `settings.local.json`, `~/.claude.json` | whole `~/.config/opencode/` dir (incl. `opencode.json`) | `~/.qwen/settings.json` (+ home `~/.qwen/.env` ro if present) | `~/.codex/config.toml` (copied into the per-project dir at launch) | -| **Forwarded host env vars** (prefix match + a few specific vars; see `ENV_PASSTHROUGH_REGEX` in each wrapper) | **Always** | `ANTHROPIC_*`, `CLAUDE_CODE_*`, `CLAUDE_AX_*`, `CLAUDE_ENABLE_*`, `CLAUDE_AUTOCOMPACT_*`, `AWS_*`, `OTEL_*`, a few specific | `OPENCODE_*`, `ANTHROPIC_*`, `OPENAI_*`, `OPENROUTER_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*`, `AWS_*` | `QWEN_*`, `OPENAI_*`, `DASHSCOPE_*`, `BAILIAN_*`, `MODELSCOPE_*`, `OPENROUTER_*`, `ANTHROPIC_*`, `GEMINI_*`, `GOOGLE_*` | `CODEX_*`, `OPENAI_*`, `OPENROUTER_*`, `ANTHROPIC_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*`, `AWS_*` | +| Channel | Always or opt-in? | ccbox | ocbox | qcbox | cxbox | ompbox | +|---------|-------------------|-------|-------|-------|-------|--------| +| **Credential store file** (OAuth session / API key written by the harness) | **Opt-in** — only with `--with-credentials` | `~/.claude/.credentials.json` | `~/.local/share/opencode/auth.json` | `~/.qwen/oauth_creds.json` | `~/.codex/auth.json` | `~/.omp/.env` + `~/.omp/agent/.env` (ro); OAuth tokens live in the per-project `agent.db` instead and are never shared | +| **Main config** (the settings/config the harness needs to run; always mounted so the harness behaves correctly) | **Always** | `~/.claude/settings.json`, `settings.local.json`, `~/.claude.json` | whole `~/.config/opencode/` dir (incl. `opencode.json`) | `~/.qwen/settings.json` (+ home `~/.qwen/.env` ro if present) | `~/.codex/config.toml` (copied into the per-project dir at launch) | `~/.omp/agent/config.yml` (copied into the per-project dir at launch) | +| **Forwarded host env vars** (prefix match + a few specific vars; see `ENV_PASSTHROUGH_REGEX` in each wrapper) | **Always** | `ANTHROPIC_*`, `CLAUDE_CODE_*`, `CLAUDE_AX_*`, `CLAUDE_ENABLE_*`, `CLAUDE_AUTOCOMPACT_*`, `AWS_*`, `OTEL_*`, a few specific | `OPENCODE_*`, `ANTHROPIC_*`, `OPENAI_*`, `OPENROUTER_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*`, `AWS_*` | `QWEN_*`, `OPENAI_*`, `DASHSCOPE_*`, `BAILIAN_*`, `MODELSCOPE_*`, `OPENROUTER_*`, `ANTHROPIC_*`, `GEMINI_*`, `GOOGLE_*` | `CODEX_*`, `OPENAI_*`, `OPENROUTER_*`, `ANTHROPIC_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*`, `AWS_*` | `OMP_*`, `PI_*` (excluding the state-relocating vars), `ANTHROPIC_*`, `OPENAI_*`, `OPENROUTER_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*`, `QWEN_*`, `DASHSCOPE_*`, `COPILOT_*`, `AWS_*` | The key point that surprises people: **`--with-credentials` does not control the main config or the env vars.** If an API key is stored in the always-mounted config (an `"env"` block, or a provider `apiKey`/`envKey`), or is exported on the host and matches a forwarded prefix, it reaches the container whether or not `--with-credentials` is passed. That is the intended trade-off for mounting the config unconditionally — the harness needs its config, and whatever is inside that config comes along. See [usage.md → API Provider Configuration](usage.md#api-provider-configuration) and [usage.md → Credentials](usage.md#credentials) for how to keep a key out (forwarded env var, or a project-local override file instead of the shared config). -How each harness *consumes* a key differs — ccbox reads standard env vars directly, ocbox uses the store file or the provider's declared env var (custom providers use `{env:VAR}` in config), qcbox resolves the env var named by `envKey` with priority shell > auto-loaded `.env` > settings `env` block, and cxbox reads `OPENAI_API_KEY` directly or uses the ChatGPT OAuth session in `auth.json` (custom providers via `model_providers` in `config.toml`). Details in [usage.md](usage.md#how-each-harness-reads-the-key). +How each harness *consumes* a key differs — ccbox reads standard env vars directly, ocbox uses the store file or the provider's declared env var (custom providers use `{env:VAR}` in config), qcbox resolves the env var named by `envKey` with priority shell > auto-loaded `.env` > settings `env` block, cxbox reads `OPENAI_API_KEY` directly or uses the ChatGPT OAuth session in `auth.json` (custom providers via `model_providers` in `config.toml`), and ompbox reads a provider's standard env var directly or a dotenv value from `~/.omp/.env` / `~/.omp/agent/.env`. Details in [usage.md](usage.md#how-each-harness-reads-the-key). ### ccbox (Claude Code) @@ -179,16 +181,35 @@ An optional `~/.qwen/.env` is mounted read-only if present. Codex stores all state under `~/.codex` (`CODEX_HOME` is intentionally not forwarded into the container), keyed by project path. The per-project host directory is mounted as the container's entire `~/.codex`, so sessions, the state DB, and memories never mix across projects; the shared `config.toml` is **copied** into it at launch (seeded on first run, refreshed whenever the host file is newer) rather than bind-mounted, because Codex saves config edits (project trust, `/model`, …) by renaming a temp file over `config.toml`, which fails on a single-file bind mount. Settings changed inside the container therefore stay per-project and are replaced the next time the host file changes; edit `~/.codex/config.toml` on the host for global changes. With `--with-credentials` the shared `auth.json` is mounted on top so credentials stay global (Codex rewrites it in place, so a file mount is fine there). Without the flag `codex login` inside the container writes `auth.json` into the per-project data dir, so it does not persist to the host file or to other projects. +### ompbox (Oh My Pi) + +| Location | Purpose | Scope | +|----------|---------|-------| +| `~/.omp/agent/config.yml` | Global config (model, providers, settings); copied into each per-project dir at launch | Shared (source of truth) | +| `~/.omp/agent/{commands,rules,prompts,instructions,skills,hooks,tools,extensions}/` | Shareable config directories | Shared (rw, bind-mounted) | +| `~/.omp/agent/{SYSTEM.md,APPEND_SYSTEM.md,RULES.md,TITLE_SYSTEM.md,AGENTS.md,models.yml,mcp.json,keybindings.json}` | Shareable config files, when present on the host | Shared (ro, bind-mounted) | +| `~/.omp/.env`, `~/.omp/agent/.env` | Dotenv API keys (opt-in, `--with-credentials`) | Shared (ro, not mounted by default) | +| `~/.omp/ompbox-projects/{name}_{hash}/agent/` | `agent.db` (+ `-wal`/`-shm`), sessions, blobs, terminal sessions, history, and the `config.yml` copy (mounted as the container `~/.omp/agent`) | Per-project | + +Oh My Pi keeps three kinds of state, each with a different constraint on how it can be mounted: + +- **`agent.db`** is a SQLite database in WAL mode holding OAuth tokens, `/login`-saved API keys, settings, usage stats, model performance data, and the memory store — the memory store is keyed by working directory, which inside the container is always `/workspace`. Sharing it across projects would therefore mix every project's memories together, so it stays strictly per-project and is never bind-mounted as a single file (WAL mode also makes a single-file mount unsafe: the `-wal`/`-shm` companions need to live alongside it on the same filesystem). +- **`config.yml`** is saved by omp via write-temp-then-rename, the same pattern Codex uses for `config.toml`, so it cannot be a single-file bind mount (rename over a bind-mounted file fails with `EBUSY`). It is copied into the per-project dir at launch instead — seeded on first run, refreshed whenever the host file is newer — exactly like cxbox's `config.toml`. +- **Shared config directories and files** under `~/.omp/agent/` (commands, rules, prompts, instructions, skills, hooks, tools, extensions, plus the user-authored prompt/config files above) are ordinary files or directories that omp does not rewrite in place, so they are bind-mounted directly from the host: the directories read-write, the files read-only, and only when they exist on the host. + +`--with-credentials` mounts the two dotenv files read-only; it does not and cannot share `agent.db`, so `/login` (browser OAuth, or headless by pasting the callback URL) always stays local to the project it was run in. + ## Firewall When launched with `--with-firewall` (Linux only), outbound connections are restricted to an allowlist baked into each image at build time from two files: - `firewall-domains.txt` — common to all harnesses: GitHub (`github.com`, `api.github.com`, `objects.githubusercontent.com`, plus their IP ranges), npm registry, Python packages (`pypi.org`, `files.pythonhosted.org`), Rust packages (`crates.io`, `static.crates.io`) -- `firewall-domains-{claude,opencode,qwencode,codex}.txt` — harness-specific: +- `firewall-domains-{claude,opencode,qwencode,codex,omp}.txt` — harness-specific: - **claude**: `api.anthropic.com`, `statsig.anthropic.com`, `claude.ai`, `code.claude.com`, `sentry.io` - **opencode**: `opencode.ai`, `api.opencode.ai`, `models.dev`, plus common provider endpoints - **qwencode**: `chat.qwen.ai`, `portal.qwen.ai`, DashScope endpoints, plus common provider endpoints - **codex**: `api.openai.com`, `auth.openai.com`, `chatgpt.com`, plus common provider endpoints + - **omp**: `omp.sh` (install/update scripts), `my.omp.sh` (opt-in `/collab` relay), plus common provider endpoints (`api.anthropic.com`, `api.openai.com`, `auth.openai.com`, `chatgpt.com`, `generativelanguage.googleapis.com`, `openrouter.ai`, `api.groq.com`, `api.mistral.ai`, `api.x.ai`, `api.deepseek.com`) `init-firewall.sh` resolves the allowlist into an ipset at container start and installs default-deny iptables rules. diff --git a/docs/development.md b/docs/development.md index c7ed4d7..ea9c931 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,13 +6,13 @@ |------|-------------| | `Dockerfile.base` | Harness-independent base image (Fedora 44, OS packages, runtimes, tools), published as `quay.io/guimou/codebox-base` | | `Dockerfile` | Harness image built `FROM ${BASE_IMAGE}`, parameterized by `HARNESS`/`HARNESS_VERSION` build args | -| `lib/box-common.sh` | Shared launcher engine (sourced by all four launchers): runtime-neutral session spec plus the Podman runtime backend | +| `lib/box-common.sh` | Shared launcher engine (sourced by all five launchers): runtime-neutral session spec plus the Podman runtime backend | | `tests/render-test.sh` + `tests/golden/` | Golden test of the rendered `podman run` command line for every launcher (stub runtime, no container needed) | -| `ccbox` / `ocbox` / `qcbox` / `cxbox` | Host launch scripts (thin wrappers defining harness identity, mounts, and env passthrough) | -| `CLAUDE_VERSION` / `OPENCODE_VERSION` / `QWENCODE_VERSION` / `CODEX_VERSION` | Harness version pin files | +| `ccbox` / `ocbox` / `qcbox` / `cxbox` / `ompbox` | Host launch scripts (thin wrappers defining harness identity, mounts, and env passthrough) | +| `CLAUDE_VERSION` / `OPENCODE_VERSION` / `QWENCODE_VERSION` / `CODEX_VERSION` / `OMP_VERSION` | Harness version pin files | | `os-packages.txt` | DNF packages to install (one per line) | | `firewall-domains.txt` | Allowed network domains common to all harnesses | -| `firewall-domains-{claude,opencode,qwencode,codex}.txt` | Harness-specific allowed domains, concatenated with the common file at build time | +| `firewall-domains-{claude,opencode,qwencode,codex,omp}.txt` | Harness-specific allowed domains, concatenated with the common file at build time | | `init-firewall.sh` | Firewall initialization script (iptables/ipset) | | `.github/workflows/release.yml` | Release workflow (detects which harnesses to build, builds the base once, then one job per harness) | | `.github/workflows/build-base.yml` | Reusable base image build (content-tagged, skipped when the tag exists) | @@ -39,6 +39,7 @@ See [architecture.md](architecture.md) for how the base image, harness Dockerfil ./ocbox --build ./qcbox --build ./cxbox --build +./ompbox --build # Also build the base image locally (after editing Dockerfile.base / os-packages.txt) ./ccbox --build-base @@ -85,7 +86,7 @@ DEBUG=1 ccbox Launchers, the engine and the test script are shellcheck-clean: ```bash -shellcheck ccbox ocbox qcbox cxbox lib/box-common.sh init-firewall.sh tests/render-test.sh k8s/entrypoint.sh k8s/gen-egress-firewall.sh +shellcheck ccbox ocbox qcbox cxbox ompbox lib/box-common.sh init-firewall.sh tests/render-test.sh k8s/entrypoint.sh k8s/gen-egress-firewall.sh ``` The kustomizations render without a cluster: `kubectl kustomize k8s/overlays/example` and `kubectl kustomize k8s/cluster`. @@ -111,15 +112,27 @@ Runs on pushes to `main` touching image/launcher files, and detects which harnes | Base input (`Dockerfile.base`, `os-packages.txt`, `init-firewall.sh`) | Rebuild the base, then **all** harnesses | `{box}-v{version}-N` | `{version}-N` | | Shared harness file (`Dockerfile`, `firewall-domains.txt`, `lib/`) | Rebuild **all** harnesses (base reused) | `{box}-v{version}-N` | `{version}-N` | | Harness-specific file (launcher script, firewall overlay) | Rebuild **only** that harness | `{box}-v{version}-N` | `{version}-N` | -| Weekly schedule / manual "rebuild all" dispatch | Force-rebuild the base (Fedora updates), then **all** harnesses | `{box}-v{version}-N` | `{version}-N` | +| Weekly schedule / manual "rebuild all" dispatch | Bump the version pins to the latest upstream releases, force-rebuild the base (Fedora updates), then **all** harnesses | `{box}-v{version}` for bumped harnesses, `{box}-v{version}-N` for the others | `{version}` / `{version}-N` | The `-N` suffix increments from existing `{box}-v{version}-*` tags. If the computed tag already exists, that harness is skipped. -The run is structured as three jobs: +The run is structured as four jobs: -1. **detect** — computes the harness matrix above. -2. **base** — runs only if at least one harness needs a build. Calls `build-base.yml`, which computes the base content tag and skips the build when that tag already exists in `quay.io/guimou/codebox-base` (a forced refresh overwrites it). -3. **build** — one job per harness in the matrix, `fail-fast: false`, each calling `build-and-push.yml` with the base tag from step 2 and its git tag. Every harness job builds, pushes, tags and creates its GitHub Release on its own, so a failure in one harness never blocks the others. Changelog ranges use the previous `{box}-v*` tag (with a fallback to legacy unprefixed `v*` tags for ccbox). +1. **bump** — on the weekly schedule (or a dispatch with `bump_versions`), looks up the latest upstream version of each harness (npm dist-tag `latest` for `@anthropic-ai/claude-code`, `opencode-ai`, `@qwen-code/qwen-code`, `@openai/codex`; the latest GitHub release for `can1357/oh-my-pi`), only ever moves a pin forward, commits the changed `*_VERSION` files to `main` as `chore: bump harness versions [skip ci]` and hands the new commit to the next jobs (the `[skip ci]` keeps that push from starting a second release run; this run builds the bump commit itself). On any other trigger the job is a no-op. + + The push uses a write-enabled **deploy key**: on a personal repository the `PR for main` ruleset cannot be bypassed by the GitHub Actions app, only by deploy keys. One-time setup: + + ```bash + ssh-keygen -t ed25519 -N '' -C 'ccbox release bump' -f bump-key + gh repo deploy-key add bump-key.pub --title 'release bump' --allow-write + gh secret set BUMP_DEPLOY_KEY < bump-key + rm bump-key bump-key.pub + ``` + + then, in Settings → Rules → Rulesets → `PR for main` → Add bypass, tick **Deploy keys**. Without the secret the bump job fails with an explicit error and nothing is built. +2. **detect** — computes the harness matrix above from the bump commit. +3. **base** — runs only if at least one harness needs a build. Calls `build-base.yml`, which computes the base content tag and skips the build when that tag already exists in `quay.io/guimou/codebox-base` (a forced refresh overwrites it). +4. **build** — one job per harness in the matrix, `fail-fast: false`, each calling `build-and-push.yml` with the base tag from step 2 and its git tag. Every harness job builds, pushes, tags and creates its GitHub Release on its own, so a failure in one harness never blocks the others. Changelog ranges use the previous `{box}-v*` tag (with a fallback to legacy unprefixed `v*` tags for ccbox). ### Base workflow (`build-base.yml`) @@ -127,11 +140,11 @@ Reusable workflow (also manually dispatchable with a `force` input). The base ta ### Build workflow (`build-and-push.yml`) -Reusable workflow called once per harness by the release matrix, also manually dispatchable from the Actions UI with a `harness` input (`claude` / `opencode` / `qwencode` / `codex`) and optional version/tag/base overrides. It resolves the harness to its image repository and version file, verifies the base tag exists (derived from the commit when not given), builds `Dockerfile` with `BASE_IMAGE`, `HARNESS` and `HARNESS_VERSION` build args, pushes, and, when called with a `git_tag`, creates the git tag and GitHub Release. Build caches are scoped per harness (`type=gha,scope={harness}`). +Reusable workflow called once per harness by the release matrix, also manually dispatchable from the Actions UI with a `harness` input (`claude` / `opencode` / `qwencode` / `codex` / `omp`) and optional version/tag/base overrides. An optional `ref` input selects the commit to build and tag (the release workflow passes its version-bump commit). It resolves the harness to its image repository and version file, verifies the base tag exists (derived from the commit when not given), builds `Dockerfile` with `BASE_IMAGE`, `HARNESS` and `HARNESS_VERSION` build args, pushes, and, when called with a `git_tag`, creates the git tag and GitHub Release. Build caches are scoped per harness (`type=gha,scope={harness}`). ### Image tags -Each harness pushes to its own repository (`quay.io/guimou/ccbox`, `quay.io/guimou/ocbox`, `quay.io/guimou/qcbox`, `quay.io/guimou/cxbox`) with tags: +Each harness pushes to its own repository (`quay.io/guimou/ccbox`, `quay.io/guimou/ocbox`, `quay.io/guimou/qcbox`, `quay.io/guimou/cxbox`, `quay.io/guimou/ompbox`) with tags: | Tag | Description | |-----|-------------| @@ -141,8 +154,10 @@ Each harness pushes to its own repository (`quay.io/guimou/ccbox`, `quay.io/guim ### Releasing a new harness version +The weekly run bumps every pin automatically (see above). To release a specific version by hand: + ```bash -echo "2.1.37" > CLAUDE_VERSION # or OPENCODE_VERSION / QWENCODE_VERSION / CODEX_VERSION +echo "2.1.37" > CLAUDE_VERSION # or OPENCODE_VERSION / QWENCODE_VERSION / CODEX_VERSION / OMP_VERSION git add CLAUDE_VERSION git commit -m "chore: bump Claude Code version to 2.1.37" git push origin main @@ -155,7 +170,7 @@ To enable CI/CD pushes, configure GitHub repository secrets: 1. **Create a Quay.io robot account:** - Log in to [quay.io](https://quay.io) → Account Settings → Robot Accounts - Create a robot account (e.g., `github_actions`) - - Grant **Write** permission to the `guimou/codebox-base`, `guimou/ccbox`, `guimou/ocbox`, `guimou/qcbox`, and `guimou/cxbox` repositories + - Grant **Write** permission to the `guimou/codebox-base`, `guimou/ccbox`, `guimou/ocbox`, `guimou/qcbox`, `guimou/cxbox`, and `guimou/ompbox` repositories 2. **Add GitHub secrets** (repository Settings → Secrets and variables → Actions): | Secret | Value | @@ -169,4 +184,5 @@ To enable CI/CD pushes, configure GitHub repository secrets: podman pull quay.io/guimou/ccbox:latest podman pull quay.io/guimou/ocbox: podman pull quay.io/guimou/cxbox: +podman pull quay.io/guimou/ompbox: ``` diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 244c3fd..6cca410 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -1,6 +1,6 @@ # Running on Kubernetes / OpenShift -The launchers can run inside a long-lived pod instead of on your workstation. The pod plays the role of the host: you open a shell in it, `cd` into a repository under `~/repos`, and run `ccbox` / `ocbox` / `qcbox` / `cxbox` exactly as on a laptop. The harness then runs in an [Apptainer](https://apptainer.org/) container started from a SIF converted from the same `quay.io/guimou/` image you use with Podman. +The launchers can run inside a long-lived pod instead of on your workstation. The pod plays the role of the host: you open a shell in it, `cd` into a repository under `~/repos`, and run `ccbox` / `ocbox` / `qcbox` / `cxbox` / `ompbox` exactly as on a laptop. The harness then runs in an [Apptainer](https://apptainer.org/) container started from a SIF converted from the same `quay.io/guimou/` image you use with Podman. What you keep from the workstation setup: @@ -96,7 +96,7 @@ The launchers inside the pod have no version pin files, so they use `latest` unl | Path | Purpose | |---|---| | `~/repos/` | Repositories. One session sees exactly one of them | -| `~/.claude/`, `~/.config/opencode/`, `~/.qwen/`, `~/.codex/` | Shared harness config and per-project state, as on a host (see [architecture.md](architecture.md)) | +| `~/.claude/`, `~/.config/opencode/`, `~/.qwen/`, `~/.codex/`, `~/.omp/` | Shared harness config and per-project state, as on a host (see [architecture.md](architecture.md)) | | `~/.claude/.credentials.json` and the other credential files | Credentials, opt-in per launch with `--with-credentials`, as on a host | | `~/.codebox/sifs/` | SIF store, shared by every pod using this PVC | | `~/.codebox/sessions/` | Session markers for `--list-sessions` | diff --git a/docs/plans/kubernetes-apptainer.md b/docs/plans/kubernetes-apptainer.md index b818db3..45dbbb6 100644 --- a/docs/plans/kubernetes-apptainer.md +++ b/docs/plans/kubernetes-apptainer.md @@ -25,7 +25,7 @@ harness that can only see that project plus explicitly shared config), but with long-lived pod as the "host" instead of a workstation. - The pod is the host. You `oc rsh` into it (or attach to tmux in it), navigate - the shared filesystem, and run `ccbox` / `ocbox` / `qcbox` / `cxbox` as today. + the shared filesystem, and run `ccbox` / `ocbox` / `qcbox` / `cxbox` / `ompbox` as today. - Harness containers become Apptainer runs of a SIF built from the existing OCI images. No new harness image pipeline. - One RWX PVC holds everything: home directory, projects, per-project harness @@ -158,7 +158,7 @@ is in the SIFs, the pod only hosts tmux and Apptainer. Contents: `/etc/hosts` handling; the spike hit this). - `tmux`, `git`, `jq`, `vim-minimal`, `openssh-clients`, `bind-utils` from BaseOS/AppStream; `gh` from the GitHub CLI RPM repository. -- The four launchers and `lib/*.sh` copied to `/usr/local/bin` (flat layout). +- The five launchers and `lib/*.sh` copied to `/usr/local/bin` (flat layout). - `/etc/apptainer/apptainer.conf` with a raised `sessiondir max size`. - Env: `CODEBOX_RUNTIME=apptainer`, `CODEBOX_SIF_DIR=/home/coder/.codebox/sifs`, `APPTAINER_TMPDIR=/scratch`, `APPTAINER_CACHEDIR=/scratch/cache`, diff --git a/docs/usage.md b/docs/usage.md index 6b749a7..c9c6f39 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,6 +1,6 @@ # Usage -All four launchers (`ccbox`, `ocbox`, `qcbox`, `cxbox`) share the same common flags; only the version flag and a few harness-specific options differ. +All five launchers (`ccbox`, `ocbox`, `qcbox`, `cxbox`, `ompbox`) share the same common flags; only the version flag and a few harness-specific options differ. ## Basics @@ -10,12 +10,14 @@ ccbox # Claude Code ocbox # OpenCode qcbox # Qwen Code cxbox # Codex CLI +ompbox # Oh My Pi # Use a specific harness version (if a container build exists for it) ccbox --claude-version ocbox --opencode-version qcbox --qwen-version cxbox --codex-version +ompbox --omp-version # Pass arguments directly to the harness CLI ccbox -- --help @@ -23,7 +25,7 @@ ccbox -- --version ocbox -- run "explain this repo" ``` -The container image is automatically pulled from `quay.io/guimou/{ccbox,ocbox,qcbox,cxbox}` on first run. Unknown flags are passed through to the harness CLI. +The container image is automatically pulled from `quay.io/guimou/{ccbox,ocbox,qcbox,cxbox,ompbox}` on first run. Unknown flags are passed through to the harness CLI. ## Common Flags @@ -46,7 +48,7 @@ The container image is automatically pulled from `quay.io/guimou/{ccbox,ocbox,qc | `--install` | Show OS/shell-specific installation instructions | | `--` | Everything after is passed to the harness CLI | -ccbox-only flags: `--with-teams`, `--with-tmux`, `--safe-mode`. `--with-credentials` is available on all four launchers (see below). +ccbox-only flags: `--with-teams`, `--with-tmux`, `--safe-mode`. `--with-credentials` is available on all five launchers (see below). ## Runtimes @@ -77,7 +79,7 @@ Apptainer environment variables: - Each project directory gets isolated history/session data, keyed by a hash of its path — two projects with the same name in different locations don't collide. - You can run **multiple sessions simultaneously** in the same project. Each session gets a unique container name; project data is shared between them. -- Chat transcripts persist in the per-project data dir, so you can resume past conversations after a container exits: `ccbox -- --resume`, `ocbox -- --continue` (or `-c`), `qcbox -- --resume`, `cxbox -- resume`. +- Chat transcripts persist in the per-project data dir, so you can resume past conversations after a container exits: `ccbox -- --resume`, `ocbox -- --continue` (or `-c`), `qcbox -- --resume`, `cxbox -- resume`, `ompbox -- --continue` (or `ompbox -- --resume [id]`). - See [architecture.md](architecture.md) for exactly what is mounted, shared, and isolated per harness. ## API Provider Configuration @@ -92,8 +94,9 @@ Every launcher always forwards the host environment variables matching its harne | `ocbox` | `OPENCODE_*`, `ANTHROPIC_*`, `OPENAI_*`, `OPENROUTER_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*` | `AWS_REGION`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`, plus `NODE_OPTIONS`, `NO_COLOR`, `FORCE_COLOR` | the whole `~/.config/opencode/` directory (incl. `opencode.json`) | | `qcbox` | `QWEN_*`, `OPENAI_*`, `DASHSCOPE_*`, `BAILIAN_*`, `MODELSCOPE_*`, `OPENROUTER_*`, `ANTHROPIC_*`, `GEMINI_*`, `GOOGLE_*` | `NODE_OPTIONS`, `NO_COLOR`, `FORCE_COLOR`, `NODE_EXTRA_CA_CERTS` (non-credential) | `~/.qwen/settings.json` (a home-level `~/.qwen/.env` is also mounted read-only if present) | | `cxbox` | `CODEX_*`, `OPENAI_*`, `OPENROUTER_*`, `ANTHROPIC_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*` | `AWS_REGION`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`, plus `NODE_OPTIONS`, `NO_COLOR`, `FORCE_COLOR` | `~/.codex/config.toml` (copied into the per-project dir at launch; refreshed when the host file is newer) | +| `ompbox` | `OMP_*`, `PI_*` (except `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR`, `PI_CONFIG_FILES`, `PI_PROFILE`, `OMP_PROFILE`, `PI_INSTALL_DIR` — these would relocate omp's state or override the baked config overlay), `ANTHROPIC_*`, `OPENAI_*`, `OPENROUTER_*`, `GEMINI_*`, `GOOGLE_*`, `AZURE_*`, `DEEPSEEK_*`, `MISTRAL_*`, `XAI_*`, `GROQ_*`, `QWEN_*`, `DASHSCOPE_*`, `COPILOT_*` | `AWS_REGION`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`, plus `NODE_OPTIONS`, `NO_COLOR`, `FORCE_COLOR` | `~/.omp/agent/config.yml` (copied into the per-project dir at launch; refreshed when the host file is newer) | -GitHub token injection (`GH_TOKEN`) is separate and works the same for all four (see [GitHub Authentication](#github-authentication)). +GitHub token injection (`GH_TOKEN`) is separate and works the same for all five (see [GitHub Authentication](#github-authentication)). > **Consequence:** if your harness's main config file contains API keys (a `"env"` block or a provider definition with an `apiKey`/`envKey`), those files are mounted into the container **regardless of `--with-credentials`** — that flag only controls the separate OAuth/credential store file (see [Credentials](#credentials)). If that is not what you want, don't keep keys in the shared config: use a shell export (or per-project override below), or a file that is *not* mounted. @@ -146,10 +149,11 @@ The forwarding above gets variables *into* the container; how the harness picks - **ocbox (OpenCode)** — for built-in providers, the key comes from `auth.json` (via `/connect` — that's the file `--with-credentials` shares) *or* from the standard env var the provider declares, e.g. `ANTHROPIC_API_KEY` (Anthropic), `OPENAI_API_KEY` (OpenAI), `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `XAI_API_KEY`, `AZURE_API_KEY` + `AZURE_RESOURCE_NAME` (Azure), `AWS_*` (Bedrock) — all covered by the forwarded prefixes. For **custom** providers, use substitution in the config instead: `"apiKey": "{env:MY_KEY}"`. OpenCode does **not** auto-load `.env` files. - **qcbox (Qwen Code)** — reads the API key from the env var named by `envKey` in your `modelProviders` entry (or the auth type's default, e.g. `OPENAI_API_KEY`, `DASHSCOPE_API_KEY`). Priority: shell environment > auto-loaded `.env` file > `"env"` block in settings. Qwen Code auto-loads the **first** `.env` it finds walking up from the project root: `.qwen/.env`, then `.env` (fallback: `~/.qwen/.env`, `~/.env`) — only the first file is used, and it never overrides already-set variables. - **cxbox (Codex CLI)** — with a ChatGPT account, sign in via `codex login` (the OAuth session lands in `auth.json`, the file `--with-credentials` shares). With an API key, set `OPENAI_API_KEY` (forwarded from the host) or put a `model_providers` entry in `~/.codex/config.toml`. Codex does **not** auto-load `.env` files; it reads env vars directly. +- **ompbox (Oh My Pi)** — for the 60+ built-in providers, `omp` reads the provider's standard env var directly (`ANTHROPIC_API_KEY`/`ANTHROPIC_OAUTH_TOKEN`, `OPENAI_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `XAI_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `DEEPSEEK_API_KEY`, `QWEN_OAUTH_TOKEN`, `COPILOT_GITHUB_TOKEN`, `AZURE_*`, …), or a dotenv value from `~/.omp/.env` / `~/.omp/agent/.env` (mounted read-only with `--with-credentials`). OAuth via `/login ` writes into the per-project `agent.db`, which is never shared across projects; headless environments can paste the callback URL instead (`/login `). ### Per-project overrides (keep the shared config key-free) -Each project is mounted at `/workspace`, so a project-local config file lives inside the project and **overrides the shared global config** for sessions started there — without touching your `~/.claude/`, `~/.config/opencode/`, `~/.qwen/`, or `~/.codex/` files: +Each project is mounted at `/workspace`, so a project-local config file lives inside the project and **overrides the shared global config** for sessions started there — without touching your `~/.claude/`, `~/.config/opencode/`, `~/.qwen/`, `~/.codex/`, or `~/.omp/` files: | Launcher | Project-local override (lives in the project, read-write) | |----------|-----------------------------------------------------------| @@ -157,6 +161,7 @@ Each project is mounted at `/workspace`, so a project-local config file lives in | `ocbox` | `opencode.json` / `opencode.jsonc` in the project root (or nearest git root). Project config overrides global `~/.config/opencode/opencode.json` for conflicting keys. | | `qcbox` | `.qwen/settings.json` in the project root (project settings override user settings), and/or a project `.qwen/.env` / `.env` for keys. | | `cxbox` | `AGENTS.md` in the project root (or nearest git root) for per-project instructions; `codex.md` for memory. (The `model_providers`/key config itself is global `~/.codex/config.toml` — use a forwarded env var for per-project keys.) | +| `ompbox` | `.omp/` in the project root (config.yml, SYSTEM.md, skills/, …), plus `AGENTS.md` — `omp` also reads project-local `.env`, `.claude/`, and `.codex/`. | Example — per-project key, shared config stays clean (ccbox, shown for the others by analogy): @@ -188,13 +193,13 @@ Note: because project-local files sit in the workspace, they are visible to what ## Pin a Version (for teams) -Each harness has its own version pin file in the repo directory: `CLAUDE_VERSION` (ccbox), `OPENCODE_VERSION` (ocbox), `QWENCODE_VERSION` (qcbox), `CODEX_VERSION` (cxbox): +Each harness has its own version pin file in the repo directory: `CLAUDE_VERSION` (ccbox), `OPENCODE_VERSION` (ocbox), `QWENCODE_VERSION` (qcbox), `CODEX_VERSION` (cxbox), `OMP_VERSION` (ompbox): ```bash echo "" > ~/path/to/ccbox/CLAUDE_VERSION ``` -This ensures everyone uses the same version. The `--claude-version` / `--opencode-version` / `--qwen-version` / `--codex-version` flags override the respective file. +This ensures everyone uses the same version. The `--claude-version` / `--opencode-version` / `--qwen-version` / `--codex-version` / `--omp-version` flags override the respective file. Version pin files only exist in clone-based installs (the launcher looks for them next to its resolved location). With a flat install (scripts copied to `~/.local/bin`), the image tag defaults to `latest` — use the version flag to pin. @@ -227,7 +232,7 @@ ccbox --github-token "ghp_xxx" # Use specific token instead of auto-detectin There are **two different things** that can hold credentials, and only one of them is gated by a flag: 1. **The harness credential store file** — a dedicated file each harness writes for OAuth sessions / API keys. This is **not** mounted by default. This is the only thing `--with-credentials` controls. -2. **The shared config file(s)** — the main settings/config each launcher always mounts (ccbox `~/.claude/settings.json`, ocbox `~/.config/opencode/opencode.json`, qcbox `~/.qwen/settings.json`, cxbox `~/.codex/config.toml`, copied into the per-project dir at launch). If *you* put an API key inside one of these (an `"env"` block, or a provider's `apiKey`/`envKey`), that file travels into the container **with or without `--with-credentials`**, because the config must be mounted for the harness to behave correctly. This is a deliberate design trade-off: the config is always available, so any secret you store in it is always available too. +2. **The shared config file(s)** — the main settings/config each launcher always mounts (ccbox `~/.claude/settings.json`, ocbox `~/.config/opencode/opencode.json`, qcbox `~/.qwen/settings.json`, cxbox `~/.codex/config.toml`, ompbox `~/.omp/agent/config.yml`, copied into the per-project dir at launch). If *you* put an API key inside one of these (an `"env"` block, or a provider's `apiKey`/`envKey`), that file travels into the container **with or without `--with-credentials`**, because the config must be mounted for the harness to behave correctly. This is a deliberate design trade-off: the config is always available, so any secret you store in it is always available too. So: omitting `--with-credentials` does **not** guarantee a credential-free container — it only keeps the dedicated credential store file private. If you store keys in your main config, they are passed regardless. To keep the container free of a given key, don't put it in the shared config; use a forwarded environment variable (see [API Provider Configuration](#api-provider-configuration)) or a project-local override file that lives in the project, not in the shared home dir. Without any of these, plain API-key auth still needs nothing extra — provider keys are forwarded from the host environment automatically. @@ -238,9 +243,12 @@ ccbox --with-credentials # mount ~/.claude/.credentials.json (API key or OAuth ocbox --with-credentials # mount ~/.local/share/opencode/auth.json (provider credentials) qcbox --with-credentials # mount ~/.qwen/oauth_creds.json (Qwen OAuth) cxbox --with-credentials # mount ~/.codex/auth.json (API key or ChatGPT OAuth) +ompbox --with-credentials # mount ~/.omp/.env and ~/.omp/agent/.env, read-only (dotenv API keys) ``` -The file is mounted read-write, created (empty) on the host if it does not exist yet. Without the flag, the container uses its own empty credential file — so `claude /login` (ccbox), `opencode auth login` (ocbox), the `/auth` flow (qcbox), or `codex login` (cxbox) inside the container does not persist to the host. +The file is mounted read-write, created (empty) on the host if it does not exist yet — except ompbox's, which are mounted read-only. Without the flag, the container uses its own empty credential file — so `claude /login` (ccbox), `opencode auth login` (ocbox), the `/auth` flow (qcbox), or `codex login` (cxbox) inside the container does not persist to the host. + +ompbox is different: OAuth and `/login`-saved API keys live in the per-project `agent.db` (a SQLite database), not in a dotenv file, so `--with-credentials` cannot share them — `/login` (browser flow, or headless by pasting the callback URL) always stays local to the project it was run in. `--with-credentials` only shares dotenv-style keys placed in `~/.omp/.env` or `~/.omp/agent/.env`. ## Firewall diff --git a/firewall-domains-omp.txt b/firewall-domains-omp.txt new file mode 100644 index 0000000..eba7dce --- /dev/null +++ b/firewall-domains-omp.txt @@ -0,0 +1,23 @@ +# Allowed domains for the firewall - Oh My Pi specific + +# Anthropic API (Claude models) +api.anthropic.com + +# OpenAI API (model responses for API-key auth) and OAuth (ChatGPT sign-in) +api.openai.com +auth.openai.com +chatgpt.com + +# Other supported providers +generativelanguage.googleapis.com +openrouter.ai +api.groq.com +api.mistral.ai +api.x.ai +api.deepseek.com + +# Oh My Pi install/update scripts and website +omp.sh + +# Optional /collab relay (opt-in feature, off by default) +my.omp.sh diff --git a/init-firewall.sh b/init-firewall.sh index 1b3d94c..8f4819d 100644 --- a/init-firewall.sh +++ b/init-firewall.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Firewall initialization script for the harness container (ccbox/ocbox/qcbox/cxbox) +# Firewall initialization script for the harness container (ccbox/ocbox/qcbox/cxbox/ompbox) # Restricts network access to allowed domains only set -e diff --git a/k8s/Containerfile b/k8s/Containerfile index 4d198be..3f667b3 100644 --- a/k8s/Containerfile +++ b/k8s/Containerfile @@ -1,7 +1,7 @@ # codebox-pod: the slim image a long-lived coding pod runs on Kubernetes / # OpenShift. The pod plays the role of the workstation: you rsh into it, -# cd into a repo under ~/repos and run ccbox / ocbox / qcbox / cxbox exactly -# as on a host. The launchers then start the harness with Apptainer from a +# cd into a repo under ~/repos and run ccbox / ocbox / qcbox / cxbox / ompbox +# exactly as on a host. The launchers then start the harness with Apptainer from a # SIF converted from the regular quay.io/guimou/ image. # # Nothing development-related is installed here: every tool the harness may @@ -78,8 +78,8 @@ RUN groupadd -g 1000 coder && \ # Launchers and the shared engine, flat layout (box-common.sh next to the # launchers, as for a ~/.local/bin install) -COPY ccbox ocbox qcbox cxbox lib/box-common.sh k8s/entrypoint.sh /usr/local/bin/ -RUN chmod 0755 /usr/local/bin/ccbox /usr/local/bin/ocbox /usr/local/bin/qcbox /usr/local/bin/cxbox \ +COPY ccbox ocbox qcbox cxbox ompbox lib/box-common.sh k8s/entrypoint.sh /usr/local/bin/ +RUN chmod 0755 /usr/local/bin/ccbox /usr/local/bin/ocbox /usr/local/bin/qcbox /usr/local/bin/cxbox /usr/local/bin/ompbox \ /usr/local/bin/box-common.sh /usr/local/bin/entrypoint.sh ENV HOME=/home/coder \ diff --git a/k8s/gen-egress-firewall.sh b/k8s/gen-egress-firewall.sh index fe126a3..25cf651 100755 --- a/k8s/gen-egress-firewall.sh +++ b/k8s/gen-egress-firewall.sh @@ -8,7 +8,7 @@ # k8s/gen-egress-firewall.sh [harness...] > egressfirewall.yaml # oc apply -n -f egressfirewall.yaml # -# With no argument all four harness overlays are included (one pod serves +# With no argument all five harness overlays are included (one pod serves # all launchers). Only one EgressFirewall per namespace is allowed, and it # must be named "default". Cluster-internal traffic (DNS, the image # registry, the API) is not subject to EgressFirewall rules; pulling the @@ -21,7 +21,7 @@ set -euo pipefail REPO_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" harnesses=("$@") -[[ ${#harnesses[@]} -gt 0 ]] || harnesses=(claude opencode qwencode codex) +[[ ${#harnesses[@]} -gt 0 ]] || harnesses=(claude opencode qwencode codex omp) files=("${REPO_DIR}/firewall-domains.txt") for h in "${harnesses[@]}"; do diff --git a/k8s/overlays/example/codebox.env.example b/k8s/overlays/example/codebox.env.example index c4c6df3..fd5c4a4 100644 --- a/k8s/overlays/example/codebox.env.example +++ b/k8s/overlays/example/codebox.env.example @@ -6,7 +6,7 @@ # GitHub: a fine-grained PAT or OAuth token (gh in the pod and the harness # both use it) GH_TOKEN= -# Anthropic (ccbox), or use Vertex AI / Bedrock variables instead +# Anthropic (ccbox, ompbox), or use Vertex AI / Bedrock variables instead ANTHROPIC_API_KEY= -# OpenAI (cxbox, ocbox, qcbox) +# OpenAI (cxbox, ocbox, qcbox, ompbox) OPENAI_API_KEY= diff --git a/lib/box-common.sh b/lib/box-common.sh index f8917dd..c353ade 100644 --- a/lib/box-common.sh +++ b/lib/box-common.sh @@ -1,9 +1,9 @@ #!/bin/bash -# Shared launcher engine for the harness containers (ccbox / ocbox / qcbox / cxbox). +# Shared launcher engine for the harness containers (ccbox / ocbox / qcbox / cxbox / ompbox). # # This file is sourced by the thin per-harness wrappers, which must define # the following variables before sourcing: -# BOX_NAME - launcher/image name (ccbox, ocbox, qcbox, cxbox) +# BOX_NAME - launcher/image name (ccbox, ocbox, qcbox, cxbox, ompbox) # HARNESS_TITLE - human-readable harness name (e.g. "Claude Code") # HARNESS_CLI - CLI binary to run inside the container # REGISTRY_IMAGE - registry repository (e.g. "guimou/ccbox") @@ -11,6 +11,12 @@ # VERSION_FILE - version pin file in the repo (e.g. "CLAUDE_VERSION") # ENV_PASSTHROUGH_REGEX - grep -E regex of env var prefixes to forward # ENV_PASSTHROUGH_VARS - array of specific env var names to forward +# ENV_PASSTHROUGH_EXCLUDE_REGEX - optional grep -E regex of env var names to +# never forward, even if ENV_PASSTHROUGH_REGEX +# matches them (e.g. a var that must always come +# from the mounted layout, not the host). Defaults +# to empty (nothing excluded) when a wrapper does +# not set it. # # And the following hook functions (all optional, defaults are no-ops): # harness_parse_arg "$@" - consume harness-specific CLI args; set ARG_SHIFT @@ -1044,8 +1050,10 @@ box_main() { fi # Generic passthrough: forward harness-relevant env vars from host + : "${ENV_PASSTHROUGH_EXCLUDE_REGEX:=}" if [[ -n "$ENV_PASSTHROUGH_REGEX" ]]; then while IFS='=' read -r name value; do + [[ -n "$ENV_PASSTHROUGH_EXCLUDE_REGEX" && "$name" =~ $ENV_PASSTHROUGH_EXCLUDE_REGEX ]] && continue add_env "$name" "$value" done < <(env | grep -E "$ENV_PASSTHROUGH_REGEX") fi diff --git a/ompbox b/ompbox new file mode 100755 index 0000000..322bda2 --- /dev/null +++ b/ompbox @@ -0,0 +1,232 @@ +#!/bin/bash +# Oh My Pi Development Container Launch Script +# Launches a containerized Oh My Pi (omp) environment with the current +# directory mounted +# +# Harness-specific wrapper around lib/box-common.sh. +# +# Oh My Pi state layout (user root ~/.omp, agent dir ~/.omp/agent): +# ~/.omp/agent/agent.db(+-wal,-shm) SQLite (WAL mode): OAuth tokens, +# /login-saved API keys, settings, usage +# stats, model perf, AND the memory store +# (keyed by cwd). There is no auth.json. +# ~/.omp/agent/sessions/, blobs/, terminal-sessions/, history.db +# per-project runtime state +# ~/.omp/agent/config.yml global settings (YAML) +# ~/.omp/agent/{commands,rules,prompts,instructions,skills,hooks,tools,extensions}/ +# shareable config directories +# ~/.omp/agent/{SYSTEM,APPEND_SYSTEM,RULES,TITLE_SYSTEM}.md, AGENTS.md, +# models.yml, mcp.json, keybindings.json +# shareable, user-authored config files +# ~/.omp/.env, ~/.omp/agent/.env dotenv credential files (API keys) +# +# Three constraints drive the layout below: +# 1. agent.db is a WAL-mode SQLite database: its -wal/-shm sidecars must +# live next to it, so it can never be a single-file bind mount (the +# way the other launchers share their auth.json); it lives in the +# per-project agent dir instead. +# 2. Every project is mounted at /workspace inside the container, so omp's +# memory store (keyed by cwd) would mix memories from different host +# projects if agent.db were shared. Hence the per-project isolation +# below, exactly like agent.db. +# 3. omp persists config.yml edits by writing a temp file and renaming it +# over config.yml; a rename onto a single-file bind mount fails with +# EBUSY. So config.yml is COPIED into the per-project dir at launch +# (seeded on first run, refreshed whenever the host file is newer), +# exactly like cxbox does for config.toml. Edits made inside the +# container therefore stay per-project; the host file is the source of +# truth. The other shareable config dirs/files above have no such +# constraint and are bind-mounted directly. + +set -e + +# Resolve the real script location (follows symlinks, e.g. ~/.local/bin/ompbox) +WRAPPER_SOURCE="${BASH_SOURCE[0]}" +while [[ -h "$WRAPPER_SOURCE" ]]; do + WRAPPER_DIR="$(cd -P "$(dirname "$WRAPPER_SOURCE")" && pwd)" + WRAPPER_SOURCE="$(readlink "$WRAPPER_SOURCE")" + [[ "$WRAPPER_SOURCE" != /* ]] && WRAPPER_SOURCE="${WRAPPER_DIR}/${WRAPPER_SOURCE}" +done +SCRIPT_DIR="$(cd -P "$(dirname "$WRAPPER_SOURCE")" && pwd)" +WRAPPER_PATH="${SCRIPT_DIR}/$(basename "$WRAPPER_SOURCE")" + +# Harness identity +BOX_NAME="ompbox" +HARNESS="omp" +HARNESS_TITLE="Oh My Pi" +HARNESS_CLI="omp" +REGISTRY_IMAGE="guimou/ompbox" +VERSION_FLAG="--omp-version" +VERSION_FILE="OMP_VERSION" + +# Harness-specific flags +WITH_CREDENTIALS=false + +# Shareable config directories / files under ~/.omp/agent (bind-mounted +# directly; no rename-on-save constraint like config.yml has) +readonly OMP_SHARED_DIRS=(commands rules prompts instructions skills hooks tools extensions) +readonly OMP_SHARED_FILES=(SYSTEM.md APPEND_SYSTEM.md RULES.md TITLE_SYSTEM.md AGENTS.md models.yml mcp.json keybindings.json) + +# Env vars forwarded from the host (Oh My Pi config + common provider keys) +ENV_PASSTHROUGH_REGEX='^(OMP_|PI_|ANTHROPIC_|OPENAI_|OPENROUTER_|GEMINI_|GOOGLE_|AZURE_|DEEPSEEK_|MISTRAL_|XAI_|GROQ_|QWEN_|DASHSCOPE_|COPILOT_)' +# Never forward these: they would move omp's state out of the mounted layout +# or override the baked overlay (see PI_CONFIG_FILES in the Dockerfile) +ENV_PASSTHROUGH_EXCLUDE_REGEX='^(PI_CODING_AGENT_DIR|PI_CONFIG_DIR|PI_CONFIG_FILES|PI_PROFILE|OMP_PROFILE|PI_INSTALL_DIR)$' +ENV_PASSTHROUGH_VARS=( + AWS_REGION AWS_PROFILE AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN AWS_BEARER_TOKEN_BEDROCK + NODE_OPTIONS NO_COLOR FORCE_COLOR +) + +harness_parse_arg() { + case $1 in + --with-credentials) + WITH_CREDENTIALS=true + ARG_SHIFT=1 + ;; + *) + return 1 + ;; + esac +} + +harness_extra_help() { + echo " --with-credentials Mount ~/.omp/.env and ~/.omp/agent/.env read-only (API keys as dotenv)" +} + +# Ensure global config files and directories exist +harness_ensure_config() { + OMP_CONFIG_DIR="${HOME}/.omp" + OMP_AGENT_DIR="${OMP_CONFIG_DIR}/agent" + + mkdir -p "$OMP_AGENT_DIR" + local d + for d in "${OMP_SHARED_DIRS[@]}"; do + mkdir -p "${OMP_AGENT_DIR}/${d}" + done + + # Global config (empty file is valid YAML) + [[ -f "${OMP_AGENT_DIR}/config.yml" ]] || touch "${OMP_AGENT_DIR}/config.yml" + + # Credential dotenv files, shared across all projects. Opt-in via + # --with-credentials; without the flag neither file is mounted, so + # `/login` inside the container stays per-project (in agent.db). + if $WITH_CREDENTIALS; then + if [[ ! -f "${OMP_CONFIG_DIR}/.env" ]]; then + log_info "No existing credentials found, starting fresh: ${OMP_CONFIG_DIR}/.env" + touch "${OMP_CONFIG_DIR}/.env" + fi + if [[ ! -f "${OMP_AGENT_DIR}/.env" ]]; then + log_info "No existing credentials found, starting fresh: ${OMP_AGENT_DIR}/.env" + touch "${OMP_AGENT_DIR}/.env" + fi + fi +} + +# Setup project-specific agent dir under ~/.omp/ompbox-projects/ +harness_setup_project() { + PROJECT_DATA_DIR="${HOME}/.omp/ompbox-projects/${PROJECT_KEY}" + mkdir -p "${PROJECT_DATA_DIR}/agent" + local d + for d in "${OMP_SHARED_DIRS[@]}"; do + mkdir -p "${PROJECT_DATA_DIR}/agent/${d}" + done + + # Seed / refresh the per-project config.yml from the shared one (see the + # header comment for why it is copied instead of mounted) + local shared_config="${OMP_AGENT_DIR}/config.yml" + local project_config="${PROJECT_DATA_DIR}/agent/config.yml" + local project_env="${PROJECT_DATA_DIR}/agent/.env" + + # Podman creates the mount point for a single-file bind mount inside the + # data dir as the container's root (a host subuid), unreadable by coder. + # config.yml got one from earlier runs; .env and the shared files get one + # from --with-credentials / conditional file mounts. Replace anything we + # do not own (podman unshare has the subuid mapping needed to delete it). + local f + for f in "$project_config" "$project_env" "${OMP_SHARED_FILES[@]/#/${PROJECT_DATA_DIR}/agent/}"; do + if [[ -e "$f" && ! -O "$f" ]]; then + log_warn "Replacing ${f} (not owned by you; leftover bind-mount placeholder)" + podman unshare rm -f "$f" 2>/dev/null || rm -f "$f" + fi + done + + if [[ ! -f "$project_config" ]]; then + cp "$shared_config" "$project_config" + elif [[ "$shared_config" -nt "$project_config" ]]; then + log_info "Shared config.yml is newer, refreshing the per-project copy (in-container edits are replaced)" + cp "$shared_config" "$project_config" + fi + # omp must be able to read and rewrite it whatever the host file's mode + chmod u+rw "$project_config" + + # agent/.env is always a mount target placeholder (the credentials mount + # is opt-in per-run, but the file must exist so a later + # --with-credentials run lands on a file coder can read). + [[ -f "$project_env" ]] || touch "$project_env" + + # Shared files: only worth a per-project placeholder when the shared file + # actually exists (that is what gets ro-mounted). A stale empty + # placeholder left over from a since-deleted shared file would otherwise + # act as an empty override, so remove it; a non-empty placeholder is a + # deliberate per-project override and is left alone. + for f in "${OMP_SHARED_FILES[@]}"; do + local project_file="${PROJECT_DATA_DIR}/agent/${f}" + if [[ -f "${OMP_AGENT_DIR}/${f}" ]]; then + [[ -f "$project_file" ]] || touch "$project_file" + elif [[ -f "$project_file" && ! -s "$project_file" ]]; then + rm -f "$project_file" + fi + done +} + +harness_mounts() { + # Per-project agent dir: agent.db (+ -wal/-shm), sessions, blobs, + # terminal-sessions, history.db, and the config.yml copy + add_mount "${PROJECT_DATA_DIR}/agent" /home/coder/.omp/agent + + # Shareable config directories, bind-mounted from the host on top of the + # per-project agent dir (no rename-on-save constraint, unlike config.yml) + local d + for d in "${OMP_SHARED_DIRS[@]}"; do + add_mount "${OMP_AGENT_DIR}/${d}" "/home/coder/.omp/agent/${d}" + done + + # Shareable config files, only mounted when present on the host + local f + for f in "${OMP_SHARED_FILES[@]}"; do + if [[ -f "${OMP_AGENT_DIR}/${f}" ]]; then + add_mount "${OMP_AGENT_DIR}/${f}" "/home/coder/.omp/agent/${f}" ro + fi + done + + # Credentials (opt-in via --with-credentials, read-only dotenv files). + # harness_ensure_config / harness_setup_project already created empty + # files when missing, so both always exist here (force_mount) and no + # "file missing" branch is needed. no_status: harness_log_status prints + # the "Credentials:" line itself, in its original position (before the + # GitHub summary block). + add_optional_mount WITH_CREDENTIALS "${OMP_CONFIG_DIR}/.env" "/home/coder/.omp/.env" ro force_mount "Credentials" " (shared across projects)" no_status + add_optional_mount WITH_CREDENTIALS "${OMP_AGENT_DIR}/.env" "/home/coder/.omp/agent/.env" ro force_mount "Credentials" " (shared across projects)" no_status +} + +harness_log_status() { + log_info "Credentials: $(if $WITH_CREDENTIALS; then echo 'mounted (shared across projects)'; else echo 'not mounted (use --with-credentials)'; fi)" +} + +# Load the shared engine and run. Two supported layouts: +# - repo clone: lib/box-common.sh next to the launcher +# - flat install: box-common.sh next to the launcher (e.g. ~/.local/bin) +if [[ -f "${SCRIPT_DIR}/lib/box-common.sh" ]]; then + # shellcheck source=lib/box-common.sh + source "${SCRIPT_DIR}/lib/box-common.sh" +elif [[ -f "${SCRIPT_DIR}/box-common.sh" ]]; then + # shellcheck source=lib/box-common.sh + source "${SCRIPT_DIR}/box-common.sh" +else + echo "[ERROR] Shared engine box-common.sh not found in ${SCRIPT_DIR}" >&2 + echo "Download it next to the launcher:" >&2 + echo " curl -fsSL https://raw.githubusercontent.com/guimou/ccbox/main/lib/box-common.sh -o ${SCRIPT_DIR}/box-common.sh" >&2 + exit 1 +fi +box_main "$@" diff --git a/tests/golden/apptainer-ompbox-args.txt b/tests/golden/apptainer-ompbox-args.txt new file mode 100644 index 0000000..811f6b3 --- /dev/null +++ b/tests/golden/apptainer-ompbox-args.txt @@ -0,0 +1,35 @@ +--bind /etc/localtime:/etc/localtime:ro +--bind ROOT/home/.omp/agent/AGENTS.md:/home/coder/.omp/agent/AGENTS.md:ro +--bind ROOT/home/.omp/agent/commands:/home/coder/.omp/agent/commands +--bind ROOT/home/.omp/agent/extensions:/home/coder/.omp/agent/extensions +--bind ROOT/home/.omp/agent/hooks:/home/coder/.omp/agent/hooks +--bind ROOT/home/.omp/agent/instructions:/home/coder/.omp/agent/instructions +--bind ROOT/home/.omp/agent/prompts:/home/coder/.omp/agent/prompts +--bind ROOT/home/.omp/agent/rules:/home/coder/.omp/agent/rules +--bind ROOT/home/.omp/agent/skills:/home/coder/.omp/agent/skills +--bind ROOT/home/.omp/agent/tools:/home/coder/.omp/agent/tools +--bind ROOT/home/.omp/ompbox-projects/demo_HASH/agent:/home/coder/.omp/agent +--bind ROOT/repos/demo:/workspace +--cleanenv +--env ANTHROPIC_API_KEY=anthropic-stub +--env AWS_REGION=eu-west-1 +--env CODEBOX_SESSION=ompbox-demo-HASH-SESSION +--env GH_TOKEN=ghp_stubtoken +--env GITHUB_TOKEN=ghp_stubtoken +--env LANG=C.UTF-8 +--env NO_COLOR=1 +--env OPENAI_API_KEY=openai-stub +--env PROJECT_NAME=demo +--env TERM=xterm-256color +--env TZ=UTC +--ipc +--no-home +--no-mount tmp,cwd +--pid +--pwd /workspace +--userns +--writable-tmpfs +-- +ROOT/home/.codebox/sifs/ompbox-TAG.sif +omp +--version diff --git a/tests/golden/ompbox-all-opts.txt b/tests/golden/ompbox-all-opts.txt new file mode 100644 index 0000000..fb0c6b7 --- /dev/null +++ b/tests/golden/ompbox-all-opts.txt @@ -0,0 +1,40 @@ +--cap-add=NET_ADMIN +--cap-add=NET_RAW +--hostname ompbox +--name ompbox-demo-HASH-SESSION +--rm +--userns=keep-id:uid=1000,gid=1000 +-e ANTHROPIC_API_KEY=anthropic-stub +-e AWS_REGION=eu-west-1 +-e GH_TOKEN=ghp_stubtoken +-e GITHUB_TOKEN=ghp_stubtoken +-e LANG=C.UTF-8 +-e NO_COLOR=1 +-e OPENAI_API_KEY=openai-stub +-e PROJECT_NAME=demo +-e TERM=xterm-256color +-e TZ=UTC +-it +-v /etc/localtime:/etc/localtime:ro +-v ROOT/home/.config/gcloud:/home/coder/.config/gcloud:ro,z +-v ROOT/home/.gitconfig:/home/coder/.gitconfig:ro,z +-v ROOT/home/.omp/.env:/home/coder/.omp/.env:ro,z +-v ROOT/home/.omp/agent/.env:/home/coder/.omp/agent/.env:ro,z +-v ROOT/home/.omp/agent/AGENTS.md:/home/coder/.omp/agent/AGENTS.md:ro,z +-v ROOT/home/.omp/agent/commands:/home/coder/.omp/agent/commands:z +-v ROOT/home/.omp/agent/extensions:/home/coder/.omp/agent/extensions:z +-v ROOT/home/.omp/agent/hooks:/home/coder/.omp/agent/hooks:z +-v ROOT/home/.omp/agent/instructions:/home/coder/.omp/agent/instructions:z +-v ROOT/home/.omp/agent/prompts:/home/coder/.omp/agent/prompts:z +-v ROOT/home/.omp/agent/rules:/home/coder/.omp/agent/rules:z +-v ROOT/home/.omp/agent/skills:/home/coder/.omp/agent/skills:z +-v ROOT/home/.omp/agent/tools:/home/coder/.omp/agent/tools:z +-v ROOT/home/.omp/ompbox-projects/demo_HASH/agent:/home/coder/.omp/agent:z +-v ROOT/npm-global:/home/coder/.npm-global:ro,z +-v ROOT/repos/demo:/workspace:z +-w /workspace +-- +quay.io/guimou/ompbox:TAG +/bin/bash +-c +sudo /usr/local/bin/init-firewall.sh && omp diff --git a/tests/golden/ompbox-args.txt b/tests/golden/ompbox-args.txt new file mode 100644 index 0000000..c6d268e --- /dev/null +++ b/tests/golden/ompbox-args.txt @@ -0,0 +1,33 @@ +--hostname ompbox +--name ompbox-demo-HASH-SESSION +--rm +--userns=keep-id:uid=1000,gid=1000 +-e ANTHROPIC_API_KEY=anthropic-stub +-e AWS_REGION=eu-west-1 +-e GH_TOKEN=ghp_stubtoken +-e GITHUB_TOKEN=ghp_stubtoken +-e LANG=C.UTF-8 +-e NO_COLOR=1 +-e OPENAI_API_KEY=openai-stub +-e PROJECT_NAME=demo +-e TERM=xterm-256color +-e TZ=UTC +-it +-v /etc/localtime:/etc/localtime:ro +-v ROOT/home/.omp/agent/AGENTS.md:/home/coder/.omp/agent/AGENTS.md:ro,z +-v ROOT/home/.omp/agent/commands:/home/coder/.omp/agent/commands:z +-v ROOT/home/.omp/agent/extensions:/home/coder/.omp/agent/extensions:z +-v ROOT/home/.omp/agent/hooks:/home/coder/.omp/agent/hooks:z +-v ROOT/home/.omp/agent/instructions:/home/coder/.omp/agent/instructions:z +-v ROOT/home/.omp/agent/prompts:/home/coder/.omp/agent/prompts:z +-v ROOT/home/.omp/agent/rules:/home/coder/.omp/agent/rules:z +-v ROOT/home/.omp/agent/skills:/home/coder/.omp/agent/skills:z +-v ROOT/home/.omp/agent/tools:/home/coder/.omp/agent/tools:z +-v ROOT/home/.omp/ompbox-projects/demo_HASH/agent:/home/coder/.omp/agent:z +-v ROOT/npm-global:/home/coder/.npm-global:ro,z +-v ROOT/repos/demo:/workspace:z +-w /workspace +-- +quay.io/guimou/ompbox:TAG +omp +--version diff --git a/tests/golden/ompbox-default.txt b/tests/golden/ompbox-default.txt new file mode 100644 index 0000000..93b8da7 --- /dev/null +++ b/tests/golden/ompbox-default.txt @@ -0,0 +1,32 @@ +--hostname ompbox +--name ompbox-demo-HASH-SESSION +--rm +--userns=keep-id:uid=1000,gid=1000 +-e ANTHROPIC_API_KEY=anthropic-stub +-e AWS_REGION=eu-west-1 +-e GH_TOKEN=ghp_stubtoken +-e GITHUB_TOKEN=ghp_stubtoken +-e LANG=C.UTF-8 +-e NO_COLOR=1 +-e OPENAI_API_KEY=openai-stub +-e PROJECT_NAME=demo +-e TERM=xterm-256color +-e TZ=UTC +-it +-v /etc/localtime:/etc/localtime:ro +-v ROOT/home/.omp/agent/AGENTS.md:/home/coder/.omp/agent/AGENTS.md:ro,z +-v ROOT/home/.omp/agent/commands:/home/coder/.omp/agent/commands:z +-v ROOT/home/.omp/agent/extensions:/home/coder/.omp/agent/extensions:z +-v ROOT/home/.omp/agent/hooks:/home/coder/.omp/agent/hooks:z +-v ROOT/home/.omp/agent/instructions:/home/coder/.omp/agent/instructions:z +-v ROOT/home/.omp/agent/prompts:/home/coder/.omp/agent/prompts:z +-v ROOT/home/.omp/agent/rules:/home/coder/.omp/agent/rules:z +-v ROOT/home/.omp/agent/skills:/home/coder/.omp/agent/skills:z +-v ROOT/home/.omp/agent/tools:/home/coder/.omp/agent/tools:z +-v ROOT/home/.omp/ompbox-projects/demo_HASH/agent:/home/coder/.omp/agent:z +-v ROOT/npm-global:/home/coder/.npm-global:ro,z +-v ROOT/repos/demo:/workspace:z +-w /workspace +-- +quay.io/guimou/ompbox:TAG +omp diff --git a/tests/render-test.sh b/tests/render-test.sh index 05d8a0a..8303e8d 100755 --- a/tests/render-test.sh +++ b/tests/render-test.sh @@ -42,6 +42,7 @@ mkdir -p "$FAKE_HOME" "$WORKSPACE" "$NPM_PREFIX" "$STUBS" "$OUT" touch "${FAKE_HOME}/.gitconfig" "${FAKE_HOME}/.claude.json" mkdir -p "${FAKE_HOME}/.config/gcloud" "${FAKE_HOME}/.claude" "${FAKE_HOME}/.qwen" touch "${FAKE_HOME}/.claude/status-line.sh" "${FAKE_HOME}/.claude/RULES.md" "${FAKE_HOME}/.qwen/.env" +mkdir -p "${FAKE_HOME}/.omp/agent" && touch "${FAKE_HOME}/.omp/agent/AGENTS.md" # --- stubs --------------------------------------------------------------- @@ -88,7 +89,7 @@ chmod +x "${STUBS}"/* # Wrappers are invoked through symlinks in a bin dir, like a real install BIN="${ROOT}/bin" mkdir -p "$BIN" -for box in ccbox ocbox qcbox cxbox; do +for box in ccbox ocbox qcbox cxbox ompbox; do ln -s "${REPO_DIR}/${box}" "${BIN}/${box}" done @@ -111,6 +112,9 @@ SCENARIOS=( "cxbox-default|cxbox|" "cxbox-all-opts|cxbox|--with-firewall --with-credentials --with-gcloud --with-gitconfig" "cxbox-args|cxbox|-- --version" + "ompbox-default|ompbox|" + "ompbox-all-opts|ompbox|--with-firewall --with-credentials --with-gcloud --with-gitconfig" + "ompbox-args|ompbox|-- --version" # apptainer runtime (the pod-as-host case): no clipboard/npm-global, SIF image "apptainer-ccbox-default|ccbox|--runtime apptainer" "apptainer-ccbox-all-opts|ccbox|--runtime apptainer --with-credentials --with-gcloud --with-gitconfig --with-teams --with-tmux" @@ -118,6 +122,7 @@ SCENARIOS=( "apptainer-ocbox-default|ocbox|--runtime apptainer --with-credentials" "apptainer-qcbox-default|qcbox|--runtime apptainer" "apptainer-cxbox-args|cxbox|--runtime apptainer -- --version" + "apptainer-ompbox-args|ompbox|--runtime apptainer -- --version" # runtime chosen by CODEBOX_RUNTIME, per-session scratch bound at /tmp "apptainer-env-var|ccbox|" "!apptainer-firewall|ccbox|--runtime apptainer --with-firewall"