fix: enable Codex sandbox in Docker

This commit is contained in:
2026-07-20 16:19:59 +02:00
parent 14737db853
commit a12229a147
5 changed files with 70 additions and 6 deletions
+7 -2
View File
@@ -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
+4 -1
View File
@@ -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
+12
View File
@@ -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}
+4 -3
View File
@@ -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,
+43
View File
@@ -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"