GitHub App 一键安装:manifest 建 App、安装记录、双 secret 验签、/fix-review 内容去重

This commit is contained in:
host committed 2026-10-26 16:02:45 +08:00
1 parent ecf4b5c715
commit 32059178bd
5 files changed
+1210 -14

No files matched your search

+39
View File
@@ -159,6 +159,45 @@ client_secret(只走环境变量),不是无认证后门。
没有返回 404)后恢复原来的 oauth 账号登录。查看自己的绑定:
`GET /oauth/{provider}/bindings`(须持本站 key,只看得到自己的)。
### GitHub App 一键安装(已实现安装一半;App-token API 动作未做)
目标:把手动建 webhook(填 URL、secret、选事件)变成“点一下
安装”。流程用 GitHub manifest 流程(GitHub 文档标准流程):
1. 操作方持本站 key 调 `POST /github-app/setup/state`(body 可含
org),返回 `{state, setup_url}`;要求环境变量
`GITHUB_APP_PUBLIC_BASE`(公网域名,如 `https://api.diante.me`,
manifest 里的 hook / 回调地址都由它拼出)。
2. 用户打开 setup_url:表单页含 manifest JSON(App 名默认
`codex-app-server`,可由 `GITHUB_APP_NAME` / `GITHUB_APP_URL`
改;权限 contents:write、pull_requests:write、issues:read,
事件 issue_comment、pull_request;默认私有 App,只有所有者
能安装,`GITHUB_APP_PUBLIC=1` 可翻成公开)与 state,点提交
到 GitHub 建 App 页(个人账号 `/settings/apps/new`,组织账号
`/organizations/{org}/settings/apps/new`),用户确认 Create。
3. GitHub 跳回 `GET /github-app/callback?code=...&state=...`:
server 验 setup state(一次性、1 小时过期,manifest code 同样
1 小时内有效)→ POST
`api.github.com/app-manifests/{code}/conversions` 换回 id、slug、
webhook_secret、pem → 存库里 `github_app_config` 表(单行)。
成功页只显示 App 身份与安装链接
`https://github.com/apps/<slug>/installations/new`,绝不显示
webhook_secret 与 pem。
4. 用户点安装:installation 事件到 `/webhooks/github`,记
`github_app_installations` 表(created/added 增行、removed 减
行、deleted 清安装),`GET /github-app/status`(须持本站 key)
可查配置与安装列表,同样不含 secret/pem。
验签与去重:webhook 验签同时接受 App secret(库里)与 env 里
`GITHUB_WEBHOOK_SECRET`(手动 webhook,过渡期共存);App 装好
后应删掉手动 webhook,避免同一事件送两遍;即使共存,
/fix-review 也有内容去重(repo + PR + 评论 id,
`github_fix_review_dedup` 表),只建一次任务。
诚实边界:pem 虽然存下来了,但本阶段不用它签 JWT、不用它换
installation token;推分支、建 PR 仍走 gh + PAT(见“云端派发与
自动 PR”),App-token API 动作是下一阶段。
### 推送凭证清单
- webhook / slack:需要可注入 sender(worker 侧),HTTP 层没有
+412
View File
@@ -0,0 +1,412 @@
"""GitHub App 一键安装:manifest 建 App + 安装记录 + App webhook。
范围(诚实边界):本模块只做“安装”这一半——manifest 生成、
建 App 回调换凭证、安装事件记录、App webhook 验签。App 的
pem 虽然存下来了,但本模块不用它签 JWT、不用它换 installation
token:推分支、建 PR 目前仍走 gh + PAT(见 cloud/pr.py),
App-token API 动作是下一阶段,不在本模块假装已做。
流程(GitHub manifest 流程,见 GitHub 文档):
POST /github-app/setup/state(持本站 key)-> state + setup_url
-> 用户打开 setup 页(表单含 manifest JSON + state)点提交
-> GitHub 建 App 页(用户确认 Create)
-> GitHub 跳回 /github-app/callback?code=...&state=...
-> server 验 setup state(一次性、1 小时过期)-> 换 code
(POST api.github.com/app-manifests/{code}/conversions,
无需认证,1 小时内有效)-> 存 App 配置
-> 成功页给安装链接(https://github.com/apps/<slug>/
installations/new),用户点安装 -> installation 事件
到 /webhooks/github -> 记安装记录。
凭证存放:App 配置存数据库 github_app_config 表(单行),
不进仓库、不进日志、不出现在任何 API 响应里;webhook_secret
与 pem 只在 server 进程内读(验签用 secret,JWT 阶段用 pem)。
与 env 里 GITHUB_WEBHOOK_SECRET 的关系:手动 webhook 仍用
env secret,App webhook 用库里 secret,验签时两个都试(过渡
期共存);App 装好后应删掉手动 webhook,避免同一事件送两
遍(/fix-review 另有内容去重兜底,见 claim_fix_review)。
零第三方依赖,只用 Python 标准库;换 code 与 userinfo 均可
注入 fake http 客户端做单测。
"""
import json
import secrets
import time
import urllib.request
from cloud.db import connect as _db_connect
# setup state 有效期:与 manifest code 的 1 小时对齐。
SETUP_STATE_TTL_SECONDS = 3600
# manifest 默认值:App 名与主页;hook / 回调地址由部署的公网
# 域名(GITHUB_APP_PUBLIC_BASE)拼出,不硬编码 diante.me。
DEFAULT_APP_NAME = "codex-app-server"
DEFAULT_APP_URL = "https://github.com/openqj/codex-app-server"
# App 权限与事件:与当前 PAT 能力对齐(读仓库、推分支、建 PR、
# 读 issue),不多要;webhook 只订阅 /fix-review 与 PR 相关事件。
APP_PERMISSIONS = {
"contents": "write",
"pull_requests": "write",
"issues": "read",
}
APP_EVENTS = ["issue_comment", "pull_request"]
class AppError(Exception):
"""App 流程失败:换 code 失败、响应缺字段等。
reason 取值:``exchange_failed`` / ``config_incomplete`` /
``state_invalid`` / ``state_expired``。
"""
def __init__(self, message, reason=None):
super().__init__(message)
self.reason = reason
def build_app_manifest(public_base, app_name=DEFAULT_APP_NAME,
app_url=DEFAULT_APP_URL, public=False):
"""拼 manifest dict:GitHub 建 App 表单直接提交这份 JSON。
public=False 是单租户部署的默认(只有 App 所有者能安装);
多租户 SaaS 形态才需要 public=True(改环境变量翻转)。
"""
if not public_base or not isinstance(public_base, str):
raise ValueError("public_base required")
base = public_base.rstrip("/")
return {
"name": app_name,
"url": app_url,
"hook_attributes": {"url": base + "/webhooks/github"},
"redirect_url": base + "/github-app/callback",
"callback_urls": [base + "/github-app/callback"],
"public": bool(public),
"default_permissions": dict(APP_PERMISSIONS),
"default_events": list(APP_EVENTS),
}
class SetupStateStore:
"""setup state 存储:一次性、1 小时过期,防回调被冒用。
表 github_app_setup_states:state(主键)、created_at。
state 只由持本站 key 调 POST /github-app/setup/state 的
操作方签发;回调验到即删,同一 state 第二次回调必然失败。
"""
def __init__(self, db_path):
self.db_path = str(db_path)
self.conn = _db_connect(self.db_path)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_setup_states("
"state TEXT PRIMARY KEY, created_at REAL)"
)
self.conn.commit()
def create_state(self):
state = secrets.token_urlsafe(32)
self.conn.execute(
"INSERT INTO github_app_setup_states(state, created_at)"
" VALUES(?, ?)",
(state, time.time()),
)
self.conn.commit()
return state
def consume_state(self, state, now=None,
ttl_seconds=SETUP_STATE_TTL_SECONDS):
"""校验并消耗 state:成功返回 True,失败返回 False。
不存在 -> False;过期 -> 删行并返回 False;成功 -> 删行
并返回 True。``now`` 只为单测注入时间。
"""
if not state:
return False
if now is None:
now = time.time()
row = self.conn.execute(
"SELECT created_at FROM github_app_setup_states"
" WHERE state = ?",
(state,),
).fetchone()
if row is None:
return False
self.conn.execute(
"DELETE FROM github_app_setup_states WHERE state = ?",
(state,),
)
self.conn.commit()
if now > row[0] + ttl_seconds:
return False
return True
def _urllib_post_json(url, headers):
"""默认的换 code 实现:POST 空 body,返回解析后的 dict。"""
request = urllib.request.Request(url, data=b"", method="POST",
headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode("utf-8", "replace")
try:
parsed = json.loads(raw)
except ValueError as exc:
raise AppError("manifest conversion response is not JSON: %s"
% (exc,), reason="exchange_failed") from exc
if not isinstance(parsed, dict):
raise AppError("manifest conversion response is not an object",
reason="exchange_failed")
return parsed
def exchange_manifest_code(code, http_client=None):
"""用 manifest code 换 App 配置:返回 {id, slug, client_id,
webhook_secret, pem, html_url}(缺关键字段即 AppError)。
http_client 注入约定(单测用):``http_client(url, headers)
-> dict``。真实路径无需认证(code 本身就是一次性凭证),
1 小时内必须完成。
"""
if not code or not isinstance(code, str):
raise AppError("code required", reason="exchange_failed")
url = "https://api.github.com/app-manifests/%s/conversions" % code
headers = {"Accept": "application/vnd.github+json",
"User-Agent": "codex-app-server"}
client = http_client if http_client is not None else _urllib_post_json
try:
result = client(url, headers)
except AppError:
raise
except Exception as exc:
raise AppError(
"manifest conversion failed: %s" % (exc,),
reason="exchange_failed",
) from exc
if not isinstance(result, dict) or result.get("error"):
raise AppError("manifest conversion failed: %r" % (result,),
reason="exchange_failed")
missing = [k for k in ("id", "webhook_secret", "pem")
if not result.get(k)]
if missing:
raise AppError(
"manifest conversion response missing: %s" % ",".join(missing),
reason="config_incomplete")
return {
"app_id": result.get("id"),
"slug": result.get("slug"),
"client_id": result.get("client_id"),
"webhook_secret": result.get("webhook_secret"),
"pem": result.get("pem"),
"html_url": result.get("html_url"),
}
def save_app_config(conn, config):
"""存 App 配置(单行,id 恒为 1;重复 setup 直接覆盖)。
调用方须保证 config 含 webhook_secret 与 pem;本函数不校验
它们的值,只负责落库。secret/pem 绝不出现在返回值里。
"""
conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_config("
"id INTEGER PRIMARY KEY CHECK(id = 1), "
"app_id TEXT, slug TEXT, client_id TEXT, "
"webhook_secret TEXT, pem TEXT, html_url TEXT, created_at REAL)"
)
conn.execute(
"INSERT OR REPLACE INTO github_app_config(id, app_id, slug,"
" client_id, webhook_secret, pem, html_url, created_at)"
" VALUES(1, ?, ?, ?, ?, ?, ?, ?)",
(config.get("app_id"), config.get("slug"),
config.get("client_id"), config.get("webhook_secret"),
config.get("pem"), config.get("html_url"), time.time()),
)
conn.commit()
def get_app_config(conn):
"""取 App 配置 dict,未配置返回 None。
返回里含 webhook_secret 与 pem——调用方只许在 server 进程内
用(验签 / JWT),绝不放进任何 API 响应;公开状态接口须
另外拼不含 secret 的视图。
"""
conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_config("
"id INTEGER PRIMARY KEY CHECK(id = 1), "
"app_id TEXT, slug TEXT, client_id TEXT, "
"webhook_secret TEXT, pem TEXT, html_url TEXT, created_at REAL)"
)
row = conn.execute(
"SELECT app_id, slug, client_id, webhook_secret, pem, html_url,"
" created_at FROM github_app_config WHERE id = 1",
).fetchone()
if row is None:
return None
return {
"app_id": row[0], "slug": row[1], "client_id": row[2],
"webhook_secret": row[3], "pem": row[4], "html_url": row[5],
"created_at": row[6],
}
def public_app_status(conn):
"""公开状态视图:不含 secret/pem,只给配置与否 + 身份信息。"""
config = get_app_config(conn)
if config is None:
return {"configured": False, "app_id": None, "slug": None,
"html_url": None}
return {"configured": True, "app_id": config["app_id"],
"slug": config["slug"], "html_url": config["html_url"]}
def _ensure_installations_table(conn):
conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_installations("
"installation_id TEXT, account_login TEXT, "
"repo_full_name TEXT, created_at REAL, "
"UNIQUE(installation_id, repo_full_name))"
)
conn.commit()
def record_installation_repos(conn, installation_id, account_login, repos):
"""记安装的仓库行(按 (installation_id, repo) 去重,多次
同步不翻倍)。repos 为 full_name 字符串列表。"""
_ensure_installations_table(conn)
now = time.time()
for repo in repos or []:
conn.execute(
"INSERT OR IGNORE INTO github_app_installations("
"installation_id, account_login, repo_full_name, created_at)"
" VALUES(?, ?, ?, ?)",
(str(installation_id), account_login, repo, now),
)
conn.commit()
def remove_installation_repos(conn, installation_id, repos):
"""删安装的部分仓库行(removed 事件用)。"""
_ensure_installations_table(conn)
for repo in repos or []:
conn.execute(
"DELETE FROM github_app_installations"
" WHERE installation_id = ? AND repo_full_name = ?",
(str(installation_id), repo),
)
conn.commit()
def remove_installation(conn, installation_id):
"""删整个安装的全部仓库行(deleted / suspended 后的清理用,
返回删掉的行数)。"""
_ensure_installations_table(conn)
cur = conn.execute(
"DELETE FROM github_app_installations WHERE installation_id = ?",
(str(installation_id),),
)
conn.commit()
return cur.rowcount
def list_installations(conn):
"""列出全部安装行(按 installation_id / repo 排序)。"""
_ensure_installations_table(conn)
rows = conn.execute(
"SELECT installation_id, account_login, repo_full_name, created_at"
" FROM github_app_installations"
" ORDER BY installation_id, repo_full_name",
).fetchall()
return [{"installation_id": r[0], "account_login": r[1],
"repo_full_name": r[2], "created_at": r[3]} for r in rows]
def parse_installation_event(payload):
"""解析 installation 事件,返回动作 dict 或 None。
只认 action 在 created / deleted / added / removed /
new_permissions_accepted 且带 installation 的 payload;其它
事件返回 None(调用方继续走原有解析)。repositories 取
full_name:created 用 payload repositories,added 用
repositories_added,removed 用 repositories_removed。
"""
if not isinstance(payload, dict):
return None
action = payload.get("action")
if action not in ("created", "deleted", "added", "removed",
"new_permissions_accepted"):
return None
installation = payload.get("installation")
if not isinstance(installation, dict):
return None
account = installation.get("account") or {}
def names(key):
out = []
for repo in payload.get(key) or []:
if isinstance(repo, dict) and repo.get("full_name"):
out.append(repo["full_name"])
return out
if action == "added":
repos = names("repositories_added")
elif action == "removed":
repos = names("repositories_removed")
elif action == "deleted":
repos = []
else:
repos = names("repositories")
return {
"action": action,
"installation_id": installation.get("id"),
"account_login": account.get("login"),
"repos": repos,
}
def claim_fix_review(conn, repo, pr_number, comment_id):
""" /fix-review 内容去重:同一仓库 + PR + 评论只建一次任务。
手动 webhook 与 App webhook 共存时,同一评论会送两遍,
delivery_id 不同、内容相同——delivery 去重拦不住,内容去重
才能拦。返回 (is_new, task_id):首次认领 True;已认领过
返回 False 与当初建的 task_id(可能为 None:认领方还没来
得及把 task_id 写回,任务正在建)。comment_id 为 None 时
无法去重,直接放行(不阻塞合法任务)。
"""
conn.execute(
"CREATE TABLE IF NOT EXISTS github_fix_review_dedup("
"repo TEXT, pr_number INTEGER, comment_id TEXT, "
"task_id TEXT, created_at REAL, "
"UNIQUE(repo, pr_number, comment_id))"
)
if comment_id is None:
return True, None
cur = conn.execute(
"INSERT OR IGNORE INTO github_fix_review_dedup("
"repo, pr_number, comment_id, task_id, created_at)"
" VALUES(?, ?, ?, NULL, ?)",
(repo, pr_number, str(comment_id), time.time()),
)
conn.commit()
if cur.rowcount > 0:
return True, None
row = conn.execute(
"SELECT task_id FROM github_fix_review_dedup"
" WHERE repo = ? AND pr_number = ? AND comment_id = ?",
(repo, pr_number, str(comment_id)),
).fetchone()
return False, row[0] if row else None
def set_fix_review_task_id(conn, repo, pr_number, comment_id, task_id):
"""认领后把 task_id 写回内容去重行(供重复送达时回读)。"""
if comment_id is None:
return
conn.execute(
"UPDATE github_fix_review_dedup SET task_id = ?"
" WHERE repo = ? AND pr_number = ? AND comment_id = ?",
(task_id, repo, pr_number, str(comment_id)),
)
conn.commit()
+306 -14
View File
@@ -48,7 +48,7 @@ import uuid
from collections import deque
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs, quote, urlparse
from cloud.auth import AuthStore
from cloud.cost import CostStore
@@ -85,12 +85,30 @@ from cloud.push import (
dispatch_event as _push_dispatch,
list_subscriptions as _push_list,
)
from cloud.github_app import (
DEFAULT_APP_NAME,
DEFAULT_APP_URL,
AppError,
SetupStateStore,
build_app_manifest,
claim_fix_review,
exchange_manifest_code,
get_app_config,
list_installations,
parse_installation_event,
public_app_status,
record_installation_repos,
remove_installation,
remove_installation_repos,
save_app_config,
set_fix_review_task_id,
)
from cloud.webhooks import (
WebhookDeliveryStore,
parse_github_check_run,
parse_github_pr_comment,
parse_slack_command,
verify_github_signature,
verify_github_signature_any,
verify_slack_signature,
)
@@ -559,6 +577,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
else:
self._send_json(404, {"error": "not found"})
return
if path.startswith("/github-app/"):
# GitHub App 一键安装:setup/state 与 status 须持本站 key
# (在处理器内验);setup 表单页与 callback 靠 setup
# state 凭证(一次性、1 小时),不走 X-API-Key(浏览器
# 回调带不了请求头,state 就是凭证)。
if method == "POST" and path == "/github-app/setup/state":
self._github_app_setup_state()
elif method == "GET" and path == "/github-app/setup":
self._github_app_setup_page()
elif method == "GET" and path == "/github-app/callback":
self._github_app_callback()
elif method == "GET" and path == "/github-app/status":
self._github_app_status()
else:
self._send_json(404, {"error": "not found"})
return
if path.startswith("/webhooks/"):
# webhook 靠签名认证(外部服务无法持 X-API-Key),不走 API key。
if method != "POST":
@@ -2042,6 +2076,196 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
self._send_json(200, {"unbound": True, "provider": provider})
# -- GitHub App 一键安装 ------------------------------------------
def _github_app_public_base(self):
base = os.environ.get("GITHUB_APP_PUBLIC_BASE") or ""
return base.rstrip("/") or None
def _github_app_error_page(self, reason, message):
"""App 流程错误页:只含 reason 与提示,不含任何 secret、
pem、code、state(message 来自流程错误,本身不含密钥,
仍做 HTML 转义)。"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>GitHub App setup failed</title></head><body>"
"<h1>GitHub App setup failed</h1>"
"<p>reason: %s</p><p>%s</p>"
"</body></html>"
% (html.escape(str(reason or "error")),
html.escape(str(message))))
def _github_app_setup_state(self):
"""签发 setup state:须持本站 key(只有操作方能发起建 App)。
要求环境变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
https://api.diante.me):manifest 里的 hook / 回调地址
都由它拼出。返回 {state, setup_url},setup_url 是用户
在浏览器里打开的表单页;state 一次性、1 小时过期。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
base = self._github_app_public_base()
if not base:
self._send_json(400, {
"error": "GITHUB_APP_PUBLIC_BASE not configured"})
return
body, err = self._read_json()
if err:
body = {}
org = (body or {}).get("org")
store = SetupStateStore(self.server.db_path)
try:
state = store.create_state()
finally:
try:
store.conn.close()
except Exception:
pass
setup_url = base + "/github-app/setup?state=" + state
if org:
setup_url += "&org=" + quote(str(org))
self._send_json(200, {"state": state, "setup_url": setup_url})
def _github_app_setup_page(self):
"""建 App 表单页:公开页,但 state 必须是操作方签发的。
无 state 或 state 未签发时不渲染表单(state 是回调的
唯一凭证,公开页自己现发 state 等于谁都能覆盖 App 配置)。
表单按 GitHub manifest 文档 POST 到 github.com 的建 App
页(个人账号 /settings/apps/new,组织账号
/organizations/{org}/settings/apps/new),manifest JSON
由 JS 填进隐藏字段;用户在 GitHub 页确认 Create 后跳回
本站 /github-app/callback。
"""
query = parse_qs(urlparse(self.path).query)
state = (query.get("state") or [None])[0]
org = (query.get("org") or [None])[0]
if not state:
self._send_html(400, self._github_app_error_page(
"missing_state",
"setup state required: ask the operator to issue one"))
return
base = self._github_app_public_base()
if not base:
self._send_html(400, self._github_app_error_page(
"not_configured", "GITHUB_APP_PUBLIC_BASE not configured"))
return
manifest = build_app_manifest(
base,
app_name=os.environ.get("GITHUB_APP_NAME") or DEFAULT_APP_NAME,
app_url=os.environ.get("GITHUB_APP_URL") or DEFAULT_APP_URL,
public=os.environ.get("GITHUB_APP_PUBLIC") == "1")
manifest_json = json.dumps(manifest).replace("<", "\\u003c")
if org:
action = ("https://github.com/organizations/%s"
"/settings/apps/new?state=%s"
% (quote(str(org)),
quote(state)))
else:
action = ("https://github.com/settings/apps/new?state=%s"
% quote(state))
perms = ", ".join("%s: %s" % kv
for kv in manifest["default_permissions"].items())
events = ", ".join(manifest["default_events"])
page = (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>Create GitHub App</title></head><body>"
"<h1>Create GitHub App</h1>"
"<p>App name: %s</p>"
"<p>Webhook URL: %s</p>"
"<p>Permissions: %s</p>"
"<p>Events: %s</p>"
"<p>Click Submit to create the app on GitHub. GitHub will "
"redirect back here to finish setup.</p>"
"<form action=\"%s\" method=\"post\">"
"<input type=\"hidden\" name=\"manifest\" id=\"manifest\">"
"<input type=\"submit\" value=\"Create GitHub App\">"
"</form>"
"<script>document.getElementById(\"manifest\").value = "
"JSON.stringify(%s);</script>"
"</body></html>"
% (html.escape(manifest["name"]),
html.escape(manifest["hook_attributes"]["url"]),
html.escape(perms), html.escape(events),
html.escape(action), manifest_json))
self._send_html(200, page)
def _github_app_callback(self):
"""建 App 回调:验 setup state -> 换 code -> 存配置。
state 一次性、1 小时过期;换 code 失败或响应缺字段时
报错误页,不存任何东西。成功页只显示 App 身份(slug、
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
(两者只在库里,server 进程内用)。
"""
query = parse_qs(urlparse(self.path).query)
code = (query.get("code") or [None])[0]
state = (query.get("state") or [None])[0]
if not code or not state:
self._send_html(400, self._github_app_error_page(
"missing_params", "code and state required"))
return
store = SetupStateStore(self.server.db_path)
try:
ok = store.consume_state(state)
finally:
try:
store.conn.close()
except Exception:
pass
if not ok:
self._send_html(400, self._github_app_error_page(
"invalid_state", "setup state invalid or expired"))
return
http_client = getattr(self.server, "github_app_exchange_client",
None)
try:
config = exchange_manifest_code(code, http_client=http_client)
except AppError as e:
self._send_html(400, self._github_app_error_page(
e.reason or "exchange_failed", str(e)))
return
conn = self._db()
try:
save_app_config(conn, config)
finally:
conn.close()
slug = config.get("slug") or ""
page = (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>GitHub App created</title></head><body>"
"<h1>GitHub App created</h1>"
"<p>App: %s</p>"
"<p>App ID: %s</p>"
"<p>Install it on your repositories:</p>"
"<p><a href=\"https://github.com/apps/%s/installations/new\">"
"Install this GitHub App</a></p>"
"<p>After installing, webhooks from the app are verified "
"automatically. API actions (push, PR) still use the "
"existing PAT until the App-token phase; you may remove "
"the manual webhook once the app is installed.</p>"
"</body></html>"
% (html.escape(slug),
html.escape(str(config.get("app_id"))),
html.escape(slug)))
self._send_html(200, page)
def _github_app_status(self):
"""App 配置与安装状态:须持本站 key;绝不返回 secret/pem。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
conn = self._db()
try:
status = public_app_status(conn)
status["installations"] = list_installations(conn)
finally:
conn.close()
self._send_json(200, status)
# -- 推送订阅与分发 ---------------------------------------------------
def _push_create_endpoint(self, user_id, project_id):
body, err = self._read_json()
@@ -2088,23 +2312,41 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
顺序是故意的:secret 未配 503、签名无效 401(两者都在
去重之前,避免未验签的重复送达污染去重表);同一
X-GitHub-Delivery 第二次送达直接回 200 duplicate,不
重复建任务、不重复记审计。/fix-review 解析成功后在
控制面建一个 queued 任务(prompt 含 repo、PR 号、评论
原文、作者),由 worker 认领执行——webhook 本身不跑模型。
任务归属:按项目名 == repo full_name 匹配项目;没有匹配
项目时不凭空建项目、不建任务,返回 task_id 为 None 并
说明原因(worker 的 repo 仍是配置里的 repo,不是任意
GitHub 仓库的自动克隆,见 README)。check_run /
check_suite 只做通知解析,返回解析结果而不建任务、不改
云端任务状态(CI 状态回填由 pr-checks 主动拉取)。
重复建任务、不重复记审计。验签接受两个 secret:App
webhook secret(库里,App 装好后)与 env 里
GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存;App
装好、手动 webhook 删掉后自然不再被命中)。
/fix-review 解析成功后在控制面建一个 queued 任务(prompt
含 repo、PR 号、评论原文、作者),由 worker 认领执行——
webhook 本身不跑模型。任务归属:按项目名 == repo
full_name 匹配项目;没有匹配项目时不凭空建项目、不建
任务,返回 task_id 为 None 并说明原因(worker 的 repo
仍是配置里的 repo,不是任意 GitHub 仓库的自动克隆,
见 README)。check_run / check_suite 只做通知解析,
返回解析结果而不建任务、不改云端任务状态(CI 状态回填
由 pr-checks 主动拉取)。installation 事件记安装记录
(created/added 增行、removed 减行、deleted 清安装),
不建任务。手动 webhook 与 App webhook 共存时同一评论
会送两遍(delivery_id 不同):/fix-review 另有内容去重
(repo + PR + 评论 id),只建一次任务。
"""
raw = self._read_body()
secret = os.environ.get("GITHUB_WEBHOOK_SECRET")
if not secret:
app_secret = None
conn = self._db()
try:
config = get_app_config(conn)
if config is not None:
app_secret = config.get("webhook_secret")
finally:
conn.close()
secrets = [s for s in
(app_secret, os.environ.get("GITHUB_WEBHOOK_SECRET"))
if s]
if not secrets:
self._send_json(503, {"error": "github webhook secret not configured"})
return
signature = self.headers.get("X-Hub-Signature-256")
if not verify_github_signature(secret, raw, signature):
if not verify_github_signature_any(secrets, raw, signature):
self._send_json(401, {"error": "unauthorized"})
return
try:
@@ -2131,6 +2373,42 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.close()
except Exception:
pass
# installation 事件:记安装记录,不建任务。
if event == "installation":
inst = parse_installation_event(payload)
if inst is None:
self._send_json(200, {"result": None})
return
conn = self._db()
try:
action = inst["action"]
if action == "created":
record_installation_repos(
conn, inst["installation_id"],
inst["account_login"], inst["repos"])
elif action == "added":
record_installation_repos(
conn, inst["installation_id"],
inst["account_login"], inst["repos"])
elif action == "removed":
remove_installation_repos(
conn, inst["installation_id"], inst["repos"])
elif action == "deleted":
remove_installation(conn, inst["installation_id"])
# new_permissions_accepted:权限已接受,无需改行。
finally:
conn.close()
with _auth_store(self.server.db_path) as auth:
auth.audit("system", "", "github_app_installation",
"%s:%s" % (inst["installation_id"],
inst["action"]))
self._send_json(200, {
"result": "installation_recorded",
"installation_id": inst["installation_id"],
"action": inst["action"],
"repos": inst["repos"],
})
return
# check_run / check_suite:只解析,不建任务、不改状态。
check = parse_github_check_run(payload)
if check is not None:
@@ -2163,6 +2441,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
error="no matching project for repo"))
return
project_id = project_row[0]
# 内容去重:手动 webhook 与 App webhook 共存时同一评论
# 送两遍(delivery_id 不同),只建一次任务;重复送达
# 回读当初的 task_id,不重复记审计。
comment_id = (payload.get("comment") or {}).get("id")
is_new, existing_task_id = claim_fix_review(
conn, repo_name, result.get("pr_number"), comment_id)
if not is_new:
self._send_json(200, dict(
result, task_id=existing_task_id,
project_id=project_id, duplicate=True))
return
task_id = uuid.uuid4().hex
now = time.time()
prompt = (
@@ -2196,6 +2485,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
duplicate=True))
return
raise
set_fix_review_task_id(
conn, repo_name, result.get("pr_number"), comment_id,
task_id)
finally:
conn.close()
with _auth_store(self.server.db_path) as auth:
+439
View File
@@ -0,0 +1,439 @@
"""GitHub App 一键安装单测:manifest、setup state、换 code、
配置、安装记录、内容去重、双 secret 验签、HTTP 端点。
不联网:换 code 挂 server.github_app_exchange_client 的 fake;
测试里出现的 secret、pem、key 一律是 fake 占位,绝无真实
secret、真实 GitHub 账号。
"""
import hashlib
import hmac
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.db import connect as _db_connect
from cloud.github_app import (
AppError,
SetupStateStore,
build_app_manifest,
claim_fix_review,
exchange_manifest_code,
get_app_config,
list_installations,
parse_installation_event,
public_app_status,
record_installation_repos,
remove_installation,
remove_installation_repos,
save_app_config,
set_fix_review_task_id,
)
from cloud.server import create_server
from cloud.webhooks import verify_github_signature_any
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.db import connect as _db_connect
from cloud.github_app import (
AppError,
SetupStateStore,
build_app_manifest,
claim_fix_review,
exchange_manifest_code,
get_app_config,
list_installations,
parse_installation_event,
public_app_status,
record_installation_repos,
remove_installation,
remove_installation_repos,
save_app_config,
set_fix_review_task_id,
)
from cloud.server import create_server
from cloud.webhooks import verify_github_signature_any
BASE = "https://example.invalid"
def _fake_exchange(result=None):
def fake(url, headers):
assert url.startswith(
"https://api.github.com/app-manifests/"), url
return dict(result or {
"id": 424242, "slug": "fake-codex-app",
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
"pem": "-----BEGIN RSA PRIVATE KEY-----\nfake-pem\n"
"-----END RSA PRIVATE KEY-----\n",
"html_url": "https://github.com/apps/fake-codex-app"})
return fake
class ManifestTestCase(unittest.TestCase):
def test_manifest_urls_and_permissions(self):
m = build_app_manifest(BASE)
self.assertEqual(m["hook_attributes"]["url"],
BASE + "/webhooks/github")
self.assertEqual(m["redirect_url"], BASE + "/github-app/callback")
self.assertEqual(m["default_permissions"]["contents"], "write")
self.assertEqual(m["default_permissions"]["pull_requests"], "write")
self.assertEqual(m["default_permissions"]["issues"], "read")
self.assertIn("issue_comment", m["default_events"])
self.assertIn("pull_request", m["default_events"])
self.assertFalse(m["public"])
def test_manifest_requires_base(self):
with self.assertRaises(ValueError):
build_app_manifest("")
class SetupStateTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
def tearDown(self):
self.tmp.cleanup()
def test_consume_once_and_expired(self):
store = SetupStateStore(self.db_path)
state = store.create_state()
self.assertTrue(store.consume_state(state))
self.assertFalse(store.consume_state(state))
store.conn.close()
def test_expired_state_rejected(self):
store = SetupStateStore(self.db_path)
state = store.create_state()
# 注入“1 小时之后”的 now:state 应过期且被删。
self.assertFalse(store.consume_state(
state, now=9999999999.0))
self.assertFalse(store.consume_state(state))
store.conn.close()
class ExchangeTestCase(unittest.TestCase):
def test_exchange_ok(self):
config = exchange_manifest_code(
"fake-code", http_client=_fake_exchange())
self.assertEqual(config["app_id"], 424242)
self.assertEqual(config["slug"], "fake-codex-app")
self.assertEqual(config["webhook_secret"], "fake-app-secret")
def test_exchange_missing_fields(self):
with self.assertRaises(AppError) as ctx:
exchange_manifest_code(
"fake-code",
http_client=_fake_exchange({"id": 1, "pem": "x"}))
self.assertEqual(ctx.exception.reason, "config_incomplete")
def test_exchange_http_error(self):
def boom(url, headers):
raise RuntimeError("boom")
with self.assertRaises(AppError) as ctx:
exchange_manifest_code("fake-code", http_client=boom)
self.assertEqual(ctx.exception.reason, "exchange_failed")
class ConfigAndInstallationTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.conn = _db_connect(self.db_path)
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_config_roundtrip_and_public_status(self):
self.assertIsNone(get_app_config(self.conn))
save_app_config(self.conn, {
"app_id": 7, "slug": "fake-app", "client_id": "Iv1.x",
"webhook_secret": "fake-secret", "pem": "fake-pem",
"html_url": "https://github.com/apps/fake-app"})
config = get_app_config(self.conn)
self.assertEqual(config["slug"], "fake-app")
status = public_app_status(self.conn)
self.assertTrue(status["configured"])
self.assertNotIn("webhook_secret", status)
self.assertNotIn("pem", status)
def test_installations_created_added_removed_deleted(self):
record_installation_repos(self.conn, 11, "octo",
["octo/a", "octo/b"])
record_installation_repos(self.conn, 11, "octo", ["octo/a"])
rows = list_installations(self.conn)
self.assertEqual(len(rows), 2) # 重复同步不翻倍
record_installation_repos(self.conn, 11, "octo", ["octo/c"])
remove_installation_repos(self.conn, 11, ["octo/b"])
repos = [r["repo_full_name"] for r in list_installations(self.conn)]
self.assertEqual(sorted(repos), ["octo/a", "octo/c"])
removed = remove_installation(self.conn, 11)
self.assertEqual(removed, 2)
self.assertEqual(list_installations(self.conn), [])
def test_parse_installation_event(self):
payload = {"action": "created",
"installation": {"id": 11, "account": {"login": "octo"}},
"repositories": [{"full_name": "octo/a"}]}
inst = parse_installation_event(payload)
self.assertEqual(inst["installation_id"], 11)
self.assertEqual(inst["repos"], ["octo/a"])
added = dict(payload, action="added", repositories_added=[
{"full_name": "octo/b"}])
self.assertEqual(parse_installation_event(added)["repos"],
["octo/b"])
self.assertIsNone(parse_installation_event(
{"action": "created"}))
self.assertIsNone(parse_installation_event(
{"action": "opened", "installation": {"id": 1}}))
class DedupTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.conn = _db_connect(self.db_path)
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_content_dedup(self):
is_new, task_id = claim_fix_review(
self.conn, "octo/a", 3, 99)
self.assertTrue(is_new)
self.assertIsNone(task_id)
set_fix_review_task_id(self.conn, "octo/a", 3, 99, "task-1")
is_new, task_id = claim_fix_review(
self.conn, "octo/a", 3, 99)
self.assertFalse(is_new)
self.assertEqual(task_id, "task-1")
# 另一个评论 id 不受影响。
self.assertTrue(claim_fix_review(
self.conn, "octo/a", 3, 100)[0])
# 无评论 id 时无法去重,直接放行。
self.assertTrue(claim_fix_review(
self.conn, "octo/a", 3, None)[0])
class SignatureAnyTestCase(unittest.TestCase):
def _sig(self, secret, raw):
return "sha256=" + hmac.new(
secret.encode(), raw, hashlib.sha256).hexdigest()
def test_any_secret_matches(self):
raw = b'{"zen":"hi"}'
self.assertTrue(verify_github_signature_any(
["app-secret", "legacy-secret"], raw,
self._sig("legacy-secret", raw)))
self.assertFalse(verify_github_signature_any(
["app-secret", "legacy-secret"], raw,
self._sig("wrong-secret", raw)))
self.assertFalse(verify_github_signature_any([], raw, "sha256=x"))
class AppHttpTestCase(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.user_a, self.key_a = self.auth.create_user("user-a")
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()
self.server.github_app_exchange_client = _fake_exchange()
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()
os.environ.pop("GITHUB_APP_PUBLIC_BASE", None)
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)
def _req(self, method, path, body=None, key=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
hdrs = dict(headers or {})
payload = None
if body is not None:
payload = body if isinstance(body, str) else json.dumps(body)
hdrs.setdefault("Content-Type", "application/json")
if key is not None:
hdrs["X-API-Key"] = key
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
raw = resp.read().decode()
try:
parsed = json.loads(raw)
except ValueError:
parsed = raw
return resp.status, parsed
finally:
conn.close()
def _sig(self, secret, raw):
return "sha256=" + hmac.new(
secret.encode(), raw, hashlib.sha256).hexdigest()
def _webhook(self, event, payload, secret, delivery):
raw = json.dumps(payload)
return self._req(
"POST", "/webhooks/github", body=raw,
headers={"X-GitHub-Event": event,
"X-GitHub-Delivery": delivery,
"X-Hub-Signature-256": self._sig(secret, raw.encode())})
def _issue_setup_state(self):
os.environ["GITHUB_APP_PUBLIC_BASE"] = BASE
status, body = self._req(
"POST", "/github-app/setup/state", {}, key=self.key_a)
self.assertEqual(status, 200, body)
return body["state"], body["setup_url"]
def test_setup_state_requires_auth_and_base(self):
status, _ = self._req("POST", "/github-app/setup/state", {})
self.assertEqual(status, 401)
os.environ.pop("GITHUB_APP_PUBLIC_BASE", None)
status, body = self._req(
"POST", "/github-app/setup/state", {}, key=self.key_a)
self.assertEqual(status, 400, body)
def test_setup_page_and_callback_flow(self):
state, setup_url = self._issue_setup_state()
self.assertTrue(setup_url.startswith(BASE + "/github-app/setup"))
# 无 state 不渲染表单。
status, page = self._req("GET", "/github-app/setup")
self.assertEqual(status, 400, page)
# 有 state 渲染表单:manifest 与 GitHub 建 App 地址都在。
status, page = self._req(
"GET", "/github-app/setup?state=%s" % state)
self.assertEqual(status, 200, page)
self.assertIn("github.com/settings/apps/new", page)
self.assertIn("codex-app-server", page)
self.assertIn("/webhooks/github", page)
# 回调:坏 state 失败;好 state 换 code 存配置。
status, page = self._req(
"GET", "/github-app/callback?code=x&state=nope")
self.assertEqual(status, 400, page)
status, page = self._req(
"GET", "/github-app/callback?code=fake-code&state=%s" % state)
self.assertEqual(status, 200, page)
self.assertIn("fake-codex-app", page)
self.assertIn("/installations/new", page)
self.assertNotIn("fake-app-secret", page)
self.assertNotIn("fake-pem", page)
# 同一 state 第二次回调必然失败。
status, page = self._req(
"GET", "/github-app/callback?code=fake-code&state=%s" % state)
self.assertEqual(status, 400, page)
# 状态接口:已配置、无 secret。
status, body = self._req(
"GET", "/github-app/status", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertTrue(body["configured"])
self.assertEqual(body["slug"], "fake-codex-app")
self.assertNotIn("webhook_secret", body)
self.assertNotIn("pem", body)
def test_webhook_dual_secret_and_installation(self):
# 先存一个 App 配置(secret 为 fake-app-secret)。
conn = _db_connect(self.db_path)
try:
save_app_config(conn, {
"app_id": 424242, "slug": "fake-codex-app",
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
"pem": "fake-pem",
"html_url": "https://github.com/apps/fake-codex-app"})
finally:
conn.close()
os.environ["GITHUB_WEBHOOK_SECRET"] = "legacy-secret"
ping = {"zen": "hi"}
# App secret 与 legacy secret 都能验过;错 secret 401。
status, _ = self._webhook(
"ping", ping, "fake-app-secret", "d-app-1")
self.assertEqual(status, 200)
status, _ = self._webhook(
"ping", ping, "legacy-secret", "d-legacy-1")
self.assertEqual(status, 200)
status, _ = self._webhook("ping", ping, "wrong", "d-wrong-1")
self.assertEqual(status, 401)
# installation 事件记安装记录。
inst_payload = {
"action": "created",
"installation": {"id": 11, "account": {"login": "octo"}},
"repositories": [{"full_name": "octo/a"},
{"full_name": "octo/b"}]}
status, body = self._webhook(
"installation", inst_payload, "fake-app-secret", "d-inst-1")
self.assertEqual(status, 200, body)
self.assertEqual(body["result"], "installation_recorded")
status, body = self._req(
"GET", "/github-app/status", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertEqual(len(body["installations"]), 2)
self.assertEqual(
body["installations"][0]["repo_full_name"], "octo/a")
def test_webhook_no_secret_is_503(self):
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)
status, _ = self._webhook(
"ping", {"zen": "hi"}, "anything", "d-none-1")
self.assertEqual(status, 503)
def test_fix_review_content_dedup_over_http(self):
# 项目名 == 仓库 full_name,/fix-review 才能建任务。
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at,"
" repo_url, auto_pr) VALUES(?, ?, ?, 0, NULL, 0)",
("proj-1", "octo/a", self.user_a))
conn.commit()
finally:
conn.close()
conn = _db_connect(self.db_path)
try:
save_app_config(conn, {
"app_id": 424242, "slug": "fake-codex-app",
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
"pem": "fake-pem",
"html_url": "https://github.com/apps/fake-codex-app"})
finally:
conn.close()
payload = {
"action": "created",
"comment": {"id": 777, "body": "please /fix-review this",
"user": {"login": "octo"}},
"issue": {"number": 5},
"repository": {"full_name": "octo/a"}}
# 同一评论送两遍,delivery_id 不同(模拟手动 + App 双通道)。
status, first = self._webhook(
"issue_comment", payload, "fake-app-secret", "d-fix-1")
self.assertEqual(status, 200, first)
self.assertIsNotNone(first.get("task_id"), first)
status, second = self._webhook(
"issue_comment", payload, "fake-app-secret", "d-fix-2")
self.assertEqual(status, 200, second)
self.assertTrue(second.get("duplicate"), second)
self.assertEqual(second.get("task_id"), first.get("task_id"))
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -60,6 +60,20 @@ def verify_github_signature(secret, payload_bytes, signature_header):
return hmac.compare_digest(expected, signature_header)
def verify_github_signature_any(secrets, payload_bytes, signature_header):
"""多 secret 验签:任一 secret 对上即 True,全不对或无 secret
即 False。过渡期手动 webhook(env secret)与 App webhook
(库里 secret)共存时用;App 装好、手动 webhook 删掉后,
env secret 自然不再被命中,不用改代码。"""
if not secrets:
return False
for secret in secrets:
if secret and verify_github_signature(
secret, payload_bytes, signature_header):
return True
return False
def parse_github_pr_comment(payload):
"""解析 GitHub issue_comment 事件,返回 fix-review 触发点或 None。