P1 security batch: quota execution checks, cost accounting, goal/coordinator semantics, PR channel, orphan signature, control-plane and console hardening

- Docker quota watchdog + fail-closed quota checks, system tasks need real owner
- max_cost_usd actually accumulated, cancel records cost, estimated rows excluded from unpriced cap, cache_write declared non-billed
- goal-resume keeps original verify on breakpoint resume, goal retry falls back to resume, goal_max_rounds capped at 100 at create and execution
- coordinator children inherit parent model/effort, orphans marked interrupted with audit
- PR jobs on docker workers return explicit needs_local_worker instead of silent empty
- worker code signature covers 6 files and is checked before claim, fail-closed
- sub2api lazy provisioning with rotation, backup whitelist + 0600, push SSRF block, repos/workers desensitized, key/project audits and limits, console buttons/quota/status wording, coordinator/automation quota reserve
This commit is contained in:
host committed 2026-10-27 16:27:50 +00:00
1 parent d254240d84
commit 1402dc67fd
20 files changed
+2616 -179

No files matched your search

+329 -54
View File
@@ -113,12 +113,15 @@ from cloud.oauth import (
)
from cloud.relay import RelayError as _RelayError
from cloud.relay import get_relay_models as _get_relay_models
from cloud.sub2api import Sub2apiError as _Sub2apiError
from cloud.sub2api import ensure_gateway_keys_table as _ensure_gateway_keys
from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key
from cloud.sub2api import gateway_name as _gateway_name
from cloud.sub2api import gateway_origin as _gateway_origin
from cloud.sub2api import get_user_gateway_key as _get_sub2api_key
from cloud.sub2api import list_gateway_models as _list_sub2api_models
from cloud.sub2api import rotate_user_gateway_key as _rotate_sub2api_key
from cloud.sub2api import validate_or_rotate_gateway_key as _validate_sub2api_key
from cloud.repos import validate_repo_url
from cloud.push import (
create_subscription as _push_create,
@@ -167,6 +170,31 @@ TRACE_STREAM_MAX_SECONDS = 3600
TRACE_STREAM_POLL_SECONDS = 1.0
TRACE_DEFAULT_DIR = "/opt/codex-app-server/work/host-traces"
# P1 控制面限额(建项目时校验,超限 400 并在错误里写明数值):
# 单用户项目数上限、项目名长度上限、自定义项目 id 长度上限。
# 均可经环境变量调(运维侧),缺省值见下方;改动只影响新项目,
# 已有超限项目不受影响(不删、不改名)。
def _env_int(name, default):
try:
value = int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
return default
return value if value > 0 else default
MAX_PROJECTS_PER_USER = _env_int("CODEX_MAX_PROJECTS_PER_USER", 50)
MAX_PROJECT_NAME_LEN = _env_int("CODEX_MAX_PROJECT_NAME_LEN", 100)
MAX_PROJECT_ID_LEN = _env_int("CODEX_MAX_PROJECT_ID_LEN", 64)
# 协调员父任务预留:每分工至少预留的美元额度(微美元)。
# 父任务准入只查一次时,N 个分工可解锁 N 倍消费;本预留要求
# 剩余配额 >= 分工数 × 本值,否则父任务直接 402(见
# _check_coordinator_reserve)。缺省 $1/分工,可经环境变量调;
# 它是准入下限估计,不是实际计费——实际花费仍按子任务
# created_by=父任务创建人记到创建人账上(worker 建子任务时
# created_by 直接取父任务创建人,成本 JOIN 据此归属)。
COORDINATOR_RESERVE_PER_PART_USD_MICROS = _env_int(
"CODEX_COORDINATOR_RESERVE_PER_PART_USD_MICROS", 1000000)
def _trace_file_name(task_id):
"""云任务 id -> 链路文件名:与 cloud.sandbox 的 _host_name
@@ -1451,6 +1479,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
else:
self._send_json(404, {"error": "not found"})
return
if path == "/users/me/gateway/rotate" and method == "POST":
self._user_gateway_rotate()
return
if path.startswith("/users/me/keys"):
# API keys 管理:全部只操作认证用户自己的 key。
if method == "GET" and path == "/users/me/keys":
@@ -1830,10 +1861,35 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if not name or not isinstance(name, str):
self._send_json(400, {"error": "name required"})
return
if len(name) > MAX_PROJECT_NAME_LEN:
self._send_json(400, {
"error": "project name too long: max %d characters"
% MAX_PROJECT_NAME_LEN})
return
project_id = body.get("id") or uuid.uuid4().hex
if not isinstance(project_id, str):
self._send_json(400, {"error": "id must be a string"})
return
if len(project_id) > MAX_PROJECT_ID_LEN:
self._send_json(400, {
"error": "project id too long: max %d characters"
% MAX_PROJECT_ID_LEN})
return
# 单用户项目数上限:按 projects.owner_id 计(成员身份加入
# 的项目不计入自己的上限,避免“被拉进很多项目就建不了
# 项目”);超限 400 且不建项目。
_count_conn = self._db()
try:
_owned = _count_conn.execute(
"SELECT COUNT(*) FROM projects WHERE owner_id = ?",
(user_id,)).fetchone()[0]
finally:
_count_conn.close()
if _owned >= MAX_PROJECTS_PER_USER:
self._send_json(400, {
"error": "project limit exceeded: max %d projects"
" per user" % MAX_PROJECTS_PER_USER})
return
# repo_url 可选:不传时为 None(老行为不变);传了则与 worker
# 共用 cloud.repos.validate_repo_url 的同一套校验,不合法时
# 400 且不建项目,绝不把带凭证的 URL 存进库。
@@ -1947,43 +2003,78 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, {"project": _project_to_dict(row)})
def _github_repos_list(self, user_id):
"""GET /github/repos:列出本服务 GitHub App 安装记录里的仓
库(installation_repositories 事件同步进 github_app_
installations 表),给控制台建项目/补仓库时的下拉菜单用。
任何已认证用户可列(只含仓库名,不含密钥);选了哪个仓库
只影响调用者自己有管理权的项目(PATCH 仍按 owner 校验)。
"""GET /github/repos:只返回调用者成员项目对应的仓库。
P1 收紧前返回全部安装记录(任一已认证用户可看到全部
安装仓库名,属跨租户信息泄露)。现按 project_members
过滤:只保留调用者是成员的项目的 repo_url 对应的仓库
(clone_url 归一化后比对:去 .git 后缀、比对小写);
调用者无成员项目或项目未填 repo_url 时返回空列表。
手填 URL 的能力不变——没进 App 安装的公开仓库仍可手动填。"""
conn = self._db()
try:
rows = list_installations(conn)
member_ids = {
row["project_id"]
for row in conn.execute(
"SELECT project_id FROM project_members"
" WHERE user_id = ?",
(user_id,)).fetchall()
}
allowed = set()
for row in conn.execute(
"SELECT id, repo_url FROM projects").fetchall():
if row["id"] not in member_ids or not row["repo_url"]:
continue
url = str(row["repo_url"]).strip().lower()
if url.endswith(".git"):
url = url[:-4]
allowed.add(url)
finally:
conn.close()
repos = [{
"installation_id": r["installation_id"],
"account_login": r["account_login"],
"repo_full_name": r["repo_full_name"],
"clone_url": "https://github.com/%s.git" % r["repo_full_name"],
} for r in rows]
repos = []
for r in rows:
clone_url = "https://github.com/%s.git" % r["repo_full_name"]
normalized = clone_url.lower()
if normalized.endswith(".git"):
normalized = normalized[:-4]
if normalized not in allowed:
continue
repos.append({
"installation_id": r["installation_id"],
"account_login": r["account_login"],
"repo_full_name": r["repo_full_name"],
"clone_url": clone_url,
})
self._send_json(200, {"repos": repos})
def _list_projects(self, user_id):
conn = self._db()
try:
member_ids = {
row["project_id"]
for row in conn.execute(
"SELECT project_id FROM project_members WHERE user_id = ?",
(user_id,),
).fetchall()
}
member_rows = conn.execute(
"SELECT project_id, role FROM project_members"
" WHERE user_id = ?",
(user_id,)).fetchall()
member_ids = {row["project_id"] for row in member_rows}
roles = {row["project_id"]: row["role"]
for row in member_rows}
rows = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects ORDER BY created_at, id"
).fetchall()
finally:
conn.close()
projects = [_project_to_dict(r) for r in rows
if r["id"] in member_ids]
projects = []
for r in rows:
if r["id"] not in member_ids:
continue
# 附加字段 my_role(只增字段,不改已有成功语义):
# 控制台据此按角色禁用重试/合并/提 PR 等按钮;服务端
# 权限仍以每次请求的 check_permission 为准,前端禁用
# 只是防误点,不是鉴权。
item = _project_to_dict(r)
item["my_role"] = roles.get(r["id"])
projects.append(item)
self._send_json(200, {"projects": projects})
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
@@ -2119,6 +2210,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
deleted = _push_delete(self.server.db_path,
subscription_id=parts[3])
if deleted:
with _auth_store(self.server.db_path) as store:
store.audit(
user_id, project_id, "push_subscription_deleted",
"subscription_id=%s" % parts[3])
self._send_json(200, {"deleted": bool(deleted)})
return
# /projects/{id}/automations
@@ -2389,31 +2485,33 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
# 缓存(见 cloud/relay.py)。单测可经 server.relay_fetcher
# 注入假取数函数;中转挂了或未配 key 时 502,不编造列表。
# 网关为 sub2api 时:模型目录因用户而异(key 绑定的组
# 决定),取调用者自己的网关 key 现查;无 key 时返回空
# 列表 + provisioning 标记(控制台据此提示开通中),不
# 在页面加载时悄悄开通(开通在建号与任务时发生)。
# 决定)。P1 收紧:首次调模型目录即做真正的懒开通/
# 健康检查,不再只回 provisioning: pending——无行则开通,
# 旧 key 经 /v1/models 实测已死(401/403,含余额为 0 的
# 403)则轮换重开;网关本身不可用时回 502、保留旧行。
# key 明文绝不进响应、日志、缓存。
if _gateway_name() == "sub2api":
conn = self._db()
try:
row = _get_sub2api_key(conn, user_id)
try:
conn.commit()
except Exception:
pass
status, api_key, _sub, _kid = _validate_sub2api_key(
conn, user_id)
except _Sub2apiError as exc:
self._send_json(502, {"error": str(exc)})
return
except Exception as exc:
self._send_json(502, {"error": str(exc)})
return
finally:
conn.close()
if row is None or not row.get("api_key"):
self._send_json(200, {"models": [], "gateway": "sub2api",
"provisioning": "pending"})
return
try:
models = _list_sub2api_models(_gateway_origin(),
row["api_key"])
models = _list_sub2api_models(_gateway_origin(), api_key)
except Exception as exc:
self._send_json(502, {"error": str(exc)})
return
self._send_json(200, {"models": models, "gateway": "sub2api",
"cached": False, "updated_at": None})
"cached": False, "updated_at": None,
"provisioning": status})
return
fetcher = getattr(self.server, "relay_fetcher", None)
try:
@@ -2647,6 +2745,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "goal_max_rounds must be >= 1"})
return
# P1:旧验收形态轮次设上限 100(与 worker/sandbox 两道门
# 一致;新 goal_mode=1 不走本分支,仍由配额兜底)。
if goal_max_rounds > 100:
self._send_json(400, {
"error": "goal_max_rounds 超过上限 100 轮"})
return
# 协调员:coordinator_parts 非空时任务本身不跑模型回合,
# worker 认领后按分工建并行子任务、等齐、合流;与 goal/
# plan/验收命令语义冲突(那些都是“本任务跑模型”的意思),
@@ -2673,6 +2777,33 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
if self._reject_if_over_quota(user_id):
return
# 协调员预留(P1):父任务准入只查一次时,N 个分工会
# 解锁 N 倍消费。父任务创建时要求剩余美元配额至少覆盖
# 分工数 × 每分工预留额,否则直接 402、父任务不建;
# 子任务归属 = 父任务创建人(worker 建子任务时
# created_by 直接取父任务创建人,成本 JOIN 据此归属,
# 子任务认领时 worker 侧配额门再查一次)。不限额用户
# (quota 为 None)不受此预留约束。
if coord_parts and not self._check_coordinator_reserve(
user_id, len(coord_parts)):
return
# sub2api 懒开通/健康检查(P1):建任务前先真校验网关
# key——无行则开通,死 key 则轮换重开;网关不可用时
# 502 且不建任务(不把死 key/待开通当成已开通发给
# worker)。xcmapi 网关下跳过本段。
if _gateway_name() == "sub2api":
_gw_conn = self._db()
try:
try:
_validate_sub2api_key(_gw_conn, user_id)
except _Sub2apiError as exc:
self._send_json(502, {"error": str(exc)})
return
except Exception as exc:
self._send_json(502, {"error": str(exc)})
return
finally:
_gw_conn.close()
# 环境绑定:environment_id 非空时必须是本项目、已 ready
# 的环境;creating/failed/archived 一律拒绝并把状态写进
# 报错(creating 时用户就知道该等 worker 准备好再建任务,
@@ -2836,6 +2967,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "create_task", task_id)
if coord_parts:
# 创建响应里写明子任务配额归属(与 worker 建子任务时
# created_by=父任务创建人一致),不是只写文档宣称。
task["quota_attribution"] = (
"coordinator subtasks are charged to the parent"
" task creator")
task["quota_reserved_usd_micros"] = (
len(coord_parts)
* COORDINATOR_RESERVE_PER_PART_USD_MICROS)
self._send_json(201, task)
def _list_tasks(self, user_id, query):
@@ -4091,6 +4231,34 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return True
return False
def _check_coordinator_reserve(self, user_id, n_parts):
"""协调员预留准入:通过返回 True;被挡时已回 402 返回 False。
剩余美元配额(quota - used,quota 为 None = 不限额时放行)
必须 >= 分工数 × COORDINATOR_RESERVE_PER_PART_USD_MICROS。
这是准入下限估计,不是计费承诺:子任务实际花费仍按
created_by=父任务创建人全额记创建人账;父任务被挡时
一个子任务也不会建,不存在“第 2 个子任务才被挡”的
半截状态。"""
if not n_parts or n_parts <= 1:
return True
quota, used, month = self._quota_status(user_id)
if quota is None:
return True
need = n_parts * COORDINATOR_RESERVE_PER_PART_USD_MICROS
if quota - used < need:
self._send_json(402, {
"error": "coordinator_quota_reserve_exceeded",
"month": month,
"quota_usd_micros": quota,
"used_usd_micros": used,
"coordinator_parts": n_parts,
"reserved_usd_micros": need,
"quota_attribution": "coordinator subtasks are charged"
" to the parent task creator"})
return False
return True
def _audit_admin(self, user_id, action, detail):
"""admin 调用审计(成功与被拒都记,失败绝不挡主流程)。"""
try:
@@ -4125,10 +4293,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _user_quota_self(self, user_id):
quota, used, month = self._quota_status(user_id)
self._send_json(200,
self._quota_payload(
user_id, quota, used, month,
self._quota_unpriced_tokens(user_id)))
# 合并取舍(P1 rebase 到 d254240):main 的 _quota_payload
# 已带 unpriced_actual_tokens,P1 的附加字段也全留,供控制台
# 同时显示美元与无价 token 两条进度;402 时控制台据
# 响应字段区分“美元配额已满”与“无价 token 已满”。
payload = self._quota_payload(
user_id, quota, used, month,
self._quota_unpriced_tokens(user_id))
payload["unpriced_tokens_used"] = self._quota_unpriced_tokens(
user_id, month)
payload["unpriced_token_cap"] = unpriced_token_cap()
self._send_json(200, payload)
def _admin_quota_get(self, user_id, query):
if not self._is_global_admin(user_id):
@@ -4204,10 +4379,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
# -- worker 池 -----------------------------------------------------
def _workers_list(self, user_id):
"""列出注册 worker:任何已认证用户可读(worker 是基础设施
信息,不属于某个项目的数据,不按项目成员过滤)。"""
"""列出注册 worker:全局管理员看完整清单;其他已认证用户
只看在线/离线聚合(P1 收紧:worker_id、执行器、容量、
心跳时间等内部细节不对普通用户暴露,控制台的 Workers
区据此只显示聚合数字)。"""
workers = _registry_list(self.server.db_path)
self._send_json(200, {"workers": workers})
if self._is_global_admin(user_id):
self._send_json(200, {"workers": workers})
return
online = sum(1 for w in workers if w.get("alive"))
self._send_json(200, {
"workers_online": online,
"workers_offline": len(workers) - online,
"workers_total": len(workers),
})
def _workers_heartbeat(self, user_id):
"""worker 心跳:body 含 worker_id(必填,缺了 400)与可选
@@ -4429,11 +4614,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"""POST /admin/backup:备份当前后端数据库,返回路径与规模信息。
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
未配置时一律 403。返回只含 backend、备份文件路径、
文件大小与任务行数,绝不返回数据库密码、备份文件内容
或任何凭据。out_dir 可由 body 指定,否则走环境变量
CLOUD_BACKUP_DIR,未设则用 cloud-backups。成功与被拒
都记审计(审计里只记路径与规模,不记库内容)。
未配置时一律 403;非运维调用方根本到不了这里,绝对
路径只回给全局管理员。out_dir 限白名单目录(P1 收紧):
白名单根来自 CLOUD_BACKUP_ROOT(逗号分隔多根),未设
则 CLOUD_BACKUP_DIR,未设则固定根 cloud-backups;请求
的 out_dir 经实路径解析后必须等于某根或在其下,含 ..
逃逸、指向 /、/etc、/opt 等系统目录的一律 400(白名单
外的绝对路径即使存在也不放行)。备份文件落盘后 chmod
0600。成功与被拒都记审计(调用人由审计的 user_id 列
记,detail 含目标目录与大小,不记库内容)。
"""
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_backup_denied", "backup")
@@ -4443,11 +4632,33 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if err:
self._send_json(400, {"error": err})
return
out_dir = body.get("out_dir") or os.environ.get(
"CLOUD_BACKUP_DIR") or "cloud-backups"
if not isinstance(out_dir, str) or not out_dir:
requested = body.get("out_dir")
if requested is not None and (
not isinstance(requested, str) or not requested):
self._send_json(400, {"error": "out_dir must be a string"})
return
roots_raw = os.environ.get("CLOUD_BACKUP_ROOT", "")
if roots_raw.strip():
roots = [os.path.realpath(p.strip())
for p in roots_raw.split(",") if p.strip()]
else:
roots = [os.path.realpath(
os.environ.get("CLOUD_BACKUP_DIR") or "cloud-backups")]
if requested:
candidate = os.path.realpath(requested)
else:
candidate = roots[0]
allowed = any(candidate == root
or candidate.startswith(root + os.sep)
for root in roots)
if not allowed:
self._audit_admin(
user_id, "admin_backup_denied",
"out_dir outside backup roots: %s" % requested)
self._send_json(400, {
"error": "out_dir outside allowed backup roots"})
return
out_dir = candidate
backend = _db_backend_name()
try:
if backend == "postgres":
@@ -4456,6 +4667,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
else:
from cloud.backup import backup_db as _backup_sqlite
dest = _backup_sqlite(self.server.db_path, out_dir, keep=3)
try:
os.chmod(dest, 0o600)
except OSError:
pass
size_bytes = os.path.getsize(dest)
conn = self._db()
try:
@@ -4468,8 +4683,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
self._audit_admin(
user_id, "admin_backup",
"backend=%s path=%s size=%d tasks=%d"
% (backend, dest, size_bytes, task_count))
"caller=%s backend=%s out_dir=%s path=%s size=%d tasks=%d"
% (user_id, backend, out_dir, dest, size_bytes, task_count))
self._send_json(200, {
"backend": backend, "path": dest,
"size_bytes": size_bytes, "task_count": task_count,
@@ -5165,6 +5380,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
label = "api-key"
with _auth_store(self.server.db_path) as auth:
key_id, api_key_plain = auth.create_api_key(user_id, label)
# 审计只记 key_id 与 label,绝不记明文与哈希。
auth.audit(user_id, "", "api_key_created",
"key_id=%s label=%s" % (key_id, label))
self._send_json(201, {"id": key_id, "label": label,
"hint": api_key_plain[-4:],
"api_key": api_key_plain})
@@ -5182,6 +5400,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
with _auth_store(self.server.db_path) as auth:
ok = auth.revoke_api_key(user_id, key_id)
if ok:
auth.audit(user_id, "", "api_key_revoked",
"key_id=%s" % key_id)
if not ok:
self._send_json(404, {"error": "key not found"})
return
@@ -5199,8 +5420,45 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
keep_hash = hashlib.sha256(api_key.encode()).hexdigest()
with _auth_store(self.server.db_path) as auth:
count = auth.revoke_other_api_keys(user_id, keep_hash)
# 只记撤销数,不记任何 key 明文、哈希或保留 key 的身份。
auth.audit(user_id, "", "api_key_revoke_others",
"revoked_count=%d" % count)
self._send_json(200, {"revoked_count": count})
def _user_gateway_rotate(self):
"""POST /users/me/gateway/rotate:仅限本人重新开通/轮换
网关 key。非 sub2api 网关时 400;网关不可用时 502 且
旧行不动(rotate 内部先删旧行再重开,只在重开失败时
旧 key 已丢——调用方须知:轮换是显式动作,失败后须
重试本端点重新开通)。审计只记 sub2api_user_id 与
key_id,绝不记 key 明文;响应不含 key。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
if _gateway_name() != "sub2api":
self._send_json(400, {"error": "gateway is not sub2api"})
return
conn = self._db()
try:
try:
status, _key, sub_id, key_id = _rotate_sub2api_key(
conn, user_id)
except _Sub2apiError as exc:
self._send_json(502, {"error": str(exc)})
return
except Exception as exc:
self._send_json(502, {"error": str(exc)})
return
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, "", "gateway_key_rotated",
"sub2api_user_id=%s key_id=%s"
% (sub_id, key_id))
self._send_json(200, {"rotated": True, "provisioning": status,
"sub2api_user_id": sub_id})
# -- 推送订阅与分发 ---------------------------------------------------
def _push_create_endpoint(self, user_id, project_id):
body, err = self._read_json()
@@ -5215,6 +5473,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
# 审计只记订阅 id 与通道:target 全文不记(Slack webhook
# URL 本身含 secret 路径,记进审计等于把 secret 落盘)。
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "push_subscription_created",
"subscription_id=%s channel=%s"
% (sub["id"], sub["channel"]))
self._send_json(201, sub)
def _push_dispatch_endpoint(self, user_id):
@@ -5228,8 +5492,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {"error": "event_type and project_id required"})
return
# P0 安全批后:/admin/push/dispatch 是管理端点,只认
# CODEX_GLOBAL_ADMINS,不再只凭项目 read 就能让服务端
# 向订阅目标(可能含内网地址)真 POST。被拒与成功都记审计。
# CODEX_GLOBAL_ADMINS——该口径已强于 P1 要求的 create_task,
# 不再降级。被拒与成功都记审计;目标 SSRF 校验在
# push.dispatch_event / 真实发送层做。
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_push_denied",
"dispatch %s %s" % (event_type, project_id))
@@ -5258,11 +5523,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"""创建自动化定义:只登记定义与到期状态,不跑模型、
不另起调度器(执行器后续走 claim 门,见 automations
模块文档)。body:name、prompt 必填;interval_seconds
可选(默认 86400,最小 60);repo_url 可选。"""
可选(默认 86400,最小 60);repo_url 可选。
P1 配额门:创建时先过与建任务同一道配额门(美元配额
与无价 token cap,超限 402),避免“定义免费建、到期
无限跑”;单自动化的 spend 上限即创建人配额本身——
每次到期产出的任务以 created_by=创建人建任务,走
POST /tasks 的配额门(触发路径若绕过服务端建任务,
则不在本门覆盖内,见残留)。本服务端没有自动化触发
端点,触发侧的门不在本次 server.py 改动内。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
if self._reject_if_over_quota(user_id):
return
interval = body.get("interval_seconds", 86400)
if interval is None:
interval = 86400