Skip to content

Commit 6057797

Browse files
Mateuszcursoragent
andcommitted
harden codex ws/compat paths from PR #86 review
Resolve CodeRabbit review findings: - tool-call ID canonicalization: remap provisional item-only IDs onto the learned call_id so args-before-added no longer fragments one tool call into two (openairesponsestream.RemapToolCallID + stream.go) - parameterless object tool schemas inject additionalProperties:false and required:[] so strict:true is accepted by the Responses API - no-tools payloads omit parallel_tool_calls (tool-protocol signal leak) - authjson rejects non-regular token files (FIFO/device) - managed oauth files count skipped symlinks for an accurate error - codexclientcompat drops tool-use instructions when no tools; add nil guard - streamdebug Close always emits the terminal debug record with correct status - gemini handler trims ALegID consistently across EnsureCallDiag calls - openailegacy decode drops dead cp=nil assignment - diag/streamdebug use sort.Strings instead of duplicated insertion sorts - ws dialer documents custom-RoundTripper fallback; drop dead close branch - refbackend SSE event name derived from frame type; WS tests add read deadlines Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9129ff7 commit 6057797

18 files changed

Lines changed: 267 additions & 51 deletions

File tree

internal/core/diag/debug_summary.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package diag
33
import (
44
"log/slog"
55
"os"
6+
"sort"
67
"strconv"
78
"strings"
89
"sync"
@@ -35,11 +36,7 @@ func StableCounts(counts map[string]int) []string {
3536
for k := range counts {
3637
keys = append(keys, k)
3738
}
38-
for i := 1; i < len(keys); i++ {
39-
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
40-
keys[j], keys[j-1] = keys[j-1], keys[j]
41-
}
42-
}
39+
sort.Strings(keys)
4340
out := make([]string, 0, len(keys))
4441
for _, k := range keys {
4542
out = append(out, k+"="+strconv.Itoa(counts[k]))

internal/plugins/backends/openaicodex/authjson.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ func checkTokenFilePermissions(path string) error {
139139
if err != nil {
140140
return nil // let the caller's ReadFile produce the canonical not-exist error
141141
}
142+
if !info.Mode().IsRegular() {
143+
return fmt.Errorf("%s: token file %q is not a regular file", ID, path)
144+
}
142145
if info.Mode().Perm()&0o077 != 0 {
143146
return fmt.Errorf("%s: token file %q is group/other accessible (mode %o); expected 0600", ID, path, info.Mode().Perm())
144147
}

internal/plugins/backends/openaicodex/managed_oauth_files.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func loadManagedAccounts(dir string, filter []string) ([]managedAccount, error)
2525
if ent.Type()&os.ModeSymlink != 0 {
2626
// security: skip symlinked account files so a planted symlink cannot
2727
// read targets outside the managed-oauth storage directory.
28+
skipped++
2829
continue
2930
}
3031
path := filepath.Join(dir, ent.Name())

internal/plugins/backends/openaicodex/payload.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func PayloadForCall(call *lipapi.Call, cand routing.AttemptCandidate, cfg Config
9898
}
9999
if call.Options.ParallelToolCalls != nil {
100100
p.ParallelToolCalls = call.Options.ParallelToolCalls
101-
} else if p.ParallelToolCalls == nil {
101+
} else if p.ParallelToolCalls == nil && len(call.Tools) > 0 {
102102
v := false
103103
p.ParallelToolCalls = &v
104104
}

internal/plugins/backends/openaicodex/payload_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ func TestPayloadForCall_noToolsOmitsToolChoice(t *testing.T) {
217217
if strings.Contains(string(raw), `"tool_choice"`) {
218218
t.Fatalf("no-tools payload must omit tool_choice: %s", raw)
219219
}
220+
if strings.Contains(string(raw), `"parallel_tool_calls"`) {
221+
t.Fatalf("no-tools payload must omit parallel_tool_calls: %s", raw)
222+
}
220223
}
221224

222225
func TestPayloadForCall_modelInstructionsReasoningTemperatureToolsMultimodal(t *testing.T) {
@@ -814,6 +817,44 @@ func TestPayloadForCall_strictCompatibleToolSchemaUsesStrictTrue(t *testing.T) {
814817
}
815818
}
816819

820+
func TestPayloadForCall_parameterlessObjectSchemaGetsAdditionalPropertiesAndRequired(t *testing.T) {
821+
t.Parallel()
822+
strict, raw := codexToolStrict(t, `{"type":"object"}`)
823+
if !strict {
824+
t.Fatalf("parameterless object schema must stay strict=true after normalization: %s", raw)
825+
}
826+
var decoded struct {
827+
Tools []struct {
828+
Parameters map[string]any `json:"parameters"`
829+
} `json:"tools"`
830+
}
831+
if err := json.Unmarshal(raw, &decoded); err != nil {
832+
t.Fatal(err)
833+
}
834+
if len(decoded.Tools) != 1 {
835+
t.Fatalf("tools: %v", decoded.Tools)
836+
}
837+
params := decoded.Tools[0].Parameters
838+
if ap, ok := params["additionalProperties"].(bool); !ok || ap {
839+
t.Fatalf("parameterless object must have additionalProperties:false: %#v", params)
840+
}
841+
req, ok := params["required"].([]any)
842+
if !ok || len(req) != 0 {
843+
t.Fatalf("parameterless object must have required:[]: %#v", params)
844+
}
845+
}
846+
847+
func TestPayloadForCall_parameterlessObjectWithAdditionalPropertiesTrueIsStrictFalse(t *testing.T) {
848+
t.Parallel()
849+
// A parameterless object that explicitly allows additional properties is not
850+
// strict-compatible; it must be sent strict:false so the upstream does not
851+
// reject it.
852+
strict, _ := codexToolStrict(t, `{"type":"object","additionalProperties":true}`)
853+
if strict {
854+
t.Fatal("parameterless object with additionalProperties:true must use strict=false")
855+
}
856+
}
857+
817858
func TestPayloadForCall_composedLooseToolSchemaUsesStrictFalse(t *testing.T) {
818859
t.Parallel()
819860
cases := []struct {

internal/plugins/backends/openaicodex/stream.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ func (m *codexEventMapper) handleOutputItemDone(data string) error {
219219
return nil
220220
}
221221
m.rememberToolCallID(ev.Item.ID, ev.Item.CallID)
222+
m.remapProvisionalToolCall(ev.Item.ID, ev.Item.CallID)
222223
if item, ok := outputFunctionCallInputItem(ev.Item.Type, ev.Item.ID, ev.Item.CallID, ev.Item.Name, ev.Item.Arguments); ok {
223224
m.outputItems = append(m.outputItems, item)
224225
}
@@ -296,6 +297,7 @@ func (m *codexEventMapper) handleOutputItemAdded(data string) error {
296297
return nil
297298
}
298299
m.rememberToolCallID(ev.Item.ID, ev.Item.CallID)
300+
m.remapProvisionalToolCall(ev.Item.ID, ev.Item.CallID)
299301
return m.mapper.ToolCallAdded(codexCanonicalToolCallID(ev.Item.ID, ev.Item.CallID), ev.Item.Name)
300302
}
301303

@@ -341,17 +343,40 @@ func (m *codexEventMapper) rememberToolCallID(itemID, callID string) {
341343
return
342344
}
343345
m.toolCallIDs[itemID] = callID
346+
// Once the real call_id is known, drop the provisional flag so toolCallID
347+
// stops returning the item-only ID and all subsequent events canonicalize
348+
// onto the call_id.
349+
delete(m.provisional, itemID)
344350
}
345351

346-
func (m *codexEventMapper) toolCallID(itemID, callID string) string {
352+
// remapProvisionalToolCall moves any mapper state buffered under the
353+
// provisional item-only ID onto the real call_id once it is learned. Without
354+
// this, argument deltas that arrived before output_item.added stay buffered
355+
// under the item ID while ToolCallAdded targets the call_id, fragmenting one
356+
// logical tool call into two.
357+
func (m *codexEventMapper) remapProvisionalToolCall(itemID, callID string) {
347358
itemID = strings.TrimSpace(itemID)
348359
callID = strings.TrimSpace(callID)
349-
if itemID != "" && m.provisional[itemID] {
350-
return itemID
360+
if itemID == "" || callID == "" || callID == itemID {
361+
return
351362
}
363+
m.mapper.RemapToolCallID(itemID, callID)
364+
}
365+
366+
func (m *codexEventMapper) toolCallID(itemID, callID string) string {
367+
itemID = strings.TrimSpace(itemID)
368+
callID = strings.TrimSpace(callID)
369+
// Prefer a learned call_id over the provisional item-only ID so deltas and
370+
// completion events resolve to the same canonical ID as output_item.added.
352371
if callID == "" {
353372
callID = strings.TrimSpace(m.toolCallIDs[itemID])
354373
}
374+
if callID != "" {
375+
return codexCanonicalToolCallID(itemID, callID)
376+
}
377+
if itemID != "" && m.provisional[itemID] {
378+
return itemID
379+
}
355380
if callID == "" && itemID != "" {
356381
m.provisional[itemID] = true
357382
}

internal/plugins/backends/openaicodex/stream_internal_test.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -403,21 +403,36 @@ func TestHandleData_toolArgsBeforeAddedWaitForToolName(t *testing.T) {
403403
}
404404

405405
events := stream.DrainPending(&s.mapper.pending)
406-
var toolStarted *lipapi.Event
406+
var startedCount int
407+
var startedID, startedName string
407408
var args strings.Builder
409+
var finishedIDs []string
408410
for i := range events {
409411
ev := events[i]
410412
switch ev.Kind {
411413
case lipapi.EventToolCallStarted:
412-
toolStarted = &ev
414+
startedCount++
415+
startedID = ev.ToolCallID
416+
startedName = ev.ToolName
413417
case lipapi.EventToolCallArgsDelta:
414418
args.WriteString(ev.Delta)
419+
case lipapi.EventToolCallFinished:
420+
finishedIDs = append(finishedIDs, ev.ToolCallID)
415421
}
416422
}
417-
if toolStarted == nil || toolStarted.ToolName != "read" {
418-
t.Fatalf("tool started = %+v, want name read; events=%+v", toolStarted, events)
423+
if startedCount != 1 {
424+
t.Fatalf("tool call started count = %d, want 1 (no provisional/real duplicate); events=%+v", startedCount, events)
425+
}
426+
if startedID != "call_late" {
427+
t.Fatalf("tool call started id = %q, want call_late; events=%+v", startedID, events)
428+
}
429+
if startedName != "read" {
430+
t.Fatalf("tool started name = %q, want read; events=%+v", startedName, events)
419431
}
420432
if got := args.String(); got != `{"filePath":` {
421-
t.Fatalf("args = %q", got)
433+
t.Fatalf("args = %q, want incremental delta preserved after remap onto call_id", got)
434+
}
435+
if len(finishedIDs) != 1 || finishedIDs[0] != "call_late" {
436+
t.Fatalf("tool call finished ids = %v, want [call_late]; events=%+v", finishedIDs, events)
422437
}
423438
}

internal/plugins/backends/openaicodex/toolschema.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@ package openaicodex
55
// additionalProperties:false and list all of its properties in required. Schemas
66
// that do not comply must be sent with strict:false, otherwise the upstream
77
// rejects the request (e.g. "additionalProperties is required to be supplied
8-
// and to be false"). Parameterless schemas (no properties) are treated as
9-
// compatible so they keep strict:true. The check is conservative: when in
10-
// doubt it returns false, which only relaxes strict mode (safe) and never
8+
// and to be false"). Parameterless object schemas are normalized by
9+
// addStrictAdditionalProperties to include additionalProperties:false (and an
10+
// empty required list) so they remain strict-compatible instead of leaking a
11+
// strict:true tool that the upstream rejects. The check is conservative: when
12+
// in doubt it returns false, which only relaxes strict mode (safe) and never
1113
// causes an upstream rejection.
1214
func isStrictCompatibleSchema(schema map[string]any) bool {
1315
if hasRef(schema) || !strictCompatibleCompositions(schema) {
1416
return false
1517
}
16-
props, _ := schema["properties"].(map[string]any)
17-
if len(props) == 0 {
18+
if !isObjectSchema(schema) {
19+
// Non-object root (array/primitive): only its array items must comply.
1820
return strictCompatibleArrayItems(schema)
1921
}
2022
ap, ok := schema["additionalProperties"]
@@ -24,6 +26,7 @@ func isStrictCompatibleSchema(schema map[string]any) bool {
2426
if asBool, ok := ap.(bool); !ok || asBool {
2527
return false
2628
}
29+
props, _ := schema["properties"].(map[string]any)
2730
reqRaw, _ := schema["required"].([]any)
2831
required := make(map[string]bool, len(reqRaw))
2932
for _, r := range reqRaw {
@@ -51,17 +54,37 @@ func normalizeToolSchemaForCodex(schema map[string]any) (map[string]any, bool) {
5154
return schema, isStrictCompatibleSchema(schema)
5255
}
5356

57+
// isObjectSchema reports whether a node is a JSON object schema: either it
58+
// declares type "object" or it carries a non-empty properties map. Empty
59+
// properties maps without an explicit object type are not treated as objects so
60+
// that a truly empty schema ({}) is left untouched.
61+
func isObjectSchema(node map[string]any) bool {
62+
if t, _ := node["type"].(string); t == "object" {
63+
return true
64+
}
65+
props, ok := node["properties"].(map[string]any)
66+
return ok && len(props) > 0
67+
}
68+
5469
func addStrictAdditionalProperties(v any) {
5570
switch x := v.(type) {
5671
case map[string]any:
5772
for k, child := range x {
5873
addStrictAdditionalProperties(child)
5974
x[k] = child
6075
}
61-
if props, ok := x["properties"].(map[string]any); ok && len(props) > 0 {
76+
if isObjectSchema(x) {
6277
if _, ok := x["additionalProperties"]; !ok {
6378
x["additionalProperties"] = false
6479
}
80+
// Parameterless objects must also carry an explicit required:[] for the
81+
// Responses API strict mode; inject it only when no properties and no
82+
// required are already declared.
83+
if props, _ := x["properties"].(map[string]any); len(props) == 0 {
84+
if _, ok := x["required"]; !ok {
85+
x["required"] = []any{}
86+
}
87+
}
6588
}
6689
case []any:
6790
for i, child := range x {

internal/plugins/backends/openaicodex/ws.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ func newWSDialer(client *http.Client) *websocket.Dialer {
253253
d.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
254254
}
255255
}
256+
// When client.Transport is a custom RoundTripper (e.g. instrumentation)
257+
// rather than *http.Transport, proxy/TLS settings cannot be introspected
258+
// generically, so the WS dialer falls back to default networking. This
259+
// differs from the HTTPS path that uses the same client.
256260
}
257261
return d
258262
}
@@ -568,9 +572,6 @@ func readFirstNonEmptyWSMessage(ctx context.Context, conn *websocket.Conn, timeo
568572
if ctx != nil && ctx.Err() != nil {
569573
return nil, ctx.Err()
570574
}
571-
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
572-
return nil, newWSTransportError(fmt.Errorf("read websocket: %w", err))
573-
}
574575
return nil, newWSTransportError(fmt.Errorf("read websocket: %w", err))
575576
}
576577
if len(strings.TrimSpace(string(data))) > 0 {

internal/plugins/backends/protocols/openairesponsestream/mapper.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,34 @@ func (m *Mapper) flushPendingToolArgs(id string) error {
265265
return nil
266266
}
267267

268+
// RemapToolCallID consolidates tool-call state buffered under oldID onto newID.
269+
// It is used when a tool call's real call_id is learned after argument deltas
270+
// were already buffered under the provisional item-only ID, so pending args and
271+
// started/arg-delta/finished flags all move onto the canonical ID instead of
272+
// fragmenting into two tool calls. Remapping is a no-op when no state exists
273+
// under oldID.
274+
func (m *Mapper) RemapToolCallID(oldID, newID string) {
275+
if oldID == "" || newID == "" || oldID == newID {
276+
return
277+
}
278+
if deltas, ok := m.pendingToolArgs[oldID]; ok {
279+
m.pendingToolArgs[newID] = append(m.pendingToolArgs[newID], deltas...)
280+
delete(m.pendingToolArgs, oldID)
281+
}
282+
if m.toolCallStarted[oldID] {
283+
m.toolCallStarted[newID] = true
284+
delete(m.toolCallStarted, oldID)
285+
}
286+
if m.toolCallArgDeltas[oldID] {
287+
m.toolCallArgDeltas[newID] = true
288+
delete(m.toolCallArgDeltas, oldID)
289+
}
290+
if m.toolCallFinished[oldID] {
291+
m.toolCallFinished[newID] = true
292+
delete(m.toolCallFinished, oldID)
293+
}
294+
}
295+
268296
// EmitOutputMediaFromResponse maps assistant message media in a completed Responses payload.
269297
func EmitOutputMediaFromResponse(m *Mapper, resp responses.Response) error {
270298
for _, item := range resp.Output {

0 commit comments

Comments
 (0)