Route Codex approvals to Telegram

Force app-server turns to use the user approval reviewer so command approvals surface in the bot on Codex 0.134.

Add focused protocol logs for approval requests and guardian review events to diagnose silent approval stalls.
This commit is contained in:
Codex
2026-05-28 09:57:43 +00:00
parent 2b0da9f508
commit c00ffb42f2
4 changed files with 129 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"strconv"
@@ -26,6 +27,7 @@ type Client struct {
pending map[string]chan rpcResult
events chan Event
connected bool
logger *log.Logger
}
type Event struct {
@@ -151,6 +153,12 @@ func New(socketPath, version string) *Client {
}
}
func (c *Client) SetLogger(logger *log.Logger) {
c.mu.Lock()
defer c.mu.Unlock()
c.logger = logger
}
func (c *Client) Events() <-chan Event {
return c.events
}
@@ -240,10 +248,11 @@ func (c *Client) StartThread(ctx context.Context, cwd, model, sandbox string) (T
return Thread{}, err
}
params := map[string]any{
"cwd": cwd,
"approvalPolicy": "on-request",
"sandbox": threadSandbox(sandbox),
"serviceName": "codex_telegram_bot",
"cwd": cwd,
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": threadSandbox(sandbox),
"serviceName": "codex_telegram_bot",
}
if model != "" {
params["model"] = model
@@ -327,9 +336,10 @@ func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningE
return Turn{}, err
}
params := map[string]any{
"threadId": threadID,
"input": input,
"approvalPolicy": "on-request",
"threadId": threadID,
"input": input,
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
}
if strings.TrimSpace(cwd) != "" {
params["cwd"] = cwd
@@ -463,6 +473,7 @@ func (c *Client) readLoop(conn *websocket.Conn) {
return
}
if env.Method != "" && hasID {
c.logProtocolEvent("server request", env.Method, id.Key(), env.Params)
c.events <- Event{
ID: &id,
Method: env.Method,
@@ -476,6 +487,9 @@ func (c *Client) readLoop(conn *websocket.Conn) {
continue
}
if env.Method != "" {
if shouldLogNotification(env.Method) {
c.logProtocolEvent("notification", env.Method, "", env.Params)
}
c.events <- Event{
Method: env.Method,
Params: env.Params,
@@ -484,6 +498,29 @@ func (c *Client) readLoop(conn *websocket.Conn) {
}
}
func (c *Client) logProtocolEvent(kind, method, id string, params json.RawMessage) {
c.mu.Lock()
logger := c.logger
c.mu.Unlock()
if logger == nil {
return
}
if id != "" {
logger.Printf("codex %s: method=%s id=%s params_bytes=%d", kind, method, id, len(params))
return
}
logger.Printf("codex %s: method=%s params_bytes=%d", kind, method, len(params))
}
func shouldLogNotification(method string) bool {
switch method {
case "item/guardianApprovalReview/started", "item/guardianApprovalReview/completed", "guardianWarning", "serverRequest/resolved":
return true
default:
return false
}
}
func (c *Client) completeCall(id RequestID, result json.RawMessage, rpcErr *RPCError) {
c.mu.Lock()
ch := c.pending[id.Key()]

View File

@@ -77,6 +77,12 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
serverDone <- unexpectedMessage("thread/start", start["method"])
return
}
startParams := start["params"].(map[string]any)
if startParams["approvalsReviewer"] != "user" || startParams["approvalPolicy"] != "on-request" {
payload, _ := json.Marshal(startParams)
serverDone <- unexpectedMessage("thread/start approval params", string(payload))
return
}
if err := conn.WriteJSON(map[string]any{
"id": start["id"],
"result": map[string]any{
@@ -88,6 +94,26 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
return
}
var turnStart map[string]any
if err := conn.ReadJSON(&turnStart); err != nil {
serverDone <- err
return
}
if turnStart["method"] != "turn/start" {
serverDone <- unexpectedMessage("turn/start", turnStart["method"])
return
}
turnParams := turnStart["params"].(map[string]any)
if turnParams["approvalsReviewer"] != "user" || turnParams["approvalPolicy"] != "on-request" {
payload, _ := json.Marshal(turnParams)
serverDone <- unexpectedMessage("turn/start approval params", string(payload))
return
}
if err := conn.WriteJSON(map[string]any{"id": turnStart["id"], "result": map[string]any{"turn": map[string]any{"id": "turn_1", "status": "running"}}}); err != nil {
serverDone <- err
return
}
var readThread map[string]any
if err := conn.ReadJSON(&readThread); err != nil {
serverDone <- err
@@ -174,6 +200,14 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
if thread.ID != "thr_1" || thread.CWD != projectCWD {
t.Fatalf("unexpected thread: %+v", thread)
}
turn, err := client.StartTurn(ctx, "thr_1", projectCWD, "", "", "workspace-write", []InputItem{{Type: "text", Text: "hello"}})
if err != nil {
t.Fatal(err)
}
if turn.ID != "turn_1" {
t.Fatalf("unexpected turn: %+v", turn)
}
readThread, err := client.ReadThread(ctx, "thr_1")
if err != nil {
t.Fatal(err)

View File

@@ -1266,6 +1266,15 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) {
return item, nil
}
func renderGuardianReviewHTML(title string, raw json.RawMessage) string {
var params map[string]any
if err := json.Unmarshal(raw, &params); err != nil {
return SummaryDetailsHTML(title, string(raw))
}
sections := renderSelectedArgumentDetailsHTML(params, []string{"action", "review", "decisionSource", "targetItemId", "reviewId"})
return SummaryRawHTMLSectionsLimited(title, sections, TelegramHTMLMessageLimit)
}
func renderCodexItemStarted(item codexThreadItemView) string {
switch item.Type {
case "commandExecution":
@@ -1702,6 +1711,40 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
return err
}
return b.sendImageOutput(ctx, params.ThreadID, item)
case "item/guardianApprovalReview/started", "item/guardianApprovalReview/completed":
var params struct {
ThreadID string `json:"threadId"`
TurnID string `json:"turnId"`
ReviewID string `json:"reviewId"`
TargetItemID *string `json:"targetItemId"`
}
if err := json.Unmarshal(event.Params, &params); err != nil {
return err
}
targetItemID := ""
if params.TargetItemID != nil {
targetItemID = *params.TargetItemID
}
b.logger.Printf("codex guardian approval review: method=%s thread=%s turn=%s review=%s target=%s", event.Method, params.ThreadID, params.TurnID, params.ReviewID, targetItemID)
if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
title := "Codex approval auto-review started"
if event.Method == "item/guardianApprovalReview/completed" {
title = "Codex approval auto-review completed"
}
return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderGuardianReviewHTML(title, event.Params))
}
case "guardianWarning":
var params struct {
ThreadID string `json:"threadId"`
Message string `json:"message"`
}
if err := json.Unmarshal(event.Params, &params); err != nil {
return err
}
b.logger.Printf("codex guardian warning: thread=%s message=%q", params.ThreadID, truncateForStatus(params.Message))
if params.ThreadID != "" && b.hasOutputThread(params.ThreadID) {
return b.sendOutputBlock(ctx, params.ThreadID, "Codex warning: "+params.Message)
}
case "turn/diff/updated":
var params struct {
ThreadID string `json:"threadId"`
@@ -1830,6 +1873,7 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
return errors.New("approval request missing threadId")
}
itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID)
b.logger.Printf("codex approval request: method=%s request=%s thread=%s turn=%s item=%s approval=%s call=%s", event.Method, event.ID.Key(), threadID, params.TurnID, params.ItemID, params.ApprovalID, params.CallID)
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
if err != nil {
return err
@@ -1911,6 +1955,12 @@ func (b *Bot) setOutputTurnID(threadID, turnID string) {
}
}
func (b *Bot) hasOutputThread(threadID string) bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.outputs[threadID] != nil
}
func (b *Bot) hasOutputTurn(threadID, turnID string) bool {
b.mu.Lock()
defer b.mu.Unlock()