diff --git a/Dockerfile b/Dockerfile index 62b8629..58fd5a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 时间也对齐北京时间。 diff --git a/README.md b/README.md index f930347..2298d88 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 8df8f0c..2886520 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -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(): diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index 5ff5c05..1b5a89c 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c1fb2cc..5234533 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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