diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go index 7bb7fd68b1..698a3bbd5e 100644 --- a/backend/internal/handler/openai_automation_bootstrap_test.go +++ b/backend/internal/handler/openai_automation_bootstrap_test.go @@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) { + output := `wiki` + got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, "")) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists()) + + again, changedAgain := normalizeCodexAutomationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) +} + func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) tests := []struct { @@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { name: "real call context", body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`), }, - { - name: "heartbeat output", - body: codexAutomationBootstrapBody(t, `wiki`, ""), - }, { name: "mismatched memory id", body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""), @@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "arbitrary tool output", output: `wiki`}, + {name: "root attribute", output: `wiki`}, + {name: "namespaced root", output: `wiki`}, + {name: "extra child", output: `wikiok`}, + {name: "nested id content", output: `wiki`}, + {name: "padded id", output: ` wiki `}, + {name: "unsafe id", output: `../wiki`}, + {name: "comment", output: `wiki`}, + {name: "trailing content", output: `wikiextra`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := codexAutomationBootstrapBody(t, tt.output, "") + got, changed := normalizeCodexAutomationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) { output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..c895b90c53 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1756,7 +1756,7 @@ func isCodexAutomationCandidate(item map[string]any) bool { return false } output, ok := item["output"].(string) - return ok && validCodexAutomationBootstrap(output) + return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output)) } func stringField(item map[string]any, key string) string { @@ -1834,6 +1834,56 @@ func validCodexAutomationLastRun(value string) bool { return err == nil && runAt.UnixMilli() == epochMillis } +func validCodexAutomationHeartbeat(value string) bool { + decoder := xml.NewDecoder(strings.NewReader(value)) + var rootSeen, automationIDSeen bool + var automationID bytes.Buffer + depth := 0 + for { + token, err := decoder.Token() + if err == io.EOF { + id := automationID.String() + return rootSeen && automationIDSeen && depth == 0 && + strings.TrimSpace(id) == id && validCodexAutomationID(id) + } + if err != nil { + return false + } + switch current := token.(type) { + case xml.StartElement: + depth++ + if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 { + return false + } + if depth == 1 { + if rootSeen || current.Name.Local != "heartbeat" { + return false + } + rootSeen = true + } else if automationIDSeen || current.Name.Local != "automation_id" { + return false + } + automationIDSeen = depth == 2 + case xml.EndElement: + if current.Name.Space != "" { + return false + } + depth-- + if depth < 0 { + return false + } + case xml.CharData: + if depth == 2 { + _, _ = automationID.Write(current) + } else if len(bytes.TrimSpace(current)) != 0 { + return false + } + case xml.Comment, xml.ProcInst, xml.Directive: + return false + } + } +} + func validCodexDelegationEnvelope(value string) bool { decoder := xml.NewDecoder(strings.NewReader(value)) var rootSeen, sourceSeen, inputSeen bool