From 133084b369833e60e5e524df2ac002ff8d1fff21 Mon Sep 17 00:00:00 2001 From: Guillaume Moutier Date: Tue, 8 Sep 2026 15:15:38 -0400 Subject: [PATCH 1/3] ompbox: add Oh My Pi as a new harness Oh My Pi (omp, https://github.com/can1357/oh-my-pi) is installed from the checksum-verified prebuilt GitHub release binary (omp-linux-{x64,arm64}), with its native addons pre-extracted at build time and the startup update check disabled through a baked config overlay loaded via PI_CONFIG_FILES. State layout: omp keeps OAuth tokens, API keys, settings, usage and its memory store in one WAL-mode SQLite file (agent.db) keyed by cwd, which is always /workspace inside the container. The whole agent dir is therefore per project (~/.omp/ompbox-projects//agent); config.yml is copied in at launch (omp saves it via rename, like Codex's config.toml); the shared config dirs and prompt files from ~/.omp/agent are bind-mounted on top. --with-credentials mounts the dotenv files ~/.omp/.env and ~/.omp/agent/.env read-only. Engine: add ENV_PASSTHROUGH_EXCLUDE_REGEX so a wrapper can keep specific vars matching its prefix list from being forwarded (ompbox: the PI_*/OMP_* vars that relocate omp state; cxbox: CODEX_HOME, which its comment already claimed was not forwarded). Also: CI workflows, pod image, egress firewall generator, golden tests and docs updated for the fifth harness. --- Signed-off-by: Guillaume Moutier Co-authored-by: Claude --- .github/workflows/build-and-push.yml | 7 +- .github/workflows/build-pod.yml | 1 + .github/workflows/release.yml | 4 + .github/workflows/tests.yml | 3 +- .gitignore | 3 + AGENTS.md | 33 ++-- CONTRIBUTING.md | 6 +- Dockerfile | 38 +++- Dockerfile.base | 3 +- OMP_VERSION | 1 + README.md | 18 +- cxbox | 1 + docs/architecture.md | 53 ++++-- docs/development.md | 20 +- docs/kubernetes.md | 4 +- docs/plans/kubernetes-apptainer.md | 4 +- docs/usage.md | 28 ++- firewall-domains-omp.txt | 23 +++ init-firewall.sh | 2 +- k8s/Containerfile | 8 +- k8s/gen-egress-firewall.sh | 4 +- k8s/overlays/example/codebox.env.example | 4 +- lib/box-common.sh | 12 +- ompbox | 232 +++++++++++++++++++++++ tests/golden/apptainer-ompbox-args.txt | 35 ++++ tests/golden/ompbox-all-opts.txt | 40 ++++ tests/golden/ompbox-args.txt | 33 ++++ tests/golden/ompbox-default.txt | 32 ++++ tests/render-test.sh | 7 +- 29 files changed, 581 insertions(+), 78 deletions(-) create mode 100644 OMP_VERSION create mode 100644 firewall-domains-omp.txt create mode 100755 ompbox create mode 100644 tests/golden/apptainer-ompbox-args.txt create mode 100644 tests/golden/ompbox-all-opts.txt create mode 100644 tests/golden/ompbox-args.txt create mode 100644 tests/golden/ompbox-default.txt diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index e6afaab..900df25 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -9,7 +9,7 @@ on: workflow_call: inputs: 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 +39,7 @@ on: - opencode - qwencode - codex + - omp harness_version: description: 'Harness version (leave empty to use the version file)' required: false @@ -88,6 +89,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 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..0b16fe3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,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 +35,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' @@ -145,6 +148,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 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..ecc2766 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) @@ -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..6efd489 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`. @@ -127,11 +128,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. 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 | |-----|-------------| @@ -142,7 +143,7 @@ Each harness pushes to its own repository (`quay.io/guimou/ccbox`, `quay.io/guim ### Releasing a new harness version ```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 +156,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 +170,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" From 0ad21609cccbb22e9ef553dcdd37ae8da70ed9a5 Mon Sep 17 00:00:00 2001 From: Guillaume Moutier Date: Tue, 8 Sep 2026 15:47:05 -0400 Subject: [PATCH 2/3] ci: bump the harness version pins automatically in the weekly release run The scheduled run (or a dispatch with bump_versions) now starts with a bump job: it resolves the latest upstream version of every harness (npm dist-tag latest for Claude Code, OpenCode, Qwen Code and Codex; latest GitHub release for Oh My Pi), only ever moves a pin forward, commits the changed *_VERSION files to main and hands that commit to detect/build. Bumped harnesses are released as plain {box}-v{version}, the others as -N rebuilds, all in the same run (GITHUB_TOKEN pushes do not trigger workflows). build-and-push.yml gains an optional ref input so the checkout, the image sha tag (metadata-action context: git) and the release target all point at the bump commit. A concurrency group keeps a manual dispatch from overlapping the cron run. --- Signed-off-by: Guillaume Moutier Co-authored-by: Claude --- .github/workflows/build-and-push.yml | 10 ++- .github/workflows/release.yml | 120 ++++++++++++++++++++++++++- AGENTS.md | 2 +- docs/development.md | 15 ++-- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 900df25..a60ac86 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -8,6 +8,10 @@ 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, codex, or omp' required: true @@ -67,6 +71,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 with: + ref: ${{ inputs.ref || github.sha }} fetch-depth: 0 - name: Resolve harness parameters @@ -146,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 != '' }} @@ -193,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/release.yml b/.github/workflows/release.yml index 0b16fe3..c095771 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,15 @@ 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. Pushes made with GITHUB_TOKEN do not trigger +# workflows, which is why the release happens in this same run. The "PR for +# main" ruleset must list the GitHub Actions app as a bypass actor for the +# push to be accepted. on: push: @@ -51,9 +60,104 @@ 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 + + - name: Bump version pin files + id: bump + env: + GH_TOKEN: ${{ github.token }} + 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 + + # 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 + git commit -q -m "chore: bump harness versions" -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 @@ -66,10 +170,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 @@ -111,9 +218,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 @@ -169,7 +280,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 @@ -179,6 +290,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/AGENTS.md b/AGENTS.md index ecc2766..a2a93b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,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 (the ruleset must let the GitHub Actions app bypass the PR requirement) 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. diff --git a/docs/development.md b/docs/development.md index 6efd489..ced5a9d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -112,15 +112,16 @@ 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` and hands the new commit to the next jobs. Pushes made with the workflow token do not trigger workflows, which is why the release happens in the same run. On any other trigger the job is a no-op. Requirement: the `PR for main` ruleset must list the GitHub Actions app as a bypass actor, otherwise the push is rejected. +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`) @@ -128,7 +129,7 @@ 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` / `omp`) 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 @@ -142,6 +143,8 @@ 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 / OMP_VERSION git add CLAUDE_VERSION From 613792c1b56ebb02f449f68181b8c20e9a75db9c Mon Sep 17 00:00:00 2001 From: Guillaume Moutier Date: Tue, 8 Sep 2026 16:00:13 -0400 Subject: [PATCH 3/3] ci: push the weekly version bump with a deploy key On a personal repository the 'PR for main' ruleset offers no bypass for the GitHub Actions app, only for deploy keys. The bump job therefore checks out main with the BUMP_DEPLOY_KEY secret and pushes over SSH; the bump commit carries [skip ci] so that push (which, unlike a GITHUB_TOKEN push, would trigger workflows) does not start a second release run. A missing secret fails the bump job with an explicit message. Setup documented in docs/development.md. --- Signed-off-by: Guillaume Moutier Co-authored-by: Claude --- .github/workflows/release.yml | 23 ++++++++++++++++++----- AGENTS.md | 2 +- docs/development.md | 13 ++++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c095771..7e4e012 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,12 @@ name: Release # 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. Pushes made with GITHUB_TOKEN do not trigger -# workflows, which is why the release happens in this same run. The "PR for -# main" ruleset must list the GitHub Actions app as a bypass actor for the -# push to be accepted. +# 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: @@ -91,11 +93,16 @@ jobs: 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 @@ -104,6 +111,10 @@ jobs: 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 @@ -149,7 +160,9 @@ jobs: 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 - git commit -q -m "chore: bump harness versions" -m "${SUMMARY#$'\n'}" + # [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})" diff --git a/AGENTS.md b/AGENTS.md index a2a93b8..4bda6a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,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). 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 (the ruleset must let the GitHub Actions app bypass the PR requirement) 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/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. diff --git a/docs/development.md b/docs/development.md index ced5a9d..ea9c931 100644 --- a/docs/development.md +++ b/docs/development.md @@ -118,7 +118,18 @@ The `-N` suffix increments from existing `{box}-v{version}-*` tags. If the compu The run is structured as four jobs: -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` and hands the new commit to the next jobs. Pushes made with the workflow token do not trigger workflows, which is why the release happens in the same run. On any other trigger the job is a no-op. Requirement: the `PR for main` ruleset must list the GitHub Actions app as a bypass actor, otherwise the push is rejected. +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).