{"openapi":"3.0.3","info":{"title":"OKC Cloud API","version":"1.1.0","description":"授权 SRC 渗透测试控制台。**身份校验以 Bearer 令牌为主**：\n\n- `POST /api/login`（或 `/api/register`）返回 `token`（`token_type: Bearer`），之后每个请求都带 `Authorization: Bearer <token>`；也接受 `X-OKC-Key` 头。\n- 长期脚本用账号 **API Key**（`okcak_…`，同一头部，可撤销、可单独限额度）：由管理员创建后交给使用人。明文只显示一次。\n- Bearer 认证的请求**免 CSRF**（头部由调用方显式附加，别的源加不上）。\n- cookie 仍会下发并接受，但只服务浏览器 `EventSource` 的 SSE 通道。\n\n**开始测试前会校验余额**：先查账号积分额度，再查模型账户真实余额；任一不足直接 `402`（`quota_exhausted` / `key_quota_exhausted` / `no_balance`），不会白建沙箱。余额查询失败（网络问题）按“未知”放行，另有全局花费上限兜底。\n\n额度口径（账号，一律按**积分**；价目表 **1000 积分 = 1.5 元**）：`points_quota > 0` 是上限；**`0` 表示还没分配额度**（新注册账号的默认值，开不了测试，需管理员发放）；**负数表示不限**。管理员账号天然不限。API Key 的 `points_quota` 是另一回事：`0` = 跟随账号额度，正数 = 该 Key 自己的独立上限。响应里另有 `token_quota`/`tokens_used` 两个**历史列**，只作审计，判定与展示都不读它。\n\n计费注意：`input_tokens` 与 `cache_read_tokens` 是**两个不相交的量**（未命中/命中缓存），不是总量与子集；消耗的对外口径是**积分**：`usage.cost` 是积分数、`usage.currency` 是 `\"POINTS\"`。积分按**用量**计量——`积分 = token ÷ 1000 × 该模型费率`（费率**按模型**配置在部署的 `pricing.points_per_1k_tokens` 里，实时值请读 `GET /api/pricing` 的 `pricing.model_rate`——**不要**把某个模型的费率写死在客户端：换线路之后它就过期了）；账号字段 `cny_used` 是同一笔积分按价目表折成的人民币，**不是**平台真实花费（真实账单只在运维侧 `okcd.py balance`）。\n\n所有响应 `application/json; charset=utf-8`（SSE 与文件除外）。错误统一 `{\"error\": code, \"message\": ...}`。"},"servers":[{"url":"/"}],"security":[{"bearerKey":[]},{"sessionCookie":[]}],"paths":{"/api/login":{"post":{"summary":"登录：用户名+密码 → 返回 Bearer 令牌","security":[],"requestBody":{"content":{"application/json":{"examples":{"账号登录":{"value":{"username":"xjun","password":"…"}},"操作员密码（用户名留空）":{"value":{"password":"…"}}}}}},"responses":{"200":{"description":"{ok, token, token_type: Bearer, expires_in, user}"},"401":{"description":"invalid_credentials"},"429":{"description":"too_many_attempts（8 次/5 分钟）"}}}},"/api/logout":{"post":{"summary":"退出当前会话"}},"/api/register":{"post":{"summary":"注册账号（第一个账号成为管理员）","description":"平台**开启邮箱验证**时（`okc.py set-smtp` 之后），这里还要带 `email` 与 `code`——就是 `POST /api/register/send-code` 发出去的那 6 位码。验证码一次性、10 分钟过期、同一个码最多试 5 次；一个邮箱只能注册一个账号。","security":[],"requestBody":{"required":true,"content":{"application/json":{"example":{"username":"xjun","password":"至少8位","email":"you@example.com","code":"042317"}}}},"responses":{"201":{"description":"{ok, username, is_admin, user}；同时下发会话 cookie"},"400":{"description":"username_invalid / username_too_short / password_too_short / email_invalid / code_required / code_wrong / code_expired / code_none"},"403":{"description":"registration_closed"},"409":{"description":"username_taken / email_taken"},"429":{"description":"too_many_attempts（验证码试错过多）"},"503":{"description":"email_verify_unavailable / mail_failed（发信没配好或发不出去）"}}}},"/api/register/send-code":{"post":{"summary":"给注册用的邮箱发一个验证码","description":"公开接口（不需要登录）。限流三档：来源 IP 每小时、目标邮箱每小时、以及同一个邮箱的重发冷却。**答复不区分这个邮箱是否已注册**——否则它就成了一个免登录的账号查询器。邮件正文里只有验证码、有效期，以及「不是本人操作请忽略」。","security":[],"requestBody":{"required":true,"content":{"application/json":{"example":{"email":"you@example.com"}}}},"responses":{"200":{"description":"{ok, expires_in, resend_after}"},"400":{"description":"email_invalid / email_required / email_domain_not_allowed"},"429":{"description":"resend_too_soon / too_many_attempts"},"503":{"description":"email_verify_unavailable / mail_failed"}}}},"/api/password/send-code":{"post":{"summary":"给忘记口令的账号发一个重置验证码","description":"公开接口（不需要登录）。与 `/api/register/send-code` 同一条防线（来源 IP 每小时 / 目标邮箱每小时 / 重发冷却三档限流），但**只给已经有账号且账号活着的邮箱发信**；答复形状与注册那条逐字相同，存在与否都不变（不做出账号查询器）。邮件主题写明「重置登录口令」，并说清没有这个码谁也改不了口令。","security":[],"requestBody":{"required":true,"content":{"application/json":{"example":{"email":"you@example.com"}}}},"responses":{"200":{"description":"{ok, expires_in, resend_after}"},"400":{"description":"email_invalid / email_required"},"429":{"description":"resend_too_soon / too_many_attempts"},"503":{"description":"email_verify_unavailable / mail_failed"}}}},"/api/password/reset":{"post":{"summary":"用邮件验证码重置口令（顺带下线该账号的所有会话）","description":"公开接口（不需要登录）。验证码按**用途**隔离：注册码不能拿来改口令。改口令与下线会话是**一笔事务**——要求重置的场景几乎总是「旧口令可能已经在别人手里」，只改口令不踢会话等于没把账号拿回来；答复里的`signed_out` 就是踢掉了几个会话。**不动 API Key**（那是另一套凭证，有自己的撤销入口）。邮箱没有账号时的答复与「没有待用的验证码」同形。","security":[],"requestBody":{"required":true,"content":{"application/json":{"example":{"email":"you@example.com","code":"123456","password":"至少 8 位"}}}},"responses":{"200":{"description":"{ok, username, signed_out, message}"},"400":{"description":"code_required / code_none / code_wrong / code_expired / password_too_short / email_invalid"},"429":{"description":"code_too_many（同一邮箱试错次数用尽）"}}}},"/api/me":{"get":{"summary":"当前账号：额度、用量与自己的密钥","description":"个人中心的引导接口。**不含**平台侧余额（那是部署的内部读数）；开测前的余额校验照做，不足返回 402 `no_balance`。（`?refresh_balance=1` 参数保留，已忽略。）","responses":{"200":{"description":"{username, is_admin, user, keys, pricing}"}}}},"/api/keys":{"get":{"summary":"我的 API Key 列表","description":"只返回当前账号自己的密钥；每把都带 `is_default`、前缀、名称与用量。","responses":{"200":{"description":"{keys: [ApiKey]}"}}},"post":{"summary":"创建一把 API Key（个人中心自助，随机生成、自己命名）","description":"明文**只在这一次响应里**出现。新 Key 不设独立预算（`points_quota: 0`）——花的就是账号的额度，也就是「Key 的余额等于所属账号的余额」。每个账号最多 5 把有效密钥；账号还没有默认 Key 时，新建的这把自动成为默认。只接受浏览器会话 + CSRF（被偷的 Key 不能再签 Key）。","requestBody":{"content":{"application/json":{"example":{"label":"我的脚本"}}}},"responses":{"201":{"description":"{ok, secret, key}"},"409":{"description":"too_many_keys / account_required"},"403":{"description":"session_required / csrf 失败"}}}},"/api/keys/{id}/revoke":{"post":{"summary":"撤销自己的一把 API Key","description":"只能撤自己的（别人的返回 404）。撤掉的若是默认 Key，默认顺位自动给剩下最新的一把。","responses":{"200":{"description":"{ok, keys}"},"404":{"description":"key_not_found"}}}},"/api/keys/{id}/default":{"post":{"summary":"把自己的一把 API Key 设为默认","description":"示例、脚本模板都指着默认那把；一账号一把。","responses":{"200":{"description":"{ok, keys}"},"404":{"description":"key_not_found"}}}},"/api/keys/claim":{"post":{"summary":"兑换码充值：把管理员发的兑换码兑到自己的账号上","description":"管理员生成一个**待领取**的兑换码并交给使用者；使用者在个人中心粘贴即可把它绑定到自己的账号，额度随之生效（同时成为账号的一把 Key，账号没有默认 Key 时它顶上）。领取前该兑换码无法用于认证。重复领取自己的是幂等成功。","requestBody":{"required":true,"content":{"application/json":{"example":{"key":"okcak_..."}}}},"responses":{"200":{"description":"{ok, already_yours, key}"},"400":{"description":"key_required"},"401":{"description":"未认证"},"404":{"description":"key_not_found —— 密钥不存在（多半没复制全）"},"409":{"description":"key_taken（已被别人领取）/ key_revoked（已被撤销）/ account_required"},"429":{"description":"too_many_attempts"}}}},"/api/ledger":{"get":{"summary":"额度与密钥的变更记录（个人中心「变更记录」）","description":"逐条记录额度的发放/调整、密钥的发放/领取/撤销、以及每次测试的消耗。普通账号只看得到自己的；管理员加 `?all=1` 看全平台。每行自带当时快照（quota_after / used_after），不必重放历史。","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50}},{"name":"all","in":"query","schema":{"type":"boolean"},"description":"仅管理员有效"}],"responses":{"200":{"description":"{entries: [LedgerEntry], scope}"}}}},"/api/pricing":{"get":{"summary":"价目表与购买档位（前端统一取价的地方）","description":"登录即可读。返回 `pricing`（积分单价、按模型的积分费率、峰谷费率）与 `buy`（店铺分档链接与实售价格）。侧栏「购买积分」、个人中心、任何要换算积分/token/金额的界面都从这一份取数——**价格只有一个出处**。","responses":{"200":{"description":"{pricing, buy}"}}},"post":{"summary":"统一改价（仅管理员）","description":"部分更新：只改提交了的字段，没提交的保持原值。写回 config.json 并**立即生效**（重载价目表，不重启守护进程）。正在跑的任务按各自计费时点的费率入账，改价只影响之后的新用量。`points_per_1k_tokens` 是**合并**语义：只覆盖写到的模型，没写的模型保持原费率；某模型给 0 表示删掉该模型的专属费率（回落到默认费率）。示例里的数值只是形式，实时值以 `GET /api/pricing` 为准。","requestBody":{"content":{"application/json":{"example":{"cny_per_1000_points":1.5,"points_per_1k_tokens":{"cn:glm-5.3-flash":0.3135333},"buy_links":{"10000":"https://www.kufaka.com/item/xaoc80"},"buy_prices":{"10000":15}}}}},"responses":{"200":{"description":"{ok, pricing, buy} —— 改完后的生效值"},"400":{"description":"bad_value（数值为负/链接协议不合法等）"},"403":{"description":"admin_only / csrf"}}}},"/api/session":{"get":{"summary":"当前认证状态与设置","security":[],"description":"未登录时返回 `{authenticated: false, registration_open, currency, capabilities}`。登录后多出 `via`/`username`/`user`/`is_admin`。`settings` 与 `counts` **只对管理员下发**（后者是**全平台**任务量）；普通账号拿到的是公开视图 `public_view()`——绝对路径、沙箱账号名、内网模型网关地址、代理控制端口与全平台计数都属于部署内部读数，与 `/api/health` 同一道门。","responses":{"200":{"description":"{authenticated, via?, operator?, settings?, counts?, capabilities: {skills, tools, mcp}} — capabilities 只报数量，不含任何技能内容；settings/counts 仅管理员"}}}},"/api/openapi.json":{"get":{"summary":"本文档（无需认证）","security":[]}},"/api/tasks":{"get":{"summary":"任务列表（登录或 Key）","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":200}},{"name":"before","in":"query","schema":{"type":"number"},"description":"分页游标：只返回 created_at 早于该时间戳的任务"},{"name":"state","in":"query","schema":{"type":"string"},"description":"按状态筛选，逗号分隔或重复出现均可。可用：queued, starting, running, stopping, done, failed, aborted, interrupted。写错返回 400 bad_state，不是空列表","example":"done,failed"}],"responses":{"200":{"description":"{tasks: [TaskSummary], counts, total, filter, has_more, limit, scope, running}"},"400":{"description":"bad_state"}}},"post":{"summary":"创建狩猎任务","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCreate"},"example":{"target":"example.com","authorization":"XX SRC / 项目 12345","cookies":"sessionid=abc123"}}}},"responses":{"201":{"description":"{task: TaskSummary, prompt_preview, prompt_sha256}"},"400":{"description":"target_invalid / authorization_required"},"402":{"description":"quota_exhausted / key_quota_exhausted —— 额度用完"},"429":{"description":"排队过多"}}}},"/api/tasks/{id}":{"get":{"summary":"任务详情（含完整提示词）","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"example":"t_7K3M9PQR2XW5YZAB8CDEFGHJKN"}],"responses":{"200":{"description":"{task, prompt}"},"404":{"description":"not_found"}}}},"/api/tasks/{id}/events":{"get":{"summary":"历史事件回放","parameters":[{"name":"after","in":"query","schema":{"type":"integer","default":0},"description":"返回 seq 大于该值的事件"}],"responses":{"200":{"description":"{task, events: [Event], last_seq}"}}}},"/api/tasks/{id}/stream":{"get":{"summary":"实时事件流（SSE）","description":"事件名：progress（Event）/ task（TaskSummary）/ ping / end。断线重连带 Last-Event-ID 头即可无损续传。浏览器 EventSource 无法带 Authorization，走会话 cookie；程序侧可用 `curl -N -H 'Authorization: Bearer okcak_...'`。","responses":{"200":{"description":"text/event-stream"}}}},"/api/overview/stream":{"get":{"summary":"看板事件流（SSE，事件名 task/ping）"}},"/api/tasks/{id}/artifacts":{"get":{"summary":"产出文件索引","responses":{"200":{"description":"{files: [{path,size,kind}], sandbox: bool, archived: bool}——archived=true 表示沙箱已删、列表来自删除前保存的交付物归档"}}}},"/api/tasks/{id}/artifact":{"get":{"summary":"读取一个产出文件（纯文本）","parameters":[{"name":"path","in":"query","required":true,"schema":{"type":"string"},"example":"report/report.md"}],"responses":{"200":{"description":"text/markdown"},"404":{"description":"not_found"}}}},"/api/tasks/{id}/file":{"get":{"summary":"预览或下载一个产出文件（含 PDF 等二进制）","parameters":[{"name":"path","in":"query","required":true,"schema":{"type":"string"},"example":"report/submissions/01-高危-越权.pdf"},{"name":"download","in":"query","required":false,"schema":{"type":"string","enum":["1","0"]},"description":"1 = 强制下载；省略时 PDF/图片在线预览，其余下载"}],"responses":{"200":{"description":"文件本体"},"404":{"description":"not_found"}}}},"/api/tasks/{id}/archive":{"get":{"summary":"打包下载交付物（report/ 与评估结论，zip）","responses":{"200":{"description":"application/zip，内含 report/… 结构"},"404":{"description":"not_found / no_deliverables（还没有交付文件）"},"413":{"description":"too_large（超过打包上限）"}}}},"/api/tasks/{id}/stop":{"post":{"summary":"停止任务（含排队中的）","description":"默认停下之后平台仍会进入报告阶段（定级 + 出交付文件），那一段照常计费。请求体带 `{\"review\": false}` 则是「停下，不出报告」：测试到此为止，报告阶段整个跳过——线上实测报告阶段的开销可达测试阶段的四倍（918,543 token / 357 积分里 288 积分花在停下之后），所以要真省钱只有这一档。两种选择都不影响磁盘上的证据与笔记，之后仍可用 `/resume` 接着跑。返回 `review` 说明这次走的是哪一档。","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"review":{"type":"boolean","description":"false = 停下后不进入报告阶段（默认 true）"}}}}}},"responses":{"200":{"description":"{ok, state: stopping, review}"},"409":{"description":"已结束"}}}},"/api/tasks/{id}/resume":{"post":{"summary":"继续/重新开始：沙箱还在就接着上次继续，否则用原参数新起一个任务","description":"接受已结束（done/failed/aborted/interrupted）的任务。沙箱里还留着结论或报告时，**同一条任务重新排队续测**（同一个 id、同一份沙箱、同一份证据），平台会从对话记录与 notes 生成交接说明；沙箱已被清理时退化为旧行为：用原参数提交一个新任务。返回 `mode`（`resume` 或 `new_task`）说明走的是哪条路。余额/额度不足时返回 402。","responses":{"201":{"description":"{ok, mode, task, resumed_from}"},"402":{"description":"quota_exhausted / key_quota_exhausted / no_balance"},"409":{"description":"task_still_live / task_not_resumable"}}}},"/api/tasks/{id}/wrapup":{"post":{"summary":"停下这轮测试并立刻下发报告评审与输出指令","description":"与 `/stop` 的区别只有一件事：评审阶段**必定执行**（停在这里就是为了要报告），不再看自动的素材检查；已经结束但沙箱还在的任务则当场在后台补跑一次评审。返回 `mode`：`stopping`（在跑，先停再评）、`cancelled`（还在排队，没有产出）、`reviewing`（已结束，正在后台评审）。命令行等价：`okcd.py review <任务ID>`。（控制台**没有**引用这个接口的按钮：早先那个「直接输出报告」按钮已按操作者要求下线，所以这里的说明只讲接口语义，不再指某个 UI 位置。）","responses":{"200":{"description":"{ok, mode, message, task}"},"409":{"description":"not_found / task_still_live（状态异常）/ task_not_resumable / task_no_material（沙箱里没有可评审的产出）/ task_in_audit（停在开测前的审核阶段，没有测试产出）"}}}},"/api/settings":{"get":{"summary":"设置、费率与在跑数量（登录或 Key）"}},"/api/announcements":{"get":{"summary":"运营公告（无需认证，登录页也要看得到）","description":"只返回**未过期**的公告，最新的在前，最多 5 条。公告由管理员发布；读接口公开，是因为维护通知在登录之前就该看得见。","responses":{"200":{"description":"{announcements: [Announcement], count}"}}}},"/api/health":{"get":{"summary":"平台自检（仅管理员）","description":"部署的运维自查：组件就绪逐项状态、磁盘与任务目录占用、在跑/排队任务数。内容包含绝对路径与沙箱账号，因此只对管理员开放。","responses":{"200":{"description":"{ready,total,ok,checks:[{label,ok,hint,group}],vitals}"},"403":{"description":"admin_only"}}}},"/api/stats":{"get":{"summary":"用量统计：今日、区间、累计与逐日曲线","description":"给个人中心的用量面板用。范围与身份一致：普通账号只看自己的狩猎，管理员看全盘。`tz` 是客户端时区（UTC 以东的秒数），逐日分桶按它切，否则北京时间早上 8 点前跑的测试会被算到前一天。","parameters":[{"name":"days","in":"query","schema":{"type":"integer","default":14},"description":"曲线天数，1–90"},{"name":"tz","in":"query","schema":{"type":"integer","default":0},"description":"时区偏移（秒，UTC 以东为正），限制在 ±14 小时内"}],"responses":{"200":{"description":"{scope,currency,tz_offset,days,today,range,totals,daily}"}}}}},"components":{"securitySchemes":{"bearerKey":{"type":"http","scheme":"bearer","description":"API Key：`okcak_...`，由管理员生成。"},"sessionCookie":{"type":"apiKey","in":"cookie","name":"okc_session"}},"schemas":{"TaskCreate":{"type":"object","required":["target","authorization"],"description":"**操作者一共只能提供三样东西：目标、Cookie、授权依据。**这些东西在**提交时一次性采集**，之后平台不再接受任何指令输入——没有「向运行中的 agent 追加要求」的接口，这是刻意的：授权边界必须在开始测试之前说清楚，而不是测到一半再补。","properties":{"target":{"type":"string","example":"example.com","description":"裸域名即可，服务端补 https://"},"authorization":{"type":"string","example":"XX SRC / 项目 12345","description":"授权依据，必填，随指令留档"},"cookies":{"type":"string","example":"sessionid=abc123; csrftoken=xyz","description":"操作者自己的登录会话（`name=value; name2=value2`），用来到达需要登录才能访问的页面。只进指令与任务行，不出现在任何接口响应或交付文件里。"},"scope":{"type":"string","description":"留空取目标主机名"},"operator":{"type":"string"},"label":{"type":"string"},"effort":{"type":"string","enum":["low","high","max"]},"max_seconds":{"type":"integer"}}},"TaskSummary":{"type":"object","properties":{"id":{"type":"string","description":"t_ + 26 位 Crockford Base32（128 位 CSPRNG）"},"state":{"type":"string","enum":["queued","starting","running","stopping","done","failed","aborted","interrupted"]},"usage":{"$ref":"#/components/schemas/Usage"},"progress":{"type":"object","properties":{"turns":{"type":"integer"},"steps":{"type":"integer"},"tool_calls":{"type":"integer"},"last_seq":{"type":"integer"}}}}},"Usage":{"type":"object","properties":{"input_tokens":{"type":"integer","description":"未命中缓存的输入（按未命中价计费）"},"cache_read_tokens":{"type":"integer","description":"命中缓存的前缀（便宜约 50 倍）"},"output_tokens":{"type":"integer"},"prompt_tokens":{"type":"integer","description":"input + cache_read"},"total_tokens":{"type":"integer"},"cost":{"type":"integer","description":"积分消耗（= token ÷ 1000 × 该模型费率；价目表 1000 积分 = 1.5 元）—— 展示用这个"},"currency":{"type":"string","example":"POINTS"}}},"Event":{"type":"object","properties":{"seq":{"type":"integer"},"ts":{"type":"number"},"kind":{"type":"string","description":"round.start | task.state | sandbox.ready | agent.ready | step.start | assistant.text | tool.call | tool.result | usage | operator.message | notice | error | task.finished"},"data":{"type":"object","description":"工具调用与结果只带「调用了什么 + 成功与否」，原始参数与工具输出不会出现在事件流里（要读原文请到任务沙箱的 logs/runtime.jsonl）"}}}}}}