DEV Community

Cover image for osFoundry처럼 Codex CLI를 Python으로 감싸는 방법
BBT for BBT 한국어

Posted on

osFoundry처럼 Codex CLI를 Python으로 감싸는 방법

osFoundry 데스크톱 앱에서 Codex의 "로그인 확인"을 누르면 아래 명령이 실행된다. 명령을 조립하는 건 src-tauri/src/providers/agent_cli.rs의 build_codex_command이고, cli:codex:agent로 도는 실제 작업도 전부 이 함수를 거친다.

codex exec "Reply with the single word: ok" \
  --json \
  --skip-git-repo-check \
  --ignore-user-config \
  --ignore-rules \
  -s read-only \
  -c 'approval_policy="never"'
Enter fullscreen mode Exit fullscreen mode

하나씩 뜯어보자.

  • exec 뒤의 프롬프트는 위치 인자 하나다. Codex에는 시스템 프롬프트를 넣는 플래그가 없다. 그래서 osFoundry는 system + "\n\n" + 대화 기록을 문자열 하나로 합쳐서 넘긴다.
  • --json을 붙이면 stdout이 한 줄에 JSON 객체 하나씩 나온다. osFoundry가 읽는 출력은 이게 전부다.
  • --skip-git-repo-check가 있으면 git 저장소가 아닌 폴더에서도 실행이 시작된다.
  • --ignore-user-config는 ~/.codex/config.toml을 읽지 않게 한다. 내가 따로 붙여 둔 MCP 서버가 osFoundry 실행에 끼어들지 않는다. 인증은 CODEX_HOME에서 읽으니 로그인은 그대로 살아 있다.
  • --ignore-rules는 codex exec --help에 이 플래그가 보일 때만 붙인다. 구버전 Codex는 모르는 플래그를 받으면 실행 자체가 죽는다. 그래서 버전 표를 따로 관리하지 않고 바이너리에 직접 물어본다.
  • -s와 approval_policy="never"는 샌드박스 종류를 정하고, 승인해 줄 사람이 없다고 알려 준다. 자율 실행에는 -s workspace-write가 붙는다. osFoundry의 MCP 브리지를 붙이는 실행만 대신 --dangerously-bypass-approvals-and-sandbox가 붙는데, 이것도 git worktree 안에서만 그렇다.

프로세스를 띄우기 전에 osFoundry는 자식 프로세스 환경에서 CODEX_API_KEY, OPENAI_API_KEY, OPENAI_BASE_URL을 뺀다. 이 값들이 남아 있으면 실행이 ChatGPT 로그인이 아니라 종량제 API 계정으로 과금되기 때문이다.

이 글에서 다루는 건 이 파이프의 반대편이다. 스트림 형식이 약속되지 않은 에이전트 CLI에서 답과 진행 상황, 그리고 깔끔한 실패를 꺼내야 한다.

최종 답 파일 대신 왜 이벤트를 읽나

이런 CLI에서 답을 받는 방법은 두 가지다.

  1. 최종 답을 파일로 받는다. 출력 경로를 넘기고(codex exec -o FILE, osFoundry 모듈 주석에도 문서화된 파일 출력으로 적혀 있다), 프로세스가 끝나길 기다렸다가 파일을 읽는다. 계약은 "파일 하나에 문자열 하나"가 전부다.
  2. stdout 이벤트를 읽는다. --json을 붙여 한 줄씩 읽고, 아는 아이템만 골라낸다.

지금 osFoundry는 2번이다. 파서는 runtime/session_event.rs의 parse_codex_line에 있고, 보는 게 생각보다 적다.

이벤트 바뀌는 것
item.completed + agent_message 답 텍스트
item.completed + reasoning Thinking: … 줄
item.completed + command_execution Using shell — <command>
item.completed + mcp_tool_call Using <tool>… (Codex는 server와 tool을 따로 준다)
item.completed + file_change / todo_list Edited <path> / Plan: 1. … 2. …
turn.completed 사용량 (input_tokens, cached_input_tokens, output_tokens)
turn.failed, error 실패. 메시지가 인증 오류처럼 보이면 인증 플래그도 세운다

나머지 이벤트는 버리고, JSON이 아닌 줄도 건너뛴다. 스키마가 고정되지 않은 상대인데도 돌아가는 건 이 관대함 덕분이다. 모르는 이벤트 타입이 늘어도 손해가 없다. 깨질 수 있는 건 위 일곱 줄에 걸린 필드 이름이 바뀔 때뿐이다.

대신 단위가 굵다. Codex가 내보내는 건 토큰 델타가 아니라 완료된 아이템이다. 답은 메시지가 다 써진 순간에 한꺼번에 도착한다. 앱이 Codex에 "실시간 스트림 없음"을 붙이고 run-then-emit이라고 설명하는 이유다. 진행 줄은 아이템이 끝날 때마다 하나씩 온다.

Mac에서 둘 다 다시 만들어 봤다

이 Mac에는 Codex 바이너리가 없다. 그래서 fake_codex.py라는 대역을 만들었다. 같은 플래그를 받고, 위의 이벤트 타입을 정해진 지연으로 찍고, 종료할 때 -o 파일에 최종 메시지를 쓴다. 멈추기, 크래시, 인증 실패도 시킬 수 있다. Codex가 아니니까 잴 수 있는 건 래퍼 쪽 동작이지 Codex의 속도가 아니다.

대역의 타임라인은 이렇다. 0.4초에 reasoning이 나온다. 이어서 셸 자식 프로세스(sleep 0.8)를 띄운다. 그로부터 약 1.3초 뒤에 agent_message, 다시 0.2초 뒤에 turn.completed. 답 파일은 종료 시점에 쓴다.

래퍼 wrap.py는 Python 3.9.6 표준 라이브러리만 썼다. 이벤트 방식의 핵심만 추리면 이렇다.

def run_events(argv, timeout, env=None, group=True, drain_stderr=True):
    t0 = time.monotonic()
    p = subprocess.Popen(argv + ["--json"], stdin=subprocess.DEVNULL,
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         env=env, start_new_session=group)
    err = []
    if drain_stderr:
        threading.Thread(target=_drain, args=(p.stderr, err), daemon=True).start()
    lines = queue.Queue()
    threading.Thread(target=pump, args=(p.stdout, lines), daemon=True).start()

    deadline = t0 + timeout
    while True:
        left = deadline - time.monotonic()
        if left <= 0:
            os.killpg(p.pid, signal.SIGKILL) if group else p.kill()
            r["error"] = "timed out after %ss" % timeout
            break
        try:
            at, raw = lines.get(timeout=left)
        except queue.Empty:
            continue
        if raw is None:                        # stdout closed
            break
        try:
            v = json.loads(raw)
        except ValueError:
            continue                           # log line on stdout
        t = v.get("type")
        if t == "item.completed":
            item = v.get("item", {})
            if item.get("type") == "agent_message":
                r["text"] += item.get("text", "")
            else:
                r["steps"].append(item.get("type"))
        elif t == "turn.completed":
            r["usage"] = v.get("usage")
        elif t in ("turn.failed", "error"):
            r["error"] = (v.get("error") or {}).get("message") or v.get("message")
    p.wait()
    if not r["error"] and not r["text"].strip():
        r["error"] = "returned no output (%s)" % tail(err)[-200:]
Enter fullscreen mode Exit fullscreen mode

파일 방식은 --json 대신 -o <tmp>로 띄우고, p.wait(timeout=…)로 기다린 다음, 파일이 있으면 읽는 게 끝이다.

첫 바이트까지 vs 답까지

환경은 Apple M1, macOS 15.8.1이다. 방식마다 20번 돌려 중앙값을 냈고, Popen 직전부터 time.monotonic()으로 쟀다(bench.py).

이벤트 (--json) 파일 (-o)
stdout 첫 바이트 0.034초 없음
첫 완료 아이템 0.443초 없음
답을 받은 시점 2.562초 2.824초
프로세스 종료 2.775초 2.824초
성공 20 / 20 20 / 20

절대값은 대역의 sleep이 정한 숫자다. 대역과 상관없이 남는 건 두 가지다.

  • 파일 방식은 끝날 때까지 아무것도 안 나온다. 2.8초 동안 호출하는 쪽은 "생각 중"과 "멈춤"을 구분할 수 없다. 이벤트 방식은 첫 진행 줄이 0.44초에 오고, 바이트는 0.034초부터 흐른다. 이건 거의 Python 기동 비용이다(빈 python3 -c pass가 평균 19ms였다).
  • 파일은 CLI가 마지막 처리까지 끝내야 생긴다. 이번엔 stdout에 메시지가 뜬 뒤 0.26초 늦었다. 실제 에이전트라면 메시지를 다 쓴 뒤 종료까지 뭘 하느냐에 따라 이 차이가 달라진다.

실패는 각각 어떻게 보이나

같은 래퍼로 케이스마다 한 번씩 돌렸다.

케이스 종료 코드 이벤트 방식 파일 방식
답 전에 크래시 101 에러. stderr 끝부분을 힌트로 붙여 0.44초 에러(답 파일 없음) 0.51초
인증 실패 (turn.failed 401) 1 401 Unauthorized: please log in, 인증 플래그 답 파일 없음 + stderr 문장
메시지 뒤 kill (turn.completed 없음) 137 답 유지, 에러 없음 답 파일 없음
3초 기한을 넘겨 멈춤 -9 3.004초에 timed out after 3s 3.027초에 timed out after 3s

중요한 건 두 줄이다.

먼저 인증. 이벤트 방식은 로그인 문제라는 걸 구조화된 필드로 알려 준다. 파일 방식은 stderr 문장을 보고 짐작하는 수밖에 없다. osFoundry는 메시지를 짧은 단어 목록(401, unauthorized, please log in, sign in 등)과 대조해서 앱 쪽 로그인 안내를 띄운다.

다음은 메시지 뒤 kill. 여기서 두 방식의 답이 갈린다. 이벤트를 읽었다면 완전한 agent_message가 있고 turn.completed만 없다. 파일 방식은 아무것도 남지 않는다. osFoundry는 스트림 내용으로 판정한다. turn.failed/error가 왔거나 텍스트가 한 글자도 안 왔으면 실패고, 종료 코드는 판정에 넣지 않는다. turn.completed가 빠진 걸 "부분 결과"로 다루고 싶다면 손댈 곳이 바로 여기다.

타임아웃은 뭘 죽이나

osFoundry는 실행 전체를 tokio::time::timeout으로 감싼다. 자율 실행은 1,800초, 로그인 확인처럼 스트리밍하지 않는 완성은 300초다. 자식은 kill_on_drop(true)로 띄우기 때문에, 타임아웃으로 future가 버려지면 프로세스도 같이 죽는다.

그런데 에이전트 CLI는 자기 자식 프로세스를 따로 둔다. 대역은 "셸 도구"로 sleep 30을 띄우고 그걸 기다리며 멈춘다. 래퍼가 돌아오고 0.3초 뒤에 그 PID가 살아 있는지 확인했다.

kill 방식 래퍼 복귀 도구 자식 프로세스
p.kill() (직계 자식만) 3.010초 살아 있음
start_new_session=True + os.killpg 3.011초 사라짐

걸린 시간은 같고 뒷정리만 다르다. 도구 자식이 stdout 파이프를 쥐고 있으면, EOF를 기다리는 식의 읽기에는 EOF가 끝까지 안 온다. 위 루프가 readline이 돌아오길 기다리지 않고 기한을 보는 이유다.

stderr는 왜 따로 스레드로 빼나

이런 CLI는 인증 오류와 panic을 stderr에 쓴다. osFoundry도 stderr를 파이프로 받아서 에러 메시지용으로 끝부분(에이전트 경로에서 약 4,000바이트)을 남긴다. 문제는 파이프 버퍼에 한계가 있다는 거다. 이 Mac에선 아무도 안 읽는 stderr에 정확히 65,536바이트를 쓴 시점에서 자식이 멈췄다(pipecap.py). 그래서 대역이 답하기 전에 약 200KB의 트레이스를 쓰게 해 봤다.

stderr 처리 결과
stdout과 동시에 스레드로 읽기 2.56초에 답
stdout이 닫힌 뒤에 읽기 답 없음, timed out after 8s

CLI는 아무도 안 읽는 쓰기에서 막히고, stdout은 안 닫히고, 실행은 기한 초과로 날아간다. 동시에 읽는 비용은 스레드 하나면 된다. 이거 모르고 붙이면 삽질 확정이다.

직접 붙인다면 정해 둘 것

  • 누군가 실행을 지켜본다면 답 파일보다 이벤트 스트림을 고른다. 필요한 이벤트 타입만 보고, JSON이 아닌 줄과 모르는 타입은 실패시키지 말고 건너뛴다.
  • CLI에 시스템 프롬프트 플래그가 없으면, 단순한 구분자를 두고 프롬프트 본문 앞에 붙인다. 이전 턴은 라벨을 단 대화 기록으로 넘긴다.
  • 성공을 종료 코드로 볼지 스트림으로 볼지 먼저 정한다. turn.completed 없는 메시지를 뭘로 칠지도 같이 정한다.
  • 자식은 전용 프로세스 그룹으로 띄우고, 타임아웃 때는 그룹째 kill해서 도구 프로세스도 같이 정리한다.
  • stderr는 동시에 읽고 끝부분만 상한을 둬서 남긴다. 보여 줘야 할 인증 오류가 여기로 온다.
  • 구독 로그인을 종량제 키로 돌려 버리는 환경 변수는 지운다. 선택적 플래그는 버전 표로 관리하지 말고 --help에 물어본다.

여기서 다룬 Codex 항목은 앱의 BYOK 키 다이얼로그 안 "외부 AI" 탭에 있다.

Top comments (0)