mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
Docker 环境支持 Codex CLI (#116)
解决 Docker 容器内无法读取 codex 命令的问题:多阶段构建提取原生二进制 + 只读挂载主机登录态 + 适配 loopback local-access provider。
This commit is contained in:
+20
@@ -9,6 +9,7 @@ ARG PYPI_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
# 备用 PyPI 源:主源同步延迟/故障时自动兜底(阿里云与清华互为补充)
|
||||
ARG PYPI_FALLBACK=https://mirrors.aliyun.com/pypi/simple
|
||||
ARG BACKEND_EXTRAS=
|
||||
ARG CODEX_CLI_VERSION=0.144.3
|
||||
|
||||
# === Stage 1: 前端构建 ===
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
@@ -46,6 +47,20 @@ RUN if [ "$INCLUDE_STOCKSDK" = "1" ]; then \
|
||||
mkdir -p /build/node_modules; \
|
||||
fi
|
||||
|
||||
# === Stage 1c: Codex CLI ===
|
||||
# 固定版本保证镜像可复现;只复制安装产物到运行镜像,不保留 npm。
|
||||
FROM node:20-bookworm-slim AS codex-builder
|
||||
ARG USE_CN_MIRROR=1
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
ARG CODEX_CLI_VERSION=0.144.3
|
||||
RUN if [ "$USE_CN_MIRROR" = "1" ]; then npm config set registry "$NPM_REGISTRY"; fi \
|
||||
&& npm install --global --prefix /opt/codex "@openai/codex@${CODEX_CLI_VERSION}" \
|
||||
&& CODEX_NATIVE="$(find /opt/codex -type f -path '*/vendor/*/bin/codex' -print -quit)" \
|
||||
&& test -n "$CODEX_NATIVE" \
|
||||
&& cp "$CODEX_NATIVE" /opt/codex-native \
|
||||
&& chmod +x /opt/codex-native \
|
||||
&& /opt/codex-native --version
|
||||
|
||||
# === Stage 2: Python 运行时 ===
|
||||
FROM python:3.11-slim AS runtime
|
||||
ARG USE_CN_MIRROR=1
|
||||
@@ -56,6 +71,7 @@ ARG INCLUDE_STOCKSDK=0
|
||||
WORKDIR /app
|
||||
|
||||
# Node.js 运行时: 仅在启用 stock-sdk 插件时安装(供 node bridge.mjs 使用)。
|
||||
# Codex CLI 从官方 npm 包提取原生二进制,不依赖运行时 Node.js。
|
||||
# bookworm 自带 nodejs 18.19, 满足插件 engines>=18; --no-install-recommends 精简,
|
||||
# 自带 libnode/libc-ares 等全部动态依赖, 无需手动补库。
|
||||
# 国内构建走 apt mirror 已在 debian 镜像sources.list 配好, 无需额外换源。
|
||||
@@ -110,6 +126,10 @@ ENV STATIC_DIR=/app/static \
|
||||
# Frontend 静态产物
|
||||
COPY --from=frontend-builder /build/dist ./static
|
||||
|
||||
# Codex CLI 使用官方 npm 包携带的当前平台原生二进制,无需运行时 Node.js。
|
||||
COPY --from=codex-builder /opt/codex-native /usr/local/bin/codex
|
||||
RUN codex --version
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
# 兜底时区: 交易时段判断已在代码里显式用北京时间 (app/market_time.py),
|
||||
# 此处让日志时间戳等其余 naive 时间也对齐北京时间。
|
||||
|
||||
@@ -161,6 +161,14 @@ docker compose up --build
|
||||
# 打开 http://localhost:3018
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
> Codex CLI 模式允许 TickFlow 容器读取本机 Codex 登录凭据,仅应在受信任的本机环境启用。凭据目录以只读方式挂载,不会写入镜像。
|
||||
|
||||
镜像已内置 **stock-sdk** 数据源插件(Node 运行时 + 依赖),开箱即用。
|
||||
|
||||
> 📖 Docker 进阶、GitHub Actions 自构建、老 CPU 兼容、访问密码设置等见 [docs/deployment.md](./docs/deployment.md)。
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,9 +7,12 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
|
||||
CODEX_CLI_VERSION: ${CODEX_CLI_VERSION:-0.144.3}
|
||||
container_name: TickFlow_Stock_Panel
|
||||
ports:
|
||||
- "${PORT:-3018}:3018"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -18,7 +21,11 @@ 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
|
||||
# 复用主机 Codex 登录态;后端只读后复制到单次请求的临时 CODEX_HOME。
|
||||
- ${HOME}/.codex:/root/.codex:ro
|
||||
restart: unless-stopped
|
||||
|
||||
Reference in New Issue
Block a user