From 8c621f65d5866edc4bcc303bb04e627d378bfce7 Mon Sep 17 00:00:00 2001 From: Jeff MAURY Date: Thu, 1 Oct 2026 08:55:59 +0200 Subject: [PATCH] feat: add Codex agent Signed-off-by: Jeff MAURY --- .agents/skills/add-agent/SKILL.md | 1 + README.md | 51 ++++- src/agent/codex.rs | 85 ++++++++ src/agent/mod.rs | 15 ++ tests/integration_test.rs | 321 +++++++++++++++++++++++++++--- 5 files changed, 440 insertions(+), 33 deletions(-) create mode 100644 src/agent/codex.rs diff --git a/.agents/skills/add-agent/SKILL.md b/.agents/skills/add-agent/SKILL.md index b4bf3b7..5f3af34 100644 --- a/.agents/skills/add-agent/SKILL.md +++ b/.agents/skills/add-agent/SKILL.md @@ -13,6 +13,7 @@ End-to-end checklist for making a new AI coding agent available via `--agent`. Adding a new agent touches five layers: the agent module, `src/agent/mod.rs`, unit tests, integration tests (including extending the `image_tests!` macro), and the README. The existing agents are the canonical reference: - **`claude`** — Claude Code CLI, curl installer, onboarding skip via `.claude.json`, agent-level network policy, anthropic+vertexai inference, skills at `/sandbox/.claude/skills`. +- **`codex`** — OpenAI Codex CLI, Node.js + npm installer, openai inference, model via `.codex/config.toml`, endpoint via `OPENAI_BASE_URL` env var, no skills support. - **`opencode`** — Opencode CLI, curl installer, per-inference config submodule pattern (one `configure()` per provider), all three inference providers, skills at `/sandbox/.opencode/skills`. ## Step 1 — choose the file structure diff --git a/README.md b/README.md index 6e2ee95..6b2f0f7 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The tool assembles the image in layers — base image, agent installation, agent | Agent | User settings | Auto-onboarding | Skills | | ---------- | ------------- | --------------- | ------ | | `claude` | Yes | Yes | Yes
`~/.claude/skills/` | +| `codex` | Yes | N/A | N/A | | `opencode` | Yes | N/A | Yes
`~/.opencode/skills/` | ### Agent × Inference Supported Features @@ -55,6 +56,7 @@ The tool assembles the image in layers — base image, agent installation, agent | `opencode` | `vertexai` | N/A | No
fixed endpoint | Yes
`model` in `.config/opencode/config.json` | | `opencode` | `ollama` | Yes
Ollama provider config | Yes
`baseURL` in Ollama provider config | Yes
`model` in `.config/opencode/config.json` | | `opencode` | `openai` | Yes if model or endpoint | Yes
`baseURL` in custom provider config | Yes
`model` in `.config/opencode/config.json` | +| `codex` | `openai` | Yes if model | Yes
`ENV OPENAI_BASE_URL` | Yes
`model` in `.codex/config.toml` | ## Quick start @@ -225,10 +227,12 @@ Pass `--agent` to install an agent into the image. | Agent | Value | Description | | ----------- | ---------- | ------------------------------ | | Claude Code | `claude` | Anthropic's Claude Code CLI | +| Codex | `codex` | OpenAI's Codex CLI agent | | OpenCode | `opencode` | OpenCode AI coding agent | ```sh openshell-image-builder --runtime podman --agent claude myimage:latest +openshell-image-builder --runtime podman --agent codex myimage:latest openshell-image-builder --runtime podman --agent opencode myimage:latest ``` @@ -242,7 +246,7 @@ You can pre-populate the sandbox home directory with settings files specific to /agents// ``` -where `` is the directory described in [Configuring the base image](#configuring-the-base-image), and `` matches the value passed to `--agent` (`claude` or `opencode`). +where `` is the directory described in [Configuring the base image](#configuring-the-base-image), and `` matches the value passed to `--agent` (`claude`, `codex`, or `opencode`). All files and subdirectories are copied into `/sandbox/` (the sandbox user's home directory), owned by the `sandbox` user. The copy happens before the agent is installed, so the agent installer can create additional files on top without overwriting your settings. @@ -288,7 +292,7 @@ Pass `--inference` to allow the agent to reach its LLM backend. This is separate | Anthropic | `anthropic` | `claude`, `opencode` | Anthropic API (`api.anthropic.com`) | | Vertex AI | `vertexai` | `claude`, `opencode` | Google Vertex AI (`oauth2.googleapis.com`, `aiplatform.googleapis.com`, `*-aiplatform.googleapis.com`) | | Ollama | `ollama` | `opencode` | Local models on the host machine, reached via `host.openshell.internal:11434` | -| OpenAI | `openai` | `opencode` | OpenAI API (`api.openai.com`), or any OpenAI-compatible endpoint via `--endpoint` | +| OpenAI | `openai` | `codex`, `opencode` | OpenAI API (`api.openai.com`), or any OpenAI-compatible endpoint via `--endpoint` | ```sh openshell-image-builder --runtime podman --agent claude --inference anthropic myimage:latest @@ -296,6 +300,7 @@ openshell-image-builder --runtime podman --agent opencode --inference anthropic openshell-image-builder --runtime podman --agent claude --inference vertexai myimage:latest openshell-image-builder --runtime podman --agent opencode --inference vertexai myimage:latest openshell-image-builder --runtime podman --agent opencode --inference ollama myimage:latest +openshell-image-builder --runtime podman --agent codex --inference openai myimage:latest openshell-image-builder --runtime podman --agent opencode --inference openai myimage:latest ``` @@ -310,6 +315,7 @@ Use `--endpoint` to override the inference provider's default URL — useful for | `opencode` | `anthropic` | ✅ | Written to opencode config as `provider.anthropic.options.baseURL` | | `opencode` | `vertexai` | ❌ | Rejected — Vertex AI has a proprietary fixed endpoint | | `opencode` | `ollama` | ✅ | Written to opencode config as `provider.ollama.options.baseURL`; `localhost` in the URL is rewritten to `host.openshell.internal`; defaults to `http://host.openshell.internal:11434/v1` if omitted | +| `codex` | `openai` | ✅ | Baked into the image as `ENV OPENAI_BASE_URL=` | | `opencode` | `openai` | ✅ | When provided, opencode is configured to use a custom `@ai-sdk/openai-compatible` provider with `options.baseURL` set to the given URL | ```sh @@ -345,6 +351,7 @@ Use `--model` to bake a default model into the image. The agent uses this model | `opencode` | `anthropic` | Written to opencode config as top-level `"model"` field (can be combined with `--endpoint`) | | `opencode` | `vertexai` | Written to opencode config as top-level `"model"` field | | `opencode` | `ollama` | Written to opencode config as top-level `"model": "ollama/"` field; only the specified model is registered in the models map | +| `codex` | `openai` | Written to `.codex/config.toml` as `model = ""` | | `opencode` | `openai` | Written to opencode config as `"model": "openai/"` (native OpenAI) or `"model": "custom/"` (with `--endpoint`) | ```sh @@ -413,7 +420,7 @@ The policy is built in four layers, merged in order: 1. **Base** ([`assets/policy.yaml`](assets/policy.yaml)) — general-purpose tooling: Git operations over HTTPS and the GitHub REST API via `gh`. 2. **Inference** (added by `--inference`) — LLM backend endpoints scoped to the agent binary. For example, `--inference anthropic` adds `api.anthropic.com` and `statsig.anthropic.com`; `--inference vertexai` adds `oauth2.googleapis.com` and `aiplatform.googleapis.com` (including the `*-aiplatform.googleapis.com` wildcard); `--inference ollama` adds `host.openshell.internal:11434` for local model access; `--inference openai` adds `api.openai.com` (or the custom endpoint host when `--endpoint` is used). -3. **Agent** (added by `--agent`) — agent-specific endpoints. For example, `--agent claude` adds `platform.claude.com`, `raw.githubusercontent.com`, and the GitHub REST API for Claude's coding tools; `--agent opencode` adds `opencode.ai`, `registry.npmjs.org`, and `models.dev`. +3. **Agent** (added by `--agent`) — agent-specific endpoints. For example, `--agent claude` adds `platform.claude.com`, `raw.githubusercontent.com`, and the GitHub REST API for Claude's coding tools; `--agent codex` adds `registry.npmjs.org` and the GitHub REST API; `--agent opencode` adds `opencode.ai`, `registry.npmjs.org`, and `models.dev`. 4. **Workspace** (added from `network.hosts` in `.kaiden/workspace.json` when `--with-workspace-config` is used) — user-defined hosts that any binary in standard PATH directories (`/bin`, `/usr/bin`, `/usr/local/bin`, `/sandbox/.local/bin`) and the agent binary (when present) may reach. See [Workspace network rules](#workspace-network-rules). ## Dev Container Features @@ -485,6 +492,7 @@ During the build, each skill directory is copied into the agent's skills directo | Agent | Skills directory | | ---------- | ------------------------------ | | `claude` | `/sandbox/.claude/skills/` | +| `codex` | N/A (no skills support) | | `opencode` | `/sandbox/.opencode/skills/` | With `--agent claude` and `"skills": ["./my-skill"]`, the skill lands at `/sandbox/.claude/skills/my-skill/` in the image, owned by the `sandbox` user. @@ -569,7 +577,7 @@ openshell-image-builder [OPTIONS] | `` | Tag for the built image (e.g. `myimage:latest`) | | `--runtime ` | Container CLI to use for building images (`podman`, `docker`, `container`) | | `--config ` | Path to config directory containing `config.toml` (env: `OPENSHELL_IMAGE_BUILDER_CONFIG`) | -| `--agent ` | Agent to install in the image (`claude`, `opencode`) | +| `--agent ` | Agent to install in the image (`claude`, `codex`, `opencode`) | | `--inference ` | Inference server the agent will connect to (`anthropic`, `vertexai`, `ollama`, `openai`) | | `--endpoint ` | Override the inference provider's default endpoint URL (see [Custom endpoint](#custom-endpoint---endpoint)) | | `--model ` | Default model for the agent to use (see [Default model](#default-model---model)) | @@ -695,6 +703,41 @@ $ openshell sandbox create \ -- bash -c 'cd /sandbox/work && claude --bare' ``` +### Codex agent + OpenAI models provider + +```sh +$ openshell-image-builder \ + --runtime podman \ + --agent codex \ + --inference openai \ + --model o4-mini \ + --with-agent-settings \ + sandbox_image:codex_openai + +$ openshell provider create \ + --type generic \ + --credential OPENAI_API_KEY=sk-... \ + --name codex_openai_provider + +$ openshell sandbox create \ + --from sandbox_image:codex_openai \ + --provider codex_openai_provider \ + --upload . \ + --name codex_openai_sandbox \ + --no-auto-providers \ + -- codex + +# Or, with podman driver, you can mount the files +# (https://docs.nvidia.com/openshell/reference/sandbox-compute-drivers#podman-driver-config-mounts) +$ openshell sandbox create \ + --from sandbox_image:codex_openai \ + --provider codex_openai_provider \ + --driver-config-json '{"podman":{"mounts":[{"type":"bind","source":"/path/to/your/sources","target":"/sandbox/work","read_only":false}]}}' \ + --name codex_openai_sandbox \ + --no-auto-providers \ + -- codex +``` + ### OpenCode agent + Ollama (local models) Ollama must be running on the host before starting the sandbox. diff --git a/src/agent/codex.rs b/src/agent/codex.rs new file mode 100644 index 0000000..a928158 --- /dev/null +++ b/src/agent/codex.rs @@ -0,0 +1,85 @@ +// Copyright (C) 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use super::Agent; + +pub struct CodexAgent; + +impl Agent for CodexAgent { + fn id(&self) -> &str { + "codex" + } + + fn install(&self) -> String { + "RUN curl -fsSL https://chatgpt.com/codex/install.sh | sh\nENV PATH=/sandbox/.local/bin:$PATH" + .to_string() + } + + fn binary_path(&self) -> &str { + "/sandbox/.local/bin/codex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_id_is_codex() { + assert_eq!(CodexAgent.id(), "codex"); + } + + #[test] + fn install_is_nonempty() { + assert!(!CodexAgent.install().is_empty()); + } + + #[test] + fn install_contains_codex_installer() { + assert!( + CodexAgent + .install() + .contains("https://chatgpt.com/codex/install.sh") + ); + } + + #[test] + fn install_adds_local_bin_to_path() { + assert!( + CodexAgent + .install() + .contains("ENV PATH=/sandbox/.local/bin:$PATH") + ); + } + + #[test] + fn binary_path_is_local_bin_codex() { + assert_eq!(CodexAgent.binary_path(), "/sandbox/.local/bin/codex"); + } + + #[test] + fn skills_dir_is_empty() { + assert_eq!(CodexAgent.skills_dir(), ""); + } + + #[test] + fn skip_onboarding_is_noop() { + let mut files = std::collections::HashMap::new(); + files.insert("some.json".to_string(), "content".to_string()); + let result = CodexAgent.skip_onboarding(files.clone()); + assert_eq!(result, files); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 18a1d4e..d800c3d 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -15,11 +15,14 @@ // SPDX-License-Identifier: Apache-2.0 mod claude; +mod codex; mod opencode; #[cfg(test)] pub use claude::ClaudeAgent; #[cfg(test)] +pub use codex::CodexAgent; +#[cfg(test)] pub use opencode::OpencodeAgent; use clap::ValueEnum; @@ -72,12 +75,14 @@ pub trait Agent { #[derive(Clone, ValueEnum)] pub enum AgentKind { Claude, + Codex, Opencode, } pub fn from_kind(kind: AgentKind) -> Box { match kind { AgentKind::Claude => Box::new(claude::ClaudeAgent), + AgentKind::Codex => Box::new(codex::CodexAgent), AgentKind::Opencode => Box::new(opencode::OpencodeAgent), } } @@ -98,6 +103,16 @@ mod tests { assert!(agent.install().contains("https://opencode.ai/install")); } + #[test] + fn from_kind_codex_installs_codex() { + let agent = from_kind(AgentKind::Codex); + assert!( + agent + .install() + .contains("https://chatgpt.com/codex/install.sh") + ); + } + #[test] fn opencode_skip_onboarding_is_noop() { let agent = from_kind(AgentKind::Opencode); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index e48822b..f6fcff8 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -112,6 +112,10 @@ static UBUNTU_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static FEDORA_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static UBI_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static HUMMINGBIRD_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); +static UBUNTU_CODEX_IMAGE: OnceLock = OnceLock::new(); +static FEDORA_CODEX_IMAGE: OnceLock = OnceLock::new(); +static UBI_CODEX_IMAGE: OnceLock = OnceLock::new(); +static HUMMINGBIRD_CODEX_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_OPENAI_MODEL_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_NO_POLICY_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_CLAUDE_NO_AGENT_SETTINGS_IMAGE: OnceLock = OnceLock::new(); @@ -625,6 +629,15 @@ fn check_opencode_in_path(image: &str, expected: bool) { } } +fn check_codex_in_path(image: &str, expected: bool) { + let out = run_in_image(image, "which codex"); + if expected { + assert!(out.status.success(), "codex not found in PATH"); + } else { + assert!(!out.status.success(), "codex should not be in PATH"); + } +} + fn check_claude_policy(image: &str, expected: bool) { let out = run_in_image(image, "cat /etc/openshell/policy.yaml"); assert!(out.status.success(), "failed to read policy.yaml"); @@ -659,6 +672,23 @@ fn check_opencode_policy(image: &str, expected: bool) { } } +fn check_codex_policy(image: &str, expected: bool) { + let out = run_in_image(image, "cat /etc/openshell/policy.yaml"); + assert!(out.status.success(), "failed to read policy.yaml"); + let policy = String::from_utf8_lossy(&out.stdout); + if expected { + assert!( + policy.contains("name: codex"), + "codex policy rule not found in policy.yaml" + ); + } else { + assert!( + !policy.contains("name: codex"), + "codex policy rule should not be present in policy.yaml" + ); + } +} + fn check_anthropic_policy(image: &str, expected: bool) { let out = run_in_image(image, "cat /etc/openshell/policy.yaml"); assert!(out.status.success(), "failed to read policy.yaml"); @@ -732,7 +762,7 @@ fn check_openai_policy(image: &str, expected: bool) { // --------------------------------------------------------------------------- macro_rules! image_tests { - ($mod_name:ident, $image_fn:ident, has_claude: $has_claude:literal, has_opencode: $has_opencode:literal, has_anthropic: $has_anthropic:literal, has_vertexai: $has_vertexai:literal, has_ollama: $has_ollama:literal, has_openai: $has_openai:literal) => { + ($mod_name:ident, $image_fn:ident, has_claude: $has_claude:literal, has_opencode: $has_opencode:literal, has_codex: $has_codex:literal, has_anthropic: $has_anthropic:literal, has_vertexai: $has_vertexai:literal, has_ollama: $has_ollama:literal, has_openai: $has_openai:literal) => { mod $mod_name { use super::*; @@ -766,6 +796,12 @@ macro_rules! image_tests { check_opencode_in_path($image_fn(), $has_opencode); } + #[test] + #[ignore] + fn codex_in_path() { + check_codex_in_path($image_fn(), $has_codex); + } + #[test] #[ignore] fn policy_yaml_present() { @@ -784,6 +820,12 @@ macro_rules! image_tests { check_opencode_policy($image_fn(), $has_opencode); } + #[test] + #[ignore] + fn policy_has_codex_rules() { + check_codex_policy($image_fn(), $has_codex); + } + #[test] #[ignore] fn policy_has_anthropic_rules() { @@ -811,34 +853,38 @@ macro_rules! image_tests { }; } -image_tests!(ubuntu, ubuntu_image, has_claude: false, has_opencode: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(ubuntu_claude, ubuntu_claude_image, has_claude: true, has_opencode: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(ubuntu_opencode, ubuntu_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(ubuntu_claude_vertexai, ubuntu_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(ubuntu_opencode_vertexai,ubuntu_opencode_vertexai_image,has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(fedora, fedora_image, has_claude: false, has_opencode: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(fedora_claude, fedora_claude_image, has_claude: true, has_opencode: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(fedora_opencode, fedora_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(fedora_claude_vertexai, fedora_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(fedora_opencode_vertexai,fedora_opencode_vertexai_image,has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(ubi, ubi_image, has_claude: false, has_opencode: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(ubi_claude, ubi_claude_image, has_claude: true, has_opencode: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(ubi_opencode, ubi_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(ubi_claude_vertexai, ubi_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(ubi_opencode_vertexai, ubi_opencode_vertexai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(hummingbird, hummingbird_image, has_claude: false, has_opencode: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(hummingbird_claude, hummingbird_claude_image, has_claude: true, has_opencode: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(hummingbird_opencode, hummingbird_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); -image_tests!(hummingbird_claude_vertexai, hummingbird_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(hummingbird_opencode_vertexai, hummingbird_opencode_vertexai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); -image_tests!(ubuntu_opencode_ollama, ubuntu_opencode_ollama_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); -image_tests!(fedora_opencode_ollama, fedora_opencode_ollama_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); -image_tests!(ubi_opencode_ollama, ubi_opencode_ollama_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); -image_tests!(hummingbird_opencode_ollama, hummingbird_opencode_ollama_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); -image_tests!(ubuntu_opencode_openai, ubuntu_opencode_openai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); -image_tests!(fedora_opencode_openai, fedora_opencode_openai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); -image_tests!(ubi_opencode_openai, ubi_opencode_openai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); -image_tests!(hummingbird_opencode_openai, hummingbird_opencode_openai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(ubuntu, ubuntu_image, has_claude: false, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(ubuntu_claude, ubuntu_claude_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(ubuntu_opencode, ubuntu_opencode_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(ubuntu_claude_vertexai, ubuntu_claude_vertexai_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(ubuntu_opencode_vertexai,ubuntu_opencode_vertexai_image,has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(fedora, fedora_image, has_claude: false, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(fedora_claude, fedora_claude_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(fedora_opencode, fedora_opencode_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(fedora_claude_vertexai, fedora_claude_vertexai_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(fedora_opencode_vertexai,fedora_opencode_vertexai_image,has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(ubi, ubi_image, has_claude: false, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(ubi_claude, ubi_claude_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(ubi_opencode, ubi_opencode_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(ubi_claude_vertexai, ubi_claude_vertexai_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(ubi_opencode_vertexai, ubi_opencode_vertexai_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(hummingbird, hummingbird_image, has_claude: false, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(hummingbird_claude, hummingbird_claude_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(hummingbird_opencode, hummingbird_opencode_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: true, has_vertexai: false, has_ollama: false, has_openai: false); +image_tests!(hummingbird_claude_vertexai, hummingbird_claude_vertexai_image, has_claude: true, has_opencode: false, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(hummingbird_opencode_vertexai, hummingbird_opencode_vertexai_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: true, has_ollama: false, has_openai: false); +image_tests!(ubuntu_opencode_ollama, ubuntu_opencode_ollama_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); +image_tests!(fedora_opencode_ollama, fedora_opencode_ollama_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); +image_tests!(ubi_opencode_ollama, ubi_opencode_ollama_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); +image_tests!(hummingbird_opencode_ollama, hummingbird_opencode_ollama_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: true, has_openai: false); +image_tests!(ubuntu_opencode_openai, ubuntu_opencode_openai_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(fedora_opencode_openai, fedora_opencode_openai_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(ubi_opencode_openai, ubi_opencode_openai_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(hummingbird_opencode_openai, hummingbird_opencode_openai_image, has_claude: false, has_opencode: true, has_codex: false, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(ubuntu_codex, ubuntu_codex_image, has_claude: false, has_opencode: false, has_codex: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(fedora_codex, fedora_codex_image, has_claude: false, has_opencode: false, has_codex: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(ubi_codex, ubi_codex_image, has_claude: false, has_opencode: false, has_codex: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); +image_tests!(hummingbird_codex, hummingbird_codex_image, has_claude: false, has_opencode: false, has_codex: true, has_anthropic: false, has_vertexai: false, has_ollama: false, has_openai: true); // --------------------------------------------------------------------------- // Workspace helpers for feature-based builds @@ -1358,6 +1404,69 @@ fn hummingbird_opencode_openai_image() -> &'static str { }) } +fn ubuntu_codex_image() -> &'static str { + UBUNTU_CODEX_IMAGE.get_or_init(|| { + build_image( + "openshell-test-ubuntu-codex:integration", + &["--agent", "codex", "--inference", "openai", "--with-policy"], + ) + }) +} + +fn fedora_codex_image() -> &'static str { + FEDORA_CODEX_IMAGE.get_or_init(|| { + let config = fedora_config_dir(); + build_image( + "openshell-test-fedora-codex:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--agent", + "codex", + "--inference", + "openai", + "--with-policy", + ], + ) + }) +} + +fn ubi_codex_image() -> &'static str { + UBI_CODEX_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image( + "openshell-test-ubi-codex:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--agent", + "codex", + "--inference", + "openai", + "--with-policy", + ], + ) + }) +} + +fn hummingbird_codex_image() -> &'static str { + HUMMINGBIRD_CODEX_IMAGE.get_or_init(|| { + let config = hummingbird_config_dir(); + build_image( + "openshell-test-hummingbird-codex:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--agent", + "codex", + "--inference", + "openai", + "--with-policy", + ], + ) + }) +} + fn ubuntu_opencode_openai_model_image() -> &'static str { UBUNTU_OPENCODE_OPENAI_MODEL_IMAGE.get_or_init(|| { build_image( @@ -2090,6 +2199,58 @@ mod opencode_openai { } } +// --------------------------------------------------------------------------- +// Codex + OpenAI integration tests +// --------------------------------------------------------------------------- + +mod codex_openai { + use super::*; + + #[test] + #[ignore] + fn codex_with_anthropic_inference_is_rejected() { + let binary = env!("CARGO_BIN_EXE_openshell-image-builder"); + let status = Command::new(binary) + .args([ + "--runtime", + "podman", + "--agent", + "codex", + "--inference", + "anthropic", + "should-not-be-built:test", + ]) + .status() + .expect("binary should run"); + assert!( + !status.success(), + "building with --agent codex --inference anthropic should fail" + ); + } + + #[test] + #[ignore] + fn codex_with_ollama_inference_is_rejected() { + let binary = env!("CARGO_BIN_EXE_openshell-image-builder"); + let status = Command::new(binary) + .args([ + "--runtime", + "podman", + "--agent", + "codex", + "--inference", + "ollama", + "should-not-be-built:test", + ]) + .status() + .expect("binary should run"); + assert!( + !status.success(), + "building with --agent codex --inference ollama should fail" + ); + } +} + // --------------------------------------------------------------------------- // --model integration tests // --------------------------------------------------------------------------- @@ -2102,6 +2263,7 @@ static UBUNTU_CLAUDE_ANTHROPIC_MODEL_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_CLAUDE_VERTEXAI_MODEL_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_ANTHROPIC_MODEL_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_OLLAMA_MODEL_IMAGE: OnceLock = OnceLock::new(); +static UBUNTU_CODEX_OPENAI_MODEL_IMAGE: OnceLock = OnceLock::new(); fn ubuntu_claude_anthropic_model_image() -> &'static str { UBUNTU_CLAUDE_ANTHROPIC_MODEL_IMAGE.get_or_init(|| { @@ -2171,6 +2333,23 @@ fn ubuntu_opencode_ollama_model_image() -> &'static str { }) } +fn ubuntu_codex_openai_model_image() -> &'static str { + UBUNTU_CODEX_OPENAI_MODEL_IMAGE.get_or_init(|| { + build_image( + "openshell-test-ubuntu-codex-openai-model:integration", + &[ + "--agent", + "codex", + "--inference", + "openai", + "--model", + MODEL_OPENAI, + "--with-agent-settings", + ], + ) + }) +} + // claude + anthropic + model: .claude/settings.json written with "model" field mod model_claude_anthropic { use super::*; @@ -2415,6 +2594,50 @@ mod model_opencode_openai { } } +// codex + openai + model: .codex/config.toml written with "model" field +mod model_codex_openai { + use super::*; + + #[test] + #[ignore] + fn config_toml_present() { + let out = run_in_image( + ubuntu_codex_openai_model_image(), + "test -f /sandbox/.codex/config.toml", + ); + assert!( + out.status.success(), + ".codex/config.toml not found when built with --inference openai --model" + ); + } + + #[test] + #[ignore] + fn config_toml_contains_model() { + let cmd = format!("grep -q '{}' /sandbox/.codex/config.toml", MODEL_OPENAI); + let out = run_in_image(ubuntu_codex_openai_model_image(), &cmd); + assert!( + out.status.success(), + "model value not found in .codex/config.toml" + ); + } + + #[test] + #[ignore] + fn config_toml_owned_by_sandbox() { + let out = run_in_image( + ubuntu_codex_openai_model_image(), + "stat -c '%U' /sandbox/.codex/config.toml", + ); + assert!(out.status.success(), "failed to stat .codex/config.toml"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "sandbox", + ".codex/config.toml not owned by sandbox" + ); + } +} + // --------------------------------------------------------------------------- // --endpoint integration tests // --------------------------------------------------------------------------- @@ -2425,6 +2648,7 @@ const OLLAMA_CUSTOM_ENDPOINT: &str = "http://localhost:9999/v1"; static UBUNTU_CLAUDE_ANTHROPIC_ENDPOINT_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_ANTHROPIC_ENDPOINT_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_OLLAMA_CUSTOM_ENDPOINT_IMAGE: OnceLock = OnceLock::new(); +static UBUNTU_CODEX_OPENAI_ENDPOINT_IMAGE: OnceLock = OnceLock::new(); fn ubuntu_claude_anthropic_endpoint_image() -> &'static str { UBUNTU_CLAUDE_ANTHROPIC_ENDPOINT_IMAGE.get_or_init(|| { @@ -2479,6 +2703,23 @@ fn ubuntu_opencode_ollama_custom_endpoint_image() -> &'static str { }) } +fn ubuntu_codex_openai_endpoint_image() -> &'static str { + UBUNTU_CODEX_OPENAI_ENDPOINT_IMAGE.get_or_init(|| { + build_image( + "openshell-test-ubuntu-codex-openai-endpoint:integration", + &[ + "--agent", + "codex", + "--inference", + "openai", + "--endpoint", + "https://my-openai-proxy.example.com", + "--with-policy", + ], + ) + }) +} + // claude + anthropic + custom endpoint: policy replaced, ANTHROPIC_BASE_URL baked in mod endpoint_claude_anthropic { use super::*; @@ -2605,6 +2846,22 @@ mod endpoint_opencode_ollama_custom { } } +// codex + openai + custom endpoint: OPENAI_BASE_URL baked in +mod endpoint_codex_openai { + use super::*; + + #[test] + #[ignore] + fn openai_base_url_env_set_to_proxy() { + let cmd = "test \"$OPENAI_BASE_URL\" = \"https://my-openai-proxy.example.com\""; + let out = run_in_image(ubuntu_codex_openai_endpoint_image(), cmd); + assert!( + out.status.success(), + "OPENAI_BASE_URL is not set to the proxy URL in the image" + ); + } +} + // vertexai + endpoint rejection: does not require podman, never #[ignore] mod endpoint_rejection { use super::*; @@ -2860,6 +3117,12 @@ fn cleanup_images() { "openshell-test-fedora-ssl-certs:integration", "openshell-test-ubuntu-no-certs:integration", "openshell-test-fedora-no-certs:integration", + "openshell-test-ubuntu-codex:integration", + "openshell-test-fedora-codex:integration", + "openshell-test-ubi-codex:integration", + "openshell-test-hummingbird-codex:integration", + "openshell-test-ubuntu-codex-openai-model:integration", + "openshell-test-ubuntu-codex-openai-endpoint:integration", ] { Command::new("podman") .args(["rmi", "--force", tag])