diff --git a/.env.example b/.env.example
index d78460c..edd1028 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,8 @@ POOL_ADMIN_TOKEN=
# 员工侧 codex 实际访问的网关地址。
# 必须是 daemon 宿主机可达的地址;CHEK 正式环境默认走 prod 域名。
POOL_BASE_URL=https://codex.chekkk.com/v1
+# CHEK prod 员工端按 Multica 任务自动换取短时凭证。
+POOL_SESSION_URL=https://codex.chekkk.com/api/self-service/session-key
# ---- multica ----
# 云端为 https://api.multica.ai;自托管填自己的地址
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c4a0890
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ cache: pip
+ - name: Install
+ run: python -m pip install -e ".[dev]"
+ - name: Test
+ run: python -m pytest -q
diff --git a/README.md b/README.md
index ea2052c..288c1da 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# corp-codex-pool
-把多个 Codex 订阅变成一个内部号池,按人发密钥、按人计量,并让 [multica](https://github.com/multica-ai/multica) 拉起的 codex 自动走这个号池。
+把多个 Codex 订阅变成一个内部号池,让 [Multica](https://github.com/multica-ai/multica) 任务按人、按任务使用和审计公司额度。
-**不改 multica 一行代码,不改 codex 一行代码,也不碰你自己的 `codex`。**
+**CHEK 正式环境不向员工分发永久 Key,只允许 Multica 任务中的 `mcodex` 申请短时、任务绑定凭证;你自己的 `codex` 不受影响。**
```
codex → provider: openai → 你的个人订阅
@@ -48,10 +48,10 @@ mcodex → provider: gw → 公司号池网关 → Pro 账号 A / B /
| 能力 | 只有网关 | 只有 multica | 接上之后 |
|---|---|---|---|
| 多个订阅共享 | 有 | 无 | 有 |
-| 按人计量 | 做不到(只看得见密钥) | 无请求级用量 | 密钥即人,可归集 |
+| 按人/任务计量 | 做不到(只看得见密钥) | 无请求级用量 | 临时 Key 名绑定用户与任务,可归集 |
| 超额硬拦截 | 可做 | 物理上做不到1 | 网关拦截 |
-| 员工无需登录 | — | — | 密钥由服务端下发 |
-| 离职即时回收 | — | — | 吊销密钥,0.06 秒生效 |
+| 员工无需管理 Key | — | — | 任务启动时自动签发,不落盘 |
+| 防止脱离公司任务使用 | 无 | 有任务上下文 | 缺任务票或上下文不匹配即拒绝 |
1 multica 的用量只在任务结束后上报一次,跑到一半发现超额没法掐断。
@@ -65,7 +65,6 @@ mcodex → provider: gw → 公司号池网关 → Pro 账号 A / B /
### 不适用
- 只有一个人用 —— 直接用 codex 就好
-- 需要精确到"每个任务花了多少" —— 见[已知限制](#已知限制)
- 想绕过订阅条款做转售 —— 本项目是给企业内部分发自购订阅用的
---
@@ -87,11 +86,7 @@ flowchart LR
G -.per-request 计量.-> DB[(用量表)]
```
-它覆盖两种运行场景:
-
-**你直接敲 `mcodex`** — 环境里没有 `CODEX_HOME`,就设成 `~/.mcodex`(那里有号池 provider 配置和密钥),然后 exec codex。
-
-**multica daemon 拉起** — daemon 会把 `CODEX_HOME` 指向 per-task 目录,并从 `~/.codex/config.toml` 拷一份配置进去。既然宿主那份不含号池 provider,`mcodex` 就在 exec 前把 provider 注入这份 per-task 配置。daemon 准备完就不再动它,所以这个时机是安全的,且每个任务都是新目录。
+CHEK 正式环境只覆盖一种运行场景:**Multica daemon 拉起**。daemon 把 `CODEX_HOME` 和 `mat_` 任务票据注入进程,`mcodex` 用任务票向生产桥接层换取两小时有效的 `mcx_` 凭证,再 exec 真 Codex。直接在终端运行 `mcodex` 因缺少任务上下文会失败。
### 依赖的三个上游机制
@@ -99,9 +94,9 @@ flowchart LR
| 机制 | 作用 |
|---|---|
-| codex 自定义 provider 的 `env_key` 从环境变量读密钥,且**优先于 `auth.json`** | 每人一把密钥,不用分发账号;有 `env_key` 时**不需要 `codex login`** |
+| codex 自定义 provider 的 `env_key` 从环境变量读密钥,且**优先于 `auth.json`** | 本次任务注入临时凭证;不需要 `codex login` |
| multica daemon 支持 `MULTICA_CODEX_PATH` 指定 codex 可执行文件 | 不改 multica 就能换成 `mcodex` |
-| multica 的 `custom_env` 会被 daemon 铺到 codex 子进程环境,且不拦自定义变量名 | 密钥可按人下发到各自的 agent |
+| Multica 的 `mat_` 任务票在服务端绑定用户、Agent、任务和 Workspace | 网关不信任客户端自报身份,只接受服务端证明 |
### 密钥注入时序
@@ -115,7 +110,9 @@ sequenceDiagram
D->>D: 准备 per-task CODEX_HOME
拷贝宿主 config.toml
D->>M: exec mcodex app-server --listen stdio://
env: CODEX_HOME, MULTICA_TASK_ID…
M->>M: 往 per-task config.toml 注入 [model_providers.gw]
- M->>M: 从 ~/.mcodex/key 读密钥
(custom_env 已有则不覆盖)
+ M->>G: 用 mat_ 任务票申请短时凭证
+ G->>S: 校验服务端绑定的用户/Agent/任务/Workspace
+ G-->>M: 返回加密 mcx_ 凭证(不含可见原始 Key)
M->>C: execvp(argv 与 stdio 原样透传)
C->>G: POST /v1/responses
Authorization: Bearer GW_API_KEY
G->>G: 按密钥鉴权 + 配额检查 + 路由到某个账号
@@ -146,14 +143,15 @@ pip install -e .
|---|---|
| [codex CLI](https://github.com/openai/codex) | 已安装。`mcodex` 会 exec 它 |
| 号池网关 | 必须实现 **Responses API**(`POST /v1/responses`)。本项目针对 [codex-lb](https://github.com/Soju06/codex-lb) 开发与验证 |
-| multica | 可选。只在需要让 multica agent 走号池时用 |
+| Multica | CHEK 正式环境必需;员工先登录并在本机运行 daemon |
> ⚠️ **网关必须支持 Responses API。** codex 已经移除了 `wire_api = "chat"`,只接受 `"responses"`。只做 chat/completions 兼容的网关**接不上**。
### CHEK 正式维护方式
-CHEK 内部正式方案按三层维护:
+CHEK 内部正式方案按四层维护:
+- `chekdata/multica`:提供任务票据的服务端绑定证明
- `chekdata/codex-lb`:正式托管号池网关源码、CI 与镜像
- `chekdata/corp-codex-pool`:正式托管员工侧 `poolctl` / `mcodex`
- `chekdata/ops-bootstrap`:正式托管 prod ArgoCD 发布清单
@@ -164,41 +162,18 @@ CHEK 内部正式方案按三层维护:
## 快速开始
-### 管理员:签发密钥
+### 员工:一次性接入
```bash
-cp .env.example .env # 填写网关地址与管理面密码
-poolctl status # 确认能连上网关
-
-poolctl issue zhangsan --weekly-token-limit 20000000
+multica login
+poolctl enroll
```
-密钥明文**只在签发时显示一次**,之后只能看到前缀。记下来发给员工。
-
-### 员工:本机初始化
-
-```bash
-poolctl mcodex init --key-stdin
-# 粘贴密钥,回车,Ctrl-D
-```
-
-之后把 `codex` 换成 `mcodex`:
-
-```bash
-mcodex
-mcodex exec "重构这个模块"
-```
-
-`mcodex init` 默认从 `~/.codex/config.toml` 继承使用偏好(模型、推理强度、已信任目录),手感和平时一致。**只继承偏好,不复制任何凭据。**
-
-### 让 multica 走号池
+`poolctl enroll` 会初始化独立的 `~/.mcodex`、清理旧永久 Key,并用 `mcodex` 重启 Multica daemon。之后员工只需在 `multica.chekkk.com` 创建任务,不需要看见、粘贴或保存 API Key。
-```bash
-poolctl daemon restart # 带上 MULTICA_CODEX_PATH 启动
-poolctl daemon status # 确认它用的确实是 mcodex
-```
+### 使用
-输出应包含:
+在 Multica 中创建/继续一个 Codex 任务。任务的每轮消息、工具结果和状态由 Multica 原生任务窗口保存;网关按临时 Key 记录模型、token、成本和延迟。脱离 Multica 直接运行 `mcodex` 会被拒绝。可用 `poolctl daemon status` 验证运行入口,输出应包含:
```
✓ daemon 使用的 codex:/…/mcodex(走号池)
@@ -207,17 +182,27 @@ poolctl daemon status # 确认它用的确实是 mcodex
### 验证
```bash
-poolctl doctor --key sk-clb-...
+poolctl doctor
```
-13+ 项检查,覆盖网关连通性、配置正确性、密钥有效性、超限契约。每个失败项都带可执行的下一步。
+检查覆盖网关连通性、配置、任务绑定凭证模式和 Multica 登录状态。每个失败项都带可执行的下一步。
---
## 命令参考
+### `poolctl enroll` — 员工一键接入
+
+```bash
+poolctl enroll [--no-inherit] [--real-codex ] [--no-restart-daemon]
+```
+
+不接收 Key。它只准备 `mcodex` 并确保 daemon 通过该入口启动;凭证在每个任务开始时自动签发。
+
### `poolctl issue` — 签发密钥
+这是兼容私有/手工部署的管理员命令。CHEK prod 员工路径不使用它,也不向员工下发其结果。
+
```bash
poolctl issue <员工标识> [选项]
```
@@ -304,6 +289,8 @@ poolctl unsetup [--dry-run]
### `poolctl mcodex init` — 初始化员工端
+底层初始化命令;CHEK prod 员工优先使用 `poolctl enroll`。`--key` 选项仅保留给私有/手工部署。
+
```bash
poolctl mcodex init [--key <明文> | --key-stdin] [--inherit/--no-inherit]
[--home ] [--real-codex ]
@@ -346,6 +333,7 @@ multica 只能通过 `MULTICA_CODEX_PATH` 环境变量指定 codex 路径(它
| `POOL_ADMIN_PASSWORD` | — | 管理面密码。codex-lb 用 cookie session 登录 |
| `POOL_ADMIN_TOKEN` | — | 预留,当前管理面不接受 Bearer |
| `POOL_BASE_URL` | `https://codex.chekkk.com/v1` | 员工侧 codex 访问的网关地址。**必须是 daemon 宿主机可达的地址** |
+| `POOL_SESSION_URL` | `https://codex.chekkk.com/api/self-service/session-key` | Multica 任务票换取短时号池凭证的地址 |
| `MULTICA_SERVER_URL` | `https://api.multica.ai` | 自托管填自己的地址 |
| `MULTICA_TOKEN` | — | 不填则读 `~/.multica/config.json` |
| `POOL_PROVIDER_ID` | `gw` | 出现在 `[model_providers.]` |
@@ -355,7 +343,7 @@ multica 只能通过 `MULTICA_CODEX_PATH` 环境变量指定 codex 路径(它
| 变量 | 说明 |
|---|---|
-| `GW_API_KEY` | 号池密钥。优先于 `~/.mcodex/key` |
+| `GW_API_KEY` | 仅在 Codex 进程内存在的 `mcx_` 任务绑定凭证;正式模式不持久化 |
| `MCODEX_HOME` | 覆盖默认家目录 `~/.mcodex` |
| `MCODEX_REAL_CODEX` | 临时覆盖真 codex 路径,便于排障与灰度 |
@@ -385,11 +373,11 @@ env_key_instructions = "由号池控制台下发,请勿手工设置"
## 两种接入方式
-| | `mcodex`(推荐) | `poolctl setup` |
+| | `mcodex`(CHEK prod) | `poolctl setup`(仅私有/手工部署) |
|---|---|---|
| 配置位置 | `~/.mcodex/config.toml` | `~/.codex/config.toml` |
| 影响你自己的 `codex` | **不影响** | 会一起走号池 |
-| 未设密钥时 | 只有 `mcodex` 报错 | **`codex` 直接报错** |
+| 凭证 | Multica 任务内自动签发 | 需要手工永久 Key |
| multica 接入 | 设 `MULTICA_CODEX_PATH` | 无需额外设置 |
| 命令 | `mcodex` | `codex` 照旧 |
@@ -399,7 +387,7 @@ env_key_instructions = "由号池控制台下发,请勿手工设置"
ERROR failed to refresh available models: Missing environment variable: `GW_API_KEY`
```
-除非确实想让整台机器都走号池,否则用 `mcodex`。
+CHEK prod 强制使用 `mcodex`;`setup` 不能绕过生产桥接层的任务绑定校验。
`poolctl doctor` 会自动识别当前用的是哪种,并在报告里标注。
@@ -407,39 +395,32 @@ ERROR failed to refresh available models: Missing environment variable: `GW_API_
## 密钥管理
-### 两种粒度
-
-| 方式 | 存放位置 | 适用 |
-|---|---|---|
-| 机器级 | `~/.mcodex/key`(权限 600) | 一台机器一个人,最简单 |
-| per-agent | multica agent 的 `custom_env` | 一台机器多人 / 多档位 |
+### 正式模式
-`custom_env` 优先于家目录密钥文件,两者可混用。`poolctl doctor` 会说明当前实际生效的是哪种。
+员工侧不保存原始 `sk-clb` Key,也不把 Key 写入 Agent `custom_env`。桥接层为每个任务创建有有效期和 token 上限的原始 Key,再把它加密封装为绑定用户、Agent、任务和 Workspace 的 `mcx_` 凭证。每次 API 请求都会解封并核对三元组后才转发。
### 生命周期
```
-签发 (poolctl issue)
- → 明文只显示一次
- → 下发到员工机器 (mcodex init) 或 agent (deliver)
- → 使用中:网关按密钥记账
- → 吊销 (poolctl revoke):0.06 秒生效
+Multica 创建任务 → daemon 注入 mat_ 任务票
+ → mcodex 自动换取短时 mcx_ 凭证(不落盘)
+ → 网关逐请求核对任务上下文并记账
+ → 任务票完成即回收;mcx_ 与底层 Key 最晚按 TTL 失效
```
### 安全须知
-- **密钥在员工机器上是明文存放的**(`~/.mcodex/key`,权限 600)。
-- **走 `custom_env` 时对 agent 的 shell 可见** —— `custom_env` 里的键会进 codex 的 `shell_environment_policy.include_only`,agent 自己跑 `env` 能看到。
-- 号池密钥只是入场券:泄露后果限于该员工额度,且可即时吊销。若安全评审不接受,需要改 multica 上游走 daemon 侧 `agentEnv`(含 `KEY` 的变量会被排除出 `include_only`,但 codex 主进程仍拿得到)。
-- **号池 agent 一律设为 private。** multica 的 `agent_runtime.visibility` 若为 `public`,工作区任何成员派的活都会跑在这个 owner 的号上,"一人一密钥"会退化成"一机一密钥"。
+- 原始网关 Key 只存在于生产桥接层和 codex-lb 之间,员工拿到的是加密、限时、任务绑定凭证。
+- `mcodex` 在 daemon 配置完 shell 环境白名单后才注入凭证,Agent 的 shell 工具不会继承 `GW_API_KEY`。
+- 操作系统账户所有者仍可能调试自身进程;因此本方案提供强归因、短 TTL 和审计,而不是声称能抵抗已控制员工电脑的恶意管理员。
---
## 用量与计量
-### 归属依据是密钥
+### 归属依据是任务 Key
-一把密钥对应一个员工,映射关系由 `poolctl issue` 建立。网关按密钥记录每次请求的模型、token 数、成本、延迟。
+一把短时 Key 对应一个 Multica 用户和任务,名称包含用户/任务短 ID。网关按 Key 记录每次请求的模型、token 数、成本、延迟,Multica 保存同一任务的完整对话窗口。
### 采集了什么
@@ -447,11 +428,11 @@ ERROR failed to refresh available models: Missing environment variable: `GW_API_
|---|---|
| 请求数、token 数(输入/缓存/输出/推理) | 提示词内容 |
| 模型、成本、延迟 | 代码内容 |
-| 时间戳、账号归属 | 对话历史 |
+| 时间戳、用户/任务归属 | 提示词和回复正文的网关副本 |
### 对账粒度
-**能到人,到不了任务。** 详见[已知限制](#对账只能到人到不了任务)。
+**可以到人和任务。** 任务 Key 名与网关日志负责成本归集,Multica task id 负责打开对应对话窗口;网关不额外复制对话正文。
`poolctl usage --by-session` 提供会话级归集作为折中,基于同一会话的请求共享 `requestId` 前 16 位这个**实测规律**(非上游契约,可能随版本变化)。
@@ -536,13 +517,9 @@ ss -tnp | grep <网关端口>
## 已知限制
-### 对账只能到人,到不了任务
-
-codex **确实会**把 `x-multica-task-id` 等头发出去(已用回显服务实证),但 codex-lb 的 `RequestLogEntry` **不记录任何自定义请求头**。
-
-同时 multica 的 `session_id` 与网关的 `conversationId` 无一命中 —— app-server 模式下后者恒为空。
+### 对话不在网关重复落库
-所以 task 级精确对账需要网关侧改造。`env_http_headers` 照配不误(无害,且网关将来支持时即可用),但不要指望现在就能用。
+完整提示词、回复和工具轨迹由 Multica 原生任务窗口保存;codex-lb 仅保存请求级用量。任务 Key 名提供两边的对账键。这避免在第二套系统复制敏感正文,但也意味着网关控制台本身不能展示对话内容。
### 超限请求不进请求日志
@@ -554,9 +531,9 @@ codex **确实会**把 `x-multica-task-id` 等头发出去(已用回显服务
多账号池化后,如果会话没有粘到同一个账号,提示词缓存会失效,成本优势会显著缩水。**加第二个账号后必须重测。**
-### 密钥对 agent 可见
+### 本机所有者边界
-见[安全须知](#安全须知)。
+凭证不会进入 Agent shell,但掌控员工操作系统账户的人仍可能检查 Codex 进程。短 TTL、任务绑定和审计用于缩小风险窗口,不能替代终端安全管理。
### 版本绑定
@@ -628,7 +605,7 @@ multica 的用量写入是 REPLACE 语义,会与 agent 自报互相覆盖;
```bash
pip install -e ".[dev]"
-pytest # 65 个测试
+pytest # 71 个测试
```
测试重点覆盖:
@@ -636,7 +613,7 @@ pytest # 65 个测试
- TOML 注入的语义正确性与幂等性(顶层键陷阱、marker 块共存、反复注入稳定)
- 与用户 `~/.codex` 的隔离性(不改源文件、不复制凭据)
- codex 路径解析优先级(固化 > 环境变量 > PATH,软链不跟随)
-- daemon 场景下不覆盖 `CODEX_HOME`、不覆盖已有密钥
+- daemon 场景下不覆盖 `CODEX_HOME`,并强制用任务票换取临时凭证
- doctor 的模式识别
### 项目结构
@@ -668,17 +645,17 @@ docs/
不需要,也不需要 `codex login`。有 `env_key` 时 codex 不走 first-party auth 路径。
-**Q: 密钥丢了怎么办?**
+**Q: 员工需要申请或保存密钥吗?**
-明文只在签发时显示一次。丢了就 `poolctl revoke` 再重新 `issue`。
+不需要。`mcodex` 在每个 Multica 任务启动时自动申请,凭证不写磁盘;失败时重新创建/重试任务即可。
**Q: 能限制员工只用某几个模型吗?**
能,`--allowed-model`。注意白名单外的模型会触发 codex 重试 6 次。
-**Q: 支持飞书 / OIDC 登录吗?**
+**Q: 支持飞连 / OIDC 登录吗?**
-不支持。multica 只有邮箱验证码和 Google 两种登录方式,没有 OIDC/SAML 抽象。要做需要自己写。
+CHEK prod 已通过 `multica.chekkk.com` 前置飞连 OIDC 单点登录;任务票据仍由 Multica 服务端签发。
**Q: 支持自托管 multica 吗?**
@@ -686,7 +663,7 @@ docs/
**Q: 以后功能更新在哪个仓库做?**
-在 `chekdata/corp-codex-pool` 做。若网关能力也要变更,则同步改 `chekdata/codex-lb`,然后由 `chekdata/ops-bootstrap` 发到 prod。
+员工端在 `chekdata/corp-codex-pool`,任务身份契约在 `chekdata/multica`,网关能力在 `chekdata/codex-lb`,生产策略和 ArgoCD 发布在 `chekdata/ops-bootstrap`。
**Q: 为什么我的请求要几分钟?**
diff --git a/corp_codex_pool/cli.py b/corp_codex_pool/cli.py
index be69af8..4c0e8ce 100644
--- a/corp_codex_pool/cli.py
+++ b/corp_codex_pool/cli.py
@@ -2,7 +2,7 @@
设计原则:
- 所有会改变状态的命令都支持 --dry-run,且默认打印将要发生的变更。
-- 密钥明文只在签发那一刻出现在终端一次,其余场合一律打码。
+- 正式员工入口不分发永久密钥;旧管理员命令输出的密钥其余场合一律打码。
- 每一步失败都给出可执行的下一步,而不是只报错。
"""
@@ -68,10 +68,9 @@ def main(ctx, env_file):
@click.option("--dry-run", is_flag=True, help="只显示将要写入的内容")
@click.pass_context
def setup(ctx, base_url, provider_id, env_key, config_path, no_default, no_headers, dry_run):
- """向宿主 ~/.codex/config.toml 注入号池 provider。
+ """旧版管理员工具:向宿主 ~/.codex/config.toml 注入号池 provider。
- multica daemon 会把这个文件原样拷贝进每个 per-task CODEX_HOME,
- 因此这一步做完,daemon 拉起的 codex 就会走号池,无需改 multica。
+ 正式员工接入请使用 poolctl enroll;该命令仅保留用于迁移和排障。
"""
settings = _settings(ctx)
spec = ProviderSpec(
@@ -105,7 +104,7 @@ def setup(ctx, base_url, provider_id, env_key, config_path, no_default, no_heade
click.echo(f" 备份:{result.backup}")
click.echo(f" provider = {spec.provider_id} base_url = {spec.base_url}")
click.echo(f" 密钥来源环境变量:{spec.env_key}")
- click.echo("\n下一步:poolctl issue <员工标识> 签发密钥并下发")
+ click.echo("\n正式员工接入:poolctl enroll")
def _print_block_diff(before: str, after: str) -> None:
@@ -151,7 +150,7 @@ def issue(
ctx, person, agent_id, workspace_id, weekly_token_limit, allowed_model,
expires_days, traffic_class, reuse, dry_run,
):
- """为一名员工签发号池密钥,可选直接下发到 multica agent。
+ """旧版管理员工具:签发永久号池密钥并可选下发。
PERSON 是员工标识,会成为密钥名(建议用工号或邮箱前缀)。
"""
@@ -432,7 +431,7 @@ def doctor(ctx, key, config_path, as_json):
*doctor_mod.check_codex_config(
settings.provider_id, settings.pool_base_url, settings.env_key, config_path
),
- *doctor_mod.check_mcodex_home(settings.env_key),
+ *doctor_mod.check_mcodex_home(settings.env_key, settings.pool_session_url),
]
if key:
@@ -447,46 +446,51 @@ def doctor(ctx, key, config_path, as_json):
doctor_mod.Check("multica 连通", doctor_mod.OK, f"{identity.name} <{identity.email}>")
)
agents = mc.agents()
- delivered = [
- a for a in agents if settings.env_key in (mc.get_env(a["id"]) or {})
- ]
-
- # 密钥有两个来源,任一成立即可:
- # per-agent —— agent 的 custom_env,适合一台机器多人/多档位
- # 机器级 —— mcodex 家目录的密钥文件,一台机器一把
- # 只有两者都没有才是真问题。
- from .mcodex import KEY_FILENAME, mcodex_home
-
- machine_key = (mcodex_home() / KEY_FILENAME).exists()
-
- if delivered:
+ if settings.pool_session_url:
checks.append(
doctor_mod.Check(
- "agent 密钥来源",
+ "agent 凭证模式",
doctor_mod.OK,
- f"{len(delivered)}/{len(agents)} 个 agent 带有 {settings.env_key}"
- "(per-agent,优先于机器级密钥)",
- )
- )
- elif machine_key:
- checks.append(
- doctor_mod.Check(
- "agent 密钥来源",
- doctor_mod.OK,
- f"未使用 custom_env,{len(agents)} 个 agent 统一走 mcodex 机器级密钥",
+ f"{len(agents)} 个 agent 均在任务启动时使用 mat_ 票据换取临时凭证",
)
)
else:
- checks.append(
- doctor_mod.Check(
- "agent 密钥来源",
- doctor_mod.FAIL,
- f"{len(agents)} 个 agent 都没有 {settings.env_key},"
- "mcodex 家目录也没有密钥文件",
- "机器级:poolctl mcodex init --key-stdin\n"
- " 或按人下发:poolctl issue <员工> --agent-id ",
+ delivered = [
+ a for a in agents if settings.env_key in (mc.get_env(a["id"]) or {})
+ ]
+
+ # Legacy/manual deployments may still use per-agent or machine keys.
+ from .mcodex import KEY_FILENAME, mcodex_home
+
+ machine_key = (mcodex_home() / KEY_FILENAME).exists()
+ if delivered:
+ checks.append(
+ doctor_mod.Check(
+ "agent 密钥来源",
+ doctor_mod.OK,
+ f"{len(delivered)}/{len(agents)} 个 agent 带有 {settings.env_key}"
+ "(per-agent,优先于机器级密钥)",
+ )
+ )
+ elif machine_key:
+ checks.append(
+ doctor_mod.Check(
+ "agent 密钥来源",
+ doctor_mod.OK,
+ f"未使用 custom_env,{len(agents)} 个 agent 统一走 mcodex 机器级密钥",
+ )
+ )
+ else:
+ checks.append(
+ doctor_mod.Check(
+ "agent 密钥来源",
+ doctor_mod.FAIL,
+ f"{len(agents)} 个 agent 都没有 {settings.env_key},"
+ "mcodex 家目录也没有密钥文件",
+ "机器级:poolctl mcodex init --key-stdin\n"
+ " 或按人下发:poolctl issue <员工> --agent-id ",
+ )
)
- )
except MulticaError as exc:
checks.append(
doctor_mod.Check("multica 连通", doctor_mod.WARN, str(exc), "运行 multica login")
@@ -567,6 +571,8 @@ def mcodex_init(ctx, key, key_stdin, inherit, home, real_codex):
click.echo(f" 已继承偏好:{', '.join(info['inherited'])}(未复制任何凭据)")
if info["key_stored"]:
click.echo(f" 密钥已存入 {info['home']}/key(600)")
+ elif settings.pool_session_url:
+ click.echo(" 凭证:由 Multica 每个任务启动时自动签发,不写入磁盘")
else:
click.secho(
f" ! 尚无密钥。设置环境变量 {settings.env_key},"
@@ -576,6 +582,63 @@ def mcodex_init(ctx, key, key_stdin, inherit, home, real_codex):
click.echo(f"\n你的 ~/.codex 未被改动。现在可以直接用:mcodex exec \"...\"")
+@main.command()
+@click.option("--inherit/--no-inherit", default=True, show_default=True,
+ help="从 ~/.codex/config.toml 继承使用偏好,不含任何凭据")
+@click.option("--home", type=click.Path(path_type=Path), help="自定义 mcodex 家目录")
+@click.option("--real-codex", type=click.Path(path_type=Path), help="真 codex 的绝对路径")
+@click.option("--restart-daemon/--no-restart-daemon", default=True, show_default=True)
+@click.pass_context
+def enroll(ctx, inherit, home, real_codex, restart_daemon):
+ """一次性接入公司号池;后续凭证按 Multica 任务自动签发。"""
+ import subprocess
+
+ from .mcodex import DEFAULT_MCODEX_HOME, KEY_FILENAME, McodexError, init_home
+ from .multica import load_local_config
+
+ settings = _settings(ctx)
+ try:
+ config = load_local_config()
+ if not str(config.get("token", "")).strip():
+ raise MulticaError("本机 Multica 尚未登录")
+ spec = ProviderSpec(
+ provider_id=settings.provider_id,
+ base_url=settings.pool_base_url,
+ env_key=settings.env_key,
+ )
+ target = home or DEFAULT_MCODEX_HOME
+ info = init_home(
+ target,
+ spec,
+ inherit_from=Path.home() / ".codex" / "config.toml" if inherit else None,
+ real_codex=str(real_codex) if real_codex else None,
+ )
+ # Remove legacy permanent credentials so task-bound exchange is the
+ # only remaining employee path.
+ legacy_key = target / KEY_FILENAME
+ if legacy_key.exists():
+ legacy_key.unlink()
+ except (McodexError, MulticaError, OSError, ValueError) as exc:
+ _fail(str(exc))
+ return
+
+ click.secho("✓ 已接入公司号池", fg="green")
+ click.echo(f" Multica:{config.get('server_url', '')}")
+ click.echo(f" mcodex:{info['config']}")
+ click.echo(" 凭证:任务启动时自动申请,任务外无法使用")
+
+ if not restart_daemon:
+ click.echo("\n下一步:poolctl daemon restart")
+ return
+
+ env = _daemon_env(settings)
+ subprocess.run(["multica", "daemon", "stop"], env=env, check=False)
+ result = subprocess.run(["multica", "daemon", "start"], env=env, check=False)
+ if result.returncode != 0:
+ _fail("Multica daemon 启动失败", "运行 poolctl daemon status 查看状态")
+ click.secho("✓ Multica daemon 已使用 mcodex 启动", fg="green")
+
+
@main.group()
def daemon():
"""带号池配置启停 multica daemon。
diff --git a/corp_codex_pool/config.py b/corp_codex_pool/config.py
index 32fa895..4aeee0a 100644
--- a/corp_codex_pool/config.py
+++ b/corp_codex_pool/config.py
@@ -29,6 +29,7 @@ class Settings:
pool_admin_token: str = ""
pool_admin_password: str = ""
pool_base_url: str = "https://codex.chekkk.com/v1"
+ pool_session_url: str = "https://codex.chekkk.com/api/self-service/session-key"
multica_server_url: str = "https://api.multica.ai"
multica_token: str = ""
provider_id: str = "gw"
@@ -46,6 +47,7 @@ def get(name: str, default: str) -> str:
pool_admin_token=get("POOL_ADMIN_TOKEN", ""),
pool_admin_password=get("POOL_ADMIN_PASSWORD", ""),
pool_base_url=get("POOL_BASE_URL", cls.pool_base_url).rstrip("/"),
+ pool_session_url=get("POOL_SESSION_URL", cls.pool_session_url).rstrip("/"),
multica_server_url=get("MULTICA_SERVER_URL", cls.multica_server_url).rstrip("/"),
multica_token=get("MULTICA_TOKEN", ""),
provider_id=get("POOL_PROVIDER_ID", cls.provider_id),
diff --git a/corp_codex_pool/doctor.py b/corp_codex_pool/doctor.py
index a3bf9b1..d0ad54b 100644
--- a/corp_codex_pool/doctor.py
+++ b/corp_codex_pool/doctor.py
@@ -101,7 +101,7 @@ def detect_mode(config_path: Path | None = None) -> tuple[str, Path]:
return "宿主注入", default_config_path()
-def check_mcodex_home(env_key: str) -> list[Check]:
+def check_mcodex_home(env_key: str, session_url: str | None = None) -> list[Check]:
"""mcodex 模式特有的检查:密钥来源与 codex 路径固化。"""
import os
@@ -115,7 +115,12 @@ def check_mcodex_home(env_key: str) -> list[Check]:
has_key_file = (home / KEY_FILENAME).exists()
has_key_env = bool(os.environ.get(env_key))
- if has_key_file or has_key_env:
+ if session_url:
+ detail = f"任务启动时从 {session_url} 自动签发;不写磁盘"
+ if has_key_file or has_key_env:
+ detail += "(检测到旧静态密钥,但任务模式会忽略)"
+ checks.append(Check("mcodex 任务绑定凭证", OK, detail))
+ elif has_key_file or has_key_env:
source = []
if has_key_env:
source.append(f"环境变量 {env_key}")
diff --git a/corp_codex_pool/mcodex.py b/corp_codex_pool/mcodex.py
index 91bf58f..76539a1 100644
--- a/corp_codex_pool/mcodex.py
+++ b/corp_codex_pool/mcodex.py
@@ -24,6 +24,8 @@
import sys
from pathlib import Path
+import httpx
+
from .codex_config import ProviderSpec, inject
#: mcodex 自己的家目录,与用户的 ~/.codex 完全隔离
@@ -47,6 +49,13 @@
"plan_mode_reasoning_effort",
)
+TASK_CONTEXT_ENV = (
+ "MULTICA_TOKEN",
+ "MULTICA_TASK_ID",
+ "MULTICA_AGENT_ID",
+ "MULTICA_WORKSPACE_ID",
+)
+
class McodexError(RuntimeError):
pass
@@ -132,6 +141,61 @@ def read_key(home: Path) -> str | None:
return None
+def request_session_key(
+ session_url: str,
+ *,
+ client: httpx.Client | None = None,
+) -> str:
+ """Exchange the current Multica task token for a task-bound pool key."""
+ context = {name: os.environ.get(name, "").strip() for name in TASK_CONTEXT_ENV}
+ missing = [name for name, value in context.items() if not value]
+ if missing:
+ raise McodexError(
+ "公司号池只能从 Multica 任务启动;缺少任务上下文:"
+ + ", ".join(missing)
+ )
+ if not context["MULTICA_TOKEN"].startswith("mat_"):
+ raise McodexError("Multica 未提供任务级凭据(应为 mat_ 开头),拒绝申请号池密钥")
+
+ owns_client = client is None
+ http = client or httpx.Client(timeout=30.0, follow_redirects=False)
+ try:
+ response = http.post(
+ session_url,
+ headers={
+ "Authorization": f"Bearer {context['MULTICA_TOKEN']}",
+ "Accept": "application/json",
+ },
+ json={
+ "task_id": context["MULTICA_TASK_ID"],
+ "agent_id": context["MULTICA_AGENT_ID"],
+ "workspace_id": context["MULTICA_WORKSPACE_ID"],
+ },
+ )
+ except httpx.HTTPError as exc:
+ raise McodexError(f"无法连接公司号池凭证服务:{exc}") from exc
+ finally:
+ if owns_client:
+ http.close()
+
+ if response.status_code != 200:
+ try:
+ message = response.json().get("error", "")
+ except ValueError:
+ message = ""
+ detail = f":{message}" if message else ""
+ raise McodexError(f"公司号池拒绝签发临时凭证(HTTP {response.status_code}){detail}")
+
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ raise McodexError("公司号池返回了无效响应") from exc
+ key = payload.get("key") if isinstance(payload, dict) else None
+ if not isinstance(key, str) or not key.startswith("mcx_"):
+ raise McodexError("公司号池响应缺少任务绑定凭证")
+ return key
+
+
def init_home(
home: Path,
spec: ProviderSpec,
@@ -198,7 +262,12 @@ def init_home(
}
-def prepare_env(spec: ProviderSpec) -> dict[str, str]:
+def prepare_env(
+ spec: ProviderSpec,
+ session_url: str | None = None,
+ *,
+ client: httpx.Client | None = None,
+) -> dict[str, str]:
"""算出 exec 前要设置的环境变量,并按需注入 provider 配置。
返回要追加/覆盖的环境变量。不直接改 os.environ,便于测试。
@@ -206,6 +275,9 @@ def prepare_env(spec: ProviderSpec) -> dict[str, str]:
updates: dict[str, str] = {}
daemon_home = os.environ.get("CODEX_HOME")
+ if session_url and not daemon_home:
+ raise McodexError("公司号池只允许在 Multica 任务中使用,请从 Multica 创建任务")
+
if daemon_home:
# multica 场景:daemon 已把 CODEX_HOME 指向 per-task 目录并拷好配置。
# 不能改 CODEX_HOME(daemon 的 isBlockedEnvKey 也不允许用户覆盖),
@@ -223,6 +295,12 @@ def prepare_env(spec: ProviderSpec) -> dict[str, str]:
)
updates["CODEX_HOME"] = str(home_for_key)
+ if session_url:
+ # Always override a stale/static key in task mode. The returned value is
+ # encrypted and bound to this task; it is never written to disk.
+ updates[spec.env_key] = request_session_key(session_url, client=client)
+ return updates
+
if not os.environ.get(spec.env_key):
key = read_key(home_for_key)
if not key:
@@ -249,7 +327,7 @@ def main(argv: list[str] | None = None) -> int:
)
try:
- updates = prepare_env(spec)
+ updates = prepare_env(spec, settings.pool_session_url)
real_codex = find_real_codex(mcodex_home())
except McodexError as exc:
print(f"mcodex: {exc}", file=sys.stderr)
diff --git "a/docs/\345\221\230\345\267\245\351\241\273\347\237\245.md" "b/docs/\345\221\230\345\267\245\351\241\273\347\237\245.md"
index 5b85815..3cba926 100644
--- "a/docs/\345\221\230\345\267\245\351\241\273\347\237\245.md"
+++ "b/docs/\345\221\230\345\267\245\351\241\273\347\237\245.md"
@@ -1,89 +1,73 @@
-# Codex 号池使用须知
+# Codex 公司号池使用须知
-公司为 AI 编码工具统一采购了 Codex 订阅,通过内部网关分发。你不需要自己的 Codex 账号,也不需要登录。
+公司为 AI 编码任务统一提供 Codex 额度。正式入口是 Multica:员工不需要 Codex 账号,也不会收到或保存永久 API Key。
---
-## 一、怎么开始
+## 一、首次接入
-管理员会给你一把密钥(形如 `sk-clb-…`)。在你的开发机上执行一次:
+本机先安装 Codex CLI、Multica CLI 和 `corp-codex-pool`,然后执行:
```bash
-poolctl mcodex init --key-stdin
-# 粘贴密钥后回车,再按 Ctrl-D
+multica login
+poolctl enroll
```
-之后把平时敲的 `codex` 换成 `mcodex`:
-
-```bash
-mcodex # 交互式
-mcodex exec "重构这个模块" # 一次性
-```
-
-**你原来的 `codex` 命令不受影响**,仍然走你自己的账号。两者可以并存:`codex` 用个人订阅,`mcodex` 用公司额度。
-
-首次初始化会从你的 `~/.codex/config.toml` 继承使用偏好(模型、推理强度、已信任目录),所以手感和平时一致。不会复制任何登录凭据。
+`poolctl enroll` 会初始化独立的 `~/.mcodex`,清理旧的永久 Key,并让 Multica daemon 使用 `mcodex`。它不会修改你的 `~/.codex`,个人 `codex` 命令仍走你自己的账号。
---
-## 二、遇到报错怎么看
+## 二、日常使用
-### 「exceeded retry limit, last status: 429」
+1. 打开 `https://multica.chekkk.com`,使用飞连一键登录。
+2. 在 Multica 创建或继续一个 Codex 任务。
+3. 在 Multica 的任务窗口查看对话、工具执行和结果。
-```
-ERROR: exceeded retry limit, last status: 429 Too Many Requests
-```
-
-**这不是网络故障,是你的额度用完了。**
+不要直接在终端运行 `mcodex`。正式网关只接受 Multica daemon 注入的 `mat_` 任务票据;缺少任务票据、Agent、任务或 Workspace 任一上下文都会被拒绝。
-这条文案来自 codex 客户端,它没能识别出网关返回的额度信息,所以显示得比较误导。看到它就找管理员,或等下一个额度周期。
-
-额度按周重置。可以问管理员你还剩多少。
+---
-### 「does not have access to model 'xxx'」
+## 三、凭证与审计
-```
-HTTP 403 model_not_allowed
-```
+- 每个任务启动时,`mcodex` 自动换取约两小时有效的 `mcx_` 任务凭证。
+- 凭证绑定 Multica 用户、Agent、任务和 Workspace,不匹配即返回 401。
+- 员工拿不到原始 `sk-clb` 网关 Key,临时凭证也不会写入磁盘或 Agent `custom_env`。
+- Multica 保存任务对话;网关保存模型、token、成本、延迟以及对应用户/任务短 ID。
-你的密钥有模型白名单,请求的模型不在里面。换成允许的模型,或找管理员放开。
+这能确保公司额度的正常使用都有明确人员和任务记录。它不能判断工作内容是否“有价值”,但脱离 Multica 的普通 API 调用无法通过正式入口。
-**注意**:撞到这个错误时 codex 会自动重试 6 次,所以别反复手动重试,先把模型改对。
+---
-### 「Invalid API key」
+## 四、常见问题
-密钥已被吊销或输错了。找管理员重新签发。
+### 提示“公司号池只能从 Multica 任务启动”
-### 请求很久没反应
+当前命令不是由 Multica daemon 拉起。回到 Multica 创建任务,不要手动运行 `mcodex`。
-上游偶尔会慢,实测同一时段不同模型差异很大(某些模型要 3~7 分钟)。先耐心等,若持续如此,换个模型试试,或反馈给管理员。
+### 提示“Multica task binding rejected”
----
+任务票据已失效或上下文不匹配。重新创建/重试任务;持续出现时运行 `poolctl daemon status` 并联系管理员。
-## 三、几件需要知道的事
+### 返回 429
-**用量是记录在案的。** 网关按密钥记录每次请求的模型、token 数和成本。管理员能看到"谁用了多少",用于成本归集和容量规划。
+当前任务额度或全池容量已用完。等待额度恢复,或联系管理员查看用量。
-**不记录你的代码和对话内容。** 归集只用结构化信号(请求数、token 数、模型、时间),不涉及你写了什么。
+### daemon 没有使用 mcodex
-**密钥别外传。** 它是你的额度凭证,泄露后别人会消耗你的额度。发现异常立刻找管理员吊销——吊销是即时生效的。
+```bash
+poolctl daemon status
+poolctl daemon restart
+```
-**密钥在你机器上是明文存放的**(`~/.mcodex/key`,权限 600)。跑在你机器上的 agent 理论上读得到。这是当前架构的已知限制,密钥仅限号池、可随时吊销。
+不要用 `multica daemon restart` 代替第二条命令,它可能丢失 `MULTICA_CODEX_PATH`。
---
-## 四、常用命令
+## 五、自检
```bash
-mcodex exec "..." # 跑一次
-poolctl doctor # 自检,出问题先跑这个
+poolctl doctor
+poolctl daemon status
```
-`poolctl doctor` 会逐项检查网关连通性、配置正确性、密钥有效性,并对每个失败项给出下一步。把它的完整输出发给管理员,比描述"用不了"有效得多。
-
----
-
-## 五、找谁
-
-- 密钥、额度、模型白名单 → 管理员
-- `poolctl doctor` 全绿但仍然用不了 → 带上完整输出找管理员
+反馈问题时附上这两条命令的完整输出;输出不会包含任务票据或原始网关 Key。
diff --git a/pyproject.toml b/pyproject.toml
index 5087ad9..24507af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "corp-codex-pool"
-version = "0.1.0"
+version = "0.2.0"
description = "Codex 号池与 multica 运行时的集成层:provider 注入、密钥下发、用量对账"
requires-python = ">=3.11"
dependencies = [
diff --git a/tests/test_mcodex.py b/tests/test_mcodex.py
index 6e189e9..835ca18 100644
--- a/tests/test_mcodex.py
+++ b/tests/test_mcodex.py
@@ -3,6 +3,7 @@
import tomllib
import pytest
+import httpx
from corp_codex_pool.codex_config import ProviderSpec
from corp_codex_pool.mcodex import (
@@ -13,6 +14,7 @@
mcodex_home,
prepare_env,
read_key,
+ request_session_key,
)
HOST_CONFIG = '''\
@@ -38,7 +40,16 @@ def spec():
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
"""每个用例都从干净环境开始,避免真实环境泄漏进测试。"""
- for name in ("CODEX_HOME", "GW_API_KEY", "MCODEX_HOME", "MCODEX_REAL_CODEX"):
+ for name in (
+ "CODEX_HOME",
+ "GW_API_KEY",
+ "MCODEX_HOME",
+ "MCODEX_REAL_CODEX",
+ "MULTICA_TOKEN",
+ "MULTICA_TASK_ID",
+ "MULTICA_AGENT_ID",
+ "MULTICA_WORKSPACE_ID",
+ ):
monkeypatch.delenv(name, raising=False)
@@ -203,6 +214,69 @@ def test_missing_key_errors_with_guidance(self, tmp_path, monkeypatch, spec):
with pytest.raises(McodexError, match="poolctl issue"):
prepare_env(spec)
+ def test_task_mode_uses_bound_session_key(self, tmp_path, monkeypatch, spec):
+ task_home = tmp_path / "per-task"
+ task_home.mkdir()
+ (task_home / "config.toml").write_text("", encoding="utf-8")
+ monkeypatch.setenv("CODEX_HOME", str(task_home))
+ monkeypatch.setenv("GW_API_KEY", "legacy-static-key")
+ monkeypatch.setenv("MULTICA_TOKEN", "mat_task-secret")
+ monkeypatch.setenv("MULTICA_TASK_ID", "task-1")
+ monkeypatch.setenv("MULTICA_AGENT_ID", "agent-1")
+ monkeypatch.setenv("MULTICA_WORKSPACE_ID", "workspace-1")
+
+ def handler(request):
+ assert request.headers["Authorization"] == "Bearer mat_task-secret"
+ assert request.read() == (
+ b'{"task_id":"task-1","agent_id":"agent-1",'
+ b'"workspace_id":"workspace-1"}'
+ )
+ return httpx.Response(200, json={"key": "mcx_bound-secret"})
+
+ with httpx.Client(transport=httpx.MockTransport(handler)) as client:
+ updates = prepare_env(spec, "https://pool.example/session-key", client=client)
+
+ assert updates["GW_API_KEY"] == "mcx_bound-secret"
+ assert "CODEX_HOME" not in updates
+
+ def test_task_mode_rejects_standalone_use(self, tmp_path, monkeypatch, spec):
+ home = tmp_path / "h"
+ monkeypatch.setenv("MCODEX_HOME", str(home))
+
+ with pytest.raises(McodexError, match="只允许在 Multica 任务"):
+ prepare_env(spec, "https://pool.example/session-key")
+
+
+class TestRequestSessionKey:
+ def test_requires_complete_task_context(self):
+ with pytest.raises(McodexError, match="MULTICA_TOKEN"):
+ request_session_key("https://pool.example/session-key")
+
+ def test_rejects_personal_access_token(self, monkeypatch):
+ monkeypatch.setenv("MULTICA_TOKEN", "mul_personal")
+ monkeypatch.setenv("MULTICA_TASK_ID", "task-1")
+ monkeypatch.setenv("MULTICA_AGENT_ID", "agent-1")
+ monkeypatch.setenv("MULTICA_WORKSPACE_ID", "workspace-1")
+
+ with pytest.raises(McodexError, match="mat_ 开头"):
+ request_session_key("https://pool.example/session-key")
+
+ def test_redacts_server_response_details(self, monkeypatch):
+ monkeypatch.setenv("MULTICA_TOKEN", "mat_task-secret")
+ monkeypatch.setenv("MULTICA_TASK_ID", "task-1")
+ monkeypatch.setenv("MULTICA_AGENT_ID", "agent-1")
+ monkeypatch.setenv("MULTICA_WORKSPACE_ID", "workspace-1")
+
+ with httpx.Client(
+ transport=httpx.MockTransport(
+ lambda request: httpx.Response(403, json={"error": "task binding mismatch"})
+ )
+ ) as client:
+ with pytest.raises(McodexError, match="task binding mismatch") as caught:
+ request_session_key("https://pool.example/session-key", client=client)
+
+ assert "mat_task-secret" not in str(caught.value)
+
def _make_exe(path):
path.parent.mkdir(parents=True, exist_ok=True)
@@ -397,3 +471,17 @@ def test_no_checks_when_mcodex_unused(self, tmp_path, monkeypatch):
monkeypatch.setenv("MCODEX_HOME", str(tmp_path / "nope"))
assert check_mcodex_home("GW_API_KEY") == []
+
+ def test_task_bound_mode_does_not_require_static_key(self, tmp_path, monkeypatch, spec):
+ from corp_codex_pool.doctor import OK, check_mcodex_home
+
+ init_home(tmp_path, spec)
+ monkeypatch.setenv("MCODEX_HOME", str(tmp_path))
+
+ checks = check_mcodex_home(
+ "GW_API_KEY", "https://codex.chekkk.com/api/self-service/session-key"
+ )
+
+ credential = next(c for c in checks if c.name == "mcodex 任务绑定凭证")
+ assert credential.status == OK
+ assert "不写磁盘" in credential.detail