From 64b8ecc3be89e14c4e7b1e02bffe61c467e9aa51 Mon Sep 17 00:00:00 2001 From: WardLu Date: Sat, 15 Aug 2026 13:18:30 +0800 Subject: [PATCH 1/3] feat: add public repository and release gates --- public-release-gate/SKILL.md | 99 ++++++++++ public-release-gate/VERSION | 1 + public-release-gate/agents/openai.yaml | 7 + public-repo-git-gate/SKILL.md | 74 ++++++++ public-repo-git-gate/VERSION | 1 + public-repo-git-gate/agents/openai.yaml | 7 + .../scripts/public_repo_check.py | 172 ++++++++++++++++++ .../tests/test_public_repo_check.py | 120 ++++++++++++ 8 files changed, 481 insertions(+) create mode 100644 public-release-gate/SKILL.md create mode 100644 public-release-gate/VERSION create mode 100644 public-release-gate/agents/openai.yaml create mode 100644 public-repo-git-gate/SKILL.md create mode 100644 public-repo-git-gate/VERSION create mode 100644 public-repo-git-gate/agents/openai.yaml create mode 100644 public-repo-git-gate/scripts/public_repo_check.py create mode 100644 public-repo-git-gate/tests/test_public_repo_check.py diff --git a/public-release-gate/SKILL.md b/public-release-gate/SKILL.md new file mode 100644 index 0000000..bdabdd9 --- /dev/null +++ b/public-release-gate/SKILL.md @@ -0,0 +1,99 @@ +--- +name: public-release-gate +description: 通用公开仓库 Release 审核流程,覆盖版本与 Tag 一致性、最终构建产物、压缩包、密钥和内部资料、第三方许可证、生产响应头及 GitHub Release 附件核对。用户要求发布、打 Tag、创建 Release、上线验收或“按全局防线检查”时使用,并按项目实际配置调整。 +--- + +# Public Release Gate + +把 Release 当作独立交付物审核。仓库提交检查不等于 Release 审核;必须同时检查源码、最终产物、发布页面和部署状态。commit、push 和 PR 的公开内容与分支闸门由同仓库的 `public-repo-git-gate` skill 负责,不在每次普通提交时重复执行本 skill 的产物检查。 + +本 skill 是人工审核流程,不宣称单个脚本或 CI 通过就能证明所有 Release 条件。优先复用项目已有的自动化检查,再补做最终产物、托管平台和生产环境的人工核对;没有实际证据时,不得标记为通过。 + +审核阶段默认只读。创建 Tag、GitHub Release、上传附件、修改生产配置或部署都需要用户明确授权;通过本 skill 不等于获得这些操作的授权。 + +## 输入与输出 + +开始前明确仓库根目录、目标版本和 Tag、最终产物路径、第三方资源清单、生产地址以及项目专属配置。 + +完成后输出一条结构化记录,至少包含: + +```text +Release gate: PASS | BLOCKED | FAILED +Repository: +Version / tag: +Build and artifacts: +Publicity and sensitive-data scan: +Third-party notices: +Deployment and response headers: +Release page and attachments: +Evidence: +Skipped checks / residual risks: +``` + +`PASS` 只表示所有必需证据已核对;`BLOCKED` 表示缺少外部状态、权限或用户决定;`FAILED` 表示检查发现问题。两种非通过状态都不能创建 Tag、GitHub Release 或继续部署。 + +## 执行顺序 + +1. 识别仓库可见性、技术栈、构建命令、产物目录、版本来源、第三方资源和部署入口。 +2. 检查 `package.json`/锁文件或项目对应的版本文件、README、CHANGELOG、Release Notes 和 Tag 是否一致。 +3. 从干净状态构建最终产物;扫描产物目录和最终压缩包,不要只扫描源码。 +4. 检查密钥、个人数据、客户数据、内部商业/法律资料、私有模型或服务配置,以及不必要公开的文件。 +5. 对 vendored 代码、模型、WASM、字体和媒体逐项核对来源、版本/提交、许可证、再分发条件和 SHA-256;许可证清单必须匹配最终资源。 +6. 在部署完成后检查 HTTPS、HTTP 状态、关键静态入口、CSP、HSTS、X-Frame-Options 等项目要求的响应头。 +7. 创建 GitHub Release 前核对 Tag、Release 页面和每个附件;附件必须来自已扫描的最终产物,计算并记录 SHA-256。 +8. 涉及数据库迁移、边缘函数或外部配置时,单独确认生产状态。CI 通过不代表生产迁移已经执行。 + +## 复用方式 + +优先复用项目已有的 `release:check`。没有时,在项目根目录建立 `release-gate.config.json`,至少声明: + +```json +{ + "versionFiles": ["package.json"], + "artifactPaths": ["dist"], + "vendoredPaths": [], + "noticeFiles": ["THIRD_PARTY_NOTICES.md"], + "requireDeploymentChecklist": true, + "deploymentChecklist": "docs/release-checklist.md", + "production": { + "requiredHeaders": [], + "forbiddenHeaderValues": [], + "paths": ["/"] + } +} +``` + +以上是跨项目约定样例,不是自动校验的通用 schema;项目应由自己的 `release:check` 负责校验实际字段和路径。 + +项目专属内容放配置中,例如 Supabase 迁移、Vercel 生产地址、Piper 资源和特定响应头;不要把一个项目的假设复制到其他项目。 + +推荐提供以下命令或等价入口: + +```text +npm run verify # 源码、测试和构建 +npm run release:check # Release 元数据、产物、许可证和部署检查 +``` + +没有自动化入口时,仍执行同样的检查并在 PR/Release Notes 留下证据。任何检查失败都停止发布,不要先创建 Release 再解释。 + +## 失败、阻塞与恢复 + +1. 发现密钥、个人数据、内部资料、许可证缺失或产物不一致时,停止发布并记录精确路径、附件或检查项。 +2. 遇到无法访问的生产地址、GitHub Release 权限、部署状态或外部配置时,标记为 `BLOCKED`,不要用本地构建成功替代外部证据。 +3. 修复后从失败的检查项重新执行,并重新核对受影响的版本、产物哈希和附件;不要直接沿用旧的 `PASS` 记录。 +4. 任何已暴露的密钥或个人数据按安全事件处理,轮换凭据并保留事件记录;仅删除文件或改写历史不足以证明风险已经消失。 + +## 平台边界 + +版本、构建、产物和许可证检查适用于不同语言和构建系统,但具体命令必须以项目配置为准。GitHub Release、HTTP 响应头和生产部署检查依赖对应托管平台;不能把未测试的平台 CLI、Preview 环境或本地服务器结果描述为生产验证。 + +## 触发词 + +将以下请求视为完整触发: + +- “按全局公开仓库防线检查本次 Release” +- “检查这个 Tag 能不能发布” +- “审核最终安装包/压缩包和 GitHub Release” +- “上线后验收这个版本” + +普通“检查提交”只覆盖 commit/PR 闸门;用户提到 Release、Tag、安装包、商店包、部署或上线时,必须升级为本技能的完整流程。 diff --git a/public-release-gate/VERSION b/public-release-gate/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/public-release-gate/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/public-release-gate/agents/openai.yaml b/public-release-gate/agents/openai.yaml new file mode 100644 index 0000000..01fe3aa --- /dev/null +++ b/public-release-gate/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Public Release Gate" + short_description: "Audit release artifacts, metadata, and deployment state" + default_prompt: "Use $public-release-gate to audit this release before creating its tag or publishing artifacts." + +policy: + allow_implicit_invocation: false diff --git a/public-repo-git-gate/SKILL.md b/public-repo-git-gate/SKILL.md new file mode 100644 index 0000000..de747f9 --- /dev/null +++ b/public-repo-git-gate/SKILL.md @@ -0,0 +1,74 @@ +--- +name: public-repo-git-gate +description: 通用公开 Git 仓库 commit、push 和 PR 防线。检查公开范围、staged/untracked 文件、密钥和个人数据、内部资料、分支与远程目标、PR base/head、CI 和合并前条件。用户要求提交、推送、开 PR、审查公开仓库内容或“按全局防线检查”时使用。 +--- + +# Public Repository Git Gate + +把 Git 提交、远程推送和 Pull Request 当成连续的公开性闸门。`.gitignore` 不是安全边界;必须检查 Git 实际会提交或已经纳入版本控制的内容。 + +本 skill 的检查默认是只读的。通过检查不自动授权 commit、push、创建 PR 或修改远程分支;这些动作仍需用户明确要求,并按当前仓库的分支和审批规则执行。 + +## 使用脚本 + +从这个 skill 目录运行。三类检查不是三次全量重复:commit 检查暂存区,push 检查相对 base 的变化,PR 由 CI 对最终 head 独立验证。 + +```bash +python3 scripts/public_repo_check.py --repo /path/to/repository --staged --require-feature-branch +``` + +推荐在提交前运行 staged 检查,在推送前运行全量检查: + +```bash +python3 scripts/public_repo_check.py --repo /path/to/repository --staged --require-feature-branch +python3 scripts/public_repo_check.py --repo /path/to/repository --changed-since origin/main --check-remote +``` + +首次接入、规则配置变化、发生过误推送或准备 Release 时,再使用 `--all` 做全量基线检查。项目可以通过 `--config path/to/public-repo-gate.json` 增加项目专属禁用路径和规则。默认规则保持保守;确实需要公开的特殊资源应在项目配置中逐项说明,不能直接关闭整套检查。 + +如果仓库很大,可以把 `origin/main` 换成 PR 的准确 base SHA;不要使用过旧的本地 base。若 ref 不存在,检查应失败并先同步 base,而不是默认为“没有变化”。 + +## 分层检查策略 + +| 阶段 | 检查范围 | 运行内容 | 目标耗时 | +| --- | --- | --- | --- | +| commit | staged 文件 | 路径、密钥、diff 格式、特性分支 | 秒级、离线 | +| push | `base...HEAD` 变化 | 增量公开性检查、remote/branch/upstream 核对 | 秒级,不跑完整测试 | +| PR | 最终 head | CI 全量安全检查、lint、test、build;审查 PR diff | 由 CI 承担 | +| Release | 最终产物和线上状态 | 压缩包、许可证、Tag、部署和附件 | 单独执行 | + +commit 通过的结果不能替代 push/PR 的远程状态确认;push 的增量检查也不能替代 PR CI。这样每层只重复自己必须承担的部分。 + +## Commit 闸门 + +1. 先确认当前仓库可见性。远程仓库、分支、PR、Preview 和历史默认按公开内容处理。 +2. 检查 `git status --short --branch`、当前分支和目标 PR base;普通改动不得直接提交 `main`/`master`。 +3. 只把必要文件加入暂存区;检查 staged 文件名、状态和完整 diff,不只看摘要。 +4. 运行脚本的 `--staged` 检查和 `git diff --cached --check`。这是快速闸门,不要求每次提交都运行完整测试。 +5. 代码风险较高时按项目规则补跑针对性测试;完整 lint、test、build、security 和许可证检查由 PR CI 负责。 +6. 检查 commit message、版本文件、公开文档、测试和配置是否同步。内部计划、法律意见、商业策略、客户数据和 agent 私有配置不进入公开提交。 + +## Push 闸门 + +1. 推送前重新确认 remote URL、仓库可见性、目标分支、upstream 和 PR head;不要凭旧记忆判断远程状态。 +2. 运行 `--changed-since origin/main --check-remote`,再检查本分支最近提交是否包含不应公开的内容。脚本不能证明托管平台的仓库一定是私有或公开,平台设置仍需人工确认。 +3. 普通工作流只推送特性分支,不直接推送 `main`。不得使用 `--force` 覆盖他人分支;历史清理等例外必须先保留本地备份并明确记录原因。 +4. 推送后通过远程平台确认分支实际存在、提交 SHA 一致、PR base/head 正确,且没有意外创建或更新其他 PR。 +5. 若发现误推送,立即停止继续推送;保留备份引用,暂停/关闭 PR,轮换暴露的密钥,从干净公开 base 重建分支,再按托管平台流程处理历史对象和缓存。 + +## PR 闸门 + +1. PR 必须以 `main` 为 base,head 指向本次特性分支;标题和描述说明范围、风险、验证结果及未完成项。 +2. PR 文件列表和完整 diff 再检查一次,特别是新增文件、生成物、隐藏目录、配置文件、许可证和文档。 +3. CI 必须通过;其中应包含一次最终 head 的全量公开性扫描以及项目 lint、test、build、安全和许可证检查。涉及数据库、部署、第三方资源或发布物时,补充对应的真实环境证据。CI 通过不等于生产迁移已执行。 +4. PR 描述应明确列出增量本地检查、CI 全量检查、项目测试/构建、安全检查和许可证检查结果;失败或跳过必须说明原因。 +5. 作者不能把自己的 PR 当作独立 Review;至少需要一次合适的 Review,必要时请求安全或法律审查。 +6. 合并前再次确认没有内部资料、个人数据、密钥或不必要的公开内容。合并后删除特性分支,并独立核对 `main` 的最终提交。 + +## 检查失败时 + +停止当前 commit/push/merge,不要“先提交再解释”。先判断是误报、项目专属公开资源,还是确实不应公开;对误报增加最小范围的项目配置并保留理由,对真实问题移出暂存区或从提交中删除。发生密钥、个人数据或内部材料暴露时,按安全事件处理,不能只依赖重写分支历史。 + +## 与 Release 闸门的边界 + +本 skill 覆盖 commit、push 和 PR。Tag、最终安装包、压缩包、GitHub Release 附件和生产部署验收使用同仓库的 `public-release-gate` skill;两者都通过后才可称为完整发布审核。不要在 commit、push、PR 每层重复运行 Release 产物扫描。 diff --git a/public-repo-git-gate/VERSION b/public-repo-git-gate/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/public-repo-git-gate/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/public-repo-git-gate/agents/openai.yaml b/public-repo-git-gate/agents/openai.yaml new file mode 100644 index 0000000..192fdba --- /dev/null +++ b/public-repo-git-gate/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Public Repository Git Gate" + short_description: "Check public Git commits, pushes, and pull requests" + default_prompt: "Use $public-repo-git-gate to audit this repository before committing or opening a pull request." + +policy: + allow_implicit_invocation: false diff --git a/public-repo-git-gate/scripts/public_repo_check.py b/public-repo-git-gate/scripts/public_repo_check.py new file mode 100644 index 0000000..0788ab3 --- /dev/null +++ b/public-repo-git-gate/scripts/public_repo_check.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Check Git content and branch state before a public commit, push, or PR.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Iterable + + +DEFAULT_FORBIDDEN_PATHS = [ + r"(^|/)(?:internal|private|legal|commercial|finance|billing|entitlement|secrets?)(?:/|$)", + r"(^|/)(?:\.env|\.vercel|\.supabase|\.codex|\.agents|\.claude)(?:[/.]|$)", + r"(^|/)(?:production|prod|customer|user-data|exports?)(?:[-_][^/]*)?\.(?:csv|json|sql|dump|db|sqlite|zip)$", + r"(^|/)(?:ROADMAP|TODO)(?:\.[^/]*)?$", +] +DEFAULT_SECRET_PATTERNS = [ + r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----", + r"(?:sk-(?:proj-)?|gh[pousr]_)[A-Za-z0-9_-]{12,}", + r"sb_secret_[A-Za-z0-9_-]{8,}", + r"postgres(?:ql)?://[^\s:'\"]+:[^\s@'\"]+@", +] + + +def git(repo: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and result.returncode: + raise RuntimeError(result.stderr.decode("utf-8", "replace").strip() or f"git {' '.join(args)} failed") + return result.stdout.decode("utf-8", "replace") + + +def git_paths(repo: Path, staged: bool, changed_since: str | None) -> list[str]: + if changed_since: + args = ["diff", "--name-only", "--diff-filter=ACMRTUXB", "-z", f"{changed_since}...HEAD"] + elif staged: + args = ["diff", "--cached", "--name-only", "--diff-filter=ACMRTUXB", "-z"] + else: + args = ["ls-files", "--cached", "--others", "--exclude-standard", "-z"] + return [path for path in git(repo, *args).split("\0") if path] + + +def read_content(repo: Path, path: str, staged: bool) -> bytes: + if staged: + result = subprocess.run( + ["git", "-C", str(repo), "show", f":{path}"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode: + return b"" + return result.stdout + return (repo / path).read_bytes() + + +def load_config(path: Path | None) -> dict: + if not path: + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def scan_paths(repo: Path, paths: Iterable[str], staged: bool, config: dict) -> list[str]: + forbidden_paths = [*DEFAULT_FORBIDDEN_PATHS, *config.get("forbidden_paths", [])] + secret_patterns = [*DEFAULT_SECRET_PATTERNS, *config.get("forbidden_terms", [])] + path_rules = [re.compile(rule, re.IGNORECASE) for rule in forbidden_paths] + secret_rules = [re.compile(rule, re.IGNORECASE) for rule in secret_patterns] + findings: list[str] = [] + + for raw_path in paths: + path = raw_path.replace("\\", "/") + if any(rule.search(path) for rule in path_rules): + findings.append(f"{path}: forbidden public-repository path") + continue + try: + data = read_content(repo, raw_path, staged) + except OSError as error: + findings.append(f"{path}: cannot read candidate ({error})") + continue + if b"\0" in data: + continue + text = data.decode("utf-8", "replace") + if any(rule.search(text) for rule in secret_rules): + findings.append(f"{path}: possible secret or credential") + + return findings + + +def check_remote(repo: Path) -> list[str]: + findings: list[str] = [] + remotes = git(repo, "remote", check=False).split() + for remote in remotes: + url = git(repo, "remote", "get-url", remote, check=False).strip() + if re.search(r"https?://[^/\s:]+:[^@\s]+@", url): + findings.append(f"remote {remote}: URL contains embedded credentials") + print(f"remote {remote}: {url}") + if not remotes: + findings.append("repository has no configured remote") + return findings + + +def check_repo(repo: Path, staged: bool, changed_since: str | None, require_feature_branch: bool, + check_remote_flag: bool, config: dict) -> list[str]: + findings: list[str] = [] + branch = git(repo, "branch", "--show-current").strip() + if not branch: + findings.append("repository is in detached HEAD state") + if require_feature_branch and branch in {"main", "master"}: + findings.append(f"current branch {branch} is protected; use a feature branch and PR") + + if staged and not changed_since: + diff_check = subprocess.run( + ["git", "-C", str(repo), "diff", "--cached", "--check"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if diff_check.returncode: + findings.append("staged diff has whitespace errors:\n" + diff_check.stdout.decode("utf-8", "replace").strip()) + + paths = git_paths(repo, staged, changed_since) + print(f"branch: {branch or '(detached)'}") + scope = "staged" if staged else (f"changed since {changed_since}" if changed_since else "tracked/untracked") + print(f"checking {len(paths)} {scope} candidate paths") + findings.extend(scan_paths(repo, paths, staged, config)) + if check_remote_flag: + findings.extend(check_remote(repo)) + return sorted(set(findings)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path.cwd()) + scope = parser.add_mutually_exclusive_group() + scope.add_argument("--staged", action="store_true", help="scan only the current index") + scope.add_argument("--all", action="store_true", help="scan tracked and non-ignored untracked candidates") + scope.add_argument("--changed-since", metavar="REF", help="scan paths changed between REF and HEAD") + parser.add_argument("--require-feature-branch", action="store_true") + parser.add_argument("--check-remote", action="store_true") + parser.add_argument("--config", type=Path) + args = parser.parse_args() + + repo = args.repo.resolve() + if not (repo / ".git").exists() and not (repo / ".git").is_file(): + print(f"not a Git repository: {repo}", file=sys.stderr) + return 2 + try: + findings = check_repo(repo, staged=args.staged, changed_since=args.changed_since, + require_feature_branch=args.require_feature_branch, + check_remote_flag=args.check_remote, config=load_config(args.config)) + except (OSError, RuntimeError, json.JSONDecodeError) as error: + print(f"public repository check could not run: {error}", file=sys.stderr) + return 2 + if findings: + print("Public repository Git gate failed:", file=sys.stderr) + for finding in findings: + print(f"- {finding}", file=sys.stderr) + return 1 + print("Public repository Git gate passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/public-repo-git-gate/tests/test_public_repo_check.py b/public-repo-git-gate/tests/test_public_repo_check.py new file mode 100644 index 0000000..848d651 --- /dev/null +++ b/public-repo-git-gate/tests/test_public_repo_check.py @@ -0,0 +1,120 @@ +import contextlib +import io +import subprocess +import tempfile +import unittest +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from scripts.public_repo_check import check_remote, check_repo, git_paths, scan_paths + + +class PublicRepoCheckTests(unittest.TestCase): + def test_forbidden_path_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + (repo / "docs" / "internal").mkdir(parents=True) + (repo / "docs" / "internal" / "plan.md").write_text("private", encoding="utf-8") + findings = scan_paths(repo, ["docs/internal/plan.md"], staged=False, config={}) + self.assertEqual(findings, ["docs/internal/plan.md: forbidden public-repository path"]) + + def test_secret_in_text_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + (repo / "config.js").write_text("const token = '" + "sk-proj-" + "123456789012345';", encoding="utf-8") + findings = scan_paths(repo, ["config.js"], staged=False, config={}) + self.assertEqual(findings, ["config.js: possible secret or credential"]) + + def test_binary_candidate_is_skipped(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + (repo / "model.bin").write_bytes(b"\x00\x01private-looking-text") + findings = scan_paths(repo, ["model.bin"], staged=False, config={}) + self.assertEqual(findings, []) + + def test_project_config_adds_forbidden_rule(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + (repo / "docs" / "plan.md").parent.mkdir(parents=True) + (repo / "docs" / "plan.md").write_text("internal launch plan", encoding="utf-8") + (repo / "docs" / "notes.md").write_text("confidential phrase", encoding="utf-8") + findings = scan_paths( + repo, + ["docs/plan.md"], + staged=False, + config={ + "forbidden_paths": [r"(^|/)plan\.md$"], + }, + ) + term_findings = scan_paths( + repo, + ["docs/notes.md"], + staged=False, + config={"forbidden_terms": [r"confidential phrase"]}, + ) + self.assertEqual( + findings, + ["docs/plan.md: forbidden public-repository path"], + ) + self.assertEqual(term_findings, ["docs/notes.md: possible secret or credential"]) + + def test_changed_since_returns_only_incremental_paths(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + self._git(repo, "init", "-b", "main") + self._git(repo, "config", "user.email", "test@example.com") + self._git(repo, "config", "user.name", "Public Repo Gate Test") + (repo / "base.txt").write_text("base", encoding="utf-8") + self._git(repo, "add", "base.txt") + self._git(repo, "commit", "-m", "base") + (repo / "incremental.txt").write_text("incremental", encoding="utf-8") + self._git(repo, "add", "incremental.txt") + self._git(repo, "commit", "-m", "incremental") + + paths = git_paths(repo, staged=False, changed_since="HEAD^") + + self.assertEqual(paths, ["incremental.txt"]) + + def test_remote_credentials_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + self._git(repo, "init", "-b", "main") + self._git(repo, "remote", "add", "origin", "https://demo:placeholder@example.com/repo.git") + output = io.StringIO() + with contextlib.redirect_stdout(output): + findings = check_remote(repo) + + self.assertEqual( + findings, + ["remote origin: URL contains embedded credentials"], + ) + + def test_protected_branch_requires_feature_branch(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + self._git(repo, "init", "-b", "main") + findings = check_repo( + repo, + staged=False, + changed_since=None, + require_feature_branch=True, + check_remote_flag=False, + config={}, + ) + + self.assertEqual(findings, ["current branch main is protected; use a feature branch and PR"]) + + @staticmethod + def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +if __name__ == "__main__": + unittest.main() From 0cdf19eacaa7e485ba9b1bf0f968ff58bcf68602 Mon Sep 17 00:00:00 2001 From: WardLu Date: Sat, 15 Aug 2026 13:21:14 +0800 Subject: [PATCH 2/3] feat: add test scope routing skill --- test-scope-routing/SKILL.md | 88 +++++++++++++++++++ test-scope-routing/VERSION | 1 + test-scope-routing/agents/openai.yaml | 4 + .../references/usage-examples.md | 43 +++++++++ 4 files changed, 136 insertions(+) create mode 100644 test-scope-routing/SKILL.md create mode 100644 test-scope-routing/VERSION create mode 100644 test-scope-routing/agents/openai.yaml create mode 100644 test-scope-routing/references/usage-examples.md diff --git a/test-scope-routing/SKILL.md b/test-scope-routing/SKILL.md new file mode 100644 index 0000000..c62c41a --- /dev/null +++ b/test-scope-routing/SKILL.md @@ -0,0 +1,88 @@ +--- +name: test-scope-routing +description: Route validation by changed boundaries and risk, choosing the smallest sufficient checks for code, docs, UI, data, auth, sync, build, security, and release work. Use when deciding test commands, reducing unnecessary full-suite runs, auditing test layering, or documenting what was and was not verified. +--- + +# Test Scope Routing + +Choose validation from the changed surface and risk. Keep the local feedback loop small, while reserving complete regression and release gates for merge, release, and high-risk boundaries. + +For compact routing examples, read [references/usage-examples.md](references/usage-examples.md) when needed. + +## Establish the project contract + +Before selecting commands: + +1. Read the applicable `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, and test-scope documentation. +2. Inspect `package.json`, Makefiles, task runners, test configuration, CI workflows, and release workflows. +3. Identify the repository, branch, worktrees, submodules, and uncommitted changes. Preserve changes that are outside the request. +4. Use project-defined commands and prerequisites. Do not invent a global command name or assume that `verify`, `fast`, or `full` has a standard meaning. +5. Check whether a command starts a database, browser, production connection, deployment, or other external state before running it. + +Treat project documentation as the authority for exact commands and boundaries. This skill supplies the routing method, not project-specific command names. + +## Write a small spec first + +For behavior changes, record before implementation: + +- in-scope behavior and explicit non-goals; +- user-observable acceptance conditions; +- affected code, UI, data, permission, offline, conflict, error, and compatibility boundaries; +- the smallest validation tier and any additional tiers; +- checks intentionally not run and why. + +If a requirement is materially ambiguous, resolve the spec or decision first. For a behavior change, add a focused failing test when practical, then implement the smallest change. + +## Select the smallest sufficient tier + +Use semantic tiers. Projects may rename or combine them, but the responsibilities must remain distinct. + +| Tier | Responsibility | Typical scope | +| --- | --- | --- | +| L0 static | Syntax, formatting, repository policy, static assets, and documentation constraints | Docs, copy, low-risk CSS, static configuration | +| L1 fast | Deterministic local logic and state behavior | Utilities, models, reducers, controllers, rule calculations | +| L2 targeted UI | User-visible rendering and interaction in the affected journey | Navigation, forms, settings, offline UI, PWA, accessibility-sensitive flows | +| L3 integration | External boundaries and data correctness | Database schema/RLS, functions, auth, sync, export, deletion, network errors | +| L4 full | Merge, release, or high-risk system boundary | Full build, security, dependency, complete E2E, database, release, and production checks | + +Route by the union of touched boundaries: + +- Documentation-only changes normally need L0 or no executable test when the project has no documentation checker. +- Pure logic changes need L1; use a related-test or explicit-test-file route when the project supports it. +- UI changes need L1 only when logic is affected, plus the smallest relevant L2 journey. +- Auth, synchronization, data lifecycle, schema, RLS, or server-function changes need the relevant L1 and L3 checks. Do not treat a UI test as permission or database evidence. +- Dependency, build, Service Worker, CSP, public-resource, or release changes need the project’s L4 checks. +- Merge, release, and explicitly high-risk changes require L4 even when lower tiers already passed. + +Do not run every tier merely because a command is convenient. Do add tiers when the change crosses a boundary. If no targeted check exists, report the gap as unverified instead of hiding it behind an unrelated full suite. + +## Inspect command composition + +Before composing or recommending shortcuts: + +- Expand each command and identify nested checks. Avoid running the same unit, coverage, build, or E2E suite twice. +- Do not assume `verify` means full regression. Confirm whether it excludes database, functions, E2E, or production probes. +- Prefer a test-file, project, tag, or related-test selector for local iteration when supported. +- Treat a command named `fast` as only a label; verify whether it runs all unit tests or only impacted tests. +- Keep local database checks local and follow the project’s migration/control-plane rules. Never substitute a production write for validation. +- Separate code failure, flaky behavior, missing dependency, unavailable Docker/browser, missing environment variables, and missing production URL. Environment blockage is not a passing test. +- Keep CI and release gates appropriately complete even when local iteration is targeted. + +## Record evidence + +In the task or PR, record: + +1. Scope and acceptance conditions. +2. Selected tier(s) and the exact command(s) run. +3. Results, including counts and relevant artifacts when available. +4. Tiers not run and the reason. +5. Environment blockers, flaky tests, or unverified production behavior. +6. Remaining merge or release gates. + +Do not describe a change as fully verified from an exit code, HTTP 200, empty output, pending CI check, or unit coverage percentage alone. State the actual boundary covered and the boundary that remains unverified. + +## Keep the global and project layers separate + +- Keep this skill framework-level: risk classification, tier responsibilities, command inspection, and evidence reporting. +- Keep project-specific facts in the project’s test-scope and contribution documents: exact commands, path mappings, thresholds, environments, browser/database prerequisites, and release exceptions. +- Keep one-off audits and handoffs as task records, not as the permanent global source of truth. diff --git a/test-scope-routing/VERSION b/test-scope-routing/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/test-scope-routing/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/test-scope-routing/agents/openai.yaml b/test-scope-routing/agents/openai.yaml new file mode 100644 index 0000000..0df859a --- /dev/null +++ b/test-scope-routing/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Test Scope Routing" + short_description: "按改动风险选择最小充分测试范围,减少无意义全量验证" + default_prompt: "Use $test-scope-routing to select and document the smallest sufficient validation scope for this change." diff --git a/test-scope-routing/references/usage-examples.md b/test-scope-routing/references/usage-examples.md new file mode 100644 index 0000000..d5c6ea2 --- /dev/null +++ b/test-scope-routing/references/usage-examples.md @@ -0,0 +1,43 @@ +# Usage Examples + +Use these examples as routing patterns, not as fixed commands. Replace each command with the target project's documented equivalent. + +## Documentation or copy change + +**Change:** README wording or a low-risk label. + +**Route:** L0, or no executable test if the project has no documentation checker. + +**Record:** The affected document, the static check if one exists, and why database, browser, and full regression checks were not relevant. + +## Pure logic change + +**Change:** A rule calculation, reducer, model, or utility function. + +**Route:** L1 with the related test file when supported. Add broader unit or coverage checks at the project's merge boundary. + +**Record:** The rule and error cases covered; do not claim UI or network coverage from unit results. + +## User-visible flow change + +**Change:** A settings form, navigation path, offline state, or PWA interaction. + +**Route:** L1 when local logic changes, plus the smallest relevant L2 browser or component journey. Do not run unrelated authenticated or database flows. + +**Record:** The browser/device or component environment, journey covered, and any skipped flows. + +## Data or permission boundary change + +**Change:** Database schema, RLS, authentication, synchronization, export, deletion, or a server function. + +**Route:** Relevant L1 and L3 checks. Add affected UI journeys. Use L4 before merge or release. + +**Record:** The tenant/permission boundary, failure and recovery cases, local service prerequisites, and whether production state remains unverified. + +## Release or high-risk change + +**Change:** Dependency, build system, Service Worker, CSP, public resource, release script, or merge/release candidate. + +**Route:** The project's L4 gate, including the required build, security, integration, E2E, artifact, and production checks. + +**Record:** Exact commands, final artifacts, environment blockers, and any production URL or deployment verification that was not performed. From 313a3c8d810ffcde77dde87b7d4e6fff2e78a1cd Mon Sep 17 00:00:00 2001 From: WardLu Date: Sat, 15 Aug 2026 18:16:33 +0800 Subject: [PATCH 3/3] fix(codex-cross-provider-session-repair): 0.7.5 terminal auto-close and repo docs sync - session repair 0.7.4/0.7.5: independent Terminal waiter, session-titled window auto-close after Enter, bilingual status, and offline tests - sync root README catalog/version and repository layout - validate all skill directories in CI workflow - add AGENTS.md/CLAUDE.md repo collaboration conventions --- .github/workflows/validate-skills.yml | 19 +- AGENTS.md | 37 ++ CLAUDE.md | 1 + README.md | 27 +- README.zh-CN.md | 27 +- .../CHANGELOG.md | 92 +++ codex-cross-provider-session-repair/README.md | 83 ++- .../README.zh-CN.md | 55 +- codex-cross-provider-session-repair/SKILL.md | 99 ++- codex-cross-provider-session-repair/VERSION | 2 +- .../scripts/repair.py | 127 +++- .../scripts/start_repair.py | 324 ++++++++++ .../scripts/wait_and_repair.py | 567 ++++++++++++++++++ .../tests/test_repair.py | 56 +- .../tests/test_start_repair.py | 144 +++++ .../tests/test_wait_and_repair.py | 358 +++++++++++ 16 files changed, 1963 insertions(+), 55 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 codex-cross-provider-session-repair/scripts/start_repair.py create mode 100644 codex-cross-provider-session-repair/scripts/wait_and_repair.py create mode 100644 codex-cross-provider-session-repair/tests/test_start_repair.py create mode 100644 codex-cross-provider-session-repair/tests/test_wait_and_repair.py diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml index 2040493..3575834 100644 --- a/.github/workflows/validate-skills.yml +++ b/.github/workflows/validate-skills.yml @@ -20,12 +20,17 @@ jobs: python - <<'PY' import pathlib, subprocess, sys validator = pathlib.Path.home() / '.agents' / 'skills' / 'skill-creator' / 'scripts' / 'quick_validate.py' - root = pathlib.Path('codex-cross-provider-session-repair') - if validator.exists(): - subprocess.check_call([sys.executable, str(validator), str(root)]) - else: - text = (root / 'SKILL.md').read_text(encoding='utf-8') - assert text.startswith('---\n') and '\nname: codex-cross-provider-session-repair\n' in text + roots = sorted(path.parent for path in pathlib.Path('.').glob('*/SKILL.md')) + assert roots, 'no skill directories found' + for root in roots: + if validator.exists(): + subprocess.check_call([sys.executable, str(validator), str(root)]) + else: + text = (root / 'SKILL.md').read_text(encoding='utf-8') + assert text.startswith('---\n') and '\nname:' in text and '\ndescription:' in text PY - name: Run offline tests - run: python -m unittest discover -s codex-cross-provider-session-repair/tests -v + run: | + for tests in */tests; do + python -m unittest discover -s "$tests" -v + done diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ae77da6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Skills 集合项目约定 + +## 仓库边界 + +- 本仓库保存可独立安装、测试和发布的 Agent Skill;每个 Skill 位于根目录下独立的 kebab-case 目录。 +- Skill 入口必须命名为大写 `SKILL.md`,以 YAML frontmatter 开头,并包含与目录/用途一致的 `name` 和可触发的 `description`。 +- 根 README 只做目录、安装和兼容性导航;完整工作流放对应 `SKILL.md`,大段背景、示例和实现细节按需放 `references/`、README 或脚本。 +- 不为同一 Skill 在多个位置维护漂移副本。安装目录是发布结果,不是本仓库事实源。 + +## Skill 设计与实现 + +- 规则只保留模型无法可靠推断的流程、安全边界和完成标准;不要堆叠通用礼貌、重复全局约定或用提示词模拟 CI/权限。 +- 新增或修改 Skill 时核对触发条件、输入、输出、失败模式、恢复方式和平台兼容性;危险操作必须默认收窄目标并在写入前备份。 +- 优先让脚本承担确定性解析、校验和批量处理。脚本应可从 Skill 目录运行,避免硬编码个人路径,并提供 `--help` 或清晰入口。 +- 测试保持离线、确定性和无真实副作用;不得包含真实会话、令牌、密钥、备份、用户名数据或完整诊断内容。 +- 面向多个系统的 Skill 要明确共用部分和平台差异,不能声称在未测试的 agent、OS 或版本上已验证。 + +## 版本与发布 + +- `VERSION` 是规范版本;发布时同步 `SKILL.md` 元数据、README 徽章/目录、`CHANGELOG.md` 和 `vMAJOR.MINOR.PATCH` 标签。 +- 用户可见行为、默认写入范围或安全语义变化需要更新文档与测试;破坏性变化按语义化版本处理。 +- 公开发布前分别检查源码、安装包/归档、第三方许可证和最终 Release 附件;普通提交检查不能替代 Release 验收。 +- 不把本地已安装副本、测试通过或标签存在单独当成发布完成证据。 + +## 修改与验证 + +- 先完整读取目标 Skill 的 `SKILL.md` 及其直接引用,再修改;不要只根据目录名推断行为。 +- 元数据检查以 `.github/workflows/validate-skills.yml` 为准,至少验证 frontmatter、`name`、`description` 和大写入口名。 +- 运行单个 Skill 测试:`python -m unittest discover -s /tests -v`;涉及公共校验逻辑时运行所有现有 `*/tests`。 +- 脚本、备份、跨平台或修复逻辑变化还要运行对应 `--help`、dry-run/预览和失败路径测试;真实写入结果必须单独验证。 +- 修改根目录索引或安装说明时同步 `README.md` 与 `README.zh-CN.md`,并检查链接和版本是否指向现存文件。 + +## 当前入口 + +- 集合说明:`README.md`、`README.zh-CN.md` +- CI 校验:`.github/workflows/validate-skills.yml` +- 各 Skill 的行为、测试和发布细节:对应目录中的 `SKILL.md` 与 README diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index c06d37f..75ce187 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,16 @@ Focused, versioned, open-source skills for agent workflows. ## What this repository is -This repository is a collection of self-contained skills for AI agents and developer workflows. Each skill lives in its own kebab-case directory and includes its instructions, scripts, tests, documentation, license, and version metadata. Individual skills may target one agent or work across several agents; their compatibility is documented in each skill's README and `SKILL.md`. +This repository is a collection of self-contained skills for AI agents and developer workflows. Each skill lives in its own kebab-case directory and includes a `SKILL.md`; scripts, tests, references, documentation, licenses, and version metadata are added when the skill needs them. Individual skills may target one agent or work across several agents; their compatibility is documented in each skill's `SKILL.md` and any linked references. -The first skill in the collection is Codex-specific: it helps recover an old Codex Desktop conversation after a provider switch, import, or fork. +The collection currently includes a Codex-specific session repair skill, public repository/release gates, and a framework-agnostic test-scope routing skill. | Skill | Purpose | Version | Documentation | | --- | --- | --- | --- | -| [`codex-cross-provider-session-repair`](codex-cross-provider-session-repair/) | Diagnose and repair provider mismatches and remote compaction `404 Item with id 'rs_...' not found` errors without deleting the Codex home. | `0.4.0` | [English](codex-cross-provider-session-repair/README.md) · [简体中文](codex-cross-provider-session-repair/README.zh-CN.md) | +| [`codex-cross-provider-session-repair`](codex-cross-provider-session-repair/) | Diagnose and repair provider mismatches and remote compaction `404 Item with id 'rs_...' not found` errors without deleting the Codex home. | `0.7.5` | [English](codex-cross-provider-session-repair/README.md) · [简体中文](codex-cross-provider-session-repair/README.zh-CN.md) | +| [`public-release-gate`](public-release-gate/) | Review public repository releases, final artifacts, third-party notices, deployment headers, and GitHub Release attachments. | `0.1.0` | [SKILL.md](public-release-gate/SKILL.md) | +| [`public-repo-git-gate`](public-repo-git-gate/) | Check public repository content and branch/remote/PR state across commit, push, and pull request workflows. | `0.1.0` | [SKILL.md](public-repo-git-gate/SKILL.md) | +| [`test-scope-routing`](test-scope-routing/) | Route validation by change risk and affected boundaries instead of defaulting to the full test suite. | `0.1.0` | [SKILL.md](test-scope-routing/SKILL.md) | ## Install @@ -101,8 +104,26 @@ The skill preserves visible messages and tool history. It does not delete the wh │ ├── evals/ │ ├── VERSION │ ├── CHANGELOG.md +│ ├── CONTRIBUTING.md │ ├── SECURITY.md │ └── LICENSE +├── public-release-gate/ +│ ├── SKILL.md +│ ├── agents/ +│ └── VERSION +├── public-repo-git-gate/ +│ ├── SKILL.md +│ ├── agents/ +│ ├── assets/ +│ ├── references/ +│ ├── scripts/ +│ ├── tests/ +│ └── VERSION +├── test-scope-routing/ +│ ├── SKILL.md +│ ├── agents/ +│ ├── references/ +│ └── VERSION ├── LICENSE └── README.md ~~~ diff --git a/README.zh-CN.md b/README.zh-CN.md index 86523b4..e8c69a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,13 +15,16 @@ ## 这是一个什么仓库 -这是一个面向 AI agent 和开发者工作流的自包含 Skill 集合。每个 Skill 都位于独立的 kebab-case 目录中,并包含自己的指令、脚本、测试、文档、许可证和版本元数据。不同 Skill 可以只支持某一个 agent,也可以跨多个 agent 使用;具体兼容性请以各 Skill 的 README 和 `SKILL.md` 为准。 +这是一个面向 AI agent 和开发者工作流的自包含 Skill 集合。每个 Skill 都位于独立的 kebab-case 目录中,并包含自己的 `SKILL.md`;脚本、测试、references、文档、许可证和版本元数据按需要加入。不同 Skill 可以只支持某一个 agent,也可以跨多个 agent 使用;具体兼容性请以各 Skill 的 `SKILL.md` 和直接引用的资源为准。 -当前集合中的第一个 Skill 专门用于 Codex:它可以修复切换模型供应商、导入或分叉旧会话后无法继续的 Codex Desktop 对话。 +当前集合包含一个 Codex 会话修复 Skill、公开仓库/Release 闸门,以及一个跨框架的测试范围路由 Skill。 | Skill | 用途 | 版本 | 文档 | | --- | --- | --- | --- | -| [`codex-cross-provider-session-repair`](codex-cross-provider-session-repair/) | 在不删除 Codex 目录的前提下,诊断并修复供应商不一致,以及远程上下文压缩出现 `404 Item with id 'rs_...' not found` 的问题。 | `0.4.0` | [English](codex-cross-provider-session-repair/README.md) · [简体中文](codex-cross-provider-session-repair/README.zh-CN.md) | +| [`codex-cross-provider-session-repair`](codex-cross-provider-session-repair/) | 在不删除 Codex 目录的前提下,诊断并修复供应商不一致,以及远程上下文压缩出现 `404 Item with id 'rs_...' not found` 的问题。 | `0.7.5` | [English](codex-cross-provider-session-repair/README.md) · [简体中文](codex-cross-provider-session-repair/README.zh-CN.md) | +| [`public-release-gate`](public-release-gate/) | 审核公开仓库的 Release、最终产物、第三方许可证、部署响应头和 GitHub Release 附件。 | `0.1.0` | [SKILL.md](public-release-gate/SKILL.md) | +| [`public-repo-git-gate`](public-repo-git-gate/) | 覆盖 commit、push 和 Pull Request 的公开内容、分支、远程仓库和 PR 状态检查。 | `0.1.0` | [SKILL.md](public-repo-git-gate/SKILL.md) | +| [`test-scope-routing`](test-scope-routing/) | 根据改动风险和受影响边界选择验证范围,避免默认运行全量测试。 | `0.1.0` | [SKILL.md](test-scope-routing/SKILL.md) | ## 安装 @@ -101,8 +104,26 @@ cd skills/codex-cross-provider-session-repair │ ├── evals/ │ ├── VERSION │ ├── CHANGELOG.md +│ ├── CONTRIBUTING.md │ ├── SECURITY.md │ └── LICENSE +├── public-release-gate/ +│ ├── SKILL.md +│ ├── agents/ +│ └── VERSION +├── public-repo-git-gate/ +│ ├── SKILL.md +│ ├── agents/ +│ ├── assets/ +│ ├── references/ +│ ├── scripts/ +│ ├── tests/ +│ └── VERSION +├── test-scope-routing/ +│ ├── SKILL.md +│ ├── agents/ +│ ├── references/ +│ └── VERSION ├── LICENSE ├── README.md └── README.zh-CN.md diff --git a/codex-cross-provider-session-repair/CHANGELOG.md b/codex-cross-provider-session-repair/CHANGELOG.md index 20d5e3e..18b9a2d 100644 --- a/codex-cross-provider-session-repair/CHANGELOG.md +++ b/codex-cross-provider-session-repair/CHANGELOG.md @@ -2,6 +2,98 @@ All notable changes to this skill are documented here. +## [0.7.5] - 2026-08-15 + +### Fixed + +- Close the dedicated Terminal repair window after the final Enter prompt. The + previous implementation sent `close` to a Terminal tab object, which macOS + Terminal rejects with error `-1708`; the new path matches the window title + and closes it only when it contains one tab. + +## [0.7.4] - 2026-08-15 + +### Fixed + +- Give each macOS repair tab a session-specific custom title and close only + that tab after the final Enter prompt, instead of relying on the Terminal + profile's completed-window behavior. +- Use Terminal's raw `do script` event for launcher compatibility and keep a + shell-exit fallback when Terminal automation is unavailable. + +## [0.7.3] - 2026-08-15 + +### Fixed + +- Make the macOS Terminal launcher exit its outer shell after the final Enter + prompt, so the Terminal tab/window actually closes instead of leaving an + idle shell open. + +## [0.7.2] - 2026-08-14 + +### Changed + +- Reworked the macOS Terminal launcher to use a readable 120×36 Terminal + tab/window and run + the worker through a short temporary runner, so long shell arguments no + longer wrap into an unreadable block. +- Added a separated bilingual header and final result panel around the worker + output. + +## [0.7.1] - 2026-08-14 + +### Added + +- Added a bilingual wait-limit announcement and reminders at approximately 60, + 180, and 240 seconds during the default 300-second Codex shutdown wait. +- Added `wait_timeout_seconds`, `wait_phase`, `conversation_notice`, and + `wait_timed_out` status fields so the current conversation and Terminal can + explain the same state. +- Added an explicit timeout message that says no files were changed and Codex + must remain closed before retrying. + +## [0.7.0] - 2026-08-13 + +### Added + +- Added `scripts/start_repair.py` as the recommended consent-to-repair entry + point. It opens a visible Terminal on macOS, keeps the final result visible, + and safely handles Codex being closed before or after the worker starts. +- Added bilingual terminal and JSON status fields, including + `Verified / 已验证`, `can_reopen`, and `next_action`. +- Added explicit support for starting the approved repair job after Codex is + already stopped, while retaining a stable process-free safety window. + +## [0.6.0] - 2026-08-12 + +### Added + +- Added target-scoped repair for sessions pinned to an unsupported saved model, + including structured rollout settings and the root `threads.model` snapshot. +- Added `--remove-reasoning none` to the process-aware wrapper and independent + verification that model repairs agree in both JSONL and SQLite. +- Added detection for the bundled `codex` app-server and Codex Framework service + processes, which can keep the rollout and SQLite WAL open after the main window exits. + +## [0.5.0] - 2026-08-10 + +### Added + +- Added `scripts/wait_and_repair.py`, a process-aware wrapper that waits for a + stable Codex Desktop shutdown, detects rollout races, applies the existing + target-scoped repair, and independently verifies the result. +- Added atomic JSON status reporting with `diagnosed`, `waiting`, `stopped`, + `applying`, `verified`, and `failed` states. +- Added offline tests for process restart races, rollout changes, status files, + and end-to-end apply/verify behavior. + +### Changed + +- Made the documented order explicit: quit Codex before writing, and reopen it + only after verification. +- Labeled log-derived stale IDs as historical so they are not confused with + local reasoning items that would still be submitted. + ## [0.3.0] - 2026-08-03 ### Added diff --git a/codex-cross-provider-session-repair/README.md b/codex-cross-provider-session-repair/README.md index f4e599b..6cdc660 100644 --- a/codex-cross-provider-session-repair/README.md +++ b/codex-cross-provider-session-repair/README.md @@ -1,15 +1,16 @@ # Codex Cross-Provider Session Repair -[![Validate skills](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml/badge.svg)](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml) [![Version 0.4.0](https://img.shields.io/badge/version-0.4.0-2563eb.svg)](VERSION) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Validate skills](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml/badge.svg)](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml) [![Version 0.7.5](https://img.shields.io/badge/version-0.7.5-2563eb.svg)](VERSION) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) `codex-cross-provider-session-repair` is a small, backup-first Codex Skill for recovering an old Codex Desktop conversation after a provider switch, import, or fork. [English](README.md) · [简体中文](README.zh-CN.md) · [Install](#install) · [Usage](#usage) · [Development](#development) · [Versioning and upgrades](#versioning-and-upgrades) · [Security](SECURITY.md) · [Changelog](CHANGELOG.md) -It addresses two failure modes that look similar in the UI: +It addresses three failure modes that look similar in the UI: -1. the saved session or its root `state_5.sqlite` thread row still points at an unavailable provider; and -2. remote context compaction returns `404 Item with id 'rs_...' not found` because local reasoning items were sent with `store=false` and were never persisted by the service. +1. the saved session or its root `state_5.sqlite` thread row still points at an unavailable provider; +2. remote context compaction returns `404 Item with id 'rs_...' not found` because local reasoning items were sent with `store=false` and were never persisted by the service; and +3. an imported session is pinned to a model unsupported by the current ChatGPT account, such as `ark-code-latest`. The repair is session-scoped. It never performs a global provider replacement or deletes the Codex home. @@ -89,7 +90,7 @@ python .\scripts\repair.py ` --codex-home "$env:USERPROFILE\.codex" ``` -For a repeated remote-compaction 404 where the report maps stale IDs to local reasoning records: +For a repeated remote-compaction 404 where the report maps stale IDs to local reasoning records, fully quit Codex Desktop first: ```powershell python .\scripts\repair.py ` @@ -98,6 +99,53 @@ python .\scripts\repair.py ` --remove-reasoning all --apply ``` +Run the same command without `--apply` afterward. The decisive verification +fields are `Stale IDs present as local reasoning: []` and +`Local reasoning records: 0`. Log-derived IDs are labeled as historical and may +remain in the report. + +Recommended user-facing run: after the user approves the repair, start this +command immediately. On macOS it opens a visible Terminal window, shows the +final result, and closes the uniquely titled repair window after the user +presses Enter: + +```bash +python3 scripts/start_repair.py \ + --session-id \ + --codex-home "$HOME/.codex" \ + --remove-reasoning stale +``` + +The worker can safely start before or after Codex is closed. The current +conversation should immediately tell the user that the repair may wait up to +300 seconds and that Codex must be fully quit. The Terminal announces the wait +limit and reminds the user at about 60, 180, and 240 seconds. On macOS it opens +a readable 120-column by 36-row Terminal tab/window and hides the long worker +command behind a short temporary runner. It shows +bilingual states such as `Waiting / 等待中`, `Applying / 修复中`, +`Verified / 已验证`, and `Failed / 失败`. Only `Verified / 已验证` means it is +safe to reopen Codex; after pressing Enter on the final prompt, the launcher +closes the exact repair window identified by the session ID when it contains +only the repair tab. If Terminal automation permission is unavailable, the +shell still exits and the finished window can be closed with Command+W. A +timeout explicitly says that no files were changed. + +For advanced use, run the lower-level worker from an independent Terminal while +Codex Desktop is still open: + +```bash +python3 scripts/wait_and_repair.py \ + --session-id \ + --codex-home "$HOME/.codex" \ + --remove-reasoning all \ + --status-file "/tmp/codex-session-repair-.json" +``` + +Then fully quit Codex Desktop. The wrapper announces the configured wait limit, +prints periodic reminders, waits for a stable process-free window, refuses to +write if Codex reappears or the rollout changes, and ends with +`Verified / 已验证` only after the repair and a second diagnostic succeed. + For a provider mismatch, pass the known current provider and opt into the target-only database/session update: ```powershell @@ -107,9 +155,28 @@ python .\scripts\repair.py ` --provider custom --fix-provider --apply ``` -The command is dry-run unless `--apply` is present. Every apply run creates a timestamped JSONL backup. A database backup is created only when the target `threads.model_provider` row changes; WAL/SHM sidecars are copied alongside it. +The command is dry-run unless `--apply` is present. Every apply run creates a timestamped JSONL backup. A database backup is created only when the target `threads.model_provider` row changes; WAL/SHM sidecars are copied alongside it. Do not treat a background process exit as proof of completion unless a backup and `verified` status are visible. + +For an unsupported saved model, align the target rollout settings and root +thread snapshot with the current configured model. Use `none` explicitly so +this repair preserves local reasoning records: + +```bash +python3 scripts/repair.py \ + --session-id \ + --codex-home "$HOME/.codex" \ + --model --fix-model \ + --remove-reasoning none --apply +``` + +The command does not change the global `config.toml` or unrelated sessions. +Verification must show the target `threads.model` and structured rollout model +settings using the requested model, plus the new JSONL/database backups. -After applying, fully quit Codex Desktop—including the tray process—and relaunch it. A page refresh does not unload the cached rollout. Test with a short confirmation prompt before resuming the original project task. +After the manual apply and verification, relaunch Codex Desktop. For the +process-aware wrapper, it is already safe to relaunch once the status reaches +`verified`. A page refresh does not unload the cached rollout. Test with a +short confirmation prompt before resuming the original project task. To build a portable `.skill` archive without extra dependencies: @@ -124,6 +191,8 @@ The repair tool has no third-party runtime dependencies: ```bash python -m unittest discover -s tests -v python scripts/repair.py --help +python scripts/wait_and_repair.py --help +python scripts/start_repair.py --help ``` The `evals/evals.json` file contains realistic trigger and behavior prompts. The `evals` directory is intentionally excluded from distributable `.skill` packages by the standard skill packager. diff --git a/codex-cross-provider-session-repair/README.zh-CN.md b/codex-cross-provider-session-repair/README.zh-CN.md index 7f5e69a..32c6cf9 100644 --- a/codex-cross-provider-session-repair/README.zh-CN.md +++ b/codex-cross-provider-session-repair/README.zh-CN.md @@ -1,15 +1,16 @@ # Codex 跨供应商旧会话修复 -[![Validate skills](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml/badge.svg)](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml) [![版本 0.4.0](https://img.shields.io/badge/version-0.4.0-2563eb.svg)](VERSION) [![MIT 许可证](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Validate skills](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml/badge.svg)](https://github.com/WardLu/skills/actions/workflows/validate-skills.yml) [![版本 0.7.5](https://img.shields.io/badge/version-0.7.5-2563eb.svg)](VERSION) [![MIT 许可证](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) `codex-cross-provider-session-repair` 是一个以备份优先为原则的 Codex Skill,用于修复切换模型供应商、导入或分叉旧会话后无法继续的问题。 [English](README.md) · [简体中文](README.zh-CN.md) · [快速安装](#推荐使用-npx-skills) · [使用方法](#使用方法) · [开发测试](#开发测试) · [版本与升级](#版本与升级) · [安全说明](SECURITY.md) · [变更记录](CHANGELOG.md) -它处理两类在界面上很相似的故障: +它处理三类在界面上很相似的故障: 1. 已保存的会话或根目录 `state_5.sqlite` 中的线程仍指向不可用的供应商; -2. 远程上下文压缩返回 `404 Item with id 'rs_...' not found`,原因是本地推理记录以 `store=false` 发送后并未被服务持久化。 +2. 远程上下文压缩返回 `404 Item with id 'rs_...' not found`,原因是本地推理记录以 `store=false` 发送后并未被服务持久化; +3. 导入的会话固定使用当前 ChatGPT 账号不支持的模型,例如 `ark-code-latest`。 修复范围严格限定在指定会话,不会全局替换供应商,也不会删除整个 Codex 目录。 @@ -87,7 +88,7 @@ python .\scripts\repair.py ` --codex-home "$env:USERPROFILE\.codex" ~~~ -当远程压缩反复返回 404,且报告确认错误 ID 对应本地推理记录时: +当远程压缩反复返回 404,且报告确认错误 ID 对应本地推理记录时,请先完全退出 Codex Desktop: ~~~powershell python .\scripts\repair.py ` @@ -96,6 +97,33 @@ python .\scripts\repair.py ` --remove-reasoning all --apply ~~~ +执行完成后,再运行同一条命令但去掉 `--apply`。真正决定是否成功的是: +`Stale IDs present as local reasoning: []` 和 +`Local reasoning records: 0`。报告里的历史日志 ID 可能仍然存在,它们会被明确标记为历史记录。 + +推荐给普通用户的流程是:用户同意修复后,立即启动下面这条命令。macOS 会自动打开一个可见的「终端」窗口,显示最终结果;用户按回车后,启动器会按会话 ID 关闭本次修复的窗口: + +~~~bash +python3 scripts/start_repair.py \ + --session-id \ + --codex-home "$HOME/.codex" \ + --remove-reasoning stale +~~~ + +启动后,当前会话应立即提醒用户:“修复任务已启动,最多等待 300 秒;请完全退出 Codex。”用户可以直接退出 Codex,不需要再回复“已退出”。如果 Codex 已经退出,这条命令也能继续执行安全检查。macOS 会打开一个可读的 120 列、36 行 Terminal 标签页/窗口,并通过短 runner 隐藏很长的命令参数;屏幕只显示分段状态。终端会先显示等待上限,并在约 60、180、240 秒再次提醒;状态使用双语:`Waiting / 等待中`、`Applying / 修复中`、`Verified / 已验证`、`Failed / 失败`。只有看到 `Verified / 已验证` 才能重新打开 Codex。最终按回车后,启动器会关闭带有该会话 ID 且只包含修复标签页的目标窗口;如果 macOS 尚未授予 Terminal 自动化权限,脚本会退出,但用户需要手动按 `Command+W` 关闭已完成的窗口。超时会明确说明未修改文件,并要求保持 Codex 关闭。 + +高级用户也可以在 Codex Desktop 仍打开时,从独立的「终端」启动底层等待器: + +~~~bash +python3 scripts/wait_and_repair.py \ + --session-id \ + --codex-home "$HOME/.codex" \ + --remove-reasoning all \ + --status-file "/tmp/codex-session-repair-.json" +~~~ + +然后完全退出 Codex Desktop。该工具会显示配置的最长等待时间,并在等待期间周期提醒;如果 Codex 重新出现,等待超过上限,或等待期间会话文件发生变化,就会停止写入并显示 `Failed / 失败`。只有修复和二次诊断都通过后才会显示 `Verified / 已验证`。 + 如果是供应商不一致,请传入当前供应商,并显式启用仅目标会话的更新: ~~~powershell @@ -105,9 +133,22 @@ python .\scripts\repair.py ` --provider custom --fix-provider --apply ~~~ -不带 `--apply` 时始终是预览模式。每次应用修复都会创建带时间戳的 JSONL 备份;只有目标 `threads.model_provider` 行发生变化时才会备份数据库,并同时复制 WAL/SHM 伴随文件。 +不带 `--apply` 时始终是预览模式。每次应用修复都会创建带时间戳的 JSONL 备份;只有目标 `threads.model_provider` 行发生变化时才会备份数据库,并同时复制 WAL/SHM 伴随文件。不要只依据后台进程退出或返回码判断成功,必须看到备份和 `verified` 状态。 + +如果是会话保存的模型不受当前账号支持,请把目标会话的结构化模型设置和根目录线程快照对齐到当前模型。显式使用 `none`,确保不会删除本地推理记录: + +~~~bash +python3 scripts/repair.py \ + --session-id \ + --codex-home "$HOME/.codex" \ + --model --fix-model \ + --remove-reasoning none --apply +~~~ + +该命令不会修改全局 `config.toml`,也不会影响其他会话。验证时应确认目标 `threads.model`、JSONL 中的结构化模型设置都已变为指定模型,并且生成了新的 JSONL/数据库备份。 -应用修复后,请完全退出 Codex Desktop(包括托盘进程)并重新启动。刷新页面不会清除桌面进程缓存的会话事件流。先发送一条简短的确认提示,确认成功后再继续原项目任务。 +手动执行并验证完成后,再重新启动 Codex Desktop;使用等待器时,状态变为 +`verified` 后即可重开。刷新页面不会清除桌面进程缓存的会话事件流。先发送一条简短的确认提示,确认成功后再继续原项目任务。 无需额外依赖即可生成可分发的 `.skill` 压缩包: @@ -122,6 +163,8 @@ python scripts/package.py --output ./dist ~~~bash python -m unittest discover -s tests -v python scripts/repair.py --help +python scripts/wait_and_repair.py --help +python scripts/start_repair.py --help ~~~ `evals/evals.json` 包含真实的触发条件和行为提示词。标准 Skill 打包器不会把 `evals` 目录放入可分发的 `.skill` 包。 diff --git a/codex-cross-provider-session-repair/SKILL.md b/codex-cross-provider-session-repair/SKILL.md index d696a81..59ac59e 100644 --- a/codex-cross-provider-session-repair/SKILL.md +++ b/codex-cross-provider-session-repair/SKILL.md @@ -2,26 +2,30 @@ name: codex-cross-provider-session-repair description: Repair Codex Desktop sessions that fail after switching model providers or importing/forking old conversations. Use this skill whenever a user mentions an old Codex session becoming invalid, “model provider not found”, “Codex cannot load config.toml”, remote context compaction 404s, “Item with id rs_... not found”, repeated reconnecting during resume, or a migrated Codex conversation that cannot continue—even when the user only asks to inspect or explain the failure. Diagnose the target session across its JSONL rollout, root state_5.sqlite, config.toml, and logs_2.sqlite, then make a target-scoped backup-first repair with verification and restart instructions. license: MIT -compatibility: Requires Python 3.9+ with the standard library; Windows, macOS, and Linux Codex homes are supported. Stop Codex Desktop before applying file or database changes. metadata: - version: "0.4.0" + version: "0.7.5" repository: "https://github.com/WardLu/skills/tree/main/codex-cross-provider-session-repair" maintainer: "Ward Lu" --- # Codex cross-provider session repair +Compatibility: Requires Python 3.9+ with the standard library; Windows, macOS, +and Linux Codex homes are supported. Stop Codex Desktop before applying file +or database changes. On macOS, the recommended launcher requires Terminal +automation permission so it can close only its uniquely titled repair window. + Use this skill for a damaged or incompatible *saved Codex session*, not for ordinary model/API troubleshooting. The goal is to make one existing conversation resumable while keeping its user-visible history and avoiding broad cache deletion. ## User interaction flow (end-to-end) The user does not need to memorise CLI flags. A typical session looks like this: -1. **User opens a *new* Codex conversation** (the broken one cannot be used) and says something like: "会话 019fc2cb-5370-7d32-899c-89310a4e370a 打不开了,切换模型后报错" or "用 codex-cross-provider-session-repair 修复会话 ". +1. **User opens a *new* Codex conversation** (the broken one cannot be used) and provides the session UUID and visible error. 2. **Agent runs the dry-run diagnostic** (see Workflow below). The report shows which failure mode applies and what will be changed. -3. **Agent explains the root cause in one sentence** and asks the user to approve the write (the script writes inside `~/.codex`, which requires elevated permissions in sandboxed mode). -4. **Agent runs `--apply`** with the appropriate flags. A timestamped backup is created automatically. -5. **Agent tells the user to fully quit and relaunch Codex Desktop** (Cmd+Q on macOS), then open the repaired session and send a short test message. +3. **Agent explains the root cause and gets approval for the write.** After approval, start `scripts/start_repair.py` immediately. On macOS it opens a readable Terminal tab/window and runs the independent worker through a short temporary runner; do not paste the full worker command into the user's shell. The worker is safe whether Codex is still open, is being closed, or was already closed after the approval. Immediately tell the user in the current conversation: `修复任务已启动,最多等待 300 秒;请完全退出 Codex。终端会持续显示进度,完成前不要重新打开。 / Repair started; it may wait up to 300 seconds. Fully quit Codex; follow the Terminal status and do not reopen it before completion.` +4. **Do not reopen Codex until verification says `Verified / 已验证`.** The Terminal shows bilingual states: `Waiting / 等待中`, `Applying / 修复中`, `Verified / 已验证`, or `Failed / 失败`. During the default 300-second wait it prints reminders at about 60, 180, and 240 seconds. The status JSON also exposes `wait_timeout_seconds`, `wait_phase`, `conversation_notice`, and `can_reopen`; only `can_reopen: true` permits reopening. If the current conversation remains available, mirror the wait/reminder status there; after Codex is fully closed, the independent Terminal and status JSON are authoritative. +5. **After verification, relaunch Codex Desktop** (Cmd+Q/reopen on macOS), open the repaired session, and send a short test message. ### Finding the session ID @@ -46,13 +50,15 @@ If the user does not know the UUID, help them find it: ### One-shot repair command -When the user provides a session ID and the dry-run confirms the failure mode, the agent can combine all needed flags in a single `--apply` invocation: +After Codex Desktop is fully quit, the manual one-shot command can combine all +needed flags in a single `--apply` invocation: ```text -python3 /scripts/repair.py --session-id --fix-provider --provider --fix-model-turn --remove-reasoning stale --apply +python3 /scripts/repair.py --session-id --codex-home --fix-provider --provider --fix-model-turn --remove-reasoning all --apply ``` -The script creates a backup before writing, so it is safe to combine flags. +Run the same command without `--apply` afterward. Do not report completion until +the post-repair report has been captured and verified. ## What this skill fixes @@ -81,11 +87,20 @@ approval) to write `remote_compaction_v2 = false` under `[features]` in config.toml with a backup; after applying, fully quit and relaunch Codex Desktop. +A fifth failure occurs when an imported session is pinned to a model that the +current ChatGPT account cannot use, such as `ark-code-latest`. The report +identifies the model in the rollout's structured turn settings and the target +`threads.model` snapshot. Use `--fix-model --model ` to update +only those target-session values. Pair it with `--remove-reasoning none` when +the session has no stale-reasoning diagnosis; this preserves all local +reasoning records. + ## Safety contract - Work on the requested session ID only. Do not update every thread or every session by default. - Start with a dry run. Before any write, make a timestamped backup of the JSONL; back up the root SQLite database (including WAL/SHM sidecars) only when changing its provider row. -- Ask the user to fully quit Codex Desktop, including the tray process, before applying changes. A live process can cache old events or hold a file lock. +- Ask the user to fully quit Codex Desktop, including the tray process and its bundled `codex app-server`/service processes, before applying changes. A live process can cache old events or hold a file lock. +- Never treat a task-bound background waiter, a closed tool session, or a returned process exit code without a backup as proof that a repair ran. The independent wait wrapper must end in `verified`. - Never delete the whole Codex home, all sessions, `config.toml`, auth tokens, caches, or databases as a generic “reset”. - Preserve every user message, visible assistant message, tool call, tool result, and `event_msg`. Only remove `response_item` records whose payload type is `reasoning`, and only when the diagnosis supports the stale-compaction repair. - Do not print auth tokens, refresh tokens, API keys, or complete session contents. Redact paths and secrets in reports. @@ -130,6 +145,19 @@ python scripts/repair.py --session-id --codex-home \ Update only the target session's `session_meta` payload and the target `threads` row. Never run a global `UPDATE ... WHERE model_provider=...` for a shared Codex home. +**Unsupported saved model.** If the report shows a model such as +`ark-code-latest` that the current account does not support, align only the +target rollout settings and root thread snapshot with the current configured +model: + +```text +python scripts/repair.py --session-id --codex-home \ + --model --fix-model --remove-reasoning none --apply +``` + +This creates a JSONL backup and a database backup when the target row changes. +It does not delete reasoning records or alter the global `config.toml`. + **Stale remote compaction.** If logs show `Item with id 'rs_...' not found` and the IDs map to local `response_item` records with `payload.type == "reasoning"`, use: ```text @@ -148,6 +176,43 @@ python scripts/repair.py --session-id --codex-home \ Provider repair and reasoning cleanup can be combined in one invocation after the dry-run report confirms both conditions. +For the recommended user-facing execution, start this command immediately after +the user approves the repair. On macOS it opens a visible Terminal window, +shows the final result, and closes its uniquely titled repair window after the +user presses Enter: + +```text +python3 /scripts/start_repair.py --session-id \ + --codex-home --remove-reasoning stale +``` + +The command passes `--allow-already-stopped` to the worker, so the user may +quit Codex before or after the worker starts. The worker still requires a +stable process-free window and a rollout fingerprint match before writing. +Terminal output and the status file use bilingual labels. The Terminal opens a +120-column by 36-row window, hides the long worker command behind a temporary +runner, and first announces the maximum wait, then reminds the user at about +60, 180, and 240 seconds. `Verified / 已验证` means it is safe to reopen; `Failed / 失败` +means keep Codex closed. The launcher tags the repair window with the session +ID; after the final Enter prompt, it closes that exact window when it contains +only the repair tab and exits its shell. If macOS has not granted Terminal +automation permission, the worker still exits normally and the user can close +the finished window with Command+W. A timeout writes `wait_timed_out: true` +and a conversation-ready next-step message without changing the session. + +For advanced use, start the lower-level process-aware worker from a Terminal +that is independent of Codex while Codex is still open: + +```text +python3 /scripts/wait_and_repair.py --session-id --codex-home --remove-reasoning all --status-file /tmp/codex-session-repair-.json +``` + +Then fully quit Codex Desktop. The wrapper prints bilingual status labels and +atomically updates the same status file. It announces the configured wait +limit and emits periodic reminders. If Codex reappears during the stability +window or the rollout changes while waiting, it stops without writing and +reports `Failed / 失败`. + **Gemini model-turn compaction.** If the dry-run report shows `Model-turn compaction: RISK` or `logged-error`, the effective history ends with an assistant message and Gemini compaction will fail. Use: ```text @@ -164,11 +229,18 @@ Run the script again without `--apply`, or use its `--verify` output. Confirm: - every remaining JSONL line parses; - zero stale IDs remain as local reasoning items; - provider values agree with the chosen current provider when provider repair was requested; +- the target `threads.model` and all structured rollout model settings agree with the chosen model when model repair was requested; - the backup path is reported; and +- a new backup was created by this apply, not just a zero exit code; and - no unrelated session was changed; - the `Model-turn compaction` line shows `ok` (not `RISK`) and `last_role=user` when `--fix-model-turn` was applied. -The raw `rs_...` text may still occur inside a historical `task_complete` error event. That text is not a `response_item` submitted for compaction and does not need to be deleted. +The report labels log-derived IDs as `Historical remote stale IDs (from logs)`. +That list may remain after a successful repair because it is historical evidence. +The decisive fields are `Stale IDs present as local reasoning: []` together with +`Local reasoning records: 0`. The raw `rs_...` text may still occur inside a +historical `task_complete` error event; that text is not a `response_item` +submitted for compaction and does not need to be deleted. ### 5. Reload and test @@ -181,7 +253,10 @@ If compaction succeeds and the error changes to authentication, transport, or pr Install this skill with `npx skills add WardLu/skills --skill codex-cross-provider-session-repair --global --agent codex --yes`, or use the platform installer documented in `README.md`. - `scripts/repair.py` — deterministic, backup-first diagnosis and target-scoped repair. +- `scripts/start_repair.py` — recommended consent-to-terminal launcher; opens a readable 120×36 Terminal tab/window on macOS, hides the long worker command behind a temporary runner, and starts the worker even when Codex was already closed. +- `scripts/wait_and_repair.py` — process-aware wait/apply/verify wrapper for use from an independent Terminal. It fails closed if Codex was not detected initially, reappears during the stability window, or changes the rollout while waiting. - `tests/test_repair.py` — offline tests using temporary fake Codex homes and SQLite databases. +- `tests/test_wait_and_repair.py` — offline tests for process lifecycle, rollout race protection, status files, and end-to-end verification. - `README.md` — installation, upgrade, release, and troubleshooting guide. ## Documentation @@ -190,4 +265,4 @@ The English installation and maintenance guide is `README.md`; Simplified Chines ## Report format -Give the user a concise result with: root cause, exact target session, files changed, backup paths, verification counts, whether an independent smoke test reached `Context compacted`, and the one required restart step. Never include credentials or full JSONL lines. +Give the user a concise result with: root cause, exact target session, files changed, backup paths, verification counts, whether the independent process-aware run reached `verified`, whether an independent smoke test reached `Context compacted`, and the one required restart step. When the wait ends in a timeout, explicitly state that the configured wait limit was reached, no files were changed, and the user must fully quit Codex before retrying. Never include credentials or full JSONL lines. diff --git a/codex-cross-provider-session-repair/VERSION b/codex-cross-provider-session-repair/VERSION index 1d0ba9e..8bd6ba8 100644 --- a/codex-cross-provider-session-repair/VERSION +++ b/codex-cross-provider-session-repair/VERSION @@ -1 +1 @@ -0.4.0 +0.7.5 diff --git a/codex-cross-provider-session-repair/scripts/repair.py b/codex-cross-provider-session-repair/scripts/repair.py index 7cda978..73e2b34 100755 --- a/codex-cross-provider-session-repair/scripts/repair.py +++ b/codex-cross-provider-session-repair/scripts/repair.py @@ -312,6 +312,34 @@ def effective_last_message_after_rollback_removal(records: list[dict[str, Any] | return None +def read_structured_models(records: list[dict[str, Any] | None]) -> list[str]: + """Collect model values from structured per-turn settings in the rollout.""" + models: list[str] = [] + for record in records: + if not record or record.get("type") != "event_msg": + continue + payload = record.get("payload") or {} + event_type = payload.get("type") + if event_type == "turn_context" and isinstance(payload.get("model"), str): + models.append(payload["model"]) + continue + if event_type != "thread_settings_applied": + continue + settings = payload.get("thread_settings") + if not isinstance(settings, dict): + continue + if isinstance(settings.get("model"), str): + models.append(settings["model"]) + collaboration = settings.get("collaboration_mode") + if isinstance(collaboration, dict): + collaboration_settings = collaboration.get("settings") + if isinstance(collaboration_settings, dict) and isinstance( + collaboration_settings.get("model"), str + ): + models.append(collaboration_settings["model"]) + return models + + def inspect_session(codex_home: Path, session_id: str) -> dict[str, Any]: rollout = find_rollout(codex_home, session_id) lines, records, parse_errors = read_jsonl(rollout) @@ -353,6 +381,7 @@ def inspect_session(codex_home: Path, session_id: str) -> dict[str, Any]: "stale_ids_present_as_reasoning": sorted(set(stale_ids) & reasoning_set), "reasoning_item_count": len(reasoning_ids), "reasoning_item_ids": reasoning_ids, + "structured_model_values": read_structured_models(records), "child_database_present": (codex_home / "sqlite" / "state_5.sqlite").is_file(), "model_turn": model_turn_info, "remote_compaction": { @@ -378,29 +407,36 @@ def rewrite_session( fix_provider: bool, remove_reasoning: str, fix_model_turn: bool = False, -) -> tuple[Path | None, int, int, int]: + fix_model: bool = False, + model: str | None = None, +) -> tuple[Path | None, int, int, int, int]: """Rewrite session JSONL. - Returns ``(backup_path, removed_reasoning, provider_updates, model_turn_inserts)``. + Returns ``(backup_path, removed_reasoning, provider_updates, model_turn_inserts, model_updates)``. When *fix_model_turn* is set the function also strips ``thread_rolled_back`` events so that previously rolled-back turns become effective again. If the last response_item message is still an assistant turn after that, a dummy user message is appended to satisfy Gemini's requirement that requests must - not end with a model turn. + not end with a model turn. When *fix_model* is set, structured model + settings in the target rollout are changed to *model*. """ path = Path(report["rollout_path"]) lines, records, parse_errors = read_jsonl(path) if parse_errors: raise ValueError(f"refusing to rewrite malformed JSONL; lines: {parse_errors[:10]}") + if fix_model and not model: + raise ValueError("fix_model requires a target model") + stale = set(report["stale_ids_present_as_reasoning"]) - needs_write = remove_reasoning != "none" or fix_provider or fix_model_turn + needs_write = remove_reasoning != "none" or fix_provider or fix_model_turn or fix_model backup = backup_file(path, "session-repair") if needs_write else None output: list[str] = [] removed = 0 provider_updates = 0 model_turn_inserts = 0 + model_updates = 0 rollbacks_removed = 0 # When fixing model-turn, find the last effective response_item message @@ -445,6 +481,32 @@ def rewrite_session( provider_updates += 1 output.append(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") continue + if fix_model and model and record.get("type") == "event_msg": + model_changed = False + event_type = payload.get("type") + if event_type == "thread_settings_applied": + settings = payload.get("thread_settings") + if isinstance(settings, dict): + if settings.get("model") != model: + settings["model"] = model + model_changed = True + collaboration = settings.get("collaboration_mode") + if isinstance(collaboration, dict): + collaboration_settings = collaboration.get("settings") + if ( + isinstance(collaboration_settings, dict) + and collaboration_settings.get("model") != model + ): + collaboration_settings["model"] = model + model_changed = True + elif event_type == "turn_context" and payload.get("model") != model: + payload["model"] = model + model_changed = True + if model_changed: + record["payload"] = payload + model_updates += 1 + output.append(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") + continue output.append(original) # Insert a user message right after the last assistant message so @@ -476,7 +538,7 @@ def rewrite_session( model_turn_inserts += 1 if not needs_write: - return None, 0, 0, 0 + return None, 0, 0, 0, 0 temporary = path.with_name(f".{path.name}.repair-{os.getpid()}.tmp") with temporary.open("w", encoding="utf-8", newline="") as handle: @@ -491,7 +553,7 @@ def rewrite_session( raise if rollbacks_removed: print(f"Removed {rollbacks_removed} thread_rolled_back event(s) to restore effective turns.") - return backup, removed, provider_updates, model_turn_inserts + return backup, removed, provider_updates, model_turn_inserts, model_updates def update_thread_provider(codex_home: Path, session_id: str, provider: str) -> Path | None: @@ -521,6 +583,32 @@ def update_thread_provider(codex_home: Path, session_id: str, provider: str) -> return backup +def update_thread_model(codex_home: Path, session_id: str, model: str) -> Path | None: + """Update only the target thread's model snapshot, with a database backup.""" + database = codex_home / "state_5.sqlite" + row = read_thread_row(database, session_id) + if not row or row.get("error") or "model" not in row: + return None + if row["model"] == model: + return None + backup = backup_file(database, "session-model-repair") + for suffix in ("-wal", "-shm"): + sidecar = Path(str(database) + suffix) + if sidecar.is_file(): + shutil.copy2(sidecar, Path(str(backup) + suffix)) + connection = sqlite3.connect(database) + try: + connection.execute("BEGIN IMMEDIATE") + connection.execute("UPDATE threads SET model = ? WHERE id = ?", (model, session_id)) + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + return backup + + def disable_remote_compaction(codex_home: Path) -> Path | None: """Add ``remote_compaction_v2 = false`` under ``[features]`` in config.toml. @@ -576,7 +664,7 @@ def print_report(report: dict[str, Any], as_json: bool) -> None: print(f"Global provider: {safe['global_provider'] or '(not found)'}") print(f"Target session_meta: {safe['target_session_meta']}") print(f"Root threads row: {safe['root_thread'] or '(not found)'}") - print(f"Remote stale IDs: {safe['stale_remote_item_ids']}") + print(f"Historical remote stale IDs (from logs): {safe['stale_remote_item_ids']}") print(f"Stale IDs present as local reasoning: {safe['stale_ids_present_as_reasoning']}") print(f"Local reasoning records: {safe['reasoning_item_count']}") print(f"Child sqlite database present: {safe['child_database_present']}") @@ -611,6 +699,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--codex-home", help="Override CODEX_HOME") parser.add_argument("--provider", help="Current provider to write when --fix-provider is used") parser.add_argument("--fix-provider", action="store_true", help="Repair target session_meta and DB provider") + parser.add_argument("--model", help="Target model to write when --fix-model is used") + parser.add_argument("--fix-model", action="store_true", help="Repair target model settings and DB model") parser.add_argument( "--remove-reasoning", choices=("none", "stale", "all"), @@ -647,6 +737,8 @@ def main(argv: Iterable[str] | None = None) -> int: raise ValueError("refusing to apply: JSONL has parse errors") if args.fix_provider and not args.provider: raise ValueError("--fix-provider requires --provider") + if args.fix_model and not args.model: + raise ValueError("--fix-model requires --model") if args.remove_reasoning == "stale" and not report["stale_ids_present_as_reasoning"]: print("No stale local reasoning IDs matched; no session rewrite needed.") mt = report.get("model_turn", {}) @@ -656,25 +748,38 @@ def main(argv: Iterable[str] | None = None) -> int: removed = 0 provider_updates = 0 model_turn_inserts = 0 - if args.remove_reasoning != "none" or args.fix_provider or args.fix_model_turn: - session_backup, removed, provider_updates, model_turn_inserts = rewrite_session( - report, args.provider, args.fix_provider, args.remove_reasoning, args.fix_model_turn + model_updates = 0 + if args.remove_reasoning != "none" or args.fix_provider or args.fix_model_turn or args.fix_model: + session_backup, removed, provider_updates, model_turn_inserts, model_updates = rewrite_session( + report, + args.provider, + args.fix_provider, + args.remove_reasoning, + args.fix_model_turn, + args.fix_model, + args.model, ) db_backup = None if args.fix_provider and args.provider: db_backup = update_thread_provider(codex_home, args.session_id, args.provider) + model_db_backup = None + if args.fix_model and args.model: + model_db_backup = update_thread_model(codex_home, args.session_id, args.model) config_backup = None if args.disable_remote_compaction: config_backup = disable_remote_compaction(codex_home) print( f"Applied: removed_reasoning={removed}, " f"session_meta_provider_updates={provider_updates}, " - f"model_turn_inserts={model_turn_inserts}" + f"model_turn_inserts={model_turn_inserts}, " + f"model_settings_updates={model_updates}" ) if session_backup: print(f"Session backup: {session_backup}") if db_backup: print(f"Database backup: {db_backup}") + if model_db_backup: + print(f"Model database backup: {model_db_backup}") if args.disable_remote_compaction: if config_backup: print(f"config.toml backup: {config_backup}") diff --git a/codex-cross-provider-session-repair/scripts/start_repair.py b/codex-cross-provider-session-repair/scripts/start_repair.py new file mode 100644 index 0000000..2bb87ee --- /dev/null +++ b/codex-cross-provider-session-repair/scripts/start_repair.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Start a detached, beginner-friendly Codex session repair job. + +This is the user-facing entry point. It opens a visible Terminal window on +macOS, runs the process-aware worker there, and leaves the final status visible +until the user presses Enter; the dedicated Terminal window is then closed. +The worker accepts both "Codex is still open" and "the user already quit +Codex" because starting this command is itself the explicit repair approval. +""" + +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Iterable + +import wait_and_repair + + +def default_status_path(session_id: str) -> Path: + return wait_and_repair.default_status_path(session_id) + + +def build_worker_argv( + *, + skill_dir: Path, + session_id: str, + codex_home: Path, + remove_reasoning: str, + status_file: Path, + timeout: float, + stable_seconds: float, + poll_seconds: float, + provider: str | None = None, + fix_provider: bool = False, + model: str | None = None, + fix_model: bool = False, + fix_model_turn: bool = False, + disable_remote_compaction: bool = False, +) -> list[str]: + argv = [ + sys.executable, + str(skill_dir / "wait_and_repair.py"), + "--session-id", + session_id, + "--codex-home", + str(codex_home), + "--remove-reasoning", + remove_reasoning, + "--allow-already-stopped", + "--status-file", + str(status_file), + "--timeout", + str(timeout), + "--stable-seconds", + str(stable_seconds), + "--poll-seconds", + str(poll_seconds), + ] + if provider: + argv.extend(["--provider", provider]) + if fix_provider: + argv.append("--fix-provider") + if model: + argv.extend(["--model", model]) + if fix_model: + argv.append("--fix-model") + if fix_model_turn: + argv.append("--fix-model-turn") + if disable_remote_compaction: + argv.append("--disable-remote-compaction") + return argv + + +def _argument_value(argv: list[str], option: str, default: str) -> str: + try: + index = argv.index(option) + except ValueError: + return default + if index + 1 >= len(argv): + return default + return str(argv[index + 1]) + + +def build_conversation_notice(timeout: float) -> str: + return f"修复任务已启动,最多等待 {timeout:g} 秒;请完全退出 Codex。 / Repair started; maximum wait is {timeout:g}s; fully quit Codex." + + +def _shell_literal(text: str) -> str: + return "'" + text.replace("'", "'\"'\"'") + "'" + + +def _shell_print(text: str) -> str: + return f"printf '%s\\n' {_shell_literal(text)}" + + +def terminal_tab_title(session_id: str) -> str: + return f"Codex session repair {session_id}" + + +def build_terminal_title_command(session_id: str) -> str: + title = _shell_literal(terminal_tab_title(session_id)) + return f"printf '\\033]0;%s\\007' {title}" + + +def build_terminal_close_applescript(session_id: str) -> str: + marker = json.dumps(terminal_tab_title(session_id), ensure_ascii=False) + return "\n".join( + [ + 'tell application "Terminal"', + "repeat with targetWindow in windows", + f"if (name of targetWindow) contains {marker} then", + "if (count of tabs of targetWindow) is 1 then", + "close targetWindow", + "return", + "end if", + "end if", + "end repeat", + "end tell", + ] + ) + + +def build_terminal_close_command(session_id: str) -> str: + applescript = build_terminal_close_applescript(session_id) + return f"/usr/bin/osascript -e {shlex.quote(applescript)} >/dev/null 2>&1" + + +def build_terminal_runner_script(argv: list[str], *, session_id: str, timeout: float) -> str: + command = " ".join(shlex.quote(str(part)) for part in argv) + lines = [ + "#!/bin/zsh", + "set +e", + "trap 'rm -f -- \"$0\"' EXIT", + "clear", + _shell_print(""), + _shell_print("============================================================"), + _shell_print("Codex 会话修复 / Codex session repair"), + _shell_print("============================================================"), + _shell_print(f"会话 / Session: {session_id}"), + _shell_print(f"等待上限 / Wait limit: {timeout:g} 秒 / {timeout:g} seconds"), + _shell_print("操作 / Action: 请使用 Command+Q 完全退出 Codex"), + _shell_print("提示 / Note: 状态会双语显示;完成前不要重新打开 Codex。"), + _shell_print("------------------------------------------------------------"), + _shell_print(""), + command, + "rc=$?", + _shell_print(""), + "if [ \"$rc\" -eq 0 ]; then", + _shell_print("Verified / 已验证:修复完成,现在可以重新打开 Codex。"), + "else", + _shell_print("Failed / 失败:修复未完成,请保持 Codex 关闭。"), + "fi", + _shell_print(""), + _shell_print("按回车关闭此窗口 / Press Enter to close"), + "read -r", + "exit \"$rc\"", + ] + return "\n".join(lines) + "\n" + + +def build_terminal_command( + argv: list[str], *, workdir: Path, runner_path: Path | None = None +) -> str: + session_id = _argument_value(argv, "--session-id", "unknown") + close_command = build_terminal_close_command(session_id) + if runner_path is not None: + return ( + f"{build_terminal_title_command(session_id)}; " + f"cd {shlex.quote(str(workdir))} && /bin/zsh {shlex.quote(str(runner_path))}; " + f"rc=$?; {close_command}; exit \"$rc\"" + ) + + command = " ".join(shlex.quote(str(part)) for part in argv) + timeout = _argument_value(argv, "--timeout", "300") + wait_message = shlex.quote( + f"Wait limit / 等待上限: up to {timeout} seconds / 最多 {timeout} 秒;请完全退出 Codex。" + ) + return ( + f"{build_terminal_title_command(session_id)}; " + f"cd {shlex.quote(str(workdir))} && " + "echo 'Codex session repair started / Codex 会话修复已启动;请保持此窗口打开。' && " + f"echo {wait_message} && " + f"{command}; rc=$?; echo; " + "if [ \"$rc\" -eq 0 ]; then " + "echo 'verified / 已验证:Repair complete; you can reopen Codex now / 修复完成,现在可以重新打开 Codex。'; " + "else " + "echo 'failed / 失败:Repair did not complete; keep Codex closed / 修复未完成,请不要重新打开 Codex。'; " + f"fi; echo; echo '按回车关闭此窗口'; read -r; {close_command}; exit \"$rc\"" + ) + + +def build_terminal_applescript(command: str, *, session_id: str | None = None) -> str: + if session_id: + command = f"{build_terminal_title_command(session_id)}; {command}" + lines = [ + 'tell application "Terminal"', + "activate", + f"set repairTab to «event coredosc» {json.dumps(command, ensure_ascii=False)}", + ] + if session_id: + lines.extend( + [ + "tell repairTab", + f"set custom title to {json.dumps(terminal_tab_title(session_id), ensure_ascii=False)}", + "set title displays custom title to true", + "end tell", + ] + ) + lines.extend( + [ + "tell front window", + "set number of columns to 120", + "set number of rows to 36", + "end tell", + "end tell", + ] + ) + return "\n".join(lines) + + +def write_terminal_runner(script: str, session_id: str) -> Path: + handle = tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + prefix=f"codex-session-repair-{session_id}-", + suffix=".zsh", + delete=False, + ) + try: + handle.write(script) + handle.flush() + finally: + handle.close() + path = Path(handle.name) + path.chmod(0o700) + return path + + +def open_terminal(command: str, *, session_id: str | None = None) -> None: + """Open macOS Terminal and run the worker command in a visible window.""" + if sys.platform != "darwin": + raise RuntimeError("--open-terminal is currently supported on macOS only") + applescript = build_terminal_applescript(command, session_id=session_id) + subprocess.run(["osascript", "-e", applescript], check=True) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--session-id", required=True) + parser.add_argument("--codex-home", required=True) + parser.add_argument("--remove-reasoning", choices=("none", "stale", "all"), default="none") + parser.add_argument("--provider") + parser.add_argument("--fix-provider", action="store_true") + parser.add_argument("--model") + parser.add_argument("--fix-model", action="store_true") + parser.add_argument("--fix-model-turn", action="store_true") + parser.add_argument("--disable-remote-compaction", action="store_true") + parser.add_argument("--status-file", type=Path) + parser.add_argument( + "--timeout", + type=float, + default=300.0, + help="Maximum Codex shutdown wait in seconds; Terminal reminds at about 60/180/240s by default", + ) + parser.add_argument("--stable-seconds", type=float, default=5.0) + parser.add_argument("--poll-seconds", type=float, default=1.0) + terminal = parser.add_mutually_exclusive_group() + terminal.add_argument("--open-terminal", dest="open_terminal", action="store_true") + terminal.add_argument("--no-open-terminal", dest="open_terminal", action="store_false") + parser.set_defaults(open_terminal=(sys.platform == "darwin")) + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + args = build_parser().parse_args(argv) + skill_dir = Path(__file__).resolve().parent + status_file = (args.status_file or default_status_path(args.session_id)).expanduser().resolve() + worker_argv = build_worker_argv( + skill_dir=skill_dir, + session_id=args.session_id, + codex_home=Path(args.codex_home).expanduser().resolve(), + remove_reasoning=args.remove_reasoning, + status_file=status_file, + timeout=args.timeout, + stable_seconds=args.stable_seconds, + poll_seconds=args.poll_seconds, + provider=args.provider, + fix_provider=args.fix_provider, + model=args.model, + fix_model=args.fix_model, + fix_model_turn=args.fix_model_turn, + disable_remote_compaction=args.disable_remote_compaction, + ) + if args.open_terminal: + runner_path = write_terminal_runner( + build_terminal_runner_script( + worker_argv, + session_id=args.session_id, + timeout=args.timeout, + ), + args.session_id, + ) + try: + open_terminal( + build_terminal_command(worker_argv, workdir=skill_dir, runner_path=runner_path), + session_id=args.session_id, + ) + except Exception: + runner_path.unlink(missing_ok=True) + raise + print(build_conversation_notice(args.timeout)) + print(f"已打开终端状态窗口:{status_file};最长等待 {args.timeout:g} 秒 / wait limit {args.timeout:g}s") + return 0 + return subprocess.run(worker_argv, cwd=skill_dir).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/codex-cross-provider-session-repair/scripts/wait_and_repair.py b/codex-cross-provider-session-repair/scripts/wait_and_repair.py new file mode 100644 index 0000000..296222c --- /dev/null +++ b/codex-cross-provider-session-repair/scripts/wait_and_repair.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Wait for Codex Desktop to stop, then run and verify a target repair. + +This wrapper is deliberately separate from ``repair.py``. The repair logic +stays deterministic and backup-first; this module owns the process lifecycle +and refuses to write when Codex reappears or the rollout changes while waiting. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Iterable, Sequence, TextIO + +import repair + + +class DesktopRestartedError(RuntimeError): + """Codex Desktop appeared again before it was safe to write.""" + + +class DesktopNotDetectedError(RuntimeError): + """The automatic mode could not confirm that Codex was running first.""" + + +class DesktopWaitTimeoutError(TimeoutError): + """Codex Desktop did not stay stopped within the configured timeout.""" + + +class RolloutChangedError(RuntimeError): + """The target rollout changed while waiting, so the diagnosis is stale.""" + + +class RepairApplyError(RuntimeError): + """The underlying repair command did not produce a verified result.""" + + +@dataclass(frozen=True) +class ProcessInfo: + pid: int + command: str + + +STATUS_LABELS = { + "diagnosed": ("Diagnosed", "已诊断"), + "waiting": ("Waiting", "等待中"), + "stopped": ("Stopped", "已停止"), + "applying": ("Applying", "修复中"), + "verified": ("Verified", "已验证"), + "failed": ("Failed", "失败"), +} + +STATUS_ACTIONS = { + "verified": "Reopen Codex / 可以重新打开 Codex", + "failed": "Keep Codex closed / 保持 Codex 关闭", +} + +DEFAULT_WAIT_REMINDER_SECONDS = (60.0, 180.0, 240.0) + + +def _is_desktop_command(command: str) -> bool: + normalized = command.strip().lower() + if not normalized: + return False + # macOS may leave orphaned Chromium crashpad handlers alive after the + # desktop app exits. They do not hold the Codex session open and must not + # block the safe, process-free window. + if "browser_crashpad_handler" in normalized: + return False + basename = Path(normalized).name + if basename in {"chatgpt.exe", "codex.exe"}: + return True + return any( + suffix in normalized + for suffix in ( + "/chatgpt.app/contents/macos/chatgpt", + "/codex.app/contents/macos/codex", + "/chatgpt helper.app/contents/macos/chatgpt helper", + "/chatgpt.app/contents/resources/codex", + "/codex.app/contents/resources/codex", + "/chatgpt.app/contents/frameworks/codex framework.framework/", + "/codex.app/contents/frameworks/codex framework.framework/", + ) + ) + + +def parse_process_table(text: str) -> list[ProcessInfo]: + """Parse ``ps -axo pid=,comm=`` output without matching the shell command.""" + processes: list[ProcessInfo] = [] + for line in text.splitlines(): + parts = line.strip().split(maxsplit=1) + if len(parts) != 2: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + command = parts[1].strip() + if _is_desktop_command(command): + processes.append(ProcessInfo(pid, command)) + return processes + + +def list_codex_desktop_processes() -> list[ProcessInfo]: + """Return visible Codex Desktop/main-helper processes. + + Process inspection is a safety boundary. If the operating-system query + fails, the exception is propagated and the caller must not apply a repair. + """ + if os.name == "nt": + completed = subprocess.run( + ["tasklist", "/fo", "csv", "/nh"], + check=True, + capture_output=True, + text=True, + ) + processes: list[ProcessInfo] = [] + for row in csv.reader(completed.stdout.splitlines()): + if len(row) < 2 or not _is_desktop_command(row[0]): + continue + try: + processes.append(ProcessInfo(int(row[1]), row[0])) + except ValueError: + continue + return processes + + completed = subprocess.run( + ["ps", "-axo", "pid=,comm="], + check=True, + capture_output=True, + text=True, + ) + return parse_process_table(completed.stdout) + + +def _notify(notify: Callable[[str, str], None] | None, status: str, message: str) -> None: + if notify is not None: + notify(status, message) + + +def wait_for_desktop_exit( + process_reader: Callable[[], Sequence[ProcessInfo]], + *, + timeout: float = 300.0, + stable_seconds: float = 5.0, + poll_seconds: float = 1.0, + reminder_seconds: Sequence[float] | None = None, + allow_already_stopped: bool = False, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, + notify: Callable[[str, str], None] | None = None, +) -> None: + """Wait for a stable process-free window, failing closed on a restart. + + ``allow_already_stopped`` is used only by the explicit repair-job launcher. + It makes the workflow safe when the user quits Codex before the worker's + first process sample; the worker still requires a stable process-free + window before writing anything. + """ + if timeout <= 0 or stable_seconds < 0 or poll_seconds <= 0: + raise ValueError("timeout and poll_seconds must be positive; stable_seconds cannot be negative") + + configured_reminders = DEFAULT_WAIT_REMINDER_SECONDS if reminder_seconds is None else reminder_seconds + try: + reminder_points = sorted( + { + float(point) + for point in configured_reminders + if 0 < float(point) < timeout + } + ) + except (TypeError, ValueError) as exc: + raise ValueError("reminder_seconds must contain positive numbers") from exc + + started_at = monotonic() + absent_since: float | None = None + first_sample = True + initial_notice_sent = False + next_reminder = 0 + + def announce_waiting(prefix_zh: str, prefix_en: str) -> None: + nonlocal initial_notice_sent, next_reminder + elapsed = max(0.0, monotonic() - started_at) + if not initial_notice_sent: + _notify( + notify, + "waiting", + f"{prefix_zh} 最长等待 {timeout:g} 秒,已等待约 {elapsed:g} 秒。请使用 Command+Q 完全退出 Codex。 / " + f"{prefix_en} Maximum wait: {timeout:g}s; waited about {elapsed:g}s. Please fully quit Codex with Command+Q.", + ) + initial_notice_sent = True + while next_reminder < len(reminder_points) and elapsed >= reminder_points[next_reminder]: + point = reminder_points[next_reminder] + remaining = max(0.0, timeout - point) + _notify( + notify, + "waiting", + f"等待提醒:已等待约 {point:g} 秒,距离超时约 {remaining:g} 秒。请确认已使用 Command+Q 完全退出 Codex。 / " + f"Wait reminder: about {point:g}s elapsed, about {remaining:g}s until timeout. Confirm that Codex was fully quit with Command+Q.", + ) + next_reminder += 1 + + while True: + processes = list(process_reader()) + now = monotonic() + if processes: + if absent_since is not None: + raise DesktopRestartedError( + "Codex Desktop reappeared during the shutdown stability window; no files were changed." + ) + announce_waiting( + "Codex 仍在运行,正在等待完全退出。", + "Codex is still running; waiting for full exit.", + ) + else: + if first_sample: + if not allow_already_stopped: + raise DesktopNotDetectedError( + "Could not confirm that Codex Desktop was running. " + "Start automatic mode while Codex is open, or use the manual repair command." + ) + absent_since = now + announce_waiting( + "未检测到 Codex,正在确认它已经完全退出。", + "Codex is not detected; confirming full exit.", + ) + if absent_since is None: + absent_since = now + announce_waiting( + "已检测不到 Codex,正在确认安全窗口。", + "Codex is absent; confirming a safe window.", + ) + elif now - absent_since >= stable_seconds: + _notify( + notify, + "stopped", + f"Codex 已完全退出,安全检查已通过({stable_seconds:g} 秒)。 / Codex fully exited; safety check passed ({stable_seconds:g}s).", + ) + return + first_sample = False + + if now - started_at >= timeout: + raise DesktopWaitTimeoutError( + f"等待 Codex 完全退出已达到 {timeout:g} 秒,但进程仍未稳定停止;未修改文件。 / " + f"Codex Desktop did not stay stopped within {timeout:g}s; no files were changed." + ) + sleep(poll_seconds) + + +class StatusReporter: + """Write human-readable progress and an atomically replaced JSON status file.""" + + def __init__(self, session_id: str, path: Path | None = None, stream: TextIO | None = None): + self.session_id = session_id + self.path = path + self.stream = stream if stream is not None else sys.stdout + + def update(self, status: str, message: str, **fields: object) -> dict[str, object]: + status_en, status_zh = STATUS_LABELS.get(status, (status, status)) + payload: dict[str, object] = { + "session_id": self.session_id, + "status": status, + "status_label": f"{status_en} / {status_zh}", + "message": message, + "can_reopen": status == "verified", + "next_action": STATUS_ACTIONS.get( + status, + "Keep Codex closed and wait / 保持 Codex 关闭并等待", + ), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + payload.update(fields) + if self.path is not None: + self.path.parent.mkdir(parents=True, exist_ok=True) + handle_path: str | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=self.path.parent, + prefix=f".{self.path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle_path = handle.name + os.replace(handle_path, self.path) + finally: + if handle_path is not None and Path(handle_path).exists(): + Path(handle_path).unlink() + print(f"[{status_en} / {status_zh}] {message}", file=self.stream, flush=True) + return payload + + +def rollout_fingerprint(path: Path) -> dict[str, object]: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + stat = path.stat() + return {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns, "sha256": digest} + + +def default_status_path(session_id: str) -> Path: + """Return the predictable status path shown to beginner users.""" + return Path(tempfile.gettempdir()) / f"codex-session-repair-{session_id}.json" + + +def _backup_paths(rollout: Path) -> set[Path]: + return set(rollout.parent.glob(f"{rollout.name}.bak-session-repair-*")) + + +def _build_repair_argv( + session_id: str, + codex_home: Path, + remove_reasoning: str, + provider: str | None, + fix_provider: bool, + model: str | None, + fix_model: bool, + fix_model_turn: bool, + disable_remote_compaction: bool, +) -> list[str]: + argv = [ + "--session-id", + session_id, + "--codex-home", + str(codex_home), + "--remove-reasoning", + remove_reasoning, + "--apply", + ] + if provider: + argv.extend(["--provider", provider]) + if fix_provider: + argv.append("--fix-provider") + if model: + argv.extend(["--model", model]) + if fix_model: + argv.append("--fix-model") + if fix_model_turn: + argv.append("--fix-model-turn") + if disable_remote_compaction: + argv.append("--disable-remote-compaction") + return argv + + +def _verify_post_report( + report: dict[str, object], + remove_reasoning: str, + fix_model: bool = False, + model: str | None = None, +) -> None: + if report["json_parse_errors"]: + raise RepairApplyError("post-repair verification found malformed JSONL") + if remove_reasoning == "all" and report["reasoning_item_count"] != 0: + raise RepairApplyError( + f"post-repair verification found {report['reasoning_item_count']} local reasoning records" + ) + if remove_reasoning == "stale" and report["stale_ids_present_as_reasoning"]: + raise RepairApplyError("post-repair verification found stale IDs still present as local reasoning") + if fix_model: + if not model: + raise RepairApplyError("model repair verification requires a target model") + root_thread = report.get("root_thread") + if not isinstance(root_thread, dict) or root_thread.get("model") != model: + raise RepairApplyError("post-repair verification found the target DB model was not updated") + structured_models = report.get("structured_model_values") or [] + if any(value != model for value in structured_models): + raise RepairApplyError("post-repair verification found an old structured rollout model") + + +def run_wait_and_repair( + *, + session_id: str, + codex_home: Path, + remove_reasoning: str = "all", + provider: str | None = None, + fix_provider: bool = False, + model: str | None = None, + fix_model: bool = False, + fix_model_turn: bool = False, + disable_remote_compaction: bool = False, + process_reader: Callable[[], Sequence[ProcessInfo]] = list_codex_desktop_processes, + status_file: Path | None = None, + timeout: float = 300.0, + stable_seconds: float = 5.0, + poll_seconds: float = 1.0, + reminder_seconds: Sequence[float] | None = None, + allow_already_stopped: bool = False, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, + apply_runner: Callable[[list[str]], int] = repair.main, + stream: TextIO | None = None, +) -> dict[str, object]: + """Diagnose, wait safely, apply via ``repair.py``, and verify the result.""" + reporter = StatusReporter(session_id, status_file or default_status_path(session_id), stream) + try: + report = repair.inspect_session(codex_home, session_id) + if report["json_parse_errors"]: + raise ValueError("refusing to wait/apply: JSONL has parse errors") + rollout = Path(report["rollout_path"]) + before_fingerprint = rollout_fingerprint(rollout) + before_backups = _backup_paths(rollout) + reporter.update( + "diagnosed", + f"已完成只读诊断。修复任务已启动,最多等待 {timeout:g} 秒。请完全退出 Codex(macOS 使用 Command+Q);修复尚未开始。 / " + f"Read-only diagnosis complete. Repair job started; the worker may wait up to {timeout:g}s. Fully quit Codex (use Command+Q on macOS); repair has not started yet.", + line_count=report["line_count"], + reasoning_item_count=report["reasoning_item_count"], + wait_timeout_seconds=timeout, + wait_phase="waiting_for_desktop_exit", + conversation_notice=f"修复任务已启动,最多等待 {timeout:g} 秒;请完全退出 Codex。 / Repair started; maximum wait is {timeout:g}s; fully quit Codex.", + ) + + wait_for_desktop_exit( + process_reader, + timeout=timeout, + stable_seconds=stable_seconds, + poll_seconds=poll_seconds, + reminder_seconds=reminder_seconds, + allow_already_stopped=allow_already_stopped, + sleep=sleep, + monotonic=monotonic, + notify=lambda status, message: reporter.update( + status, + message, + wait_timeout_seconds=timeout, + wait_phase="waiting_for_desktop_exit", + ), + ) + if list(process_reader()): + raise DesktopRestartedError("Codex Desktop reappeared before apply; no files were changed.") + + after_wait_fingerprint = rollout_fingerprint(rollout) + if after_wait_fingerprint != before_fingerprint: + raise RolloutChangedError( + "The target rollout changed while waiting; diagnosis is stale and no files were changed." + ) + + reporter.update( + "applying", + "Codex 已完全退出,正在备份并修复目标会话。请保持 Codex 关闭。 / Codex fully exited; backing up and repairing the target session. Keep Codex closed.", + wait_timeout_seconds=timeout, + wait_phase="applying", + ) + argv = _build_repair_argv( + session_id, + codex_home, + remove_reasoning, + provider, + fix_provider, + model, + fix_model, + fix_model_turn, + disable_remote_compaction, + ) + exit_code = apply_runner(argv) + if exit_code != 0: + raise RepairApplyError(f"repair.py exited with status {exit_code}") + + new_backups = _backup_paths(rollout) - before_backups + if not new_backups: + raise RepairApplyError("repair.py returned success but did not create a session backup") + post_report = repair.inspect_session(codex_home, session_id) + _verify_post_report(post_report, remove_reasoning, fix_model, model) + backup = str(sorted(new_backups)[-1]) + reporter.update( + "verified", + "修复完成并验证通过,现在可以重新打开 Codex。 / Repair complete and verified; you can reopen Codex now.", + backup=backup, + line_count=post_report["line_count"], + reasoning_item_count=post_report["reasoning_item_count"], + historical_remote_stale_ids=post_report["stale_remote_item_ids"], + wait_timeout_seconds=timeout, + wait_phase="verified", + ) + return {"status": "verified", "backup": backup, "post_report": post_report} + except Exception as exc: + failure_fields: dict[str, object] = { + "error_type": type(exc).__name__, + "wait_timeout_seconds": timeout, + } + if isinstance(exc, DesktopWaitTimeoutError): + failure_fields.update( + { + "wait_timed_out": True, + "conversation_notice": f"等待超时:Codex 完全退出超过 {timeout:g} 秒,未修改文件。请检查是否只关闭了窗口;如需重试,请使用 Command+Q 完全退出。 / " + f"Codex did not fully exit within {timeout:g}s; no files were changed. Check whether only the window was closed; use Command+Q before retrying.", + } + ) + reporter.update( + "failed", + f"修复未完成,请保持 Codex 关闭,暂时不要重新打开。 / Repair did not complete; keep Codex closed and do not reopen it yet. 原因 / Reason: {exc}", + **failure_fields, + ) + raise + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--session-id", required=True, help="Target Codex session UUID") + parser.add_argument("--codex-home", required=True, help="Codex home directory") + parser.add_argument( + "--remove-reasoning", + choices=("none", "stale", "all"), + default="all", + help="Remove exact stale reasoning IDs, or all internal reasoning records", + ) + parser.add_argument("--provider", help="Current provider when --fix-provider is used") + parser.add_argument("--fix-provider", action="store_true") + parser.add_argument("--model", help="Target model when --fix-model is used") + parser.add_argument("--fix-model", action="store_true") + parser.add_argument("--fix-model-turn", action="store_true") + parser.add_argument("--disable-remote-compaction", action="store_true") + parser.add_argument("--status-file", type=Path, help="Atomically updated JSON status file") + parser.add_argument( + "--allow-already-stopped", + action="store_true", + help="Allow the explicit repair job to start after Codex was already closed", + ) + parser.add_argument( + "--timeout", + type=float, + default=300.0, + help="Maximum Codex shutdown wait in seconds; emits bilingual reminders at about 60/180/240s by default", + ) + parser.add_argument("--stable-seconds", type=float, default=5.0) + parser.add_argument("--poll-seconds", type=float, default=1.0) + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + run_wait_and_repair( + session_id=args.session_id, + codex_home=Path(args.codex_home).expanduser().resolve(), + remove_reasoning=args.remove_reasoning, + provider=args.provider, + fix_provider=args.fix_provider, + model=args.model, + fix_model=args.fix_model, + fix_model_turn=args.fix_model_turn, + disable_remote_compaction=args.disable_remote_compaction, + status_file=args.status_file.expanduser().resolve() if args.status_file else None, + timeout=args.timeout, + stable_seconds=args.stable_seconds, + poll_seconds=args.poll_seconds, + allow_already_stopped=args.allow_already_stopped, + ) + return 0 + except (OSError, ValueError, RuntimeError, TimeoutError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/codex-cross-provider-session-repair/tests/test_repair.py b/codex-cross-provider-session-repair/tests/test_repair.py index ba4351e..554b17c 100644 --- a/codex-cross-provider-session-repair/tests/test_repair.py +++ b/codex-cross-provider-session-repair/tests/test_repair.py @@ -1,8 +1,10 @@ import json +import io import sqlite3 import sys import tempfile import unittest +from contextlib import redirect_stdout from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) @@ -72,9 +74,17 @@ def test_dry_run_reports_layers_and_stale_ids(self): self.assertEqual(report["stale_ids_present_as_reasoning"], ["rs_stale_1", "rs_stale_2"]) self.assertEqual(report["reasoning_item_count"], 2) + def test_report_labels_log_ids_as_historical(self): + report = repair.inspect_session(self.home, SESSION_ID) + output = io.StringIO() + with redirect_stdout(output): + repair.print_report(report, as_json=False) + + self.assertIn("Historical remote stale IDs (from logs):", output.getvalue()) + def test_stale_repair_preserves_visible_and_event_items(self): report = repair.inspect_session(self.home, SESSION_ID) - backup, removed, provider_updates, _ = repair.rewrite_session(report, None, False, "stale") + backup, removed, provider_updates, _, _ = repair.rewrite_session(report, None, False, "stale") self.assertTrue(backup and backup.exists()) self.assertEqual(removed, 2) self.assertEqual(provider_updates, 0) @@ -86,7 +96,7 @@ def test_stale_repair_preserves_visible_and_event_items(self): def test_provider_repair_updates_only_target_layers(self): report = repair.inspect_session(self.home, SESSION_ID) - backup, removed, provider_updates, _ = repair.rewrite_session(report, "custom", True, "none") + backup, removed, provider_updates, _, _ = repair.rewrite_session(report, "custom", True, "none") self.assertTrue(backup and backup.exists()) self.assertEqual(removed, 0) self.assertEqual(provider_updates, 1) @@ -96,6 +106,42 @@ def test_provider_repair_updates_only_target_layers(self): self.assertEqual(after["target_session_meta"][0]["model_provider"], "custom") self.assertEqual(after["root_thread"]["model_provider"], "custom") + def test_model_repair_updates_target_settings_and_database(self): + self.session_path.write_text( + self.session_path.read_text(encoding="utf-8") + + event( + "event_msg", + { + "type": "thread_settings_applied", + "thread_settings": { + "model": "ark-code-latest", + "collaboration_mode": {"settings": {"model": "ark-code-latest"}}, + }, + }, + ) + + event("event_msg", {"type": "turn_context", "model": "ark-code-latest"}), + encoding="utf-8", + ) + report = repair.inspect_session(self.home, SESSION_ID) + + backup, removed, provider_updates, inserts, model_updates = repair.rewrite_session( + report, + None, + False, + "none", + fix_model=True, + model="gpt-5.6-luna", + ) + db_backup = repair.update_thread_model(self.home, SESSION_ID, "gpt-5.6-luna") + + self.assertTrue(backup and backup.exists()) + self.assertEqual((removed, provider_updates, inserts, model_updates), (0, 0, 0, 2)) + self.assertTrue(db_backup and db_backup.exists()) + text = self.session_path.read_text(encoding="utf-8") + self.assertNotIn("ark-code-latest", text) + self.assertIn("gpt-5.6-luna", text) + self.assertEqual(repair.inspect_session(self.home, SESSION_ID)["root_thread"]["model"], "gpt-5.6-luna") + def test_malformed_jsonl_is_not_rewritten(self): with self.session_path.open("a", encoding="utf-8") as handle: handle.write("not-json\n") @@ -199,7 +245,7 @@ def test_detects_model_turn_risk(self): def test_fix_model_turn_removes_rollbacks(self): report = repair.inspect_session(self.home, SESSION_ID_MT) - backup, _, _, inserts = repair.rewrite_session( + backup, _, _, inserts, _ = repair.rewrite_session( report, None, False, "none", fix_model_turn=True ) self.assertTrue(backup and backup.exists()) @@ -237,7 +283,7 @@ def test_fix_model_turn_appends_user_when_no_rollback(self): self.assertTrue(mt["model_turn_risk"]) self.assertEqual(mt["last_effective_role"], "assistant") - backup, _, _, inserts = repair.rewrite_session( + backup, _, _, inserts, _ = repair.rewrite_session( report, None, False, "none", fix_model_turn=True ) self.assertEqual(inserts, 1) @@ -271,7 +317,7 @@ def test_fix_model_turn_appends_user_when_raw_tail_is_user_but_effective_ends_as self.assertTrue(mt["model_turn_risk"]) self.assertEqual(mt["last_effective_role"], "assistant") - backup, _, _, inserts = repair.rewrite_session( + backup, _, _, inserts, _ = repair.rewrite_session( report, None, False, "none", fix_model_turn=True ) self.assertTrue(backup and backup.exists()) diff --git a/codex-cross-provider-session-repair/tests/test_start_repair.py b/codex-cross-provider-session-repair/tests/test_start_repair.py new file mode 100644 index 0000000..1e1d971 --- /dev/null +++ b/codex-cross-provider-session-repair/tests/test_start_repair.py @@ -0,0 +1,144 @@ +import tempfile +import unittest +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import start_repair # noqa: E402 + + +class StartRepairTests(unittest.TestCase): + def test_worker_command_is_safe_after_user_quits_before_start(self): + with tempfile.TemporaryDirectory() as directory: + skill_dir = Path(directory) / "skill" + home = Path(directory) / ".codex" + status = Path(directory) / "status.json" + + argv = start_repair.build_worker_argv( + skill_dir=skill_dir, + session_id="session-1", + codex_home=home, + remove_reasoning="stale", + status_file=status, + timeout=120, + stable_seconds=8, + poll_seconds=1, + ) + + self.assertIn("--allow-already-stopped", argv) + self.assertIn("--remove-reasoning", argv) + self.assertIn("stale", argv) + self.assertIn("--status-file", argv) + self.assertIn(str(status), argv) + + def test_terminal_command_explains_final_state_and_stays_open(self): + command = start_repair.build_terminal_command( + [ + "python3", + "wait_and_repair.py", + "--session-id", + "session-1", + "--timeout", + "300", + ], + workdir=Path("/tmp/repair-skill"), + ) + + self.assertIn("Wait limit / 等待上限", command) + self.assertIn("最多 300 秒", command) + self.assertIn("verified / 已验证", command) + self.assertIn("failed / 失败", command) + self.assertIn("read -r", command) + self.assertIn('exit "$rc"', command) + self.assertIn("\\033]0;", command) + self.assertIn("/usr/bin/osascript", command) + + def test_terminal_command_uses_short_runner_path_when_available(self): + command = start_repair.build_terminal_command( + [ + "python3", + "wait_and_repair.py", + "--session-id", + "session-1", + "--codex-home", + "/Users/wardlu/.codex", + ], + workdir=Path("/tmp/repair-skill"), + runner_path=Path("/tmp/codex-session-repair-session-1.sh"), + ) + + self.assertIn("/bin/zsh", command) + self.assertIn("/tmp/codex-session-repair-session-1.sh", command) + self.assertNotIn("--session-id", command) + self.assertNotIn("--codex-home", command) + self.assertTrue(command.endswith('exit "$rc"')) + + def test_terminal_command_closes_the_dedicated_repair_window_after_enter(self): + command = start_repair.build_terminal_command( + [ + "python3", + "wait_and_repair.py", + "--session-id", + "session-1", + ], + workdir=Path("/tmp/repair-skill"), + runner_path=Path("/tmp/codex-session-repair-session-1.sh"), + ) + + self.assertIn("/usr/bin/osascript", command) + self.assertIn("Codex session repair session-1", command) + self.assertIn("\\033]0;", command) + self.assertIn('exit "$rc"', command) + + def test_terminal_close_script_closes_the_dedicated_repair_window(self): + script = start_repair.build_terminal_close_applescript("session-1") + + self.assertIn("name of targetWindow", script) + self.assertIn("count of tabs of targetWindow", script) + self.assertIn("close targetWindow", script) + self.assertNotIn("close targetTab", script) + + def test_runner_script_has_readable_sections_and_final_state(self): + script = start_repair.build_terminal_runner_script( + ["python3", "wait_and_repair.py", "--session-id", "session-1"], + session_id="session-1", + timeout=300, + ) + + self.assertIn("Codex 会话修复 / Codex session repair", script) + self.assertIn("会话 / Session: session-1", script) + self.assertIn("等待上限 / Wait limit: 300 秒 / 300 seconds", script) + self.assertIn("Command+Q", script) + self.assertIn("Verified / 已验证", script) + self.assertIn("Failed / 失败", script) + self.assertIn("read -r", script) + + def test_terminal_applescript_creates_a_readable_window(self): + script = start_repair.build_terminal_applescript("echo ready", session_id="session-1") + + self.assertIn("coredosc", script) + self.assertIn("set custom title", script) + self.assertIn("title displays custom title", script) + self.assertIn("Codex session repair session-1", script) + self.assertIn("tell front window", script) + self.assertIn("number of columns", script) + self.assertIn("120", script) + self.assertIn("number of rows", script) + self.assertIn("36", script) + self.assertIn("echo ready", script) + + def test_default_status_path_is_predictable(self): + expected = Path(tempfile.gettempdir()) / "codex-session-repair-session-1.json" + self.assertEqual(start_repair.default_status_path("session-1"), expected) + + def test_conversation_notice_includes_wait_limit_and_action(self): + notice = start_repair.build_conversation_notice(300) + + self.assertIn("最多等待 300 秒", notice) + self.assertIn("完全退出 Codex", notice) + self.assertIn("maximum wait is 300s", notice) + + +if __name__ == "__main__": + unittest.main() diff --git a/codex-cross-provider-session-repair/tests/test_wait_and_repair.py b/codex-cross-provider-session-repair/tests/test_wait_and_repair.py new file mode 100644 index 0000000..e294a51 --- /dev/null +++ b/codex-cross-provider-session-repair/tests/test_wait_and_repair.py @@ -0,0 +1,358 @@ +import io +import json +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import wait_and_repair # noqa: E402 +import start_repair # noqa: E402 + + +SESSION_ID = "019fe5cf-9cc1-7cd1-8a04-21dca7b5cf85" + + +def event(kind, payload): + return json.dumps( + {"timestamp": "2026-08-10T00:00:00Z", "type": kind, "payload": payload}, + separators=(",", ":"), + ) + "\n" + + +class FakeClock: + def __init__(self): + self.value = 0.0 + + def monotonic(self): + return self.value + + def sleep(self, seconds): + self.value += seconds + + +def write_fake_home(root, reasoning_count=2, model=None): + home = root / ".codex" + rollout_dir = home / "sessions" / "2026" / "08" / "10" + rollout_dir.mkdir(parents=True) + rollout = rollout_dir / f"rollout-test-{SESSION_ID}.jsonl" + records = [event("session_meta", {"id": SESSION_ID, "model_provider": "custom"})] + for index in range(reasoning_count): + records.append(event("response_item", {"type": "reasoning", "id": f"rs_test_{index}"})) + records.append( + event( + "response_item", + {"type": "message", "id": "msg-visible", "role": "user", "content": []}, + ) + ) + if model: + records.append( + event( + "event_msg", + { + "type": "thread_settings_applied", + "thread_settings": { + "model": model, + "collaboration_mode": {"settings": {"model": model}}, + }, + }, + ) + ) + records.append(event("event_msg", {"type": "turn_context", "model": model})) + rollout.write_text("".join(records), encoding="utf-8") + return home, rollout + + +class WaitForDesktopExitTests(unittest.TestCase): + def test_process_table_ignores_shell_commands_that_only_mention_codex(self): + table = "\n".join( + [ + "42 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT", + "43 /bin/zsh -c ps -axo pid=,comm= | rg ChatGPT", + "44 /Applications/ChatGPT.app/Contents/Frameworks/ChatGPT Helper.app/Contents/MacOS/ChatGPT Helper", + "45 /Applications/ChatGPT.app/Contents/Resources/codex", + "46 /Applications/ChatGPT.app/Contents/Frameworks/Codex Framework.framework/Versions/151.0/Helpers/Codex (Service).app/Contents/MacOS/Codex (Service)", + "47 /Applications/ChatGPT.app/Contents/Frameworks/Codex Framework.framework/Versions/151.0/Helpers/browser_crashpad_handler --database=/Users/test/Library/Application Support/Codex/Crashpad", + ] + ) + + processes = wait_and_repair.parse_process_table(table) + + self.assertEqual([process.pid for process in processes], [42, 44, 45, 46]) + + def test_requires_stable_absence_before_returning(self): + clock = FakeClock() + states = [ + [wait_and_repair.ProcessInfo(42, "ChatGPT")], + [], + [], + [], + ] + + wait_and_repair.wait_for_desktop_exit( + lambda: states.pop(0), + timeout=10, + stable_seconds=2, + poll_seconds=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + self.assertEqual(clock.value, 3) + + def test_aborts_if_desktop_reappears_during_stability_window(self): + clock = FakeClock() + states = [ + [wait_and_repair.ProcessInfo(42, "ChatGPT")], + [], + [wait_and_repair.ProcessInfo(99, "ChatGPT")], + ] + + with self.assertRaises(wait_and_repair.DesktopRestartedError): + wait_and_repair.wait_for_desktop_exit( + lambda: states.pop(0), + timeout=10, + stable_seconds=2, + poll_seconds=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + def test_refuses_to_start_when_desktop_was_never_detected(self): + clock = FakeClock() + + with self.assertRaises(wait_and_repair.DesktopNotDetectedError): + wait_and_repair.wait_for_desktop_exit( + lambda: [], + timeout=10, + stable_seconds=2, + poll_seconds=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + def test_allows_already_stopped_when_repair_job_was_started_after_quit(self): + clock = FakeClock() + states = [[], [], []] + + wait_and_repair.wait_for_desktop_exit( + lambda: states.pop(0), + timeout=10, + stable_seconds=2, + poll_seconds=1, + allow_already_stopped=True, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + self.assertEqual(clock.value, 2) + + def test_waiting_announces_time_limit_and_periodic_reminders(self): + clock = FakeClock() + notices = [] + + with self.assertRaises(wait_and_repair.DesktopWaitTimeoutError): + wait_and_repair.wait_for_desktop_exit( + lambda: [wait_and_repair.ProcessInfo(42, "ChatGPT")], + timeout=5, + stable_seconds=2, + poll_seconds=1, + reminder_seconds=(1, 3), + sleep=clock.sleep, + monotonic=clock.monotonic, + notify=lambda status, message: notices.append((status, message)), + ) + + messages = [message for status, message in notices if status == "waiting"] + self.assertTrue(any("最长等待 5 秒" in message for message in messages)) + self.assertTrue(any("已等待约 1 秒" in message for message in messages)) + self.assertTrue(any("距离超时约 2 秒" in message for message in messages)) + + + +class WaitAndRepairTests(unittest.TestCase): + def test_model_repair_with_none_preserves_reasoning_and_verifies_both_layers(self): + with tempfile.TemporaryDirectory() as directory: + home, rollout = write_fake_home(Path(directory), model="ark-code-latest") + state = sqlite3.connect(home / "state_5.sqlite") + state.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT, model TEXT, cwd TEXT, archived INTEGER)" + ) + state.execute( + "INSERT INTO threads VALUES (?, ?, ?, ?, ?)", + (SESSION_ID, "custom", "ark-code-latest", "/work", 0), + ) + state.commit() + state.close() + status_file = Path(directory) / "repair-status.json" + clock = FakeClock() + states = [ + [wait_and_repair.ProcessInfo(42, "ChatGPT")], + [], + [], + [], + [], + ] + + result = wait_and_repair.run_wait_and_repair( + session_id=SESSION_ID, + codex_home=home, + remove_reasoning="none", + model="gpt-5.6-luna", + fix_model=True, + process_reader=lambda: states.pop(0), + status_file=status_file, + timeout=10, + stable_seconds=2, + poll_seconds=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + self.assertEqual(result["status"], "verified") + self.assertEqual(result["post_report"]["reasoning_item_count"], 2) + self.assertEqual(result["post_report"]["root_thread"]["model"], "gpt-5.6-luna") + self.assertEqual(result["post_report"]["structured_model_values"], ["gpt-5.6-luna"] * 3) + self.assertTrue(list(rollout.parent.glob("*.bak-session-repair-*"))) + + def test_success_writes_verified_status_and_removes_reasoning(self): + with tempfile.TemporaryDirectory() as directory: + home, rollout = write_fake_home(Path(directory)) + status_file = Path(directory) / "repair-status.json" + clock = FakeClock() + states = [ + [wait_and_repair.ProcessInfo(42, "ChatGPT")], + [], + [], + [], + [], + ] + + result = wait_and_repair.run_wait_and_repair( + session_id=SESSION_ID, + codex_home=home, + remove_reasoning="all", + process_reader=lambda: states.pop(0), + status_file=status_file, + timeout=10, + stable_seconds=2, + poll_seconds=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + stream=(stream := io.StringIO()), + ) + + self.assertEqual(result["status"], "verified") + self.assertEqual(result["post_report"]["reasoning_item_count"], 0) + self.assertTrue(status_file.exists()) + self.assertEqual(json.loads(status_file.read_text())["status"], "verified") + self.assertTrue(list(rollout.parent.glob("*.bak-session-repair-*"))) + self.assertIn("可以重新打开 Codex", stream.getvalue()) + self.assertIn("Verified / 已验证", stream.getvalue()) + status_payload = json.loads(status_file.read_text()) + self.assertEqual(status_payload["status_label"], "Verified / 已验证") + self.assertTrue(status_payload["can_reopen"]) + self.assertEqual(status_payload["next_action"], "Reopen Codex / 可以重新打开 Codex") + + def test_repairs_when_codex_was_already_closed_before_job_started(self): + with tempfile.TemporaryDirectory() as directory: + home, rollout = write_fake_home(Path(directory)) + status_file = Path(directory) / "repair-status.json" + clock = FakeClock() + states = [[], [], [], []] + + result = wait_and_repair.run_wait_and_repair( + session_id=SESSION_ID, + codex_home=home, + remove_reasoning="all", + allow_already_stopped=True, + process_reader=lambda: states.pop(0), + status_file=status_file, + timeout=10, + stable_seconds=2, + poll_seconds=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + ) + + self.assertEqual(result["status"], "verified") + self.assertEqual(result["post_report"]["reasoning_item_count"], 0) + + def test_refuses_to_apply_when_rollout_changes_while_waiting(self): + with tempfile.TemporaryDirectory() as directory: + home, rollout = write_fake_home(Path(directory)) + status_file = Path(directory) / "repair-status.json" + clock = FakeClock() + states = [ + [wait_and_repair.ProcessInfo(42, "ChatGPT")], + [], + [], + [], + [], + ] + changed = False + + def sleep(seconds): + nonlocal changed + if not changed: + rollout.write_text( + rollout.read_text(encoding="utf-8") + + event("event_msg", {"type": "task_complete"}), + encoding="utf-8", + ) + changed = True + clock.sleep(seconds) + + with self.assertRaises(wait_and_repair.RolloutChangedError): + wait_and_repair.run_wait_and_repair( + session_id=SESSION_ID, + codex_home=home, + remove_reasoning="all", + process_reader=lambda: states.pop(0), + status_file=status_file, + timeout=10, + stable_seconds=2, + poll_seconds=1, + sleep=sleep, + monotonic=clock.monotonic, + ) + + self.assertEqual(json.loads(status_file.read_text())["status"], "failed") + self.assertFalse(list(rollout.parent.glob("*.bak-session-repair-*"))) + + def test_timeout_status_explains_wait_limit_and_next_step(self): + with tempfile.TemporaryDirectory() as directory: + home, rollout = write_fake_home(Path(directory)) + status_file = Path(directory) / "repair-status.json" + clock = FakeClock() + stream = io.StringIO() + + with self.assertRaises(wait_and_repair.DesktopWaitTimeoutError): + wait_and_repair.run_wait_and_repair( + session_id=SESSION_ID, + codex_home=home, + remove_reasoning="stale", + process_reader=lambda: [wait_and_repair.ProcessInfo(42, "ChatGPT")], + status_file=status_file, + timeout=3, + stable_seconds=2, + poll_seconds=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + stream=stream, + ) + + status_payload = json.loads(status_file.read_text()) + self.assertEqual(status_payload["status"], "failed") + self.assertTrue(status_payload["wait_timed_out"]) + self.assertEqual(status_payload["wait_timeout_seconds"], 3) + self.assertIn("等待超时", status_payload["conversation_notice"]) + self.assertIn("最多等待 3 秒", stream.getvalue()) + self.assertIn("保持 Codex 关闭", stream.getvalue()) + self.assertFalse(list(rollout.parent.glob("*.bak-session-repair-*"))) + + +if __name__ == "__main__": + unittest.main()