修复:适配 Docker Codex 本地访问服务

This commit is contained in:
zhang,zhao
2026-07-14 11:51:35 +08:00
parent 452816aee3
commit 46338f4435
6 changed files with 115 additions and 4 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ docker compose up --build
# 打开 http://localhost:3018
```
Docker 镜像内置固定版本的 **Codex CLI**Compose 会将主机 `${HOME}/.codex` 只读挂载到容器,因此主机需先完成 Codex 登录。需要覆盖镜像内版本时可设置构建参数:
Docker 镜像内置固定版本的 **Codex CLI**Compose 会将主机 `${HOME}/.codex` 只读挂载到容器,因此主机需先完成 Codex 登录。若主机 Codex 使用 loopback local-access provider,容器会保留实际端口并自动将主机名映射为 `host.docker.internal`需要覆盖镜像内版本时可设置构建参数:
```bash
CODEX_CLI_VERSION=0.144.3 docker compose up --build
+39
View File
@@ -14,6 +14,7 @@ import tomllib
from collections.abc import AsyncIterator, Callable, Sequence
from pathlib import Path
from types import TracebackType
from urllib.parse import urlsplit, urlunsplit
from app import secrets_store
from app.config import settings
@@ -699,6 +700,10 @@ def _codex_home() -> Path:
def _write_compatible_codex_config(path: Path) -> None:
config = _read_codex_config()
lines: list[str] = []
local_provider = _docker_codex_local_provider(config)
if local_provider:
lines.append(_toml_string("model_provider", "codex_local_access"))
model = current_ai_model() or normalize_codex_model(str(config.get("model") or ""))
if model:
@@ -713,9 +718,43 @@ def _write_compatible_codex_config(path: Path) -> None:
lines.append(_toml_string("approval_policy", "never"))
lines.append(_toml_string("sandbox_mode", "read-only"))
if local_provider:
lines.append("")
lines.append("[model_providers.codex_local_access]")
for key in ("name", "base_url", "wire_api", "experimental_bearer_token"):
value = local_provider.get(key)
if isinstance(value, str) and value:
lines.append(_toml_string(key, value))
for key in ("requires_openai_auth", "supports_websockets"):
value = local_provider.get(key)
if isinstance(value, bool):
lines.append(f"{key} = {'true' if value else 'false'}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _docker_codex_local_provider(config: dict) -> dict | None:
"""Return the local-access provider adapted to Docker's host gateway."""
docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip()
if not docker_host or config.get("model_provider") != "codex_local_access":
return None
providers = config.get("model_providers")
if not isinstance(providers, dict):
return None
source = providers.get("codex_local_access")
if not isinstance(source, dict):
return None
provider = dict(source)
base_url = str(provider.get("base_url") or "").strip()
parsed = urlsplit(base_url)
if parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
port = f":{parsed.port}" if parsed.port else ""
provider["base_url"] = urlunsplit(parsed._replace(netloc=f"{docker_host}{port}"))
return provider
def _read_codex_config() -> dict:
path = _codex_home() / "config.toml"
if not path.exists():
+65
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import tomllib
import httpx
import openai
@@ -161,3 +163,66 @@ def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path):
assert "AI_API_KEY" not in env
assert "OPENAI_API_KEY" not in env
assert "AUTH_PASSWORD" not in env
def test_codex_config_adapts_local_access_provider_for_docker(monkeypatch, tmp_path):
monkeypatch.setenv("CODEX_DOCKER_HOST", "host.docker.internal")
monkeypatch.setattr(ai_provider, "current_ai_model", lambda: "")
monkeypatch.setattr(ai_provider, "current_codex_reasoning_effort", lambda: "")
monkeypatch.setattr(
ai_provider,
"_read_codex_config",
lambda: {
"model_provider": "codex_local_access",
"model": "gpt-5.6-sol",
"model_providers": {
"codex_local_access": {
"name": "Codex API Service",
"base_url": "http://localhost:62678/v1",
"wire_api": "responses",
"requires_openai_auth": True,
"supports_websockets": False,
"experimental_bearer_token": "local-secret",
}
},
},
)
path = tmp_path / "config.toml"
ai_provider._write_compatible_codex_config(path)
with path.open("rb") as f:
config = tomllib.load(f)
assert config["model_provider"] == "codex_local_access"
provider = config["model_providers"]["codex_local_access"]
assert provider["base_url"] == "http://host.docker.internal:62678/v1"
assert provider["experimental_bearer_token"] == "local-secret"
assert provider["requires_openai_auth"] is True
assert provider["supports_websockets"] is False
def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, tmp_path):
monkeypatch.delenv("CODEX_DOCKER_HOST", raising=False)
monkeypatch.setattr(ai_provider, "current_ai_model", lambda: "")
monkeypatch.setattr(ai_provider, "current_codex_reasoning_effort", lambda: "")
monkeypatch.setattr(
ai_provider,
"_read_codex_config",
lambda: {
"model_provider": "codex_local_access",
"model_providers": {
"codex_local_access": {
"base_url": "http://localhost:62678/v1",
"experimental_bearer_token": "must-not-leak",
}
},
},
)
path = tmp_path / "config.toml"
ai_provider._write_compatible_codex_config(path)
text = path.read_text(encoding="utf-8")
assert "model_provider" not in text
assert "model_providers" not in text
assert "must-not-leak" not in text
+2
View File
@@ -19,6 +19,8 @@ services:
# 导致每次 up --build 重建容器都丢数据。environment 优先级高于 env_file,
# 无论 .env 怎么写这里都以容器路径为准。
- DATA_DIR=/app/data
# 将主机 Codex Desktop 的 loopback local-access 端点映射到 Docker host gateway。
- CODEX_DOCKER_HOST=host.docker.internal
volumes:
- ./data:/app/data
- ./tiers.yaml:/app/tiers.yaml:ro
@@ -37,6 +37,7 @@
- [x] Pass `CODEX_CLI_VERSION` through Compose.
- [x] Mount `${HOME}/.codex:/root/.codex:ro`.
- [x] Set `CODEX_DOCKER_HOST=host.docker.internal` for loopback local-access providers.
- [x] Run `docker compose config` and confirm the version and read-only mount.
### Task 4: Document behavior
@@ -55,9 +56,10 @@
- [x] Build the Codex builder stage and verify `codex-cli 0.144.3`.
- [x] Copy the extracted binary into the current TickFlow runtime image and verify it executes without Node.js.
- [ ] Recreate the app with the Codex-enabled image and existing data.
- [ ] Verify `/api/settings` reports Codex configured.
- [ ] POST `/api/strategies/ai/test` and expect `{"ok":true}` with `OK`.
- [x] Add red/green tests for opt-in local-access provider mapping and default token isolation.
- [x] Recreate the app with the Codex-enabled image and existing data.
- [x] Verify `/api/settings` reports Codex configured.
- [x] POST `/api/strategies/ai/test` and receive `{"ok":true}` with `OK`.
- [ ] Re-run provider tests and inspect final Git/GitNexus scope.
- [ ] Push `codex/docker-codex-cli` and open a Draft PR against `main`.
@@ -32,6 +32,8 @@ Dockerfile 新增独立的 `codex-builder` 阶段,使用 Node bookworm 镜像
后端现有 `_prepare_codex_home` 会从只读挂载中读取 `auth.json` 和兼容配置,再复制或生成到单次请求的临时 `CODEX_HOME`。Codex 子进程只使用临时目录,因此不会修改主机凭据目录。
当主机配置明确选择 `codex_local_access` 时,Compose 通过 `CODEX_DOCKER_HOST=host.docker.internal` 启用受控适配:临时配置只复制该 provider 的必要白名单字段,将 `localhost``127.0.0.1``::1` 改为 Docker host gateway,并保留原端口。未设置该环境变量时继续使用原有隔离配置,不复制 provider 或 bearer token。
### 数据流
1. Compose 启动容器并只读挂载主机 Codex home。
@@ -52,6 +54,7 @@ Dockerfile 新增独立的 `codex-builder` 阶段,使用 Node bookworm 镜像
- Codex home 仅以只读方式挂载。
- 凭据不进入 Docker build context 的产物层。
- local-access bearer token 仅从只读主机配置复制到单次请求的临时配置,且只在 Docker 显式启用适配时发生。
- 现有 `--ephemeral``--sandbox read-only``approval_policy = "never"` 和空白临时工作区保持不变。
- 文档明确说明:启用 Docker Codex 模式意味着 TickFlow 容器能够读取 Codex 登录凭据,应仅在受信任的本机环境使用。