diff --git a/cloud/console.html b/cloud/console.html index 6e21687..9a688f6 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -90,6 +90,22 @@
未加载
+
+

管理区 (仅全局管理员)

+
填 key 后判断权限…
+ +
+

健康状态

@@ -112,6 +128,20 @@
+

项目成员

+
+ + + + +
+
先选项目
+

Workers

@@ -120,6 +150,31 @@
+
+

技能与 MCP

+
反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。
+
+ + 先选项目 +
+

技能

+
先选项目
+

应用

+
先选项目
+

MCP 服务

+
先选项目
+
+ + +
+
+ + + +
+
+
+

环境 (选、建、管:当前项目的基准检出)

@@ -198,6 +253,23 @@

任务列表 (每 10 秒自动刷新当前项目)

+ + +
@@ -273,6 +345,22 @@
点任务行查看详情后,点“刷新队列”查看评审记录

实时链路

+
+ + + +
点任务行查看详情后,这里显示过程链路

后续消息

@@ -316,6 +404,19 @@

分诊收件箱

+

线程

+
点任务行查看详情后,这里显示线程元数据
+
+ + + + + + + +
+
+
限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。
diff --git a/cloud/migrate_sqlite_to_postgres.py b/cloud/migrate_sqlite_to_postgres.py index a1fbee4..598d114 100644 --- a/cloud/migrate_sqlite_to_postgres.py +++ b/cloud/migrate_sqlite_to_postgres.py @@ -15,7 +15,10 @@ import argparse import sys -from cloud.migrations import migrate_sqlite_to_postgres +from cloud.migrations import ( + migrate_sqlite_to_postgres, + migration_coverage, +) def main(argv=None): @@ -30,6 +33,17 @@ def main(argv=None): print("%s: %d" % (table, count)) total += count print("total inserted: %d" % total) + # 覆盖报告:只含表名,不含行内容与任何密钥。missing 非空 + # 即清单与 schema 对齐回归;unmigrated 是目标库无此表、 + # 本迁移无法迁移的表,须另行处理,不能读成“已迁完”。 + coverage = migration_coverage() + if coverage["missing_from_migration"]: + print("WARNING missing_from_migration: %s" + % ", ".join(coverage["missing_from_migration"])) + print("unmigrated (no PG schema, NOT migrated): %s" + % ", ".join(coverage["unmigrated_no_pg_schema"])) + print("skipped by design: %s" + % ", ".join(coverage["skipped_by_design"])) return 0 diff --git a/cloud/migrations.py b/cloud/migrations.py index a6ae120..468de77 100644 --- a/cloud/migrations.py +++ b/cloud/migrations.py @@ -7,9 +7,22 @@ - schema 来自 cloud.db.POSTGRES_SCHEMA / init_postgres_schema, 本模块不另发明表结构;迁移前先建表(若目标库已有表, CREATE TABLE IF NOT EXISTS 是 no-op)。 +- P2-ops 对齐:MIGRATION_TABLES 必须等于 POSTGRES_SCHEMA 的 + 表集合(见 migration_coverage 与 test_p2_migrations.py)。 + 以前漏掉的 uploads、task_attachments、task_followups、 + environments 已补进清单;补进时发现 task_followups 在 + SQLite 侧比 PG schema 多一列 sender_task_id(peer_messages + 的运行时 ensure_followups_table 会在 PG 上 ALTER 补列, + POSTGRES_SCHEMA 里没有它),迁移时按同样方式先补列再插, + 否则整批迁移会在该表上撞未知列失败。 - kb_fts(SQLite FTS5 虚拟表)不迁移:Postgres 没有 FTS5, KBStore 在 PG 上走 LIKE 分支,kb_docs 的 content 已足够。 - sqlite_sequence(AUTOINCREMENT 内部表)不迁移。 +- UNMIGRATED_TABLES:SQLite 各 store 建出、但 POSTGRES_SCHEMA + 里没有的表(api_keys、评审、自动化、GitHub App、网关 key + 等)。它们不是“被跳过”,而是本模块无法迁移——迁移报告 + (migration_coverage / CLI 输出)逐项列出,调用方不得 + 把“迁移成功”读成“全部数据都过去了”。 - 不打印、不返回任何密码或密钥:函数只返回 {表: inserted}, CLI 只打印表名与行数。 - Postgres 连接参数只走 CLOUD_DB_* 环境变量(见 cloud.db), @@ -17,6 +30,7 @@ """ import os +import re import sqlite3 from cloud.db import ( @@ -43,10 +57,94 @@ MIGRATION_TABLES = ( # (本批后无行按默认额度处理,不再是无限额,但显式 # 管理员额度仍需原样迁过去,不能丢)。 "user_quotas", + # P2-ops 对齐:以下 4 张表在 POSTGRES_SCHEMA 里有、 + # 原清单里没有,补进后清单与 schema 表集合一致。 + # uploads / task_attachments:附件包与任务附件; + # task_followups:任务间互发消息的后续消息队列; + # environments:项目环境(分支/executor)。 + # 四张表主键均为 TEXT 或复合主键,没有身份列, + # 不需要序列拨动(见 SEQUENCE_TABLES)。 + "uploads", + "task_attachments", + "task_followups", + "environments", ) _SKIP_TABLES = frozenset({"kb_fts", "sqlite_sequence"}) +# 身份列的表:POSTGRES_SCHEMA 里唯一用 +# ``BIGINT GENERATED BY DEFAULT AS IDENTITY`` 的 4 张表。 +# 迁移时显式插 id 不会推序列,迁完必须 setval 到 MAX(id), +# 否则后续省略 id 的 INSERT 会撞主键。新增身份列表必须 +# 同步改这里,test_p2_migrations.py 会拿 schema 实测对齐。 +SEQUENCE_TABLES = ( + "audit_log", + "summaries", + "kb_docs", + "cost_records", +) + +# SQLite 各 store 建出、但 POSTGRES_SCHEMA 里没有的表: +# 本迁移模块无法把它们迁到 Postgres(目标库无此表), +# 不是静默跳过——migration_coverage() 与 CLI 逐项报告。 +# 其中 rate_limit_hits / workers 是易失的限流与心跳数据, +# 丢了只影响限流窗口与 worker 可见性,不影响业务数据; +# 其余(api_keys、task_reviews、automations、GitHub App、 +# user_gateway_keys 等)丢了就是真丢,迁库前必须另行 +# 处理(重建/重绑/重发 key),不能只跑本迁移就宣称迁完。 +UNMIGRATED_TABLES = ( + "api_keys", + "task_approvals", + "task_reviews", + "automations", + "automation_runs", + "github_app_setup_states", + "github_app_config", + "github_app_installations", + "github_fix_review_dedup", + "external_identities", + "pr_jobs", + "rate_limit_hits", + "user_gateway_keys", + "webhook_deliveries", + "workers", +) + + +def schema_tables(): + """POSTGRES_SCHEMA 里的表名集合(只认 CREATE TABLE 语句)。""" + names = set() + for statement in POSTGRES_SCHEMA: + m = re.search(r"CREATE TABLE IF NOT EXISTS (\w+)", + statement) + if m: + names.add(m.group(1)) + return names + + +def migration_coverage(): + """迁移覆盖报告:清单、schema、未迁移项三者一次给全。 + + migrated:本次迁移模块会迁的表(= MIGRATION_TABLES); + schema_tables:POSTGRES_SCHEMA 里的表; + missing_from_migration:schema 里有、清单里没有的表 + (本批后应为空;非空即对齐回归,调用方应报错 + 而不是继续跑迁移); + unmigrated_no_pg_schema:UNMIGRATED_TABLES(目标库 + 无此表,无法迁移,须另行处理); + skipped_by_design:kb_fts / sqlite_sequence(设计上不迁)。 + 不含任何密钥或行内容,只含表名。 + """ + schema = schema_tables() + return { + "migrated": tuple(MIGRATION_TABLES), + "schema_tables": tuple(sorted(schema)), + "missing_from_migration": tuple( + sorted(schema - set(MIGRATION_TABLES))), + "unmigrated_no_pg_schema": tuple(UNMIGRATED_TABLES), + "skipped_by_design": tuple(sorted(_SKIP_TABLES)), + } + def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None): """迁移并返回 {表名: 本次插入行数}。 @@ -83,6 +181,26 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None): continue columns = [d[0] for d in src.execute( 'SELECT * FROM "%s" LIMIT 0' % table).description] + if table == "task_followups" and "sender_task_id" in columns: + # SQLite 侧有、POSTGRES_SCHEMA 里没有的列: + # 与 peer_messages.ensure_followups_table 同款, + # 先查现有列、缺了再 ALTER 补列,已有列是 no-op。 + # PRAGMA 经适配层翻译成 information_schema 查询, + # 取 row[1] 即列名(与 init_postgres_schema 同款)。 + # 补列失败也不在这里吞掉迁移:INSERT 时若列仍 + # 缺失会直接报错(大声失败,绝不静默丢列)。 + try: + existing = {row[1] for row in pg_conn.execute( + "PRAGMA table_info(task_followups)").fetchall()} + except Exception: + existing = set() + if "sender_task_id" not in existing: + try: + pg_conn.execute( + "ALTER TABLE task_followups" + " ADD COLUMN sender_task_id TEXT") + except Exception: + pass placeholders = ", ".join("?" for _ in columns) col_list = ", ".join('"%s"' % c for c in columns) inserted = 0 @@ -96,8 +214,7 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None): inserted += cur.rowcount # 身份列的序列不会因显式插 id 自动前进:迁完把序列 # 拨到当前最大 id,否则后续省略 id 的 INSERT 会撞主键。 - if table in ("audit_log", "summaries", "kb_docs", - "cost_records") and "id" in columns: + if table in SEQUENCE_TABLES and "id" in columns: try: pg_conn.execute( "SELECT setval(pg_get_serial_sequence(?, ?)," @@ -120,5 +237,9 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None): __all__ = [ "MIGRATION_TABLES", + "SEQUENCE_TABLES", + "UNMIGRATED_TABLES", + "migration_coverage", "migrate_sqlite_to_postgres", + "schema_tables", ] diff --git a/cloud/server.py b/cloud/server.py index 4e77fd3..0c81c4f 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -46,6 +46,8 @@ import re import shutil import socket import sqlite3 +import subprocess +import sys import threading import time import uuid @@ -55,6 +57,7 @@ from decimal import Decimal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, quote, urlparse +from cloud.auth import ROLES as _MEMBER_ROLES from cloud.auth import AuthStore from cloud.coordinator import parse_parts as _coord_parse_parts from cloud.coordinator import validate_parts as _coord_validate_parts @@ -164,6 +167,12 @@ _server_log = logging.getLogger("cloud.server") TERMINAL_STATUSES = frozenset( {"completed", "failed", "interrupted", "merged", "merge-failed"} ) +# 线程改操作(rename/archive/unarchive/compact)只允许这三个终态: +# 比 TERMINAL_STATUSES 窄——merged/merge-failed 的 thread 已随任务 +# 终局,不再改;running/queued 一律 409。 +THREAD_MUTATION_STATUSES = frozenset( + {"completed", "failed", "interrupted"} +) # 实时链路(任务过程事件流):host 在回合进行中把 app-server # 通知摘要成条目追加写 jsonl,服务端 tail 文件经 SSE 推送。 TRACE_STREAM_MAX_SECONDS = 3600 @@ -253,6 +262,211 @@ def _trace_dirs_for_task(server, task): return dirs +# -- 线程 / skills / MCP 元数据操作(经 host.py CLI 透传)-------------- +# 元数据操作,不跑模型回合:只调 thread/list、thread/read、 +# thread/name/set、thread/archive、thread/unarchive、 +# thread/compact/start、skills/list、app/list、 +# mcpServerStatus/list、config/mcpServer/reload、 +# mcpServer/oauth/login 这类只读或线程管理方法,不开 turn, +# 因此不违反本文件开头“不直接跑模型回合”的原则。 +# +# host.py CLI 契约(与正在加 thread-* 子命令的另一路对齐,见 +# 各 handler 的 argv 注释):子命令只往 stdout 打一个 JSON +# 对象;失败时 exit 非零且 stdout 是 {"error": ...}。本模块 +# 解析 whole-stdout JSON,解析失败或 host.py 缺失一律 502, +# 绝不伪造成功。argv 里的子命令名与参数名是本批的约定, +# host.py 侧实现时须与之一致(单测用 stub 脚本按此约定伪造)。 +def _host_py_path(): + """host.py 路径:环境变量 CODEX_HOST_PY 优先,否则取 + cloud/server.py 所在目录的上一级 host.py(与 worker 的 + --host-py 缺省规则同款)。""" + env_path = os.environ.get("CODEX_HOST_PY") + if env_path: + return env_path + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(os.path.dirname(here), "host.py") + + +def _run_host_cli(argv, codex_home, timeout=60): + """调 host.py CLI 子命令,返回 (ok, result_or_error)。 + + ok 为 True 时 result 是 stdout 解析出的 JSON 对象;ok 为 + False 时 result 是可直接放进 502 error 字段的错误文本 + (CLI 自带的 error 原样透传,解析失败/缺文件/超时则给 + 明确的服务端错误,绝不把 stderr 或原始 key 混进响应)。 + """ + host_py = _host_py_path() + if not host_py or not os.path.isfile(host_py): + return False, "host.py not found" + cmd = [sys.executable, host_py] + if codex_home: + cmd += ["--codex-home", codex_home] + cmd += list(argv) + try: + proc = subprocess.run(cmd, capture_output=True, text=True, + timeout=timeout) + except subprocess.TimeoutExpired: + return False, "host.py timed out" + except OSError as exc: + return False, "cannot run host.py: %s" % exc + stdout = (proc.stdout or "").strip() + try: + parsed = json.loads(stdout) if stdout else None + except ValueError: + return False, "host.py output is not valid JSON" + if proc.returncode != 0: + err = None + if isinstance(parsed, dict): + err = parsed.get("error") + return False, str(err) if err else ( + "host.py failed with exit %d" % proc.returncode) + if not isinstance(parsed, dict): + return False, "host.py output is not a JSON object" + if parsed.get("error"): + return False, str(parsed["error"]) + return True, parsed + + +def _tenant_safe_project_name(project_id): + """项目 id -> 租户目录安全名,与 worker._tenant_safe_name + 同规则;映射不出安全名时返回 None。""" + if not project_id: + return None + safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(project_id)).strip(".") + if not safe or safe in (".", ".."): + return None + return safe + + +def _tenant_codex_home(server, project_id): + """项目对应的租户 codex-home:tenants_root/<安全项目名>/ + codex-home。线程持久化在 CODEX_HOME 里,租户按项目分 + home,因此线程操作必须带这个 home,不能用共享 home。""" + safe = _tenant_safe_project_name(project_id) + if not safe: + return None + return os.path.join(_tenants_root_for(server), safe, "codex-home") + + +def _tenant_work_dir(server, project_id): + """项目对应的租户工作目录:tenants_root/<安全项目名>/work, + skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会 + 默认用自身 cwd,那是错的 home 目录)。""" + safe = _tenant_safe_project_name(project_id) + if not safe: + return None + return os.path.join(_tenants_root_for(server), safe, "work") + + +def _thread_worktree_for_task(server, task): + """本地任务的 worktree 推导:host.py dispatch 有仓库时在 + /work/host-worktrees/ 建 worktree, + host 任务名 = 任务 id 的安全字符清洗(与 sandbox 的 + _host_name 同规则)。无仓库任务的 thread cwd 是 worker 的 + 仓库路径或 host.py 当前目录,服务端表里没有、推不出来, + 此时按此路径过滤会返回零个 thread,调用方拿到 404—— + 这是诚实结果,不是静默错指。部署可注入 + server.host_worktrees_dir(/pr 路径同款属性)或环境变量 + CODEX_HOST_WORKTREES_DIR 覆盖 worktree 根。""" + base = getattr(server, "host_worktrees_dir", None) + if not base: + base = os.environ.get("CODEX_HOST_WORKTREES_DIR") + if not base: + base = os.path.join(os.path.dirname(_host_py_path()), + "work", "host-worktrees") + safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"])) + return os.path.join(base, safe) + + +def _extract_thread_list(result): + """从 thread-list 的 CLI 结果里取 thread 列表:标准形态是 + ThreadListResponse 的 data;threads 键与 result 包裹是 + 容忍形态(CLI 形状未定前不因键名不同就 502)。取不到 + 列表时返回 None(调用方按 502 处理)。""" + if not isinstance(result, dict): + return None + for key in ("data", "threads"): + value = result.get(key) + if isinstance(value, list): + return value + inner = result.get("result") + if isinstance(inner, dict): + for key in ("data", "threads"): + value = inner.get(key) + if isinstance(value, list): + return value + return None + + +def _extract_thread(result): + """从 thread-read 的 CLI 结果里取 thread 对象:标准形态是 + ThreadReadResponse 的 thread;结果本身就是 thread 对象 + 时也接受。取不到时返回 None。""" + if not isinstance(result, dict): + return None + thread = result.get("thread") + if isinstance(thread, dict): + return thread + if isinstance(result.get("id"), str): + return result + inner = result.get("result") + if isinstance(inner, dict): + thread = inner.get("thread") + if isinstance(thread, dict): + return thread + if isinstance(inner.get("id"), str): + return inner + return None + + +def _pick_latest_thread(threads): + """cwd 过滤返回多个 thread 时的取用规则:取最新一个非 + archived。协议说明:Thread 对象本身没有 archived 字段, + 非归档由 thread/list 的 archived 过滤保证(不传 archived + 时只回非归档 thread);这里只防御性地跳过显式带 + archived 真值的条目(CLI 形状若补了该字段时仍正确)。 + 最新 = updatedAt 最大,缺失时退 createdAt,再缺失时按 + 列表原顺序取后者(同秒时后到的更新)。""" + best = None + best_key = None + for idx, item in enumerate(threads): + if not isinstance(item, dict): + continue + if item.get("archived"): + continue + ts = item.get("updatedAt") + if ts is None: + ts = item.get("createdAt") + try: + ts = float(ts) + except (TypeError, ValueError): + ts = 0 + key = (ts, idx) + if best_key is None or key > best_key: + best = item + best_key = key + return best + + +def _extract_result_list(result, keys): + """从 skills/apps/mcp 列表类 CLI 结果里取列表:标准形态 + 是各 Response 的 data;传入的备用键是容忍形态。取不到 + 时返回 None。""" + if not isinstance(result, dict): + return None + for key in keys: + value = result.get(key) + if isinstance(value, list): + return value + inner = result.get("result") + if isinstance(inner, dict): + for key in keys: + value = inner.get(key) + if isinstance(value, list): + return value + return None + + def _read_trace_entries(path): """读 host 写的 jsonl 链路文件:跳过坏行;按 id 去重,后到的 完成态覆盖先到的开始态,保首次顺序。文件不存在返回空列表。""" @@ -1648,6 +1862,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): _task_part, _rev_part = rest.split("/reviews/", 1) self._update_review_findings( user_id, _task_part, _rev_part.split("/")[0]) + elif rest.endswith("/threads") and method == "GET": + self._task_threads(user_id, rest[: -len("/threads")]) + elif rest.endswith("/thread") and method == "GET": + self._task_thread(user_id, rest[: -len("/thread")], query) + elif rest.endswith("/thread/rename") and method == "POST": + self._task_thread_rename( + user_id, rest[: -len("/thread/rename")]) + elif rest.endswith("/thread/archive") and method == "POST": + self._task_thread_archive( + user_id, rest[: -len("/thread/archive")]) + elif rest.endswith("/thread/unarchive") and method == "POST": + self._task_thread_unarchive( + user_id, rest[: -len("/thread/unarchive")]) + elif rest.endswith("/thread/compact") and method == "POST": + self._task_thread_compact( + user_id, rest[: -len("/thread/compact")]) elif rest.endswith("/fork") and method == "POST": self._fork_task(user_id, rest[: -len("/fork")]) elif rest.endswith("/approvals") and method == "GET": @@ -2077,6 +2307,170 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): projects.append(item) self._send_json(200, {"projects": projects}) + # -- 项目成员:邀请 / 列表 / 改角色 ------------------------------- + def _can_manage_members(self, user_id, project_id): + """成员管理权:项目 owner 或全局管理员。 + + 全局管理员(CODEX_GLOBAL_ADMINS)不一定是项目成员, + 故不能只用项目内 manage_project 判定;项目 owner 判定 + 复用 check_permission 的 manage_project(仅 owner)。 + """ + if self._is_global_admin(user_id): + return True + with _auth_store(self.server.db_path) as store: + return store.check_permission( + user_id, project_id, "manage_project") + + def _set_member_row(self, store, project_id, actor_id, target_id, + role): + """写成员行并记审计:owner 走 AuthStore.set_member(内含 + owner 校验与审计);非成员的全局管理员走直接写 + 同格式 + 审计(set_member 的 owner 校验会挡住全局管理员,故分流)。 + """ + if store.check_permission(actor_id, project_id, + "manage_project"): + store.set_member(project_id, actor_id, target_id, role) + return + store.conn.execute( + "INSERT OR REPLACE INTO project_members" + "(project_id, user_id, role) VALUES(?, ?, ?)", + (project_id, target_id, role)) + store.conn.commit() + store.audit(actor_id, project_id, "set_member", + "%s:%s" % (target_id, role)) + + def _project_members_list(self, user_id, project_id): + """GET /projects/{id}/members:项目成员(read)或全局 + 管理员可读。只返回 user_id / username / role,绝不返回 + key 哈希等认证字段。""" + if not self._project_exists(project_id): + self._send_json(404, {"error": "project not found"}) + return + if not self._is_global_admin(user_id): + err = self._check_project(user_id, project_id, "read") + if err: + self._send_json(err[0], {"error": err[1]}) + return + conn = self._db() + try: + rows = conn.execute( + "SELECT m.user_id, m.role, u.username FROM" + " project_members m LEFT JOIN users u" + " ON u.id = m.user_id WHERE m.project_id = ?" + " ORDER BY u.username, m.user_id", + (project_id,)).fetchall() + finally: + conn.close() + self._send_json(200, {"project_id": project_id, "members": [ + {"user_id": r["user_id"], "username": r["username"], + "role": r["role"]} for r in rows]}) + + def _resolve_invite_target(self, body): + """邀请目标解析:优先 user_id,其次 username 查 id; + 返回 (target_id, error_status, error)。""" + target = body.get("user_id") + username = body.get("username") + if target is not None and not isinstance(target, str): + return None, 400, "user_id must be a string" + if username is not None and not isinstance(username, str): + return None, 400, "username must be a string" + if not target and not username: + return None, 400, "user_id or username required" + conn = self._db() + try: + if target: + row = conn.execute( + "SELECT id FROM users WHERE id = ?", + (target,)).fetchone() + if row is None: + return None, 404, "user not found" + return row["id"], None, None + row = conn.execute( + "SELECT id FROM users WHERE username = ?", + (username,)).fetchone() + if row is None: + return None, 404, "user not found" + return row["id"], None, None + finally: + conn.close() + + def _project_member_invite(self, user_id, project_id): + """POST /projects/{id}/members:限项目 owner 或全局管理 + 员。已是成员时 409(改角色走 PATCH,不用邀请覆盖); + 成功记 set_member 审计。role 缺省 contributor。""" + if not self._project_exists(project_id): + self._send_json(404, {"error": "project not found"}) + return + if not self._can_manage_members(user_id, project_id): + self._send_json(403, {"error": "forbidden"}) + return + body, jerr = self._read_json() + if jerr: + self._send_json(400, {"error": jerr}) + return + role = body.get("role") or "contributor" + if role not in _MEMBER_ROLES: + self._send_json(400, {"error": "unknown role"}) + return + target, estatus, eerr = self._resolve_invite_target(body) + if eerr: + self._send_json(estatus, {"error": eerr}) + return + with _auth_store(self.server.db_path) as store: + existing = store.conn.execute( + "SELECT role FROM project_members" + " WHERE project_id = ? AND user_id = ?", + (project_id, target)).fetchone() + if existing is not None: + self._send_json(409, {"error": "already_member"}) + return + self._set_member_row(store, project_id, user_id, + target, role) + self._send_json(201, {"project_id": project_id, + "user_id": target, "role": role}) + + def _project_member_patch(self, user_id, project_id, target): + """PATCH /projects/{id}/members/{user_id}:限项目 owner + 或全局管理员。唯一 owner 降为非 owner 时 409(无论降 + 自己还是降他人——否则项目将无 owner 可管);目标非成员 + 时 404。""" + if not self._project_exists(project_id): + self._send_json(404, {"error": "project not found"}) + return + if not self._can_manage_members(user_id, project_id): + self._send_json(403, {"error": "forbidden"}) + return + body, jerr = self._read_json() + if jerr: + self._send_json(400, {"error": jerr}) + return + role = body.get("role") + if role not in _MEMBER_ROLES: + self._send_json(400, {"error": "unknown role"}) + return + with _auth_store(self.server.db_path) as store: + cur = store.conn.execute( + "SELECT role FROM project_members" + " WHERE project_id = ? AND user_id = ?", + (project_id, target)).fetchone() + if cur is None: + self._send_json(404, {"error": "member not found"}) + return + if cur[0] == "owner" and role != "owner": + owners = store.conn.execute( + "SELECT COUNT(*) FROM project_members" + " WHERE project_id = ? AND role = 'owner'", + (project_id,)).fetchone()[0] + if owners <= 1: + self._send_json( + 409, + {"error": "cannot demote the only owner"}) + return + self._set_member_row(store, project_id, user_id, + target, role) + self._send_json(200, {"project_id": project_id, + "user_id": target, "role": role}) + # -- 项目子资源:记忆 / 知识库 / 成本 ------------------------------- def _project_exists(self, project_id): conn = self._db() @@ -2107,6 +2501,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): if len(parts) == 1 and method == "PATCH": self._update_project(user_id, project_id) return + # /projects/{id}/members:GET 列表 / POST 邀请 + if len(parts) == 2 and parts[1] == "members": + if method == "GET": + self._project_members_list(user_id, project_id) + elif method == "POST": + self._project_member_invite(user_id, project_id) + else: + self._send_json(404, {"error": "not found"}) + return + # /projects/{id}/members/{user_id}:PATCH 改角色 + if len(parts) == 3 and parts[1] == "members": + if method == "PATCH": + self._project_member_patch(user_id, project_id, parts[2]) + else: + self._send_json(404, {"error": "not found"}) + return # /projects/{id}/memory if len(parts) == 2 and parts[1] == "memory": if method == "POST": @@ -2257,6 +2667,69 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): return self._triage_inbox(project_id) return + # /projects/{id}/skills GET(项目 read) + if len(parts) == 2 and parts[1] == "skills": + 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._skills_list(project_id) + return + # /projects/{id}/apps GET(项目 read) + if len(parts) == 2 and parts[1] == "apps": + 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._apps_list(project_id) + return + # /projects/{id}/mcp-servers GET(项目 read) + if len(parts) == 2 and parts[1] == "mcp-servers": + 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._mcp_servers_list(project_id) + return + # /projects/{id}/mcp-servers/reload POST(项目 manage_project) + if len(parts) == 3 and parts[1] == "mcp-servers" \ + and parts[2] == "reload": + if method != "POST": + self._send_json(404, {"error": "not found"}) + return + err = self._check_project(user_id, project_id, + "manage_project") + if err: + self._send_json(err[0], {"error": err[1]}) + return + self._mcp_servers_reload(user_id, project_id) + return + # /projects/{id}/mcp-servers/{name}/oauth-login POST + # (项目 manage_project;name 为空或穿越时 404) + if len(parts) == 4 and parts[1] == "mcp-servers" \ + and parts[3] == "oauth-login": + if method != "POST": + self._send_json(404, {"error": "not found"}) + return + if not parts[2] or parts[2] in (".", ".."): + self._send_json(404, {"error": "not found"}) + return + err = self._check_project(user_id, project_id, + "manage_project") + if err: + self._send_json(err[0], {"error": err[1]}) + return + self._mcp_server_oauth_login(user_id, project_id, parts[2]) + return self._send_json(404, {"error": "not found"}) def _memory_create(self, user_id, project_id): @@ -3513,10 +3986,375 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): "review_findings_updated", "%s -> %s" % (task_id, review_id)) self._send_json(200, updated) + # -- 任务线程操作(经 host.py CLI 透传)---------------------------- + # 本批限制(P2 切片 C 接线时如实保留,见 cherry-pick 说明): + # Docker/异机任务线程操作一律 409(见 _thread_task_guard); + # 改操作仅 completed/failed/interrupted,merged/merge-failed + # 409(见 THREAD_MUTATION_STATUSES,控制台门控与此一致); + # turn/steer 与 review/start 不在本批(review 端点是 P1 已有 + # 功能,不由本批新增或改动)。 + def _thread_task(self, user_id, task_id, action): + """取任务并验项目权限:返回 (task, None),或 (None, err), + err 为 (status, msg),调用方直接 _send_json。任务不存在 + 404 在先(与 peer 消息同款顺序),权限不足 403。""" + row = self._fetch_task(task_id) + if row is None: + return None, (404, "task not found") + task = _task_to_dict(row) + with _auth_store(self.server.db_path) as store: + if not store.check_permission( + user_id, task["project_id"], action): + return None, (403, "forbidden") + return task, None + + def _thread_task_guard(self, task): + """线程操作的执行器门:只做 local 执行器任务。Docker + 任务的 thread 在容器内、cloud 任务的 thread 不在本机, + 本机租户 codex-home 里没有,查了也是错指,一律 409 + 明说,不回空结果假装无 thread。executor 为空(建任务 + 时未指定)时放行:老任务多为 NULL 且实际由 local + worker 认领,卡死它们等于端点对老任务不可用;若实际 + 是 Docker 跑的,thread/list 会返回零个而得 404。 + worktree 在别机时同样 409(与链路的跨机诚实降级同款, + 但改操作绝不能在错的主机 home 上执行,所以不用 200 + 空结果)。返回 None 表示通过,否则为 (status, msg)。""" + executor = task.get("executor") + if executor == "docker": + return (409, "thread ops are local-executor only;" + " docker task threads live inside the container") + if executor == "cloud": + return (409, "thread ops are local-executor only;" + " cloud task threads are not on this host") + worktree_host = task.get("worktree_host") + if worktree_host and worktree_host != socket.gethostname(): + return (409, "task worktree is on host %s;" + " thread ops must run there" % worktree_host) + return None + + def _list_task_threads(self, task): + """对任务调 thread-list:返回 (threads, codex_home, cwd, + err),err 为 (status, msg) 或 None。cwd 过滤用任务 + worktree(见 _thread_worktree_for_task);codex-home + 用租户 home(见 _tenant_codex_home)。""" + codex_home = _tenant_codex_home(self.server, + task["project_id"]) + if codex_home is None: + return None, None, None, ( + 502, "cannot resolve tenant codex-home") + cwd = _thread_worktree_for_task(self.server, task) + ok, result = _run_host_cli(["thread-list", "--cwd", cwd], + codex_home) + if not ok: + return None, None, None, (502, result) + threads = _extract_thread_list(result) + if threads is None: + return None, None, None, ( + 502, "host.py thread-list output has no thread list") + return threads, codex_home, cwd, None + + def _resolve_task_thread(self, task): + """单线程端点的解析:thread-list 后按 _pick_latest_thread + 取最新一个非 archived。返回 (thread, codex_home, cwd, + err);零个 thread 时 err 为 (404, "thread not found")。""" + threads, codex_home, cwd, err = self._list_task_threads(task) + if err is not None: + return None, None, None, err + thread = _pick_latest_thread(threads) + if thread is None: + return None, None, None, (404, "thread not found") + return thread, codex_home, cwd, None + + def _task_threads(self, user_id, task_id): + """GET /tasks/{id}/threads:thread/list 原样返回列表。 + 读权限用项目 read;零个 thread 时返回空列表(原样), + 不像单线程端点那样 404——列表端点没有“解析不到”的 + 概念,空就是空。""" + task, err = self._thread_task(user_id, task_id, "read") + if err is not None: + self._send_json(err[0], {"error": err[1]}) + return + guard = self._thread_task_guard(task) + if guard is not None: + self._send_json(guard[0], {"error": guard[1]}) + return + threads, _home, cwd, err = self._list_task_threads(task) + if err is not None: + self._send_json(err[0], {"error": err[1]}) + return + self._send_json(200, {"task_id": task_id, "cwd": cwd, + "threads": threads}) + + def _task_thread(self, user_id, task_id, query): + """GET /tasks/{id}/thread:先 thread/list 解析出 thread, + 再 thread/read。默认只要元数据(includeTurns 不传); + ?include_turns=1 时才传 includeTurns。读操作不限制 + 任务状态;执行错误原样透传为 502。""" + task, err = self._thread_task(user_id, task_id, "read") + if err is not None: + self._send_json(err[0], {"error": err[1]}) + return + guard = self._thread_task_guard(task) + if guard is not None: + self._send_json(guard[0], {"error": guard[1]}) + return + thread, codex_home, cwd, err = self._resolve_task_thread(task) + if err is not None: + self._send_json(err[0], {"error": err[1]}) + return + argv = ["thread-read", "--thread-id", thread["id"]] + include_turns = (query.get("include_turns") or ["0"])[0] + if include_turns == "1": + argv.append("--include-turns") + ok, result = _run_host_cli(argv, codex_home) + if not ok: + self._send_json(502, {"error": result}) + return + full = _extract_thread(result) + if full is None: + self._send_json(502, { + "error": "host.py thread-read output has no thread"}) + return + self._send_json(200, {"task_id": task_id, "cwd": cwd, + "thread_id": thread["id"], + "thread": full}) + + def _task_thread_mutate(self, user_id, task_id, argv, audit_action, + audit_detail, timeout=60): + """线程改操作的公共流程:权限用项目 create_task;只允许 + 终态任务(completed/failed/interrupted),running/queued + 一律 409;解析 thread 后调对应 CLI,成功记审计。""" + task, err = self._thread_task(user_id, task_id, "create_task") + if err is not None: + self._send_json(err[0], {"error": err[1]}) + return + guard = self._thread_task_guard(task) + if guard is not None: + self._send_json(guard[0], {"error": guard[1]}) + return + if task["status"] not in THREAD_MUTATION_STATUSES: + self._send_json(409, { + "error": "thread ops require a terminal task", + "status": task["status"]}) + return + thread, codex_home, _cwd, err = self._resolve_task_thread(task) + if err is not None: + self._send_json(err[0], {"error": err[1]}) + return + ok, result = _run_host_cli( + list(argv) + ["--thread-id", thread["id"]], + codex_home, timeout=timeout) + if not ok: + self._send_json(502, {"error": result}) + return + with _auth_store(self.server.db_path) as store: + store.audit(user_id, task["project_id"], audit_action, + audit_detail % thread["id"]) + self._send_json(200, {"task_id": task_id, + "thread_id": thread["id"], + "result": result}) + + def _task_thread_rename(self, user_id, task_id): + """POST /tasks/{id}/thread/rename {name}:thread/name/set。 + name 必填、非空、200 字内;记 thread_renamed 审计。""" + body, jerr = self._read_json() + if jerr: + self._send_json(400, {"error": jerr}) + return + name = body.get("name") + if not name or not isinstance(name, str) or not name.strip(): + self._send_json(400, {"error": "name required"}) + return + name = name.strip() + if len(name) > 200: + self._send_json(400, {"error": "name too long"}) + return + self._task_thread_mutate( + user_id, task_id, ["thread-rename", "--name", name], + "thread_renamed", "%s renamed to " + name) + + def _task_thread_archive(self, user_id, task_id): + """POST /tasks/{id}/thread/archive:thread/archive。 + 记 thread_archived 审计。""" + self._task_thread_mutate(user_id, task_id, ["thread-archive"], + "thread_archived", "%s archived") + + def _task_thread_unarchive(self, user_id, task_id): + """POST /tasks/{id}/thread/unarchive:thread/unarchive。 + 记 thread_unarchived 审计。""" + self._task_thread_mutate(user_id, task_id, ["thread-unarchive"], + "thread_unarchived", "%s unarchived") + + def _task_thread_compact(self, user_id, task_id): + """POST /tasks/{id}/thread/compact:thread/compact/start。 + 压缩可能耗时,CLI 超时放宽到 120 秒;记 thread_compacted + 审计。注意:这是线程压缩,不是开新回合,不跑模型 + 轮次之外的 turn(压缩本身由 app-server 内部完成)。""" + self._task_thread_mutate(user_id, task_id, ["thread-compact"], + "thread_compacted", "%s compacted", + timeout=120) + + # -- 项目 skills / apps / MCP(经 host.py CLI 透传)-------------- + # codex-home 按项目分租户目录,所以这批端点是项目 scope: + # 读用项目 read,改用项目 manage_project(owner)。 + def _project_host_home(self, project_id): + """项目端点的租户 home 与工作目录:返回 (codex_home, + work_dir),任一解析不出时返回 (None, None)。注意:这里 + 反映的是宿主租户 home,容器内 home 可能不同,skills/MCP + 面看到的是宿主侧视图,不代表容器内视图。""" + codex_home = _tenant_codex_home(self.server, project_id) + work_dir = _tenant_work_dir(self.server, project_id) + if codex_home is None or work_dir is None: + return None, None + return codex_home, work_dir + + def _skills_list(self, project_id): + """GET /projects/{id}/skills:skills/list 原样返回。""" + codex_home, work_dir = self._project_host_home(project_id) + if codex_home is None: + self._send_json(502, { + "error": "cannot resolve tenant codex-home"}) + return + ok, result = _run_host_cli(["skills-list", "--cwd", work_dir], + codex_home) + if not ok: + self._send_json(502, {"error": result}) + return + skills = _extract_result_list(result, ("data", "skills")) + if skills is None: + self._send_json(502, { + "error": "host.py skills-list output has no list"}) + return + self._send_json(200, {"project_id": project_id, + "skills": skills}) + + def _apps_list(self, project_id): + """GET /projects/{id}/apps:app/list 原样返回(schema 里 + 是 AppsListParams/AppsListResponse;项目 scope 下不带 + threadId,不做按线程的特性门控)。""" + codex_home, _work = self._project_host_home(project_id) + if codex_home is None: + self._send_json(502, { + "error": "cannot resolve tenant codex-home"}) + return + ok, result = _run_host_cli(["apps-list"], codex_home) + if not ok: + self._send_json(502, {"error": result}) + return + apps = _extract_result_list(result, ("data", "apps")) + if apps is None: + self._send_json(502, { + "error": "host.py apps-list output has no list"}) + return + body = {"project_id": project_id, "apps": apps} + cursor = result.get("nextCursor") + if cursor is not None: + body["nextCursor"] = cursor + self._send_json(200, body) + + def _mcp_servers_list(self, project_id): + """GET /projects/{id}/mcp-servers:mcpServerStatus/list + 原样返回(schema 里是 ListMcpServerStatusParams/ + Response;不带 threadId,走配置发现)。""" + codex_home, _work = self._project_host_home(project_id) + if codex_home is None: + self._send_json(502, { + "error": "cannot resolve tenant codex-home"}) + return + ok, result = _run_host_cli(["mcp-servers-list"], codex_home) + if not ok: + self._send_json(502, {"error": result}) + return + servers = _extract_result_list(result, ("data", "servers")) + if servers is None: + self._send_json(502, { + "error": "host.py mcp-servers-list output has no list"}) + return + body = {"project_id": project_id, "servers": servers} + cursor = result.get("nextCursor") + if cursor is not None: + body["nextCursor"] = cursor + self._send_json(200, body) + + def _mcp_servers_reload(self, user_id, project_id): + """POST /projects/{id}/mcp-servers/reload:config/mcpServer/ + reload。核对结论:该方法在协议 schema 里真实存在—— + v1 与 v2 的 ClientRequest 方法枚举里都有它,params + 为 null,没有专用的响应定义(结果通常为空),所以 + 正常调、结果原样透传;只有 CLI 本身报错时才 502, + 绝不伪造“不支持”也不伪造成功。记审计。""" + codex_home, _work = self._project_host_home(project_id) + if codex_home is None: + self._send_json(502, { + "error": "cannot resolve tenant codex-home"}) + return + ok, result = _run_host_cli(["mcp-servers-reload"], codex_home) + if not ok: + self._send_json(502, {"error": result}) + return + with _auth_store(self.server.db_path) as store: + store.audit(user_id, project_id, "mcp_servers_reloaded", + "mcp servers reloaded") + self._send_json(200, {"project_id": project_id, + "reloaded": True, "result": result}) + + def _mcp_server_oauth_login(self, user_id, project_id, name): + """POST /projects/{id}/mcp-servers/{name}/oauth-login: + mcpServer/oauth/login。协议返回(McpServerOauthLoginResponse: + authorizationUrl 必填、loginId 可空)原样透传给调用方, + 浏览器里完成登录的 URL/步骤由调用方展示。完成态轮询 + 不做:协议里完成信号是 McpServerOauthLoginCompletedNotification + (服务端通知,不是请求/响应轮询),本端点只做到“发起 + 登录并返回授权 URL”这一步,绝不假装登录已完成。 + 记审计时只记服务器名与 loginId,不记授权 URL。""" + body, jerr = self._read_json() + if jerr: + self._send_json(400, {"error": jerr}) + return + argv = ["mcp-server-oauth-login", "--server-name", name] + scopes = body.get("scopes") + if scopes is not None: + if not isinstance(scopes, list) or any( + not isinstance(s, str) for s in scopes): + self._send_json(400, { + "error": "scopes must be a list of strings"}) + return + if scopes: + argv += ["--scopes", ",".join(scopes)] + timeout_secs = body.get("timeoutSecs") + if timeout_secs is not None: + if not isinstance(timeout_secs, int) or timeout_secs <= 0: + self._send_json(400, { + "error": "timeoutSecs must be a positive integer"}) + return + argv += ["--timeout-secs", str(timeout_secs)] + codex_home, _work = self._project_host_home(project_id) + if codex_home is None: + self._send_json(502, { + "error": "cannot resolve tenant codex-home"}) + return + ok, result = _run_host_cli(argv, codex_home, timeout=120) + if not ok: + self._send_json(502, {"error": result}) + return + if not isinstance(result.get("authorizationUrl"), str): + self._send_json(502, { + "error": "host.py oauth-login output has no" + " authorizationUrl"}) + return + with _auth_store(self.server.db_path) as store: + store.audit(user_id, project_id, + "mcp_server_oauth_login_started", + "%s loginId=%s" % ( + name, result.get("loginId") or "")) + response = dict(result) + response["project_id"] = project_id + response["server_name"] = name + self._send_json(200, response) def _fork_task(self, user_id, task_id): """会话分支:从已结束任务的 thread 分叉出新任务。 + 新任务继承源任务的 executor/模型/强度/权限/计划模式, 不继承 goal(分支是普通任务,首轮 prompt 就是这里传的 message)。worker 认领后先调 host.py fork(分叉 thread diff --git a/cloud/tests/test_console.py b/cloud/tests/test_console.py index 2049ed8..462c7b5 100644 --- a/cloud/tests/test_console.py +++ b/cloud/tests/test_console.py @@ -65,7 +65,15 @@ class ConsoleTestCase(unittest.TestCase): # 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。 for marker in ("/health", "/projects", "/tasks", "/workers", "X-API-Key", "agentBox", "/agents", - "peerMessage", "/messages"): + "peerMessage", "/messages", + "threadBox", "threadGateNote", "threadList", + "THREAD_MUTATION_STATUSES", + "/thread", "/threads", + "/thread/rename", "/thread/archive", + "/thread/unarchive", "/thread/compact", + "skillList", "appList", "mcpList", "mcpOauthBox", + "/skills", "/apps", "/mcp-servers", + "/mcp-servers/reload", "/oauth-login"): self.assertIn(marker, text) def test_console_trailing_slash_same_page(self): diff --git a/cloud/tests/test_p2_console.py b/cloud/tests/test_p2_console.py new file mode 100644 index 0000000..a45ffc0 --- /dev/null +++ b/cloud/tests/test_p2_console.py @@ -0,0 +1,340 @@ +"""P2 切片 A(控制台管理面与筛选)单测。 + +只覆盖本切片新增或复用的服务端契约 + 控制台壳标记,不跑 +浏览器 JS(与 test_console.py 同款:壳只验标记与路由): +- 成员端点:非 owner 邀请 403、owner 邀请成功(user_id 与 + username 两种入参)、重复邀请 409、非法角色 400、未知用户 + 404、成员列表读权限、改角色权限与唯一 owner 防降级、 + 非成员全局管理员可邀请/改角色。 +- 管理区复用端点:非管理员 sweep 403、管理员配额读成功、 + 探针语义(非管理员 GET /admin/quotas 无参 403,管理员 400)。 +- 链路全量:写 1200 条链路文件,GET /trace 全量返回(控制台 + “下载完整链路”依赖的正是该端点,而非屏上截断的 1000 条)。 +- Workers:心跳后 /workers 返回 epoch 秒 last_seen 与 alive。 +- 控制台壳含本切片新增标记。 + +测试用的 key、用户 id 全是假值;不联网、不碰生产。 +""" + +import http.client +import json +import os +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +try: + from cloud.auth import AuthStore + from cloud.server import create_server +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.server import create_server + + +def _req(port, method, path, body=None, key=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + headers = {"Content-Type": "application/json"} + if key: + headers["X-API-Key"] = key + conn.request(method, path, + json.dumps(body) if body is not None else None, headers) + resp = conn.getresponse() + raw = resp.read() + conn.close() + try: + data = json.loads(raw) + except ValueError: + data = None + return resp.status, data + + +class P2ConsoleTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.trace_dir = os.path.join(self.tmp.name, "host-traces") + os.makedirs(self.trace_dir, exist_ok=True) + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.maint_id, self.maint_key = self.auth.create_user("maint") + self.member_id, self.member_key = self.auth.create_user("member") + self.admin_id, self.admin_key = self.auth.create_user("admin") + self.outsider_id, self.out_key = self.auth.create_user("outsider") + self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS") + # 全局管理员:非项目成员的 admin,验“owner 或全局管理员”双通道。 + os.environ["CODEX_GLOBAL_ADMINS"] = self.admin_id + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.server.trace_dir = self.trace_dir + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, proj = _req(self.port, "POST", "/projects", + {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201, proj) + self.project_id = proj["id"] + self.auth.set_member(self.project_id, self.owner_id, + self.maint_id, "maintainer") + + 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() + if self._old_admins is None: + os.environ.pop("CODEX_GLOBAL_ADMINS", None) + else: + os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins + + def _members(self, key=None): + return _req(self.port, "GET", + "/projects/%s/members" % self.project_id, + key=key) + + # -- 成员邀请 / 列表 ------------------------------------------- + def test_non_owner_invite_forbidden(self): + # maintainer 不是 owner、不是全局管理员:邀请必须 403, + # 且安全门不能因“项目成员”身份放行。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.maint_key) + self.assertEqual(status, 403, data) + + def test_outsider_invite_forbidden(self): + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.out_key) + self.assertEqual(status, 403, data) + + def test_owner_invite_by_user_id_and_username(self): + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "contributor"}, + self.owner_key) + self.assertEqual(status, 201, data) + self.assertEqual(data["role"], "contributor") + # 第二个用户走 username 入参(控制台输入框两种都接受)。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"username": "outsider", "role": "viewer"}, + self.owner_key) + self.assertEqual(status, 201, data) + self.assertEqual(data["user_id"], self.outsider_id) + + def test_invite_validation(self): + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "superuser"}, + self.owner_key) + self.assertEqual(status, 400) + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": "no-such-user", "role": "viewer"}, + self.owner_key) + self.assertEqual(status, 404) + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"role": "viewer"}, + self.owner_key) + self.assertEqual(status, 400) + + def test_invite_duplicate_conflict(self): + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.owner_key) + self.assertEqual(status, 201) + # 已是成员:改角色走 PATCH,重复邀请 409,不静默覆盖。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "maintainer"}, + self.owner_key) + self.assertEqual(status, 409, data) + + def test_member_list_read_and_outsider_denied(self): + _req(self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.owner_key) + status, data = self._members(self.maint_key) + self.assertEqual(status, 200, data) + roles = {m["user_id"]: m["role"] for m in data["members"]} + self.assertEqual(roles[self.owner_id], "owner") + self.assertEqual(roles[self.maint_id], "maintainer") + self.assertEqual(roles[self.member_id], "viewer") + # 列表不泄露认证字段。 + for m in data["members"]: + self.assertNotIn("api_key_hash", m) + self.assertNotIn("key_hash", m) + status, _ = self._members(self.out_key) + self.assertEqual(status, 403) + + def test_global_admin_can_invite_without_membership(self): + # admin 不是项目成员,但凭 CODEX_GLOBAL_ADMINS 可邀请。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "contributor"}, + self.admin_key) + self.assertEqual(status, 201, data) + + # -- 改角色 / 唯一 owner 防降级 --------------------------------- + def test_patch_role_by_owner_and_non_owner_denied(self): + _req(self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.owner_key) + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.member_id), + {"role": "maintainer"}, self.maint_key) + self.assertEqual(status, 403, data) + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.member_id), + {"role": "maintainer"}, self.owner_key) + self.assertEqual(status, 200, data) + self.assertEqual(data["role"], "maintainer") + status, _ = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, "ghost"), + {"role": "viewer"}, self.owner_key) + self.assertEqual(status, 404) + + def test_cannot_demote_only_owner(self): + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.owner_id), + {"role": "maintainer"}, self.owner_key) + self.assertEqual(status, 409, data) + # 先把另一位成员升为 owner,之后降原 owner 才放行。 + status, _ = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.maint_id), + {"role": "owner"}, self.owner_key) + self.assertEqual(status, 200) + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.owner_id), + {"role": "maintainer"}, self.maint_key) + self.assertEqual(status, 200, data) + + def test_global_admin_patch_blocked_by_sole_owner_guard(self): + # 全局管理员也不能把唯一 owner 降掉(防项目无 owner 可管)。 + status, _ = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.owner_id), + {"role": "viewer"}, self.admin_key) + self.assertEqual(status, 409) + + # -- 管理区复用端点 --------------------------------------------- + def test_admin_probe_and_quota_reuse(self): + # 探针语义:控制台凭此区分管理员与否,不新增端点。 + status, _ = _req(self.port, "GET", "/admin/quotas", + key=self.maint_key) + self.assertEqual(status, 403) + status, _ = _req(self.port, "GET", "/admin/quotas", + key=self.admin_key) + self.assertEqual(status, 400) + status, data = _req( + self.port, "GET", + "/admin/quotas?user_id=%s" % self.owner_id, + key=self.admin_key) + self.assertEqual(status, 200, data) + self.assertIn("used_usd_micros", data) + status, _ = _req( + self.port, "GET", + "/admin/quotas?user_id=%s" % self.owner_id, + key=self.maint_key) + self.assertEqual(status, 403) + + def test_non_admin_sweep_forbidden(self): + status, _ = _req(self.port, "POST", "/admin/sweep", {}, + self.maint_key) + self.assertEqual(status, 403) + status, _ = _req(self.port, "POST", "/admin/backup", {}, + self.maint_key) + self.assertEqual(status, 403) + + # -- 链路全量 / Workers ------------------------------------------ + def test_trace_endpoint_returns_full_entries(self): + status, task = _req( + self.port, "POST", "/tasks", + {"project_id": self.project_id, "prompt": "trace probe"}, + self.owner_key) + self.assertEqual(status, 201, task) + task_id = task["id"] + path = os.path.join(self.trace_dir, task_id + ".jsonl") + with open(path, "w", encoding="utf-8") as f: + for i in range(1200): + f.write(json.dumps( + {"id": "e%d" % i, "kind": "message", + "text": "line %d" % i}) + "\n") + status, data = _req( + self.port, "GET", "/tasks/%s/trace" % task_id, + key=self.owner_key) + self.assertEqual(status, 200, data) + # 服务端不截断:控制台下载按钮拿到的就是这 1200 条全量, + # 屏上 1000 条截断只是渲染层行为。 + self.assertEqual(len(data["entries"]), 1200) + + def test_workers_last_seen_is_epoch_and_alive(self): + status, data = _req( + self.port, "POST", "/workers/heartbeat", + {"worker_id": "w-p2", "executor": "docker", + "capacity": 2, "running": 0}, + self.owner_key) + self.assertEqual(status, 200, data) + # 合并适配(P2 rebase 到 97f019d,P1 已合并):P1 起完整 + # worker 清单只给全局管理员,项目 owner 只能看到聚合 + # (聚合行为由 P1 的 test_workers_admin_full_member_aggregate_only + # 覆盖);本用例验的是完整清单里的 epoch last_seen 与 + # alive,故用本夹具的全局管理员 admin_key 查,不降门。 + status, data = _req(self.port, "GET", "/workers", + key=self.admin_key) + self.assertEqual(status, 200, data) + workers = data["workers"] + self.assertEqual(len(workers), 1) + # 控制台“x 秒/分钟前 + 120 秒在线阈值”的输入:epoch 秒。 + self.assertIsInstance(workers[0]["last_seen"], (int, float)) + self.assertLess(abs(workers[0]["last_seen"] - time.time()), 60) + self.assertTrue(workers[0]["alive"]) + + # -- 控制台壳标记 ------------------------------------------------- + def test_console_contains_p2_markers(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + conn.request("GET", "/console") + resp = conn.getresponse() + text = resp.read().decode("utf-8") + conn.close() + self.assertEqual(resp.status, 200) + for marker in ("/members", "inviteMember", "memberList", + "adminBody", "/admin/sweep", "/admin/backup", + "/admin/quotas", "taskStatusFilter", + "taskExecutorFilter", "taskSearch", + "btnDownloadTrace", "traceKindFilter", + "workerAgeText", "last_seen"): + self.assertIn(marker, text, marker) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_p2_migrations.py b/cloud/tests/test_p2_migrations.py new file mode 100644 index 0000000..56ddc38 --- /dev/null +++ b/cloud/tests/test_p2_migrations.py @@ -0,0 +1,203 @@ +"""P2-ops 迁移对齐:迁移清单必须等于 Postgres schema 表集合, +未迁移项必须被显式报告,绝不能静默跳过。 + +不用真实 Postgres:迁移本体用一个假 PG 连接跑(只记录 +INSERT / ALTER,不连库);schema 对齐直接读 POSTGRES_SCHEMA。 +真实 PG 上的幂等性仍由 test_postgres_integration.py 覆盖 +(有 CLOUD_TEST_PG_DSN 时才跑)。 +""" +import os +import re +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +try: + from cloud.db import POSTGRES_SCHEMA + from cloud.migrations import ( + MIGRATION_TABLES, + SEQUENCE_TABLES, + UNMIGRATED_TABLES, + migrate_sqlite_to_postgres, + migration_coverage, + schema_tables, + ) +except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.db import POSTGRES_SCHEMA + from cloud.migrations import ( + MIGRATION_TABLES, + SEQUENCE_TABLES, + UNMIGRATED_TABLES, + migrate_sqlite_to_postgres, + migration_coverage, + schema_tables, + ) + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +class FakeCursor: + def __init__(self, rows=None, rowcount=1): + self._rows = rows or [] + self.rowcount = rowcount + + def fetchall(self): + return self._rows + + +class FakePgConn: + """只记录 SQL 的假 PG 连接:init_postgres_schema 的 DDL + 直接忽略;PRAGMA 按构造时给的列集合回答(row[1] 为列名, + 与 init_postgres_schema / 迁移补列代码的取法一致)。""" + + def __init__(self, pragma_columns=None): + # pragma_columns: {表名: {列名}};缺省表示“列齐全”。 + self.pragma_columns = pragma_columns or {} + self.statements = [] + self.inserts = [] # (表名, 列名元组) + self.alters = [] + self.commits = 0 + + def execute(self, sql, params=()): + self.statements.append(sql) + if sql.startswith("PRAGMA table_info("): + table = sql[len("PRAGMA table_info("):].rstrip(")") + cols = self.pragma_columns.get(table) + if cols is None: + # 未指定时假装列齐全:projects 含 repo_url, + # task_followups 含 sender_task_id。 + cols = {"repo_url"} if table == "projects" \ + else {"sender_task_id"} + rows = [(i, c, None) for i, c in enumerate(sorted(cols))] + return FakeCursor(rows=rows) + m = re.search(r'INSERT INTO "(\w+)" \(([^)]*)\)', sql) + if m: + cols = tuple(c.strip().strip('"') + for c in m.group(2).split(",")) + self.inserts.append((m.group(1), cols)) + return FakeCursor(rowcount=1) + if sql.startswith("ALTER TABLE"): + self.alters.append(sql) + return FakeCursor(rowcount=0) + return FakeCursor(rowcount=0) + + def commit(self): + self.commits += 1 + + +def _make_sqlite(path): + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE uploads(id TEXT PRIMARY KEY, created_by TEXT," + " created_at REAL, status TEXT)") + conn.execute("CREATE TABLE task_attachments(task_id TEXT, bundle_id TEXT," + " rel_path TEXT, size INTEGER," + " PRIMARY KEY(task_id, rel_path))") + conn.execute("CREATE TABLE task_followups(id TEXT PRIMARY KEY," + " task_id TEXT, project_id TEXT, body TEXT, mode TEXT," + " status TEXT, created_by TEXT, sender_task_id TEXT," + " created_at REAL, updated_at REAL)") + conn.execute("CREATE TABLE environments(id TEXT PRIMARY KEY," + " project_id TEXT, name TEXT, branch TEXT, executor TEXT," + " status TEXT, error_text TEXT, repo_url TEXT," + " created_by TEXT, created_at REAL, updated_at REAL)") + conn.execute("INSERT INTO uploads VALUES(?, ?, ?, ?)", + ("up1", "u1", 1.0, "done")) + conn.execute("INSERT INTO task_attachments VALUES(?, ?, ?, ?)", + ("t1", "b1", "a.txt", 3)) + conn.execute("INSERT INTO task_followups VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("f1", "t1", "p1", "hi", "queued", "pending", "u1", + "sender-task", 1.0, 1.0)) + conn.execute("INSERT INTO environments VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("e1", "p1", "dev", "main", "docker", "ready", "", + "https://example.invalid/r", "u1", 1.0, 1.0)) + conn.commit() + conn.close() + + +class TestMigrationAlignment(unittest.TestCase): + def test_migration_tables_equal_schema_tables(self): + # 核心对齐断言:以前漏掉的 4 张表补进后,两边集合必须一致。 + self.assertEqual(set(MIGRATION_TABLES), schema_tables()) + for table in ("uploads", "task_attachments", + "task_followups", "environments", "user_quotas"): + self.assertIn(table, MIGRATION_TABLES) + + def test_sequence_tables_equal_identity_tables(self): + # 序列拨动清单必须等于 schema 里 IDENTITY 列的表集合: + # TEXT 主键的新表不需要拨序列,漏拨或多拨都会在这里变红。 + identity = set() + for statement in POSTGRES_SCHEMA: + m = re.search(r"CREATE TABLE IF NOT EXISTS (\w+)", statement) + if m and "GENERATED BY DEFAULT AS IDENTITY" in statement: + identity.add(m.group(1)) + self.assertEqual(set(SEQUENCE_TABLES), identity) + + def test_unmigrated_are_real_sqlite_tables_not_in_schema(self): + # 未迁移清单不得是凭空编的:每项都必须在 cloud/ 源码里 + # 真有 CREATE TABLE,且确实不在 PG schema 里;同时不得 + # 与迁移清单重叠(重叠即“既迁又报未迁”,自相矛盾)。 + sources = "" + for name in os.listdir(REPO_ROOT / "cloud"): + if name.endswith(".py"): + with open(REPO_ROOT / "cloud" / name) as f: + sources += f.read() + schema = schema_tables() + for table in UNMIGRATED_TABLES: + self.assertIn("CREATE TABLE IF NOT EXISTS %s" % table, sources, + "%s 不在任何 SQLite DDL 里,清单已过期" % table) + self.assertNotIn(table, schema) + self.assertNotIn(table, MIGRATION_TABLES) + + def test_coverage_reports_no_missing(self): + coverage = migration_coverage() + self.assertEqual(coverage["missing_from_migration"], ()) + self.assertEqual(coverage["unmigrated_no_pg_schema"], + tuple(UNMIGRATED_TABLES)) + self.assertEqual(set(coverage["skipped_by_design"]), + {"kb_fts", "sqlite_sequence"}) + + +class TestMigrateNewTables(unittest.TestCase): + def test_new_tables_migrated_and_sender_column_added(self): + # PG 侧 task_followups 尚无 sender_task_id:迁移必须先 + # ALTER 补列,再把含该列的行插过去,而不是撞未知列失败。 + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "src.db") + _make_sqlite(src) + fake = FakePgConn(pragma_columns={ + "projects": {"repo_url"}, + "task_followups": {"id", "task_id", "project_id", "body", + "mode", "status", "created_by", + "created_at", "updated_at"}, + }) + result = migrate_sqlite_to_postgres(src, pg_conn=fake) + for table in ("uploads", "task_attachments", + "task_followups", "environments"): + self.assertEqual(result.get(table), 1, table) + self.assertTrue( + any("task_followups" in a and "sender_task_id" in a + for a in fake.alters), + "未发出 sender_task_id 补列 ALTER:%s" % fake.alters) + fu_inserts = [cols for t, cols in fake.inserts + if t == "task_followups"] + self.assertEqual(len(fu_inserts), 1) + self.assertIn("sender_task_id", fu_inserts[0]) + + def test_sender_column_present_skips_alter(self): + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "src.db") + _make_sqlite(src) + fake = FakePgConn() # 缺省:列齐全 + migrate_sqlite_to_postgres(src, pg_conn=fake, + tables=("task_followups",)) + # init_postgres_schema 自身会给 projects/tasks 补列, + # 这里只断言 task_followups 没有重复补列。 + self.assertEqual( + [a for a in fake.alters if "task_followups" in a], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_skills_mcp.py b/cloud/tests/test_skills_mcp.py new file mode 100644 index 0000000..014e960 --- /dev/null +++ b/cloud/tests/test_skills_mcp.py @@ -0,0 +1,337 @@ +"""Skills/apps/MCP server tests: /projects//skills, /apps, +/mcp-servers, /mcp-servers/reload, /mcp-servers//oauth-login. + +No real host.py CLI, no real codex binary: a stub script written into +tmp pretends to be host.py and prints canned JSON per the frozen CLI +contract - one JSON object on stdout, nonzero exit plus {"error": ...} +on failure. The stub reads a scenario JSON file and appends every +invocation to a log file so tests can assert the argv the server +passed, including the tenant work dir used as skills/list cwd. +""" + +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.server import create_server +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.server import create_server + +STUB = r'''import json +import os +import sys + +def main(): + argv = sys.argv[1:] + cmd = None + rest = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--codex-home": + i += 2 + continue + if a.startswith("--codex-home="): + i += 1 + continue + if cmd is None: + cmd = a + i += 1 + continue + rest.append(a) + i += 1 + log = os.environ.get("THREADOPS_STUB_LOG") + if log: + with open(log, "a", encoding="utf-8") as f: + f.write(json.dumps({"cmd": cmd, "argv": rest}) + "\n") + scenario = {} + path = os.environ.get("THREADOPS_STUB_SCENARIO") + if path and os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + scenario = json.load(f) + if scenario.get("fail_cmd") == cmd: + print(json.dumps({"error": scenario.get("fail_error", "boom")})) + sys.exit(1) + if cmd == "skills-list": + print(json.dumps({"data": scenario.get("skills", [ + {"cwd": "/work", "skills": [{"name": "demo-skill"}]}, + ])})) + elif cmd == "apps-list": + print(json.dumps({"data": [{"id": "app-1", "name": "Demo App"}], + "nextCursor": "cur-1"})) + elif cmd == "mcp-servers-list": + print(json.dumps({"data": [{"name": "srv-1", + "runtimeStatus": "running"}]})) + elif cmd == "mcp-servers-reload": + print(json.dumps({})) + elif cmd == "mcp-server-oauth-login": + if scenario.get("oauth_no_url"): + print(json.dumps({})) + else: + print(json.dumps({ + "authorizationUrl": "https://example.invalid/oauth/authorize?x=1", + "loginId": "login-123"})) + else: + print(json.dumps({"error": "unknown subcommand"})) + sys.exit(1) + +main() +''' + + +class SkillsMcpTestCase(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.reader_id, self.reader_key = self.auth.create_user("reader") + self.outsider_id, self.outsider_key = self.auth.create_user("outsider") + self.stub_path = os.path.join(self.tmp.name, "stub_host.py") + with open(self.stub_path, "w", encoding="utf-8") as f: + f.write(STUB) + self.scenario_path = os.path.join(self.tmp.name, "scenario.json") + self.log_path = os.path.join(self.tmp.name, "stub.log") + self._write_scenario({}) + self._old_host_py = os.environ.get("CODEX_HOST_PY") + os.environ["CODEX_HOST_PY"] = self.stub_path + os.environ["THREADOPS_STUB_SCENARIO"] = self.scenario_path + os.environ["THREADOPS_STUB_LOG"] = self.log_path + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.server.tenants_root = os.path.join(self.tmp.name, "tenants") + 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.project) + self.auth.set_member(self.project["id"], self.owner_id, + self.reader_id, "viewer") + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + try: + self.auth.conn.close() + except Exception: + pass + if self._old_host_py is None: + os.environ.pop("CODEX_HOST_PY", None) + else: + os.environ["CODEX_HOST_PY"] = self._old_host_py + os.environ.pop("THREADOPS_STUB_SCENARIO", None) + os.environ.pop("THREADOPS_STUB_LOG", None) + self.tmp.cleanup() + + def _write_scenario(self, scenario): + with open(self.scenario_path, "w", encoding="utf-8") as f: + json.dump(scenario, f) + + def _stub_calls(self, cmd=None): + if not os.path.exists(self.log_path): + return [] + calls = [] + with open(self.log_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + calls.append(json.loads(line)) + if cmd is not None: + calls = [c for c in calls if c["cmd"] == cmd] + return calls + + 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 _audit_actions(self, project_id): + conn = sqlite3.connect(self.db_path) + try: + rows = conn.execute( + "SELECT action, detail FROM audit_log WHERE project_id = ?", + (project_id,)).fetchall() + finally: + conn.close() + return [(r[0], r[1]) for r in rows] + + def _pid(self): + return self.project["id"] + + def test_unauthorized_without_key(self): + status, body = self._req( + "GET", "/projects/%s/skills" % self._pid(), None, None) + self.assertEqual(status, 401, body) + + def test_forbidden_outsider_reads(self): + for suffix in ("skills", "apps", "mcp-servers"): + status, body = self._req( + "GET", "/projects/%s/%s" % (self._pid(), suffix), + None, self.outsider_key) + self.assertEqual(status, 403, body) + + def test_project_not_found(self): + status, body = self._req( + "GET", "/projects/nope/skills", None, self.owner_key) + self.assertEqual(status, 404, body) + + def test_skills_list_passthrough_and_cwd(self): + status, body = self._req( + "GET", "/projects/%s/skills" % self._pid(), None, + self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["project_id"], self._pid()) + self.assertEqual(body["skills"], [ + {"cwd": "/work", "skills": [{"name": "demo-skill"}]}]) + calls = self._stub_calls("skills-list") + self.assertEqual(len(calls), 1) + self.assertIn("--cwd", calls[0]["argv"]) + idx = calls[0]["argv"].index("--cwd") + expected = os.path.join( + self.tmp.name, "tenants", self._pid(), "work") + self.assertEqual(calls[0]["argv"][idx + 1], expected) + + def test_reader_can_read_skills(self): + status, body = self._req( + "GET", "/projects/%s/skills" % self._pid(), None, + self.reader_key) + self.assertEqual(status, 200, body) + + def test_apps_list_passthrough(self): + status, body = self._req( + "GET", "/projects/%s/apps" % self._pid(), None, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["apps"], [{"id": "app-1", "name": "Demo App"}]) + self.assertEqual(body["nextCursor"], "cur-1") + + def test_mcp_servers_list_passthrough(self): + status, body = self._req( + "GET", "/projects/%s/mcp-servers" % self._pid(), None, + self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["servers"], [ + {"name": "srv-1", "runtimeStatus": "running"}]) + + def test_reload_ok_and_audit(self): + status, body = self._req( + "POST", "/projects/%s/mcp-servers/reload" % self._pid(), + {}, self.owner_key) + self.assertEqual(status, 200, body) + self.assertTrue(body["reloaded"]) + self.assertEqual(body["result"], {}) + actions = [a for a, _d in self._audit_actions(self._pid())] + self.assertIn("mcp_servers_reloaded", actions) + + def test_reload_forbidden_for_reader(self): + status, body = self._req( + "POST", "/projects/%s/mcp-servers/reload" % self._pid(), + {}, self.reader_key) + self.assertEqual(status, 403, body) + + def test_reload_cli_failure_502_not_fake_success(self): + self._write_scenario( + {"fail_cmd": "mcp-servers-reload", + "fail_error": "reload rejected"}) + status, body = self._req( + "POST", "/projects/%s/mcp-servers/reload" % self._pid(), + {}, self.owner_key) + self.assertEqual(status, 502, body) + self.assertEqual(body["error"], "reload rejected") + self.assertNotIn("reloaded", body) + + def test_oauth_login_passthrough(self): + status, body = self._req( + "POST", + "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), + {"scopes": ["read", "write"]}, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual( + body["authorizationUrl"], + "https://example.invalid/oauth/authorize?x=1") + self.assertEqual(body["loginId"], "login-123") + self.assertEqual(body["project_id"], self._pid()) + self.assertEqual(body["server_name"], "srv-1") + calls = self._stub_calls("mcp-server-oauth-login") + self.assertEqual(len(calls), 1) + self.assertIn("--server-name", calls[0]["argv"]) + idx = calls[0]["argv"].index("--server-name") + self.assertEqual(calls[0]["argv"][idx + 1], "srv-1") + self.assertIn("--scopes", calls[0]["argv"]) + idx = calls[0]["argv"].index("--scopes") + self.assertEqual(calls[0]["argv"][idx + 1], "read,write") + actions = self._audit_actions(self._pid()) + self.assertTrue(any( + a == "mcp_server_oauth_login_started" and "srv-1" in d + for a, d in actions)) + + def test_oauth_login_forbidden_for_reader(self): + status, body = self._req( + "POST", + "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), + {}, self.reader_key) + self.assertEqual(status, 403, body) + + def test_oauth_login_validation(self): + status, body = self._req( + "POST", + "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), + {"scopes": "not-a-list"}, self.owner_key) + self.assertEqual(status, 400, body) + status, body = self._req( + "POST", + "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), + {"timeoutSecs": 0}, self.owner_key) + self.assertEqual(status, 400, body) + + def test_oauth_login_cli_failure_502(self): + self._write_scenario( + {"fail_cmd": "mcp-server-oauth-login", + "fail_error": "oauth rejected"}) + status, body = self._req( + "POST", + "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), + {}, self.owner_key) + self.assertEqual(status, 502, body) + self.assertEqual(body["error"], "oauth rejected") + + def test_oauth_login_missing_url_502(self): + self._write_scenario({"oauth_no_url": True}) + status, body = self._req( + "POST", + "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), + {}, self.owner_key) + self.assertEqual(status, 502, body) + self.assertNotIn("authorizationUrl", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_thread_ops.py b/cloud/tests/test_thread_ops.py new file mode 100644 index 0000000..8f46a13 --- /dev/null +++ b/cloud/tests/test_thread_ops.py @@ -0,0 +1,430 @@ +"""Thread ops server tests: GET/POST /tasks//thread* endpoints. + +No real host.py CLI, no real codex binary: a stub script written into +tmp pretends to be host.py and prints canned JSON per the frozen CLI +contract - one JSON object on stdout, nonzero exit plus {"error": ...} +on failure. The stub reads a scenario JSON file and appends every +invocation to a log file so tests can assert the argv the server +passed, including the derived --cwd worktree path. +""" + +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.server import create_server +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.server import create_server + +STUB = r'''import json +import os +import sys + +def main(): + argv = sys.argv[1:] + cmd = None + rest = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--codex-home": + i += 2 + continue + if a.startswith("--codex-home="): + i += 1 + continue + if cmd is None: + cmd = a + i += 1 + continue + rest.append(a) + i += 1 + log = os.environ.get("THREADOPS_STUB_LOG") + if log: + with open(log, "a", encoding="utf-8") as f: + f.write(json.dumps({"cmd": cmd, "argv": rest}) + "\n") + scenario = {} + path = os.environ.get("THREADOPS_STUB_SCENARIO") + if path and os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + scenario = json.load(f) + if scenario.get("fail_cmd") == cmd: + print(json.dumps({"error": scenario.get("fail_error", "boom")})) + sys.exit(1) + if scenario.get("raw_stdout_for") == cmd: + print(scenario.get("raw_stdout", "")) + return + opts = {} + j = 0 + while j < len(rest): + a = rest[j] + if a.startswith("--") and j + 1 < len(rest) and not rest[j + 1].startswith("--"): + opts[a] = rest[j + 1] + j += 2 + elif a.startswith("--"): + opts[a] = True + j += 1 + else: + j += 1 + if cmd == "thread-list": + print(json.dumps({"data": scenario.get("threads", [ + {"id": "t-old", "updatedAt": 100, "createdAt": 90}, + {"id": "t-new", "updatedAt": 200, "createdAt": 190}, + ])})) + elif cmd == "thread-read": + thread = dict(scenario.get("thread", {"id": "t-new", "updatedAt": 200})) + if opts.get("--include-turns"): + thread["turns"] = [{"id": "turn-1"}] + print(json.dumps({"thread": thread})) + elif cmd == "thread-rename": + print(json.dumps({"ok": True, "name": opts.get("--name")})) + elif cmd in ("thread-archive", "thread-unarchive", "thread-compact"): + print(json.dumps({"ok": True})) + else: + print(json.dumps({"error": "unknown subcommand"})) + sys.exit(1) + +main() +''' + + +class ThreadOpsTestCase(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.reader_id, self.reader_key = self.auth.create_user("reader") + self.outsider_id, self.outsider_key = self.auth.create_user("outsider") + self.stub_path = os.path.join(self.tmp.name, "stub_host.py") + with open(self.stub_path, "w", encoding="utf-8") as f: + f.write(STUB) + self.scenario_path = os.path.join(self.tmp.name, "scenario.json") + self.log_path = os.path.join(self.tmp.name, "stub.log") + self._write_scenario({}) + self._old_host_py = os.environ.get("CODEX_HOST_PY") + os.environ["CODEX_HOST_PY"] = self.stub_path + os.environ["THREADOPS_STUB_SCENARIO"] = self.scenario_path + os.environ["THREADOPS_STUB_LOG"] = self.log_path + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.server.tenants_root = os.path.join(self.tmp.name, "tenants") + self.server.host_worktrees_dir = os.path.join( + self.tmp.name, "worktrees") + 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.project) + self.auth.set_member(self.project["id"], self.owner_id, + self.reader_id, "viewer") + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + try: + self.auth.conn.close() + except Exception: + pass + if self._old_host_py is None: + os.environ.pop("CODEX_HOST_PY", None) + else: + os.environ["CODEX_HOST_PY"] = self._old_host_py + os.environ.pop("THREADOPS_STUB_SCENARIO", None) + os.environ.pop("THREADOPS_STUB_LOG", None) + self.tmp.cleanup() + + def _write_scenario(self, scenario): + with open(self.scenario_path, "w", encoding="utf-8") as f: + json.dump(scenario, f) + + def _stub_calls(self, cmd=None): + if not os.path.exists(self.log_path): + return [] + calls = [] + with open(self.log_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + calls.append(json.loads(line)) + if cmd is not None: + calls = [c for c in calls if c["cmd"] == cmd] + return calls + + 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 _create_task(self, **extra): + body = {"project_id": self.project["id"], "prompt": "do work"} + body.update(extra) + status, task = self._req("POST", "/tasks", body, self.owner_key) + self.assertEqual(status, 201, task) + return task + + def _set_status(self, task_id, status): + conn = sqlite3.connect(self.db_path) + try: + conn.execute("UPDATE tasks SET status = ? WHERE id = ?", + (status, task_id)) + conn.commit() + finally: + conn.close() + + def _audit_actions(self, project_id): + conn = sqlite3.connect(self.db_path) + try: + rows = conn.execute( + "SELECT action FROM audit_log WHERE project_id = ?", + (project_id,)).fetchall() + finally: + conn.close() + return [r[0] for r in rows] + + def test_unauthorized_without_key(self): + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/thread" % task["id"], None, None) + self.assertEqual(status, 401, body) + + def test_forbidden_outsider_read(self): + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/threads" % task["id"], None, + self.outsider_key) + self.assertEqual(status, 403, body) + + def test_forbidden_reader_cannot_mutate(self): + task = self._create_task(executor="local") + self._set_status(task["id"], "completed") + status, body = self._req( + "POST", "/tasks/%s/thread/archive" % task["id"], {}, + self.reader_key) + self.assertEqual(status, 403, body) + + def test_forbidden_outsider_cannot_mutate(self): + # P2 接线新增:非成员改操作同样 403,且不留审计。 + task = self._create_task(executor="local") + self._set_status(task["id"], "completed") + status, body = self._req( + "POST", "/tasks/%s/thread/archive" % task["id"], {}, + self.outsider_key) + self.assertEqual(status, 403, body) + self.assertNotIn("thread_archived", + self._audit_actions(self.project["id"])) + + def test_task_not_found(self): + status, body = self._req( + "GET", "/tasks/nope/thread", None, self.owner_key) + self.assertEqual(status, 404, body) + status, body = self._req( + "POST", "/tasks/nope/thread/archive", {}, self.owner_key) + self.assertEqual(status, 404, body) + + def test_docker_task_409(self): + task = self._create_task(executor="docker") + self._set_status(task["id"], "completed") + status, body = self._req( + "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) + self.assertEqual(status, 409, body) + status, body = self._req( + "POST", "/tasks/%s/thread/archive" % task["id"], {}, + self.owner_key) + self.assertEqual(status, 409, body) + self.assertEqual(self._stub_calls(), []) + + def test_terminal_gate_409_and_read_allowed(self): + task = self._create_task(executor="local") + self._set_status(task["id"], "running") + status, body = self._req( + "POST", "/tasks/%s/thread/archive" % task["id"], {}, + self.owner_key) + self.assertEqual(status, 409, body) + self.assertEqual(body["status"], "running") + status, body = self._req( + "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) + self.assertEqual(status, 200, body) + + def test_merged_task_mutation_409_but_read_ok(self): + task = self._create_task(executor="local") + self._set_status(task["id"], "merged") + status, _body = self._req( + "POST", "/tasks/%s/thread/archive" % task["id"], {}, + self.owner_key) + self.assertEqual(status, 409) + status, body = self._req( + "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) + self.assertEqual(status, 200, body) + + def test_threads_list_passthrough_and_cwd(self): + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual( + [t["id"] for t in body["threads"]], ["t-old", "t-new"]) + expected_cwd = os.path.join( + self.tmp.name, "worktrees", task["id"]) + self.assertEqual(body["cwd"], expected_cwd) + calls = self._stub_calls("thread-list") + self.assertEqual(len(calls), 1) + self.assertIn("--cwd", calls[0]["argv"]) + idx = calls[0]["argv"].index("--cwd") + self.assertEqual(calls[0]["argv"][idx + 1], expected_cwd) + + def test_thread_latest_non_archived_rule(self): + self._write_scenario({"threads": [ + {"id": "t-old", "updatedAt": 100, "createdAt": 90}, + {"id": "t-arch", "updatedAt": 300, "createdAt": 290, + "archived": True}, + {"id": "t-new", "updatedAt": 200, "createdAt": 190}, + ]}) + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["thread_id"], "t-new") + + def test_thread_tie_break_later_in_list_wins(self): + self._write_scenario({"threads": [ + {"id": "t-a", "updatedAt": 50, "createdAt": 50}, + {"id": "t-b", "updatedAt": 50, "createdAt": 50}, + ]}) + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["thread_id"], "t-b") + + def test_thread_not_found_when_empty(self): + self._write_scenario({"threads": []}) + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) + self.assertEqual(status, 404, body) + status, body = self._req( + "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["threads"], []) + + def test_thread_read_include_turns(self): + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) + self.assertEqual(status, 200, body) + self.assertNotIn("turns", body["thread"]) + status, body = self._req( + "GET", "/tasks/%s/thread?include_turns=1" % task["id"], + None, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["thread"]["turns"], [{"id": "turn-1"}]) + calls = self._stub_calls("thread-read") + self.assertIn("--include-turns", calls[1]["argv"]) + self.assertNotIn("--include-turns", calls[0]["argv"]) + + def test_rename_ok_and_audit(self): + task = self._create_task(executor="local") + self._set_status(task["id"], "completed") + status, body = self._req( + "POST", "/tasks/%s/thread/rename" % task["id"], + {"name": "new name"}, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["result"]["name"], "new name") + self.assertIn("thread_renamed", + self._audit_actions(self.project["id"])) + calls = self._stub_calls("thread-rename") + self.assertEqual(len(calls), 1) + self.assertIn("new name", calls[0]["argv"]) + + def test_rename_validation(self): + task = self._create_task(executor="local") + self._set_status(task["id"], "completed") + status, _body = self._req( + "POST", "/tasks/%s/thread/rename" % task["id"], + {}, self.owner_key) + self.assertEqual(status, 400) + status, _body = self._req( + "POST", "/tasks/%s/thread/rename" % task["id"], + {"name": " "}, self.owner_key) + self.assertEqual(status, 400) + + def test_archive_unarchive_compact_ok_and_audit(self): + task = self._create_task(executor="local") + self._set_status(task["id"], "failed") + for action in ("archive", "unarchive", "compact"): + status, body = self._req( + "POST", "/tasks/%s/thread/%s" % (task["id"], action), + {}, self.owner_key) + self.assertEqual(status, 200, body) + actions = self._audit_actions(self.project["id"]) + self.assertIn("thread_archived", actions) + self.assertIn("thread_unarchived", actions) + self.assertIn("thread_compacted", actions) + + def test_cli_failure_passthrough_502(self): + self._write_scenario( + {"fail_cmd": "thread-list", "fail_error": "list boom"}) + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) + self.assertEqual(status, 502, body) + self.assertEqual(body["error"], "list boom") + self._write_scenario( + {"fail_cmd": "thread-archive", "fail_error": "arch boom"}) + self._set_status(task["id"], "completed") + status, body = self._req( + "POST", "/tasks/%s/thread/archive" % task["id"], {}, + self.owner_key) + self.assertEqual(status, 502, body) + self.assertEqual(body["error"], "arch boom") + + def test_cli_bad_json_502(self): + self._write_scenario( + {"raw_stdout_for": "thread-list", "raw_stdout": "not json{"}) + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) + self.assertEqual(status, 502, body) + + def test_host_py_missing_502(self): + os.environ["CODEX_HOST_PY"] = os.path.join( + self.tmp.name, "does-not-exist.py") + task = self._create_task(executor="local") + status, body = self._req( + "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) + self.assertEqual(status, 502, body) + self.assertIn("host.py", body["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/README.md b/deploy/README.md index 796e9ea..15e6213 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -104,6 +104,16 @@ deploy/rollback.sh [user] 恢复后同样重启并验 health(60 秒),成功/失败都明说;主机上没有备份包 时直接报错退出,不编造。 +### 回滚成功标准:清单里每个端口都通过(P2-ops) + +rollback.sh 与 host_deploy.sh 共用同一份 unit 清单约定:server-1 必含, +server-2 以 `systemctl cat` 探测为准(存在才纳入停/起与健康检查),worker +是 autoscaler active 时用 autoscaler、否则用 cloud-worker。两脚本改动清单 +时必须同步改,`deploy/tests/test_p2_ops.py` 会双向核对(只改一处就变红)。 + +回滚成功 = 8765 通过,且 server-2 存在时 8766 也通过。只通 8765 不算成功: +LB 上游的 server-2 若仍跑故障代码,回滚等于没回完。 + ### 回滚边界:只回代码,不回数据库 数据库迁移是只加列的幂等操作(例如 projects.repo_url、tasks 的 6 列), @@ -128,6 +138,9 @@ sh /opt/codex-app-server/deploy/restore-drill.sh audit_log 行数(行数相等即通过,只比行数,不比对具体内容,不打印任何 行内容)→ 比对完立即删掉测试库(trap 保证中途失败也删)。 +范围(P2-ops):演练 PASS 只证明数据库备份可恢复,不证明 worktree / +链路 / 上传可恢复——那些不在备份里,见“备份制度”一节。 + 安全红线:恢复目标永远是新生成的测试库名,绝不等于生产库名; `restore_postgres` 本身也会拒绝目标库名等于 `CLOUD_DB_NAME` (`allow_production` 绝不在本脚本里设为 True),双重保险。本脚本绝不 @@ -165,14 +178,38 @@ journalctl -u monitor.service -f # 看每次检查的输出 timer 每 5 分钟跑一次,日志进 journal。 -### 监控边界:本轮只有“主机侧检查 + journal 日志” +### 监控边界:默认只有“检查 + journal 日志”,告警出口可选(P2-ops) -monitor.sh 本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行 -决定通道(例如 SSH key 常驻通道让外部 cron 拉 journal,或者一个 -webhook URL 让 monitor 失败时 POST 过去),在通道定下来之前,不要假装 -monitor.sh 会通知任何人。手工验证步骤:主机上跑一次 monitor.sh 看 JSON -与退出码,再跑一次 --self-test 看 SELF-TEST PASS,最后 -`systemctl list-timers | grep monitor` 确认 timer 已启用。 +默认不配告警出口时,monitor.sh 仍然只做检查 + journal 日志,不会发消息 +给任何人。需要 fail 时通知时,在 monitor.service 的 `Environment=`(或 +monitor.timer 的运行环境)里二选一配置,默认关闭: + +```ini +# 二选一,同时配了只用 webhook: +Environment=MONITOR_ALERT_URL=https://example.invalid/hook +# 或: +Environment=MONITOR_ALERT_MAILTO=ops@example.invalid +``` + +- 只在 overall=fail 时发,pass 时不发;故障持续期间 timer 每 5 分钟跑 + 一次就会重发,接收端需自行去重(本机不做去重状态文件,免得状态文件 + 本身成为新的故障点)。 +- 告警内容只有 JSON 检查结果(pass/fail 与 server_mode),不含 health + 正文、不含任何环境变量、不含密钥;URL 与收件人只走环境变量。 +- 邮件路径依赖本机 `mail` 命令,没有它时会记一条“告警未发出”到 journal, + 不会静默假装已通知。 + +### 单机模式:MONITOR_SINGLE_HOST=1(P2-ops) + +默认 server 检查要求 server-1 与 server-2 双 active。确认是单机部署时, +在 monitor 运行环境里设 `MONITOR_SINGLE_HOST=1`,server 检查只查 +server-1;否则单机主机的 server 检查会恒 fail,恒 fail 会掩盖真故障。 +双机主机上绝不要设它(设了就是静默降级)。输出 JSON 的 `server_mode` +字段写明本次是 single 还是 dual,降级可见。 + +手工验证步骤:主机上跑一次 monitor.sh 看 JSON 与退出码,再跑一次 +--self-test 看 SELF-TEST PASS,最后 `systemctl list-timers | grep monitor` +确认 timer 已启用。--self-test 时内层检查会清掉告警出口,不会误发告警。 ## worker 心跳与 health 的 worker 字段 @@ -225,17 +262,72 @@ completed 测试任务是直接在 DB 里把测试任务置 completed,不跑 - 审计日志是 append-only 的:验证过程的审计条目会留在 audit_log 里, 脚本不删审计表——审计就该留痕,这一点不是清理遗漏。 -## 公网入口与 webhook secret(已有文件,不属于本轮新增) +## 备份制度:什么可恢复、什么不可恢复(P2-ops,二选一里选了“写清区分”) -- `caddy/Caddyfile`:只暴露 `/webhooks/*` 与 `/oauth/*`,任务/项目/管理 - API 对外一律 403;域名与邮箱占位符需部署时替换。 +不要谎称已做全量备份。本节写清现状;worktree / 链路 / 上传目录纳入 +备份范围是另一单(需先定量:租户目录大小、备份窗口、恢复演练抽查), +不在本批做。 + +| 内容 | 在备份里吗 | 恢复手段 | +| --- | --- | --- | +| 数据库(Postgres 全库) | 是 | pg-backup 每天一次(留 7 份)+ restore-drill 恢复到测试库比对行数 | +| 代码(/opt/codex-app-server,不含 work/) | 是 | 每次部署前打备份 tar(留 5 份)+ rollback.sh 恢复 | +| 任务 worktree(work/、tenants/ 下各任务目录) | 否 | 无。主机丢了就丢了;数据库里的任务行还在,但 worktree 没了,续跑/链路/附件都会缺 | +| 链路文件(host-traces/)与上传文件(附件包) | 否 | 无。链路是过程记录,丢了只影响可观测性;上传附件丢了则任务附件不可下载 | +| 运行配置(/etc/codex-app-server/env、unit 文件、nginx/Caddy 配置) | 否(不在代码 tar 与数据库备份里) | 换机时从备份清单手动重贴,见“公网入口”与 new-host-checklist.md | + +后果要说透:只靠现有备份,数据库能恢复、代码能回滚,但一台主机彻底丢了, +在跑任务的工作目录与历史链路是回不来的;恢复后需要把受影响的任务标记为 +失败/重建,而不是假装它们还能续跑。restore-drill.sh 的 PASS 只证明数据库 +备份可恢复,不证明整机可恢复(脚本头注释同样写明)。 + +## 公网入口与 webhook secret + +- `caddy/Caddyfile`(P2-ops:占位符改为 Caddy 原生变量模板):只暴露 + `/webhooks/*` 与 `/oauth/*`,任务/项目/管理 API 对外一律 403;自动证书 + 走 Caddy(本批二选一里选了 Caddy,certbot 路径未做成模板、只是备选, + 不要假装两条路都已就绪)。 - `setup-webhook-secret.sh`:在主机上生成 webhook secret 并写入 env 文件, 绝不打印 secret;已设则不动,避免轮换后 GitHub 端旧 secret 失效。 +### Caddy 模板部署步骤(新机可复现,不申请真实证书也能备好文件) + +```sh +# 1. 主机上装 Caddy(包管理器或官方二进制均可),建运行目录: +mkdir -p /etc/caddy +cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile +cp deploy/caddy/caddy.service /etc/systemd/system/caddy.service +# 2. 写变量文件(域名与邮箱不是密钥,但也不要写进仓库): +cat > /etc/caddy/env <<'EOF' +DOMAIN=example.invalid +ACME_EMAIL=ops@example.invalid +EOF +chmod 600 /etc/caddy/env +# 3. Caddyfile 里的 {$DOMAIN} / {$ACME_EMAIL} 由 Caddy 运行时从环境变量 +# 展开;变量未设时 Caddy 启动即报错(fail-fast,不会静默跑成空域名)。 +systemctl daemon-reload +systemctl enable --now caddy +# 4. 验证:curl -v https://$DOMAIN/webhooks/github 应被 Caddy 转发 +# (控制面本身仍只监听 127.0.0.1:8765,不直接对外)。 +``` + +### 当前生产现状(换机需重贴,模板不等于已上线) + +当前生产走宝塔 nginx(api.diante.me 等 vhost 由 BT 面板管理),不是 +Caddy;BT 若重生成 vhost 会吃掉 location,需从备份贴回(见 +new-host-checklist.md 第 8 节)。仓库内 Caddy 模板是“新机可复现”的选项, +不是“生产已切 Caddy”的声明。备份清单必须另含:BT vhost 配置备份、 +/etc/caddy/Caddyfile(如已用 Caddy)、caddy.service、unit 文件 +(codex-cloud-server*.service 等不在 deploy tar 里,改动后须单独 scp, +见 codex-cloud-server-2.service 头注释)——这些都不在代码 tar 与数据库 +备份里,换机时不重贴就等于没备份。 + ## 仍未做(不要假装已做) -- 推送告警通道:monitor 失败时通知到 Muse 聊天的通道还没定(见监控一节)。 - 回滚不回数据库:见回滚边界一节;改类型/删列的迁移需要单独评估。 - 恢复演练不碰生产库:演练只比行数,不比对具体内容;恢复到生产库的 真实灾难恢复流程(停服务、恢复、重启、验 health)还没演练过。 +- worktree / 链路 / 上传目录未纳入备份:见“备份制度”一节;主机彻底丢了 + 这些回不来,需要另单定量后做。 +- 生产仍走宝塔 nginx,未切 Caddy:Caddy 模板只是仓库内可部署选项。 - CloudExecutor、GUI、Slack 执行、推送实发:与本轮无关,仍是 0 或占位。 diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index e4805a5..92c818c 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,13 +1,27 @@ -# 公网入口模板:只暴露 webhook 与 OAuth 回调,任务/项目/管理 API 不对外。 -# 使用前把 换成你自己的域名,把 换成 Let's Encrypt 联系邮箱。 -# 部署侧在主机上装 Caddy 后,把本文件放到 /etc/caddy/Caddyfile 并 reload。 +# 公网入口模板(Caddy 自动证书路径,P2-ops 二选一里选了 Caddy, +# certbot 路径只是备选、未做成模板)。 +# +# 只暴露 webhook 与 OAuth 回调,任务/项目/管理 API 不对外。 +# +# 变量是 Caddy 原生环境变量展开({$DOMAIN} / {$ACME_EMAIL}), +# 不是部署时 sed 替换:Caddy 启动时从进程环境读这两个变量, +# 未设时启动即报错(fail-fast,不会静默跑成空域名)。 +# 部署步骤见 deploy/README.md“公网入口”一节;变量文件示例: +# /etc/caddy/env(600): +# DOMAIN=example.invalid +# ACME_EMAIL=ops@example.invalid +# caddy.service 模板(同目录)用 EnvironmentFile 指向该文件。 +# # 控制面本身仍只监听 127.0.0.1:8765,不直接对外;Caddy 只做反向代理 + TLS。 # GitHub webhook 与 OAuth 回调都要求 https 且证书有效,Caddy 的自动证书满足这一点; # 自签名证书不行,GitHub 会拒绝送达,浏览器也会拦截 OAuth 回调。 +# +# 当前生产现状:生产走宝塔 nginx,不是 Caddy;本模板是新机可复现的选项, +# 不是“生产已切 Caddy”的声明(见 deploy/README.md)。 - { +{$DOMAIN} { encode gzip - tls + tls {$ACME_EMAIL} # GitHub webhook:签名校验在控制面做(GITHUB_WEBHOOK_SECRET),Caddy 只转发。 @github path /webhooks/github @@ -18,7 +32,7 @@ reverse_proxy @slack 127.0.0.1:8765 # OAuth 浏览器登录/回调:回调 URL 必须与 OAuth App 里填的完全一致, - # 即 https:///oauth//callback。 + # 即 https://{$DOMAIN}/oauth//callback。 @oauth path /oauth/* reverse_proxy @oauth 127.0.0.1:8765 diff --git a/deploy/caddy/caddy.service b/deploy/caddy/caddy.service new file mode 100644 index 0000000..ad79e3a --- /dev/null +++ b/deploy/caddy/caddy.service @@ -0,0 +1,24 @@ +# Caddy 前置代理 unit 模板(与 deploy/caddy/Caddyfile 配对)。 +# +# 注意:unit 文件不在 deploy tar 里(与 codex-cloud-server-2.service +# 同款约束),改动后须单独放到 /etc/systemd/system/ 再 +# daemon-reload + enable + start;只重启 Caddy 不会生效。 +# EnvironmentFile 指向 /etc/caddy/env(600,部署时按 +# deploy/README.md“公网入口”一节手写 DOMAIN / ACME_EMAIL, +# 域名与邮箱不要写进仓库)。Caddyfile 里的 {$DOMAIN} / +# {$ACME_EMAIL} 由 Caddy 运行时从该环境展开。 +[Unit] +Description=Codex public entry (Caddy reverse proxy + automatic TLS) +After=network.target + +[Service] +User=caddy +Group=caddy +EnvironmentFile=/etc/caddy/env +ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile +ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/deploy.py b/deploy/deploy.py index 3a44bcf..512441e 100644 --- a/deploy/deploy.py +++ b/deploy/deploy.py @@ -291,8 +291,17 @@ def transfer(host, user, local, remote, expected_sha): sys.exit(1) +# 部署前默认跑的测试清单:必须用 discover -s <目录>, +# 绝不能用 deploy.tests 这种包路径——包路径会把 deploy 当包 +# import、盖掉 deploy.py 本体(2026-10-27 教训),导致 +# 13 个假报错。P2-ops:deploy/tests 以前漏在清单外, +# deploy.py 自身的回归(如 ssh 分发)可能带病上线, +# 现补进默认清单。 +DEFAULT_TEST_SUITES = ("tests", "cloud/tests", "deploy/tests") + + def run_tests(): - for suite in ("tests", "cloud/tests"): + for suite in DEFAULT_TEST_SUITES: run(["python3", "-m", "unittest", "discover", "-s", suite], cwd=REPO_ROOT) diff --git a/deploy/host_deploy.sh b/deploy/host_deploy.sh index 1c905ec..c70bdea 100644 --- a/deploy/host_deploy.sh +++ b/deploy/host_deploy.sh @@ -133,6 +133,8 @@ worker_unit() { # server-2(8766)是 HA 阶段 2 加的;unit 存在才纳入停/起与 health # 检查,老主机没有它时退回单机行为。2026-10-27 首推教训:脚本只动 # server-1 时,LB 上游的 server-2 仍跑旧内存代码,新旧混跑。 +# 与 rollback.sh 共享的 unit 清单约定(P2-ops):改动清单时两脚本 +# 同步改,deploy/tests/test_p2_ops.py 会双向核对。 SERVER_UNITS="codex-cloud-server" HEALTH_PORTS="8765" if systemctl cat codex-cloud-server-2 >/dev/null 2>&1; then diff --git a/deploy/monitor.service b/deploy/monitor.service index 049f799..c3bcdf7 100644 --- a/deploy/monitor.service +++ b/deploy/monitor.service @@ -6,8 +6,10 @@ After=network.target codex-cloud-server.service codex-cloud-worker.service Type=oneshot User=codexsvc WorkingDirectory=/opt/codex-app-server -# monitor.sh 只做检查、不读 env 文件、不发消息;失败时 systemd 会把 -# 退出码与输出记进 journal,告警通道见 deploy/README.md(另行决定)。 +# monitor.sh 只做检查、不读 env 文件;默认不发消息(告警出口默认关闭), +# 失败时 systemd 会把退出码与输出记进 journal。可选告警出口 +# (MONITOR_ALERT_URL / MONITOR_ALERT_MAILTO)见 deploy/README.md: +# 配了才在 fail 时发,告警内容只有 pass/fail,不含密钥。 ExecStart=/bin/sh /opt/codex-app-server/deploy/monitor.sh [Install] diff --git a/deploy/monitor.sh b/deploy/monitor.sh index d015d28..1da26fa 100755 --- a/deploy/monitor.sh +++ b/deploy/monitor.sh @@ -31,19 +31,45 @@ # JSON),断言 health 那一项为 pass;其它项在非主机机器上 fail 是正常的, # self-test 不因此失败。不要把 --self-test 通过误读成“主机健康”。 # -# 告警边界:本脚本只做“检查 + journal 日志”(配合 monitor.timer 每 5 分钟 -# 跑一次),它本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行 -# 决定通道(SSH key 常驻通道或 webhook URL),见 deploy/README.md。 +# 单机模式(P2-ops):默认 server 检查要求 server-1 与 server-2 +# 双 active(HA 阶段 2),单机主机上 server-2 不存在会恒 fail, +# 恒 fail 会掩盖真故障。确认是单机部署时设 MONITOR_SINGLE_HOST=1, +# server 检查只查 server-1;双机主机上绝不要设它(设了就是 +# 静默降级,冗余丢了也看不见)。输出 JSON 里 server_mode 字段 +# 写明本次用的是 single 还是 dual,降级可见。 +# +# 告警出口(P2-ops,默认关闭):只在 overall=fail 时发一次, +# pass 时不发;故障持续期间 timer 每 5 分钟跑一次就会重发, +# 接收端需自行去重(本机不做去重状态文件,免得状态文件本身 +# 成为新的故障点)。二选一,同时配了只用 webhook: +# MONITOR_ALERT_URL=https://example.invalid/hook # webhook POST JSON +# MONITOR_ALERT_MAILTO=ops@example.invalid # mail 命令发邮件 +# 告警内容只有 JSON 检查结果(pass/fail 与 server_mode), +# 不含 health 正文、不含任何环境变量、不含密钥;URL 与收件人 +# 只走环境变量,不写进脚本、不进日志。 set -eu +SERVER_MODE=dual +if [ "${MONITOR_SINGLE_HOST:-0}" = "1" ]; then SERVER_MODE=single; fi + HEALTH_URL=${MONITOR_HEALTH_URL:-http://127.0.0.1:8765/health} APP_DIR=${MONITOR_APP_DIR:-/opt/codex-app-server} DISK_PATH=${MONITOR_DISK_PATH:-/var/lib/codex-app-server} if [ ! -d "$DISK_PATH" ]; then DISK_PATH="/"; fi check_server() { - # HA 阶段 2:两台 server 都必须 active,任一挂了都算 fail + # 默认双机:两台 server 都必须 active,任一挂了都算 fail # (单台 active 时 API 还能用,但冗余已丢,必须可见)。 + # MONITOR_SINGLE_HOST=1 时只查 server-1(单机部署专用, + # 双机主机上设它等于静默降级,见文件头注释)。 + if [ "$SERVER_MODE" = "single" ]; then + if systemctl is-active codex-cloud-server >/dev/null 2>&1; then + echo pass + else + echo fail + fi + return + fi if systemctl is-active codex-cloud-server >/dev/null 2>&1 \ && systemctl is-active codex-cloud-server-2 >/dev/null 2>&1; then echo pass @@ -136,11 +162,36 @@ run_checks() { for v in "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B"; do if [ "$v" != "pass" ]; then OVERALL=fail; break; fi done - printf '{"overall": "%s", "checks": {"server": "%s", "worker": "%s", "health": "%s", "postgres": "%s", "docker_postgres": "%s", "disk": "%s", "app_dir": "%s", "backup": "%s"}}\n' \ - "$OVERALL" "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B" + LAST_JSON=$(printf '{"overall": "%s", "server_mode": "%s", "checks": {"server": "%s", "worker": "%s", "health": "%s", "postgres": "%s", "docker_postgres": "%s", "disk": "%s", "app_dir": "%s", "backup": "%s"}}' \ + "$OVERALL" "$SERVER_MODE" "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B") + printf '%s\n' "$LAST_JSON" if [ "$OVERALL" = "pass" ]; then return 0; else return 1; fi } +send_alert() { + # 只在 fail 时由主流程调用。内容就是 LAST_JSON(pass/fail + # 与 server_mode),绝不拼 health 正文、环境变量或密钥。 + if [ -n "${MONITOR_ALERT_URL:-}" ]; then + if [ -n "${MONITOR_ALERT_MAILTO:-}" ]; then + echo "monitor: webhook 与邮件同时配置,只用 webhook" >&2 + fi + curl -s --max-time 10 -X POST -H 'Content-Type: application/json' \ + -d "$LAST_JSON" "$MONITOR_ALERT_URL" >/dev/null 2>&1 \ + || echo "monitor: webhook 告警发送失败" >&2 + return 0 + fi + if [ -n "${MONITOR_ALERT_MAILTO:-}" ]; then + if command -v mail >/dev/null 2>&1; then + printf '%s\n' "$LAST_JSON" \ + | mail -s "codex monitor FAIL" "$MONITOR_ALERT_MAILTO" \ + 2>/dev/null \ + || echo "monitor: 邮件告警发送失败" >&2 + else + echo "monitor: 已配 MAILTO 但本机无 mail 命令,告警未发出" >&2 + fi + fi +} + if [ "${1:-}" = "--self-test" ]; then # 起一个 canned health server:只返回固定健康 JSON,不读任何真实服务。 SELF_PORT=18999 @@ -173,8 +224,11 @@ PY SERVER_PID=$! trap 'kill $SERVER_PID 2>/dev/null || true' EXIT sleep 1 + # 内层跑完整检查时清掉告警出口:self-test 在非主机上 + # 其它项必然 fail,不清会在配了告警的环境里误发一条。 MONITOR_HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \ HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \ + MONITOR_ALERT_URL= MONITOR_ALERT_MAILTO= \ sh "$0" > /tmp/monitor-self-test.json 2>/dev/null || true # 注意:上面那行是跑完整检查,health 以外项在非主机上 fail 是正常的; # self-test 只断言两件事:输出是合法 JSON,且 health 解析为 pass。 @@ -195,4 +249,10 @@ sys.exit(0 if d["checks"]["health"] == "pass" else 1) fi fi -run_checks +if run_checks; then + exit 0 +fi +# 只有 fail 走到这里:默认不配出口时 send_alert 是空操作, +# 不会因为没配告警就多报错;配了出口则发一条 fail 告警。 +send_alert +exit 1 diff --git a/deploy/restore-drill.sh b/deploy/restore-drill.sh index 311ac67..c153be3 100755 --- a/deploy/restore-drill.sh +++ b/deploy/restore-drill.sh @@ -4,6 +4,12 @@ # 用法(主机上):sh /opt/codex-app-server/deploy/restore-drill.sh # 可选环境变量:BACKUP_DIR(默认 /var/lib/codex-app-server/backups)。 # +# 演练范围(P2-ops,诚实边界):本演练只证明“数据库备份可恢复” +# (行数比对通过)。它不证明 worktree / 链路 / 上传文件可恢复, +# 因为那些根本不在备份里(见 deploy/README.md“备份制度”一节): +# 代码备份 tar 明确排除 work/,pg-backup 只备数据库。不要把 +# “恢复演练 PASS”读成“整机可恢复”。 +# # 步骤: # 1. 跑一次 backup_postgres 生成新鲜备份(.sql)。 # 2. 新建一个测试库 codex_control_restore_test_<时间戳>,用 diff --git a/deploy/rollback.sh b/deploy/rollback.sh index 8010539..f294dc3 100755 --- a/deploy/rollback.sh +++ b/deploy/rollback.sh @@ -10,9 +10,23 @@ # # 回滚边界:只回代码,不回数据库(理由见 deploy.sh 头注释与 # deploy/README.md:数据库迁移是只加列的幂等操作,老代码仍能读新列)。 -# 备份包来自 deploy.sh 每次部署前在主机上打的 +# 备份包来自每次部署前在主机上打的 # /opt/codex-app-server-backups/codex-app-server-<时间戳>.tar.gz, # 本脚本恢复其中最新的一份;主机上没有备份包时直接报错退出,不编造。 +# +# 与 host_deploy.sh 共享的约定(P2-ops,两脚本改动时必须同步, +# deploy/tests/test_p2_ops.py 会双向核对,见文件尾注释): +# - server unit 清单:codex-cloud-server 必含,codex-cloud-server-2 +# 以 systemctl cat 探测为准(存在才纳入停/起与健康检查); +# - worker:autoscaler active 时停/起 autoscaler,否则 cloud-worker +# (与 host_deploy.sh 的 worker_unit() 同款); +# - 成功标准:清单里每个 server 端口的 health 都通过(8765 必查, +# server-2 存在时 8766 也必查),只通 8765 不算回滚成功—— +# 否则 LB 上游的 server-2 仍跑故障代码,回滚等于没回完。 +# +# 仿真覆盖(仅测试用,生产用默认值):CODEX_ROLLBACK_APP_DIR、 +# CODEX_ROLLBACK_BACKUP_DIR、CODEX_ROLLBACK_HEALTH_ATTEMPTS、 +# CODEX_ROLLBACK_HEALTH_INTERVAL。默认值与旧行为完全一致。 set -eu HOST=${1:-} @@ -33,12 +47,30 @@ ssh_cmd() { ssh_cmd "$USER_ARG@$HOST" 'sh -s' <<'HOST_SCRIPT' set -eu -APP_DIR=/opt/codex-app-server -BACKUP_DIR=/opt/codex-app-server-backups +APP_DIR=${CODEX_ROLLBACK_APP_DIR:-/opt/codex-app-server} +BACKUP_DIR=${CODEX_ROLLBACK_BACKUP_DIR:-/opt/codex-app-server-backups} +HEALTH_ATTEMPTS=${CODEX_ROLLBACK_HEALTH_ATTEMPTS:-12} +HEALTH_INTERVAL=${CODEX_ROLLBACK_HEALTH_INTERVAL:-5} + +# 与 host_deploy.sh 共享的 unit 清单约定(见本文件头注释): +# server-1 必含,server-2 以 unit 存在为准;改动清单时两脚本同步改。 +SERVER_UNITS="codex-cloud-server" +HEALTH_PORTS="8765" +if systemctl cat codex-cloud-server-2 >/dev/null 2>&1; then + SERVER_UNITS="$SERVER_UNITS codex-cloud-server-2" + HEALTH_PORTS="$HEALTH_PORTS 8766" +fi +if systemctl is-active -q codex-autoscaler 2>/dev/null; then + WORKER_UNIT=codex-autoscaler +else + WORKER_UNIT=codex-cloud-worker +fi check_health() { - OUT=$(curl -s --max-time 5 http://127.0.0.1:8765/health || true) - [ -n "$OUT" ] && printf '%s' "$OUT" | python3 -c ' + for port in $HEALTH_PORTS; do + OUT=$(curl -s --max-time 5 "http://127.0.0.1:$port/health" || true) + [ -n "$OUT" ] || return 1 + if ! printf '%s' "$OUT" | python3 -c ' import json, sys try: d = json.load(sys.stdin) @@ -46,14 +78,18 @@ except Exception: sys.exit(1) sys.exit(0 if (d.get("ok") is True and d.get("backend") == "postgres" and d.get("db_ok") is True) else 1) -' +'; then + return 1 + fi + done + return 0 } wait_health() { i=0 - while [ "$i" -lt 12 ]; do + while [ "$i" -lt "$HEALTH_ATTEMPTS" ]; do if check_health; then return 0; fi - sleep 5 + sleep "$HEALTH_INTERVAL" i=$((i + 1)) done return 1 @@ -66,15 +102,18 @@ if [ -z "$BACKUP_FILE" ]; then exit 1 fi echo "将恢复备份包:$BACKUP_FILE" -systemctl stop codex-cloud-worker codex-cloud-server || true +echo "停服务:worker_unit=$WORKER_UNIT server_units=$SERVER_UNITS" +systemctl stop "$WORKER_UNIT" $SERVER_UNITS || true find "$APP_DIR" -mindepth 1 -maxdepth 1 ! -name work -exec rm -rf {} + -tar -xzf "$BACKUP_FILE" -C /opt -chown -R codexsvc:codexsvc "$APP_DIR" -systemctl start codex-cloud-server codex-cloud-worker +tar -xzf "$BACKUP_FILE" -C "$(dirname "$APP_DIR")" +if id codexsvc >/dev/null 2>&1; then + chown -R codexsvc:codexsvc "$APP_DIR" +fi +systemctl start $SERVER_UNITS "$WORKER_UNIT" if wait_health; then - echo "回滚成功,health 通过,当前代码为备份包:$BACKUP_FILE" + echo "回滚成功,全部端口 health 通过($HEALTH_PORTS),当前代码为备份包:$BACKUP_FILE" exit 0 fi -echo "回滚后 health 仍失败,需人工介入" >&2 +echo "回滚后 health 仍失败(需全部端口通过),需人工介入" >&2 exit 1 HOST_SCRIPT diff --git a/deploy/tests/test_deploy.py b/deploy/tests/test_deploy.py index 950b5b8..4ba850a 100644 --- a/deploy/tests/test_deploy.py +++ b/deploy/tests/test_deploy.py @@ -82,6 +82,33 @@ class TestDeployPure(unittest.TestCase): self.assertIsNone(deploy_mod.live_commit("Warning: something\n")) +class TestDefaultTestSuites(unittest.TestCase): + """P2-ops 回归:部署前默认清单必须含 deploy/tests, + 且 run_tests() 确实按 discover -s 逐套跑(不是包路径)。""" + + def test_default_suites_include_deploy_tests(self): + self.assertIn("deploy/tests", deploy_mod.DEFAULT_TEST_SUITES) + self.assertIn("tests", deploy_mod.DEFAULT_TEST_SUITES) + self.assertIn("cloud/tests", deploy_mod.DEFAULT_TEST_SUITES) + + def test_run_tests_discovers_every_suite(self): + calls = [] + + def fake_run(cmd, cwd=None, check=True): + calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, + stdout="", stderr="") + + with mock.patch.object(deploy_mod, "run", side_effect=fake_run): + deploy_mod.run_tests() + discovered = [c[c.index("-s") + 1] for c in calls + if "discover" in c] + self.assertEqual(discovered, list(deploy_mod.DEFAULT_TEST_SUITES)) + # 没有任何一套走包路径(deploy.tests 会盖掉 deploy.py)。 + for c in calls: + self.assertNotIn("deploy.tests", c) + + class TestBuildRelease(unittest.TestCase): def test_build_from_temp_git_repo(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/deploy/tests/test_p2_ops.py b/deploy/tests/test_p2_ops.py new file mode 100644 index 0000000..3485cf2 --- /dev/null +++ b/deploy/tests/test_p2_ops.py @@ -0,0 +1,296 @@ +"""P2-ops 回归:monitor 单机开关与告警出口、rollback 与 +host_deploy 的 unit/健康清单同步。 + +全部用假 systemctl / curl / ssh / mail,不碰真实主机与真实服务。 +告警测试用的 webhook 接收器只监听 127.0.0.1 临时端口。 +""" +import http.server +import json +import os +import shutil +import subprocess +import tarfile +import tempfile +import threading +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) +DEPLOY_DIR = os.path.join(REPO_ROOT, "deploy") +MONITOR_SCRIPT = os.path.join(DEPLOY_DIR, "monitor.sh") +ROLLBACK_SCRIPT = os.path.join(DEPLOY_DIR, "rollback.sh") +HOST_DEPLOY_SCRIPT = os.path.join(DEPLOY_DIR, "host_deploy.sh") +REAL_CURL = shutil.which("curl") + + +def _write_exe(path, body): + with open(path, "w") as f: + f.write(body) + os.chmod(path, 0o755) + + +class MonitorSim(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = self.tmp.name + self.app_dir = os.path.join(base, "app") + os.makedirs(self.app_dir) + self.backup_dir = os.path.join(base, "backups") + os.makedirs(self.backup_dir) + with open(os.path.join(self.backup_dir, + "cloud-backup-fresh.sql"), "w") as f: + f.write("-- fresh\n") + self.health_path = os.path.join(base, "health.json") + # 健康正文里埋一个哨兵串:告警正文绝不能把它带出去 + # (告警只能发 LAST_JSON 的 pass/fail,不发 health 正文)。 + with open(self.health_path, "w") as f: + json.dump({"ok": True, "backend": "postgres", "db_ok": True, + "worker_ok": True, + "note": "sentinel-health-secret"}, f) + fake_bin = os.path.join(base, "fakebin") + os.makedirs(fake_bin) + _write_exe(os.path.join(fake_bin, "systemctl"), + "#!/bin/sh\n" + "if [ \"$1\" = \"is-active\" ]; then\n" + " case \"$2\" in\n" + " codex-cloud-server) exit \"$FAKE_SERVER1\" ;;\n" + " codex-cloud-server-2) exit \"$FAKE_SERVER2\" ;;\n" + " *) exit 1 ;;\n" + " esac\n" + "fi\n" + "exit 0\n") + real_curl = REAL_CURL or "/usr/bin/curl" + self.curl_log = os.path.join(base, "curl.log") + open(self.curl_log, "w").close() + _write_exe(os.path.join(fake_bin, "curl"), + "#!/bin/sh\n" + "echo \"$@\" >> \"$FAKE_CURL_LOG\"\n" + "for a in \"$@\"; do\n" + " case \"$a\" in\n" + " *\"/health\") cat \"$FAKE_HEALTH_JSON\"; exit 0 ;;\n" + " esac\n" + "done\n" + "exec \"%s\" \"$@\"\n" % real_curl) + self.fake_bin = fake_bin + self.env = dict(os.environ) + self.env["PATH"] = fake_bin + os.pathsep + os.environ["PATH"] + self.env["MONITOR_HEALTH_URL"] = "http://127.0.0.1:8765/health" + self.env["MONITOR_APP_DIR"] = self.app_dir + self.env["MONITOR_DISK_PATH"] = base + self.env["MONITOR_BACKUP_DIR"] = self.backup_dir + self.env["MONITOR_ALERT_URL"] = "" + self.env["MONITOR_ALERT_MAILTO"] = "" + self.env["FAKE_HEALTH_JSON"] = self.health_path + self.env["FAKE_CURL_LOG"] = self.curl_log + self.env["FAKE_SERVER1"] = "0" # server-1 active + self.env["FAKE_SERVER2"] = "1" # server-2 inactive + + def tearDown(self): + self.tmp.cleanup() + + def run_monitor(self, extra_env=None): + env = dict(self.env) + if extra_env: + env.update(extra_env) + r = subprocess.run(["sh", MONITOR_SCRIPT], + capture_output=True, text=True, env=env) + return r, json.loads(r.stdout.strip()) + + def test_single_host_passes_server_check(self): + _, out = self.run_monitor({"MONITOR_SINGLE_HOST": "1"}) + self.assertEqual(out["server_mode"], "single") + self.assertEqual(out["checks"]["server"], "pass") + + def test_dual_host_fails_server_check_when_server2_down(self): + # 默认双机:server-2 不 active 即 fail,不能静默降级。 + _, out = self.run_monitor() + self.assertEqual(out["server_mode"], "dual") + self.assertEqual(out["checks"]["server"], "fail") + + def test_webhook_alert_on_fail_only(self): + if REAL_CURL is None: + self.skipTest("本机无 curl,跳过 webhook 告警测试") + received = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + received.append(self.rfile.read(length)) + self.send_response(200) + self.end_headers() + + def log_message(self, *a): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = "http://127.0.0.1:%d/hook" % server.server_address[1] + r, out = self.run_monitor({"MONITOR_ALERT_URL": url}) + self.assertNotEqual(r.returncode, 0) # 其它项 fail,整体 fail + self.assertEqual(len(received), 1) + body = json.loads(received[0].decode()) + self.assertEqual(body["overall"], "fail") + self.assertNotIn("sentinel-health-secret", + received[0].decode()) + finally: + server.shutdown() + server.server_close() + + def test_alert_default_off(self): + # 不配出口:即使整体 fail,monitor 也不应发出任何告警请求。 + # 行为证据:假 curl 记录了全部调用,其中没有任何 POST + # (webhook 告警是唯一的 POST 调用;health 检查是 GET)。 + r, out = self.run_monitor() + self.assertNotEqual(r.returncode, 0) + self.assertEqual(out["overall"], "fail") + with open(self.curl_log) as f: + calls = f.read() + self.assertNotIn("POST", calls) + self.assertIn("/health", calls) + + def test_mail_alert_uses_mail_command(self): + mail_log = os.path.join(self.tmp.name, "mail.log") + _write_exe(os.path.join(self.fake_bin, "mail"), + "#!/bin/sh\n" + "echo \"ARGS: $@\" >> \"%s\"\n" + "cat >> \"%s\"\n" % (mail_log, mail_log)) + r, _ = self.run_monitor( + {"MONITOR_ALERT_MAILTO": "ops@example.invalid"}) + self.assertNotEqual(r.returncode, 0) + with open(mail_log) as f: + content = f.read() + self.assertIn("codex monitor FAIL", content) + self.assertIn('"overall": "fail"', content) + self.assertNotIn("sentinel-health-secret", content) + + +class RollbackSim(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = self.tmp.name + self.app = os.path.join(base, "codex-app-server") + os.makedirs(os.path.join(self.app, "work")) + with open(os.path.join(self.app, "work", "keep.txt"), "w") as f: + f.write("keep\n") + with open(os.path.join(self.app, "broken.py"), "w") as f: + f.write("x = 0\n") + self.backup_dir = os.path.join(base, "backups") + os.makedirs(self.backup_dir) + # 备份包:顶层目录名必须等于 APP_DIR 的 basename, + # rollback 解包到 dirname(APP_DIR) 后正好落回 APP_DIR。 + src = os.path.join(base, "src", "codex-app-server") + os.makedirs(src) + with open(os.path.join(src, "restored.py"), "w") as f: + f.write("x = 1\n") + with open(os.path.join(src, "DEPLOYED_COMMIT"), "w") as f: + f.write("backupcommit\n") + tarball = os.path.join(self.backup_dir, + "codex-app-server-20260101.tar.gz") + with tarfile.open(tarball, "w:gz") as tf: + for root, dirs, files in os.walk(src): + dirs.sort() + for name in sorted(files): + full = os.path.join(root, name) + tf.add(full, arcname=os.path.relpath(full, os.path.join( + base, "src"))) + self.health_dir = os.path.join(base, "health") + os.makedirs(self.health_dir) + fake_bin = os.path.join(base, "fakebin") + os.makedirs(fake_bin) + self.log_path = os.path.join(base, "systemctl.log") + open(self.log_path, "w").close() + _write_exe(os.path.join(fake_bin, "ssh"), + "#!/bin/sh\n" + "# 假 ssh:忽略目标参数,直接把 heredoc 脚本在本地跑。\n" + "exec sh -s\n") + _write_exe(os.path.join(fake_bin, "systemctl"), + "#!/bin/sh\n" + "echo \"$@\" >> \"$FAKE_SYSTEMCTL_LOG\"\n" + "if [ \"$1\" = \"cat\" ] && [ \"$2\" = \"codex-cloud-server-2\" ]; then\n" + " if [ \"$FAKE_SERVER2_PRESENT\" = \"0\" ]; then exit 1; fi\n" + " exit 0\n" + "fi\n" + "if [ \"$1\" = \"is-active\" ]; then exit 1; fi\n" + "exit 0\n") + _write_exe(os.path.join(fake_bin, "curl"), + "#!/bin/sh\n" + "for a in \"$@\"; do\n" + " case \"$a\" in\n" + " *\":8765/health\") cat \"$FAKE_HEALTH_DIR/8765.json\"; exit 0 ;;\n" + " *\":8766/health\") cat \"$FAKE_HEALTH_DIR/8766.json\"; exit 0 ;;\n" + " esac\n" + "done\n" + "exit 1\n") + self.env = dict(os.environ) + self.env["PATH"] = fake_bin + os.pathsep + os.environ["PATH"] + self.env["CODEX_ROLLBACK_APP_DIR"] = self.app + self.env["CODEX_ROLLBACK_BACKUP_DIR"] = self.backup_dir + self.env["CODEX_ROLLBACK_HEALTH_ATTEMPTS"] = "2" + self.env["CODEX_ROLLBACK_HEALTH_INTERVAL"] = "0.1" + self.env["FAKE_SYSTEMCTL_LOG"] = self.log_path + self.env["FAKE_HEALTH_DIR"] = self.health_dir + self.env["FAKE_SERVER2_PRESENT"] = "1" + self.env.pop("SSH_PROXY_CMD", None) + + def tearDown(self): + self.tmp.cleanup() + + def set_health(self, port, ok): + with open(os.path.join(self.health_dir, "%s.json" % port), "w") as f: + json.dump({"ok": ok, "backend": "postgres", "db_ok": ok}, f) + + def run_rollback(self): + return subprocess.run(["sh", ROLLBACK_SCRIPT, "testhost"], + capture_output=True, text=True, env=self.env) + + def systemctl_log(self): + with open(self.log_path) as f: + return f.read() + + def test_success_requires_both_ports(self): + self.set_health(8765, True) + self.set_health(8766, True) + r = self.run_rollback() + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn("全部端口 health 通过", r.stdout) + log = self.systemctl_log() + self.assertIn( + "stop codex-cloud-worker codex-cloud-server codex-cloud-server-2", + log) + self.assertIn( + "start codex-cloud-server codex-cloud-server-2 codex-cloud-worker", + log) + self.assertTrue(os.path.exists(os.path.join(self.app, "restored.py"))) + self.assertFalse(os.path.exists(os.path.join(self.app, "broken.py"))) + with open(os.path.join(self.app, "work", "keep.txt")) as f: + self.assertEqual(f.read().strip(), "keep") + + def test_server2_health_failure_fails_rollback(self): + # 8765 通、8766 不通:只通一端口不算回滚成功。 + self.set_health(8765, True) + self.set_health(8766, False) + r = self.run_rollback() + self.assertNotEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn("health 仍失败", r.stdout + r.stderr) + + +class TestUnitListSync(unittest.TestCase): + """两脚本清单同步核对:任一脚本改了清单、另一脚本没跟进, + 这里就变红,而不是等到回滚时才发现 server-2 没被停/验。""" + + def test_both_scripts_share_server_units_and_ports(self): + with open(ROLLBACK_SCRIPT) as f: + rollback = f.read() + with open(HOST_DEPLOY_SCRIPT) as f: + host_deploy = f.read() + for script, name in ((rollback, "rollback.sh"), + (host_deploy, "host_deploy.sh")): + self.assertIn("codex-cloud-server-2", script, name) + self.assertIn("8766", script, name) + self.assertIn("共享的 unit 清单", script, name) + + +if __name__ == "__main__": + unittest.main() diff --git a/host.py b/host.py index 0cec2db..b5420e2 100644 --- a/host.py +++ b/host.py @@ -2065,6 +2065,16 @@ def _resolve_path(base_dir, p): return os.path.join(base_dir, p) +def _thread_cli_result(resp, method): + # thread-* 子命令的冻结契约:成功只打 result 内容,失败由调用方 + # 打 error JSON 并以非零退出;result 为空时打空对象,保持 stdout + # 始终是一个 JSON 对象。 + if not resp or "error" in resp: + raise RuntimeError(f"{method} 失败:{resp}") + result = resp.get("result") + return result if result is not None else {} + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--codex-home", default=None) @@ -2143,6 +2153,17 @@ def main(): help="评审目标:指定提交 sha(commit)") p.add_argument("--instructions", default=None, help="评审目标:自定义评审指令(custom)") + p = sub.add_parser("thread-list"); p.add_argument("--cwd", default=None); p.add_argument("--archived", action="store_true") + p = sub.add_parser("thread-read"); p.add_argument("--thread-id", required=True); p.add_argument("--include-turns", action="store_true") + p = sub.add_parser("thread-rename"); p.add_argument("--thread-id", required=True); p.add_argument("--name", required=True) + p = sub.add_parser("thread-archive"); p.add_argument("--thread-id", required=True) + p = sub.add_parser("thread-unarchive"); p.add_argument("--thread-id", required=True) + p = sub.add_parser("thread-compact"); p.add_argument("--thread-id", required=True) + p = sub.add_parser("skills-list"); p.add_argument("--cwd", default=None) + p = sub.add_parser("apps-list") + p = sub.add_parser("mcp-servers-list") + p = sub.add_parser("mcp-servers-reload") + p = sub.add_parser("mcp-server-oauth-login"); p.add_argument("--server-name", required=True); p.add_argument("--scopes", default=None); p.add_argument("--timeout-secs", type=int, default=None) p = sub.add_parser("run"); p.add_argument("--task", required=True); p.add_argument("--prompt", required=True) p = sub.add_parser("interrupt"); p.add_argument("--task", required=True) p = sub.add_parser("delegate") @@ -2592,6 +2613,149 @@ def main(): t["status"] = "discarded" save_state(args.state, state) print(f"discarded {args.task}") + elif args.cmd == "thread-list": + # 只读命令,不 resume;cwd/archived 只在给了时才进 params, + # 不传时服务端按自身缺省过滤。 + try: + params = {} + if args.cwd: + params["cwd"] = args.cwd + if args.archived: + params["archived"] = True + resp = srv.request("thread/list", params, timeout=60) + print(json.dumps(_thread_cli_result(resp, "thread/list"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "thread-read": + try: + srv.resume_thread(args.thread_id) + resp = srv.request("thread/read", + {"threadId": args.thread_id, + "includeTurns": bool(args.include_turns)}, + timeout=60) + print(json.dumps(_thread_cli_result(resp, "thread/read"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "thread-rename": + try: + srv.resume_thread(args.thread_id) + resp = srv.request("thread/name/set", + {"threadId": args.thread_id, + "name": args.name}, + timeout=60) + print(json.dumps(_thread_cli_result(resp, "thread/name/set"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "thread-archive": + # 沿用现有 archive 行为:不 resume,直接调 thread/archive。 + try: + resp = srv.archive_thread(args.thread_id) + print(json.dumps(_thread_cli_result(resp, "thread/archive"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "thread-unarchive": + try: + srv.resume_thread(args.thread_id) + resp = srv.request("thread/unarchive", + {"threadId": args.thread_id}, + timeout=60) + print(json.dumps(_thread_cli_result(resp, "thread/unarchive"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "thread-compact": + try: + srv.resume_thread(args.thread_id) + resp = srv.request("thread/compact/start", + {"threadId": args.thread_id}, + timeout=120) + print(json.dumps(_thread_cli_result(resp, "thread/compact/start"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "skills-list": + # skills/mcp 管理面与 thread-* 子命令同款冻结契约:成功 + # 只打 result 内容,失败打 error JSON 并以非零退出。 + # skills/list 等方法不带 threadId,不需要 resume。 + try: + params = {} + if args.cwd: + params["cwds"] = [args.cwd] + resp = srv.request("skills/list", params, timeout=60) + print(json.dumps(_thread_cli_result(resp, "skills/list"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "apps-list": + try: + resp = srv.request("app/list", {}, timeout=60) + print(json.dumps(_thread_cli_result(resp, "app/list"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "mcp-servers-list": + try: + resp = srv.request("mcpServerStatus/list", {}, timeout=60) + print(json.dumps(_thread_cli_result(resp, "mcpServerStatus/list"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "mcp-servers-reload": + try: + resp = srv.request("config/mcpServer/reload", None, timeout=60) + print(json.dumps(_thread_cli_result(resp, "config/mcpServer/reload"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) + elif args.cmd == "mcp-server-oauth-login": + try: + params = {"name": args.server_name} + if args.scopes: + params["scopes"] = [s for s in args.scopes.split(",") if s] + if args.timeout_secs is not None: + params["timeoutSecs"] = args.timeout_secs + resp = srv.request("mcpServer/oauth/login", params, timeout=120) + print(json.dumps(_thread_cli_result(resp, "mcpServer/oauth/login"), + ensure_ascii=False)) + except SystemExit: + raise + except Exception as e: + print(json.dumps({"error": str(e)}, ensure_ascii=False)) + raise SystemExit(1) elif args.cmd == "compact": t = state["tasks"][args.task] # 注意:compact 要求 thread 已在当前 server 里加载, diff --git a/tests/test_host_thread_cli.py b/tests/test_host_thread_cli.py new file mode 100644 index 0000000..7a0c1d1 --- /dev/null +++ b/tests/test_host_thread_cli.py @@ -0,0 +1,363 @@ +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +import host + + +class FakeServer: + # Fake app-server: records every call in order, never spawns a + # real codex binary. request returns {"result": ...} per method, + # or an error response when the method is in error_methods. + # resume_thread / archive_thread mimic the real Server methods: + # they raise RuntimeError on an error response. + + def __init__(self, **kwargs): + self.calls = [] + self.results = { + "thread/list": {"data": [{"id": "th-1"}]}, + "thread/read": {"thread": {"id": "th-1"}}, + "thread/name/set": {}, + "thread/archive": {}, + "thread/unarchive": {"thread": {"id": "th-1"}}, + "thread/compact/start": {}, + } + self.error_methods = set() + self.resume_error = False + self.archive_error = False + + def initialize(self): + self.calls.append(("initialize",)) + return {} + + def close(self): + self.calls.append(("close",)) + + def request(self, method, params=None, timeout=120): + self.calls.append(("request", method, params)) + if method in self.error_methods: + return {"error": {"message": "boom"}} + return {"result": self.results.get(method, {})} + + def resume_thread(self, thread_id): + self.calls.append(("resume", thread_id)) + if self.resume_error: + raise RuntimeError("thread/resume 失败:boom") + return {"thread": {"id": thread_id}} + + def archive_thread(self, thread_id): + self.calls.append(("archive", thread_id)) + if self.archive_error: + raise RuntimeError("thread/archive 失败:boom") + return {"result": {}} + + +class ThreadCliTest(unittest.TestCase): + def _run(self, fake, cli_args, tasks=None): + with tempfile.TemporaryDirectory() as tmp: + state_path = os.path.join(tmp, "host-state.json") + with open(state_path, "w", encoding="utf-8") as f: + json.dump({"tasks": tasks or {}, "automations": []}, f) + cfg_path = os.path.join(tmp, "cfg.json") + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump({"state": state_path}, f) + argv = ["host.py", "--config", cfg_path, "--state", state_path] + argv += cli_args + out = io.StringIO() + code = 0 + with mock.patch.object(host, "Server", lambda **kw: fake): + with mock.patch.object(sys, "argv", argv): + with redirect_stdout(out): + try: + host.main() + except SystemExit as e: + code = e.code if isinstance(e.code, int) else 1 + return code, out.getvalue(), state_path + + def _calls_before_close(self, fake): + return [c for c in fake.calls if c[0] != "close" + and c[0] != "initialize"] + + def test_list_no_filters_no_resume(self): + fake = FakeServer() + code, out, _ = self._run(fake, ["thread-list"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "thread/list", {})]) + + def test_list_cwd_and_archived_passthrough(self): + fake = FakeServer() + code, out, _ = self._run( + fake, ["thread-list", "--cwd", "/tmp/work", "--archived"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "thread/list", + {"cwd": "/tmp/work", "archived": True})]) + + def test_read_defaults_include_turns_false_and_resume_first(self): + fake = FakeServer() + code, out, _ = self._run(fake, ["thread-read", "--thread-id", "th-1"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [ + ("resume", "th-1"), + ("request", "thread/read", + {"threadId": "th-1", "includeTurns": False}), + ]) + + def test_read_include_turns_flag(self): + fake = FakeServer() + code, out, _ = self._run( + fake, ["thread-read", "--thread-id", "th-1", "--include-turns"]) + self.assertEqual(code, 0) + calls = self._calls_before_close(fake) + self.assertEqual(calls[1], ("request", "thread/read", + {"threadId": "th-1", + "includeTurns": True})) + + def test_rename_resume_first_and_params(self): + fake = FakeServer() + code, out, _ = self._run( + fake, ["thread-rename", "--thread-id", "th-1", + "--name", "new-name"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [ + ("resume", "th-1"), + ("request", "thread/name/set", + {"threadId": "th-1", "name": "new-name"}), + ]) + + def test_archive_no_resume(self): + fake = FakeServer() + code, out, _ = self._run( + fake, ["thread-archive", "--thread-id", "th-1"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("archive", "th-1")]) + + def test_unarchive_resume_first(self): + fake = FakeServer() + code, out, _ = self._run( + fake, ["thread-unarchive", "--thread-id", "th-1"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [ + ("resume", "th-1"), + ("request", "thread/unarchive", {"threadId": "th-1"}), + ]) + + def test_compact_resume_first(self): + fake = FakeServer() + code, out, _ = self._run( + fake, ["thread-compact", "--thread-id", "th-1"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [ + ("resume", "th-1"), + ("request", "thread/compact/start", {"threadId": "th-1"}), + ]) + + def test_method_error_is_error_json_and_nonzero(self): + fake = FakeServer() + fake.error_methods = {"thread/list"} + code, out, _ = self._run(fake, ["thread-list"]) + self.assertNotEqual(code, 0) + parsed = json.loads(out) + self.assertEqual(set(parsed.keys()), {"error"}) + self.assertIn("thread/list", parsed["error"]) + + def test_resume_failure_is_error_json_and_nonzero(self): + fake = FakeServer() + fake.resume_error = True + code, out, _ = self._run( + fake, ["thread-rename", "--thread-id", "th-1", + "--name", "new-name"]) + self.assertNotEqual(code, 0) + parsed = json.loads(out) + self.assertEqual(set(parsed.keys()), {"error"}) + # rename must not reach thread/name/set when resume fails + methods = [c[1] for c in fake.calls if c[0] == "request"] + self.assertNotIn("thread/name/set", methods) + + def test_old_compact_unchanged(self): + fake = FakeServer() + tasks = {"t1": {"thread_id": "th-1", "status": "running", + "cwd": "/tmp/work"}} + code, out, _ = self._run(fake, ["compact", "--task", "t1"], + tasks=tasks) + self.assertEqual(code, 0) + # old human-readable output, not the frozen JSON contract + self.assertIn("compact t1:", out) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [ + ("resume", "th-1"), + ("request", "thread/compact/start", {"threadId": "th-1"}), + ]) + + def test_old_archive_unchanged(self): + fake = FakeServer() + tasks = {"t1": {"thread_id": "th-1", "status": "running", + "cwd": "/tmp/work"}} + with tempfile.TemporaryDirectory() as tmp: + state_path = os.path.join(tmp, "host-state.json") + with open(state_path, "w", encoding="utf-8") as f: + json.dump({"tasks": tasks, "automations": []}, f) + cfg_path = os.path.join(tmp, "cfg.json") + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump({"state": state_path}, f) + out = io.StringIO() + code = 0 + with mock.patch.object(host, "Server", lambda **kw: fake): + with mock.patch.object( + sys, "argv", + ["host.py", "--config", cfg_path, + "--state", state_path, + "archive", "--task", "t1"]): + with redirect_stdout(out): + try: + host.main() + except SystemExit as e: + code = e.code if isinstance(e.code, int) else 1 + self.assertEqual(code, 0) + self.assertIn("archived t1", out.getvalue()) + saved = json.load(open(state_path, encoding="utf-8")) + self.assertEqual(saved["tasks"]["t1"]["status"], "archived") + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("archive", "th-1")]) + + +class SkillsCliTest(unittest.TestCase): + # skills/mcp CLI 子命令:与 thread-* 同款冻结契约(纯 JSON + # stdout、失败非零),但不带 threadId、不 resume。 + + def _run(self, fake, cli_args): + with tempfile.TemporaryDirectory() as tmp: + state_path = os.path.join(tmp, "host-state.json") + with open(state_path, "w", encoding="utf-8") as f: + json.dump({"tasks": {}, "automations": []}, f) + cfg_path = os.path.join(tmp, "cfg.json") + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump({"state": state_path}, f) + argv = ["host.py", "--config", cfg_path, "--state", state_path] + argv += cli_args + out = io.StringIO() + code = 0 + with mock.patch.object(host, "Server", lambda **kw: fake): + with mock.patch.object(sys, "argv", argv): + with redirect_stdout(out): + try: + host.main() + except SystemExit as e: + code = e.code if isinstance(e.code, int) else 1 + return code, out.getvalue() + + def _calls_before_close(self, fake): + return [c for c in fake.calls if c[0] != "close" + and c[0] != "initialize"] + + def _fake_with(self, results): + fake = FakeServer() + fake.results.update(results) + return fake + + def test_skills_list_cwd_maps_to_cwds(self): + fake = self._fake_with({ + "skills/list": {"data": [{"cwd": "/tmp/work", "errors": [], + "skills": []}]}}) + code, out = self._run(fake, ["skills-list", "--cwd", "/tmp/work"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {"data": [{"cwd": "/tmp/work", + "errors": [], + "skills": []}]}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "skills/list", + {"cwds": ["/tmp/work"]})]) + + def test_skills_list_no_cwd_empty_params(self): + fake = self._fake_with({"skills/list": {"data": []}}) + code, out = self._run(fake, ["skills-list"]) + self.assertEqual(code, 0) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "skills/list", {})]) + + def test_apps_list(self): + fake = self._fake_with({"app/list": {"data": []}}) + code, out = self._run(fake, ["apps-list"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {"data": []}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "app/list", {})]) + + def test_mcp_servers_list(self): + fake = self._fake_with({"mcpServerStatus/list": {"data": []}}) + code, out = self._run(fake, ["mcp-servers-list"]) + self.assertEqual(code, 0) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "mcpServerStatus/list", {})]) + + def test_mcp_servers_reload(self): + fake = self._fake_with({"config/mcpServer/reload": {}}) + code, out = self._run(fake, ["mcp-servers-reload"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "config/mcpServer/reload", + None)]) + + def test_oauth_login_full_flags(self): + fake = self._fake_with({ + "mcpServer/oauth/login": {"authorizationUrl": "https://x/login", + "loginId": "lg-1"}}) + code, out = self._run(fake, ["mcp-server-oauth-login", + "--server-name", "srv", + "--scopes", "a,b", + "--timeout-secs", "30"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out), {"authorizationUrl": + "https://x/login", + "loginId": "lg-1"}) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "mcpServer/oauth/login", + {"name": "srv", "scopes": ["a", "b"], + "timeoutSecs": 30})]) + + def test_oauth_login_minimal(self): + fake = self._fake_with({ + "mcpServer/oauth/login": {"authorizationUrl": "https://x/login"}}) + code, _ = self._run(fake, ["mcp-server-oauth-login", + "--server-name", "srv"]) + self.assertEqual(code, 0) + calls = self._calls_before_close(fake) + self.assertEqual(calls, [("request", "mcpServer/oauth/login", + {"name": "srv"})]) + + def test_method_error_is_error_json_and_nonzero(self): + fake = FakeServer() + fake.error_methods = {"app/list"} + code, out = self._run(fake, ["apps-list"]) + self.assertNotEqual(code, 0) + parsed = json.loads(out) + self.assertEqual(set(parsed.keys()), {"error"}) + self.assertIn("app/list", parsed["error"]) + + +if __name__ == "__main__": + unittest.main()