diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go new file mode 100644 index 0000000000..7bb7fd68b1 --- /dev/null +++ b/backend/internal/handler/openai_automation_bootstrap_test.go @@ -0,0 +1,133 @@ +package handler + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const automationBootstrapPrompt = "Review the project and report any important changes." + +func codexAutomationBootstrap(automationID, lastRun, prompt string) string { + return "Automation: Scheduled project review\n" + + "Automation ID: " + automationID + "\n" + + "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md\n" + + "Last run: " + lastRun + "\n\n" + prompt +} + +func codexAutomationBootstrapBody(t *testing.T, output, callID string) []byte { + t.Helper() + return []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + + mustJSON(t, output) + callID + `}]}`) +} + +func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) { + tests := []struct { + name string + lastRun string + crlf bool + }{ + {name: "never", lastRun: "never"}, + {name: "timestamp", lastRun: "2026-09-01T02:06:34.536Z (1788228394536)"}, + {name: "crlf", lastRun: "never", crlf: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := codexAutomationBootstrap("wiki-maintenance", tt.lastRun, automationBootstrapPrompt) + if tt.crlf { + output = strings.ReplaceAll(output, "\n", "\r\n") + } + 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()) + }) + } +} + +func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { + validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) + tests := []struct { + name string + body []byte + }{ + { + name: "ordinary missing call id", + body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"other","output":` + mustJSON(t, validOutput) + `}]}`), + }, + { + name: "tui namespace", + body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_tui","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`), + }, + { + name: "valid call id", + body: codexAutomationBootstrapBody(t, validOutput, `,"call_id":"call-1"`), + }, + { + name: "previous response", + body: []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`), + }, + { + 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), ""), + }, + { + name: "unsafe automation id", + body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("../wiki", "never", automationBootstrapPrompt), ""), + }, + { + name: "invalid timestamp", + body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "yesterday", automationBootstrapPrompt), ""), + }, + { + name: "mismatched timestamp epoch", + body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "2026-09-01T02:06:34.536Z (1)", automationBootstrapPrompt), ""), + }, + { + name: "missing separator", + body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "\n\n"+automationBootstrapPrompt, "\n"+automationBootstrapPrompt, 1), ""), + }, + { + name: "empty prompt", + body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "never", " \n"), ""), + }, + { + name: "mixed missing call id output", + body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"computer_call_output","output":"done"}]}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, changed := normalizeCodexAutomationBootstrap(tt.body) + require.False(t, changed) + require.Equal(t, tt.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"}]}`) + + got, changed := normalizeCodexAutomationBootstrap(body) + require.True(t, changed) + require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String()) + require.Equal(t, output, gjson.GetBytes(got, "input.1.content.0.text").String()) + require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String()) + + again, changedAgain := normalizeCodexAutomationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 40763b1976..25209d87e0 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -438,6 +438,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { body = cappedBody } + if normalizedBody, changed := normalizeCodexAutomationBootstrap(body); changed { + body = normalizedBody + reqLog.Info("openai.codex_automation_bootstrap_normalized", + zap.String("normalization", "call_output_to_user_message"), + ) + } if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed { body = normalizedBody reqLog.Info("openai.codex_delegation_bootstrap_normalized", @@ -1591,6 +1597,14 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, } func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { + return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate) +} + +func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) { + return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate) +} + +func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) { if !hasUniqueJSONMembers(body) { return body, false } @@ -1629,7 +1643,7 @@ func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { if exists && (!isString || strings.TrimSpace(callID) != "") { return body, false } - if !isCodexDelegationCandidate(item) { + if !isCandidate(item) { return body, false } } @@ -1638,11 +1652,11 @@ func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { changed := false for i, raw := range input { item, ok := raw.(map[string]any) - if !ok || !isCodexDelegationCandidate(item) { + if !ok || !isCandidate(item) { continue } output, ok := item["output"].(string) - if !ok || !validCodexDelegationEnvelope(output) { + if !ok { continue } input[i] = map[string]any{ @@ -1732,6 +1746,16 @@ func isCodexDelegationCandidate(item map[string]any) bool { return ok && validCodexDelegationEnvelope(output) } +func isCodexAutomationCandidate(item map[string]any) bool { + if stringField(item, "type") != "function_call_output" || + stringField(item, "namespace") != "codex_app" || + stringField(item, "name") != "automation_update" { + return false + } + output, ok := item["output"].(string) + return ok && validCodexAutomationBootstrap(output) +} + func stringField(item map[string]any, key string) string { value, _ := item[key].(string) return value @@ -1742,6 +1766,71 @@ func isCodexDelegationTool(namespace, name string) bool { (name == "create_thread" || name == "send_message_to_thread") } +func validCodexAutomationBootstrap(value string) bool { + normalized := strings.ReplaceAll(value, "\r\n", "\n") + if strings.ContainsRune(normalized, '\r') { + return false + } + lines := strings.Split(normalized, "\n") + if len(lines) < 6 { + return false + } + if _, ok := codexAutomationHeaderValue(lines[0], "Automation: "); !ok { + return false + } + automationID, ok := codexAutomationHeaderValue(lines[1], "Automation ID: ") + if !ok || !validCodexAutomationID(automationID) { + return false + } + expectedMemory := "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md" + if lines[2] != expectedMemory { + return false + } + lastRun, ok := codexAutomationHeaderValue(lines[3], "Last run: ") + if !ok || !validCodexAutomationLastRun(lastRun) || lines[4] != "" { + return false + } + return strings.TrimSpace(strings.Join(lines[5:], "\n")) != "" +} + +func codexAutomationHeaderValue(line, prefix string) (string, bool) { + if !strings.HasPrefix(line, prefix) { + return "", false + } + value := strings.TrimPrefix(line, prefix) + return value, value != "" && strings.TrimSpace(value) == value +} + +func validCodexAutomationID(value string) bool { + if len(value) == 0 || len(value) > 128 || value == "." || value == ".." { + return false + } + for i := 0; i < len(value); i++ { + c := value[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' { + continue + } + return false + } + return true +} + +func validCodexAutomationLastRun(value string) bool { + if value == "never" { + return true + } + separator := strings.LastIndex(value, " (") + if separator <= 0 || !strings.HasSuffix(value, ")") { + return false + } + runAt, err := time.Parse(time.RFC3339Nano, value[:separator]) + if err != nil { + return false + } + epochMillis, err := strconv.ParseInt(value[separator+2:len(value)-1], 10, 64) + return err == nil && runAt.UnixMilli() == epochMillis +} + func validCodexDelegationEnvelope(value string) bool { decoder := xml.NewDecoder(strings.NewReader(value)) var rootSeen, sourceSeen, inputSeen bool