這是你在 osFoundry 桌面 App 裡對 Codex 按下 Verify sign-in 時實際執行的指令。它由 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加上 prompt,prompt 是一個位置參數。Codex 沒有 system prompt 的旗標,所以 osFoundry 把這個參數組成system + "\n\n" + transcript。 -
--json讓 stdout 變成每行一個 JSON 物件。osFoundry 只讀這條串流。 -
--skip-git-repo-check讓執行可以在不是 repository 的資料夾裡開始。 -
--ignore-user-config會略過~/.codex/config.toml,使用者自己設定的 MCP server 就不會被帶進這次執行。Auth 仍然從CODEX_HOME載入。 -
--ignore-rules只有在codex exec --help有列出來時才會加上。osFoundry 會先問 binary,因為舊版會拒絕這個旗標,而只要有一個不認得的旗標,整次執行就會失敗。 -
-s搭配approval_policy="never"設定 sandbox,並告訴 Codex 不會有人在旁邊幫它核准任何事。自主執行會拿到-s workspace-write。有掛上 osFoundry MCP bridge 的執行則改用--dangerously-bypass-approvals-and-sandbox,而且只限在 git worktree 裡。
在 spawn 之前,osFoundry 也會從子行程的環境變數裡拿掉 CODEX_API_KEY、OPENAI_API_KEY 和 OPENAI_BASE_URL。只要其中任何一個有設定,這次執行就會跑去按量計費的 API 帳號,而不是用 ChatGPT 的登入。
這篇接下來講的是那條 pipe 的另一端。你手上有一個串流格式不保證穩定的 agent CLI,而你需要從它拿到答案、進度,以及乾淨的失敗結果。
為什麼讀事件,而不是讀最終答案檔?
要從這類 CLI 拿到答案有兩種做法:
- 把最終答案寫到檔案。傳一個輸出路徑(
codex exec -o FILE;osFoundry 自己模組裡的註記稱它為有文件記載的檔案輸出),等行程結束,再讀檔。契約就是一個檔案裡放一個字串。 - stdout 上的事件串流。傳
--json,一行一行讀,只留下你看得懂的項目。
osFoundry 目前的 builder 用的是第二種。Parser 在 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
|
失敗,訊息看起來像登入問題時標記為 auth |
其他一律忽略,不是 JSON 的行也一樣。正是這種容錯,讓它在沒有穩定 schema 的情況下也能用。不認得的事件類型不會造成任何影響。只有這七列裡某個欄位被改名,才會讓 parser 壞掉。
代價是粒度。Codex 送出的是已完成的項目,不是 token delta。答案會在訊息完成時一次出現,所以 App 把 Codex 標成 No live stream,並描述為 run-then-emit。進度行就不一樣了:每完成一個項目就會送來一行。
在 Mac 上把兩種做法重建出來
我用的這台機器沒有安裝 Codex binary。所以我寫了 fake_codex.py,一個替身程式:接受同樣的旗標、以固定延遲印出上面那些事件類型、結束時把最終訊息寫進 -o 檔案,而且可以指定它卡住、crash 或 auth 失敗。它不是 Codex。用它能量到的是 wrapper 的行為,不是 Codex 的速度。
替身的時間軸:0.4 s 送出一個 reasoning 項目,接著一個 shell 子行程(sleep 0.8),大約 1.3 s 後送出 agent_message,再過 0.2 s 送出 turn.completed。答案檔在結束時寫入。
Wrapper 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:]
檔案策略是同樣的 spawn,只是把 --json 換成 -o <tmp>,然後 p.wait(timeout=…),檔案存在的話再讀它。
第一個 byte 的時間 vs. 拿到答案的時間
測試環境:Apple M1、macOS 15.8.1,每種策略跑 20 次,取中位數,用 time.monotonic() 從 Popen 前一刻開始計時(bench.py)。
事件(--json) |
檔案(-o) |
|
|---|---|---|
| 第一個 stdout byte | 0.034 s | 無 |
| 第一個完成的項目 | 0.443 s | 無 |
| 可取得答案 | 2.562 s | 2.824 s |
| 行程結束 | 2.775 s | 2.824 s |
| 成功次數 | 20 / 20 | 20 / 20 |
絕對數字來自替身裡的 sleep。表格裡有兩件事不是:
- 用檔案的話,結束前什麼都不會出來。這 2.8 s 內,呼叫端沒辦法分辨是「在想」還是「卡住了」。用事件的話,第一行進度在 0.44 s 就到,byte 在 0.034 s 就開始出現,差不多就是 Python 本身 spawn 的成本(在這台機器上,空的
python3 -c pass平均 19 ms)。 - 檔案要等 CLI 最後一步做完才會存在。這次執行中,它比訊息出現在 stdout 上晚了 0.26 s。換成真正的 agent,這段落差就是 CLI 在寫完訊息到結束之間做了多少事。
從兩邊看,失敗長什麼樣子?
同一個 wrapper,每種情況跑一次:
| 情況 | exit | 事件策略 | 檔案策略 |
|---|---|---|---|
| 回答前 crash | 101 | 錯誤,附上 stderr 尾段當提示,0.44 s | 錯誤:沒有答案檔,0.51 s |
auth 失敗(turn.failed 401) |
1 |
401 Unauthorized: please log in,標記為 auth |
沒有答案檔 + stderr 文字 |
訊息送出後被 kill(沒有 turn.completed) |
137 | 答案保留,無錯誤 | 沒有答案檔 |
| 卡住超過 3 s 期限 | -9 | 3.004 s 時 timed out after 3s
|
3.027 s 時 timed out after 3s
|
這裡有兩列比較重要。
Auth。事件串流會在結構化欄位裡告訴你這是登入問題。檔案策略只能從 stderr 的文字去猜。osFoundry 會拿訊息去比對一份短清單(401、unauthorized、please log in、sign in、…),然後跳出自己的登入提示。
訊息送出後被 kill。兩種策略在這裡結果不同。讀事件的話,你手上有完整的 agent_message,但沒有 turn.completed。讀檔案的話,你什麼都沒有。osFoundry 是根據串流內容來判斷:收到 turn.failed/error 事件,或完全沒收到任何文字時,這次執行就算失敗,exit status 不列入判斷。如果你想把缺少 turn.completed 視為部分結果,就是在這裡加。
逾時到底 kill 掉了什麼?
osFoundry 用 tokio::time::timeout 包住整次執行:自主執行是 1,800 s,登入探測和其他非串流的 completion 是 300 s。子行程是用 kill_on_drop(true) spawn 的,所以逾時把 future drop 掉的時候,行程就會被 kill。
不過 agent CLI 自己也會開子行程。替身會啟動 sleep 30 當作它的「shell tool」,然後卡在那裡。我在 wrapper 回傳後 0.3 s 檢查了那個 PID:
| Kill 方式 | wrapper 回傳 | 之後的 tool 子行程 |
|---|---|---|
p.kill()(只有直接子行程) |
3.010 s | 還在跑 |
start_new_session=True + os.killpg
|
3.011 s | 已消失 |
時間一樣,清理結果不同。如果 tool 子行程一直佔著你的 stdout pipe,靠 EOF 驅動的 reader 也永遠等不到 EOF,這就是為什麼上面的迴圈是檢查期限,而不是等 readline 回傳。
為什麼要在獨立執行緒上把 stderr 讀乾淨?
這類 CLI 會把 auth 失敗和 panic 寫到 stderr,所以 osFoundry 會把它接成 pipe,並保留尾段(agent 路徑上大約 4,000 bytes)放進錯誤訊息。但 pipe 的緩衝區大小是固定的。在這台 Mac 上,子行程往沒人讀的 stderr 寫到剛好 65,536 bytes 就被阻塞了(pipecap.py)。接著我讓替身在回答前先寫出約 200 KB 的 trace 輸出:
| stderr 處理方式 | 結果 |
|---|---|
| 讀 stdout 的同時在執行緒上讀乾淨 | 2.56 s 取得答案 |
| stdout 關閉後才讀 | 沒有答案,timed out after 8s
|
CLI 卡在一個沒人讀的寫入上,stdout 一直開著,這次執行就這樣耗到期限。同時讀 stderr 的成本只是一條執行緒。
如果你要自己串起來
- 如果有人在看著執行過程,選事件串流,不要用答案檔。只依賴你需要的那幾種事件類型,遇到非 JSON 的行和不認得的類型就跳過,不要因此失敗。
- CLI 沒有 system prompt 旗標時,就把它放進 prompt 文字裡,用簡單的分隔符隔開,之前的對話輪次則保留成加上標籤的 transcript。
- 事先決定成功與否是由 exit code 還是串流來判斷,以及沒有
turn.completed的訊息要算什麼。 - 給子行程自己的 process group,逾時時 kill 整個 group,讓 tool 行程一起結束。
- 同時讀 stderr,並保留有上限的尾段。你會想顯示的 auth 錯誤就在裡面。
- 拿掉會把訂閱登入導向按量計費 key 的環境變數,並用探測
--help的方式處理可選旗標,而不是維護一張版本對照表。
上面說的 Codex 那一列,在 App 的 BYOK keys 對話框 裡的 External AI 分頁。
Top comments (0)