diff --git a/Makefile b/Makefile index 5330831..45b0cc3 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,8 @@ CLAUDE_IMG ?= claude-code:local CLAUDE_CTR ?= claude-$(PROJECT_NAME) GROK_IMG ?= grok-build:local GROK_CTR ?= grok-$(PROJECT_NAME) +CODEX_IMG ?= codex-cli:local +CODEX_CTR ?= codex-$(PROJECT_NAME) BROKER_IMG ?= swarmforge-docker-broker:latest @@ -25,6 +27,9 @@ CLAUDE_ARGS ?= GROK_DATA_DIR ?= $(HOME)/.local/share/grok GROK_HOME_DIR ?= $(GROK_DATA_DIR)/home GROK_ARGS ?= +CODEX_DATA_DIR ?= $(HOME)/.local/share/codex +CODEX_HOME_DIR ?= $(CODEX_DATA_DIR)/home +CODEX_ARGS ?= # Stable per-repo mount path knobs, shared by every persistent-home harness. SWARMFORGE_REPO_SLUG ?= SWARMFORGE_REMOTE_NAME ?= origin @@ -169,7 +174,17 @@ GROK_RUN_MOUNTS = \ --tmpfs $(ANVIL_HOME)/.grok/commands \ $(SWARMFORGE_LAYER_MOUNTS) -.PHONY: opencode_network build_opencode update_opencode build_broker build_claude update_claude build_grok update_grok run_opencode stop_opencode run_claude stop_claude run_grok stop_grok run_ollama logs_ollama stop_ollama gpu_stat clean \ +CODEX_RUN_ENV = \ + -e SWARMFORGE_AGENT_BIN=codex \ + $(SWARMFORGE_LAYER_ENV) + +# Codex's native skills dir is ~/.agents/skills, masked for the reason above. +CODEX_RUN_MOUNTS = \ + -v "$(CODEX_HOME_DIR)":$(ANVIL_HOME) \ + --tmpfs $(ANVIL_HOME)/.agents/skills:exec \ + $(SWARMFORGE_LAYER_MOUNTS) + +.PHONY: opencode_network build_opencode update_opencode build_broker build_claude update_claude build_grok update_grok build_codex update_codex run_opencode stop_opencode run_claude stop_claude run_grok stop_grok run_codex stop_codex run_ollama logs_ollama stop_ollama gpu_stat clean \ run_llama_3-1-8b run_gpt-oss-20b run_gpt-oss-120b run_devstral2_small test test-skills lint # The workspace is mounted read-write, but the paths inside its git dir that @@ -317,6 +332,19 @@ build_grok: update_grok: $(MAKE) build_grok SWARMFORGE_HARNESS_INSTALL_BUST=$(shell date +%s) +build_codex: + docker build \ + --target codex-runtime \ + --build-arg AGENT=codex \ + --build-arg DEBIAN_TAG=$(DEBIAN_TAG) \ + --build-arg SWARMFORGE_HARNESS_INSTALL_BUST=$(SWARMFORGE_HARNESS_INSTALL_BUST) \ + -f "$(SWARMFORGE_DIR)/anvil/Dockerfile" \ + -t $(CODEX_IMG) "$(SWARMFORGE_DIR)" + +# Rebuild only from the Codex install step onward. +update_codex: + $(MAKE) build_codex SWARMFORGE_HARNESS_INSTALL_BUST=$(shell date +%s) + run_opencode: SWARMFORGE_USER_CONFIG_DIR ?= $(HOME)/.config/opencode run_opencode: SWARMFORGE_ORG_CONFIG_DIR ?= $(if $(strip $(SWARMFORGE_ORG_CONFIG_ROOT)),$(SWARMFORGE_ORG_CONFIG_ROOT)/.opencode,) run_opencode: SWARMFORGE_REPO_CONFIG_DIR ?= $(OPENCODE_CONFIG_DIR) @@ -365,6 +393,23 @@ run_grok: opencode_network stop_grok: @docker rm -f $(GROK_CTR) >/dev/null 2>&1 || true +run_codex: SWARMFORGE_USER_CONFIG_DIR ?= $(HOME)/.codex +run_codex: SWARMFORGE_ORG_CONFIG_DIR ?= $(if $(strip $(SWARMFORGE_ORG_CONFIG_ROOT)),$(SWARMFORGE_ORG_CONFIG_ROOT)/.codex,) +run_codex: SWARMFORGE_REPO_CONFIG_DIR ?= $(SWARMFORGE_DIR)/codex +run_codex: SWARMFORGE_CONFIG_RESET ?= 0 +run_codex: opencode_network + @mkdir -p "$(CODEX_HOME_DIR)" + @mkdir -p "$(SWARMFORGE_USER_CONFIG_DIR)" + @mkdir -p "$(CODEX_HOME_DIR)/.swarmforge" + @mkdir -p "$(CODEX_HOME_DIR)/.swarmforge/skills" + @mkdir -p "$(CODEX_HOME_DIR)/.swarmforge/command" + @mkdir -p "$(CODEX_HOME_DIR)/.agents/skills" + @mkdir -p "$(CODEX_HOME_DIR)/.codex" + $(call run_agent_container,$(CODEX_CTR),$(CODEX_RUN_ENV),$(CODEX_RUN_MOUNTS),$(CODEX_IMG),$(CODEX_ARGS),repo-slug,codex) + +stop_codex: + @docker rm -f $(CODEX_CTR) >/dev/null 2>&1 || true + run_ollama: opencode_network @docker rm -f $(OLLAMA_CTR) >/dev/null 2>&1 || true docker run -d --rm --name $(OLLAMA_CTR) \ @@ -386,7 +431,7 @@ stop_ollama: gpu_stat: nvidia-smi -clean: stop_opencode stop_claude stop_grok stop_ollama +clean: stop_opencode stop_claude stop_grok stop_codex stop_ollama @docker network rm $(NETWORK) >/dev/null 2>&1 || true run_llama_3-1-8b: diff --git a/README.md b/README.md index 700267a..87aee09 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Override the target file with `OC_RC_FILE=/path/to/rc bash ./install.sh`. make build_opencode make build_claude make build_grok +make build_codex ``` To pin OpenCode to a specific release instead of latest: @@ -34,13 +35,14 @@ make update_opencode OPENCODE_VERSION=1.4.14 ``` The images share the same Debian base and toolchain (Node.js + Python; see `anvil/Dockerfile`). -Build targets pass `AGENT=opencode|claude|grok` so only the requested agent install step runs. +Build targets pass `AGENT=opencode|claude|grok|codex` so only the requested agent install step runs. 3. Run from your project directory: - OpenCode: `oc` - Claude Code: `make run_claude PROJECT_DIR=$(pwd)` - Grok Build: `make run_grok PROJECT_DIR=$(pwd)` +- Codex CLI: `make run_codex PROJECT_DIR=$(pwd)` - Pass OpenCode overrides as arguments (`oc PROFILE=work DATA_DIR=...`) or env vars (`PROFILE=work oc`). - Override the container timezone per run (affects git commit timestamps): `oc TIMEZONE=America/New_York`. @@ -60,10 +62,10 @@ alias ccd='make -C PATH_TO_SWARMFORGE run_claude PROJECT_DIR=$(pwd) CLAUDE_DATA_ - `GITCONFIG_FILE` points at an agent-specific git config instead of `~/.gitconfig`. - For Claude Code, use separate `CLAUDE_DATA_DIR` roots to isolate work/personal logins and session state. `CLAUDE_HOME_DIR` defaults to `$(CLAUDE_DATA_DIR)/home`. -- Config layering uses `SWARMFORGE_USER_CONFIG_DIR`, `SWARMFORGE_ORG_CONFIG_DIR`, and `SWARMFORGE_REPO_CONFIG_DIR` (their defaults differ per harness — see OpenCode layering under [Skills](#skills) and [Claude config layering](#claude-config-layering)). Set `SWARMFORGE_ORG_CONFIG_ROOT=/path/to/org-repo` to resolve org defaults to `.opencode` (OpenCode) and `.claude` (Claude) under that root. +- Config layering uses `SWARMFORGE_USER_CONFIG_DIR`, `SWARMFORGE_ORG_CONFIG_DIR`, and `SWARMFORGE_REPO_CONFIG_DIR` (their defaults differ per harness — see OpenCode layering under [Skills](#skills) and [Claude config layering](#claude-config-layering)). Set `SWARMFORGE_ORG_CONFIG_ROOT=/path/to/org-repo` to resolve org defaults to each harness's own directory under that root (`.opencode`, `.claude`, `.grok`, `.codex`). `SWARMFORGE_REPO_CONFIG_DIR` refers to the Swarmforge checkout (the harness repo), not the working project mounted at `/workspace`. -By default it is `$(SWARMFORGE_DIR)/opencode` for `run_opencode` and `$(SWARMFORGE_DIR)/claude` (if present) for `run_claude`. +By default each `run_*` target points it at that harness's directory in the checkout: `$(SWARMFORGE_DIR)/opencode`, and `$(SWARMFORGE_DIR)/claude`, `/grok`, `/codex` if present. Project-local config in the working repo (for example `.opencode/`) is still handled by the agent tools themselves. ### Git repos and worktrees @@ -108,8 +110,12 @@ The repo is mounted at a stable path derived from the git remote slug (with `/wo ### Shared assets (skills, commands, agents) Every harness mounts this repo's `skills/` and `commands/` into the container, exported as `SWARMFORGE_SKILLS_DIR` and `SWARMFORGE_COMMAND_DIR`. -The entrypoint copies them into each harness's native location: the container-local config dir for Claude (see [The config directory](#the-config-directory)), the merged config dir for OpenCode (`~/.config/opencode/skills/`) and Grok (`~/.grok/skills/`). -For Claude and Grok those dirs are container-private and rebuilt each run, so per-repo assets never accumulate in the persistent home or leak into other repos' sessions. +The entrypoint copies them into each harness's native location: the container-local config dir for Claude (see [The config directory](#the-config-directory)), the merged config dir for OpenCode (`~/.config/opencode/skills/`) and Grok (`~/.grok/skills/`), and `~/.agents/skills/` for Codex, whose native user location is the `.agents` convention itself. +For Claude, Grok, and Codex those dirs are container-private and rebuilt each run, so per-repo assets never accumulate in the persistent home or leak into other repos' sessions. +Codex has no user-defined slash commands, so portable commands become +same-named skills. Translation removes command-only metadata and adapts +arguments and shell interpolation. A native skill wins over a translated +command in the same layer; normal layer precedence still applies. Skills, commands, and agents come from four layers, lowest to highest precedence — later layers override same-named entries wholesale (never file-merged): @@ -118,7 +124,7 @@ Skills, commands, and agents come from four layers, lowest to highest precedence - **repo** — this checkout's `skills/`, `commands/`, and `agents/` - **workspace** — `/.agents/{skills,commands}` and `/.swarmforge/agents/` -Skills and commands follow the harness-neutral `.agents/{skills,commands}` convention and are copied as-is; agents use the unified format (see [Agents](#agents)) and are translated per harness. +Skills and commands follow the harness-neutral `.agents/{skills,commands}` convention. Skills are copied as-is; commands are copied for harnesses with native commands and translated into skills for Codex. Agents use the unified format (see [Agents](#agents)) and are translated per harness. Harness-native dirs (`/.opencode/skills/`, `/.claude/skills/`) are not consumed for skills/commands. Override the `.agents` roots with `SWARMFORGE_USER_DOTAGENTS_DIR` / `SWARMFORGE_ORG_DOTAGENTS_DIR`. @@ -176,7 +182,7 @@ Grok state persists by mounting `$(GROK_HOME_DIR)` to `/home/anvil`, keeping `~/ Grok reads the repo-root `AGENTS.md` family natively from the git root down, so it picks up this repo's instructions with no extra config. Shared skills reach `~/.grok/skills/`, Grok's native location, through the [asset pipeline](#shared-assets-skills-commands-agents) above. -Subagent definitions are not translated for Grok; the unified-agent pipeline covers OpenCode and Claude only. +Subagent definitions are not translated for Grok; the unified-agent pipeline covers OpenCode, Claude, and Codex. MCP tongs reach Grok as `[mcp_servers.]` entries in a managed block of the merged `~/.grok/config.toml` — user-level config, so no folder-trust prompt. That file is in the persistent home, so the block is rewritten every run and stripped when a session has no MCP tongs; a server the user already defines under the same name wins over the generated entry. Grok config layering uses the same three sources and order of trust as Claude (lowest to highest precedence): @@ -186,6 +192,37 @@ Grok config layering uses the same three sources and order of trust as Claude (l These merge into `~/.grok` in the container at startup, with reset disabled so credentials survive the run. Rebuild only the Grok install layer with `make update_grok`. +## Codex CLI + +`make run_codex` starts an [OpenAI Codex CLI](https://developers.openai.com/codex/cli) container with the same workspace, git-worktree, and repo-slug mounting as `make run_claude`. +The image installs the official CLI via `curl -fsSL https://chatgpt.com/codex/install.sh | sh`. +That release is a package rather than a lone binary -- `bin/codex` resolves ripgrep, `bwrap`, and a bundled zsh beside itself -- so it stays whole under `/opt/codex` and the installer's symlink is what lands on `PATH`. +Codex state persists by mounting `$(CODEX_HOME_DIR)` to `/home/anvil`, keeping credentials, sessions, and the project trust levels a stable mount path keeps valid. +`CODEX_HOME_DIR` defaults to `$(CODEX_DATA_DIR)/home`; use separate `CODEX_DATA_DIR` roots to isolate work/personal logins, as with `CLAUDE_DATA_DIR`. + +Codex reads the repo-root `AGENTS.md` family natively from the git root down, so it picks up this repo's instructions with no extra config. +Shared skills reach `~/.agents/skills/`, Codex's native user location, through the [asset pipeline](#shared-assets-skills-commands-agents) above. Portable commands reach the same location as translated skills. +Unified subagent definitions become temporary Codex role files under +`/run/swarmforge/codex-agents/` and are registered through the derived +`~/.codex/config.toml`. The checkout's native `.codex/agents/` is untouched. +MCP tongs reach Codex as `[mcp_servers.]` entries in a managed block of the derived `~/.codex/config.toml`, rewritten from the current layers every run and yielding to a server the user already defines under that name. + +Codex config layering uses the same three sources and order of trust as Claude (lowest to highest precedence): +- `SWARMFORGE_REPO_CONFIG_DIR` (default `codex/`, if present) +- `SWARMFORGE_USER_CONFIG_DIR` (default `~/.codex`) +- `SWARMFORGE_ORG_CONFIG_DIR` (optional; defaults to `$(SWARMFORGE_ORG_CONFIG_ROOT)/.codex` when that root is set) + +The entrypoint builds `config.toml` from scratch in repo → user → org order, +merging by key, then copies it to Codex's native path. The canonical output +preserves values and tables, but not comments or formatting. The native file +remains writable for Codex's atomic settings updates, but the next launch +rebuilds it; put durable settings in a source layer. Rebuild only the Codex +install layer with `make update_codex`. +The merge skips `packages/` -- the host installer's release tree, which the container has no use for -- along with `sessions/`, `history.jsonl`, and `log/`, so one machine's transcripts do not follow the user config layer into the container's home. + +Codex brings its own sandbox, which is redundant inside an anvil and may not initialize in one at all, since its Landlock and `bwrap` paths need kernel permissions a container is not guaranteed. +Relax it per run with `CODEX_ARGS='--dangerously-bypass-approvals-and-sandbox'`, or per install by setting `sandbox_mode` in a config layer. + ## Agents Subagent definitions live under `agents/` in a single unified format and are rewritten to each harness's native dialect by the container entrypoint (`swarmforge/agents/translate.py`). @@ -204,6 +241,10 @@ tools: bash: false claude: maxTurns: 12 +codex: + model: gpt-5.3-codex + model_reasoning_effort: high + sandbox_mode: read-only --- You are the reviewer agent... @@ -215,8 +256,9 @@ Field handling per harness: - `tools` uses OpenCode's lowercase tool ids mapped to booleans. For Claude Code, disabled tools become `disallowedTools` (`write: false` -> `disallowedTools: Write`); ids with no Claude equivalent are dropped. - `model` accepts a provider-qualified id (`anthropic/claude-sonnet-4-6`, passed through to OpenCode and stripped to the bare id for Claude — non-Anthropic providers dropped) or a Claude alias (`sonnet`, `haiku`, Claude-only and dropped for OpenCode). - `mode`, `temperature`, and other OpenCode-only fields are dropped for Claude Code. -- `claude:` / `opencode:` blocks merge verbatim into that harness's output frontmatter. -- `disable: true` passes through to OpenCode and skips the agent for Claude Code. +- For Codex, unqualified models pass through, `openai/` prefixes are stripped, and other providers are dropped. Names and `.toml` filenames are normalized to Codex's supported ASCII characters. Generic `tools` restrictions are dropped; use Codex sandbox and MCP settings instead. +- `claude:`, `codex:`, and `opencode:` blocks merge into that harness's output. Put Codex-only fields such as `model_reasoning_effort` and `sandbox_mode` in `codex:`. +- `disable: true` passes through to OpenCode and skips the agent for Claude Code and Codex. Unified agents live in harness-neutral `.swarmforge/agents/` directories across the same four layers as shared assets (lowest to highest precedence): @@ -225,7 +267,7 @@ Unified agents live in harness-neutral `.swarmforge/agents/` directories across - **repo** — `agents/` in the checkout (override with `SWARMFORGE_REPO_AGENTS_DIR`, which points directly at an agents dir so the rest of the checkout is never mounted) - **workspace** — `/.swarmforge/agents/` -Layers mount read-only under `/tmp/swarmforge-assets/{user,org}` and `/tmp/swarmforge-assets/repo/agents` (the in-container `SWARMFORGE_ASSETS_{USER,ORG,REPO}_DIR` env vars point at the layer roots); the entrypoint translates the stacked sources into each harness's native location (`~/.config/opencode/agents/` for OpenCode, the container-private `~/.claude/agents/` for Claude). Later layers override earlier ones by filename. +Layers mount read-only under `/tmp/swarmforge-assets/{user,org}` and `/tmp/swarmforge-assets/repo/agents` (the in-container `SWARMFORGE_ASSETS_{USER,ORG,REPO}_DIR` env vars point at the layer roots). The entrypoint translates them into `~/.config/opencode/agents/` for OpenCode, the container-private `~/.claude/agents/` for Claude, and temporary registered role files for Codex. Later layers override earlier ones by filename. Claude-native repo-local definitions (for example `/.claude/agents/`) are still discovered by Claude directly, outside this pipeline. The translator is covered by the unit suite; run it with `make test`. diff --git a/anvil/Dockerfile b/anvil/Dockerfile index fcacfe5..6bca5bb 100644 --- a/anvil/Dockerfile +++ b/anvil/Dockerfile @@ -132,6 +132,11 @@ RUN set -eux; \ install -m 0755 "${grok_bin}" /usr/local/bin/grok; \ rm -rf /root/.grok; \ ;; \ + codex) \ + echo "Installing Codex CLI (cache bust: ${SWARMFORGE_HARNESS_INSTALL_BUST})"; \ + export CODEX_HOME=/opt/codex CODEX_INSTALL_DIR=/usr/local/bin; \ + curl -fsSL https://chatgpt.com/codex/install.sh | sh; \ + ;; \ *) \ printf '%s\n' "Unsupported AGENT: ${AGENT}" >&2; \ exit 1; \ @@ -154,4 +159,6 @@ RUN chmod +x /usr/local/bin/swarmforge-statusline FROM agent-runtime AS grok-runtime +FROM agent-runtime AS codex-runtime + FROM opencode-runtime AS default diff --git a/anvil/entrypoint.sh b/anvil/entrypoint.sh index 95c6c64..d81282b 100644 --- a/anvil/entrypoint.sh +++ b/anvil/entrypoint.sh @@ -12,6 +12,9 @@ AGENT_BIN="${SWARMFORGE_AGENT_BIN:-opencode}" AGENT_BIN_PATH="/usr/local/bin/${AGENT_BIN}" CLAUDE_SETTINGS_FILE="/run/swarmforge/claude-settings.json" CLAUDE_CONFIG_HOME="/run/swarmforge/claude-config" +CODEX_CONFIG_HOME="/run/swarmforge/codex-config" +CODEX_CONFIG_FILE="${ANVIL_HOME}/.codex/config.toml" +CODEX_AGENTS_HOME="/run/swarmforge/codex-agents" # State only: nothing claude loads as configuration or code belongs here. CLAUDE_STATE_DIRS="projects sessions file-history session-env shell-snapshots @@ -45,6 +48,7 @@ copy_dir_entries() { [ -n "${src_dir}" ] || return 0 [ -d "${src_dir}" ] || return 0 + [ -n "${dst_dir}" ] || return 0 mkdir -p "${dst_dir}" @@ -60,6 +64,18 @@ copy_dir_entries() { done } +translate_codex_commands() { + src_dir="${1}" + skills_dst="${2}" + + [ -n "${src_dir}" ] || return 0 + [ -d "${src_dir}" ] || return 0 + + PYTHONPATH=/usr/local/lib/swarmforge python3 -P -m swarmforge.commands.translate \ + "${skills_dst}" "${src_dir}" \ + || printf '%s\n' "Warning: command translation failed for Codex; continuing" >&2 +} + # Only the allowlisted state outlives the run: claude loads configuration and # code out of this dir, and a shared one would hand a session's writes to the # next container. Symlinks rather than bind mounts survive the atomic rename @@ -122,6 +138,12 @@ copy_shared_assets() { skills_dst="${ANVIL_HOME}/.grok/skills" commands_dst="${ANVIL_HOME}/.grok/commands" ;; + codex) + # Codex uses skills as its extension point; portable commands are + # translated into skill packages below. + skills_dst="${ANVIL_HOME}/.agents/skills" + commands_dst="codex-skills" + ;; opencode) config_dest="${SWARMFORGE_CONFIG_DEST:-${ANVIL_HOME}/.config/opencode}" skills_dst="${config_dest}/skills" @@ -134,15 +156,26 @@ copy_shared_assets() { for layer_src in "${SWARMFORGE_DOTAGENTS_USER_DIR:-}" "${SWARMFORGE_DOTAGENTS_ORG_DIR:-}"; do [ -n "${layer_src}" ] || continue - copy_dir_entries "${layer_src}/skills" "${skills_dst}" - copy_dir_entries "${layer_src}/commands" "${commands_dst}" + if [ "${commands_dst}" = "codex-skills" ]; then + translate_codex_commands "${layer_src}/commands" "${skills_dst}" + copy_dir_entries "${layer_src}/skills" "${skills_dst}" + else + copy_dir_entries "${layer_src}/skills" "${skills_dst}" + copy_dir_entries "${layer_src}/commands" "${commands_dst}" + fi done - copy_dir_entries "${SWARMFORGE_SKILLS_DIR:-}" "${skills_dst}" - copy_dir_entries "${SWARMFORGE_COMMAND_DIR:-}" "${commands_dst}" - - copy_dir_entries "${workspace_dir}/.agents/skills" "${skills_dst}" - copy_dir_entries "${workspace_dir}/.agents/commands" "${commands_dst}" + if [ "${commands_dst}" = "codex-skills" ]; then + translate_codex_commands "${SWARMFORGE_COMMAND_DIR:-}" "${skills_dst}" + copy_dir_entries "${SWARMFORGE_SKILLS_DIR:-}" "${skills_dst}" + translate_codex_commands "${workspace_dir}/.agents/commands" "${skills_dst}" + copy_dir_entries "${workspace_dir}/.agents/skills" "${skills_dst}" + else + copy_dir_entries "${SWARMFORGE_SKILLS_DIR:-}" "${skills_dst}" + copy_dir_entries "${SWARMFORGE_COMMAND_DIR:-}" "${commands_dst}" + copy_dir_entries "${workspace_dir}/.agents/skills" "${skills_dst}" + copy_dir_entries "${workspace_dir}/.agents/commands" "${commands_dst}" + fi } # Translate unified Swarmforge agent definitions into the running harness's @@ -150,8 +183,8 @@ copy_shared_assets() { # # Unified definitions are markdown files whose YAML frontmatter is a superset # of the OpenCode agent schema (description, mode, model, temperature, tools) -# plus optional per-harness override blocks (claude:, opencode:). One shared -# translator (swarmforge.agents.translate) emits each harness's dialect, so +# plus optional per-harness override blocks (claude:, codex:, opencode:). +# One translator (swarmforge.agents.translate) emits each harness's dialect, so # adding a new harness means adding an emitter there plus a case arm here. # # Unified Swarmforge agent definitions live under /agents in the @@ -181,6 +214,9 @@ prepare_unified_agents() { opencode) agents_dst="${SWARMFORGE_CONFIG_DEST:-${ANVIL_HOME}/.config/opencode}/agents" ;; + codex) + agents_dst="${CODEX_AGENTS_HOME}" + ;; *) return 0 ;; @@ -190,13 +226,25 @@ prepare_unified_agents() { # to /usr/local/lib/swarmforge; -P keeps the working directory off sys.path, # so a workspace that happens to contain a swarmforge/ directory cannot # shadow it. These run as root, before the drop to the invoking user. - PYTHONPATH=/usr/local/lib/swarmforge python3 -P -m swarmforge.agents.translate \ + if ! PYTHONPATH=/usr/local/lib/swarmforge python3 -P -m swarmforge.agents.translate \ "${AGENT_BIN}" "${agents_dst}" \ "${SWARMFORGE_ASSETS_USER_DIR:-}/agents" \ "${SWARMFORGE_ASSETS_ORG_DIR:-}/agents" \ "${SWARMFORGE_ASSETS_REPO_DIR:-}/agents" \ - "${workspace_dir}/.swarmforge/agents" \ - || printf '%s\n' "Warning: unified agent translation failed for ${AGENT_BIN}; continuing" >&2 + "${workspace_dir}/.swarmforge/agents"; then + printf '%s\n' "Warning: unified agent translation failed for ${AGENT_BIN}; continuing" >&2 + return 0 + fi +} + +register_codex_agents() { + [ "${AGENT_BIN}" = "codex" ] || return 0 + [ -f "${CODEX_AGENTS_HOME}/config.toml" ] || return 0 + + PYTHONPATH=/usr/local/lib/swarmforge python3 -P -m swarmforge.config.merge_toml \ + --build "${CODEX_CONFIG_FILE}" \ + "${CODEX_AGENTS_HOME}/config.toml" "${CODEX_CONFIG_FILE}" \ + || printf '%s\n' "Warning: Codex agent registration failed; continuing" >&2 } merge_config_layer() { @@ -244,6 +292,11 @@ merge_config_layer() { # /usr/local/bin/grok, so copying them in would only leave them there. exclude_args="${exclude_args} --exclude=./skills --exclude=./commands --exclude=./bin --exclude=./downloads --exclude=./completions" ;; + codex) + exclude_args="${exclude_args} --exclude=./skills --exclude=./packages" + exclude_args="${exclude_args} --exclude=./sessions --exclude=./history.jsonl --exclude=./log" + exclude_args="${exclude_args} --exclude=./config.toml" + ;; opencode) exclude_args="${exclude_args} --exclude=./skills --exclude=./command" ;; @@ -312,6 +365,21 @@ build_claude_settings() { fi } +build_codex_config() { + config_dst="${1}" + config_repo_src="${2:-}" + config_user_src="${3:-}" + config_org_src="${4:-}" + + [ "${AGENT_BIN}" = "codex" ] || return 0 + + PYTHONPATH=/usr/local/lib/swarmforge python3 -P -m swarmforge.config.merge_toml \ + --build "${config_dst}/config.toml" \ + "${config_repo_src:+${config_repo_src}/config.toml}" \ + "${config_user_src:+${config_user_src}/config.toml}" \ + "${config_org_src:+${config_org_src}/config.toml}" +} + prepare_layered_config() { config_dst="${1}" user_config_src="${2:-}" @@ -339,15 +407,20 @@ prepare_layered_config() { merge_config_layer "${org_config_src}" "${config_dst}" merge_config_file "${org_config_src}/opencode.json" "${config_dst}/opencode.json" - # Sidecar (tong) MCP servers, generated by the host launcher and bind-mounted - # in read-only, merge last so they take precedence. The variable is set only - # for a harness that reads the fragment here, and each merges into its own - # config file, so the fragment never lands in another harness's. + build_codex_config \ + "${config_dst}" \ + "${repo_config_src}" \ + "${user_config_src}" \ + "${org_config_src}" + + # Sidecar MCP servers merge last but yield to same-named layer entries. + # Only harnesses handled here receive the bind-mounted fragment, and each + # merges it into its own config file. case "${AGENT_BIN:-}" in - grok) - # This dest is a persistent home, so the servers go in a managed block - # the module rewrites each run rather than being appended. - PYTHONPATH=/usr/local/lib/swarmforge python3 -P -m swarmforge.config.merge_grok_mcp \ + grok|codex) + # Servers go in a managed block the module rewrites each run rather than + # being appended; this also removes stale entries when no tongs are set. + PYTHONPATH=/usr/local/lib/swarmforge python3 -P -m swarmforge.config.merge_toml_mcp \ "${config_dst}/config.toml" ${SWARMFORGE_TONG_MCP_FILE:+"${SWARMFORGE_TONG_MCP_FILE}"} ;; *) @@ -366,11 +439,19 @@ prepare_layered_config() { prepare_agent_config() { config_dest="${SWARMFORGE_CONFIG_DEST:-}" + reset_config="${SWARMFORGE_CONFIG_RESET:-0}" # Not the caller's to choose: a merged layer landing in the shared home # would outlive the container. [ "${AGENT_BIN}" != "claude" ] || config_dest="${CLAUDE_CONFIG_HOME}" + # Rebuild Codex config outside its persistent home, which also holds state. + # Copying the result back keeps config.toml writable for atomic updates. + if [ "${AGENT_BIN}" = "codex" ]; then + config_dest="${CODEX_CONFIG_HOME}" + reset_config=1 + fi + [ -n "${config_dest}" ] || return 0 prepare_layered_config \ @@ -378,7 +459,15 @@ prepare_agent_config() { "${SWARMFORGE_CONFIG_USER_DIR:-}" \ "${SWARMFORGE_CONFIG_ORG_DIR:-}" \ "${SWARMFORGE_CONFIG_REPO_DIR:-}" \ - "${SWARMFORGE_CONFIG_RESET:-0}" + "${reset_config}" + + if [ "${AGENT_BIN}" = "codex" ]; then + # Truncation clears the prior run even when no layer supplies config.toml. + : > "${CODEX_CONFIG_FILE}" + if [ -f "${CODEX_CONFIG_HOME}/config.toml" ]; then + cp "${CODEX_CONFIG_HOME}/config.toml" "${CODEX_CONFIG_FILE}" + fi + fi } if [ ! -x "${AGENT_BIN_PATH}" ]; then @@ -409,6 +498,7 @@ fi prepare_agent_config prepare_unified_agents +register_codex_agents copy_shared_assets if [ "${AGENT_BIN}" = "claude" ]; then @@ -417,6 +507,7 @@ fi chown -R "${ANVIL_UID}:${ANVIL_GID}" "${ANVIL_HOME}" 2>/dev/null || true chown -Rh "${ANVIL_UID}:${ANVIL_GID}" "${CLAUDE_CONFIG_HOME}" 2>/dev/null || true +chown -Rh "${ANVIL_UID}:${ANVIL_GID}" "${CODEX_AGENTS_HOME}" 2>/dev/null || true chown -R "${ANVIL_UID}:${ANVIL_GID}" /workspace 2>/dev/null || true if [ "${AGENT_BIN}" = "claude" ]; then diff --git a/swarmforge/agents/translate.py b/swarmforge/agents/translate.py index b24b8b1..323512f 100644 --- a/swarmforge/agents/translate.py +++ b/swarmforge/agents/translate.py @@ -15,6 +15,8 @@ model: haiku merged into the output opencode: frontmatter verbatim) permission: ... + codex: + model_reasoning_effort: high The agent's identity is its filename (foo.md -> agent "foo"); a `name` field is emitted only for harnesses that require one. Tool names use OpenCode's @@ -30,6 +32,9 @@ `disallowedTools`, rewrites `model` (anthropic/ -> , other providers dropped, aliases pass through), and drops OpenCode-only fields. + codex Emits project-agent TOML. OpenAI model prefixes are stripped, + other providers are dropped, and codex overrides are merged. + Generic tool restrictions are dropped. Usage: python3 -m swarmforge.agents.translate ... @@ -44,7 +49,7 @@ from swarmforge.yamlite import parse_map, parse_scalar -HARNESS_OVERRIDE_KEYS = {"claude", "opencode"} +HARNESS_OVERRIDE_KEYS = {"claude", "codex", "opencode"} # OpenCode tool id -> Claude Code tool name. Ids mapping to None have no # Claude equivalent and are dropped. @@ -132,6 +137,51 @@ def render(meta, body): return "---\n%s\n---\n\n%s" % ("\n".join(emit_map(meta)), body) +TOML_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def emit_toml_key(value): + text = str(value) + return text if TOML_BARE_KEY_RE.fullmatch(text) else emit_toml_string(text) + + +def emit_toml_string(value): + return json.dumps(str(value), ensure_ascii=False) + + +def emit_toml_multiline(value): + text = str(value).replace("\\", "\\\\").replace('"', '\\"') + return chr(34) * 3 + "\n" + text + chr(34) * 3 + + +def emit_toml_value(value): + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + if isinstance(value, list): + return "[%s]" % ", ".join(emit_toml_value(item) for item in value) + if isinstance(value, dict): + pairs = ( + "%s = %s" % (emit_toml_key(key), emit_toml_value(item)) + for key, item in value.items() + ) + return "{ %s }" % ", ".join(pairs) + return emit_toml_string(value) + + +def render_codex(meta): + lines = [] + for key, value in meta.items(): + rendered = ( + emit_toml_multiline(value) + if key == "developer_instructions" + else emit_toml_value(value) + ) + lines.append("%s = %s" % (emit_toml_key(key), rendered)) + return "\n".join(lines) + "\n" + + # --- Per-harness emitters --------------------------------------------------- @@ -191,9 +241,59 @@ def to_claude(name, meta): return out +CODEX_AGENT_TABLE_FIELDS = { + "default_subagent_model", + "enabled", + "max_depth", +} + + +def normalize_codex_name(name): + normalized = re.sub(r"[^A-Za-z0-9 _-]+", "-", str(name)) + normalized = normalized.strip(" _-") or "agent" + if normalized in CODEX_AGENT_TABLE_FIELDS: + normalized = "agent-" + normalized + return normalized + + +def to_codex(name, meta, body): + if meta.get("disable") is True: + return None + requested_name = meta.get("name", name) + codex_name = normalize_codex_name(requested_name) + if codex_name != requested_name: + warn("agent '%s': Codex name normalized to '%s'" % (name, codex_name)) + out = {"name": codex_name, "developer_instructions": body} + if "description" in meta: + out["description"] = meta["description"] + else: + warn("agent '%s' has no description" % name) + + model = meta.get("model") + if model is not None: + provider, sep, model_id = str(model).partition("/") + if not sep: + out["model"] = model + elif provider == "openai": + out["model"] = model_id + + if "tools" in meta: + warn( + "agent '%s': tool restrictions are not translated for Codex; " + "use codex sandbox/MCP settings" % name + ) + + overrides = meta.get("codex") + if isinstance(overrides, dict): + out.update(overrides) + out["name"] = normalize_codex_name(out["name"]) + return out + + EMITTERS = { "opencode": to_opencode, "claude": to_claude, + "codex": to_codex, } @@ -232,13 +332,29 @@ def main(argv): return 0 os.makedirs(dest_dir, exist_ok=True) + codex_registrations = {} for filename, (meta, body) in agents.items(): name = filename[: -len(".md")] - out_meta = emitter(name, meta) + out_meta = emitter(name, meta, body) if target == "codex" else emitter(name, meta) if out_meta is None: continue - with open(os.path.join(dest_dir, filename), "w", encoding="utf-8") as handle: - handle.write(render(out_meta, body)) + out_filename = ( + "%s.toml" % normalize_codex_name(name) if target == "codex" else filename + ) + out_path = os.path.join(dest_dir, out_filename) + with open(out_path, "w", encoding="utf-8") as handle: + if target == "codex": + handle.write(render_codex(out_meta)) + codex_registrations[out_meta["name"]] = { + "config_file": os.path.abspath(out_path) + } + else: + handle.write(render(out_meta, body)) + + if codex_registrations: + config_path = os.path.join(dest_dir, "config.toml") + with open(config_path, "w", encoding="utf-8") as handle: + handle.write(render_codex({"agents": codex_registrations})) return 0 diff --git a/swarmforge/commands/__init__.py b/swarmforge/commands/__init__.py new file mode 100644 index 0000000..f35f262 --- /dev/null +++ b/swarmforge/commands/__init__.py @@ -0,0 +1 @@ +"""Portable command translation.""" diff --git a/swarmforge/commands/translate.py b/swarmforge/commands/translate.py new file mode 100644 index 0000000..a98950c --- /dev/null +++ b/swarmforge/commands/translate.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Translate portable slash commands into Codex skill packages. + +Usage: python3 -m swarmforge.commands.translate +""" + +import os +import re +import shutil +import sys + +from swarmforge.agents.translate import split_frontmatter + + +SHELL_INTERPOLATION_RE = re.compile(r"!`([^`\n]+)`") +POSITIONAL_RE = re.compile(r"\$(\d+)") + + +def warn(message): + print("swarmforge.commands.translate: %s" % message, file=sys.stderr) + + +def describe_positionals(command): + positions = sorted({int(value) for value in POSITIONAL_RE.findall(command)}) + if not positions: + return "" + labels = ", ".join("$%d" % position for position in positions) + return ", replacing %s with the corresponding positional invocation argument%s" % ( + labels, + "" if len(positions) == 1 else "s", + ) + + +def translate_body(body): + body = body.replace( + "$ARGUMENTS", "the arguments supplied with this skill invocation" + ) + + def shell_instruction(match): + command = match.group(1) + return "Run `%s`%s and use its output." % ( + command, + describe_positionals(command), + ) + + return SHELL_INTERPOLATION_RE.sub(shell_instruction, body) + + +def translate_file(path, dest_dir): + filename = os.path.basename(path) + name = filename[:-3] + with open(path, "r", encoding="utf-8") as handle: + meta, body = split_frontmatter(handle.read()) + description = meta.get("description") + if not description: + warn("skipping %s: command has no description" % path) + return + skill_dir = os.path.join(dest_dir, name) + if os.path.lexists(skill_dir): + if os.path.isdir(skill_dir) and not os.path.islink(skill_dir): + shutil.rmtree(skill_dir) + else: + os.unlink(skill_dir) + os.makedirs(skill_dir) + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as handle: + handle.write( + "---\nname: %s\ndescription: %s\n---\n\n%s" + % (name, description, translate_body(body)) + ) + + +def main(argv): + if len(argv) != 2: + print(__doc__.strip(), file=sys.stderr) + return 2 + dest_dir, src_dir = argv + if not src_dir or not os.path.isdir(src_dir): + return 0 + os.makedirs(dest_dir, exist_ok=True) + for filename in sorted(os.listdir(src_dir)): + path = os.path.join(src_dir, filename) + if filename.endswith(".md") and os.path.isfile(path): + try: + translate_file(path, dest_dir) + except ValueError as exc: + warn("skipping %s: %s" % (path, exc)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/swarmforge/config/merge_toml.py b/swarmforge/config/merge_toml.py new file mode 100644 index 0000000..c92f504 --- /dev/null +++ b/swarmforge/config/merge_toml.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Build a TOML config from ordered, key-aware layers.""" + +import datetime +import json +import math +import os +import re +import sys +import tempfile +import tomllib + + +USAGE = "usage: python3 -m swarmforge.config.merge_toml --build DST [SRC ...]" + +_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$") + + +def merge(base, override, *, path=()): + """Deep-merge two parsed TOML values, with ``override`` taking precedence.""" + if isinstance(base, dict) and isinstance(override, dict): + out = dict(base) + if path in {("agents",), ("mcp_servers",)}: + out.update(override) + return out + for key, value in override.items(): + out[key] = ( + merge(out[key], value, path=path + (key,)) + if key in out else value + ) + return out + return override + + +def read_layer(path, *, err=sys.stderr): + """Return a parsed layer, or None when it is absent or invalid.""" + if not os.path.isfile(path): + return None + try: + with open(path, "rb") as handle: + value = tomllib.load(handle) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error: + print("skipping %s: %s" % (path, error), file=err) + return None + return value + + +def _key(value): + return value if _BARE_KEY.fullmatch(value) else _string(value) + + +def _string(value): + # JSON basic strings are also TOML basic strings. Keeping Unicode literal + # avoids surrogate escapes, while DEL still needs an explicit TOML escape. + return json.dumps(value, ensure_ascii=False).replace("\x7f", "\\u007F") + + +def _value(value): + if isinstance(value, str): + return _string(value) + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if math.isnan(value): + return "nan" + if math.isinf(value): + return "inf" if value > 0 else "-inf" + return repr(value) + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, list): + return "[" + ", ".join(_value(item) for item in value) + "]" + if isinstance(value, dict): + fields = ("%s = %s" % (_key(key), _value(item)) for key, item in value.items()) + return "{ " + ", ".join(fields) + " }" + raise TypeError("unsupported TOML value: %r" % (value,)) + + +def dumps(value): + """Serialize a dictionary of values produced by ``tomllib``.""" + lines = [] + + def emit_table(table, path, *, heading): + if heading: + if lines and lines[-1] != "": + lines.append("") + lines.append("[" + ".".join(_key(part) for part in path) + "]") + + child_tables = [] + for key, item in table.items(): + if isinstance(item, dict): + child_tables.append((key, item)) + else: + lines.append("%s = %s" % (_key(key), _value(item))) + + for key, child in child_tables: + emit_table(child, path + (key,), heading=True) + + emit_table(value, (), heading=False) + return "\n".join(lines) + ("\n" if lines else "") + + +def build_file(dst_path, src_paths, *, err=sys.stderr): + """Build ``dst_path`` solely from ``src_paths``, lowest precedence first.""" + merged = {} + for path in src_paths: + layer = read_layer(path, err=err) + if layer is not None: + merged = merge(merged, layer) + + text = dumps(merged) + # Refuse to replace a valid destination with serializer output that our + # own parser cannot read back. + tomllib.loads(text) + + directory = os.path.dirname(os.path.abspath(dst_path)) + fd, temporary = tempfile.mkstemp(prefix=".swarmforge-toml-", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + os.replace(temporary, dst_path) + except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + return merged + + +def main(argv, err=sys.stderr): + if len(argv) < 2 or argv[0] != "--build": + print(USAGE, file=err) + return 2 + for argument in argv[2:]: + if argument.startswith("--"): + print("unknown argument %r" % argument, file=err) + return 2 + build_file(argv[1], argv[2:], err=err) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/swarmforge/config/merge_grok_mcp.py b/swarmforge/config/merge_toml_mcp.py similarity index 84% rename from swarmforge/config/merge_grok_mcp.py rename to swarmforge/config/merge_toml_mcp.py index 67ff451..b052dca 100644 --- a/swarmforge/config/merge_grok_mcp.py +++ b/swarmforge/config/merge_toml_mcp.py @@ -1,18 +1,20 @@ #!/usr/bin/env python3 -"""Render generated tong MCP servers into the merged Grok Build config. +"""Render generated tong MCP servers into a harness's TOML config. -Grok reads MCP servers from TOML ``[mcp_servers.]`` tables, where a -``url`` key is what selects the remote transport -- there is no type key. -The launcher emits the discovered tongs as JSON; this module renders them. +Grok Build and Codex CLI both read MCP servers from TOML +``[mcp_servers.]`` tables, where a ``url`` key is what selects the +remote transport -- there is no type key. The launcher emits the discovered +tongs as JSON; this module renders them into the file the entrypoint names. -The config dest is a persistent home, so the servers cannot simply be +Both harnesses merge into a persistent home, so the servers cannot simply be appended: they go in a sentinel-delimited managed block, rewritten on every run and removed when no fragment is given, so a session with no tongs leaves no trace of an earlier one's servers. A name already defined outside the block is skipped with a warning. Appending it would be a TOML duplicate-table error, and the user's own -definition outranking a generated one matches Grok's config precedence. +definition outranking a generated one matches both harnesses' config +precedence. """ import json @@ -20,7 +22,7 @@ import sys import tomllib -USAGE = "usage: python3 -m swarmforge.config.merge_grok_mcp CONFIG_TOML [FRAGMENT_JSON]" +USAGE = "usage: python3 -m swarmforge.config.merge_toml_mcp CONFIG_TOML [FRAGMENT_JSON]" BLOCK_BEGIN = "# >>> swarmforge tong mcp servers (generated; do not edit) >>>" BLOCK_END = "# <<< swarmforge tong mcp servers <<<" @@ -75,8 +77,8 @@ def _existing_server_names(text, path): try: parsed = tomllib.loads(text) except tomllib.TOMLDecodeError as exc: - # Grok itself will refuse the invalid config with its own error; the - # duplicate check is all that degrades here. + # The harness itself will refuse the invalid config with its own + # error; the duplicate check is all that degrades here. print( "Warning: could not parse %s (%s); skipping duplicate-name check" % (path, exc), diff --git a/swarmforge/tongs/__init__.py b/swarmforge/tongs/__init__.py index af08233..b972b75 100644 --- a/swarmforge/tongs/__init__.py +++ b/swarmforge/tongs/__init__.py @@ -79,8 +79,8 @@ anvil_mounts, canonical_alias, mcp_config_claude, - mcp_config_grok, mcp_config_opencode, + mcp_config_toml, mcp_tongs, mcp_url, plan_injection, @@ -190,8 +190,8 @@ "anvil_mounts", "canonical_alias", "mcp_config_claude", - "mcp_config_grok", "mcp_config_opencode", + "mcp_config_toml", "mcp_tongs", "mcp_url", "plan_injection", diff --git a/swarmforge/tongs/mcp.py b/swarmforge/tongs/mcp.py index c399a0a..d366f69 100644 --- a/swarmforge/tongs/mcp.py +++ b/swarmforge/tongs/mcp.py @@ -205,14 +205,14 @@ def mcp_config_claude(merged): return {"mcpServers": servers} if servers else {} -def mcp_config_grok(merged): - """Grok Build `mcp_servers` fragment for the discovered `mcp` tongs. - - HTTP MCP servers keyed by canonical alias. Grok reads them from TOML - `[mcp_servers.]` tables, where a `url` key is what selects the - remote transport -- there is no type key. The fragment stays JSON here; - swarmforge.config.merge_grok_mcp renders it. Returns `{}` when no `mcp` - tongs exist. +def mcp_config_toml(merged): + """`mcp_servers` fragment for the discovered `mcp` tongs, TOML-shaped. + + HTTP MCP servers keyed by canonical alias, in the shape Grok Build and + Codex CLI share: TOML `[mcp_servers.]` tables, where a `url` key is + what selects the remote transport -- there is no type key. The fragment + stays JSON here; swarmforge.config.merge_toml_mcp renders it. Returns + `{}` when no `mcp` tongs exist. """ servers = {} for alias, defn in mcp_tongs(merged).items(): @@ -225,7 +225,8 @@ def mcp_config_grok(merged): MCP_EMITTERS = { "opencode": mcp_config_opencode, "claude": mcp_config_claude, - "grok": mcp_config_grok, + "grok": mcp_config_toml, + "codex": mcp_config_toml, } diff --git a/tests/test_anvil_orchestrate.py b/tests/test_anvil_orchestrate.py index aa39959..0d2c351 100644 --- a/tests/test_anvil_orchestrate.py +++ b/tests/test_anvil_orchestrate.py @@ -324,21 +324,23 @@ def test_opencode_mounts_and_sets_env(self): with open(host_path, encoding="utf-8") as handle: self.assertEqual(json.load(handle), self.FRAGMENT) - def test_grok_mounts_and_sets_env(self): + def test_toml_harnesses_mount_and_set_env(self): # Delivered through the entrypoint, like OpenCode's, so the harness # argv stays untouched. fragment = {"mcp_servers": {"github": {"url": "http://github:8080/mcp"}}} - with tempfile.TemporaryDirectory() as tmp: - pre, post = launcher.orchestrate._mcp_injection(fragment, "grok", tmp) - host_path = os.path.join(tmp, "tong-mcp.json") - self.assertEqual(post, []) - self.assertEqual( - pre, - ["-v", "%s:%s:ro" % (host_path, launcher.MCP_CONFIG_CONTAINER_PATH), - "-e", "%s=%s" % (launcher.MCP_FILE_ENV, launcher.MCP_CONFIG_CONTAINER_PATH)], - ) - with open(host_path, encoding="utf-8") as handle: - self.assertEqual(json.load(handle), fragment) + for harness in ("grok", "codex"): + with tempfile.TemporaryDirectory() as tmp: + pre, post = launcher.orchestrate._mcp_injection(fragment, harness, tmp) + host_path = os.path.join(tmp, "tong-mcp.json") + self.assertEqual(post, [], harness) + self.assertEqual( + pre, + ["-v", "%s:%s:ro" % (host_path, launcher.MCP_CONFIG_CONTAINER_PATH), + "-e", "%s=%s" % (launcher.MCP_FILE_ENV, launcher.MCP_CONFIG_CONTAINER_PATH)], + harness, + ) + with open(host_path, encoding="utf-8") as handle: + self.assertEqual(json.load(handle), fragment) def test_claude_mounts_and_appends_flag(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_image_layout.py b/tests/test_image_layout.py index 69a85f6..260d7d4 100644 --- a/tests/test_image_layout.py +++ b/tests/test_image_layout.py @@ -239,6 +239,28 @@ def test_the_generated_tong_servers_merge_after_every_layer(self): self.body.index("SWARMFORGE_TONG_MCP_FILE"), ) + def test_codex_toml_config_stacks_lowest_trust_first(self): + call = self.body[self.body.index("build_codex_config"):] + self.assertEqual( + re.findall(r'"\$\{(\w+)_config_src\}"', call)[:3], + list(self.LAYERS), + ) + + def test_absent_codex_layers_do_not_resolve_from_the_root(self): + body = self.function_body("build_codex_config") + for layer in self.LAYERS: + expected = ( + "$" + "{config_%s_src:+" % layer + + "$" + "{config_%s_src}/config.toml}" % layer + ) + self.assertIn(expected, body) + + def test_codex_toml_build_precedes_generated_tong_servers(self): + self.assertLess( + self.body.index("build_codex_config"), + self.body.index("SWARMFORGE_TONG_MCP_FILE"), + ) + def test_claude_settings_stack_the_image_defaults_below_every_layer(self): """The image's defaults are a layer, and the bottom one. @@ -272,6 +294,11 @@ def test_claude_excludes_the_built_settings_from_the_file_overlay(self): claude = body[body.index("claude)"):body.index("opencode)")] self.assertIn("--exclude=./settings.json", claude) + def test_codex_excludes_the_built_config_from_the_file_overlay(self): + body = self.function_body("merge_config_layer") + codex = body[body.index("codex)"):body.index("opencode)")] + self.assertIn("--exclude=./config.toml", codex) + class ClaudeSettingsDelivery(unittest.TestCase): """The built settings reach claude as arguments, not as a file in the home. @@ -393,6 +420,66 @@ def test_state_is_linked_after_the_config_home_is_built(self): self.entrypoint.rindex("\n%s\n" % earlier), call) +class CodexConfigDelivery(unittest.TestCase): + """Codex layers are rebuilt off-home before config.toml is published.""" + + def setUp(self): + with open(ENTRYPOINT) as handle: + self.entrypoint = handle.read() + + def function_body(self, name): + body = self.entrypoint[self.entrypoint.index("%s() {" % name):] + return body[:body.index("\n}\n")] + + def test_build_directory_is_outside_host_mounts(self): + match = re.search( + r'^CODEX_CONFIG_HOME="([^"$]+)"$', self.entrypoint, re.M) + self.assertIsNotNone(match) + for mounted in ("/home/", "/workspace"): + self.assertFalse(match.group(1).startswith(mounted)) + + def test_generated_codex_agents_stay_outside_host_mounts(self): + agents_home = re.search( + r'^CODEX_AGENTS_HOME="([^"$]+)"$', self.entrypoint, re.M + ) + self.assertIsNotNone(agents_home) + for mounted in ("/home/", "/workspace"): + self.assertFalse(agents_home.group(1).startswith(mounted)) + + def test_generated_codex_agents_register_in_published_config(self): + translate = self.function_body("prepare_unified_agents") + register = self.function_body("register_codex_agents") + self.assertIn('agents_dst="${CODEX_AGENTS_HOME}"', translate) + self.assertIn('"${CODEX_AGENTS_HOME}/config.toml"', register) + self.assertIn('"${CODEX_CONFIG_FILE}"', register) + + def test_registration_runs_after_translation_and_before_assets(self): + register = self.entrypoint.rindex("\nregister_codex_agents\n") + self.assertLess( + self.entrypoint.rindex("\nprepare_unified_agents\n"), register + ) + self.assertLess(register, self.entrypoint.rindex("\ncopy_shared_assets\n")) + + def test_generated_roles_are_chowned_before_the_uid_drop(self): + chown = ( + 'chown -Rh "${ANVIL_UID}:${ANVIL_GID}" ' + '"${CODEX_AGENTS_HOME}"' + ) + self.assertIn(chown, self.entrypoint) + self.assertLess(self.entrypoint.index(chown), self.entrypoint.index("exec gosu")) + + def test_codex_forces_a_fresh_build_and_publishes_last(self): + body = self.function_body("prepare_agent_config") + self.assertIn('config_dest="${CODEX_CONFIG_HOME}"', body) + self.assertIn("reset_config=1", body) + prepare = body.index("prepare_layered_config") + truncate = body.index(': > "${CODEX_CONFIG_FILE}"') + publish = body.index( + 'cp "${CODEX_CONFIG_HOME}/config.toml" "${CODEX_CONFIG_FILE}"') + self.assertLess(prepare, truncate) + self.assertLess(truncate, publish) + + class StatusLineAgreement(unittest.TestCase): """The status line the Claude image ships must be the one it turns on. diff --git a/tests/test_merge_toml.py b/tests/test_merge_toml.py new file mode 100644 index 0000000..9a5e8d9 --- /dev/null +++ b/tests/test_merge_toml.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Tests for building a TOML config from ordered layers.""" + +import datetime +import io +import math +import os +import shutil +import stat +import sys +import tempfile +import tomllib +import unittest + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + +from swarmforge.config import merge_toml + + +class MergeTests(unittest.TestCase): + def test_nested_tables_merge_recursively(self): + self.assertEqual( + merge_toml.merge( + {"sandbox": {"mode": "workspace", "network": False}}, + {"sandbox": {"network": True}}, + ), + {"sandbox": {"mode": "workspace", "network": True}}, + ) + + def test_later_mcp_server_replaces_the_whole_lower_entry(self): + self.assertEqual( + merge_toml.merge( + {"mcp_servers": {"api": {"command": "repo-tool", "env": {"A": "1"}}}}, + {"mcp_servers": {"api": {"url": "https://org.example/mcp"}}}, + ), + {"mcp_servers": {"api": {"url": "https://org.example/mcp"}}}, + ) + + def test_later_agent_registration_replaces_the_whole_lower_entry(self): + self.assertEqual( + merge_toml.merge( + { + "agents": { + "reviewer": { + "config_file": "/run/swarmforge/reviewer.toml", + "nickname_candidates": ["Review"], + } + } + }, + { + "agents": { + "reviewer": { + "config_file": "/home/anvil/custom-reviewer.toml" + } + } + }, + ), + { + "agents": { + "reviewer": { + "config_file": "/home/anvil/custom-reviewer.toml" + } + } + }, + ) + + def test_later_value_replaces_scalar_array_or_table(self): + self.assertEqual(merge_toml.merge({"value": 1}, {"value": [2]}), {"value": [2]}) + self.assertEqual(merge_toml.merge({"value": [1]}, {"value": {"x": 2}}), {"value": {"x": 2}}) + self.assertEqual(merge_toml.merge({"value": {"x": 1}}, {"value": "new"}), {"value": "new"}) + + +class BuildFileCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="swarmforge-toml-") + self.addCleanup(shutil.rmtree, self.tmp, True) + self.dst = os.path.join(self.tmp, "config.toml") + self.err = io.StringIO() + + def layer(self, name, text): + path = os.path.join(self.tmp, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + def build(self, *paths): + return merge_toml.build_file(self.dst, paths, err=self.err) + + def parsed(self): + with open(self.dst, "rb") as handle: + return tomllib.load(handle) + + +class BuildFileTests(BuildFileCase): + def test_later_layers_win_key_by_key_and_nested_tables_compose(self): + low = self.layer( + "low.toml", + 'model = "gpt-5"\n[sandbox]\nmode = "workspace"\nnetwork = false\n', + ) + high = self.layer( + "high.toml", + 'reasoning_effort = "high"\n[sandbox]\nnetwork = true\n', + ) + + result = self.build(low, high) + + expected = { + "model": "gpt-5", + "reasoning_effort": "high", + "sandbox": {"mode": "workspace", "network": True}, + } + self.assertEqual(result, expected) + self.assertEqual(self.parsed(), expected) + + def test_arrays_replace_instead_of_merging(self): + low = self.layer("low.toml", 'features = ["a", "b"]\n') + high = self.layer("high.toml", 'features = ["c"]\n') + + self.build(low, high) + + self.assertEqual(self.parsed(), {"features": ["c"]}) + + def test_arrays_of_tables_round_trip_and_replace_as_one_value(self): + low = self.layer( + "low.toml", + '[[hooks]]\nname = "first"\ncommand = ["old"]\n' + '[[hooks]]\nname = "second"\ncommand = ["also-old"]\n', + ) + high = self.layer( + "high.toml", + '[[hooks]]\nname = "replacement"\ncommand = ["new", "--flag"]\n' + '[hooks.env]\nMODE = "safe"\n', + ) + + self.build(low, high) + + self.assertEqual( + self.parsed(), + { + "hooks": [ + { + "name": "replacement", + "command": ["new", "--flag"], + "env": {"MODE": "safe"}, + } + ] + }, + ) + + def test_destination_is_output_only(self): + self.layer("config.toml", 'stale = true\nmodel = "old"\n') + current = self.layer("current.toml", 'model = "new"\n') + + self.build(current) + + self.assertEqual(self.parsed(), {"model": "new"}) + + def test_absent_layer_is_skipped_quietly(self): + good = self.layer("good.toml", 'model = "gpt-5"\n') + + self.build(os.path.join(self.tmp, "absent.toml"), good) + + self.assertEqual(self.parsed(), {"model": "gpt-5"}) + self.assertEqual(self.err.getvalue(), "") + + def test_malformed_layer_is_reported_and_skipped(self): + broken = self.layer("broken.toml", "not = = valid\n") + good = self.layer("good.toml", 'model = "gpt-5"\n') + + self.build(broken, good) + + self.assertEqual(self.parsed(), {"model": "gpt-5"}) + self.assertIn("skipping %s:" % broken, self.err.getvalue()) + + def test_non_utf8_layer_is_reported_and_skipped(self): + broken = os.path.join(self.tmp, "broken.toml") + with open(broken, "wb") as handle: + handle.write(b"model = \"" + bytes([0xff]) + b"\"\n") + good = self.layer("good.toml", "model = \"gpt-5\"\n") + + self.build(broken, good) + + self.assertEqual(self.parsed(), {"model": "gpt-5"}) + self.assertIn("skipping %s:" % broken, self.err.getvalue()) + + def test_unreadable_layer_is_reported_and_skipped(self): + unreadable = self.layer("unreadable.toml", 'model = "old"\n') + os.chmod(unreadable, 0) + self.addCleanup(os.chmod, unreadable, stat.S_IRUSR | stat.S_IWUSR) + if os.access(unreadable, os.R_OK): + self.skipTest("cannot make a file unreadable as this user") + good = self.layer("good.toml", 'model = "gpt-5"\n') + + self.build(unreadable, good) + + self.assertEqual(self.parsed(), {"model": "gpt-5"}) + self.assertIn(unreadable, self.err.getvalue()) + + def test_no_layers_replaces_stale_destination_with_empty_toml(self): + self.layer("config.toml", "stale = true\n") + + self.build() + + self.assertEqual(self.parsed(), {}) + + def test_serializer_round_trips_all_tomllib_value_shapes(self): + value = { + "quoted.key": "snowman ☃\nline", + "emoji 😀 key": "delete \x7f escaped", + "enabled": True, + "count": 3, + "ratio": 1.25, + "nan": float("nan"), + "infinity": float("inf"), + "when": datetime.datetime(2026, 8, 24, 12, 30, tzinfo=datetime.timezone.utc), + "day": datetime.date(2026, 8, 24), + "clock": datetime.time(12, 30, 1), + "items": ["x", 2, {"nested.key": False}], + "empty": {}, + "section": {"answer": 42, "child": {"ok": True}}, + } + + parsed = tomllib.loads(merge_toml.dumps(value)) + + self.assertEqual(parsed.keys(), value.keys()) + self.assertTrue(math.isnan(parsed.pop("nan"))) + expected = dict(value) + expected.pop("nan") + self.assertEqual(parsed, expected) + + +class MainTests(BuildFileCase): + def test_build_cli(self): + layer = self.layer("layer.toml", 'model = "gpt-5"\n') + self.assertEqual(merge_toml.main(["--build", self.dst, layer], self.err), 0) + self.assertEqual(self.parsed(), {"model": "gpt-5"}) + + def test_bad_invocation_and_unknown_option(self): + self.assertEqual(merge_toml.main([], self.err), 2) + self.assertIn("usage:", self.err.getvalue()) + self.err.seek(0) + self.err.truncate(0) + self.assertEqual(merge_toml.main(["--build", self.dst, "--wat"], self.err), 2) + self.assertIn("unknown argument", self.err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_merge_grok_mcp.py b/tests/test_merge_toml_mcp.py similarity index 89% rename from tests/test_merge_grok_mcp.py rename to tests/test_merge_toml_mcp.py index 2d3f325..994e9b5 100644 --- a/tests/test_merge_grok_mcp.py +++ b/tests/test_merge_toml_mcp.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""Tests for delivering generated tong MCP servers into the Grok config. +"""Tests for delivering generated tong MCP servers into a harness config.toml. -Run: python3 tests/test_merge_grok_mcp.py +Run: python3 tests/test_merge_toml_mcp.py The config dest is a persistent home, so the servers live in a managed block rather than being appended. Most of what is asserted below is about that @@ -27,7 +27,7 @@ if REPO_ROOT not in sys.path: sys.path.insert(0, REPO_ROOT) -from swarmforge.config import merge_grok_mcp +from swarmforge.config import merge_toml_mcp FRAGMENT = {"mcp_servers": {"github": {"url": "http://github:8080/mcp"}}} @@ -45,11 +45,11 @@ def _read(path): return handle.read() -class MergeGrokMcpTests(unittest.TestCase): +class MergeTomlMcpTests(unittest.TestCase): def _merge(self, config, fragment=None): stderr = io.StringIO() with contextlib.redirect_stderr(stderr): - merge_grok_mcp.merge(config, fragment) + merge_toml_mcp.merge(config, fragment) return stderr.getvalue() def test_creates_config_with_managed_block(self): @@ -58,8 +58,8 @@ def test_creates_config_with_managed_block(self): self._merge(config, _write_fragment(tmp, FRAGMENT)) text = _read(config) - self.assertIn(merge_grok_mcp.BLOCK_BEGIN, text) - self.assertIn(merge_grok_mcp.BLOCK_END, text) + self.assertIn(merge_toml_mcp.BLOCK_BEGIN, text) + self.assertIn(merge_toml_mcp.BLOCK_END, text) self.assertEqual( tomllib.loads(text), {"mcp_servers": {"github": {"url": "http://github:8080/mcp"}}}, @@ -101,7 +101,7 @@ def test_no_fragment_strips_block_and_keeps_user_config(self): self._merge(config) text = _read(config) - self.assertNotIn(merge_grok_mcp.BLOCK_BEGIN, text) + self.assertNotIn(merge_toml_mcp.BLOCK_BEGIN, text) self.assertEqual(tomllib.loads(text), {"skills": {"ignore": ["scratch"]}}) def test_no_fragment_and_no_config_touches_nothing(self): @@ -138,8 +138,8 @@ def test_user_defined_server_wins_over_generated_one(self): ) def test_invalid_user_config_still_gets_block(self): - # Grok will reject the broken config on its own; the merge only loses - # the duplicate-name check and says so. + # The harness will reject the broken config on its own; the merge only + # loses the duplicate-name check and says so. with tempfile.TemporaryDirectory() as tmp: config = os.path.join(tmp, "config.toml") with open(config, "w", encoding="utf-8") as handle: @@ -148,7 +148,7 @@ def test_invalid_user_config_still_gets_block(self): warnings = self._merge(config, _write_fragment(tmp, FRAGMENT)) self.assertIn("duplicate-name check", warnings) - self.assertIn(merge_grok_mcp.BLOCK_BEGIN, _read(config)) + self.assertIn(merge_toml_mcp.BLOCK_BEGIN, _read(config)) def test_unusual_server_name_is_quoted(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_run_agent_container.py b/tests/test_run_agent_container.py index f1a90f0..877c54c 100644 --- a/tests/test_run_agent_container.py +++ b/tests/test_run_agent_container.py @@ -234,6 +234,49 @@ def test_nothing_else_under_the_shared_claude_dir_reaches_the_container(self): self.assertNotIn("--tmpfs", argv) +class MaskedAssetDirs(MakeRecipeCase): + """A persistent-home harness masks the dirs the entrypoint fills with assets. + + Those destinations are native to the harness, so they sit inside the home + mount rather than in a config dir rebuilt each run. Without the mask, one + repo's skills would be left in the home for every later session, against + any other repo. + """ + + def masked(self, argv): + image = next( + i for i, word in enumerate(argv) if word.endswith(":local")) + return [argv[i + 1].split(":")[0] + for i, word in enumerate(argv[:image]) if word == "--tmpfs"] + + def test_grok_masks_its_native_skills_and_commands(self): + masked = self.masked(self.docker_argv("run_grok", self.make_repo())) + self.assertIn("/home/anvil/.grok/skills", masked) + self.assertIn("/home/anvil/.grok/commands", masked) + + def test_codex_masks_the_dotagents_skills_dir_it_reads(self): + # Codex's user skills location is the harness-neutral one, so the + # dir masked here is not under a codex-named path. + masked = self.masked(self.docker_argv("run_codex", self.make_repo())) + self.assertIn("/home/anvil/.agents/skills", masked) + + +class CodexConfigMounts(MakeRecipeCase): + """Codex config and native state use the writable persistent home.""" + + def test_config_is_not_an_individual_bind_mount(self): + mounts = self.mounts(self.docker_argv("run_codex", self.make_repo())) + home = os.path.join(self.home, ".local", "share", "codex", "home") + self.assertIn("%s:/home/anvil" % home, mounts) + targets = [mount.split(":", 2)[1] for mount in mounts] + self.assertNotIn("/home/anvil/.codex/config.toml", targets) + + def test_native_state_paths_are_not_individually_mounted(self): + mounts = self.mounts(self.docker_argv("run_codex", self.make_repo())) + targets = [mount.split(":", 2)[1] for mount in mounts] + for state in ("auth.json", "sessions", "history.jsonl", "log"): + self.assertNotIn("/home/anvil/.codex/%s" % state, targets) + class HostTerminalEnv(MakeRecipeCase): """run_* forwards host TERM/COLORTERM via docker `-e NAME` passthrough. @@ -249,7 +292,7 @@ def env_flags(self, argv): def test_term_and_colorterm_are_passthrough_flags(self): repo = self.make_repo() - for target in ("run_opencode", "run_claude", "run_grok"): + for target in ("run_opencode", "run_claude", "run_grok", "run_codex"): flags = self.env_flags(self.docker_argv(target, repo)) self.assertIn("TERM", flags, target) self.assertIn("COLORTERM", flags, target) diff --git a/tests/test_tongs_mcp.py b/tests/test_tongs_mcp.py index 8452bf8..a6ca4a7 100644 --- a/tests/test_tongs_mcp.py +++ b/tests/test_tongs_mcp.py @@ -142,19 +142,24 @@ def test_claude_mcp_config_shape(self): {"mcpServers": {"github": {"type": "http", "url": "http://github:8080/mcp"}}}, ) - def test_grok_mcp_fragment_shape(self): - # No type/transport key: Grok selects the remote transport from the - # presence of `url` in the rendered [mcp_servers.] table. - fragment = tongs.mcp_config_grok(_merged("github-creds", GITHUB_TONG)) + def test_toml_mcp_fragment_shape(self): + # No type/transport key: Grok and Codex both select the remote + # transport from the presence of `url` in the rendered + # [mcp_servers.] table. + fragment = tongs.mcp_config_toml(_merged("github-creds", GITHUB_TONG)) self.assertEqual( fragment, {"mcp_servers": {"github": {"url": "http://github:8080/mcp"}}}, ) + def test_the_toml_harnesses_share_one_emitter(self): + for harness in ("grok", "codex"): + self.assertIs(tongs.MCP_EMITTERS[harness], tongs.mcp_config_toml) + def test_mcp_config_empty_when_no_mcp_tongs(self): # port-only set -> no MCP fragment at all (omitted, not an empty block). port_only = _merged("pg", PORT_TONG) - for emitter in (tongs.mcp_config_opencode, tongs.mcp_config_claude, tongs.mcp_config_grok): + for emitter in (tongs.mcp_config_opencode, tongs.mcp_config_claude, tongs.mcp_config_toml): self.assertEqual(emitter(port_only), {}) self.assertEqual(emitter({}), {}) @@ -179,7 +184,7 @@ def test_plan_injection_aggregates_across_kinds(self): def test_plan_injection_inert_when_empty(self): # The inert-when-empty invariant for this layer: nothing in, nothing out. - for harness in ("opencode", "claude", "grok"): + for harness in ("opencode", "claude", "grok", "codex"): self.assertEqual( tongs.plan_injection({}, harness), {"env": {}, "mounts": [], "mcp": {}}, diff --git a/tests/test_translate_agents.py b/tests/test_translate_agents.py index 19bab96..8c08d62 100644 --- a/tests/test_translate_agents.py +++ b/tests/test_translate_agents.py @@ -4,6 +4,7 @@ import os import sys import tempfile +import tomllib import unittest REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -335,7 +336,111 @@ def test_idempotent(self): self.assertEqual(once, twice) +class CodexEmitterTests(unittest.TestCase): + def test_basic_translation_and_overrides(self): + meta = { + "description": "Reviews code.", + "model": "openai/gpt-5.3-codex", + "tools": {"write": False}, + "codex": { + "model_reasoning_effort": "high", + "sandbox_mode": "read-only", + }, + } + out = ta.to_codex("code-reviewer", meta, "Review carefully.\n") + self.assertEqual(out["name"], "code-reviewer") + self.assertEqual(out["description"], "Reviews code.") + self.assertEqual(out["model"], "gpt-5.3-codex") + self.assertEqual(out["model_reasoning_effort"], "high") + self.assertEqual(out["sandbox_mode"], "read-only") + self.assertEqual(out["developer_instructions"], "Review carefully.\n") + self.assertNotIn("tools", out) + + def test_unqualified_model_passes_and_other_provider_drops(self): + out = ta.to_codex("a", {"description": "d", "model": "gpt-5"}, "body") + self.assertEqual(out["model"], "gpt-5") + out = ta.to_codex( + "a", {"description": "d", "model": "anthropic/claude-sonnet-4-6"}, "body" + ) + self.assertNotIn("model", out) + + def test_disable_skips_agent(self): + self.assertIsNone( + ta.to_codex("a", {"description": "d", "disable": True}, "body") + ) + + def test_name_normalization_matches_codex_constraints(self): + out = ta.to_codex("reviewer.md", {"description": "d"}, "body") + self.assertEqual(out["name"], "reviewer-md") + self.assertEqual(ta.normalize_codex_name("!!!"), "agent") + + def test_reserved_agent_table_fields_are_prefixed(self): + for name in ("default_subagent_model", "enabled", "max_depth"): + with self.subTest(name=name): + self.assertEqual(ta.normalize_codex_name(name), "agent-" + name) + + def test_render_is_valid_toml_and_preserves_multiline_prompt(self): + rendered = ta.render_codex( + { + "name": "reviewer", + "description": "Reviews \"quoted\" code.", + "developer_instructions": 'First line.\nSecond \"line\".\n', + "model_reasoning_effort": "high", + "options": {"enabled": True}, + } + ) + parsed = tomllib.loads(rendered) + self.assertEqual(parsed["name"], "reviewer") + instructions = parsed["developer_instructions"] + self.assertEqual(instructions, 'First line.\nSecond "line".\n') + self.assertEqual(parsed["options"], {"enabled": True}) + + def test_render_quotes_agent_registration_names(self): + rendered = ta.render_codex( + { + "agents": { + "code reviewer": { + "config_file": "/run/swarmforge/agents/reviewer.toml" + } + } + } + ) + parsed = tomllib.loads(rendered) + self.assertIn("code reviewer", parsed["agents"]) + + class MainTests(unittest.TestCase): + def test_codex_writes_normalized_toml_filename(self): + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "src") + dest = os.path.join(tmp, "dest") + os.makedirs(src) + with open(os.path.join(src, "code.reviewer.md"), "w") as f: + f.write("---\ndescription: Reviews code.\n---\n\nReview carefully.\n") + + rc = ta.main(["codex", dest, src]) + self.assertEqual(rc, 0) + self.assertEqual( + set(os.listdir(dest)), {"code-reviewer.toml", "config.toml"} + ) + role_path = os.path.join(dest, "code-reviewer.toml") + with open(role_path, "rb") as f: + parsed = tomllib.load(f) + self.assertEqual(parsed["name"], "code-reviewer") + self.assertEqual(parsed["developer_instructions"], "Review carefully.\n") + with open(os.path.join(dest, "config.toml"), "rb") as f: + config = tomllib.load(f) + self.assertEqual( + config, + { + "agents": { + "code-reviewer": { + "config_file": os.path.abspath(role_path), + } + } + }, + ) + def test_overlay_precedence_and_in_place(self): with tempfile.TemporaryDirectory() as tmp: shared = os.path.join(tmp, "shared") diff --git a/tests/test_translate_commands.py b/tests/test_translate_commands.py new file mode 100644 index 0000000..4c0396e --- /dev/null +++ b/tests/test_translate_commands.py @@ -0,0 +1,34 @@ +import os +import tempfile +import unittest + +from swarmforge.commands.translate import main + + +class CodexCommandTranslation(unittest.TestCase): + def translate(self, text): + with tempfile.TemporaryDirectory() as root: + source = os.path.join(root, "commands") + dest = os.path.join(root, "skills") + os.makedirs(source) + with open(os.path.join(source, "review.md"), "w") as handle: + handle.write(text) + self.assertEqual(main([dest, source]), 0) + with open(os.path.join(dest, "review", "SKILL.md")) as handle: + result = handle.read() + return result + + def test_translates_portable_command_to_codex_skill(self): + result = self.translate( + "---\ndescription: Inspect a path\nagent: build\n---\n" + "Request: $ARGUMENTS\nContents: !`ls $1`\n" + ) + self.assertTrue(result.startswith("---\nname: review\ndescription: Inspect a path\n---")) + self.assertNotIn("agent:", result) + self.assertNotIn("!`", result) + self.assertIn("the arguments supplied with this skill invocation", result) + self.assertIn("replacing $1 with the corresponding positional", result) + + +if __name__ == "__main__": + unittest.main()