首页 > 基础资料 博客日记
【Vibe Coding】折腾了一个高考假,我让Codex自动修Issue...
2026-06-09 17:30:04基础资料围观3次
前言
随着Codex等AI Agent的发展,Vibe coding逐渐成为了开发者的日常。
前几天修issue的时候,发现所有的活基本上都让codex干了:只需要一句“修一下Issue #34”,codex便自己调用gh-cli查看issue,列出plan,查找作用域,甚至修完代码还帮你测试。
我就想着,或许我们可以连输这句话都不用,直接在github上提交issue,本地的codex自动修完提pr,人类只需要review...其实Github Copilot有类似的功能,可惜太贵,于是开始自己折腾起这条工作流来。
Pre-requirement
- 一台常开机的本地开发环境
- git
- python运行时
- 公网访问(或使用内网穿透)
- 可用的codex cli
- 已经登录授权的gh cli
工作流依赖Github,gitee等平台是否可行有待试验。
Webhook监听
既然要自动修Issue,那肯定是要监听Issue提交的。这里有三种方案:
- 使用gh-cli轮询
- 使用Github Workflow事件触发
- 使用Github Webhook
第一种方案显然效率低下,而且说不定会达到api request limit被封;一开始实践的是第二种方案,使用workflow监听issue事件,然后run curl通知,不过显然第三种方案更佳。
这里使用官方的仓库Webhook通知,在仓库的设置里面:

要监听,肯定是需要本地监听器的。这里我写了一个listener.py(见文末),开放了一个http服务监听webhook事件,然后使用frp穿透出去。
需要注意的是,webhook需要一个secret值防止恶意请求。这里参考github的文档处理secret。
在仓库设置里新建一个webhook,填上穿透后的访问接口和一样的secret值,然后事件自定义,勾选Issues, Issue comments, Pull request reviews, 和 Pull request review comments。

处理事件
接收事件之后,便要调起codex。为了避免进入tui,我们使用codex exec --json模式调用,并把日志重定向到logs/文件夹下。
满心期待的试验,发现了一些问题:第一,调起来了codex也不知道你要干嘛;第二,有些指令沙箱内运行不了,比如git push,需要提权。
提示词优化
我写了一篇提示词,告诉Codex:
- 了解项目上下文
- 阅读Issue内容,修复它
- 允许提权的指令有哪些
当然这里只是简约的列出要素,实际上提示词还包括了代理使用、文档整理、完成标准、提交规范、工作流程、分支结构、测试方法等。完整文档见文末。
然后在listener.py中,将提示词文本注入到指令中,并加上webhook得到的issue相关信息。
指令审核
在我们使用CodeX TUI的时候,经常需要授权同意执行指令。那么在CLI下怎么授权呢?我阅读了codex文档,看见了-a参数(以下表格由千问总结):

那么never显然是不行的,会拒绝提权,git和gh调用不了。而untrusted中不包含git、gh指令。所以选择on-request,并且写了一个autofix.rules文件(见文末)作为approval-reviewer代替人类授权,允许执行特定的指令。
这里有点小插曲,codex执行时是看不到这个rules文件的,会导致它提权的命令和允许的规则有些许偏差。所以提示词里面需要告诉codex哪些指令可执行。
继续对话
用过codex的小伙伴都知道,并不是第一次写出来的代码就完全满意,总需要修改。所以充分利用github pr review的comment功能,提交comment之后触发事件,codex会继续修复。那么问题来了,listener怎么恢复之前的对话呢?
这里我采用指定对话id的方式,并且把对话id和issue编号、pr编号对应存到logs/下的csv文件里。这样就可以调用codex resume的方式继续修复。
当然,开发人员进入这个开发环境,也可以使用codex resume seesion_id进入tui,进行开发,毕竟在github上对话效率不高。不过直接使用codex resume的列表里貌似没有这些cli对话,需要从csv或者日志里找到对话id,指定对话id才能resume,不知道后续版本会不会把这些对话也加进去。
总结
折腾了几天的脚本和提示词,最后基本上实现了自动修Issue的功能。这里我贴张图片,就是codex自己修的pr,测试后是完全达标可以直接合并的。



这里提交用的环境里面指定的邮箱和名称,提pr需要github账户,用的gh-cli登录的用户。目前来看简单任务完成没有问题,大的比如功能开发还是需要再手动改,不过也正常啦。
目前还有几个缺点可以优化,比如:
- 不支持同时执行多个任务
- 稳定性略差,碰到网络问题等容易失效且没有反馈
可以用git worktree和多线程实现多任务,还有重试机制和部署飞书webhook通知等。不过目前够用就行啦。
源码
我把它从项目仓库里分离了出来,放在单独的Github公开仓RunlingDev/autofix下,不过有一些内容没有改,要使用需要适配一下。比如,建议把整个文件夹放在项目的chore/autofix下,如果更改名字或层级可能需要扫一遍脚本里面关于路径处理的地方。
有写了一个部署指南DEPLOYMENT.md,可以参考。一般来说,配置好Github Workflow和环境变量(写.env,有.env.example),然后执行chore/autofix/install.sh即可。
这里贴一下当前版本的代码,可以直接用;GitHub Repo上的也欢迎大家提PR优化,毕竟目前也只是能用的水平~
对了,源码里面有一些HayFrp Verify之类的字样,可能需要改改,那是我原来部署的仓库;github上的应该就不会有了,我会整理完再传上去。
install.sh:
注:使用systemd管理服务;需要root运行。重复执行(更新)会覆盖原有配置。
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_DIR="$(CDPATH= cd -- "${SCRIPT_DIR}/../.." && pwd)"
ENV_FILE="${SCRIPT_DIR}/.env"
SERVICE_NAME="hayfrp-verify-autofix.service"
SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}"
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "install.sh must be run as root because it writes systemd service files." >&2
exit 1
fi
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing prerequisite command: $1" >&2
exit 1
fi
}
require_docker_compose() {
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
return 0
fi
if command -v docker-compose >/dev/null 2>&1; then
return 0
fi
echo "Missing prerequisite command: docker compose or docker-compose" >&2
exit 1
}
load_env() {
if [ ! -f "$ENV_FILE" ]; then
echo "Missing env file: $ENV_FILE" >&2
echo "Copy ${SCRIPT_DIR}/.env.example to ${SCRIPT_DIR}/.env and fill AUTOFIX_WEBHOOK_SECRET first." >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
if [ -z "${AUTOFIX_WEBHOOK_SECRET:-}" ]; then
echo "AUTOFIX_WEBHOOK_SECRET is required in $ENV_FILE" >&2
exit 1
fi
}
write_codex_rules() {
local rules_dir="${REPO_DIR}/.codex/execpolicy"
local rules_file="${rules_dir}/autofix.rules"
mkdir -p "$rules_dir"
cat > "$rules_file" <<'EOF'
prefix_rule(pattern=["git", "fetch"], decision="allow")
prefix_rule(pattern=["git", "pull"], decision="allow")
prefix_rule(pattern=["git", "push"], decision="allow")
prefix_rule(pattern=["git", "commit"], decision="allow")
prefix_rule(pattern=["git", "status"], decision="allow")
prefix_rule(pattern=["git", "log"], decision="allow")
prefix_rule(pattern=["git", "merge"], decision="allow")
prefix_rule(pattern=["git", "stash"], decision="allow")
prefix_rule(pattern=["git", "checkout"], decision="allow")
prefix_rule(pattern=["git", "diff"], decision="allow")
prefix_rule(pattern=["git", "add"], decision="allow")
prefix_rule(pattern=["gh", "repo"], decision="allow")
prefix_rule(pattern=["gh", "issue"], decision="allow")
prefix_rule(pattern=["gh", "pr"], decision="allow")
prefix_rule(pattern=["gh", "run"], decision="allow")
prefix_rule(pattern=["gh", "workflow"], decision="allow")
prefix_rule(pattern=["gh", "search"], decision="allow")
prefix_rule(pattern=["gh", "api"], decision="allow")
prefix_rule(pattern=["npm", "ci"], decision="allow")
prefix_rule(pattern=["npm", "install"], decision="allow")
prefix_rule(pattern=["npm", "run"], decision="allow")
prefix_rule(pattern=["npm", "test"], decision="allow")
prefix_rule(pattern=["docker", "compose"], decision="allow")
prefix_rule(pattern=["docker-compose"], decision="allow")
EOF
if [ -n "${HTTPS_PROXY:-}" ]; then
cat >> "$rules_file" <<EOF
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=\"${HTTPS_PROXY}\" git fetch"], decision="allow")
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=\"${HTTPS_PROXY}\" git pull"], decision="allow")
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=\"${HTTPS_PROXY}\" git push"], decision="allow")
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=\"${HTTPS_PROXY}\" gh"], decision="allow")
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=${HTTPS_PROXY} git fetch"], decision="allow")
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=${HTTPS_PROXY} git pull"], decision="allow")
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=${HTTPS_PROXY} git push"], decision="allow")
prefix_rule(pattern=["/bin/bash", "-lc", "HTTPS_PROXY=${HTTPS_PROXY} gh"], decision="allow")
EOF
fi
echo "Wrote Codex rules: $rules_file"
}
write_systemd_service() {
local python_bin
python_bin="$(command -v python3)"
cat > "$SERVICE_PATH" <<EOF
[Unit]
Description=HayFrp Verify Autofix Listen
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=${REPO_DIR}
EnvironmentFile=${ENV_FILE}
ExecStart=${python_bin} ${SCRIPT_DIR}/listener.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
echo "Wrote systemd service: $SERVICE_PATH"
}
wait_for_listener() {
local host="127.0.0.1"
local port="${AUTOFIX_PORT:-8765}"
local url="http://${host}:${port}/health"
local max_wait="${AUTOFIX_INSTALL_WAIT_SECONDS:-30}"
local waited=0
echo "Waiting for autofix listener at $url ..."
while [ "$waited" -lt "$max_wait" ]; do
if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then
echo "Autofix listener is ready."
return 0
fi
sleep 1
waited=$((waited + 1))
done
echo "Autofix listener did not become ready within ${max_wait}s." >&2
systemctl status "$SERVICE_NAME" --no-pager >&2 || true
exit 1
}
verify_endpoint_without_token() {
local host="127.0.0.1"
local port="${AUTOFIX_PORT:-8765}"
local url="http://${host}:${port}/autofix/github/issues"
local body_file
body_file="$(mktemp)"
local status_code
set +e
status_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X POST "$url" \
-H "Content-Type: application/json" \
-d '{"action":"opened","issue_number":0,"repository_full_name":"HayFrp-Team/Verify"}')"
local curl_code=$?
set -e
if [ "$curl_code" -ne 0 ]; then
echo "curl verification failed to connect to $url" >&2
cat "$body_file" >&2 || true
rm -f "$body_file"
exit 1
fi
if [ "$status_code" != "401" ]; then
echo "Expected unauthenticated webhook check to return 401, got $status_code" >&2
cat "$body_file" >&2 || true
rm -f "$body_file"
exit 1
fi
rm -f "$body_file"
echo "Unauthenticated curl check returned 401 as expected."
}
main() {
require_root
require_command python3
require_command codex
require_command gh
require_command git
require_command curl
require_command systemctl
require_docker_compose
load_env
write_codex_rules
write_systemd_service
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
wait_for_listener
verify_endpoint_without_token
echo "Autofix listener installed and running."
}
main "$@"
listener.py:
注:基本上使用stdlib,应该不需要另外装依赖
#!/usr/bin/env python3
"""Webhook listener that starts one Codex autofix session per GitHub issue event."""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import hashlib
import hmac
import json
import os
import re
import secrets
import subprocess
import threading
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs
BASE_DIR = Path(__file__).resolve().parent
REPO_DIR = BASE_DIR.parents[1]
DEFAULT_ENV_FILE = BASE_DIR / ".env"
DEFAULT_PROMPT = BASE_DIR / "codex-prompt.md"
DEFAULT_LOG_DIR = BASE_DIR / "logs"
DEFAULT_SESSION_CSV = DEFAULT_LOG_DIR / "autofix-sessions.csv"
JOB_LOCK = threading.Lock()
ACTIVE_JOB: dict[str, Any] | None = None
JOBS: dict[str, dict[str, Any]] = {}
SESSION_ID_KEYS = {"thread_id", "session_id", "conversation_id"}
SESSION_CSV_FIELDS = [
"issue_number",
"pr_number",
"repository",
"session_id",
"resume_command",
"job_id",
"log_path",
"metadata_path",
"updated_at",
]
def parse_env_line(line: str) -> tuple[str, str] | None:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
return None
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip()
if not key:
return None
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
return key, value
def load_env_file(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
parsed = parse_env_line(line)
if parsed:
key, value = parsed
os.environ.setdefault(key, value)
def env(name: str, default: str | None = None) -> str | None:
return os.environ.get(name, default)
def resolve_path(value: str | None, default: Path, base: Path = BASE_DIR) -> Path:
if not value:
return default.resolve()
path = Path(value)
if not path.is_absolute():
path = base / path
return path.resolve()
def resolve_log_dir() -> Path:
return resolve_path(env("AUTOFIX_LOG_DIR"), DEFAULT_LOG_DIR)
def resolve_session_csv(log_dir: Path) -> Path:
return resolve_path(env("AUTOFIX_SESSION_CSV"), DEFAULT_SESSION_CSV, BASE_DIR)
def required_secret() -> str:
secret = env("AUTOFIX_WEBHOOK_SECRET")
if not secret:
raise RuntimeError("AUTOFIX_WEBHOOK_SECRET is required")
return secret
def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
def verify_github_signature(handler: BaseHTTPRequestHandler, body: bytes) -> bool:
provided = handler.headers.get("X-Hub-Signature-256", "")
if not provided.startswith("sha256="):
return False
expected_digest = hmac.new(required_secret().encode("utf-8"), body, hashlib.sha256).hexdigest()
return secrets.compare_digest(provided, f"sha256={expected_digest}")
def read_body(handler: BaseHTTPRequestHandler) -> bytes:
length = int(handler.headers.get("Content-Length", "0"))
if length <= 0:
return b""
return handler.rfile.read(length)
def parse_json_body(body: bytes) -> dict[str, Any]:
if not body:
return {}
return json.loads(body.decode("utf-8"))
def parse_webhook_payload(body: bytes, content_type: str) -> dict[str, Any]:
if "application/x-www-form-urlencoded" in content_type:
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
values = parsed.get("payload")
if not values:
raise ValueError("missing form payload")
return json.loads(values[0])
return parse_json_body(body)
def labels_from_issue(issue: dict[str, Any]) -> list[str]:
labels = issue.get("labels") or []
result = []
for item in labels:
if isinstance(item, dict) and item.get("name"):
result.append(str(item["name"]))
elif isinstance(item, str):
result.append(item)
return result
def normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
issue = payload.get("issue") or {}
label = payload.get("label") or {}
repository = payload.get("repository") or {}
number = payload.get("issue_number") or issue.get("number")
repo_full_name = payload.get("repository_full_name") or repository.get("full_name") or env("AUTOFIX_GITHUB_REPO", "HayFrp-Team/Verify")
if not number:
raise ValueError("missing issue number")
return {
"action": payload.get("action") or "opened",
"event": payload.get("event") or "issues",
"issue_number": int(number),
"issue_title": issue.get("title") or payload.get("issue_title") or "",
"issue_body": issue.get("body") or payload.get("issue_body") or "",
"issue_url": issue.get("html_url") or payload.get("issue_url") or "",
"labels": labels_from_issue(issue) or payload.get("labels") or [],
"trigger_label": label.get("name") or "",
"repository": repo_full_name,
"sender": (payload.get("sender") or {}).get("login") or payload.get("sender") or "",
}
def normalize_pr_payload(payload: dict[str, Any], event: str) -> dict[str, Any]:
issue = payload.get("issue") or {}
pull_request = payload.get("pull_request") or {}
review = payload.get("review") or {}
comment = payload.get("comment") or {}
repository = payload.get("repository") or {}
pr_number = pull_request.get("number") or issue.get("number")
repo_full_name = repository.get("full_name") or env("AUTOFIX_GITHUB_REPO", "HayFrp-Team/Verify")
if not pr_number:
raise ValueError("missing pull request number")
return {
"event": event,
"action": payload.get("action") or "",
"repository": repo_full_name,
"pr_number": int(pr_number),
"pr_title": pull_request.get("title") or issue.get("title") or "",
"pr_body": pull_request.get("body") or "",
"pr_url": pull_request.get("html_url") or issue.get("html_url") or "",
"review_state": review.get("state") or "",
"review_body": review.get("body") or "",
"comment_body": comment.get("body") or "",
"comment_url": comment.get("html_url") or review.get("html_url") or "",
"sender": (payload.get("sender") or {}).get("login") or "",
}
def autofix_label() -> str:
return (env("AUTOFIX_LABEL", "autofix") or "autofix").strip().lower()
def should_process_issue(issue_context: dict[str, Any]) -> tuple[bool, str]:
action = issue_context["action"]
if action == "opened":
return True, "new issue"
if action == "labeled" and issue_context.get("trigger_label", "").strip().lower() == autofix_label():
return True, "autofix label"
return False, "ignored issue action or label"
def should_resume_pr_event(pr_context: dict[str, Any]) -> tuple[bool, str]:
event = pr_context["event"]
action = pr_context["action"]
sender = pr_context.get("sender", "")
if sender.endswith("[bot]"):
return False, "ignored bot feedback"
if event == "issue_comment" and action == "created":
return True, "pull request comment"
if event == "pull_request_review" and action == "submitted":
if pr_context.get("review_state", "").lower() in {"changes_requested", "commented"}:
return True, "pull request review"
if event == "pull_request_review_comment" and action == "created":
return True, "pull request review comment"
return False, "ignored pull request event"
def build_prompt(issue_context: dict[str, Any], prompt_path: Path, log_dir: Path) -> str:
base_prompt = prompt_path.read_text(encoding="utf-8")
context = json.dumps(issue_context, ensure_ascii=False, indent=2)
return (
f"{base_prompt}\n\n"
"## Current Issue Context\n\n"
f"```json\n{context}\n```\n\n"
"## Runtime Paths\n\n"
f"- Repository root: `{REPO_DIR}`\n"
f"- Autofix directory: `{BASE_DIR}`\n"
f"- Summary documents directory: `{log_dir}`\n\n"
"Resolve this issue now. Use the issue number above for branch naming, commit messages, and PR linking.\n"
"The listener records the Codex thread id from this run so humans can resume the session later if needed.\n"
)
def build_resume_prompt(pr_context: dict[str, Any], log_dir: Path) -> str:
context = json.dumps(pr_context, ensure_ascii=False, indent=2)
return (
"A pull request bound to this autofix session received new feedback.\n\n"
"Inspect the latest PR comments/reviews, implement the requested changes, verify the result, commit, push, and update the existing PR.\n"
"Keep following AGENT.md and chore/autofix/codex-prompt.md, including commit hygiene and runtime-log rules.\n\n"
"## Current Pull Request Feedback Context\n\n"
f"```json\n{context}\n```\n\n"
"## Runtime Paths\n\n"
f"- Repository root: `{REPO_DIR}`\n"
f"- Autofix directory: `{BASE_DIR}`\n"
f"- Summary documents directory: `{log_dir}`\n"
)
def codex_environment() -> dict[str, str]:
command_env = os.environ.copy()
for proxy_key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
command_env.pop(proxy_key, None)
git_author_name = env("AUTOFIX_GIT_AUTHOR_NAME", "HayFrp Verify Autofix")
git_author_email = env("AUTOFIX_GIT_AUTHOR_EMAIL", "autofix@user.hayfrp.com")
if git_author_name:
command_env.setdefault("GIT_AUTHOR_NAME", git_author_name)
command_env.setdefault("GIT_COMMITTER_NAME", git_author_name)
if git_author_email:
command_env.setdefault("GIT_AUTHOR_EMAIL", git_author_email)
command_env.setdefault("GIT_COMMITTER_EMAIL", git_author_email)
return command_env
def codex_command(repo_dir: Path) -> list[str]:
codex_bin = env("AUTOFIX_CODEX_BIN", "codex") or "codex"
sandbox = env("AUTOFIX_CODEX_SANDBOX", "workspace-write") or "workspace-write"
approval = env("AUTOFIX_CODEX_APPROVAL", "on-request") or "on-request"
return [
codex_bin,
"-c",
'approvals_reviewer="auto_review"',
"-a",
approval,
"exec",
"--cd",
str(repo_dir),
"--sandbox",
sandbox,
"--json",
"-",
]
def codex_resume_command(session_id: str, prompt: str) -> list[str]:
codex_bin = env("AUTOFIX_CODEX_BIN", "codex") or "codex"
approval = env("AUTOFIX_CODEX_APPROVAL", "on-request") or "on-request"
return [
codex_bin,
"-c",
'approvals_reviewer="auto_review"',
"-a",
approval,
"resume",
session_id,
prompt,
]
def find_session_id(value: Any) -> str | None:
if isinstance(value, dict):
for key in SESSION_ID_KEYS:
found = value.get(key)
if isinstance(found, str) and found:
return found
for item in value.values():
found = find_session_id(item)
if found:
return found
elif isinstance(value, list):
for item in value:
found = find_session_id(item)
if found:
return found
return None
def extract_codex_session_id(log_path: Path) -> str | None:
if not log_path.exists():
return None
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if not stripped.startswith("{"):
continue
try:
event = json.loads(stripped)
except json.JSONDecodeError:
continue
found = find_session_id(event)
if found:
return found
return None
def write_job_metadata(job: dict[str, Any], metadata_path: Path) -> None:
metadata_path.write_text(json.dumps(job, ensure_ascii=False, indent=2), encoding="utf-8")
def read_session_rows(csv_path: Path) -> list[dict[str, str]]:
if not csv_path.exists():
return []
with csv_path.open("r", encoding="utf-8", newline="") as csv_file:
return list(csv.DictReader(csv_file))
def write_session_rows(csv_path: Path, rows: list[dict[str, str]]) -> None:
csv_path.parent.mkdir(parents=True, exist_ok=True)
with csv_path.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=SESSION_CSV_FIELDS)
writer.writeheader()
for row in rows:
writer.writerow({field: row.get(field, "") for field in SESSION_CSV_FIELDS})
def upsert_session_row(csv_path: Path, values: dict[str, Any]) -> None:
rows = read_session_rows(csv_path)
issue_number = str(values.get("issue_number") or "")
pr_number = str(values.get("pr_number") or "")
session_id = str(values.get("session_id") or "")
matched = False
for row in rows:
same_issue = issue_number and row.get("issue_number") == issue_number
same_pr = pr_number and row.get("pr_number") == pr_number
same_session = session_id and row.get("session_id") == session_id
if same_issue or same_pr or same_session:
row.update({key: str(value) for key, value in values.items() if value not in {None, ""}})
matched = True
break
if not matched:
rows.append({key: str(value) for key, value in values.items() if value not in {None, ""}})
write_session_rows(csv_path, rows)
def find_session_for_pr(csv_path: Path, pr_number: int, issue_number: int | None = None) -> dict[str, str] | None:
rows = read_session_rows(csv_path)
for row in reversed(rows):
if row.get("pr_number") == str(pr_number) and row.get("session_id"):
return row
if issue_number is not None:
for row in reversed(rows):
if row.get("issue_number") == str(issue_number) and row.get("session_id"):
return row
return None
def extract_issue_number(text: str) -> int | None:
patterns = [
r"(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)",
r"#(\d+)",
]
for pattern in patterns:
found = re.search(pattern, text or "", re.IGNORECASE)
if found:
return int(found.group(1))
return None
def fetch_pr_body(repository: str, pr_number: int, repo_dir: Path) -> str:
command = ["gh", "pr", "view", str(pr_number), "--repo", repository, "--json", "body", "--jq", ".body"]
completed = subprocess.run(
command,
text=True,
cwd=repo_dir,
env=codex_environment(),
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=30,
check=False,
)
if completed.returncode != 0:
return ""
return completed.stdout.strip()
def run_codex_job(job_id: str, issue_context: dict[str, Any], prompt: str, repo_dir: Path, log_dir: Path) -> None:
started_at = dt.datetime.now(dt.UTC)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{started_at:%Y%m%dT%H%M%SZ}-issue-{issue_context['issue_number']}-{job_id}.log"
metadata_path = log_path.with_suffix(".json")
job = JOBS[job_id]
job.update(
{
"status": "running",
"started_at": started_at.isoformat(),
"log_path": str(log_path),
"metadata_path": str(metadata_path),
}
)
write_job_metadata(job, metadata_path)
command = codex_command(repo_dir)
timeout = int(env("AUTOFIX_CODEX_TIMEOUT", "7200") or "7200")
try:
with log_path.open("w", encoding="utf-8") as log_file:
log_file.write(f"Command: {' '.join(command)}\n\n")
log_file.flush()
completed = subprocess.run(
command,
input=prompt,
text=True,
cwd=repo_dir,
env=codex_environment(),
stdout=log_file,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
)
session_id = extract_codex_session_id(log_path)
if session_id:
job["codex_session_id"] = session_id
job["resume_command"] = f"codex resume {session_id}"
upsert_session_row(
resolve_session_csv(log_dir),
{
"issue_number": issue_context["issue_number"],
"repository": issue_context["repository"],
"session_id": session_id,
"resume_command": job["resume_command"],
"job_id": job_id,
"log_path": log_path,
"metadata_path": metadata_path,
"updated_at": dt.datetime.now(dt.UTC).isoformat(),
},
)
job["status"] = "completed" if completed.returncode == 0 else "failed"
job["returncode"] = completed.returncode
except subprocess.TimeoutExpired:
job["status"] = "timeout"
job["returncode"] = None
except Exception as exc: # noqa: BLE001 - the webhook must record unexpected launcher failures.
job["status"] = "launcher_error"
job["error"] = str(exc)
finally:
job["finished_at"] = dt.datetime.now(dt.UTC).isoformat()
write_job_metadata(job, metadata_path)
global ACTIVE_JOB
with JOB_LOCK:
ACTIVE_JOB = None
def run_codex_resume_job(job_id: str, pr_context: dict[str, Any], prompt: str, repo_dir: Path, log_dir: Path, session_id: str) -> None:
started_at = dt.datetime.now(dt.UTC)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{started_at:%Y%m%dT%H%M%SZ}-pr-{pr_context['pr_number']}-resume-{job_id}.log"
metadata_path = log_path.with_suffix(".json")
job = JOBS[job_id]
job.update(
{
"status": "running",
"started_at": started_at.isoformat(),
"log_path": str(log_path),
"metadata_path": str(metadata_path),
"resume_command": f"codex resume {session_id}",
}
)
write_job_metadata(job, metadata_path)
command = codex_resume_command(session_id, prompt)
timeout = int(env("AUTOFIX_CODEX_TIMEOUT", "7200") or "7200")
try:
with log_path.open("w", encoding="utf-8") as log_file:
log_file.write(f"Command: {' '.join(command[:-1])} <prompt>\n\n")
log_file.flush()
completed = subprocess.run(
command,
text=True,
cwd=repo_dir,
env=codex_environment(),
stdout=log_file,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
)
job["status"] = "completed" if completed.returncode == 0 else "failed"
job["returncode"] = completed.returncode
except subprocess.TimeoutExpired:
job["status"] = "timeout"
job["returncode"] = None
except Exception as exc: # noqa: BLE001 - the webhook must record unexpected launcher failures.
job["status"] = "launcher_error"
job["error"] = str(exc)
finally:
job["finished_at"] = dt.datetime.now(dt.UTC).isoformat()
write_job_metadata(job, metadata_path)
global ACTIVE_JOB
with JOB_LOCK:
ACTIVE_JOB = None
def start_job(issue_context: dict[str, Any]) -> dict[str, Any]:
global ACTIVE_JOB
repo_dir = resolve_path(env("AUTOFIX_REPO_DIR"), REPO_DIR, REPO_DIR)
prompt_path = resolve_path(env("AUTOFIX_PROMPT_PATH"), DEFAULT_PROMPT)
log_dir = resolve_log_dir()
prompt = build_prompt(issue_context, prompt_path, log_dir)
job_id = uuid.uuid4().hex[:12]
job = {
"id": job_id,
"status": "queued",
"issue_number": issue_context["issue_number"],
"repository": issue_context["repository"],
"created_at": dt.datetime.now(dt.UTC).isoformat(),
}
with JOB_LOCK:
if ACTIVE_JOB:
raise RuntimeError(f"another job is active: {ACTIVE_JOB['id']}")
ACTIVE_JOB = job
JOBS[job_id] = job
thread = threading.Thread(target=run_codex_job, args=(job_id, issue_context, prompt, repo_dir, log_dir), daemon=True)
thread.start()
return job
def resume_pr_job(pr_context: dict[str, Any]) -> dict[str, Any]:
global ACTIVE_JOB
repo_dir = resolve_path(env("AUTOFIX_REPO_DIR"), REPO_DIR, REPO_DIR)
log_dir = resolve_log_dir()
csv_path = resolve_session_csv(log_dir)
issue_number = extract_issue_number(pr_context.get("pr_body", ""))
if issue_number is None:
pr_body = fetch_pr_body(pr_context["repository"], pr_context["pr_number"], repo_dir)
issue_number = extract_issue_number(pr_body)
session_row = find_session_for_pr(csv_path, pr_context["pr_number"], issue_number)
if not session_row:
raise RuntimeError(f"no recorded Codex session for PR #{pr_context['pr_number']}")
upsert_session_row(
csv_path,
{
"issue_number": issue_number or session_row.get("issue_number", ""),
"pr_number": pr_context["pr_number"],
"repository": pr_context["repository"],
"session_id": session_row["session_id"],
"resume_command": f"codex resume {session_row['session_id']}",
"updated_at": dt.datetime.now(dt.UTC).isoformat(),
},
)
prompt = build_resume_prompt(pr_context, log_dir)
job_id = uuid.uuid4().hex[:12]
job = {
"id": job_id,
"status": "queued",
"issue_number": issue_number or session_row.get("issue_number", ""),
"pr_number": pr_context["pr_number"],
"repository": pr_context["repository"],
"codex_session_id": session_row["session_id"],
"created_at": dt.datetime.now(dt.UTC).isoformat(),
}
with JOB_LOCK:
if ACTIVE_JOB:
raise RuntimeError(f"another job is active: {ACTIVE_JOB['id']}")
ACTIVE_JOB = job
JOBS[job_id] = job
thread = threading.Thread(target=run_codex_resume_job, args=(job_id, pr_context, prompt, repo_dir, log_dir, session_row["session_id"]), daemon=True)
thread.start()
return job
class AutofixHandler(BaseHTTPRequestHandler):
server_version = "HayFrpAutofix/1.0"
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler uses this naming convention.
if self.path == "/health":
json_response(self, 200, {"ok": True, "active_job": ACTIVE_JOB})
return
if self.path.startswith("/jobs/"):
job_id = self.path.rsplit("/", 1)[-1]
job = JOBS.get(job_id)
json_response(self, 200 if job else 404, job or {"error": "job not found"})
return
json_response(self, 404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler uses this naming convention.
if self.path != "/autofix/github/issues":
json_response(self, 404, {"error": "not found"})
return
body = read_body(self)
if not verify_github_signature(self, body):
json_response(self, 401, {"error": "invalid webhook signature"})
return
try:
event = self.headers.get("X-GitHub-Event", "")
delivery = self.headers.get("X-GitHub-Delivery", "")
payload = parse_webhook_payload(body, self.headers.get("Content-Type", ""))
if event == "ping":
json_response(self, 200, {"accepted": False, "event": "ping", "delivery": delivery, "message": "pong"})
return
if event in {"issue_comment", "pull_request_review", "pull_request_review_comment"}:
if event == "issue_comment" and not (payload.get("issue") or {}).get("pull_request"):
json_response(self, 202, {"accepted": False, "event": event, "delivery": delivery, "reason": "ignored non-PR issue comment"})
return
pr_context = normalize_pr_payload(payload, event)
pr_context["delivery"] = delivery
should_resume, reason = should_resume_pr_event(pr_context)
if not should_resume:
json_response(
self,
202,
{
"accepted": False,
"reason": reason,
"event": event,
"action": pr_context["action"],
},
)
return
job = resume_pr_job(pr_context)
json_response(self, 202, {"accepted": True, "job": job, "reason": reason})
return
if event and event != "issues":
json_response(self, 202, {"accepted": False, "event": event, "delivery": delivery, "reason": "ignored event"})
return
issue_context = normalize_payload(payload)
issue_context["event"] = event or issue_context["event"]
issue_context["delivery"] = delivery
should_process, reason = should_process_issue(issue_context)
if not should_process:
json_response(
self,
202,
{
"accepted": False,
"reason": reason,
"action": issue_context["action"],
"trigger_label": issue_context.get("trigger_label", ""),
},
)
return
job = start_job(issue_context)
except RuntimeError as exc:
json_response(self, 409, {"error": str(exc)})
return
except Exception as exc: # noqa: BLE001 - return a useful webhook error for malformed payloads.
json_response(self, 400, {"error": str(exc)})
return
json_response(self, 202, {"accepted": True, "job": job})
def log_message(self, fmt: str, *args: Any) -> None:
print(f"{dt.datetime.now(dt.UTC).isoformat()} {self.client_address[0]} {fmt % args}", flush=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="HayFrp Verify Codex autofix webhook listener")
parser.add_argument("--env-file", default=env("AUTOFIX_ENV_FILE", str(DEFAULT_ENV_FILE)))
parser.add_argument("--host", default=None)
parser.add_argument("--port", type=int, default=None)
return parser.parse_args()
def main() -> None:
args = parse_args()
load_env_file(resolve_path(args.env_file, DEFAULT_ENV_FILE, BASE_DIR))
host = args.host or env("AUTOFIX_HOST", "127.0.0.1") or "127.0.0.1"
port = args.port or int(env("AUTOFIX_PORT", "8765") or "8765")
required_secret()
server = ThreadingHTTPServer((host, port), AutofixHandler)
print(f"autofix webhook listening on http://{host}:{port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
codex_prompt.md:
# Codex Automatic Issue Development Prompt
You are running inside an unattended automation job for the HayFrp Verify repository.
Your task is to solve exactly one GitHub issue, commit the result, push a branch, and open a pull request for human review.
## Required Context
1. Read repository agent guidance first:
- Read `AGENT.md` before editing.
- If `AGENT.md` is missing, read `AGENTS.md` if present.
- If both are missing, read `CLAUDE.md`.
2. Use `gh issue view` to read the issue details, comments, labels, and current state before editing code.
3. Inspect the codebase before changing files. Do not assume the implementation location.
4. Scan the affected domain before making edits, including nearby routes, services, schemas, components, tests, and DB schema files when relevant.
5. Check `.codex/execpolicy/autofix.rules` before running commands so you know what command families are allowed.
6. After reading the issue and code, explicitly identify the problem or requested scope before coding.
7. Generate a short Plan before execution. Keep exactly one step in progress while working.
8. Complete the Plan step by step and update it as scope changes.
## Branch Workflow
1. Start from `master`.
2. Pull the latest `master` before creating the work branch.
3. Create one branch for the issue.
4. Branch naming:
- Bug issues use `fix/<issue-number>-<slug>`.
- Feature/enhancement issues use `feat/<issue-number>-<slug>`.
- Example: `[Feature] Automatic Develop` issue `#38` becomes `feat/38-automatic-develop`.
5. If a branch already exists, inspect it before deciding whether to reuse it or create a suffixed branch. Do not overwrite remote work.
## Implementation Workflow
1. Make the smallest complete change that resolves the issue.
2. Preserve existing behavior unless the issue explicitly requests a behavior change.
3. Add or update tests when practical.
4. Run relevant verification commands. At minimum:
- Frontend changes: run `npm run build` from `frontend/`.
- Backend Python changes: run focused tests or `python -m py_compile` for touched modules.
- Integration-sensitive changes: use Docker Compose, for example `docker compose build`, `docker compose up`, or focused service commands when appropriate.
5. If verification cannot be run, explain why in the pull request body and in the final log document.
## GitHub And Network Rules
Use `gh` CLI for issue and pull request operations.
Prefer direct commands so execpolicy can match the real command family. Do not use shell wrappers unless the exact wrapper form is present in `.codex/execpolicy/autofix.rules`.
The command argv must start with the real executable that the rules allow, for example:
- `git fetch upstream`
- `git pull upstream master`
- `git push origin <branch>`
- `gh issue view <number>`
- `npm run build`
- `docker compose build`
If `HTTPS_PROXY` must be injected inline, use only the exact `/bin/bash -lc "HTTPS_PROXY=... git/gh ..."` forms generated by `install.sh`. Other shell-wrapped command forms hide the real command from execpolicy prefix matching and can cause unattended jobs to fail.
Git operations must be authorized by `.codex/execpolicy/autofix.rules` because `.git` is read-only inside the normal workspace sandbox.
For git write or network operations, do not try the normal sandbox first. You must request elevated execution for these direct commands from the start:
- `git add`
- `git commit`
- `git checkout`
- `git merge`
- `git stash`
- `git pull`
- `git push`
- Other `git ...` commands that write `.git` or access the network
Use the direct `git ...` command with elevated execution. In Codex shell/tool calls, this means setting `sandbox_permissions` to `require_escalated` for those git commands and providing a short justification. Auto review will evaluate the request against the rules instead of asking a human. If an elevated git command is denied, check the command form against `.codex/execpolicy/autofix.rules` and retry only with a rules-covered direct command.
Never edit files inside `.git` manually. Do not create, modify, remove, or work around `.git/index.lock`. If git reports `could not create .git/index.lock` or any `.git` permission problem, that means the command was not run with the required elevated execution. Do not report the task as blocked after a normal sandbox failure; rerun the same proper direct git command with rules-authorized elevated execution.
If a git command fails due to policy, verify that the command form is covered by the rules and retry using an allowed direct command form.
Networked `git` and `gh` commands must use the configured proxy environment when a proxy is configured:
```bash
HTTPS_PROXY="$HTTPS_PROXY"
https_proxy="$HTTPS_PROXY"
```
If `HTTPS_PROXY` is empty or unset, run the commands without a proxy prefix.
For pushes, push the branch to `origin` by default unless the issue context clearly requires a different remote.
## Commit And Pull Request
1. Commit with a Conventional Commits message in English.
2. The commit message must be detailed enough for review. Prefer a subject plus body.
3. The commit message must close the issue, for example `Closes #38`.
4. Push the branch.
5. Create a pull request with `gh pr create`.
6. Target `HayFrp-Team/Verify` `master`.
7. Use a detailed English PR body containing:
- Summary
- Verification
- Linked issue, for example `Closes #38`
8. Do not merge the PR. Human reviewers merge.
## Commit Hygiene
Before staging or committing, run `git status --short --untracked-files=all` and inspect every path.
Never stage or commit runtime, secret, cache, or local automation files, including:
- `.env`, `.env.*`, and any file containing real secrets.
- `chore/autofix/.env`.
- `chore/autofix/logs/` and any other `logs/` directory.
- `.codex/`, `.agents/`, local Codex session files, or generated execpolicy files.
- `node_modules/`, `dist/`, `__pycache__/`, `.pytest_cache/`, virtual environments, and other generated caches.
Do not use `git add -f` to force ignored files into a commit. If a required source file is ignored unexpectedly, stop and explain the issue instead of force-adding it.
## Allowed Automation Command Families
Stay within these command families unless the issue explicitly requires otherwise. If a needed command is outside this allowlist, stop and explain the missing command instead of trying to bypass the policy.
- `git`: `fetch`, `pull`, `push`, `commit`, `status`, `log`, `merge`, `stash`, `checkout`, `diff`, `add`
- `gh`: `repo`, `issue`, `pr`, `run`, `workflow`, `search`, `api`
- `npm`: `ci`, `install`, `run`, `test`
- `docker compose` / `docker-compose`: build, start, stop, logs, and focused service verification
- Project-local build/test commands needed for verification
Avoid destructive commands. Do not use `git reset --hard`, `git clean`, or force-push unless the issue explicitly requires it and the PR body explains why.
## Completion Criteria
The job is complete only when:
1. The issue has been implemented.
2. Verification has run or the PR explains why it could not run.
3. A commit exists on the issue branch.
4. The branch has been pushed.
5. A pull request has been created.
6. A Markdown summary document has been written under `chore/autofix/logs/`.
## Final Summary Document
Before finishing, create a Markdown summary file under `chore/autofix/logs/`.
This summary is a runtime artifact only. It must remain untracked and must not be staged or committed.
The filename should include the issue number and a timestamp, for example:
```text
chore/autofix/logs/issue-38-20260607T120000Z.md
```
The document must include:
- Issue number and title
- Understood scope
- Work plan
- Files changed
- Verification commands and results
- Commit hash
- Pull request URL
DEPLOYMENT.md:
# Automatic Develop Deployment Guide
This directory contains a minimal webhook service for issue-driven Codex automation.
## Scope
The service is intended for issue automation only:
1. GitHub receives a new issue event.
2. A repository webhook calls this listener directly.
3. The webhook starts one fresh `codex exec` session in the repository directory.
4. Codex reads the prompt, reads repository agent guidance, fixes the issue, commits, pushes, and creates a PR.
5. Humans review and merge the PR.
The webhook does not resume old Codex sessions. One issue event creates one independent Codex conversation.
## Files
- `install.sh`: install systemd service for `listener`, load project Codex execpolicy rules to `.codex/execpolicy`
- `codex-prompt.md`: the prompt loaded into Codex for each issue.
- `listener.py`: stdlib-only Python webhook server.
- `.env`: env file for `listener`, loaded by systemd.
- `logs/`: runtime logs created by the webhook service. Do not commit logs.
## Prerequisites
- Python 3.11 or newer.
- `codex` CLI installed and authenticated for the automation user.
- `gh` CLI installed and authenticated with permission to read issues and create PRs.
- Docker Compose available through `docker compose` or `docker-compose`.
- Git remotes configured in the repository:
- `upstream` -> `https://github.com/HayFrp-Team/Verify.git`
- `origin` -> the writable fork or bot repository.
- A git/gh network proxy (not necessary), specified in env var `HTTPS_PROXY`.
- A webhook secret, specified in env var `AUTOFIX_WEBHOOK_SECRET`, configured as the GitHub webhook secret.
## Codex Rules
Install the rules file into the repository's project-level Codex execpolicy directory:
```bash
export HTTPS_PROXY="http://your-proxy-ip:port"
sudo chmod +x install.sh
./install.sh
```
The rules allow these command families:
- `git`: `fetch`, `pull`, `push`, `commit`, `status`, `log`, `merge`, `stash`, `checkout`, `diff`, `add`
- `gh`: `repo`, `issue`, `pr`, `run`, `workflow`, `search`, `api`
- `npm`: `ci`, `install`, `run`, `test`
- `docker compose` and `docker-compose`: local build/runtime smoke tests
Networked git/gh commands can set `HTTPS_PROXY` / `https_proxy` from the environment when a proxy is configured. The listener removes these variables before launching Codex so model API requests do not use this proxy.
Agents should prefer direct commands so the command argv starts with the real executable, for example `git`, `gh`, `npm`, or `docker`. When `HTTPS_PROXY` must be injected inline, `install.sh` also generates exact `/bin/bash -lc "HTTPS_PROXY=... git/gh ..."` allow rules. Do not use shell-wrapped command forms unless they are explicitly generated in `.codex/execpolicy/autofix.rules`.
Git write and network operations must be executed with rules-authorized elevation because `.git` is read-only in the normal workspace sandbox. Agents should not try normal sandbox execution first for `git add`, `git commit`, `git checkout`, `git merge`, `git stash`, `git pull`, or `git push`; they must run the proper direct `git ...` command with elevated execution, using `sandbox_permissions=require_escalated` when invoking shell/tool calls. Agents must never create, edit, delete, or work around `.git/index.lock`.
Change `.codex/execpolicy/autofix.rules` to edit the allowed command range after installation.
The webhook invokes:
```bash
codex -c 'approvals_reviewer="auto_review"' -a on-request exec --cd /path/to/Verify --sandbox workspace-write --json -
```
The prompt constrains the agent to the allowed automation flow and command families.
It also requires agents to read `AGENT.md`, inspect the affected code domain, create a Plan before implementation, and write detailed Conventional Commit messages that close the issue.
Use `AUTOFIX_CODEX_APPROVAL="on-request"` for unattended operation. This lets the model request elevated execution when sandbox or network limits apply, while `approvals_reviewer="auto_review"` evaluates those requests against execpolicy rules instead of asking a human.
## Resume Codex Jobs
The listener runs Codex with `--json` and records the emitted Codex `thread_id`.
For every job, the listener writes:
- A `.log` file with raw Codex JSONL output.
- A `.json` metadata file with job status, `codex_session_id`, and `resume_command` when the session id is available.
The metadata file is stored beside the log file under `chore/autofix/logs/`.
The listener also records issue/PR/session bindings in `chore/autofix/logs/autofix-sessions.csv` by default.
To resume a job manually:
```bash
codex resume <codex_session_id>
```
or copy the `resume_command` value from the job metadata JSON.
## Environment
Move `.env.example` to `.env`, and fill it with correct values.
DO NOT commit `.env`!
`.env` file will be loaded by systemd.
Required:
```
AUTOFIX_WEBHOOK_SECRET="replace-with-a-long-random-webhook-secret"
```
Recommended:
```bash
HTTPS_PROXY="http://your-proxy-ip:port"
AUTOFIX_REPO_DIR="/path/to/Verify"
AUTOFIX_HOST="127.0.0.1"
AUTOFIX_PORT="8765"
AUTOFIX_GITHUB_REPO="HayFrp-Team/Verify"
AUTOFIX_LABEL="autofix"
AUTOFIX_CODEX_BIN="codex"
AUTOFIX_CODEX_SANDBOX="workspace-write"
AUTOFIX_CODEX_APPROVAL="on-request"
AUTOFIX_CODEX_TIMEOUT="7200"
AUTOFIX_GIT_AUTHOR_NAME="HayFrp Verify Autofix"
AUTOFIX_GIT_AUTHOR_EMAIL="autofix@user.hayfrp.com"
```
Optional:
```bash
AUTOFIX_PROMPT_PATH="codex-prompt.md"
AUTOFIX_LOG_DIR="/path/to/Verify/chore/autofix/logs"
AUTOFIX_SESSION_CSV="/path/to/Verify/chore/autofix/logs/autofix-sessions.csv"
```
## Run Manually
```bash
python3 listener.py --host 127.0.0.1 --port 8765
```
Health check:
```bash
curl http://127.0.0.1:8765/health
```
Format and follow the newest autofix Codex log:
```bash
python3 tail_logs.py
```
Read a specific log without following:
```bash
python3 tail_logs.py logs/20260609T070524Z-issue-54-1b2c662a6f30.log --no-follow
```
Manual issue trigger:
```bash
payload='{"action":"opened","issue_number":38,"repository_full_name":"HayFrp-Team/Verify"}'
signature="$(AUTOFIX_WEBHOOK_SECRET="$AUTOFIX_WEBHOOK_SECRET" PAYLOAD="$payload" python3 - <<'PY'
import hashlib
import hmac
import os
secret = os.environ["AUTOFIX_WEBHOOK_SECRET"].encode()
payload = os.environ["PAYLOAD"].encode()
print("sha256=" + hmac.new(secret, payload, hashlib.sha256).hexdigest())
PY
)"
curl -X POST http://127.0.0.1:8765/autofix/github/issues \
-H "X-Hub-Signature-256: $signature" \
-H "Content-Type: application/json" \
-d "$payload"
```
## systemd Service
Installation script will automatically employ systemd service at `/etc/systemd/system/hayfrp-verify-autofix.service`:
```ini
[Unit]
Description=HayFrp Verify Autofix Listen
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/path/to/Verify
EnvironmentFile=/path/to/Verify/chore/autofix/.env
ExecStart=/usr/bin/python3 /path/to/Verify/chore/autofix/listener.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
## Nginx Reverse Proxy
Expose only the webhook path and keep the backend bound to `127.0.0.1`:
```nginx
location /autofix/github/issues {
proxy_pass http://127.0.0.1:8765/autofix/github/issues;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## GitHub Repository Webhook
Use a GitHub Repository Webhook as the only automatic issue trigger.
Why this is preferable:
- It does not consume Actions minutes.
- It starts faster because no runner has to be scheduled.
- It keeps "issue received" and "autofix started" as a direct server-side integration.
Configure it in GitHub:
1. Open repository settings.
2. Go to `Webhooks`.
3. Add a webhook.
4. Set `Payload URL` to the public URL mapped to `/autofix/github/issues`.
5. Set `Content type` to `application/json`.
6. Set `Secret` to the same value as `AUTOFIX_WEBHOOK_SECRET`.
7. Select `Let me select individual events`.
8. Enable `Issues`, `Issue comments`, `Pull request reviews`, and `Pull request review comments`.
9. Save the webhook.
The listener verifies `X-Hub-Signature-256` using `AUTOFIX_WEBHOOK_SECRET`. Requests without a valid signature return `401` and do not start Codex.
GitHub sends a `ping` event when the webhook is created or tested. The listener returns `200` with a `pong` response for that event and does not start Codex.
`application/json` is recommended. The listener also accepts GitHub's `application/x-www-form-urlencoded` format when the JSON payload is sent in the `payload` form field.
Autofix starts only for these issue events:
- `opened`: every newly created issue.
- `labeled`: existing issues only when the added label matches `AUTOFIX_LABEL` (`autofix` by default).
Autofix resumes an existing Codex session for these PR feedback events when a matching session is recorded in `AUTOFIX_SESSION_CSV`:
- `issue_comment.created` on a pull request.
- `pull_request_review.submitted` with `changes_requested` or `commented`.
- `pull_request_review_comment.created`.
Other issue changes return `202` with an ignored reason and do not start Codex.
## Operational Notes
- The webhook accepts one active Codex job at a time and returns `409` when another job is running.
- Job logs are written under `/path/to/Verify/chore/autofix/logs/` by default.
- Do not commit generated logs.
- The prompt requires agents to write summary files under `chore/autofix/logs/`, but those files are runtime artifacts and must remain untracked.
- Agents must inspect `git status --short --untracked-files=all` before committing and must not use `git add -f` to force ignored `.env`, `logs/`, `.codex/`, cache, or build output files into git.
- Use human review for every generated PR. The automation must not merge PRs.
- Keep webhook secrets out of git and rotate them if they are exposed.
.env.example:
AUTOFIX_WEBHOOK_SECRET="replace-with-a-long-random-webhook-secret"
# Optional network proxy for git and gh network operations. Codex API requests do not use this proxy.
HTTPS_PROXY=""
# Paths may be absolute or relative. Relative AUTOFIX_REPO_DIR is resolved from the repository root.
# Relative AUTOFIX_PROMPT_PATH and AUTOFIX_LOG_DIR are resolved from chore/autofix/.
AUTOFIX_REPO_DIR=""
AUTOFIX_PROMPT_PATH="codex-prompt.md"
AUTOFIX_LOG_DIR="logs"
AUTOFIX_SESSION_CSV="logs/autofix-sessions.csv"
AUTOFIX_HOST="127.0.0.1"
AUTOFIX_PORT="8765"
AUTOFIX_GITHUB_REPO="HayFrp-Team/Verify"
AUTOFIX_LABEL="autofix"
AUTOFIX_CODEX_BIN="codex"
AUTOFIX_CODEX_SANDBOX="workspace-write"
AUTOFIX_CODEX_APPROVAL="on-request"
AUTOFIX_CODEX_TIMEOUT="7200"
AUTOFIX_INSTALL_WAIT_SECONDS="30"
AUTOFIX_GIT_AUTHOR_NAME="HayFrp Verify Autofix"
AUTOFIX_GIT_AUTHOR_EMAIL="autofix@user.hayfrp.com"
tail_logs.py:
注:我还写了这个脚本,格式化看日志,会追踪,毕竟tail -f出来一堆json看着头大。
#!/usr/bin/env python3
"""Format and follow Codex JSONL logs produced by the autofix listener."""
from __future__ import annotations
import argparse
import json
import sys
import time
from collections import deque
from pathlib import Path
from typing import Any
BASE_DIR = Path(__file__).resolve().parent
DEFAULT_LOG_DIR = BASE_DIR / "logs"
def find_latest_log(log_dir: Path) -> Path:
logs = sorted(log_dir.glob("*.log"), key=lambda path: path.stat().st_mtime)
if not logs:
raise FileNotFoundError(f"no .log files found in {log_dir}")
return logs[-1]
def truncate(text: str, limit: int) -> str:
if limit <= 0 or len(text) <= limit:
return text
return text[:limit].rstrip() + f"\n... <truncated {len(text) - limit} chars>"
def indent(text: str, prefix: str = " ") -> str:
return "\n".join(prefix + line if line else prefix.rstrip() for line in text.splitlines())
def format_command_item(item: dict[str, Any], output_limit: int) -> str:
status = item.get("status") or "unknown"
command = item.get("command") or ""
exit_code = item.get("exit_code")
header = f"[cmd:{status}] {command}"
if exit_code is not None:
header += f" (exit {exit_code})"
output = item.get("aggregated_output") or ""
if output:
return header + "\n" + indent(truncate(str(output).rstrip(), output_limit))
return header
def format_file_change_item(item: dict[str, Any]) -> str:
status = item.get("status") or "unknown"
changes = item.get("changes") or []
paths = []
for change in changes:
if isinstance(change, dict):
path = change.get("path") or "unknown"
kind = change.get("kind") or "change"
paths.append(f"{kind}:{path}")
suffix = ", ".join(paths) if paths else "no paths"
return f"[files:{status}] {suffix}"
def format_todo_item(item: dict[str, Any]) -> str:
entries = item.get("items") or []
lines = ["[todo]"]
for entry in entries:
if not isinstance(entry, dict):
continue
marker = "x" if entry.get("completed") else " "
lines.append(f" [{marker}] {entry.get('text', '')}")
return "\n".join(lines)
def format_item(item: dict[str, Any], output_limit: int) -> str:
item_type = item.get("type")
if item_type == "agent_message":
return "[agent] " + str(item.get("text") or "")
if item_type == "command_execution":
return format_command_item(item, output_limit)
if item_type == "file_change":
return format_file_change_item(item)
if item_type == "todo_list":
return format_todo_item(item)
return f"[item:{item_type or 'unknown'}] {json.dumps(item, ensure_ascii=False)}"
def format_event(event: dict[str, Any], output_limit: int) -> str:
event_type = event.get("type")
if event_type == "thread.started":
return f"[thread] {event.get('thread_id', '')}"
if event_type == "turn.started":
return "[turn] started"
if event_type == "turn.completed":
usage = event.get("usage") or {}
if usage:
return "[turn] completed " + json.dumps(usage, ensure_ascii=False)
return "[turn] completed"
if event_type == "turn.failed":
return "[turn] failed " + json.dumps(event.get("error") or {}, ensure_ascii=False)
if event_type == "error":
return "[error] " + str(event.get("message") or "")
if event_type == "item.started":
return "[start] " + format_item(event.get("item") or {}, output_limit)
if event_type == "item.completed":
return format_item(event.get("item") or {}, output_limit)
if event_type == "item.updated":
return "[update] " + format_item(event.get("item") or {}, output_limit)
return f"[{event_type or 'event'}] {json.dumps(event, ensure_ascii=False)}"
def format_line(line: str, output_limit: int) -> str | None:
stripped = line.rstrip("\n")
if not stripped:
return None
if not stripped.startswith("{"):
return "[log] " + stripped
try:
event = json.loads(stripped)
except json.JSONDecodeError:
return "[raw] " + stripped
return format_event(event, output_limit)
def print_formatted(line: str, output_limit: int) -> None:
formatted = format_line(line, output_limit)
if formatted:
print(formatted, flush=True)
def read_initial_lines(path: Path, from_start: bool, lines: int) -> list[str]:
with path.open("r", encoding="utf-8", errors="replace") as log_file:
if from_start:
return log_file.readlines()
return list(deque(log_file, maxlen=lines))
def follow_file(path: Path, output_limit: int, interval: float) -> None:
with path.open("r", encoding="utf-8", errors="replace") as log_file:
log_file.seek(0, 2)
while True:
line = log_file.readline()
if line:
print_formatted(line, output_limit)
continue
time.sleep(interval)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Format and follow HayFrp Verify autofix Codex logs.")
parser.add_argument("logfile", nargs="?", help="Log file to read. Defaults to the newest .log file.")
parser.add_argument("--log-dir", default=str(DEFAULT_LOG_DIR), help="Directory used when selecting the newest log.")
parser.add_argument("--lines", type=int, default=80, help="Initial tail lines to print before following.")
parser.add_argument("--from-start", action="store_true", help="Print the whole file before following.")
parser.add_argument("--no-follow", action="store_true", help="Print initial content and exit.")
parser.add_argument("--interval", type=float, default=1.0, help="Follow polling interval in seconds.")
parser.add_argument("--output-limit", type=int, default=2000, help="Maximum command output chars per event. Use 0 for unlimited.")
return parser.parse_args()
def main() -> None:
args = parse_args()
path = Path(args.logfile).resolve() if args.logfile else find_latest_log(Path(args.log_dir).resolve())
print(f"[logfile] {path}", flush=True)
for line in read_initial_lines(path, args.from_start, args.lines):
print_formatted(line, args.output_limit)
if not args.no_follow:
try:
follow_file(path, args.output_limit, args.interval)
except KeyboardInterrupt:
print("\n[tail] stopped", file=sys.stderr)
if __name__ == "__main__":
main()
.gitignore:
注:日志等不建议上传git;.env禁止上传git
.env
**/logs/
chore/autofix/logs/
.codex/execpolicy/
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!
标签:
相关文章
最新发布
- 精选 5 款基于 .NET 开源免费、功能强大的 Windows 系统优化工具
- 补充MySQL官网知识--解锁Online VARCHAR字段扩展与Index的关系
- MACD:面向大语言模型的自学习知识多智能体临床诊断(可靠、可解释且可部署的 AI 辅助诊断系统)
- 使用 dotnet-counters 观测升讯威客服系统内存占用情况和数据吞吐性能
- [开源] Meta Assistant / 告别命令行,我为一堆 Python 脚本做了一个 Windows 任务栏的“家”
- 【Agentic RL / 强化学习框架】Uni-Agent 深度技术分析(1)--- 总体
- 不做通用AI助手,先做好一个垂直Agent
- Molio 开源:把知识库、AI 写作、排版和多平台发布串成一条工作流
- imx415 启动HDR场景
- 【Vibe Coding】折腾了一个高考假,我让Codex自动修Issue...

