From 63ca33cccc8c5b7786511c86a372a3861c308e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:06:09 +0800 Subject: [PATCH 1/6] Simplify image tags, Compose environment and dependency locks Three deployment concerns had drifted apart from how the project is actually run: images published extra floating tags, Compose repeated runtime settings as per-variable fallbacks, and requirements.in existed only to mirror pyproject. - .github/workflows/docker.yml publishes exactly the release version, `edge` for main and `latest` for release tags; PR and sha tags are gone, so pulled tags always name a supported build. - docker-compose.yml loads the whole .env through env_file and fixes only the container paths, so a runtime key is configured once and a commented line stays WebUI-editable instead of being locked by a fallback default. The image defaults to `latest`, and the image passes --desensitize itself. - requirements.in is removed: pyproject.toml is the single source for direct dependencies, and the build-lock test compares uv.lock against it directly. - tests/test_deployment.py follows the new Compose shape and the image-tag test pins the three published tags. --- .env.example | 73 +++++++++++++++++++++++++++++----- .github/workflows/docker.yml | 9 ++--- Dockerfile | 2 +- README.md | 12 ++---- README.zh-CN.md | 12 ++---- docker-compose.yml | 52 +++++------------------- docs/clients.md | 2 +- docs/clients.zh-CN.md | 2 +- docs/deployment.md | 58 +++++++++++++++++++++------ docs/deployment.zh-CN.md | 58 +++++++++++++++++++++------ requirements.in | 6 --- scripts/export_requirements.py | 3 -- tests/test_build_lock.py | 25 ++++++++++-- tests/test_deployment.py | 47 ++++++++++++++-------- 14 files changed, 229 insertions(+), 132 deletions(-) delete mode 100644 requirements.in diff --git a/.env.example b/.env.example index b292782..e90e967 100644 --- a/.env.example +++ b/.env.example @@ -1,78 +1,129 @@ # Copy to .env for first use; preserve existing settings when updating. -# Compose and native launches read .env; native zero-configuration startup can omit it. +# 首次使用复制为 .env;升级时保留已有配置。 +# Compose loads every key in this file into the container, so unrelated keys are harmless; +# native launches read it too, and native zero-configuration startup can omit it. +# Compose 会把本文件的每个键加载进容器,无关的键不会造成影响;原生启动同样读取它, +# 零配置的原生启动可以省略该文件。 +# Runtime keys here stay read-only in the WebUI. To let the WebUI own them, stop the +# service and run `python3 converter.py config migrate` (add --data-dir/--env-file when +# the paths differ), then delete the printed lines. Deployment keys below are never migrated. +# 这里列出的运行参数在 WebUI 中只读。要让 WebUI 接管,请停止服务后运行 +# `python3 converter.py config migrate`(路径不同时加 --data-dir/--env-file), +# 再删除它打印的行;下方的部署类键不会被迁移。 # Native listener and Compose host mapping; explicit --host/--port override native values. +# 原生监听地址与 Compose 宿主机映射;显式 --host/--port 覆盖原生取值。 CODEBUDDY2API_BIND=127.0.0.1 CODEBUDDY2API_PORT=8787 -# Compose-only image and host data directory; container data stays in /data/auth. -CODEBUDDY2API_IMAGE=codebuddy2api:local +# Compose image; uncomment to override, leaving it commented keeps the published latest tag. +# Compose 镜像;取消注释才会覆盖,保持注释即使用已发布的 latest 标签。 +# CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest +# Compose host data directory; container data stays in /data/auth. +# Compose 宿主机数据目录;容器内数据固定在 /data/auth。 CODEBUDDY2API_AUTH_PATH=./auth +# Container time zone; daily guards follow it, and an unset value keeps UTC. +# 容器时区;每日任务按它跨天,未设置时保持 UTC。 +# TZ=Asia/Shanghai +# Optional Compose override for the env file path, used instead of .env. +# 可选的 Compose 覆盖项:改读其他 env 文件而非 .env。 +# CODEBUDDY2API_ENV_FILE=.env # Optional native data directory; Compose fixes CODEBUDDY_AUTH_DIR to /data/auth. +# 可选的原生数据目录;Compose 会把 CODEBUDDY_AUTH_DIR 固定为 /data/auth。 # CODEBUDDY_AUTH_DIR=./auth # Import directory defaults to the data directory's imports/; use container paths in Compose. +# 导入目录默认是数据目录下的 imports/;Compose 中请使用容器内路径。 # CODEBUDDY_IMPORT_DIR=./auth/imports # Empty locks management; set a random key before exposing inference beyond loopback. +# 留空即锁定管理;对外暴露推理端口前请设置随机密钥。 # Native: omit/comment the variable to use the saved default key (first local start displays it once). +# 原生:省略或注释该变量会使用已保存的默认 key(首次本地启动会显示一次)。 CODEBUDDY2API_KEY= -# Native-only unsafe opt-in; Compose uses its host mapping to control exposure. +# Opt-in for headless/non-loopback no-auth runs; the image and Compose already force it on. +# 原生后台或非回环免鉴权运行选项;镜像与 Compose 已固定开启。 # CODEBUDDY2API_ALLOW_OPEN_NOAUTH=false # Disabling CSRF checks does not disable authentication; use only on trusted local networks. +# 关闭 CSRF 校验不等于关闭认证;仅在可信本地网络使用。 CODEBUDDY2API_ADMIN_CSRF=true # Extra trusted management origins behind reverse proxies (comma separated; bare domains use HTTPS). +# 反代后额外信任的管理来源(逗号分隔;裸域名按 HTTPS 处理)。 # Unset leaves the list configurable in the WebUI; an explicit value locks it. +# 不设置时列表可在 WebUI 修改;显式设置会锁定。 # CODEBUDDY2API_ADMIN_ORIGINS=https://chat.example.com -# 可信代理 IP/CIDR(逗号分隔);命中时审计改记 X-Forwarded-For/X-Real-IP 的真实客户端地址 +# Trusted proxy IPs/CIDRs; a matching peer makes the audit log record X-Forwarded-For/X-Real-IP. +# 可信代理 IP/CIDR;命中时审计改记 X-Forwarded-For/X-Real-IP 中的真实客户端地址。 # CODEBUDDY2API_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8 # Unset leaves tool metadata configurable in the WebUI; an explicit Boolean locks it. +# 不设置时工具元数据可在 WebUI 修改;显式布尔值会锁定。 # CODEBUDDY2API_KEEP_TOOL_METADATA=true # Image overflow keeps the newest blocks or returns 413; zero forbids all images. +# 图片超限时保留最新内容或返回 413;设为 0 禁止全部图片。 CODEBUDDY2API_MAX_IMAGES=16 CODEBUDDY2API_IMAGE_POLICY=truncate # Positive upstream JSON byte limit after image policy and protocol adaptation. +# 图片策略与协议适配之后,发往上游的 JSON 字节上限(正数)。 CODEBUDDY2API_MAX_REQUEST_BYTES=33554432 # Positive raw inbound byte limit, enforced before JSON parsing. +# 原始入站字节上限(正数),在解析 JSON 之前生效。 CODEBUDDY2API_MAX_INBOUND_BYTES=67108864 # Aggregated output bytes and concurrent inference requests; zero disables each limit. +# 聚合输出字节数与并发推理请求数;设为 0 分别关闭对应限制。 CODEBUDDY2API_MAX_COLLECT_BYTES=8388608 CODEBUDDY2API_MAX_CONCURRENT=64 # Optional bounded upstream connection reuse; restart to apply, disabled by default. +# 可选的上游连接复用(有上限);修改后需重启,默认关闭。 # CODEBUDDY2API_UPSTREAM_KEEPALIVE=false -# Per-account in-flight limit; zero preserves unlimited account capacity. +# Per-account in-flight limit; zero keeps account capacity unlimited. +# 单账号在途请求上限;设为 0 保持账号容量不受限。 # CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT=0 -# Optional session/attempt tracing; legacy preserves existing upstream header behavior. +# Optional session/attempt tracing; legacy keeps existing upstream header behavior. +# 可选的会话/尝试追踪;legacy 保持现有上游请求头行为。 # CODEBUDDY2API_REQUEST_CONTEXT_MODE=legacy # Responses projection is hot and WebUI-editable unless explicitly set here. -# balanced rewrites only recognized harness blocks with stable summaries; other text stays verbatim. +# Responses 投影可热生效且默认可在 WebUI 修改,除非在此显式设置。 +# balanced only rewrites recognized harness blocks with stable summaries; other text stays verbatim. +# balanced 只改写可识别的 harness 块并生成稳定摘要,其余文本保持原样。 # CODEBUDDY2API_RESPONSES_PROJECTION_MODE=balanced # Per-item head/tail limit for generated assistant text, tool arguments and tool results; # zero disables per-item trimming. Global request/output gates still apply. +# 生成文本、工具参数与工具结果的单项首尾截断上限;设为 0 关闭单项截断, +# 全局请求与输出限制仍然生效。 # Non-zero values must be between 256 and 33554432 bytes. +# 非零取值须在 256 至 33554432 字节之间。 # CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES=40000 -# compatible preserves aggregation; realtime forwards all protocol output incrementally +# compatible keeps aggregation; realtime forwards all protocol output incrementally # and does not regenerate malformed tool arguments. +# compatible 保持聚合;realtime 增量转发全部协议输出,且不重新生成损坏的工具参数。 # CODEBUDDY2API_STREAM_MODE=compatible -# Disable only declared model-capability preflight; other safeguards and Intl image merging remain enabled. +# Disable only the declared model-capability preflight; other safeguards and Intl image merging remain. +# 只关闭声明的模型能力预检;其他保护与国际站图片合并仍然生效。 # CODEBUDDY2API_MODEL_CAPABILITY_GUARD=true # Retired: this setting only warns; all persistent logs use logs.sqlite3. +# 已停用:该项只会给出告警;所有持久日志都写入 logs.sqlite3。 CODEBUDDY2API_LOG= # Legacy text-preview limit; text output is retired. SQLite diagnostics have a separate budget. +# 旧版文本预览上限;文本输出已停用,SQLite 诊断另有独立预算。 CODEBUDDY2API_LOG_BODY_LIMIT=65536 # International trial credits remain manual-only in the WebUI. -# Preauthorize first-Buddy tasks, one bounded chat (may use credits), adoption and travel. +# 国际站试用积分仍需在 WebUI 手动领取。 +# Preauthorize first-Buddy tasks: one bounded chat (may use credits), adoption and travel. +# 预授权首次 Buddy 任务:一次有上限的对话(可能消耗积分)、领养与旅行。 CODEBUDDY2API_AUTO_ACCEPT_BUDDY=false # Extra generations for malformed tool arguments; each may consume credits, zero disables retries. +# 损坏工具参数的额外生成次数;每次可能消耗积分,设为 0 关闭重试。 CODEBUDDY2API_TOOL_CALL_MAX_RETRY=3 # Pre-response credential failovers; zero disables replay. See docs/advanced.md for billing risks. +# 响应前的凭证故障切换次数;设为 0 关闭重放。计费风险见 docs/advanced.md。 CODEBUDDY2API_FAILOVER_MAX=0 # Replay incomplete writes only when partial requests cannot be billed upstream. +# 仅在部分写入不会被上游计费时才重放未完成的请求。 CODEBUDDY2API_RETRY_WRITE_TIMEOUT=false diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e6cd283..9d82157 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -119,14 +119,11 @@ jobs: uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository }} - flavor: latest=auto + # Exactly three published tags: the release version, edge for main, latest for releases. tags: | - type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=pr - type=sha type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} labels: | org.opencontainers.image.version=${{ needs.test.outputs.version }} - name: Build and publish diff --git a/Dockerfile b/Dockerfile index 67319f6..65d8c21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,4 +30,4 @@ COPY --from=frontend /web/dist/ ./web/dist/ EXPOSE 8787 # Listen on container interfaces; host port mapping controls external access. ENV CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true -CMD ["python3", "converter.py", "--host", "0.0.0.0", "--port", "8787", "--skip-check"] +CMD ["python3", "converter.py", "--host", "0.0.0.0", "--port", "8787", "--skip-check", "--desensitize"] diff --git a/README.md b/README.md index 2b82e72..091816f 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,19 @@ cd codebuddy2api cp .env.example .env ``` -Edit `.env`: set `CODEBUDDY2API_KEY` to your own random key and pick the image. Preserve an existing `.env` on upgrades: - -```dotenv -CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest -``` +Edit `.env`: set `CODEBUDDY2API_KEY` to your own random key. Compose loads the whole file into the container, so every key applies and unknown keys are harmless. Preserve an existing `.env` on upgrades. ```bash docker compose pull docker compose up -d --no-build ``` -`latest` tracks stable releases; pin a published version tag for reproducible deployments. Image features belong to that version, not to unmerged source branches. +Compose pulls `latest` by default (`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest`); images also publish the release version (for example `1.3.6`) and `edge` for `main`. Pin a version tag for reproducible deployments; image features belong to that version, not to unmerged source branches. First use: 1. Open **http://127.0.0.1:8787/dashboard** and sign in with your API key. -2. In **Credentials**, add a domestic or international account through browser login, or import an `.info` file. +2. In **Credentials**, add a domestic or international account through browser login, import an `.info` file, or paste refresh tokens for a chosen endpoint. 3. In **Models**, find an available model and use its public ID in your client. The template binds to localhost only. Configure HTTPS and restrict network access before allowing remote connections; keep and securely back up the `auth/` data directory. To run current source or log in from a terminal instead, see the [deployment guide](docs/deployment.md). @@ -82,7 +78,7 @@ The template binds to localhost only. Configure HTTPS and restrict network acces ## FAQ - **WebUI sign-in fails behind an HTTPS domain/reverse proxy?** Trust your public origin via `admin_allowed_origins` — see [Management Origin checks](docs/advanced.md#management-origin--csrf-switch). -- **No Docker?** After installing dependencies and the WebUI, run `uv run converter.py` or `python3 converter.py` without `.env`. First local startup saves a default key and displays it once — see [Local Python setup](docs/deployment.md#local-python-setup). +- **No Docker?** After installing dependencies and the WebUI, run `uv run --locked --no-build converter.py` or `python3 converter.py` without `.env`. First local startup saves a default key and displays it once — see [Local Python setup](docs/deployment.md#local-python-setup). - **Where is my data?** Everything lives in `auth/` (or `/data/auth` in Docker): credentials, settings and log databases — see [Data and backups](docs/webui.md#data-and-backups). - **Which image tag should I use?** `latest` follows stable releases, `edge` follows main, version tags pin one release — see [Published images](docs/deployment.md#use-published-images). - **Responses tool output/arguments compressed or need verbatim passthrough?** Set `responses_projection_mode` (default `balanced`, or `passthrough` to disable projection) and `responses_projection_max_bytes` (default `40000`, `0` disables per-item trimming). Balanced trimming keeps the head/tail and reports original bytes, estimated tokens and total lines; client addresses stay unchanged — see [Responses projection](docs/clients.md#responses-projection) and [details](docs/advanced.md#responses-projection). diff --git a/README.zh-CN.md b/README.zh-CN.md index 71f4056..cf03c7f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,23 +19,19 @@ cd codebuddy2api cp .env.example .env ``` -编辑 `.env`:将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥,并指定镜像;升级时保留已有 `.env`: - -```dotenv -CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest -``` +编辑 `.env`:将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥。Compose 会把整个文件加载进容器,每个键都会生效,无关的键也无影响。升级时保留已有 `.env`。 ```bash docker compose pull docker compose up -d --no-build ``` -`latest` 跟随稳定发行版;需固定部署时改用已发布的版本标签。镜像功能以对应版本为准,不包含尚未合并的源码分支改动。 +Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest`);镜像同时发布发行版本(例如 `1.3.6`)与跟随 `main` 的 `edge`。需固定部署时改用版本标签;镜像功能以对应版本为准,不包含尚未合并的源码分支改动。 首次使用: 1. 打开 **http://127.0.0.1:8787/dashboard**,使用刚设置的 API key 登录。 -2. 在「凭证管理」扫码添加国内或国际账号,也可导入 `.info` 文件。 +2. 在「凭证管理」扫码添加国内或国际账号,也可导入 `.info` 文件,或按端点粘贴刷新令牌批量导入。 3. 在「模型路由」查看可用模型,将其对外 ID 填入客户端。 按模板配置时仅允许本机访问。远程访问前请配置 HTTPS 并限制网络访问;保留并妥善备份 `auth/` 数据目录。如需直接运行源码或在终端登录,见[部署指南](docs/deployment.zh-CN.md)。 @@ -82,7 +78,7 @@ docker compose up -d --no-build ## 常见问题 - **绑定域名后经 HTTPS 反代无法登录 WebUI?** 将对外来源加入 `admin_allowed_origins` 信任列表——见[管理 Origin 校验](docs/advanced.zh-CN.md#管理-origin--csrf-开关)。 -- **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run converter.py` 或 `python3 converter.py`,无需 `.env`;首次本地启动保存默认 key 并仅在终端显示一次——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。 +- **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run --locked --no-build converter.py` 或 `python3 converter.py`,无需 `.env`;首次本地启动保存默认 key 并仅在终端显示一次——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。 - **数据在哪里?** 全部位于 `auth/`(Docker 中为 `/data/auth`):凭证、设置与日志数据库——见[数据与备份](docs/webui.zh-CN.md#数据与备份)。 - **镜像标签怎么选?** `latest` 跟随稳定版,`edge` 跟随 main,版本标签固定某一发行版——见[使用已发布镜像](docs/deployment.zh-CN.md#使用已发布镜像)。 - **Responses 工具输出/参数被压缩或需要完全原样?** 设置 `responses_projection_mode`(默认 `balanced`,设 `passthrough` 可完全关闭投影)及 `responses_projection_max_bytes`(默认 `40000`,`0` 禁用单项裁剪)。balanced 裁剪保留头尾并显示原始 bytes、估算 tokens 与总行数,客户端地址不变——见 [Responses 投影](docs/clients.zh-CN.md#responses-投影)及[详细说明](docs/advanced.zh-CN.md#responses-投影)。 diff --git a/docker-compose.yml b/docker-compose.yml index 850317b..79e2ee6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ -# Support legacy Compose syntax; shell variables override automatic .env interpolation. +# Support legacy Compose syntax; .env is loaded wholesale through env_file. version: "3.8" services: codebuddy2api: - image: ${CODEBUDDY2API_IMAGE:-ghcr.io/maiphucgiang/codebuddy2api:1.3.6} + image: ${CODEBUDDY2API_IMAGE:-ghcr.io/maiphucgiang/codebuddy2api:latest} build: . container_name: codebuddy2api ports: @@ -12,45 +12,15 @@ services: volumes: # Persist credentials, SQLite databases and WAL/SHM files in one writable local directory. - "${CODEBUDDY2API_AUTH_PATH:-./auth}:/data/auth" + env_file: + # Every key in .env reaches the container; keys the gateway does not know are ignored. + # Optional, so a missing .env keeps the gateway defaults and settings saved in SQLite. + - path: ${CODEBUDDY2API_ENV_FILE:-.env} + required: false environment: - CODEBUDDY2API_KEY: ${CODEBUDDY2API_KEY:-} - CODEBUDDY2API_ADMIN_CSRF: ${CODEBUDDY2API_ADMIN_CSRF:-true} - # Daily guards key on a local calendar day, so the container should share the - # operator's zone; a UTC container turns those guards over at 08:00 Beijing. - TZ: ${TZ:-UTC} - # First-Buddy onboarding may send one bounded real WorkBuddy conversation per account. - CODEBUDDY2API_AUTO_ACCEPT_BUDDY: ${CODEBUDDY2API_AUTO_ACCEPT_BUDDY:-false} - # Unset variables remain configurable through the WebUI. - CODEBUDDY2API_ADMIN_ORIGINS: - # Trusted proxy IPs/CIDRs; forwarded headers count only from these peers. - CODEBUDDY2API_TRUSTED_PROXIES: - CODEBUDDY2API_KEEP_TOOL_METADATA: - CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16} - CODEBUDDY2API_IMAGE_POLICY: ${CODEBUDDY2API_IMAGE_POLICY:-truncate} - CODEBUDDY2API_MAX_REQUEST_BYTES: ${CODEBUDDY2API_MAX_REQUEST_BYTES:-33554432} - CODEBUDDY2API_LOG_BODY_LIMIT: ${CODEBUDDY2API_LOG_BODY_LIMIT:-65536} - CODEBUDDY2API_LOG: ${CODEBUDDY2API_LOG:-} - CODEBUDDY2API_MAX_INBOUND_BYTES: ${CODEBUDDY2API_MAX_INBOUND_BYTES:-67108864} - CODEBUDDY2API_MAX_COLLECT_BYTES: ${CODEBUDDY2API_MAX_COLLECT_BYTES:-8388608} - CODEBUDDY2API_MAX_CONCURRENT: ${CODEBUDDY2API_MAX_CONCURRENT:-64} - CODEBUDDY2API_UPSTREAM_KEEPALIVE: - CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT: - CODEBUDDY2API_REQUEST_CONTEXT_MODE: - CODEBUDDY2API_RESPONSES_PROJECTION_MODE: - CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES: - CODEBUDDY2API_STREAM_MODE: - CODEBUDDY2API_MODEL_CAPABILITY_GUARD: - CODEBUDDY2API_TOOL_CALL_MAX_RETRY: ${CODEBUDDY2API_TOOL_CALL_MAX_RETRY:-3} - CODEBUDDY2API_FAILOVER_MAX: - CODEBUDDY2API_RETRY_WRITE_TIMEOUT: - CODEBUDDY_IMPORT_DIR: ${CODEBUDDY_IMPORT_DIR:-/data/auth/imports} - # Bind container interfaces; host port mapping controls external access. - CODEBUDDY2API_ALLOW_OPEN_NOAUTH: "true" + # Container paths stay fixed; a host-side CODEBUDDY_AUTH_DIR would point outside the mount. CODEBUDDY_AUTH_DIR: /data/auth - command: > - python3 converter.py - --host 0.0.0.0 - --port 8787 - --skip-check - --desensitize + # Bind every container interface and let the host mapping control external exposure. + CODEBUDDY2API_ALLOW_OPEN_NOAUTH: "true" + # The image CMD already runs converter.py with --host 0.0.0.0, --port 8787 and --desensitize. restart: unless-stopped diff --git a/docs/clients.md b/docs/clients.md index 93de1c1..23f0e49 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -60,7 +60,7 @@ claude - Claude Code, Anthropic SDKs and CC Switch's Anthropic provider use a Base URL without `/v1/messages`; SDKs append the path themselves. Only clients explicitly asking for a complete endpoint should include `/v1/messages`. - `POST /v1/messages` is converted to upstream Chat Completions, retaining native tool calls and reasoning content. -- `--desensitize` controls WorkBuddy's fixed CLI-template adaptation. It is off by default and enabled in the project's Compose configuration. See the [advanced reference](advanced.md) for options such as retaining fuller instructions. +- `--desensitize` controls WorkBuddy's fixed CLI-template adaptation. It is off by default and enabled in the published image. See the [advanced reference](advanced.md) for options such as retaining fuller instructions. ## Other OpenAI-compatible clients diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 8d7e906..aaf4156 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -60,7 +60,7 @@ claude - Claude Code、Anthropic SDK 与 CC Switch 的 Anthropic 提供方使用不含 `/v1/messages` 的 Base URL;SDK 会自行追加路径。只有明确要求完整端点的客户端才应包含 `/v1/messages`。 - `POST /v1/messages` 会转换为上游 Chat Completions,保留原生工具调用与推理内容。 -- `--desensitize` 控制 WorkBuddy 固定 CLI 模板的适配,默认关闭,项目的 Compose 配置已启用。保留更完整指令等选项见[进阶参考](advanced.zh-CN.md)。 +- `--desensitize` 控制 WorkBuddy 固定 CLI 模板的适配,默认关闭,已发布镜像中启用。保留更完整指令等选项见[进阶参考](advanced.zh-CN.md)。 ## 其他 OpenAI 兼容客户端 diff --git a/docs/deployment.md b/docs/deployment.md index 27a3257..7950dde 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,15 +10,18 @@ On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your o | Setting | Purpose | |---------|---------| -| `CODEBUDDY2API_IMAGE` | Compose image; the template uses `codebuddy2api:local` | +| `CODEBUDDY2API_IMAGE` | Compose image; defaults to the published `latest` tag | | `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | Native listener and Compose host mapping; default `127.0.0.1:8787` | | `CODEBUDDY2API_AUTH_PATH` | Compose host data directory; defaults to `./auth`, mounted at `/data/auth` | +| `TZ` | Container time zone; daily guards follow it, and an unset value keeps UTC | +| `CODEBUDDY2API_ENV_FILE` | Optional Compose override for the env file path; defaults to `.env` | +| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | Headless no-auth opt-in for the image and Compose, where it stays `true`; configure `CODEBUDDY2API_KEY` before exposing the port | | `CODEBUDDY2API_TRUSTED_PROXIES` | Trusted proxy IPs/CIDRs (comma separated); when the direct peer matches, the audit log records the client from `X-Forwarded-For`/`X-Real-IP` instead of the proxy address | | `CODEBUDDY_AUTH_DIR` | Local Python data directory; defaults to the repository's `auth/`. Compose sets it to `/data/auth` inside the container | | `CODEBUDDY_IMPORT_DIR` | Optional import directory; defaults to `imports/` under the data directory. Use container paths with Compose | -The example lists all active runtime variables, including inbound/aggregate byte limits, concurrency, tool retries and failover. Compose forwards these limits; unset optional settings (tool metadata, Responses projection, origin allowlist, failover and similar) remain configurable in the WebUI. Zero disables aggregate/concurrency limits, per-item Responses trimming or extra retries, not the required positive inbound limit. +`.env.example` lists every active runtime variable with its default. Compose loads the whole file, so a key you comment out stays editable in the WebUI (tool metadata, Responses projection, origin allowlist, failover and similar). Zero disables aggregate/concurrency limits, per-item Responses trimming or extra retries, not the required positive inbound limit. -Compose reads declared variables from `.env`; shell variables take precedence. The default host mapping is loopback. Set a random key, HTTPS and access restrictions before allowing remote connections. Container binding remains `0.0.0.0:8787`; change host exposure using `BIND/PORT`, not container listener arguments. +Compose loads `.env` into the container; shell variables still win for the image, host mapping and data path. The default host mapping is loopback. Set a random key, HTTPS and access restrictions before allowing remote connections. Container binding remains `0.0.0.0:8787`; change host exposure using `BIND/PORT`, not container listener arguments. Mount the entire data directory on writable local storage, not just one SQLite file, and do not share it between instances. Stop the gateway and back up the whole directory before upgrading; see [data and backups](webui.md#data-and-backups). @@ -26,7 +29,7 @@ Mount the entire data directory on writable local storage, not just one SQLite f ### Use published images -Set `CODEBUDDY2API_IMAGE` in `.env` to `ghcr.io/maiphucgiang/codebuddy2api:`, choosing an existing release, then run: +Compose loads every key in `.env` into the container, so optional settings reach the gateway without repeating variable names; a missing `.env` still starts with the gateway defaults and the settings saved in SQLite. Set `CODEBUDDY2API_IMAGE` in `.env` to `ghcr.io/maiphucgiang/codebuddy2api:` to pin an existing release, then run: ```bash docker compose pull @@ -58,23 +61,52 @@ The default loopback mapping is the only safe exposure without extra work. When ## Local Python setup -Requires Python 3.12+, uv, and Node.js with the vp CLI to build the interface: +Requires Python 3.12+ and uv. Build the interface only when the distribution does not include it, using Node.js 24 and the vp CLI pinned by `vite-plus` in `web/package.json`. ```bash uv sync --locked --no-build --python 3.12 (cd web && vp install --frozen-lockfile && vp build) -uv run converter.py +uv run --locked --no-build converter.py --host 127.0.0.1 --port 8787 ``` +`--locked` stops instead of re-resolving when `uv.lock` is stale, so a source launch never upgrades packages. `.env` in the working directory is read automatically, so `uv run` needs no `--env-file`. Distribution archives already contain the WebUI; skip the frontend build there. + Local startup does not require `.env`. Without an explicit key, the first loopback startup generates a `cb-…` default key in `auth/control.sqlite3` and displays it once in the interactive terminal after listening succeeds. Restarts reuse it without printing it again. Keep it safe: management and API requests share this key. Configure a key explicitly for first-time headless or non-loopback deployment. One-time disclosure goes directly to the controlling terminal (`/dev/tty` or Windows `CONOUT$`), not stdout/stderr; redirecting standard output does not capture the key. Terminal recording remains outside the gateway's control. A failed terminal write or database commit leaves the same key pending for the next interactive start. A crash between display and commit can therefore repeat the notice; a successfully committed display is not repeated. -Without uv, run `python3 -m venv .venv`, activate it, install dependencies with `pip install --require-hashes --only-binary=:all: -r requirements.txt`, then run `python3 converter.py`. Distribution archives include the WebUI; source installs still need the frontend build. +Without uv, keep the same hash-locked set in a virtual environment: + +```bash +python3 -m venv .venv +. .venv/bin/activate # Windows: .venv\Scripts\activate +python3 -m pip install --require-hashes --only-binary=:all: -r requirements.txt +python3 converter.py --host 127.0.0.1 --port 8787 +``` + +Both startup paths read `.env` in the current working directory when present, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` only affect Compose. + +### Configuration migration + +`.env` values stay authoritative, so any runtime key listed there occupies its WebUI field. To move them into the configuration store, stop the service and run the offline command; it never edits `.env` itself: + +```bash +python3 converter.py config migrate # cwd/.env -> ./auth/control.sqlite3 +python3 converter.py config migrate --env-file /srv/app/.env --data-dir /srv/app/auth +python3 converter.py config status --db /srv/app/auth/control.sqlite3 # read-only +``` + +Both commands resolve the env file first, then the data directory it declares (`CODEBUDDY_AUTH_DIR`), so `--data-dir`/`--db` only override the resolved target. Before writing, `migrate` verifies the target really is this project's control store (SQLite header, `user_version=2`, `control`/`runtime_state`/`gateway_secrets`, readable payload, `quick_check`) and refuses while a running instance holds the lease or the write lock; `--force` bypasses the lease warning only. Deployment keys (key, listener, data paths) are never migrated. Inside a container there is no `.env` file, so use the process environment: + +```bash +docker compose stop +docker compose run --rm --no-deps -e CODEBUDDY_AUTH_DIR=/data/auth \ + codebuddy2api python3 converter.py config migrate --no-env --data-dir /data/auth +``` -Both commands optionally read `.env` in the current working directory, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` are Compose-only. +A successful run writes the values and disables the Dashboard notice in one revision; delete the printed `.env` lines when you want the WebUI to supply them. Instances started before this release write no lease, so confirm the service is stopped once after upgrading. `config status` never writes. `--lang zh|en|auto` selects the CLI output language and, for `serve`, `login` and `config migrate`, also saves the interface language; see [configuration and CLI](advanced.md#configuration-and-cli). > The audit schema is single-writer: stop the old instance before starting a new version, and never run two gateway versions against one auth directory. @@ -90,14 +122,14 @@ Older versions cannot read the upgraded control database. Downgrades require a s ## Dependency locks -`pyproject.toml` owns direct dependencies; `uv.lock` pins all resolved versions. `requirements.in` and the hash-locked `requirements.txt` are generated compatibility files for pip, Docker and CI: +`pyproject.toml` owns direct dependencies; `uv.lock` pins all resolved versions. The hash-locked `requirements.txt` is the generated pip input for Docker and CI: ```bash uv lock python3 scripts/export_requirements.py ``` -Use `uv add`/`uv remove` for intentional dependency changes, then export and review both locks. Normal startup uses `--locked` and never upgrades packages. Metadata stays at the current `VERSION`; releases must update both version fields. Pip/Docker still require matching hashes and binary wheels; do not disable these checks. +Use `uv add`/`uv remove` for intentional dependency changes, then export and review the lock. Normal startup uses `--locked` and never upgrades packages. Metadata stays at the current `VERSION`; releases must update both version fields. Pip/Docker still require matching hashes and binary wheels; do not disable these checks. Docker's build frontend, Node and Python images are pinned by multi-platform digest. When refreshing them, retain `linux/amd64` and `linux/arm64` support and verify the build. Locks prevent drift, not future vulnerabilities; security updates still require reviewed refreshes. @@ -106,9 +138,9 @@ Docker's build frontend, Node and Python images are pinned by multi-platform dig When the WebUI is unavailable, browser login also works without starting the server: ```bash -uv run --locked --no-build --env-file .env converter.py login -uv run --locked --no-build --env-file .env converter.py login --site intl --no-browser -uv run --locked --no-build --env-file .env converter.py login --site intl-codebuddy --no-browser +uv run --locked --no-build converter.py login +uv run --locked --no-build converter.py login --site intl --no-browser +uv run --locked --no-build converter.py login --site intl-codebuddy --no-browser ``` The first command uses the domestic site. `--site intl` selects international WorkBuddy (`www.workbuddy.ai`); `--site intl-codebuddy` selects international CodeBuddy (`www.codebuddy.ai`). `--no-browser` prints a link you can open on another device. Even after the browser reports success, wait for the terminal to confirm that credentials were saved. Links expire after 10 minutes; press `Ctrl+C` to cancel. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index b867106..5c63aab 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -10,16 +10,19 @@ | 配置 | 作用 | |------|------| -| `CODEBUDDY2API_IMAGE` | Compose 镜像;模板默认 `codebuddy2api:local` | +| `CODEBUDDY2API_IMAGE` | Compose 镜像;默认使用已发布的 `latest` 标签 | | `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | 本地监听与 Compose 宿主机映射;默认 `127.0.0.1:8787` | | `CODEBUDDY2API_AUTH_PATH` | Compose 宿主机数据目录;默认 `./auth`,挂载到容器 `/data/auth` | +| `TZ` | 容器时区;每日任务按它跨天,未设置时保持 UTC | +| `CODEBUDDY2API_ENV_FILE` | 可选的 Compose 覆盖项,指定 env 文件路径;默认 `.env` | +| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | 镜像与 Compose 的后台免鉴权选项,在两者中都固定为 `true`;对外暴露端口前请配置 `CODEBUDDY2API_KEY` | | `CODEBUDDY2API_TRUSTED_PROXIES` | 可信代理 IP/CIDR(逗号分隔);直连地址命中时审计改记 `X-Forwarded-For`/`X-Real-IP` 里的真实客户端地址,而不是代理地址 | | `CODEBUDDY_AUTH_DIR` | 本地 Python 数据目录;默认仓库内 `auth/`。Compose 在容器内固定为 `/data/auth` | | `CODEBUDDY_IMPORT_DIR` | 可选导入目录;默认数据目录下的 `imports/`。Compose 中请使用容器路径 | -示例文件列出了全部生效的运行时变量,包括入站/聚合字节上限、并发、工具重试与故障转移。Compose 会转发这些限制;未设置的可选项(工具元数据、Responses 投影、来源白名单、故障转移等)仍可在 WebUI 配置。取零表示关闭聚合/并发限制、Responses 单项裁剪或额外重试,而入站上限必须为正值。 +`.env.example` 列出了全部生效的运行时变量及其默认值。Compose 会整体加载该文件,注释掉的键仍可在 WebUI 配置(工具元数据、Responses 投影、来源白名单、故障转移等)。取零表示关闭聚合/并发限制、Responses 单项裁剪或额外重试,而入站上限必须为正值。 -Compose 从 `.env` 读取已声明变量,shell 变量优先。默认宿主机映射仅回环。开放远程访问前请设置随机密钥、HTTPS 与访问限制。容器内监听保持 `0.0.0.0:8787`;调整对外暴露用 `BIND/PORT`,不要改容器监听参数。 +Compose 会把 `.env` 加载进容器;镜像、宿主机映射与数据路径仍以 shell 变量优先。默认宿主机映射仅回环。开放远程访问前请设置随机密钥、HTTPS 与访问限制。容器内监听保持 `0.0.0.0:8787`;调整对外暴露用 `BIND/PORT`,不要改容器监听参数。 数据目录需整体挂载在可写本地存储,不要只挂载单个 SQLite 文件,也不要在多个实例间共享。升级前停止网关并备份整个目录,见[数据与备份](webui.zh-CN.md#数据与备份)。 @@ -27,7 +30,7 @@ Compose 从 `.env` 读取已声明变量,shell 变量优先。默认宿主机 ### 使用已发布镜像 -在 `.env` 中将 `CODEBUDDY2API_IMAGE` 设为 `ghcr.io/maiphucgiang/codebuddy2api:<版本>`(选择已发布的版本),然后运行: +Compose 会把 `.env` 中的每个键加载进容器,可选配置无需在 Compose 里重复变量名;缺少 `.env` 时仍以网关默认值和 SQLite 中保存的设置为准。将 `CODEBUDDY2API_IMAGE` 设为 `ghcr.io/maiphucgiang/codebuddy2api:<版本>` 可固定使用已发布版本,然后运行: ```bash docker compose pull @@ -59,23 +62,52 @@ docker compose up -d ## 本地 Python 运行 -需要 Python 3.12+、uv,以及 Node.js 与 vp CLI 来构建界面: +需要 Python 3.12+ 与 uv。仅在发行包未自带界面时才需构建:Node.js 24 与 `web/package.json` 中 `vite-plus` 锁定的 vp CLI。 ```bash uv sync --locked --no-build --python 3.12 (cd web && vp install --frozen-lockfile && vp build) -uv run converter.py +uv run --locked --no-build converter.py --host 127.0.0.1 --port 8787 ``` +`uv.lock` 过期时 `--locked` 会直接失败而不是重新解析,因此源码启动不会升级依赖。当前工作目录的 `.env` 会被自动读取,`uv run` 无需 `--env-file`。发行包已包含 WebUI,可跳过界面构建。 + 本地启动无需 `.env`。没有显式 key 时,首次回环启动会生成 `cb-…` 默认密钥,保存到 `auth/control.sqlite3`,监听成功后仅在交互终端显示一次;以后重启复用且不再打印。请妥善保存,管理登录与 API 请求共用。首次后台或非回环部署请显式配置 key。 密码提示直接写入控制终端(`/dev/tty` 或 Windows `CONOUT$`),不经过 stdout/stderr,重定向标准输出不会收集密码;外部终端录制不在程序控制范围内。 终端写入或数据库提交失败时,同一密钥保留为待显示,下次交互启动可重试。显示后、提交前崩溃可能再次提示;成功提交后不重复显示。 -没有 uv 时:运行 `python3 -m venv .venv` 并激活,用 `pip install --require-hashes --only-binary=:all: -r requirements.txt` 安装依赖,再执行 `python3 converter.py`。发行包已包含 WebUI;源码安装仍需构建界面。 +没有 uv 时,用虚拟环境安装同一份带哈希的依赖: + +```bash +python3 -m venv .venv +. .venv/bin/activate # Windows:.venv\Scripts\activate +python3 -m pip install --require-hashes --only-binary=:all: -r requirements.txt +python3 converter.py --host 127.0.0.1 --port 8787 +``` + +两种启动方式都会读取当前工作目录的 `.env`(若存在),不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 只作用于 Compose。 + +### 配置迁移 + +`.env` 中的值仍然优先,因此任何写在里面的运行参数都会占住 WebUI 的对应项。要统一到配置库,先停止服务,再执行离线命令;命令不会改动 `.env` 本身: + +```bash +python3 converter.py config migrate # 当前目录 .env -> ./auth/control.sqlite3 +python3 converter.py config migrate --env-file /srv/app/.env --data-dir /srv/app/auth +python3 converter.py config status --db /srv/app/auth/control.sqlite3 # 只读 +``` + +两条命令都先解析 env 文件,再采用其中声明的数据目录(`CODEBUDDY_AUTH_DIR`),`--data-dir`/`--db` 只覆盖解析结果。写入前 `migrate` 会验证目标确实是本项目的控制库(SQLite 魔数、`user_version=2`、`control`/`runtime_state`/`gateway_secrets`、可解析的 payload、`quick_check`),并在检测到运行中实例的租约或写锁时拒绝;`--force` 只跳过租约提示。部署类键(密钥、监听地址、数据路径)不会迁移。容器内没有 `.env` 文件,改用进程环境变量: + +```bash +docker compose stop +docker compose run --rm --no-deps -e CODEBUDDY_AUTH_DIR=/data/auth \ + codebuddy2api python3 converter.py config migrate --no-env --data-dir /data/auth +``` -两种启动方式均可选读取当前工作目录的 `.env`,不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 仅用于 Compose。 +成功时值写入与“关闭 Dashboard 提醒”在同一次 revision 内提交;按提示删除 `.env` 中对应行后,这些项改由配置库提供、可在 WebUI 修改。旧版本启动的实例不写租约,升级后首次迁移前请自行确认服务已停止。`config status` 不写库。`--lang zh|en|auto` 决定 CLI 输出语言,并在 `serve`、`login`、`config migrate` 时把界面语言一并保存;见[配置与 CLI](advanced.zh-CN.md#配置与-cli)。 > 审计库为单写者设计:升级新版本前先停止旧实例,不要让两个版本的网关共用同一 auth 目录。 @@ -91,14 +123,14 @@ SQLite 状态读取或校验失败会阻止启动,不删除会话、不回退 ## 依赖锁定 -`pyproject.toml` 声明直接依赖;`uv.lock` 锁定全部解析版本。`requirements.in` 与带哈希的 `requirements.txt` 是供 pip、Docker 与 CI 使用的生成文件: +`pyproject.toml` 声明直接依赖;`uv.lock` 锁定全部解析版本。带哈希的 `requirements.txt` 是供 pip、Docker 与 CI 使用的生成文件: ```bash uv lock python3 scripts/export_requirements.py ``` -有意调整依赖时使用 `uv add`/`uv remove`,然后导出并核对两份锁定。日常启动使用 `--locked`,不会升级任何包。元数据与当前 `VERSION` 保持一致;发版必须同时更新两处版本字段。pip/Docker 仍要求哈希匹配与二进制 wheel,不要关闭这些校验。 +有意调整依赖时使用 `uv add`/`uv remove`,然后导出并核对锁定文件。日常启动使用 `--locked`,不会升级任何包。元数据与当前 `VERSION` 保持一致;发版必须同时更新两处版本字段。pip/Docker 仍要求哈希匹配与二进制 wheel,不要关闭这些校验。 Docker 构建所用的前端、Node 与 Python 镜像均按多平台摘要锁定。刷新它们时保留 `linux/amd64` 与 `linux/arm64` 支持并验证构建。锁定只能防止漂移,不能免疫未来漏洞;安全更新仍需评审后刷新。 @@ -107,9 +139,9 @@ Docker 构建所用的前端、Node 与 Python 镜像均按多平台摘要锁定 WebUI 不可用时不启动服务也能完成浏览器登录: ```bash -uv run --locked --no-build --env-file .env converter.py login -uv run --locked --no-build --env-file .env converter.py login --site intl --no-browser -uv run --locked --no-build --env-file .env converter.py login --site intl-codebuddy --no-browser +uv run --locked --no-build converter.py login +uv run --locked --no-build converter.py login --site intl --no-browser +uv run --locked --no-build converter.py login --site intl-codebuddy --no-browser ``` 第一条使用国内站。`--site intl` 选择国际 WorkBuddy(`www.workbuddy.ai`);`--site intl-codebuddy` 选择国际 CodeBuddy(`www.codebuddy.ai`)。`--no-browser` 打印授权链接,可在其他设备打开。即使浏览器显示成功,也要等终端确认凭证已保存。链接 10 分钟过期;按 `Ctrl+C` 取消。 diff --git a/requirements.in b/requirements.in deleted file mode 100644 index 46362e5..0000000 --- a/requirements.in +++ /dev/null @@ -1,6 +0,0 @@ -# Generated from pyproject.toml by scripts/export_requirements.py; do not edit. -fastapi>=0.141.1 -httpx>=0.28.1 -pytest>=9.1.1 -python-dotenv>=1.2.3 -uvicorn[standard]>=0.52.4 diff --git a/scripts/export_requirements.py b/scripts/export_requirements.py index 5cfacfd..cc0dd46 100644 --- a/scripts/export_requirements.py +++ b/scripts/export_requirements.py @@ -13,9 +13,6 @@ def main(): subprocess.run(["uv", "export", "--locked", "--no-dev", "--no-emit-project", "--format", "requirements-txt", "--output-file", "requirements.txt"], cwd=ROOT, check=True, stdout=subprocess.DEVNULL) - (ROOT / "requirements.in").write_text( - "# Generated from pyproject.toml by scripts/export_requirements.py; do not edit.\n" - + "\n".join(project["dependencies"]) + "\n") if __name__ == "__main__": diff --git a/tests/test_build_lock.py b/tests/test_build_lock.py index 5096f2c..2901a7b 100644 --- a/tests/test_build_lock.py +++ b/tests/test_build_lock.py @@ -20,6 +20,12 @@ def requirement_lines(path): yield line.strip() +def direct_requirements(): + """Canonical names of the project's direct dependencies, read from pyproject.""" + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + return {canonicalize_name(Requirement(dep).name) for dep in project["dependencies"]} + + class BuildLockTests(unittest.TestCase): def test_every_resolved_dependency_has_an_exact_version_and_sha256(self): locked = set() @@ -36,15 +42,15 @@ def test_every_resolved_dependency_has_an_exact_version_and_sha256(self): for digest in hashes: self.assertRegex(digest.strip(), r"^sha256:[0-9a-f]{64}$") locked.add(canonicalize_name(parsed.name)) - direct = {canonicalize_name(Requirement(line).name) - for line in requirement_lines(ROOT / "requirements.in")} + direct = direct_requirements() self.assertTrue(direct) self.assertTrue(direct <= locked) def test_uv_metadata_and_exported_locks_stay_consistent(self): project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] self.assertEqual(project["version"], (ROOT / "VERSION").read_text().strip()) - self.assertEqual(set(project["dependencies"]), set(requirement_lines(ROOT / "requirements.in"))) + self.assertEqual(direct_requirements(), {canonicalize_name(Requirement(dep).name) + for dep in project["dependencies"]}) lock = tomllib.loads((ROOT / "uv.lock").read_text()) packages = {p["name"]: p for p in lock["package"] if p["name"] != project["name"]} exported = set() @@ -90,6 +96,19 @@ def test_ci_workflow_parses_and_keeps_install_flags_in_the_run_string(self): "--require-hashes", "--only-binary=:all:", "-r", "requirements.txt"]) + def test_published_image_tags_are_version_edge_and_latest(self): + """Only the release version, edge for main and latest for releases are published.""" + workflow = yaml.safe_load((ROOT / ".github/workflows/docker.yml").read_text()) + step = next(step for step in workflow["jobs"]["image"]["steps"] + if str(step.get("uses", "")).startswith("docker/metadata-action")) + tags = [line.strip() for line in step["with"]["tags"].splitlines() if line.strip()] + self.assertEqual(tags, [ + "type=semver,pattern={{version}}", + "type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}", + "type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}", + ]) + self.assertNotIn("flavor", step["with"]) + def test_ci_and_container_installs_require_verified_wheels(self): installs = [] for path in (ROOT / "Dockerfile", ROOT / ".github/workflows/docker.yml"): diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 9e72736..1f94b14 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -78,7 +78,9 @@ def test_env_template_matches_runtime_defaults_and_contains_no_shared_key(self): self.assertEqual(values["CODEBUDDY2API_" + env_key], str(RUNTIME_DEFAULTS[config_key])) self.assertEqual(values["CODEBUDDY2API_KEY"], "") self.assertEqual(values["CODEBUDDY2API_BIND"], "127.0.0.1") - self.assertEqual(values["CODEBUDDY2API_IMAGE"], "codebuddy2api:local") + # The template leaves the image commented so Compose falls back to the published latest tag. + self.assertNotIn("CODEBUDDY2API_IMAGE", values) + self.assertEqual(values["CODEBUDDY2API_AUTH_PATH"], "./auth") self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", values) self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", (ROOT / "docker-compose.yml").read_text()) self.assertEqual(values["CODEBUDDY2API_ADMIN_CSRF"], str(RUNTIME_DEFAULTS["admin_csrf"]).lower()) @@ -87,20 +89,22 @@ def test_env_template_matches_runtime_defaults_and_contains_no_shared_key(self): self.assertNotIn("CODEBUDDY2API_RESPONSES_PROJECTION_MODE", values) self.assertNotIn("CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES", values) - def test_tool_metadata_compose_environment_is_optional(self): - key = "CODEBUDDY2API_KEEP_TOOL_METADATA" - self.assertRegex((ROOT / "docker-compose.yml").read_text(), rf"(?m)^ +{key}: *$") - self.assertRegex((ROOT / ".env.example").read_text(), rf"(?m)^# {key}=(true|false)$") - origins = "CODEBUDDY2API_ADMIN_ORIGINS" - self.assertRegex((ROOT / "docker-compose.yml").read_text(), rf"(?m)^ +{origins}: *$") - self.assertRegex((ROOT / ".env.example").read_text(), rf"(?m)^# {origins}=https://") - - def test_responses_projection_compose_environment_is_optional(self): + def test_tool_metadata_and_origins_stay_optional_through_dotenv(self): + compose = (ROOT / "docker-compose.yml").read_text() + example = (ROOT / ".env.example").read_text() + self.assertIn("env_file:", compose) + for key in ("CODEBUDDY2API_KEEP_TOOL_METADATA", "CODEBUDDY2API_ADMIN_ORIGINS"): + with self.subTest(key=key): + # .env decides these; leaving them out keeps the WebUI authoritative. + self.assertNotIn(key + ":", compose) + self.assertRegex(example, rf"(?m)^# {key}=") + + def test_responses_projection_stays_optional_through_dotenv(self): compose = (ROOT / "docker-compose.yml").read_text() example = (ROOT / ".env.example").read_text() for key in ("CODEBUDDY2API_RESPONSES_PROJECTION_MODE", "CODEBUDDY2API_RESPONSES_PROJECTION_MAX_BYTES"): - self.assertRegex(compose, rf"(?m)^ +{key}: *$") + self.assertNotIn(key + ":", compose) self.assertRegex(example, rf"(?m)^# {key}=") def test_docker_copies_and_allows_all_local_runtime_imports(self): @@ -177,14 +181,23 @@ def deny_external_access(event, args): capture_output=True, text=True, timeout=20) self.assertEqual(result.returncode, 0, result.stderr) - def test_compose_has_old_syntax_fallbacks_without_required_env_file(self): + def test_compose_loads_dotenv_and_defaults_to_the_latest_image(self): text = (ROOT / "docker-compose.yml").read_text() self.assertIn('version: "3.8"', text) - self.assertNotIn("env_file:", text) - self.assertNotIn("required:", text) - for name, fallback in (("MAX_IMAGES", "16"), ("IMAGE_POLICY", "truncate"), - ("MAX_REQUEST_BYTES", "33554432"), ("LOG_BODY_LIMIT", "65536"), ("ADMIN_CSRF", "true")): - self.assertIn("${CODEBUDDY2API_" + name + ":-" + fallback + "}", text) + # The whole .env reaches the container and a missing file stays valid. + self.assertIn("env_file:", text) + self.assertIn("required: false", text) + self.assertIn("ghcr.io/maiphucgiang/codebuddy2api:latest", text) + # Optional knobs come from .env instead of per-variable fallbacks in the service. + for name in ("MAX_IMAGES", "IMAGE_POLICY", "MAX_REQUEST_BYTES", "LOG_BODY_LIMIT", "ADMIN_CSRF"): + self.assertNotIn("${CODEBUDDY2API_" + name, text) + self.assertEqual(re.findall(r"(?m)^ +([A-Z][A-Z0-9_]*):", text), + ["CODEBUDDY_AUTH_DIR", "CODEBUDDY2API_ALLOW_OPEN_NOAUTH"]) + # The image CMD owns the listener flags, so Compose does not restate the command. + self.assertNotIn("command:", text) + dockerfile = (ROOT / "Dockerfile").read_text() + self.assertIn('CMD ["python3", "converter.py", "--host", "0.0.0.0", "--port", "8787", ' + '"--skip-check", "--desensitize"]', dockerfile) for filename in ("README.md", "README.zh-CN.md"): doc = (ROOT / filename).read_text() self.assertIn("cp .env.example .env", doc) From 84b1fb42759f8beadbd57d681d994ac7c12b89b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:06:13 +0800 Subject: [PATCH 2/6] Keep menus and tab rows inside the viewport Narrow layouts clipped interactive content: the settings tab row pushed the page 16px sideways at 390px, a credential row menu opened inside the scrolling table wrapper and disappeared below it, the expanded trend data table was cut off, and the mobile navigation hid its last entries behind an unmarked scroll area. - The row menu renders through a portal with viewport-clamped fixed coordinates, repositioning on scroll and resize; a panel's backdrop-filter otherwise made a locally fixed menu resolve against the panel and leave the screen. - `.tabs` and the mobile nav wrap instead of overflowing, `.chartData` scrolls its expanded table, and selects cap their width at the container. - web/e2e/layout.spec.ts locks both regressions: no horizontal page or tab overflow at 390px, and the last row menu stays portaled and fully visible. --- web/e2e/layout.spec.ts | 106 ++++++++++++++++++++++++++++++++++ web/src/pages/Credentials.tsx | 56 +++++++++++++++--- web/src/ui.module.scss | 16 +++-- 3 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 web/e2e/layout.spec.ts diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts new file mode 100644 index 0000000..b9e4286 --- /dev/null +++ b/web/e2e/layout.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from "@playwright/test"; + +// Guards narrow layouts: tab rows wrap and dropdown menus escape scrolling tables. +const credentials = Array.from({ length: 12 }, (_, i) => ({ + id: `account-${i}`, + name: `very-long-credential-name-${i}.info`, + enabled: true, + profile: "cn-cli", + nickname: `隔离测试账号 ${i}`, + health: "ready", + credits: { remaining: 100 + i }, + cooldowns: [], + token_expires_at: Date.now() + 86400000, +})); + +const settings = { + revision: 1, + items: [ + { + key: "stream_mode", + label: "流式模式", + type: "string", + value: "compatible", + stored: null, + source: "default", + mode: "hot", + locked: false, + choices: ["compatible", "realtime"], + }, + { + key: "host", + label: "监听地址", + type: "string", + value: "0.0.0.0", + stored: null, + source: "env", + mode: "restart", + locked: true, + }, + { + key: "audit_budget", + label: "审计预算", + type: "integer", + value: 8192, + stored: null, + source: "internal", + mode: "restart", + locked: true, + }, + ], + audit: { logical_bytes: 0, db_bytes: 0, wal_bytes: 0, shm_bytes: 0, degraded: false }, +}; + +async function mockAPI(page: Page) { + await page.route("**/admin/**", async (route) => { + const url = new URL(route.request().url()); + const send = (json: unknown) => route.fulfill({ json, status: 200 }); + if (url.pathname === "/admin/session") return send({ authenticated: true, csrf_token: "cs" }); + if (url.pathname === "/admin/settings") return send(settings); + if (url.pathname === "/admin/credentials") return send({ credentials }); + return send({}); + }); +} + +async function openDashboard(page: Page) { + await mockAPI(page); + await page.goto("/dashboard/settings"); +} + +test.describe("narrow layout", () => { + test("settings tabs wrap instead of pushing the page sideways", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await openDashboard(page); + await expect(page.getByRole("tab", { name: /系统只读/ })).toBeVisible(); + const numbers = await page.evaluate(() => { + const scroller = document.scrollingElement!; + const tabs = document.querySelector("[class*=tabs]"); + return { + overflow: scroller.scrollWidth - scroller.clientWidth, + tabOverflow: tabs ? tabs.scrollWidth - tabs.clientWidth : 0, + }; + }); + expect(numbers.overflow).toBeLessThanOrEqual(1); + expect(numbers.tabOverflow).toBeLessThanOrEqual(1); + }); + + test("row menu escapes the scrolling table and stays inside the viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await openDashboard(page); + await page.goto("/dashboard/credentials"); + await page.getByRole("button", { name: "列表视图" }).first().click(); + const menus = page.getByRole("button", { name: /更多/ }); + await expect(menus.first()).toBeVisible(); + await menus.nth((await menus.count()) - 1).click(); + const menu = page.locator("[role=menu]").first(); + await expect(menu).toBeVisible(); + const box = await menu.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(390); + expect(box!.y + box!.height).toBeLessThanOrEqual(820); + // Portaled to , so a `overflow: auto` table wrapper cannot clip it. + const portaled = await menu.evaluate((el) => el.parentElement === document.body); + expect(portaled).toBe(true); + }); +}); diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index fe04595..724b65d 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { OAuth } from "../OAuth"; import { Trial } from "../Trial"; import { TravelSummary } from "../Travel"; @@ -215,22 +216,53 @@ function MoreMenu({ children: ReactNode; }) { const [open, setOpen] = useState(false); + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); const ref = useRef(null); + const menuRef = useRef(null); + + // The menu is portaled to : panels use backdrop-filter, which would make a + // locally fixed menu resolve against the panel and drift outside the viewport. + const place = useCallback(() => { + const trigger = ref.current?.querySelector("button"); + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + const menu = menuRef.current; + const width = menu?.offsetWidth || 160; + const height = menu?.offsetHeight || 220; + const margin = 8; + const left = Math.min(Math.max(margin, rect.right - width), window.innerWidth - width - margin); + let top = rect.bottom + 4; + if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 4); + setPosition({ top, left }); + }, []); + useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + const target = e.target as Node; + if (!ref.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; + const onMove = () => place(); document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); + window.addEventListener("resize", onMove); + window.addEventListener("scroll", onMove, true); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onMove); + window.removeEventListener("scroll", onMove, true); }; - }, [open]); + }, [open, place]); + + useLayoutEffect(() => { + setPosition(null); + if (open) place(); + }, [open, place]); + return (
- {open && ( -
setOpen(false)}> - {children} -
- )} + {open && + createPortal( +
setOpen(false)} + > + {children} +
, + document.body, + )}
); } diff --git a/web/src/ui.module.scss b/web/src/ui.module.scss index 2d05b60..443b56c 100644 --- a/web/src/ui.module.scss +++ b/web/src/ui.module.scss @@ -88,6 +88,7 @@ $white: var(--page); color: $ink; padding: 9px 11px; min-width: 0; + max-width: 100%; min-height: 38px; } :global(input::placeholder) { @@ -535,14 +536,16 @@ $white: var(--page); display: inline-block; } .moreMenuList { - position: absolute; - right: 0; - top: calc(100% + 4px); + /* Fixed so an open menu is never clipped by a scrolling table or panel. */ + position: fixed; z-index: 40; display: flex; flex-direction: column; gap: 2px; min-width: 128px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow: auto; padding: 6px; background: var(--surface); border: 1px solid var(--line); @@ -1192,6 +1195,8 @@ $white: var(--page); padding: 0 22px 14px; font-size: 10px; color: $muted; + /* The expanded data table is wider than a narrow panel; scroll it instead of clipping. */ + overflow-x: auto; } .chartData summary { cursor: pointer; @@ -1625,6 +1630,8 @@ $white: var(--page); } .tabs { display: flex; + flex-wrap: wrap; + row-gap: 6px; gap: 6px; border-bottom: 1px solid $line; margin-bottom: 20px; @@ -2806,9 +2813,10 @@ $white: var(--page); } .sidebar nav { display: flex; + flex-wrap: wrap; margin-top: 14px; gap: 4px; - overflow: auto; + overflow: visible; } .navItem { font-size: 11px; From 96131599237ac2873151a4e686bba03923b74b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:06:24 +0800 Subject: [PATCH 3/6] Import credentials from refresh tokens A refresh token plus its endpoint identifies an account, but the gateway only accepted a complete .info file, so users holding a single refresh token could not enrol an account at all. - auth_oauth.refresh_tokens exchanges one token without any access token, reads the account uid from the JWT subject, refreshes against the selected profile and fetches the account block so the tenant is preserved for enterprise accounts; a uid mismatch is rejected. - POST /admin/credentials/refresh-import accepts up to 50 tokens behind the existing admin authentication and a shared lock, validates the endpoint, skips duplicates, reports per-token failures and stores successes through the normal credential writer. - The import drawer gains a refresh-token tab with an endpoint selector and one token per line, reusing the existing result list; prepareImports parsing is covered by imports.test.ts and the API/UI flow by refreshImport.test.tsx. --- app/admin_api.py | 63 ++++++++++ app/auth_oauth.py | 86 +++++++++++++- app/client_profiles.py | 5 + app/site_routing.py | 7 ++ web/src/imports.test.ts | 21 +++- web/src/imports.ts | 30 +++++ web/src/pages/Credentials.tsx | 202 +++++++++++++++++++++++++-------- web/src/refreshImport.test.tsx | 51 +++++++++ 8 files changed, 417 insertions(+), 48 deletions(-) create mode 100644 web/src/refreshImport.test.tsx diff --git a/app/admin_api.py b/app/admin_api.py index 7cc6a2e..61d7b00 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -20,6 +20,7 @@ from .control_store import ConflictError, validate_model from .credential_io import CredentialFileError, MAX_CREDENTIAL_BYTES, _valid_name, read_import_file from .settings import SCHEMA, resolve_settings, validate_settings +from .site_routing import PROFILE_DOMAINS MAX_UPLOAD_BYTES = 32 * 1024 * 1024 CLEAR_CONFIRMATION = "清空全部日志与统计" @@ -459,6 +460,68 @@ async def credentials_upload(request): body = await _body(request, MAX_UPLOAD_BYTES * 6 + 65536) return JSONResponse({"results": await run_in_threadpool(upload, body)}) + refresh_lock = threading.Lock() + + def refresh_import(body): + profile = body.get("profile") + if profile not in PROFILE_DOMAINS: + raise ValueError("端点必须为 cn-cli、cn-work、intl-cli 或 intl-work") + tokens = body.get("refresh_tokens") + if not isinstance(tokens, list) or not 1 <= len(tokens) <= 50: + raise ValueError("refresh_tokens 必须包含 1 至 50 项") + lines = [] + for item in tokens: + if not isinstance(item, str): + raise ValueError("每项必须是一个刷新令牌字符串") + token = item.strip() + if not token: + continue + if len(token) > 8192: + raise ValueError("刷新令牌长度超出限制") + if token not in lines: + lines.append(token) + if not lines: + raise ValueError("至少需要一个刷新令牌") + results = [] + directory = gateway.managed_auth_dir().resolve() + # Serialize the whole batch: parallel refreshes would trip upstream risk control. + with refresh_lock: + for token in lines: + uid = auth_oauth.token_uid(token) + try: + if not uid: + raise ValueError("刷新令牌缺少账号身份(sub)") + payload = auth_oauth.refresh_tokens(token, profile, uid=uid) + account = auth_oauth.fetch_account(payload["accessToken"], PROFILE_DOMAINS[profile]) + if account["uid"] != uid: + raise ValueError("账号信息与刷新令牌不一致") + cred = auth_oauth.build_auth_file(payload, account) + invalid = auth_oauth.validate_cred_data(cred)[1] + if invalid: + raise ValueError(invalid) + content = json.dumps(auth_oauth.normalize_cred_data(cred), ensure_ascii=False).encode("utf-8") + # Keep a product-specific name when the same account already exists for another endpoint. + name = f"{uid}.info" + if (directory / name).exists(): + name = f"{uid}-{profile}.info" + target = gateway._store_credential(directory, name, content, uid, replace_existing=False) + results.append({"ok": True, "name": Path(target).name, "uid": uid, "profile": profile}) + except (ValueError, RuntimeError) as error: + results.append({"ok": False, "name": uid or "未知账号", "uid": uid or None, + "profile": profile, "error": str(error)}) + except Exception: + results.append({"ok": False, "name": uid or "未知账号", "uid": uid or None, + "profile": profile, "error": "刷新或保存失败,请检查网络与存储状态"}) + event("credentials.refresh_imported", + {"profile": profile, "count": len(results), "succeeded": sum(item["ok"] for item in results)}) + return results + + @route("POST", "/admin/credentials/refresh-import") + async def credentials_refresh_import(request): + # 50 tokens of at most 8 KiB each, with JSON escaping, stay under 1 MiB. + body = await _body(request, 1024 * 1024) + return JSONResponse({"results": await run_in_threadpool(refresh_import, body)}) + def export(body): ids = body.get("ids") if body.get("confirm") is not True or not isinstance(ids, list) or not 1 <= len(ids) <= 100 or any(not isinstance(i, str) for i in ids): diff --git a/app/auth_oauth.py b/app/auth_oauth.py index 2cd3929..357cd8f 100644 --- a/app/auth_oauth.py +++ b/app/auth_oauth.py @@ -13,8 +13,8 @@ import httpx -from .client_profiles import canonical_uid -from .site_routing import profile_for_auth +from .client_profiles import canonical_uid, refresh_headers +from .site_routing import PROFILE_DOMAINS, PROFILE_ENDPOINTS, profile_for_auth, refresh_url_for_auth PLUGIN_PREFIX = "/v2/plugin" OAUTH_TIMEOUT_S = 600 # Authorization deadline in seconds @@ -62,6 +62,88 @@ def _token_issuer_origin(access_token: str) -> str: return "" +def token_uid(token) -> str: + """Read the account uid from a JWT subject, returning an empty string on failure.""" + if not isinstance(token, str) or token.count(".") != 2: + return "" + part = token.split(".")[1] + try: + claims = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))) + except Exception: + return "" + sub = claims.get("sub") if isinstance(claims, dict) else None + return sub.strip() if isinstance(sub, str) else "" + + +def refresh_tokens(refresh_token, profile, *, uid="") -> dict: + """Exchange a refresh token for a full token payload without an existing credential file. + + The endpoint is an explicit choice: a domestic issuer cannot tell the CLI and + WorkBuddy products apart, and a cross-region endpoint rejects the token. + """ + if not isinstance(refresh_token, str) or not refresh_token.strip(): + raise ValueError("刷新令牌为空") + if profile not in PROFILE_DOMAINS: + raise ValueError("未知的凭证端点") + token = refresh_token.strip() + account_uid = str(uid or token_uid(token) or "") + domain = PROFILE_DOMAINS[profile] + headers = refresh_headers(domain, account_uid) + headers["X-Refresh-Token"] = token + headers["X-Auth-Refresh-Source"] = "plugin" + try: + with httpx.Client(timeout=REQUEST_TIMEOUT_S) as client: + response = client.post(refresh_url_for_auth({"domain": domain}), headers=headers, json={}) + except httpx.HTTPError as error: + raise RuntimeError(f"刷新令牌网络失败: {type(error).__name__}") from None + if response.status_code != 200: + raise RuntimeError(f"刷新令牌被拒绝(HTTP {response.status_code})") + try: + data = response.json() + except ValueError: + raise RuntimeError("刷新令牌返回无法解析") from None + payload = data.get("data") if isinstance(data, dict) else None + if not isinstance(data, dict) or data.get("code") != 0 or not isinstance(payload, dict): + raise RuntimeError("刷新令牌已失效或端点不匹配") + if not isinstance(payload.get("accessToken"), str) or not payload["accessToken"]: + raise RuntimeError("刷新令牌未返回有效的 accessToken") + if not isinstance(payload.get("refreshToken"), str) or not payload["refreshToken"]: + raise RuntimeError("刷新令牌未返回新的 refreshToken") + tokens = dict(payload) + tokens["domain"] = domain # Pin the selected product; the response only echoes X-Domain back. + return tokens + + +def fetch_account(access_token, domain) -> dict: + """Read the official account block for a token, including any tenant id. + + Refresh-token imports have no account payload, and an enterprise credential stored + with only a uid would bill through the personal endpoint and look empty. + """ + if not isinstance(access_token, str) or not access_token: + raise ValueError("缺少有效的 accessToken") + profile = profile_for_auth({"domain": domain}) + headers = refresh_headers(domain, "") + headers["Authorization"] = f"Bearer {access_token}" + url = PROFILE_ENDPOINTS[profile] + PLUGIN_PREFIX + "/login/account" + try: + with httpx.Client(timeout=REQUEST_TIMEOUT_S) as client: + response = client.get(url, headers=headers) + except httpx.HTTPError as error: + raise RuntimeError(f"账号信息网络失败: {type(error).__name__}") from None + if response.status_code != 200: + raise RuntimeError(f"账号信息被拒绝(HTTP {response.status_code})") + try: + data = response.json() + except ValueError: + raise RuntimeError("账号信息返回无法解析") from None + account = data.get("data") if isinstance(data, dict) else None + if not isinstance(data, dict) or data.get("code") != 0 or not isinstance(account, dict): + raise RuntimeError("账号信息读取失败") + if not isinstance(account.get("uid"), str) or not account["uid"]: + raise RuntimeError("账号信息缺少 uid") + return account + def _reject_constant(value): raise ValueError(f"非标准 JSON 常量: {value}") diff --git a/app/client_profiles.py b/app/client_profiles.py index d6ceec9..8dcd7fd 100644 --- a/app/client_profiles.py +++ b/app/client_profiles.py @@ -56,6 +56,11 @@ def _auth_headers(auth: dict, account: dict) -> dict: def credential_headers(auth: dict, account: dict) -> dict: return {**SDK_HEADERS, **_auth_headers(auth, account)} +def refresh_headers(domain: str, uid: str = "") -> dict: + """Build headers for a refresh exchange that has no access token yet.""" + headers = {**SDK_HEADERS, **_auth_headers({"accessToken": "", "domain": domain}, {"uid": uid})} + headers.pop("Authorization", None) # The refresh token is the only credential in this request. + return headers def catalog_headers(auth: dict, account: dict | None = None, *, user_agent="") -> dict: headers = _auth_headers(auth, account or {}) diff --git a/app/site_routing.py b/app/site_routing.py index 26d79e7..42c41b8 100644 --- a/app/site_routing.py +++ b/app/site_routing.py @@ -23,6 +23,13 @@ "www.codebuddy.ai": "intl-cli", "www.workbuddy.ai": "intl-work", } +# Credential domain stored for each product; refresh echoes X-Domain back as auth.domain. +PROFILE_DOMAINS = { + "cn-cli": "www.codebuddy.cn", + "cn-work": "www.workbuddy.cn", + "intl-cli": "www.codebuddy.ai", + "intl-work": "www.workbuddy.ai", +} # Verified CLI and WorkBuddy packages share these relative authentication paths. _REFRESH_PATH = "/v2/plugin/auth/token/refresh" diff --git a/web/src/imports.test.ts b/web/src/imports.test.ts index eb57f9f..ebe26a4 100644 --- a/web/src/imports.test.ts +++ b/web/src/imports.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import { zipSync, strToU8 } from "fflate"; -import { prepareImports, validName, MAX_FILE } from "./imports"; +import { + MAX_FILE, + MAX_REFRESH_TOKEN_LENGTH, + parseRefreshTokens, + prepareImports, + validName, +} from "./imports"; function file(name: string, bytes: Uint8Array): File { return { name, @@ -42,3 +48,16 @@ describe("bounded credential import", () => { expect(result.results).toHaveLength(2); }); }); + +describe("refresh token batch input", () => { + it("splits lines, ignores comments and blanks, and dedupes tokens", () => { + const result = parseRefreshTokens(" rt-a \n# note\n\nrt-b\nrt-a\n"); + expect(result.tokens).toEqual(["rt-a", "rt-b"]); + expect(result.rejected).toBe(0); + }); + it("skips oversized lines without dropping the remaining ones", () => { + const result = parseRefreshTokens(`ok\n${"x".repeat(MAX_REFRESH_TOKEN_LENGTH + 1)}\nnext`); + expect(result.tokens).toEqual(["ok", "next"]); + expect(result.rejected).toBe(1); + }); +}); diff --git a/web/src/imports.ts b/web/src/imports.ts index 6884c7e..f1d8543 100644 --- a/web/src/imports.ts +++ b/web/src/imports.ts @@ -101,3 +101,33 @@ export async function prepareImports( if (!count) throw new Error("ZIP 中没有可导入文件"); return { files: accepted, results }; } + +// The endpoint is always explicit: a domestic refresh token cannot tell CLI from WorkBuddy. +export const REFRESH_PROFILES = [ + { value: "cn-work", label: "国内 WorkBuddy" }, + { value: "cn-cli", label: "国内 CodeBuddy CLI" }, + { value: "intl-work", label: "国际 WorkBuddy" }, + { value: "intl-cli", label: "国际 CodeBuddy CLI" }, +] as const; +export const MAX_REFRESH_TOKENS = 50; +export const MAX_REFRESH_TOKEN_LENGTH = 8192; +export type RefreshProfile = (typeof REFRESH_PROFILES)[number]["value"]; + +/** Split textarea input into unique tokens, skipping blanks, comments and oversized lines. */ +export function parseRefreshTokens(input: string): { tokens: string[]; rejected: number } { + const seen = new Set(); + const tokens: string[] = []; + let rejected = 0; + for (const raw of input.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.length > MAX_REFRESH_TOKEN_LENGTH) { + rejected++; + continue; + } + if (seen.has(line)) continue; + seen.add(line); + tokens.push(line); + } + return { tokens, rejected }; +} diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index 724b65d..6104ccd 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -31,7 +31,14 @@ import { ResourceState, profileLabel, } from "../components"; -import { prepareImports, type ImportResult } from "../imports"; +import { + MAX_REFRESH_TOKENS, + REFRESH_PROFILES, + parseRefreshTokens, + prepareImports, + type ImportResult, + type RefreshProfile, +} from "../imports"; import { downloadFilename } from "../downloads"; import s from "../ui.module.scss"; @@ -61,55 +68,160 @@ function boundModels(credential: Credential): string[] { } function ImportDrawer({ onClose, onDone }: { onClose: () => void; onDone: () => void }) { + const [tab, setTab] = useState<"file" | "token">("file"); const [results, setResults] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [profile, setProfile] = useState("cn-work"); + const [input, setInput] = useState(""); + const parsed = parseRefreshTokens(input); + const tooMany = parsed.tokens.length > MAX_REFRESH_TOKENS; + const switchTab = (next: "file" | "token") => { + if (busy) return; + setTab(next); + setResults([]); + setError(null); + }; + const importFiles = (files: File[]) => { + setBusy(true); + setError(null); + setResults([]); + void prepareImports(files) + .then(async (batch) => { + setResults(batch.results); + if (!batch.files.length) return; + const response = await api.post("/credentials/upload", { + files: batch.files, + replace: false, + }); + const server = list(object(response.data).results).map((item) => { + if (typeof item.name !== "string" || typeof item.ok !== "boolean") + throw new Error("导入结果缺少 name / ok"); + return { + name: item.name, + ok: item.ok, + ...(item.error ? { error: text(item.error) } : {}), + }; + }); + setResults([...batch.results, ...server]); + if (server.some((item) => item.ok)) onDone(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }; + const importTokens = () => { + if (busy || !parsed.tokens.length || tooMany) return; + setBusy(true); + setError(null); + setResults([]); + void api + .post("/credentials/refresh-import", { + profile, + refresh_tokens: parsed.tokens, + }) + .then((response) => { + const server = list(object(response.data).results).map((item) => { + if (typeof item.name !== "string" || typeof item.ok !== "boolean") + throw new Error("导入结果缺少 name / ok"); + return { + name: item.name, + ok: item.ok, + ...(item.error ? { error: text(item.error) } : {}), + }; + }); + setResults(server); + if (server.some((item) => item.ok)) onDone(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }; return ( -

- 支持 UTF-8 .info 或 ZIP,ZIP 仅接受根目录 .info。单项 ≤ 1 MiB,每批 ≤ 100 项 / 32 - MiB,不覆盖现有文件。 -

- - {busy &&

正在检查并上传,请勿关闭…

} +
+ {( + [ + ["file", "文件导入"], + ["token", "仅刷新令牌"], + ] as ["file" | "token", string][] + ).map(([key, label]) => ( + + ))} +
+ {tab === "file" ? ( + <> +

+ 支持 UTF-8 .info 或 ZIP,ZIP 仅接受根目录 .info。单项 ≤ 1 MiB,每批 ≤ 100 项 / 32 + MiB,不覆盖现有文件。 +

+ + {busy &&

正在检查并上传,请勿关闭…

} + + ) : ( + <> +

+ 每行一个刷新令牌,逐条换取访问令牌后写入新凭证。端点决定产品与地区:国内刷新令牌分不出 + CLI 与 WorkBuddy,跨区令牌会被上游直接拒绝。 +

+ +