cloud: fill gaps (memory/KB/cost/models endpoints, WAL concurrency, OAuth scaffold, push subscriptions)

This commit is contained in:
host committed 2026-10-26 01:32:57 +08:00
1 parent b74d99e959
commit 076ba01c74
11 files changed
+2831 -28

No files matched your search

+64 -26
View File
@@ -12,27 +12,16 @@ webhook)。零第三方依赖,只用 Python 标准库。
```
客户端 / GitHub / Slack
│ HTTP + X-API-Key(webhook 靠签名,见下)
│ HTTP + X-API-Key(webhook 靠签名,OAuth 靠 state,见下)
▼
cloud/server.py(控制面,ThreadingHTTPServer)
├─ 项目 / 任务:建、查、取消、心跳、stale 回收
├─ 项目 / 任务:建、查、取消、心跳、stale 回收、任务成本
├─ 记忆 / 知识库 / 成本 / 模型:已接成 HTTP 端点(见 API 表)
├─ OAuth:cloud/oauth.py(state + 授权 URL + 回调,脚手架)
├─ 推送订阅与分发:cloud/push.py(订阅表 + 偏好过滤)
├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore)
├─ 并发:cloud/db.py(WAL + busy_timeout + 有限重试)
└─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行)
│ (后续由 worker 接线)
▼
worker(尚未接成 HTTP 端点,本版需另行调用模块)
├─ 派发顺序:cloud/queue.py(order_tasks / ready_tasks /
│ budget_exceeded / TaskQueue;状态机与 host.py 对齐)
├─ 执行:cloud/sandbox.py(LocalExecutor 调 host.py CLI 做只读
│ 封装;DockerExecutor / CloudExecutor 仍是占位,未接通)
├─ 记忆:cloud/memory.py(MemoryStore:任务摘要,供 prompt 上下文)
├─ 知识库:cloud/kb.py(KBStore:按项目隔离的文档检索)
├─ 模型路由:cloud/models.py(ModelRouter / record_fallback:
│ 只决定 fallback,不跑模型回合)
├─ 成本:cloud/cost.py(CostStore / fetch_account_usage;
│ usage 来自调用方注入的 client,本模块不联网)
└─ 通知:cloud/webhooks.py(NotificationService:webhook + Slack
占位分发;Web Push / APNs / FCM 明确未接,需要证书才做)
```
状态库:单一 SQLite 文件。AuthStore 用 `users` / `project_members` /
@@ -82,10 +71,44 @@ user_id, api_key_plain = store.create_user("alice")
## API 列表
全部返回 JSON,`Content-Type: application/json`。除 `GET /health`
与 `/webhooks/*` 外,所有端点要求请求头 `X-API-Key`,无 key 或 key
错返回 `401 {"error": "unauthorized"}`。`/webhooks/*` 不走 X-API-Key,
因为外部服务无法持本站 API key,改靠各自的签名认证(见下)。
全部返回 JSON,`Content-Type: application/json`。除 `GET /health`、
`/webhooks/*`、`/oauth/*` 外,所有端点要求请求头 `X-API-Key`,无 key
或 key 错返回 `401 {"error": "unauthorized"}`。`/webhooks/*` 不走
X-API-Key,因为外部服务无法持本站 API key,改靠各自的签名认证。
`/oauth/*` 不走 X-API-Key,因为 OAuth 登录的起点就是用户还没有
本站 API key——它的认证靠随机 state(10 分钟过期、一次性)加
client_secret(只走环境变量),不是无认证后门。
### OAuth 接线步骤(脚手架,已实现,未接真实应用)
1. 注册真实 OAuth 应用(GitHub 或 Google),拿到 client_id,
回调地址填本站的 `/oauth/{provider}/callback` 公网地址。
2. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...` 拿
authorization_url,把用户送到 provider 授权页。
3. provider 回调本站并带回 code + state,调
`POST /oauth/{provider}/callback`,验 state → 换 token → 映射
本站用户并记审计。client_secret 只从环境变量读
(`GITHUB_OAUTH_CLIENT_SECRET` / `GOOGLE_OAUTH_CLIENT_SECRET`),
绝不写文件、绝不进 URL、换 token 前未配好就明确报错。
4. 仍缺:真实应用注册、回调公网地址、已有本站账号的绑定流程
(目前是确定性用户名新建/复用,不做账号绑定)。
### 推送凭证清单
- webhook / slack:需要可注入 sender(worker 侧),HTTP 层没有
sender 时明确报错,不静默成功。
- webpush:需要 VAPID 密钥对(私钥绝不进仓库)。
- apns:需要 APNs 证书。
- fcm:需要 FCM key。
以上三者目前只有接口 + 缺凭证报错,没有真实发送端。
### SQLite 并发上限
server 的连接统一走 `cloud/db.py`:WAL 模式、busy_timeout 5000ms、
写锁冲突有限重试 3 次(退避 0.05s/0.1s/0.2s,重试耗尽抛原始错误,
绝不无限循环)。多线程并发写测试已覆盖。但这只是单机缓解:
写仍是单写者串行,写密集或跨机器时必须换 Postgres,这是下一阶段,
不是本轮假装解决。
| 方法与路径 | 权限 | 说明 |
| --- | --- | --- |
@@ -98,6 +121,20 @@ user_id, api_key_plain = store.create_user("alice")
| `POST /tasks/{id}/cancel` | 该项目 `create_task` | `queued` / `running` 改为 `interrupted` 并记审计 `cancel_task`;已是终态(completed / failed / interrupted / merged / merge-failed)返回 `400`;不存在 `404` |
| `POST /tasks/{id}/heartbeat` | 该项目 `create_task` | 更新 `last_heartbeat = now`;状态为 `queued` 则改为 `running`;返回任务 |
| `POST /admin/sweep` | 认证即可 | 调 `sweep_stale_tasks(db_path, threshold_seconds=300)`(body 可传 `threshold_seconds` 覆盖默认值);把 `running` 且 `last_heartbeat`(无心跳则用 `updated_at`)早于 `now - threshold` 的任务改为 `interrupted` 并记审计 `sweep_interrupted`;返回 `{"swept": [ids]}` |
| `POST /tasks/{id}/cost` | 该任务所属项目 `read` | 成本聚合:`{task_id, project_id, total_tokens, usd_micros, turns, fallback_count, actual_tokens, estimated_tokens}`;真数与估算分开;不存在 `404`,越权 `403` |
| `POST /projects/{id}/memory` | 该项目 `create_task` | 写线程摘要 body `{thread_id?, goal, conclusion, files_changed?, open_issues?}`,返回 `201 {id}`,并记审计 `create_memory` |
| `GET /projects/{id}/memory?top_k=5` | 该项目 `read` | 最近 active 摘要(默认 top_k=5)+ `prompt_context`(只含 top_k,不全量注入);不存在 `404`,越权 `403` |
| `POST /projects/{id}/memory/{mid}/supersede` | 该项目 `create_task` | 把摘要标废弃(deprecated,不是物理删除),并记审计 `supersede_memory`;摘要不属于该项目则 `404` |
| `POST /projects/{id}/kb/index` | 该项目 `create_task` | 索引文档 body `{path, content, kind?}`,同项目同 path 重复索引会替换旧文档,返回 `201 {id}` |
| `GET /projects/{id}/kb/search?q=&top_k=5` | 该项目 `read` | FTS + LIKE 回退,仍按项目过滤,跨项目文档绝不返回;`q` 缺失 `400` |
| `GET /projects/{id}/cost` | 该项目 `read` | 项目成本总览 + 按天:`?day=YYYY-MM-DD` 查单天,`?days=N` 查最近 N 天;无参数只给总览 |
| `GET /models` | 认证即可 | 优先级列表(环境变量 `MODELS_PRIORITY` 逗号分隔,未设用默认)+ 只含调用者成员项目的模型统计与 fallback 事件(`fallback_count > 0` 的任务);不返回 prompt 内容 |
| `GET /oauth/{provider}/login?client_id=&redirect_uri=&scope=` | 无需 X-API-Key(pre-auth) | 返回 `{provider, state, authorization_url}`,只返回 URL 不跳转;provider 仅 github/google,未知 `400`;state 已入库,10 分钟过期 |
| `POST /oauth/{provider}/callback` | 无需 X-API-Key(pre-auth) | body `{code, state, client_id, redirect_uri}`;验 state → 换 token → 映射到本站用户(复用 users 表,用户名 `oauth:<provider>:<login>`)并记审计 `oauth_login`;state 无效/过期、换 token 失败、无 secret 一律 `400` 且不建会话;成功不返回 API key(发放方式由调用方另行决定) |
| `POST /projects/{id}/push/subscriptions` | 该项目 `create_task` | 记订阅 body `{channel, target, preference?}`,channel 为 webhook/slack/webpush/apns/fcm,preference 为 instant/digest/silent,返回 `201` |
| `GET /projects/{id}/push/subscriptions` | 该项目 `read` | 只列调用者自己的订阅(不泄露他人 target) |
| `DELETE /projects/{id}/push/subscriptions/{sid}` | 该项目 `create_task` | 只能删自己的订阅,项目 owner 可删任意;不存在 `404`,他人订阅 `403` |
| `POST /admin/push/dispatch` | 该项目 `read` | body `{event_type, project_id, payload?}`;只发状态变化事件(completed/failed/goal-unmet/merge-failed/review-requested);HTTP 层无注入 sender:webhook/slack 报“无 sender”,webpush/apns/fcm 报缺凭证,都不静默成功;真实发送端由 worker 注入 sender 后调 `push.dispatch_event` |
| `POST /webhooks/github` | GitHub 签名 | 见“webhook 端点” |
| `POST /webhooks/slack` | Slack 签名 | 见“webhook 端点” |
@@ -184,8 +221,9 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
Secure Vault 注入、或其它经用户明确同意的渠道),而不是把 key
写进配置文件、数据库或代码里凑合。定下之前,定时自动化与 worker
的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。
- **其它待接线(非外部条件,但同样未完成)**:memory / KB / 成本 /
模型路由尚未接成 HTTP 端点;OAuth(GitHub/Google)未接,首版只有
API key + 哈希认证;Web Push / APNs / FCM 推送未接,需要证书才做;
单机 SQLite 在高并发写下仍有 busy 锁上限,生产量上来后需评估
换库或单写者队列。
- **其它待接线(非外部条件,但同样未完成)**:OAuth 已接成脚手架
端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、
已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push /
APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下
仍有上限,生产量上来后需换 Postgres;worker 执行、云端执行、
真实通知与真实模型回合均未接通。
+135
View File
@@ -0,0 +1,135 @@
"""SQLite 并发 helper(云端控制面单机阶段)。
只解决一件事:ThreadingHTTPServer 多线程 + 单个 SQLite 文件时的写冲突。
不碰任何业务逻辑、不定义新 schema、不改 server.py 的接线:
本模块只提供连接与读写 helper,协调员后续把 server 的 _connect
换成这里的 connect 即可(表名/字段与 server.py 的 init_db、
memory.py 的 summaries 完全一致,因为本模块根本不建表)。
使用约定(必须遵守,否则 helper 帮不了你):
- 每个线程用自己的连接:绝不在多线程间共用一个 sqlite3 连接
(sqlite3 默认 check_same_thread=True,共用会直接报错)。
典型用法是每请求/每线程调用一次 connect(),用完 close()。
- 写操作走 execute_with_retry + transaction:一次语句失败重试是
安全的(单条 INSERT/UPDATE 被锁时 SQLite 已回滚该语句);
不要把多条非幂等语句包在一个重试里——重试只针对单条语句。
单机 SQLite 的上限(诚实边界,见模块末尾注释):
- WAL + busy_timeout + 有限重试只能缓解单机多线程写冲突,
不能变成多机/高并发数据库:写仍是单写者串行,读多写少尚可,
写密集或跨机器时必须换 Postgres(下一阶段,不在本轮假装解决)。
"""
import sqlite3
import time
from contextlib import contextmanager
# 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待;
# PRAGMA busy_timeout 是语句级等待,两者不冲突,后者以毫秒为单位
# 在每个连接上单独设置(不随库文件持久化,见 connect 的注释)。
DEFAULT_BUSY_TIMEOUT_MS = 5000
DEFAULT_CONNECT_TIMEOUT_S = 30
DEFAULT_MAX_RETRIES = 3
# 指数退避:0.05s、0.1s、0.2s;重试耗尽即抛原始错误,绝不无限循环。
DEFAULT_BACKOFF_SECONDS = (0.05, 0.1, 0.2)
_LOCKED_MARKER = "database is locked"
def connect(db_path, busy_timeout_ms=DEFAULT_BUSY_TIMEOUT_MS,
timeout=DEFAULT_CONNECT_TIMEOUT_S):
"""返回一个已设好 WAL + busy_timeout + Row 的新连接。
- PRAGMA journal_mode=WAL:读不阻塞写、写不阻塞读,是多线程
server 的前提;该模式随库文件持久化(:memory: 库除外,
内存库的 journal_mode 恒为 memory,属 SQLite 本身行为)。
- PRAGMA busy_timeout:写冲突时语句级等待窗口,单位毫秒;
它是每个连接的属性,不随库文件持久化,所以每次 connect 都设。
- row_factory=sqlite3.Row:与 server.py 各 handler 在 _db() 里
事后设置的 row_factory 一致,行可用 row["id"] 按列名访问。
- 不设 check_same_thread=False:跨线程共用连接是错误用法,
保持默认 True 让误用尽早报错,而不是静默出竞态。
"""
conn = sqlite3.connect(str(db_path), timeout=timeout)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=%d" % int(busy_timeout_ms))
conn.row_factory = sqlite3.Row
except Exception:
conn.close()
raise
return conn
def is_locked_error(exc):
"""只有 OperationalError 且文案含 database is locked 才算写锁冲突。
其它 OperationalError(如 no such table、语法错误)重试无用,
必须立即抛出,不能被重试掩盖成“锁问题”。
"""
return (
isinstance(exc, sqlite3.OperationalError)
and _LOCKED_MARKER in str(exc).lower()
)
def execute_with_retry(conn, sql, params=(),
max_retries=DEFAULT_MAX_RETRIES,
backoff_seconds=DEFAULT_BACKOFF_SECONDS):
"""执行单条写语句,遇写锁冲突时有限重试,返回 cursor。
重试次数 = max_retries(默认 3 次,即最多尝试 1 + 3 = 4 次),
每次重试前按 backoff_seconds 依次 sleep(默认 0.05/0.1/0.2s,
退避表短于重试次数时取最后一项,不越界)。
重试耗尽后把最后一次的原始 OperationalError 原样抛出,不吞掉、
不包装成别的异常类型,调用方看到的仍是 sqlite3.OperationalError。
非锁错误一次都不重试。
"""
attempts = 0
while True:
try:
return conn.execute(sql, params)
except sqlite3.OperationalError as exc:
if not is_locked_error(exc) or attempts >= max_retries:
raise
delay = backoff_seconds[
min(attempts, len(backoff_seconds) - 1)
]
time.sleep(delay)
attempts += 1
def read_all(conn, sql, params=()):
"""读操作 helper:不重试,只要求连接本身已走 WAL + busy_timeout。
读在 WAL 下不阻塞写,一般不需要重试;如果调用方传的是旧式
连接(非本模块 connect 建的),读结果仍正确,只是没有 WAL/
busy_timeout 保障——这属于调用方误用,本函数不静默替它建连接。
"""
return conn.execute(sql, params).fetchall()
@contextmanager
def transaction(conn):
"""单连接事务上下文:成功 commit,异常 rollback 后原样抛出。
与 execute_with_retry 配合的典型写法::
conn = connect(db_path)
try:
with transaction(conn):
execute_with_retry(conn, "INSERT INTO tasks(...) ...", (...))
finally:
conn.close()
注意:重试发生在语句级(transaction 内部),事务本身不重试;
若事务内某条语句最终抛错,整个事务回滚,不会留下半写状态。
"""
try:
yield conn
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
raise
+461
View File
@@ -0,0 +1,461 @@
"""OAuth 接线脚手架(GitHub / Google,首版)。
诚实边界:这只是脚手架,不是已接通的真实登录。
- 本模块不向真实 GitHub / Google 发任何请求:默认的 HTTP 路径
(``_urllib_post``)只在调用方真的传入真实 code 且配好
client_secret 时才会被走到;单测一律用注入的 fake http 客户端。
- 本模块不硬编码任何真实 client_id / client_secret:client_id 由
调用方以参数传入,client_secret 只从环境变量读
(GitHub:``GITHUB_OAUTH_CLIENT_SECRET``,
Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不
硬编码、绝不出现在授权 URL 里。
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
API,需另设计加密存储,本模块不提供。
- 本模块不创建本站会话:回调成功返回的只是外部身份字典,
映射到本站用户、建会话、记审计由 server 层用 ``auth.AuthStore``
完成(见 ``handle_oauth_callback`` 文档里的 server 层示例)。
附带的 ``map_external_identity_to_local_user`` 只复用 auth.py 已有
的 users 表(按确定性用户名查找/创建),不发明新的身份表,
也不发明新的审计格式。
流程(标准 OAuth2 授权码流程的接线点):
state 生成(OAuthStateStore.create_state,存库,默认 10 分钟过期)
-> 授权 URL(build_authorization_url / start_oauth_login)
-> 用户在 provider 处授权,provider 回调本站并带回 code + state
-> 回调(handle_oauth_callback):验 state -> 换 token
-> 取外部身份 -> 返回身份字典(server 层再映射 + 记审计)
零第三方依赖,只用 Python 标准库。
"""
import json
import os
import secrets
import sqlite3
import time
import urllib.parse
import urllib.request
from urllib.parse import parse_qs
# state 默认有效期:10 分钟。
STATE_TTL_SECONDS = 600
# 各 provider 的公开端点与默认 scope、client_secret 环境变量名。
# 这些是 OAuth 协议的公开端点地址,不是本站的任何凭证;真实应用
# 的 client_id / client_secret 仍需另行注册并经环境变量供给。
PROVIDERS = {
"github": {
"authorize_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"default_scope": "read:user",
"client_secret_env": "GITHUB_OAUTH_CLIENT_SECRET",
},
"google": {
"authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"default_scope": "openid email profile",
"client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET",
},
}
class OAuthError(Exception):
"""OAuth 流程失败:state 无效/过期、换 token 失败、身份取不到等。
``reason`` 供调用方区分(例如记日志或返回不同的错误提示),
取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` /
``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed``
/ ``identity_missing``。
"""
def __init__(self, message, reason=None):
super().__init__(message)
self.reason = reason
def _provider_config(provider):
"""取 provider 配置,未知 provider 抛 OAuthError(不猜端点)。"""
config = PROVIDERS.get(provider)
if config is None:
raise OAuthError(
"unknown provider: %r (supported: %s)"
% (provider, ", ".join(sorted(PROVIDERS))),
reason="unknown_provider",
)
return config
class OAuthStateStore:
"""基于 SQLite 的 OAuth state 存储。
表 oauth_states:state(主键,随机串)、provider、redirect_uri、
created_at(epoch 秒)。与 AuthStore 的 users / project_members /
audit_log 表名不冲突,可共用同一个 db 文件。
"""
def __init__(self, db_path):
self.db_path = str(db_path)
self.conn = sqlite3.connect(self.db_path)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS oauth_states("
"state TEXT PRIMARY KEY, "
"provider TEXT, "
"redirect_uri TEXT, "
"created_at REAL)"
)
self.conn.commit()
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS):
"""生成并入库一个随机 state,返回 state 字符串。
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
本函数不把过期时间单独存一列:判定统一用
``created_at + ttl_seconds``(validate 时传入的 ttl 须与
创建时一致,默认两边都是 600 秒)。
"""
_provider_config(provider)
if not redirect_uri or not isinstance(redirect_uri, str):
raise ValueError("redirect_uri required")
state = secrets.token_urlsafe(32)
self.conn.execute(
"INSERT INTO oauth_states(state, provider, redirect_uri, created_at)"
" VALUES(?, ?, ?, ?)",
(state, provider, redirect_uri, time.time()),
)
self.conn.commit()
return state
def validate_state(self, state, provider, redirect_uri=None,
ttl_seconds=STATE_TTL_SECONDS, now=None):
"""校验 state,成功返回记录 dict,失败返回 None(绝不抛错)。
校验规则(任一不满足即拒绝):
- state 在库里存在,否则 None(不存在);
- 记录的 provider 与传入一致,否则 None(不匹配,且不消耗
该 state,避免一次错 provider 的尝试烧掉合法 state);
- redirect_uri 若传入,须与记录一致,否则 None(不匹配);
- ``now``(默认 time.time())未超过
``created_at + ttl_seconds``,否则删除该行并返回 None(过期)。
成功是“一次性”的:返回前删除该行,同一 state 第二次校验
必然失败,防止重放。``now`` 参数只为单测注入时间,
生产调用传默认即可。
"""
if not state:
return None
if now is None:
now = time.time()
row = self.conn.execute(
"SELECT state, provider, redirect_uri, created_at"
" FROM oauth_states WHERE state = ?",
(state,),
).fetchone()
if row is None:
return None
stored_state, stored_provider, stored_redirect_uri, created_at = row
if stored_provider != provider:
return None
if redirect_uri is not None and stored_redirect_uri != redirect_uri:
return None
if now > created_at + ttl_seconds:
self.conn.execute(
"DELETE FROM oauth_states WHERE state = ?", (state,))
self.conn.commit()
return None
self.conn.execute(
"DELETE FROM oauth_states WHERE state = ?", (state,))
self.conn.commit()
return {
"state": stored_state,
"provider": stored_provider,
"redirect_uri": stored_redirect_uri,
"created_at": created_at,
}
def build_authorization_url(provider, client_id, redirect_uri, scope=None,
state=None):
"""拼授权 URL(纯字符串拼接,不发任何请求、不含任何 secret)。
参数:
- provider:``"github"`` 或 ``"google"``,未知抛 OAuthError;
- client_id:调用方传入的真实应用 id,本模块不硬编码、不校验
其真实性(注册见模块文档“真实 OAuth 仍缺什么”);
- redirect_uri:须与在 provider 处注册的回调地址一致;
- scope:不传则用该 provider 的默认 scope(GitHub ``read:user``,
Google ``openid email profile``);
- state:不传则现场随机生成一个(调用方若要校验,必须自己
用 OAuthStateStore 存下它;推荐直接用 ``start_oauth_login``,
它把生成 + 入库 + 拼 URL 一次做完)。
返回的 URL 只含 client_id / redirect_uri / scope / state(Google
另含 response_type=code),绝不含 client_secret。
"""
config = _provider_config(provider)
if not client_id or not isinstance(client_id, str):
raise ValueError("client_id required")
if not redirect_uri or not isinstance(redirect_uri, str):
raise ValueError("redirect_uri required")
if state is None:
state = secrets.token_urlsafe(32)
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope or config["default_scope"],
"state": state,
}
if provider == "google":
params["response_type"] = "code"
return config["authorize_url"] + "?" + urllib.parse.urlencode(params)
def start_oauth_login(state_store, provider, client_id, redirect_uri,
scope=None):
"""登录起点:生成并入库 state,再拼授权 URL。
返回 ``(state, authorization_url)``:调用方把用户重定向到该 URL,
provider 回调时带回的 state 交给 ``handle_oauth_callback`` 校验。
"""
state = state_store.create_state(provider, redirect_uri)
url = build_authorization_url(
provider, client_id, redirect_uri, scope=scope, state=state)
return state, url
def _urllib_post(token_url, payload):
"""默认的真实换 token 实现(urllib POST,返回解析后的 dict)。
只在调用方未注入 http_client 且配好 client_secret 时走到;
GitHub 的 token 端点默认回表单编码,本函数先试 JSON、
失败再按表单解析,两种都解析不出则返回空 dict(由调用方
判为换 token 失败)。
"""
data = urllib.parse.urlencode(payload).encode("utf-8")
request = urllib.request.Request(
token_url, data=data, method="POST",
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode("utf-8", "replace")
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except ValueError:
pass
form = parse_qs(raw)
return {key: values[0] for key, values in form.items() if values}
def exchange_code_for_token(provider, code, client_id, redirect_uri,
http_client=None, client_secret=None):
"""用授权码换 token,返回 provider 的 token 响应 dict。
http_client 注入约定(单测与未来真实接线共用同一签名):
``http_client(token_url, payload_dict) -> dict``,其中 payload 含
client_id / code / redirect_uri,Google 另含
grant_type=authorization_code;client_secret 仅在有值时才放进
payload。返回的 dict 须含 ``access_token``,否则视为换 token 失败;
http_client 抛异常、或返回含 ``error`` 的 dict,同样视为失败。
client_secret 供给:参数优先,未传则读该 provider 对应的环境变量
(见 PROVIDERS 的 client_secret_env)。真实路径(http_client
为 None)下仍无 secret 时,在发任何请求前就抛 OAuthError,
绝不以空 secret 去撞真实端点。注入了 http_client 的测试路径
允许 secret 缺省(fake 不需要真实 secret),生产调用不得依赖
这一宽松分支。
"""
config = _provider_config(provider)
if not code or not isinstance(code, str):
raise OAuthError("code required", reason="token_exchange_failed")
if client_secret is None:
client_secret = os.environ.get(config["client_secret_env"])
if http_client is None and not client_secret:
raise OAuthError(
"client_secret not configured: set %s (never hard-code it)"
% config["client_secret_env"],
reason="client_secret_missing",
)
payload = {
"client_id": client_id,
"code": code,
"redirect_uri": redirect_uri,
}
if provider == "google":
payload["grant_type"] = "authorization_code"
if client_secret is not None:
payload["client_secret"] = client_secret
client = http_client if http_client is not None else _urllib_post
try:
result = client(config["token_url"], payload)
except Exception as exc:
raise OAuthError(
"token exchange failed: %s" % (exc,), reason="token_exchange_failed"
) from exc
if not isinstance(result, dict) or result.get("error"):
raise OAuthError(
"token exchange failed: %r" % (result,),
reason="token_exchange_failed",
)
if not result.get("access_token"):
raise OAuthError(
"token exchange returned no access_token",
reason="token_exchange_failed",
)
return result
def _identity_from_token_or_userinfo(provider, token_result, userinfo_client):
"""从 token 响应或 userinfo 回调里抽外部身份。
顺序:先看 token 响应自带的身份字段(fake / 某些 provider 会带
id、login、email),没有则调 userinfo_client(access_token);
两边都没有可用身份时返回 None。真实 GitHub 的 token 响应不带
身份,生产调用必须传 userinfo_client(例如调
https://api.github.com/user 并取 login/id);真实 Google 的
id_token 解析同样走 userinfo_client 这一注入点,本模块不解析 JWT。
"""
for source in (token_result,):
external_id = source.get("id") or source.get("sub")
login = source.get("login") or source.get("name")
email = source.get("email")
if external_id is not None or login is not None or email is not None:
return {
"provider": provider,
"external_id": str(external_id) if external_id is not None else None,
"login": login,
"email": email,
}
if userinfo_client is None:
return None
try:
info = userinfo_client(token_result["access_token"])
except Exception:
return None
if not isinstance(info, dict):
return None
external_id = info.get("id") or info.get("sub")
login = info.get("login") or info.get("name")
email = info.get("email")
if external_id is None and login is None and email is None:
return None
return {
"provider": provider,
"external_id": str(external_id) if external_id is not None else None,
"login": login,
"email": email,
}
def handle_oauth_callback(state_store, provider, code, state, client_id,
redirect_uri, http_client=None, userinfo_client=None,
client_secret=None, now=None,
ttl_seconds=STATE_TTL_SECONDS):
"""处理 provider 回调:验 state -> 换 token -> 返回外部身份。
成功返回 dict:``{"provider", "external_id", "login", "email"}``
(缺失的字段为 None;external_id 至少与 login/email 其一存在,
否则抛 OAuthError)。
失败一律抛 OAuthError,不建任何会话、不写任何用户或审计记录:
- state 不存在 -> reason=state_not_found;
- state 过期 -> reason=state_expired(过期行在拒绝时被删除);
- state 与 provider / redirect_uri 不匹配 ->
reason=state_mismatch;
- 换 token 失败 -> reason=token_exchange_failed;
- 拿不到外部身份 -> reason=identity_missing。
本函数 deliberately 不做以下三件事,全部留给 server 层(签名
与 auth.py 完全兼容,不发明新格式):
1. 映射到本站用户:可用 ``map_external_identity_to_local_user``
(复用 AuthStore 的 users 表)或 server 层自己的映射;
2. 建会话:本站首版会话就是 AuthStore 的 API key,OAuth 登录后
是否发 key、发给谁,由 server 层决定,本模块不发;
3. 记审计:server 层在映射出本站 user_id 后调用
``AuthStore.audit(user_id, project_id, "oauth_login",
"%s:%s" % (provider, external_id))``——与 auth.py 现有
audit(action, detail) 写法一致,project_id 为登录发生的
项目上下文、无项目上下文时 server 层自定(例如空串),
本模块不替 server 层选 project_id。
access_token 在本函数内只用于取身份(经 userinfo_client),
返回前即丢弃,本模块不存储、不返回它。
"""
# 先区分拒绝原因(validate_state 本身只返回记录或 None):
# 不存在 / provider 或 redirect_uri 不匹配 / 过期各有 reason;
# 过期行在此处删除(validate_state 的过期分支同样会删,幂等)。
if now is None:
now = time.time()
peek = state_store.conn.execute(
"SELECT provider, redirect_uri, created_at"
" FROM oauth_states WHERE state = ?",
(state,),
).fetchone() if state else None
if peek is None:
raise OAuthError("unknown state", reason="state_not_found")
peek_provider, peek_redirect_uri, peek_created_at = peek
if peek_provider != provider or (
redirect_uri is not None and peek_redirect_uri != redirect_uri):
raise OAuthError("state does not match provider/redirect_uri",
reason="state_mismatch")
if now > peek_created_at + ttl_seconds:
state_store.conn.execute(
"DELETE FROM oauth_states WHERE state = ?", (state,))
state_store.conn.commit()
raise OAuthError("state expired", reason="state_expired")
record = state_store.validate_state(
state, provider, redirect_uri=redirect_uri, now=now,
ttl_seconds=ttl_seconds)
if record is None:
raise OAuthError(
"invalid state", reason="state_not_found")
token_result = exchange_code_for_token(
provider, code, client_id, redirect_uri,
http_client=http_client, client_secret=client_secret)
identity = _identity_from_token_or_userinfo(
provider, token_result, userinfo_client)
if identity is None:
raise OAuthError(
"cannot determine external identity: token response carries"
" no identity fields and no userinfo_client was provided",
reason="identity_missing",
)
if identity["external_id"] is None and identity["login"] is None \
and identity["email"] is None:
raise OAuthError(
"empty external identity", reason="identity_missing")
return identity
def map_external_identity_to_local_user(auth_store, provider, identity):
"""把外部身份映射到本站用户(复用 AuthStore 的 users 表)。
确定性用户名:``oauth:<provider>:<login 或 external_id>``。
已存在则返回 ``(user_id, False)``,不存在则经
``AuthStore.create_user`` 创建并返回 ``(user_id, True)``——
创建时生成的 API key 明文按 auth.py 约定只应返回给调用方一次,
本函数不返回它(调用方如需发 key 给登录用户,须另行决定
发放方式,本模块不代发)。这只是脚手架的默认映射:生产若要
把外部身份绑定到已有本站账号(而非每次新建),需另加绑定表
与绑定流程,本函数不假装已做绑定。
"""
_provider_config(provider)
login = identity.get("login") or identity.get("external_id") \
or identity.get("email")
if login is None:
raise OAuthError(
"identity has no login/external_id/email to map from",
reason="identity_missing",
)
username = "oauth:%s:%s" % (provider, login)
row = auth_store.conn.execute(
"SELECT id FROM users WHERE username = ?", (username,)
).fetchone()
if row is not None:
return row[0], False
user_id, _api_key_plain = auth_store.create_user(username)
return user_id, True
+360
View File
@@ -0,0 +1,360 @@
"""推送订阅与分发模块(云端架构实施)。
零第三方依赖,只用 Python 标准库。本模块只做两件事:
1. 推送订阅的增删查(SQLite 表 ``push_subscriptions``)。
2. 状态变化事件的分发(只发状态变化,不发进行中状态)。
边界(不要超出本模块宣称的范围):
- 合法事件类型与 ``webhooks.ALLOWED_NOTIFICATION_EVENTS`` 一致:
``completed``、``failed``、``goal-unmet``、``merge-failed``、
``review-requested``。其他事件类型拒绝或忽略,并写明原因,
绝不静默丢弃。
- 偏好过滤:``silent``(静默)不发;失败类事件(``failed``、
``goal-unmet``、``merge-failed``)即使偏好是 ``digest``(摘要)
也即时发;非失败类(``completed``、``review-requested``)在
``digest`` 偏好下本次不立即发,返回结果里明确列入
``skipped_for_digest``(待摘要列表),不是静默丢弃。
- 发送端可注入:``webhook`` / ``slack`` 走可注入的 ``sender``。
``sender`` 为 ``None`` 时对这两个通道也返回“无 sender”错误,
不静默成功。``sender`` 可以是可调用对象(收到一个 dict,见
:func:`dispatch_event`),也可以是按通道分的 dict
(``{"webhook": callable, "slack": callable}``),或带 ``send``
方法的对象。
- ``webpush`` / ``apns`` / ``fcm`` 只有接口,没有真实发送端:
一律明确报错“缺凭证(VAPID / APNs 证书 / FCM key)”,不编造
真实推送、不生成或提交任何私钥到仓库、不向真实推送服务发
任何请求。真实凭证与真实发送端是后续待接线的工作,见模块
末尾的缺口说明。
- 本模块不联网、不调用真实推送服务、不跑真实模型回合。
密钥、token、证书一律不由本模块读取、写入或返回。
"""
import sqlite3
import time
import uuid
from contextlib import contextmanager
__all__ = [
"CHANNELS",
"PREFERENCES",
"ALLOWED_PUSH_EVENTS",
"FAILURE_EVENTS",
"MISSING_CREDENTIAL_REASONS",
"init_push_db",
"create_subscription",
"delete_subscription",
"list_subscriptions",
"dispatch_event",
]
CHANNELS = ("webhook", "slack", "webpush", "apns", "fcm")
PREFERENCES = ("instant", "digest", "silent")
# 只发状态变化的事件集合,与 webhooks.ALLOWED_NOTIFICATION_EVENTS 一致。
ALLOWED_PUSH_EVENTS = frozenset(
{"completed", "failed", "goal-unmet", "merge-failed", "review-requested"}
)
# 失败类事件:即使偏好是 digest 也即时发。
FAILURE_EVENTS = frozenset({"failed", "goal-unmet", "merge-failed"})
# webpush/apns/fcm 缺凭证时的明确报错文案(不含任何真实凭证)。
MISSING_CREDENTIAL_REASONS = {
"webpush": "missing credential: VAPID keys not configured",
"apns": "missing credential: APNs certificate not configured",
"fcm": "missing credential: FCM key not configured",
}
def _is_connection(db):
return isinstance(db, sqlite3.Connection)
@contextmanager
def _connect(db):
"""按 db 类型取连接:Connection 直接用(不关闭);路径则新建并关闭。"""
if _is_connection(db):
yield db
elif isinstance(db, (str, bytes)) or hasattr(db, "__fspath__"):
conn = sqlite3.connect(str(db), timeout=30)
try:
yield conn
finally:
conn.close()
else:
raise TypeError("db must be a sqlite3 connection or a db path")
def init_push_db(db):
"""创建 push_subscriptions 表(已存在则不动)。"""
with _connect(db) as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS push_subscriptions("
"id TEXT PRIMARY KEY, "
"project_id TEXT NOT NULL, "
"user_id TEXT NOT NULL, "
"channel TEXT NOT NULL, "
"target TEXT NOT NULL, "
"preference TEXT NOT NULL, "
"created_at REAL, "
"UNIQUE(project_id, user_id, channel, target))"
)
conn.commit()
def _row_to_dict(row):
return {
"id": row[0],
"project_id": row[1],
"user_id": row[2],
"channel": row[3],
"target": row[4],
"preference": row[5],
"created_at": row[6],
}
def _validate_subscription_fields(project_id, user_id, channel, target,
preference):
if not project_id or not isinstance(project_id, str):
raise ValueError("project_id required")
if not user_id or not isinstance(user_id, str):
raise ValueError("user_id required")
if channel not in CHANNELS:
raise ValueError("channel must be one of %s" % (CHANNELS,))
if not target or not isinstance(target, str):
raise ValueError("target required")
if preference not in PREFERENCES:
raise ValueError("preference must be one of %s" % (PREFERENCES,))
def create_subscription(db, project_id, user_id, channel, target,
preference="instant"):
"""创建一条推送订阅,返回订阅 dict。
同一 (project_id, user_id, channel, target) 已存在时返回已有订阅,
不建重复行。channel 取值 webhook/slack/webpush/apns/fcm;
preference 取值 instant/digest/silent;非法取值抛 ValueError。
"""
_validate_subscription_fields(
project_id, user_id, channel, target, preference)
init_push_db(db)
with _connect(db) as conn:
existing = conn.execute(
"SELECT id, project_id, user_id, channel, target, preference,"
" created_at FROM push_subscriptions"
" WHERE project_id = ? AND user_id = ? AND channel = ?"
" AND target = ?",
(project_id, user_id, channel, target),
).fetchone()
if existing is not None:
return _row_to_dict(existing)
sub_id = uuid.uuid4().hex
now = time.time()
conn.execute(
"INSERT INTO push_subscriptions(id, project_id, user_id,"
" channel, target, preference, created_at)"
" VALUES(?, ?, ?, ?, ?, ?, ?)",
(sub_id, project_id, user_id, channel, target, preference, now),
)
conn.commit()
row = conn.execute(
"SELECT id, project_id, user_id, channel, target, preference,"
" created_at FROM push_subscriptions WHERE id = ?",
(sub_id,),
).fetchone()
return _row_to_dict(row)
def delete_subscription(db, subscription_id=None, *, project_id=None,
user_id=None, channel=None, target=None):
"""删除订阅,返回 True(删到至少一条)或 False(无匹配)。
优先按 subscription_id 删除;未传 id 时按给出的
project_id/user_id/channel/target 过滤删除(至少要给一个过滤条件,
否则不删任何行并返回 False,避免误删整表)。
"""
init_push_db(db)
with _connect(db) as conn:
if subscription_id is not None:
cur = conn.execute(
"DELETE FROM push_subscriptions WHERE id = ?",
(subscription_id,),
)
else:
conditions = []
params = []
for column, value in (
("project_id", project_id),
("user_id", user_id),
("channel", channel),
("target", target),
):
if value is not None:
conditions.append("%s = ?" % column)
params.append(value)
if not conditions:
return False
cur = conn.execute(
"DELETE FROM push_subscriptions WHERE "
+ " AND ".join(conditions),
params,
)
conn.commit()
return cur.rowcount > 0
def list_subscriptions(db, project_id, user_id=None):
"""列出某项目的订阅;传 user_id 时只列该用户的。
返回按 created_at、id 排序的订阅 dict 列表。只查本项目,
跨项目订阅绝不会混入(见单测)。
"""
if not project_id or not isinstance(project_id, str):
raise ValueError("project_id required")
init_push_db(db)
with _connect(db) as conn:
if user_id is None:
rows = conn.execute(
"SELECT id, project_id, user_id, channel, target,"
" preference, created_at FROM push_subscriptions"
" WHERE project_id = ? ORDER BY created_at, id",
(project_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, project_id, user_id, channel, target,"
" preference, created_at FROM push_subscriptions"
" WHERE project_id = ? AND user_id = ?"
" ORDER BY created_at, id",
(project_id, user_id),
).fetchall()
return [_row_to_dict(row) for row in rows]
def _resolve_channel_sender(sender, channel):
"""从注入的 sender 里取出某通道的发送函数,取不到返回 None。"""
if sender is None:
return None
if isinstance(sender, dict):
candidate = sender.get(channel)
if candidate is None:
return None
if callable(candidate):
return candidate
send = getattr(candidate, "send", None)
return send if callable(send) else None
if callable(sender):
return sender
send = getattr(sender, "send", None)
return send if callable(send) else None
def dispatch_event(db, event_type, project_id, payload, sender=None):
"""分发一次状态变化事件,只发本项目的订阅。
参数:
db: sqlite3 连接或数据库路径。
event_type: 合法类型见 ALLOWED_PUSH_EVENTS;其他类型拒绝,
返回 ``{"sent": [], ..., "reason": "event not allowed"}``
且不调用任何 sender。
project_id: 只查该项目的订阅,跨项目不混。
payload: 原事件载荷(dict 或其他),原样放入发送给 sender
的消息 dict 的 ``payload`` 字段。
sender: 可注入发送端(见模块 docstring)。webhook/slack
通道在 sender 为 None 时返回“无 sender”错误,不静默成功;
webpush/apns/fcm 通道不走 sender,一律返回缺凭证错误。
返回 dict,键固定为::
{
"event_type": ...,
"project_id": ...,
"sent": [{"subscription_id", "user_id", "channel", "target"}],
"skipped_for_digest": [订阅 dict(待摘要,本次未立即发)],
"skipped_silent": [订阅 dict(静默偏好,本次未发)],
"errors": [{"subscription_id", "user_id", "channel", "target",
"reason": ...}],
}
非法事件类型时额外含 ``"reason": "event not allowed"``。
sender 抛异常时记入 errors,不中断其他订阅的分发。
"""
result = {
"event_type": event_type,
"project_id": project_id,
"sent": [],
"skipped_for_digest": [],
"skipped_silent": [],
"errors": [],
}
if event_type not in ALLOWED_PUSH_EVENTS:
result["reason"] = "event not allowed"
result["errors"] = [{
"subscription_id": None,
"user_id": None,
"channel": None,
"target": None,
"reason": "event not allowed: %r not in %s"
% (event_type, sorted(ALLOWED_PUSH_EVENTS)),
}]
return result
if not project_id or not isinstance(project_id, str):
result["reason"] = "project_id required"
result["errors"] = [{
"subscription_id": None,
"user_id": None,
"channel": None,
"target": None,
"reason": "project_id required",
}]
return result
subscriptions = list_subscriptions(db, project_id)
is_failure = event_type in FAILURE_EVENTS
for sub in subscriptions:
base = {
"subscription_id": sub["id"],
"user_id": sub["user_id"],
"channel": sub["channel"],
"target": sub["target"],
}
# 偏好过滤:silent 永远不发;digest 只对非失败类延迟。
if sub["preference"] == "silent":
result["skipped_silent"].append(sub)
continue
if sub["preference"] == "digest" and not is_failure:
result["skipped_for_digest"].append(sub)
continue
# webpush/apns/fcm:只有接口,缺凭证,明确报错,不走 sender、
# 不联网、不编造推送。
if sub["channel"] in MISSING_CREDENTIAL_REASONS:
result["errors"].append(
dict(base, reason=MISSING_CREDENTIAL_REASONS[sub["channel"]]))
continue
# webhook/slack:必须有可注入 sender,否则明确报错。
channel_sender = _resolve_channel_sender(sender, sub["channel"])
if channel_sender is None:
result["errors"].append(
dict(base, reason="no sender for channel %s"
% sub["channel"]))
continue
message = {
"event_type": event_type,
"project_id": project_id,
"payload": payload,
"channel": sub["channel"],
"target": sub["target"],
"user_id": sub["user_id"],
"subscription_id": sub["id"],
}
try:
channel_sender(message)
except Exception as exc: # noqa: BLE001 -- 分发失败记 errors,不中断
result["errors"].append(
dict(base, reason="sender failed: %s" % exc))
continue
result["sent"].append(base)
return result
+550 -2
View File
@@ -45,6 +45,23 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from cloud.auth import AuthStore
from cloud.cost import CostStore
from cloud.db import connect as _db_connect
from cloud.kb import KBStore
from cloud.memory import MemoryStore
from cloud.oauth import (
OAuthError,
OAuthStateStore,
handle_oauth_callback,
map_external_identity_to_local_user,
start_oauth_login,
)
from cloud.push import (
create_subscription as _push_create,
delete_subscription as _push_delete,
dispatch_event as _push_dispatch,
list_subscriptions as _push_list,
)
from cloud.webhooks import (
parse_github_pr_comment,
parse_slack_command,
@@ -59,8 +76,12 @@ DEFAULT_SWEEP_THRESHOLD_SECONDS = 300
def _connect(db_path):
"""每个请求新建一个连接;busy timeout 给并发写留重试窗口。"""
return sqlite3.connect(str(db_path), timeout=30)
"""每个请求新建一个连接;走 cloud.db 的 WAL + busy_timeout。
单机 SQLite 的上限仍在:WAL + 有限重试只能缓解多线程写冲突,
高并发要换 Postgres(见 cloud/README.md)。
"""
return _db_connect(str(db_path))
def init_db(db_path):
@@ -236,6 +257,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def do_POST(self):
self._dispatch("POST")
def do_DELETE(self):
self._dispatch("DELETE")
def _dispatch(self, method):
parsed = urlparse(self.path)
path = parsed.path
@@ -246,6 +270,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if path == "/health" and method == "GET":
self._send_json(200, {"ok": True, "service": "cloud-control-plane"})
return
if path.startswith("/oauth/"):
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
# 这正是 OAuth 要解决的问题),靠 state + client_secret
# 认证,不走 X-API-Key;不要把它当成无认证后门。
if method == "GET" and path.endswith("/login"):
self._oauth_login(path)
elif method == "POST" and path.endswith("/callback"):
self._oauth_callback(path)
else:
self._send_json(404, {"error": "not found"})
return
if path.startswith("/webhooks/"):
# webhook 靠签名认证(外部服务无法持 X-API-Key),不走 API key。
if method != "POST":
@@ -268,16 +303,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._create_project(user_id)
elif path == "/projects" and method == "GET":
self._list_projects(user_id)
elif path == "/models" and method == "GET":
self._models_overview(user_id)
elif path.startswith("/projects/"):
self._dispatch_project(user_id, method, path, query)
elif path == "/tasks" and method == "POST":
self._create_task(user_id)
elif path == "/tasks" and method == "GET":
self._list_tasks(user_id, query)
elif path == "/admin/sweep" and method == "POST":
self._admin_sweep()
elif path == "/admin/push/dispatch" and method == "POST":
self._push_dispatch_endpoint(user_id)
elif path.startswith("/tasks/"):
rest = path[len("/tasks/"):]
if "/" not in rest and method == "GET":
self._get_task(user_id, rest)
elif rest.endswith("/cost") and method == "GET":
self._task_cost(user_id, rest[: -len("/cost")])
elif rest.endswith("/cancel") and method == "POST":
self._cancel_task(user_id, rest[: -len("/cancel")])
elif rest.endswith("/heartbeat") and method == "POST":
@@ -354,6 +397,396 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if r["id"] in member_ids]
self._send_json(200, {"projects": projects})
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
def _project_exists(self, project_id):
conn = self._db()
try:
row = conn.execute(
"SELECT id FROM projects WHERE id = ?", (project_id,)
).fetchone()
return row is not None
finally:
conn.close()
def _check_project(self, user_id, project_id, action):
"""返回 None 表示通过;否则返回 (status, error) 先行响应。"""
if not self._project_exists(project_id):
return 404, "project not found"
with _auth_store(self.server.db_path) as store:
if not store.check_permission(user_id, project_id, action):
return 403, "forbidden"
return None
def _dispatch_project(self, user_id, method, path, query):
rest = path[len("/projects/"):]
parts = rest.split("/")
project_id = parts[0] if parts else ""
if not project_id:
self._send_json(404, {"error": "not found"})
return
# /projects/{id}/memory
if len(parts) == 2 and parts[1] == "memory":
if method == "POST":
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._memory_create(user_id, project_id)
elif method == "GET":
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._memory_list(project_id, query)
else:
self._send_json(404, {"error": "not found"})
return
# /projects/{id}/memory/{mid}/supersede
if len(parts) == 4 and parts[1] == "memory" and parts[3] == "supersede":
if method != "POST":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._memory_supersede(user_id, project_id, parts[2])
return
# /projects/{id}/kb/index
if len(parts) == 3 and parts[1] == "kb" and parts[2] == "index":
if method != "POST":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._kb_index(user_id, project_id)
return
# /projects/{id}/kb/search
if len(parts) == 3 and parts[1] == "kb" and parts[2] == "search":
if method != "GET":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._kb_search(project_id, query)
return
# /projects/{id}/cost
if len(parts) == 2 and parts[1] == "cost":
if method != "GET":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._project_cost(project_id, query)
return
# /projects/{id}/push/subscriptions
if len(parts) == 3 and parts[1] == "push" and parts[2] == "subscriptions":
if method == "POST":
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._push_create_endpoint(user_id, project_id)
elif method == "GET":
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._send_json(200, {
"subscriptions": _push_list(
self.server.db_path, project_id, user_id=user_id)})
else:
self._send_json(404, {"error": "not found"})
return
# /projects/{id}/push/subscriptions/{sid} DELETE
if len(parts) == 4 and parts[1] == "push" and parts[2] == "subscriptions":
if method != "DELETE":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
subs = _push_list(self.server.db_path, project_id)
mine = [s for s in subs if s["id"] == parts[3]]
if not mine:
self._send_json(404, {"error": "subscription not found"})
return
# 只能删自己的订阅,项目 owner 可删任意(owner 角色检查)。
with _auth_store(self.server.db_path) as auth:
is_owner = auth.check_permission(
user_id, project_id, "manage_project")
if mine[0]["user_id"] != user_id and not is_owner:
self._send_json(403, {"error": "forbidden"})
return
deleted = _push_delete(self.server.db_path,
subscription_id=parts[3])
self._send_json(200, {"deleted": bool(deleted)})
return
self._send_json(404, {"error": "not found"})
def _memory_create(self, user_id, project_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
goal = body.get("goal")
conclusion = body.get("conclusion")
if not goal or not isinstance(goal, str):
self._send_json(400, {"error": "goal required"})
return
if not conclusion or not isinstance(conclusion, str):
self._send_json(400, {"error": "conclusion required"})
return
store = MemoryStore(self.server.db_path)
try:
mid = store.add_summary(
project_id,
body.get("thread_id") or "",
goal,
conclusion,
files_changed=body.get("files_changed") or [],
open_issues=body.get("open_issues") or [],
)
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id, "create_memory", str(mid))
self._send_json(201, {"id": mid, "project_id": project_id})
def _memory_list(self, project_id, query):
try:
top_k = int((query.get("top_k") or ["5"])[0])
except (TypeError, ValueError):
self._send_json(400, {"error": "top_k must be an integer"})
return
store = MemoryStore(self.server.db_path)
try:
summaries = store.list_summaries(project_id)
context = store.build_prompt_context(project_id, top_k=top_k)
finally:
try:
store.conn.close()
except Exception:
pass
self._send_json(200, {
"project_id": project_id,
"summaries": summaries[:top_k],
"prompt_context": context,
})
def _memory_supersede(self, user_id, project_id, mid_raw):
try:
mid = int(mid_raw)
except (TypeError, ValueError):
self._send_json(400, {"error": "id must be an integer"})
return
conn = self._db()
try:
row = conn.execute(
"SELECT project_id FROM summaries WHERE id = ?", (mid,)
).fetchone()
finally:
conn.close()
if row is None or row["project_id"] != project_id:
self._send_json(404, {"error": "summary not found"})
return
store = MemoryStore(self.server.db_path)
try:
store.deprecate(mid)
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id, "supersede_memory", str(mid))
self._send_json(200, {"id": mid, "status": "deprecated"})
def _kb_index(self, user_id, project_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
path = body.get("path")
content = body.get("content")
if not path or not isinstance(path, str):
self._send_json(400, {"error": "path required"})
return
if content is None or not isinstance(content, str):
self._send_json(400, {"error": "content required"})
return
store = KBStore(self.server.db_path)
try:
doc_id = store.index_file(
project_id, path, content,
kind=body.get("kind") or "file")
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id, "kb_index", path)
self._send_json(201, {"id": doc_id, "project_id": project_id,
"path": path})
def _kb_search(self, project_id, query):
q = (query.get("q") or [""])[0]
if not q:
self._send_json(400, {"error": "q required"})
return
try:
top_k = int((query.get("top_k") or ["5"])[0])
except (TypeError, ValueError):
self._send_json(400, {"error": "top_k must be an integer"})
return
store = KBStore(self.server.db_path)
try:
results = store.search(project_id, q, top_k=top_k)
finally:
try:
store.conn.close()
except Exception:
pass
self._send_json(200, {"project_id": project_id, "query": q,
"results": results})
def _project_cost(self, project_id, query):
store = CostStore(self.server.db_path)
try:
overall = store.report_by_project(project_id)
conn = store._conn
conn.row_factory = sqlite3.Row
days = (query.get("days") or [None])[0]
day = (query.get("day") or [None])[0]
daily = []
if day:
row = conn.execute(
"SELECT day, COALESCE(SUM(total_tokens),0) AS t,"
" COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c,"
" COALESCE(SUM(fallback_count),0) AS f"
" FROM cost_records WHERE project_id = ? AND day = ?"
" GROUP BY day",
(project_id, day)).fetchone()
if row:
daily = [{"day": row["day"], "total_tokens": row["t"],
"usd_micros": row["u"], "turns": row["c"],
"fallback_count": row["f"]}]
elif days is not None:
try:
n = int(days)
except (TypeError, ValueError):
self._send_json(400, {"error": "days must be an integer"})
return
from datetime import date, timedelta
cutoff = (date.today() - timedelta(days=max(n - 1, 0))
).isoformat()
rows = conn.execute(
"SELECT day, COALESCE(SUM(total_tokens),0) AS t,"
" COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c,"
" COALESCE(SUM(fallback_count),0) AS f"
" FROM cost_records WHERE project_id = ? AND day >= ?"
" GROUP BY day ORDER BY day",
(project_id, cutoff)).fetchall()
daily = [{"day": r["day"], "total_tokens": r["t"],
"usd_micros": r["u"], "turns": r["c"],
"fallback_count": r["f"]} for r in rows]
finally:
try:
store._conn.close()
except Exception:
pass
self._send_json(200, {"project_id": project_id, "overall": overall,
"daily": daily})
# -- 任务成本与模型总览 ----------------------------------------------
def _task_cost(self, user_id, task_id):
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as auth:
if not auth.check_permission(user_id, task["project_id"], "read"):
self._send_json(403, {"error": "forbidden"})
return
store = CostStore(self.server.db_path)
try:
report = store.report_by_task(task_id)
finally:
try:
store._conn.close()
except Exception:
pass
self._send_json(200, {"task_id": task_id,
"project_id": task["project_id"], **report})
def _models_overview(self, user_id):
# 优先级列表来自环境变量 MODELS_PRIORITY(逗号分隔),未设时
# 用默认列表;这只是控制面的默认配置,不是 provider 的承诺。
raw = os.environ.get("MODELS_PRIORITY", "")
priority = [m.strip() for m in raw.split(",") if m.strip()] or [
"gpt-6.1-sol", "gpt-6-sol", "gpt-5.6-sol"]
# 只统计调用者是成员的项目,避免跨项目泄露成本。
conn = self._db()
try:
member_ids = [r["project_id"] for r in conn.execute(
"SELECT project_id FROM project_members WHERE user_id = ?",
(user_id,)).fetchall()]
finally:
conn.close()
if not member_ids:
self._send_json(200, {"priority_list": priority,
"model_stats": [], "fallback_events": []})
return
placeholders = ",".join("?" for _ in member_ids)
store = CostStore(self.server.db_path)
try:
conn = store._conn
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT model, COUNT(*) AS turns,"
" COALESCE(SUM(total_tokens),0) AS tokens,"
" COALESCE(SUM(fallback_count),0) AS fallbacks"
" FROM cost_records WHERE project_id IN (%s)"
" GROUP BY model ORDER BY model" % placeholders,
member_ids).fetchall()
fb_rows = conn.execute(
"SELECT task_id, project_id, model, fallback_count"
" FROM cost_records WHERE project_id IN (%s)"
" AND fallback_count > 0"
" ORDER BY created_at DESC LIMIT 50" % placeholders,
member_ids).fetchall()
finally:
try:
store._conn.close()
except Exception:
pass
self._send_json(200, {
"priority_list": priority,
"model_stats": [
{"model": r["model"], "turns": r["turns"],
"total_tokens": r["tokens"],
"fallback_count": r["fallbacks"]} for r in rows],
"fallback_events": [
{"task_id": r["task_id"], "project_id": r["project_id"],
"model": r["model"],
"fallback_count": r["fallback_count"]} for r in fb_rows],
})
# -- 任务 ----------------------------------------------------------
def _create_task(self, user_id):
body, err = self._read_json()
@@ -553,6 +986,121 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
swept = sweep_stale_tasks(self.server.db_path, threshold)
self._send_json(200, {"swept": swept})
# -- OAuth(pre-auth,靠 state + secret,不走 X-API-Key)-----------
def _oauth_provider(self, path):
parts = [p for p in path.split("/") if p]
if len(parts) != 3 or parts[0] != "oauth":
return None
return parts[1]
def _oauth_login(self, path):
provider = self._oauth_provider(path)
if provider is None:
self._send_json(404, {"error": "not found"})
return
query = parse_qs(urlparse(self.path).query)
client_id = (query.get("client_id") or [None])[0]
redirect_uri = (query.get("redirect_uri") or [None])[0]
scope = (query.get("scope") or [None])[0]
if not client_id or not redirect_uri:
self._send_json(400, {"error": "client_id and redirect_uri required"})
return
store = OAuthStateStore(self.server.db_path)
try:
state, url = start_oauth_login(
store, provider, client_id, redirect_uri, scope=scope)
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
finally:
try:
store.conn.close()
except Exception:
pass
# 只返回 URL,不直接跳转到任何登录页;跳转由调用方决定。
self._send_json(200, {"provider": provider, "state": state,
"authorization_url": url})
def _oauth_callback(self, path):
provider = self._oauth_provider(path)
if provider is None:
self._send_json(404, {"error": "not found"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
code = body.get("code")
state = body.get("state")
client_id = body.get("client_id")
redirect_uri = body.get("redirect_uri")
if not code or not state or not client_id or not redirect_uri:
self._send_json(400, {"error": "code, state, client_id and redirect_uri required"})
return
store = OAuthStateStore(self.server.db_path)
try:
identity = handle_oauth_callback(
store, provider, code, state, client_id, redirect_uri)
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
local_id, created = map_external_identity_to_local_user(
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
self._send_json(200, {"user_id": local_id, "created": created,
"provider": provider,
"login": identity.get("login"),
"email": identity.get("email")})
# -- 推送订阅与分发 ---------------------------------------------------
def _push_create_endpoint(self, user_id, project_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
try:
sub = _push_create(
self.server.db_path, project_id, user_id,
body.get("channel"), body.get("target"),
body.get("preference") or "instant")
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
self._send_json(201, sub)
def _push_dispatch_endpoint(self, user_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
event_type = body.get("event_type")
project_id = body.get("project_id")
if not event_type or not project_id:
self._send_json(400, {"error": "event_type and project_id required"})
return
# 分发者须是该项目的成员(至少可读),否则不能往别人项目里推。
with _auth_store(self.server.db_path) as auth:
if not auth.check_permission(user_id, project_id, "read"):
self._send_json(403, {"error": "forbidden"})
return
# HTTP 层无可注入 sender:webhook/slack 会报“无 sender”,
# webpush/apns/fcm 会报缺凭证——都不静默成功。真实发送端
# 由 worker 注入 sender 后调 push.dispatch_event。
result = _push_dispatch(
self.server.db_path, event_type, project_id,
body.get("payload") or {}, sender=None)
self._send_json(200, result)
# -- webhooks(签名认证,不走 X-API-Key)---------------------------
def _handle_github_webhook(self):
raw = self._read_body()
+249
View File
@@ -0,0 +1,249 @@
"""cloud/db.py 单测:SQLite 并发 helper。
只测 db.py,不改 server.py / host.py / 其它模块。
重试逻辑的覆盖方式(本文件写清楚,供协调员核对):
- 确定性 fake 方式:FakeConn 前两次抛 database is locked、第三次
成功,断言重试后成功且退避序列为 0.05/0.1/0.2s;另测重试耗尽
抛原始错误、非锁错误一次都不重试。
- 真实并发方式:8 线程各写 20 条(server.py 同款 tasks 表),
断言无错误、最终 160 条、id 无重复。
两者都测了,前者证重试逻辑本身正确,后者证真实写冲突不再直接炸掉。
"""
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud import db as db_module
from cloud.db import (
connect,
execute_with_retry,
is_locked_error,
read_all,
transaction,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud import db as db_module
from cloud.db import (
connect,
execute_with_retry,
is_locked_error,
read_all,
transaction,
)
# 与 server.py init_db 的 tasks 表定义逐字段一致(db.py 本身不建表,
# 这里复刻定义只是为了让并发测试写进同款表,证明 schema 兼容)。
TASKS_DDL = (
"CREATE TABLE IF NOT EXISTS tasks("
"id TEXT PRIMARY KEY, "
"project_id TEXT, "
"prompt TEXT, "
"status TEXT, "
"idempotency_key TEXT, "
"depends_on TEXT, "
"priority INTEGER, "
"created_by TEXT, "
"created_at REAL, "
"updated_at REAL, "
"last_heartbeat REAL, "
"UNIQUE(project_id, idempotency_key))"
)
class FakeConn:
"""受控 fake:前 fail_times 次 execute 抛锁错误,之后返回哨兵。"""
def __init__(self, fail_times, error_text="database is locked"):
self.fail_times = fail_times
self.error_text = error_text
self.calls = 0
def execute(self, sql, params=()):
self.calls += 1
if self.calls <= self.fail_times:
raise sqlite3.OperationalError(self.error_text)
return "ok-sentinel"
class DbHelperTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "concurrency.db")
conn = connect(self.db_path)
try:
conn.execute(TASKS_DDL)
conn.commit()
finally:
conn.close()
def tearDown(self):
self.tmp.cleanup()
# -- 连接属性:WAL + busy_timeout + Row ------------------------------
def test_connect_sets_wal_busy_timeout_row_factory(self):
conn = connect(self.db_path)
try:
self.assertEqual(conn.row_factory, sqlite3.Row)
journal = conn.execute(
"PRAGMA journal_mode").fetchone()[0]
self.assertEqual(journal, "wal")
busy = conn.execute("PRAGMA busy_timeout").fetchone()[0]
self.assertEqual(busy, 5000)
# Row 行为:按列名访问,与 server.py 的 _task_to_dict 兼容。
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-row", "p1", "hello"),
)
conn.commit()
row = read_all(
conn, "SELECT * FROM tasks WHERE id = ?", ("t-row",))[0]
self.assertEqual(row["id"], "t-row")
self.assertEqual(row["prompt"], "hello")
finally:
conn.close()
def test_connect_custom_busy_timeout(self):
conn = connect(self.db_path, busy_timeout_ms=1234)
try:
busy = conn.execute("PRAGMA busy_timeout").fetchone()[0]
self.assertEqual(busy, 1234)
finally:
conn.close()
# -- 锁判定 ---------------------------------------------------------
def test_is_locked_error_only_for_locked_operational_error(self):
self.assertTrue(is_locked_error(
sqlite3.OperationalError("database is locked")))
self.assertTrue(is_locked_error(
sqlite3.OperationalError("Database Is Locked (retry)")))
self.assertFalse(is_locked_error(
sqlite3.OperationalError("no such table: tasks")))
self.assertFalse(is_locked_error(ValueError("database is locked")))
# -- 重试逻辑:fake 受控方式(确定性)--------------------------------
def test_retry_succeeds_after_two_locked_failures(self):
fake = FakeConn(fail_times=2)
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
result = execute_with_retry(fake, "INSERT ...", ())
self.assertEqual(result, "ok-sentinel")
self.assertEqual(fake.calls, 3) # 1 次初始 + 2 次重试
self.assertEqual(
[c.args[0] for c in sleep_mock.call_args_list],
[0.05, 0.1, 0.2][:2])
def test_retry_backoff_sequence_is_exponential(self):
fake = FakeConn(fail_times=3)
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
execute_with_retry(fake, "INSERT ...", ())
self.assertEqual(
[c.args[0] for c in sleep_mock.call_args_list],
[0.05, 0.1, 0.2])
def test_retry_exhausted_reraises_original_error(self):
fake = FakeConn(fail_times=99)
with mock.patch.object(db_module.time, "sleep"):
with self.assertRaises(sqlite3.OperationalError) as ctx:
execute_with_retry(fake, "INSERT ...", ())
self.assertIn("database is locked", str(ctx.exception))
# 默认 max_retries=3:1 次初始 + 3 次重试 = 4 次尝试,不多不少。
self.assertEqual(fake.calls, 4)
def test_non_locked_error_is_not_retried(self):
fake = FakeConn(fail_times=99, error_text="no such table: tasks")
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
with self.assertRaises(sqlite3.OperationalError):
execute_with_retry(fake, "SELECT ...", ())
self.assertEqual(fake.calls, 1)
sleep_mock.assert_not_called()
# -- 事务:成功提交 / 失败回滚 ---------------------------------------
def test_transaction_commits_and_rolls_back(self):
conn = connect(self.db_path)
try:
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-ok", "p1", "ok"),
)
self.assertEqual(
len(read_all(conn, "SELECT * FROM tasks")), 1)
with self.assertRaises(sqlite3.IntegrityError):
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-ok", "p1", "dup"),
)
# 冲突行回滚后仍只有原来 1 条,不留半写。
self.assertEqual(
len(read_all(conn, "SELECT * FROM tasks")), 1)
finally:
conn.close()
# -- 真实并发:8 线程 x 20 条 ----------------------------------------
def test_concurrent_writes_8_threads_x_20_no_loss(self):
errors = []
barrier = threading.Barrier(8)
def worker(thread_idx):
try:
barrier.wait(timeout=10)
conn = connect(self.db_path)
try:
for i in range(20):
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt,"
" status, idempotency_key, depends_on,"
" priority, created_by, created_at,"
" updated_at, last_heartbeat)"
" VALUES(?, ?, ?, 'queued', ?, '[]',"
" 0, 'worker', ?, ?, NULL)",
("t-%d-%d" % (thread_idx, i),
"p-concurrent",
"prompt %d-%d" % (thread_idx, i),
"key-%d-%d" % (thread_idx, i),
time.time(), time.time()),
)
finally:
conn.close()
except Exception as exc: # noqa: BLE001 - 汇总后统一断言
errors.append(exc)
threads = [threading.Thread(target=worker, args=(n,))
for n in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=60)
self.assertFalse([t for t in threads if t.is_alive()],
"worker 线程未在 60s 内结束")
self.assertEqual(errors, [], "并发写不应有任何异常: %r" % (errors,))
conn = connect(self.db_path)
try:
rows = read_all(
conn,
"SELECT id FROM tasks WHERE project_id = ?",
("p-concurrent",))
finally:
conn.close()
self.assertEqual(len(rows), 160)
self.assertEqual(len({r["id"] for r in rows}), 160)
if __name__ == "__main__":
unittest.main()
+275
View File
@@ -0,0 +1,275 @@
"""oauth 脚手架单测:state、授权 URL、回调、换 token 失败。
全部外部调用均为注入的 fake http 客户端,本文件不向真实
GitHub / Google 发任何请求;出现的 client_id / secret 哨兵值
均为测试用的假值(形如 test- / fake- 前缀),不是真实凭证。
"""
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from urllib.parse import parse_qs, urlparse
try:
from cloud.auth import AuthStore
from cloud.oauth import (
OAuthError,
OAuthStateStore,
build_authorization_url,
exchange_code_for_token,
handle_oauth_callback,
map_external_identity_to_local_user,
start_oauth_login,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.oauth import (
OAuthError,
OAuthStateStore,
build_authorization_url,
exchange_code_for_token,
handle_oauth_callback,
map_external_identity_to_local_user,
start_oauth_login,
)
PROVIDER = "github"
CLIENT_ID = "test-client-id-not-real"
REDIRECT_URI = "https://example.invalid/oauth/callback"
FAKE_SECRET_SENTINEL = "fake-secret-sentinel-not-real"
def _fake_token_client(token_result=None, error=None):
"""返回 fake http_client:记录调用,不联网。"""
calls = []
def fake(token_url, payload):
calls.append((token_url, dict(payload)))
if error is not None:
raise RuntimeError(error)
return dict(token_result or {"access_token": "fake-token-not-real"})
fake.calls = calls
return fake
class TestOAuthState(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "oauth.db")
self.store = OAuthStateStore(self.db_path)
def tearDown(self):
self.tmp.cleanup()
def test_state_create_then_validate_ok(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
self.assertTrue(state)
record = self.store.validate_state(state, PROVIDER, REDIRECT_URI)
self.assertIsNotNone(record)
self.assertEqual(record["provider"], PROVIDER)
self.assertEqual(record["redirect_uri"], REDIRECT_URI)
# 一次性:第二次校验必然失败(防重放)
self.assertIsNone(self.store.validate_state(state, PROVIDER))
def test_state_expired_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
expired_now = time.time() + 601 # 默认 TTL 600 秒
self.assertIsNone(self.store.validate_state(
state, PROVIDER, now=expired_now))
# 过期行已被删除,不会残留
row = self.store.conn.execute(
"SELECT state FROM oauth_states WHERE state = ?", (state,)
).fetchone()
self.assertIsNone(row)
def test_state_mismatch_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
# 不存在的 state
self.assertIsNone(self.store.validate_state(
"no-such-state", PROVIDER))
# provider 不匹配:拒绝,且不消耗合法 state
self.assertIsNone(self.store.validate_state(state, "google"))
# redirect_uri 不匹配:同样拒绝且不消耗
self.assertIsNone(self.store.validate_state(
state, PROVIDER, "https://example.invalid/other"))
# 合法组合仍可通过(前两次不匹配没有烧掉它)
self.assertIsNotNone(self.store.validate_state(
state, PROVIDER, REDIRECT_URI))
class TestAuthorizationUrl(unittest.TestCase):
def test_url_contains_params_and_no_secret(self):
state = "test-state-abc"
url = build_authorization_url(
PROVIDER, CLIENT_ID, REDIRECT_URI,
scope="read:user", state=state)
parsed = urlparse(url)
self.assertEqual(parsed.netloc, "github.com")
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
self.assertEqual(params["client_id"], CLIENT_ID)
self.assertEqual(params["redirect_uri"], REDIRECT_URI)
self.assertEqual(params["scope"], "read:user")
self.assertEqual(params["state"], state)
self.assertNotIn("secret", url.lower())
self.assertNotIn(FAKE_SECRET_SENTINEL, url)
def test_url_default_scope_and_google(self):
url = build_authorization_url(
"github", CLIENT_ID, REDIRECT_URI, state="s1")
self.assertIn("read%3Auser", url) # 默认 scope read:user
url = build_authorization_url(
"google", CLIENT_ID, REDIRECT_URI, state="s2")
params = {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
self.assertEqual(params["scope"], "openid email profile")
self.assertEqual(params["response_type"], "code")
self.assertNotIn("secret", url.lower())
def test_start_login_stores_state(self):
tmp = tempfile.TemporaryDirectory()
try:
store = OAuthStateStore(os.path.join(tmp.name, "s.db"))
state, url = start_oauth_login(
store, PROVIDER, CLIENT_ID, REDIRECT_URI)
self.assertIn("state=" + state, url)
row = store.conn.execute(
"SELECT provider FROM oauth_states WHERE state = ?",
(state,),
).fetchone()
self.assertEqual(row[0], PROVIDER)
finally:
tmp.cleanup()
def test_unknown_provider_rejected(self):
with self.assertRaises(OAuthError):
build_authorization_url(
"not-a-provider", CLIENT_ID, REDIRECT_URI)
class TestOAuthCallback(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "oauth.db")
self.store = OAuthStateStore(self.db_path)
# 同库上的 AuthStore:用于断言“失败不建用户/不记审计”。
self.auth = AuthStore(self.db_path)
def tearDown(self):
self.tmp.cleanup()
def _login(self, fake=None, **kwargs):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
return handle_oauth_callback(
self.store, PROVIDER, "test-code-not-real", state,
CLIENT_ID, REDIRECT_URI,
http_client=fake or _fake_token_client(
{"access_token": "fake-token-not-real",
"id": 12345, "login": "fake-user"}),
**kwargs)
def _count(self, table):
return self.store.conn.execute(
"SELECT COUNT(*) FROM %s" % table).fetchone()[0]
def test_callback_success_returns_identity(self):
identity = self._login()
self.assertEqual(identity["provider"], PROVIDER)
self.assertEqual(identity["external_id"], "12345")
self.assertEqual(identity["login"], "fake-user")
# access_token 不返回、不入库:回调只交身份
self.assertNotIn("access_token", identity)
def test_callback_maps_identity_to_local_user(self):
identity = self._login()
user_id, created = map_external_identity_to_local_user(
self.auth, PROVIDER, identity)
self.assertTrue(created)
user_id2, created2 = map_external_identity_to_local_user(
self.auth, PROVIDER, identity)
self.assertEqual(user_id, user_id2)
self.assertFalse(created2)
def test_callback_state_expired_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, PROVIDER, "code", state, CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client(),
now=time.time() + 601)
self.assertEqual(ctx.exception.reason, "state_expired")
def test_callback_state_mismatch_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, "google", "code", state, CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client())
self.assertEqual(ctx.exception.reason, "state_mismatch")
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, PROVIDER, "code", "missing-state",
CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client())
self.assertEqual(ctx.exception.reason, "state_not_found")
def test_token_exchange_failure_creates_no_session_or_user(self):
fake = _fake_token_client(error="provider refused code")
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, PROVIDER, "bad-code", state,
CLIENT_ID, REDIRECT_URI, http_client=fake)
self.assertEqual(ctx.exception.reason, "token_exchange_failed")
# 换 token 失败:无用户、无审计(本模块本来就不建会话,
# 这里断言失败路径也没有任何副作用写入同库的 auth 表)。
self.assertEqual(self._count("users"), 0)
self.assertEqual(self._count("audit_log"), 0)
def test_token_error_dict_creates_nothing(self):
fake = _fake_token_client(
{"error": "bad_verification_code"})
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError):
handle_oauth_callback(
self.store, PROVIDER, "bad-code", state,
CLIENT_ID, REDIRECT_URI, http_client=fake)
self.assertEqual(self._count("users"), 0)
def test_real_path_without_secret_refuses_before_network(self):
# http_client=None 且环境变量无 secret:必须在联网前拒绝。
# 用 patch 清空两个 secret 环境变量,并断言 urlopen 未被调用。
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
os.environ.pop("GOOGLE_OAUTH_CLIENT_SECRET", None)
with mock.patch("cloud.oauth._urllib_post") as mocked:
with self.assertRaises(OAuthError) as ctx:
exchange_code_for_token(
PROVIDER, "code", CLIENT_ID, REDIRECT_URI,
http_client=None)
self.assertEqual(
ctx.exception.reason, "client_secret_missing")
mocked.assert_not_called()
def test_userinfo_client_used_when_token_has_no_identity(self):
def userinfo(access_token):
self.assertEqual(access_token, "fake-token-not-real")
return {"id": 999, "login": "via-userinfo"}
state = self.store.create_state(PROVIDER, REDIRECT_URI)
identity = handle_oauth_callback(
self.store, PROVIDER, "code", state, CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client(
{"access_token": "fake-token-not-real"}),
userinfo_client=userinfo)
self.assertEqual(identity["external_id"], "999")
self.assertEqual(identity["login"], "via-userinfo")
if __name__ == "__main__":
unittest.main()
+304
View File
@@ -0,0 +1,304 @@
"""push.py 单测:订阅增删查、偏好过滤、分发错误不静默丢。
只用标准库,不联网、不向真实推送服务发任何请求:所有发送都用
假 sender(可调用对象或按通道分的 dict)注入;webpush/apns/fcm
只断言缺凭证报错,不调用任何 sender。
本文件不含任何真实密钥、token、证书或私钥。
"""
import os
import sqlite3
import sys
import unittest
from pathlib import Path
try:
from cloud.push import (
ALLOWED_PUSH_EVENTS,
CHANNELS,
FAILURE_EVENTS,
PREFERENCES,
create_subscription,
delete_subscription,
dispatch_event,
list_subscriptions,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.push import (
ALLOWED_PUSH_EVENTS,
CHANNELS,
FAILURE_EVENTS,
PREFERENCES,
create_subscription,
delete_subscription,
dispatch_event,
list_subscriptions,
)
class PushTestCase(unittest.TestCase):
def setUp(self):
# 内存库 + 单一连接:init 与查询共用同一连接,避免 :memory: 换连接丢表。
self.conn = sqlite3.connect(":memory:")
def tearDown(self):
self.conn.close()
def _sub(self, project="proj-a", user="user-1", channel="webhook",
target="https://example.invalid/hook", preference="instant"):
return create_subscription(
self.conn, project, user, channel, target, preference)
def _calls(self):
calls = []
def sender(message):
calls.append(message)
return calls, sender
class TestSubscriptionCrud(PushTestCase):
def test_create_and_list(self):
sub = self._sub()
self.assertEqual(sub["project_id"], "proj-a")
self.assertEqual(sub["user_id"], "user-1")
self.assertEqual(sub["channel"], "webhook")
self.assertEqual(sub["preference"], "instant")
listed = list_subscriptions(self.conn, "proj-a", "user-1")
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0]["id"], sub["id"])
def test_create_duplicate_returns_existing_not_duplicate_row(self):
first = self._sub()
second = self._sub()
self.assertEqual(first["id"], second["id"])
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 1)
def test_create_invalid_channel_or_preference_rejected(self):
with self.assertRaises(ValueError):
self._sub(channel="sms")
with self.assertRaises(ValueError):
self._sub(preference="weekly")
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 0)
def test_delete_by_id_then_no_longer_listed(self):
sub = self._sub()
self.assertTrue(delete_subscription(self.conn, sub["id"]))
self.assertEqual(
list_subscriptions(self.conn, "proj-a", "user-1"), [])
# 再次删除无匹配,返回 False,不报错。
self.assertFalse(delete_subscription(self.conn, sub["id"]))
def test_constants_cover_task_contract(self):
self.assertEqual(
set(CHANNELS), {"webhook", "slack", "webpush", "apns", "fcm"})
self.assertEqual(set(PREFERENCES), {"instant", "digest", "silent"})
self.assertEqual(
ALLOWED_PUSH_EVENTS,
{"completed", "failed", "goal-unmet", "merge-failed",
"review-requested"},
)
self.assertEqual(FAILURE_EVENTS, {"failed", "goal-unmet", "merge-failed"})
class TestDispatchPreferences(PushTestCase):
def test_silent_preference_never_sends(self):
self._sub(preference="silent")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["skipped_silent"]), 1)
self.assertEqual(result["skipped_silent"][0]["preference"], "silent")
def test_failure_event_sends_immediately_even_with_digest(self):
# 失败类:digest 也即时发,不进 skipped_for_digest。
for event_type in ("failed", "goal-unmet", "merge-failed"):
with self.subTest(event_type=event_type):
conn = sqlite3.connect(":memory:")
try:
create_subscription(
conn, "proj-a", "user-1", "webhook",
"https://example.invalid/hook", "digest")
calls, sender = self._calls()
result = dispatch_event(
conn, event_type, "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["event_type"], event_type)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["skipped_for_digest"], [])
finally:
conn.close()
def test_completed_event_filtered_by_digest_returns_pending_list(self):
self._sub(preference="digest")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=sender)
# 不立即发,但不是静默丢弃:明确列出待摘要。
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["skipped_for_digest"]), 1)
self.assertEqual(
result["skipped_for_digest"][0]["preference"], "digest")
self.assertEqual(result["errors"], [])
def test_review_requested_digest_also_deferred(self):
self._sub(preference="digest")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "review-requested", "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(calls, [])
self.assertEqual(len(result["skipped_for_digest"]), 1)
def test_instant_completed_sends(self):
self._sub(preference="instant")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["channel"], "webhook")
self.assertEqual(calls[0]["payload"], {"task_id": "t-1"})
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["errors"], [])
def test_invalid_event_rejected_and_sender_not_called(self):
self._sub(preference="instant")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "running", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(result["reason"], "event not allowed")
self.assertTrue(
any("event not allowed" in e["reason"] for e in result["errors"]))
class TestDispatchSendersAndCredentials(PushTestCase):
def test_webhook_without_sender_returns_no_sender_error_not_silent_ok(self):
self._sub(channel="webhook")
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"}, sender=None)
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("no sender", result["errors"][0]["reason"])
def test_slack_via_channel_dict_sender(self):
create_subscription(
self.conn, "proj-a", "user-1", "slack", "C123", "instant")
slack_calls = []
def slack_sender(message):
slack_calls.append(message)
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender={"slack": slack_sender})
self.assertEqual(len(slack_calls), 1)
self.assertEqual(slack_calls[0]["channel"], "slack")
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["errors"], [])
def test_webpush_apns_fcm_missing_credential_error_not_silent_drop(self):
cases = {
"webpush": "VAPID",
"apns": "APNs",
"fcm": "FCM",
}
for channel, keyword in cases.items():
with self.subTest(channel=channel):
conn = sqlite3.connect(":memory:")
try:
create_subscription(
conn, "proj-a", "user-1", channel,
"device-or-endpoint", "instant")
calls, sender = self._calls()
result = dispatch_event(
conn, "failed", "proj-a", {"task_id": "t-1"},
sender=sender)
# 绝不走假 sender、绝不假装成功:明确报错。
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("missing credential", result["errors"][0]["reason"])
self.assertIn(keyword, result["errors"][0]["reason"])
finally:
conn.close()
def test_sender_exception_recorded_not_raised_and_others_continue(self):
self._sub(user="user-1", target="https://example.invalid/hook-1")
def flaky(message):
if message["target"] == "https://example.invalid/hook-1":
raise RuntimeError("fake send failure")
calls.append(message)
calls = []
# 第二个订阅用另一个 target,同一 sender 只对第一个抛错。
create_subscription(
self.conn, "proj-a", "user-2", "webhook",
"https://example.invalid/hook-2", "instant")
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=flaky)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(len(result["errors"]), 1)
self.assertIn("sender failed", result["errors"][0]["reason"])
self.assertEqual(len(calls), 1)
class TestDispatchIsolation(PushTestCase):
def test_deleted_subscription_no_longer_receives(self):
sub = self._sub()
self.assertTrue(delete_subscription(self.conn, sub["id"]))
calls, sender = self._calls()
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(result["errors"], [])
def test_cross_project_subscriptions_do_not_mix(self):
self._sub(project="proj-a", user="user-1")
self._sub(project="proj-b", user="user-1",
target="https://example.invalid/hook-b")
calls, sender = self._calls()
# B 项目事件只发给 B 的订阅,A 的订阅收不到。
result_b = dispatch_event(
self.conn, "failed", "proj-b", {"task_id": "t-1"}, sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["target"], "https://example.invalid/hook-b")
self.assertEqual(len(result_b["sent"]), 1)
# A 项目事件只发给 A 的订阅。
calls.clear()
result_a = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(
calls[0]["target"], "https://example.invalid/hook")
self.assertEqual(len(result_a["sent"]), 1)
def test_list_subscriptions_scoped_by_project_and_user(self):
self._sub(project="proj-a", user="user-1")
self._sub(project="proj-a", user="user-2",
target="https://example.invalid/hook-2")
self._sub(project="proj-b", user="user-1",
target="https://example.invalid/hook-b")
self.assertEqual(len(list_subscriptions(self.conn, "proj-a")), 2)
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 1)
self.assertEqual(
len(list_subscriptions(self.conn, "proj-b", "user-1")), 1)
if __name__ == "__main__":
unittest.main()
+225
View File
@@ -0,0 +1,225 @@
"""新端点测试:记忆 / 知识库 / 成本 / 模型总览。
只用标准库 http.client,不跑真实模型,不联网。
每个测试用独立临时 SQLite 与临时端口。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from urllib.parse import quote
try:
from cloud.auth import AuthStore
from cloud.cost import CostStore
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.cost import CostStore
from cloud.server import create_server
class EndpointsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.viewer_id, self.viewer_key = self.auth.create_user("viewer")
self.outsider_id, self.outsider_key = self.auth.create_user("outsider")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
self.auth.set_member(
self.project["id"], self.owner_id, self.viewer_id, "viewer")
# outsider 自己的项目,用于跨项目隔离测试。
status, self.other = self._req(
"POST", "/projects", {"name": "other"}, self.outsider_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _pid(self):
return self.project["id"]
# -- 记忆 ----------------------------------------------------------
def test_memory_create_list_supersede(self):
status, created = self._req(
"POST", "/projects/%s/memory" % self._pid(),
{"thread_id": "t1", "goal": "加法", "conclusion": "已加 add2",
"files_changed": ["mod.py"]},
self.owner_key)
self.assertEqual(status, 201, created)
status, body = self._req(
"GET", "/projects/%s/memory" % self._pid(), key=self.viewer_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["summaries"]), 1)
self.assertIn("add2", body["prompt_context"])
status, body = self._req(
"POST", "/projects/%s/memory/%s/supersede"
% (self._pid(), created["id"]), {}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["status"], "deprecated")
status, body = self._req(
"GET", "/projects/%s/memory" % self._pid(), key=self.owner_key)
self.assertEqual(body["summaries"], [])
def test_memory_no_key_401_and_outsider_403(self):
status, _ = self._req(
"GET", "/projects/%s/memory" % self._pid())
self.assertEqual(status, 401)
status, _ = self._req(
"GET", "/projects/%s/memory" % self._pid(),
key=self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"POST", "/projects/%s/memory" % self._pid(),
{"goal": "g", "conclusion": "c"}, self.viewer_key)
self.assertEqual(status, 403)
def test_memory_cross_project_supersede_404(self):
status, created = self._req(
"POST", "/projects/%s/memory" % self._pid(),
{"goal": "g", "conclusion": "c"}, self.owner_key)
self.assertEqual(status, 201)
# outsider 拿自己项目的路径去废弃别人的摘要:应 403/404,不成功。
status, _ = self._req(
"POST", "/projects/%s/memory/%s/supersede"
% (self.other["id"], created["id"]), {}, self.outsider_key)
self.assertIn(status, (403, 404))
def test_memory_unknown_project_404(self):
status, _ = self._req(
"GET", "/projects/nope/memory", key=self.owner_key)
self.assertEqual(status, 404)
# -- 知识库 ---------------------------------------------------------
def test_kb_index_search_and_isolation(self):
status, _ = self._req(
"POST", "/projects/%s/kb/index" % self._pid(),
{"path": "README.md", "content": "用户登录流程说明", "kind": "readme"},
self.owner_key)
self.assertEqual(status, 201)
status, body = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self._pid(), quote("登录")),
key=self.viewer_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["results"]), 1)
# outsider 在自己项目里搜不到别人的文档。
status, body = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self.other["id"], quote("登录")),
key=self.outsider_key)
self.assertEqual(status, 200)
self.assertEqual(body["results"], [])
# 无 key 401,越权 403。
status, _ = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self._pid(), quote("登录")))
self.assertEqual(status, 401)
status, _ = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self._pid(), quote("登录")),
key=self.outsider_key)
self.assertEqual(status, 403)
# -- 成本 -----------------------------------------------------------
def test_task_and_project_cost(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self._pid(), "prompt": "做加法"}, self.owner_key)
self.assertEqual(status, 201)
store = CostStore(self.db_path)
try:
store.record(task["id"], self._pid(), "gpt-6.1-sol",
input_tokens=100, output_tokens=50,
usd_micros=1234, source="actual", fallback_count=1)
store.record_estimated(task["id"], self._pid(), "gpt-6.1-sol",
"prompt" * 10, "output" * 10)
finally:
store._conn.close()
status, body = self._req(
"GET", "/tasks/%s/cost" % task["id"], key=self.viewer_key)
self.assertEqual(status, 200)
self.assertEqual(body["actual_tokens"], 150)
self.assertGreater(body["estimated_tokens"], 0)
self.assertEqual(body["fallback_count"], 1)
status, _ = self._req(
"GET", "/tasks/%s/cost" % task["id"], key=self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"GET", "/tasks/nope/cost", key=self.owner_key)
self.assertEqual(status, 404)
status, body = self._req(
"GET", "/projects/%s/cost" % self._pid(), key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["overall"]["turns"], 2)
# -- 模型总览 -------------------------------------------------------
def test_models_overview_isolated(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self._pid(), "prompt": "x"}, self.owner_key)
store = CostStore(self.db_path)
try:
store.record(task["id"], self._pid(), "gpt-6.1-sol",
input_tokens=10, output_tokens=5)
finally:
store._conn.close()
status, body = self._req("GET", "/models", key=self.owner_key)
self.assertEqual(status, 200)
self.assertIn("gpt-6.1-sol", body["priority_list"])
models = {m["model"] for m in body["model_stats"]}
self.assertIn("gpt-6.1-sol", models)
# outsider 只能看到自己项目的统计(目前为空),看不到别人的。
status, body = self._req("GET", "/models", key=self.outsider_key)
self.assertEqual(status, 200)
models = {m["model"] for m in body["model_stats"]}
self.assertNotIn("gpt-6.1-sol", models)
status, _ = self._req("GET", "/models")
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()
+177
View File
@@ -0,0 +1,177 @@
"""OAuth 与推送的 HTTP 端点测试。
不联网、不向真实 GitHub/Google/推送服务发请求;OAuth 回调成功
路径由模块级单测覆盖,这里只测 HTTP 层的失败路径与订阅分发。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
from cloud.server import create_server
class OAuthPushEndpointsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.outsider_id, self.outsider_key = self.auth.create_user("outsider")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
# -- OAuth -----------------------------------------------------------
def test_oauth_login_returns_url_without_secret(self):
status, body = self._req(
"GET",
"/oauth/github/login?client_id=test-client"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 200)
self.assertIn("github.com/login/oauth/authorize",
body["authorization_url"])
self.assertIn("test-client", body["authorization_url"])
self.assertNotIn("secret", body["authorization_url"])
self.assertTrue(body["state"])
def test_oauth_login_unknown_provider_400(self):
status, _ = self._req(
"GET",
"/oauth/nope/login?client_id=x"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 400)
def test_oauth_callback_bad_state_400_no_session(self):
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": "no-such-state",
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
self.assertEqual(status, 400)
self.assertIn("state", body["error"])
def test_oauth_callback_good_state_no_secret_400(self):
store = OAuthStateStore(self.db_path)
try:
state = store.create_state(
"github", "http://127.0.0.1/cb")
finally:
store.conn.close()
# 确保环境里没有真实 secret(本测试不依赖它)。
old = os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
try:
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": state,
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
finally:
if old is not None:
os.environ["GITHUB_OAUTH_CLIENT_SECRET"] = old
self.assertEqual(status, 400)
self.assertIn("client_secret", body["error"])
# -- 推送 -------------------------------------------------------------
def test_push_subscribe_list_dispatch_and_delete(self):
pid = self.project["id"]
status, sub = self._req(
"POST", "/projects/%s/push/subscriptions" % pid,
{"channel": "webhook", "target": "https://example.invalid/hook",
"preference": "instant"},
self.owner_key)
self.assertEqual(status, 201, sub)
status, body = self._req(
"GET", "/projects/%s/push/subscriptions" % pid,
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["subscriptions"]), 1)
# HTTP 层无注入 sender:分发应报“无 sender”,不静默成功。
status, body = self._req(
"POST", "/admin/push/dispatch",
{"event_type": "failed", "project_id": pid,
"payload": {"task_id": "t1"}},
self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["errors"]), 1)
self.assertIn("no sender", body["errors"][0]["reason"])
# outsider 不能往别人项目里订阅/分发。
status, _ = self._req(
"POST", "/projects/%s/push/subscriptions" % pid,
{"channel": "webhook", "target": "https://example.invalid/x"},
self.outsider_key)
self.assertEqual(status, 403)
# 删除:DELETE 方法须可用,且删后列表为空。
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
conn.request("DELETE",
"/projects/%s/push/subscriptions/%s" % (pid, sub["id"]),
headers={"X-API-Key": self.owner_key})
resp = conn.getresponse()
resp.read()
self.assertEqual(resp.status, 200)
finally:
conn.close()
status, body = self._req(
"GET", "/projects/%s/push/subscriptions" % pid,
key=self.owner_key)
self.assertEqual(body["subscriptions"], [])
def test_push_dispatch_requires_auth(self):
status, _ = self._req(
"POST", "/admin/push/dispatch",
{"event_type": "completed", "project_id": self.project["id"]})
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -234,6 +234,37 @@ CI 过了才合。
fallback 列表 + 成本报告。依赖 3A 的任务记录,不依赖
3B/3C,可以和 3C 并行。
## 补齐进度(2026-10-26 第二轮,上一轮留下的未完成项)
已做完(不要再列为未完成):
- 记忆 / 知识库 / 成本 / 模型已接成 server.py 正式端点,
全部走 X-API-Key + 项目 RBAC(除 /health、/webhooks/*、
/oauth/* pre-auth 外),单测覆盖 401/403/404 与跨项目隔离。
- SQLite 并发:连接统一走 cloud/db.py(WAL + busy_timeout
5000ms + 写锁有限重试 3 次指数退避),多线程并发写单测已覆盖;
但单机上限仍在,高并发要换 Postgres,见下“仍留”。
- OAuth 接线脚手架:cloud/oauth.py(state 10 分钟过期一次性、
授权 URL 生成、回调验 state + 换 token,token 换取可注入 fake
客户端,secret 只走环境变量),端点 GET /oauth/{provider}/login
与 POST /oauth/{provider}/callback 已接;真实应用注册与绑定仍缺。
- 推送订阅与分发:cloud/push.py + 端点(订阅增删查、分发按
偏好过滤、只发状态变化事件),webhook/slack 走可注入 sender,
webpush/apns/fcm 只做接口 + 缺凭证明确报错;真实凭证仍缺。
仍留(需要外部条件,不是代码能假装解决的):
- 常驻主机 / 托管服务(VM 回收问题没变)。
- GitHub 远端 + PAT(真实 PR 仍缺这两样)。
- Slack App(真实签名密钥、斜杠命令、事件订阅)。
- 云容器账号(CloudExecutor 仍是占位,Docker 未接通挂载 /
网络隔离 / 资源限额)。
- 模型 key 供给方式(定时/worker 进程手里没有中转站 key,
上次 real-nightly-tests 就卡在这里)。
- OAuth 真实应用注册、回调公网地址、已有账号绑定流程。
- 推送真实证书(VAPID / APNs / FCM)与真实发送端。
- worker 执行、云端执行、真实通知与真实模型回合的接通;
memory/KB/成本/模型路由的 HTTP 端点已接,但 worker 回写
completed/merged 与成本记录仍需接线。
## 需要用户定的事(现在就可以定,不用等代码)
1. 控制面跑在哪:常驻机器(自己的 Mac/服务器)还是