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

@@ -34,6 +34,7 @@ func main() {
tg := telegram.NewClient(cfg.TelegramToken) tg := telegram.NewClient(cfg.TelegramToken)
codex := codexapp.New(cfg.CodexSocketPath, cfg.AppVersion) codex := codexapp.New(cfg.CodexSocketPath, cfg.AppVersion)
codex.SetLogger(logger)
defer codex.Close() defer codex.Close()
bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.CodexHome, cfg.CodexStateDB, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger) bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.CodexHome, cfg.CodexStateDB, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger)

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -26,6 +27,7 @@ type Client struct {
pending map[string]chan rpcResult pending map[string]chan rpcResult
events chan Event events chan Event
connected bool connected bool
logger *log.Logger
} }
type Event struct { 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 { func (c *Client) Events() <-chan Event {
return c.events return c.events
} }
@@ -242,6 +250,7 @@ func (c *Client) StartThread(ctx context.Context, cwd, model, sandbox string) (T
params := map[string]any{ params := map[string]any{
"cwd": cwd, "cwd": cwd,
"approvalPolicy": "on-request", "approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": threadSandbox(sandbox), "sandbox": threadSandbox(sandbox),
"serviceName": "codex_telegram_bot", "serviceName": "codex_telegram_bot",
} }
@@ -330,6 +339,7 @@ func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningE
"threadId": threadID, "threadId": threadID,
"input": input, "input": input,
"approvalPolicy": "on-request", "approvalPolicy": "on-request",
"approvalsReviewer": "user",
} }
if strings.TrimSpace(cwd) != "" { if strings.TrimSpace(cwd) != "" {
params["cwd"] = cwd params["cwd"] = cwd
@@ -463,6 +473,7 @@ func (c *Client) readLoop(conn *websocket.Conn) {
return return
} }
if env.Method != "" && hasID { if env.Method != "" && hasID {
c.logProtocolEvent("server request", env.Method, id.Key(), env.Params)
c.events <- Event{ c.events <- Event{
ID: &id, ID: &id,
Method: env.Method, Method: env.Method,
@@ -476,6 +487,9 @@ func (c *Client) readLoop(conn *websocket.Conn) {
continue continue
} }
if env.Method != "" { if env.Method != "" {
if shouldLogNotification(env.Method) {
c.logProtocolEvent("notification", env.Method, "", env.Params)
}
c.events <- Event{ c.events <- Event{
Method: env.Method, Method: env.Method,
Params: env.Params, 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) { func (c *Client) completeCall(id RequestID, result json.RawMessage, rpcErr *RPCError) {
c.mu.Lock() c.mu.Lock()
ch := c.pending[id.Key()] ch := c.pending[id.Key()]

View File

@@ -77,6 +77,12 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
serverDone <- unexpectedMessage("thread/start", start["method"]) serverDone <- unexpectedMessage("thread/start", start["method"])
return 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{ if err := conn.WriteJSON(map[string]any{
"id": start["id"], "id": start["id"],
"result": map[string]any{ "result": map[string]any{
@@ -88,6 +94,26 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
return 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 var readThread map[string]any
if err := conn.ReadJSON(&readThread); err != nil { if err := conn.ReadJSON(&readThread); err != nil {
serverDone <- err serverDone <- err
@@ -174,6 +200,14 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
if thread.ID != "thr_1" || thread.CWD != projectCWD { if thread.ID != "thr_1" || thread.CWD != projectCWD {
t.Fatalf("unexpected thread: %+v", thread) 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") readThread, err := client.ReadThread(ctx, "thr_1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -1266,6 +1266,15 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) {
return item, nil 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 { func renderCodexItemStarted(item codexThreadItemView) string {
switch item.Type { switch item.Type {
case "commandExecution": case "commandExecution":
@@ -1702,6 +1711,40 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
return err return err
} }
return b.sendImageOutput(ctx, params.ThreadID, item) 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": case "turn/diff/updated":
var params struct { var params struct {
ThreadID string `json:"threadId"` 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") return errors.New("approval request missing threadId")
} }
itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID) 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) thread, err := b.store.GetThreadByCodexID(ctx, threadID)
if err != nil { if err != nil {
return err 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 { func (b *Bot) hasOutputTurn(threadID, turnID string) bool {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()