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"'
하나씩 뜯어보자.
-
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에서 답을 받는 방법은 두 가지다.
- 최종 답을 파일로 받는다. 출력 경로를 넘기고(
codex exec -o FILE, osFoundry 모듈 주석에도 문서화된 파일 출력으로 적혀 있다), 프로세스가 끝나길 기다렸다가 파일을 읽는다. 계약은 "파일 하나에 문자열 하나"가 전부다. - 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:]
파일 방식은 --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)