From a12229a1470d1ad681cb2cbdc1fd06d08f7bd5c0 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Mon, 20 Jul 2026 16:19:59 +0200 Subject: [PATCH] fix: enable Codex sandbox in Docker --- Dockerfile | 9 ++++++-- README.md | 5 +++- compose.yaml | 12 ++++++++++ src/agentci/adapters/codex.py | 7 +++--- tests/test_codex.py | 43 +++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index c4353b0..425dd34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,11 +24,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin RUN apt-get update \ - && apt-get install --yes --no-install-recommends adduser ca-certificates git libstdc++6 \ + && apt-get install --yes --no-install-recommends \ + adduser bubblewrap ca-certificates git libstdc++6 \ && rm -rf /var/lib/apt/lists/* \ && /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \ && mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \ - && chown agentci:agentci /run/agentci + && chown agentci:agentci /run/agentci \ + && chmod u+s /usr/bin/bwrap COPY --from=uv /uv /uvx /usr/local/bin/ COPY --from=tea /bin/tea /usr/local/bin/tea @@ -36,6 +38,9 @@ COPY --from=codex /usr/local/bin/node /usr/local/bin/node COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex COPY --from=codex /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \ + && ln -s \ + /usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex \ + /usr/local/bin/codex-linux-sandbox \ && ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \ /usr/local/bin/codegraph diff --git a/README.md b/README.md index ad59fb8..98a2a39 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,10 @@ loopback destinations remain blocked. Codex's interactive Git trust check is skipped because every turn runs non-interactively against a service-owned clone; the configured filesystem and network permissions still apply. Git credentials exist only in the service-owned clone/push subprocess and are not inherited by -Codex turns. +Codex turns. The image and Compose capability/security settings let the non-root +service create Codex's nested `bwrap` sandbox. They follow Codex's secure +devcontainer pattern instead of making the service container privileged. Do not +remove Codex's configured filesystem and network restrictions. ## State and recovery diff --git a/compose.yaml b/compose.yaml index c03eef8..93a4d3a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,6 +7,18 @@ services: CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1} TEA_VERSION: ${TEA_VERSION:-0.14.2} restart: unless-stopped + # Codex applies its own bwrap sandbox inside this otherwise unprivileged container. + cap_add: + - SYS_ADMIN + - SYS_CHROOT + - SETUID + - SETGID + - SYS_PTRACE + - NET_ADMIN + - NET_RAW + security_opt: + - seccomp=unconfined + - apparmor=unconfined environment: AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000} AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci} diff --git a/src/agentci/adapters/codex.py b/src/agentci/adapters/codex.py index 3037c12..c83d6e2 100644 --- a/src/agentci/adapters/codex.py +++ b/src/agentci/adapters/codex.py @@ -76,7 +76,7 @@ class CodexClient: *self._turn_args(model, reasoning, permission, schema_name), "-", ] - session_id, result = await self._invoke(args, prompt, result_type) + session_id, result = await self._invoke(args, prompt, result_type, workspace=workspace) if not session_id: raise CodexError("Codex did not emit a thread.started event") return session_id, result @@ -104,7 +104,7 @@ class CodexClient: session_id, "-", ] - _, result = await self._invoke(args, prompt, result_type) + _, result = await self._invoke(args, prompt, result_type, workspace=workspace) return result def _turn_args( @@ -127,7 +127,7 @@ class CodexClient: ] async def _invoke( - self, args: list[str], prompt: str, result_type: type[T] + self, args: list[str], prompt: str, result_type: type[T], *, workspace: Path ) -> tuple[str | None, T]: started = monotonic() operation = "codex.resume" if "resume" in args else "codex.start" @@ -141,6 +141,7 @@ class CodexClient: try: process = await asyncio.create_subprocess_exec( *args, + cwd=workspace, env=self._environment(), stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, diff --git a/tests/test_codex.py b/tests/test_codex.py index df2dffa..213fc3f 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -1,7 +1,9 @@ +import asyncio import tomllib from pathlib import Path from agentci.adapters.codex import CodexClient, _session_id +from agentci.domain.models import AgentResult def test_enables_codegraph_in_shared_codex_config() -> None: @@ -65,3 +67,44 @@ def test_configures_research_agent_and_optional_context7_key(tmp_path) -> None: assert 'url = "https://mcp.grep.app"' in agent assert "ctx7-secret" not in agent assert client._environment()["CONTEXT7_API_KEY"] == "ctx7-secret" + + +async def test_invokes_codex_from_workflow_workspace(tmp_path, monkeypatch) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + captured: dict[str, object] = {} + + class Process: + returncode = 0 + + async def communicate(self, prompt: bytes) -> tuple[bytes, bytes]: + assert prompt == b"prompt" + return b'{"type":"thread.started","thread_id":"thread"}', b"" + + async def create_subprocess_exec(*args, **kwargs): + captured["cwd"] = kwargs["cwd"] + output = Path(args[args.index("--output-last-message") + 1]) + output.write_text( # noqa: ASYNC240 - tiny test-owned result file + '{"summary_markdown":"summary","tests":[]}' + ) + return Process() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", create_subprocess_exec) + client = CodexClient( + codex_home=tmp_path / "codex", + schemas_dir=tmp_path / "schemas", + timeout_seconds=60, + research_model="gpt-5.6-luna", + research_reasoning="high", + context7_api_key=None, + ) + + _, result = await client._invoke( + ["codex", "exec", "-"], + "prompt", + AgentResult, + workspace=workspace, + ) + + assert captured["cwd"] == workspace + assert result.summary_markdown == "summary"