From 3b25f5b2bf3c372cc9bbb83d8e7f24d7f613b829 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:34:27 +0000 Subject: [PATCH 1/5] Initial plan From a26ff8cb4fece79f9564202cc798acd10096a29e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:43:10 +0000 Subject: [PATCH 2/5] Fail trial when safe-output errors are present Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/trial_helpers.go | 30 +++++++++ pkg/cli/trial_safe_output_errors_test.go | 77 ++++++++++++++++++++++++ pkg/cli/trial_types.go | 33 ++++++++++ 3 files changed, 140 insertions(+) create mode 100644 pkg/cli/trial_safe_output_errors_test.go diff --git a/pkg/cli/trial_helpers.go b/pkg/cli/trial_helpers.go index caa0fef8082..b17f16ea45d 100644 --- a/pkg/cli/trial_helpers.go +++ b/pkg/cli/trial_helpers.go @@ -122,6 +122,7 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS } // Save individual workflow results + safeOutputErrors := extractSafeOutputErrors(artifacts.SafeOutputs) result := WorkflowTrialResult{ WorkflowName: parsedSpec.WorkflowName, RunID: runID, @@ -130,6 +131,8 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS AgenticRunInfo: artifacts.AgenticRunInfo, AdditionalArtifacts: artifacts.AdditionalArtifacts, Timestamp: time.Now(), + Success: len(safeOutputErrors) == 0, + SafeOutputErrors: safeOutputErrors, } workflowResults = append(workflowResults, result) @@ -150,6 +153,14 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== No Safe Outputs Generated by %s ===", parsedSpec.WorkflowName))) } + // Report rejected safe-output messages, if any + if len(safeOutputErrors) > 0 { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("=== %d Safe Output Message(s) Rejected from %s ===", len(safeOutputErrors), parsedSpec.WorkflowName))) + for _, msg := range safeOutputErrors { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(msg)) + } + } + // Display additional artifact information if available // if len(artifacts.AgentStdioLogs) > 0 { // fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== Agent Stdio Logs Available from %s (%d files) ===", parsedSpec.WorkflowName, len(artifacts.AgentStdioLogs)))) @@ -165,6 +176,19 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS } // Step 6: Save combined results for multi-workflow trials + overallSuccess := true + var totalRejected int + var firstErrorMessage string + for _, result := range workflowResults { + if !result.Success { + overallSuccess = false + totalRejected += len(result.SafeOutputErrors) + if firstErrorMessage == "" && len(result.SafeOutputErrors) > 0 { + firstErrorMessage = result.SafeOutputErrors[0] + } + } + } + if len(parsedSpecs) > 1 { workflowNames := sliceutil.Map(parsedSpecs, func(spec *WorkflowSpec) string { return spec.WorkflowName }) workflowNamesStr := strings.Join(workflowNames, "-") @@ -174,6 +198,7 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS WorkflowNames: workflowNames, Results: workflowResults, Timestamp: time.Now(), + Success: overallSuccess, } if err := saveTrialResult(combinedFilename, combinedResult, opts.Verbose); err != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save combined trial result: %v", err))) @@ -187,6 +212,11 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to copy trial results to repository: %v", err))) } + if !overallSuccess { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("Trial completed with %d rejected safe-output message(s)", totalRejected))) + return fmt.Errorf("trial completed with %d rejected safe-output message(s): %s", totalRejected, firstErrorMessage) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("All trials completed successfully")) return nil } diff --git a/pkg/cli/trial_safe_output_errors_test.go b/pkg/cli/trial_safe_output_errors_test.go new file mode 100644 index 00000000000..91732e620cb --- /dev/null +++ b/pkg/cli/trial_safe_output_errors_test.go @@ -0,0 +1,77 @@ +package cli + +import "testing" + +func TestExtractSafeOutputErrors(t *testing.T) { + tests := []struct { + name string + safeOutputs map[string]any + want []string + }{ + { + name: "nil safe outputs", + safeOutputs: nil, + want: nil, + }, + { + name: "no errors key", + safeOutputs: map[string]any{"items": []any{}}, + want: nil, + }, + { + name: "empty errors array", + safeOutputs: map[string]any{"items": []any{}, "errors": []any{}}, + want: nil, + }, + { + name: "non-empty errors array", + safeOutputs: map[string]any{ + "items": []any{}, + "errors": []any{"Line 1: set_issue_field requires at least one of: 'field_name', 'field_node_id' fields"}, + }, + want: []string{"Line 1: set_issue_field requires at least one of: 'field_name', 'field_node_id' fields"}, + }, + { + name: "multiple errors", + safeOutputs: map[string]any{ + "errors": []any{"error one", "error two"}, + }, + want: []string{"error one", "error two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractSafeOutputErrors(tt.safeOutputs) + if len(got) != len(tt.want) { + t.Fatalf("extractSafeOutputErrors() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("extractSafeOutputErrors()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestWorkflowTrialResultSuccessField(t *testing.T) { + result := WorkflowTrialResult{ + WorkflowName: "test-workflow", + SafeOutputErrors: []string{ + "Line 1: some validation error", + }, + Success: false, + } + if result.Success { + t.Error("expected Success to be false when SafeOutputErrors is non-empty") + } + + successResult := WorkflowTrialResult{ + WorkflowName: "test-workflow", + Success: true, + } + if !successResult.Success { + t.Error("expected Success to be true when there are no safe-output errors") + } +} diff --git a/pkg/cli/trial_types.go b/pkg/cli/trial_types.go index 51ce5e6cc12..fde4d1d0dba 100644 --- a/pkg/cli/trial_types.go +++ b/pkg/cli/trial_types.go @@ -11,6 +11,13 @@ type WorkflowTrialResult struct { AgenticRunInfo map[string]any `json:"agentic_run_info,omitempty"` AdditionalArtifacts map[string]any `json:"additional_artifacts,omitempty"` Timestamp time.Time `json:"timestamp"` + // Success reports whether the trial completed without any rejected safe-output + // messages. It is false when the safe-outputs artifact contains a non-empty + // "errors" array. + Success bool `json:"success"` + // SafeOutputErrors contains the rejected safe-output messages, if any, extracted + // from the safe-outputs artifact's "errors" array. + SafeOutputErrors []string `json:"safe_output_errors,omitempty"` } // CombinedTrialResult represents the combined results of multiple workflow trials @@ -18,6 +25,32 @@ type CombinedTrialResult struct { WorkflowNames []string `json:"workflow_names"` Results []WorkflowTrialResult `json:"results"` Timestamp time.Time `json:"timestamp"` + // Success reports whether all workflow trials completed without any rejected + // safe-output messages. + Success bool `json:"success"` +} + +// extractSafeOutputErrors extracts the "errors" array (if any) from a safe-outputs +// artifact map, returning the rejected safe-output messages as strings. +func extractSafeOutputErrors(safeOutputs map[string]any) []string { + if safeOutputs == nil { + return nil + } + rawErrors, ok := safeOutputs["errors"] + if !ok { + return nil + } + errorsSlice, ok := rawErrors.([]any) + if !ok { + return nil + } + var messages []string + for _, e := range errorsSlice { + if msg, ok := e.(string); ok && msg != "" { + messages = append(messages, msg) + } + } + return messages } // TrialRepoContext groups repository-related configuration for trial execution From b15c2dd376b3bd880efe539bf26a1d2b34cfdfba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:44:16 +0000 Subject: [PATCH 3/5] Clarify rejected safe-output error message wording Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/trial_helpers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/trial_helpers.go b/pkg/cli/trial_helpers.go index b17f16ea45d..bfcb1de2bfd 100644 --- a/pkg/cli/trial_helpers.go +++ b/pkg/cli/trial_helpers.go @@ -214,7 +214,7 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS if !overallSuccess { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("Trial completed with %d rejected safe-output message(s)", totalRejected))) - return fmt.Errorf("trial completed with %d rejected safe-output message(s): %s", totalRejected, firstErrorMessage) + return fmt.Errorf("trial completed with %d rejected safe-output message(s); first error: %s", totalRejected, firstErrorMessage) } fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("All trials completed successfully")) From ec83c92f4e7ebdf056b3e1c638dda124363c4eba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:18:01 +0000 Subject: [PATCH 4/5] Address review: expose JSON trial results, sanitize control chars, add aggregation tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/trial_helpers.go | 37 ++++---- pkg/cli/trial_safe_output_errors_test.go | 115 +++++++++++++++++++++++ pkg/cli/trial_types.go | 53 ++++++++++- 3 files changed, 187 insertions(+), 18 deletions(-) diff --git a/pkg/cli/trial_helpers.go b/pkg/cli/trial_helpers.go index bfcb1de2bfd..ea9db014876 100644 --- a/pkg/cli/trial_helpers.go +++ b/pkg/cli/trial_helpers.go @@ -143,8 +143,14 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save individual trial result: %v", err))) } - // Display safe outputs to stdout - if len(artifacts.SafeOutputs) > 0 { + // Display results to stdout. In JSON mode, emit the full WorkflowTrialResult + // (including Success/SafeOutputErrors) instead of the raw safe-outputs artifact, + // so consumers of `--json` see the pass/fail signal without inspecting nested + // "errors" arrays. + if opts.JSONOutput { + resultBytes, _ := json.MarshalIndent(result, "", " ") + fmt.Fprintln(os.Stdout, string(resultBytes)) + } else if len(artifacts.SafeOutputs) > 0 { outputBytes, _ := json.MarshalIndent(artifacts.SafeOutputs, "", " ") fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("=== Safe Outputs from %s ===", parsedSpec.WorkflowName))) fmt.Fprintln(os.Stdout, string(outputBytes)) @@ -153,11 +159,13 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== No Safe Outputs Generated by %s ===", parsedSpec.WorkflowName))) } - // Report rejected safe-output messages, if any + // Report rejected safe-output messages, if any. Messages may contain + // agent-controlled content, so control characters are sanitized before being + // written to the terminal/CI logs to avoid escape-sequence injection. if len(safeOutputErrors) > 0 { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("=== %d Safe Output Message(s) Rejected from %s ===", len(safeOutputErrors), parsedSpec.WorkflowName))) for _, msg := range safeOutputErrors { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(msg)) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(sanitizeControlChars(msg))) } } @@ -176,18 +184,7 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS } // Step 6: Save combined results for multi-workflow trials - overallSuccess := true - var totalRejected int - var firstErrorMessage string - for _, result := range workflowResults { - if !result.Success { - overallSuccess = false - totalRejected += len(result.SafeOutputErrors) - if firstErrorMessage == "" && len(result.SafeOutputErrors) > 0 { - firstErrorMessage = result.SafeOutputErrors[0] - } - } - } + overallSuccess, totalRejected, firstErrorMessage := aggregateTrialResults(workflowResults) if len(parsedSpecs) > 1 { workflowNames := sliceutil.Map(parsedSpecs, func(spec *WorkflowSpec) string { return spec.WorkflowName }) @@ -204,6 +201,11 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save combined trial result: %v", err))) } fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Combined results saved to: "+combinedFilename)) + + if opts.JSONOutput { + combinedBytes, _ := json.MarshalIndent(combinedResult, "", " ") + fmt.Fprintln(os.Stdout, string(combinedBytes)) + } } // Step 6.5: Copy trial results to host repository and commit them @@ -213,8 +215,9 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS } if !overallSuccess { + sanitizedFirstError := sanitizeControlChars(firstErrorMessage) fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("Trial completed with %d rejected safe-output message(s)", totalRejected))) - return fmt.Errorf("trial completed with %d rejected safe-output message(s); first error: %s", totalRejected, firstErrorMessage) + return fmt.Errorf("trial completed with %d rejected safe-output message(s); first error: %s", totalRejected, sanitizedFirstError) } fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("All trials completed successfully")) diff --git a/pkg/cli/trial_safe_output_errors_test.go b/pkg/cli/trial_safe_output_errors_test.go index 91732e620cb..49b0001a85a 100644 --- a/pkg/cli/trial_safe_output_errors_test.go +++ b/pkg/cli/trial_safe_output_errors_test.go @@ -75,3 +75,118 @@ func TestWorkflowTrialResultSuccessField(t *testing.T) { t.Error("expected Success to be true when there are no safe-output errors") } } + +func TestAggregateTrialResults(t *testing.T) { + tests := []struct { + name string + results []WorkflowTrialResult + wantSuccess bool + wantTotalRejected int + wantFirstError string + }{ + { + name: "no results", + results: nil, + wantSuccess: true, + wantTotalRejected: 0, + wantFirstError: "", + }, + { + name: "all successful", + results: []WorkflowTrialResult{ + {WorkflowName: "a", Success: true}, + {WorkflowName: "b", Success: true}, + }, + wantSuccess: true, + wantTotalRejected: 0, + wantFirstError: "", + }, + { + name: "one failure with rejected messages", + results: []WorkflowTrialResult{ + {WorkflowName: "a", Success: true}, + { + WorkflowName: "b", + Success: false, + SafeOutputErrors: []string{"first error", "second error"}, + }, + }, + wantSuccess: false, + wantTotalRejected: 2, + wantFirstError: "first error", + }, + { + name: "multiple failures aggregate total and keep first error in order", + results: []WorkflowTrialResult{ + { + WorkflowName: "a", + Success: false, + SafeOutputErrors: []string{"error from a"}, + }, + { + WorkflowName: "b", + Success: false, + SafeOutputErrors: []string{"error from b1", "error from b2"}, + }, + }, + wantSuccess: false, + wantTotalRejected: 3, + wantFirstError: "error from a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSuccess, gotTotalRejected, gotFirstError := aggregateTrialResults(tt.results) + if gotSuccess != tt.wantSuccess { + t.Errorf("aggregateTrialResults() success = %v, want %v", gotSuccess, tt.wantSuccess) + } + if gotTotalRejected != tt.wantTotalRejected { + t.Errorf("aggregateTrialResults() totalRejected = %d, want %d", gotTotalRejected, tt.wantTotalRejected) + } + if gotFirstError != tt.wantFirstError { + t.Errorf("aggregateTrialResults() firstErrorMessage = %q, want %q", gotFirstError, tt.wantFirstError) + } + }) + } +} + +func TestSanitizeControlChars(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "empty string", in: "", want: ""}, + {name: "plain text unchanged", in: "plain error message", want: "plain error message"}, + { + name: "escapes ANSI escape sequence", + in: "before\x1b[31mred\x1b[0mafter", + want: `before'\x1b'[31mred'\x1b'[0mafter`, + }, + { + name: "escapes newline and tab", + in: "line1\nline2\ttabbed", + want: `line1'\n'line2'\t'tabbed`, + }, + { + name: "escapes carriage return", + in: "before\rafter", + want: `before'\r'after`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeControlChars(tt.in) + if got != tt.want { + t.Errorf("sanitizeControlChars(%q) = %q, want %q", tt.in, got, tt.want) + } + for _, r := range got { + if r < 0x20 || r == 0x7f { + t.Errorf("sanitizeControlChars(%q) result %q still contains raw control character", tt.in, got) + } + } + }) + } +} diff --git a/pkg/cli/trial_types.go b/pkg/cli/trial_types.go index fde4d1d0dba..75e47ae251c 100644 --- a/pkg/cli/trial_types.go +++ b/pkg/cli/trial_types.go @@ -1,6 +1,10 @@ package cli -import "time" +import ( + "strconv" + "strings" + "time" +) // WorkflowTrialResult represents the result of running a single workflow trial type WorkflowTrialResult struct { @@ -53,6 +57,53 @@ func extractSafeOutputErrors(safeOutputs map[string]any) []string { return messages } +// aggregateTrialResults aggregates a set of per-workflow trial results into an +// overall success flag, the total count of rejected safe-output messages across +// all workflows, and the first rejected message encountered (in result order). +func aggregateTrialResults(results []WorkflowTrialResult) (overallSuccess bool, totalRejected int, firstErrorMessage string) { + overallSuccess = true + for _, result := range results { + if !result.Success { + overallSuccess = false + totalRejected += len(result.SafeOutputErrors) + if firstErrorMessage == "" && len(result.SafeOutputErrors) > 0 { + firstErrorMessage = result.SafeOutputErrors[0] + } + } + } + return overallSuccess, totalRejected, firstErrorMessage +} + +// sanitizeControlChars replaces ASCII control characters (including escape +// sequences) in a string with their Go-escaped representation. Rejected +// safe-output messages may embed agent-controlled content, so this prevents +// terminal/log control-sequence injection when the messages are printed to +// stderr or embedded in a returned error. +func sanitizeControlChars(s string) string { + if s == "" { + return s + } + var needsEscaping bool + for _, r := range s { + if r < 0x20 || r == 0x7f { + needsEscaping = true + break + } + } + if !needsEscaping { + return s + } + var b strings.Builder + for _, r := range s { + if r < 0x20 || r == 0x7f { + b.WriteString(strconv.QuoteRune(r)) + continue + } + b.WriteRune(r) + } + return b.String() +} + // TrialRepoContext groups repository-related configuration for trial execution type TrialRepoContext struct { LogicalRepo string // The repo to simulate execution against From d4eed81118bbf504a66302433429bd99b04eb6b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:20:40 +0000 Subject: [PATCH 5/5] Address code review: handle marshal errors, cover C1 control chars Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/trial_helpers.go | 28 +++++++++++++++++------- pkg/cli/trial_safe_output_errors_test.go | 7 +++++- pkg/cli/trial_types.go | 10 +++++++-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/pkg/cli/trial_helpers.go b/pkg/cli/trial_helpers.go index ea9db014876..2a07176f0e7 100644 --- a/pkg/cli/trial_helpers.go +++ b/pkg/cli/trial_helpers.go @@ -148,13 +148,21 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS // so consumers of `--json` see the pass/fail signal without inspecting nested // "errors" arrays. if opts.JSONOutput { - resultBytes, _ := json.MarshalIndent(result, "", " ") - fmt.Fprintln(os.Stdout, string(resultBytes)) + resultBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to marshal trial result for '%s': %v", parsedSpec.WorkflowName, err))) + } else { + fmt.Fprintln(os.Stdout, string(resultBytes)) + } } else if len(artifacts.SafeOutputs) > 0 { - outputBytes, _ := json.MarshalIndent(artifacts.SafeOutputs, "", " ") - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("=== Safe Outputs from %s ===", parsedSpec.WorkflowName))) - fmt.Fprintln(os.Stdout, string(outputBytes)) - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("=== End of Safe Outputs ===")) + outputBytes, err := json.MarshalIndent(artifacts.SafeOutputs, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to marshal safe outputs for '%s': %v", parsedSpec.WorkflowName, err))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("=== Safe Outputs from %s ===", parsedSpec.WorkflowName))) + fmt.Fprintln(os.Stdout, string(outputBytes)) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("=== End of Safe Outputs ===")) + } } else { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== No Safe Outputs Generated by %s ===", parsedSpec.WorkflowName))) } @@ -203,8 +211,12 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Combined results saved to: "+combinedFilename)) if opts.JSONOutput { - combinedBytes, _ := json.MarshalIndent(combinedResult, "", " ") - fmt.Fprintln(os.Stdout, string(combinedBytes)) + combinedBytes, err := json.MarshalIndent(combinedResult, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to marshal combined trial result: %v", err))) + } else { + fmt.Fprintln(os.Stdout, string(combinedBytes)) + } } } diff --git a/pkg/cli/trial_safe_output_errors_test.go b/pkg/cli/trial_safe_output_errors_test.go index 49b0001a85a..3d91ee737b7 100644 --- a/pkg/cli/trial_safe_output_errors_test.go +++ b/pkg/cli/trial_safe_output_errors_test.go @@ -174,6 +174,11 @@ func TestSanitizeControlChars(t *testing.T) { in: "before\rafter", want: `before'\r'after`, }, + { + name: "escapes C1 control character", + in: "before\u009bafter", + want: `before'\u009b'after`, + }, } for _, tt := range tests { @@ -183,7 +188,7 @@ func TestSanitizeControlChars(t *testing.T) { t.Errorf("sanitizeControlChars(%q) = %q, want %q", tt.in, got, tt.want) } for _, r := range got { - if r < 0x20 || r == 0x7f { + if isControlRune(r) { t.Errorf("sanitizeControlChars(%q) result %q still contains raw control character", tt.in, got) } } diff --git a/pkg/cli/trial_types.go b/pkg/cli/trial_types.go index 75e47ae251c..a78ee8d4484 100644 --- a/pkg/cli/trial_types.go +++ b/pkg/cli/trial_types.go @@ -85,7 +85,7 @@ func sanitizeControlChars(s string) string { } var needsEscaping bool for _, r := range s { - if r < 0x20 || r == 0x7f { + if isControlRune(r) { needsEscaping = true break } @@ -95,7 +95,7 @@ func sanitizeControlChars(s string) string { } var b strings.Builder for _, r := range s { - if r < 0x20 || r == 0x7f { + if isControlRune(r) { b.WriteString(strconv.QuoteRune(r)) continue } @@ -104,6 +104,12 @@ func sanitizeControlChars(s string) string { return b.String() } +// isControlRune reports whether r is a C0 or C1 control character (including +// DEL), which may be interpreted as terminal/log control or escape sequences. +func isControlRune(r rune) bool { + return r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) +} + // TrialRepoContext groups repository-related configuration for trial execution type TrialRepoContext struct { LogicalRepo string // The repo to simulate execution against