#!/usr/bin/env python3 """ Codex app-server 客户端(stdio JSON-RPC,零第三方依赖)。 照搬的是官方 codex 二进制自带的 app-server:本文件不实现 agent 循环、 不实现沙箱、不实现工具,只做一件事——按官方协议跟 app-server 对话: initialize -> initialized -> thread/start -> turn/start -> 流式事件(agent 文本、工具调用、审批请求)-> turn/completed 协议定义见 protocol/codex_app_server_protocol.v2.schemas.json, 由 `codex app-server generate-json-schema` 从官方二进制原样导出。 用法: python3 client.py --prompt "修掉 README 里的错别字" python3 client.py --prompt "..." --cwd /path/to/repo --model gpt-5.x python3 client.py --list-threads python3 client.py --thread-id --prompt "继续" python3 client.py --prompt "..." --approval-policy on-request --approval deny python3 client.py --prompt "..." --sandbox read-only 审批:默认 --approval-policy never,服务端不发审批请求,适合自动化。 若显式用 on-request,--approval auto 会放行、deny 会拒绝(默认 deny)。 注意:子 agent 的审批/沙箱继承父 thread,这里不做额外权限边界, 见 README“子代理”一节。 """ import argparse import json import os import select import subprocess import sys import time CODEX = os.environ.get("CODEX_BIN", "codex") class AppServer: def __init__(self, codex_home=None, extra_args=None): env = dict(os.environ) if codex_home: os.makedirs(codex_home, exist_ok=True) env["CODEX_HOME"] = codex_home cmd = [CODEX, "app-server"] if extra_args: cmd += extra_args self.proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, ) self.buf = b"" self.next_id = 1 self.pending = {} # id -> message self.approval_policy = "deny" def send(self, obj): line = json.dumps(obj) + "\n" self.proc.stdin.write(line.encode()) self.proc.stdin.flush() return obj.get("id") def request(self, method, params=None): rid = self.next_id self.next_id += 1 self.send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}) return rid def notify(self, method, params=None): self.send({"jsonrpc": "2.0", "method": method, "params": params or {}}) def _read_msg(self, timeout): """读一条服务端消息;是服务端请求时先回包,再继续读。""" end = time.time() + timeout while time.time() < end: while b"\n" in self.buf: line, self.buf = self.buf.split(b"\n", 1) if not line.strip(): continue msg = json.loads(line) if "method" in msg and "id" in msg: self._answer_server_request(msg) continue return msg r, _, _ = select.select([self.proc.stdout], [], [], 0.2) if r: chunk = os.read(self.proc.stdout.fileno(), 65536) if not chunk: return None self.buf += chunk if self.proc.poll() is not None: return None return None def _answer_server_request(self, msg): method = msg.get("method", "") # 审批类请求:按策略回包。不同审批方法的回包字段在各版本间有差异, # 这里用最常见的 decision 字段;若服务端不认,会在事件里看到错误, # 此时应改回 approval-policy=never 跑自动化,或在 README 的审批表里查本版字段。 decision = "approve" if self.approval_policy == "auto" else "deny" payload = {"decision": decision} if "approval" in method.lower() or "permission" in method.lower(): print(f"[server-request] {method} -> {decision} " f"(params={json.dumps(msg.get('params', {}))[:400]})", file=sys.stderr) self.send({"jsonrpc": "2.0", "id": msg["id"], "result": payload}) def wait_for(self, rid, timeout=600, on_event=None): """等指定 id 的响应;途中的通知/事件交给 on_event 打印。""" end = time.time() + timeout while time.time() < end: msg = self._read_msg(max(1, end - time.time())) if msg is None: break if msg.get("id") == rid: return msg if on_event: on_event(msg) return None def initialize(self): rid = self.request("initialize", { "clientInfo": {"name": "codex-app-server-client", "version": "0.1"}, }) resp = self.wait_for(rid, timeout=60) if not resp or "error" in resp: raise RuntimeError(f"initialize 失败: {resp}") self.notify("initialized", {}) return resp["result"] def close(self): try: self.proc.stdin.close() except Exception: pass try: self.proc.wait(timeout=5) except Exception: self.proc.kill() def handle_event(msg): method = msg.get("method", "") params = msg.get("params", {}) or {} # 文本增量:不同版本的字段名略有差异,尽量都覆盖 if method in ("thread/item/updated", "item/updated", "agent/message/delta", "thread/item/started", "thread/item/completed"): item = params.get("item") or params text = item.get("text") or item.get("delta") or "" if not text and isinstance(item.get("content"), list): text = "".join(c.get("text", "") for c in item["content"] if isinstance(c, dict)) if text and item.get("type") in ("agentMessage", None, "agent_message"): print(text, end="", flush=True) elif method == "turn/completed": print(f"\n[turn/completed] {json.dumps(params)[:800]}") elif "completed" in method or "started" in method: print(f"\n[{method}] {json.dumps(params)[:500]}", file=sys.stderr) elif "error" in str(params).lower(): print(f"\n[{method}] {json.dumps(params)[:800]}", file=sys.stderr) def main(): ap = argparse.ArgumentParser() ap.add_argument("--prompt", help="本回合发给 agent 的文本") ap.add_argument("--cwd", default=os.getcwd()) ap.add_argument("--model", default=None) ap.add_argument("--sandbox", default="workspace-write", choices=["read-only", "workspace-write", "danger-full-access"]) ap.add_argument("--approval-policy", default="never", choices=["never", "on-request", "untrusted"]) ap.add_argument("--approval", default="deny", choices=["auto", "deny"]) ap.add_argument("--effort", default=None, help="本回合推理档位,如 low/medium/high/xhigh/max/ultra") ap.add_argument("--thread-id", default=None, help="续跑已有 thread,不传则新建") ap.add_argument("--list-threads", action="store_true") ap.add_argument("--codex-home", default=None, help="默认用 ~/.codex;测试时可指向空目录隔离") ap.add_argument("--timeout", type=int, default=600) args = ap.parse_args() srv = AppServer(codex_home=args.codex_home) srv.approval_policy = args.approval try: info = srv.initialize() print(f"[initialize] codexHome={info.get('codexHome')} " f"platform={info.get('platformOs')}", file=sys.stderr) if args.list_threads: rid = srv.request("thread/list", {}) print(json.dumps(srv.wait_for(rid, timeout=60), indent=2, ensure_ascii=False)) return if args.thread_id: rid = srv.request("thread/resume", {"threadId": args.thread_id}) resp = srv.wait_for(rid, timeout=60) if not resp or "error" in resp: print(f"thread/resume 失败: {resp}", file=sys.stderr) return thread_id = args.thread_id print(f"[thread/resumed] {thread_id}", file=sys.stderr) else: params = {"cwd": os.path.abspath(args.cwd), "approvalPolicy": args.approval_policy, "sandbox": args.sandbox} if args.model: params["model"] = args.model rid = srv.request("thread/start", params) resp = srv.wait_for(rid, timeout=60) if not resp or "error" in resp: print(f"thread/start 失败: {resp}", file=sys.stderr) return thread = resp["result"]["thread"] thread_id = thread["id"] print(f"[thread/started] {thread_id} " f"model={resp['result'].get('model')}", file=sys.stderr) if not args.prompt: print(f"thread_id={thread_id}") return turn_params = { "threadId": thread_id, "input": [{"type": "text", "text": args.prompt}], } if args.model: turn_params["model"] = args.model if args.effort: turn_params["effort"] = args.effort rid = srv.request("turn/start", turn_params) # turn/start 的响应本身不代表完成;完成以 turn/completed 通知为准 print("[turn/started] 等待 turn/completed …", file=sys.stderr) end = time.time() + args.timeout while time.time() < end: msg = srv._read_msg(max(1, end - time.time())) if msg is None: break if msg.get("id") == rid and "error" in msg: print(f"turn/start 出错: {msg}", file=sys.stderr) break if msg.get("method") == "turn/completed": print(f"\n[done] {json.dumps(msg.get('params', {}))[:1000]}") break handle_event(msg) finally: srv.close() if __name__ == "__main__": main()