• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 21626261828

03 Feb 2026 10:18AM UTC coverage: 80.571% (-0.05%) from 80.617%
21626261828

push

github

umputun
fix(docker): auto-disable codex sandbox in container

Codex uses Landlock for sandboxing which doesn't work inside Docker
containers. Added RALPHEX_DOCKER=1 env var to base image and auto-detect
it in codex executor to use danger-full-access sandbox mode.

Also updated config comment with correct codex sandbox options.

1 of 3 new or added lines in 1 file covered. (33.33%)

2 existing lines in 1 file now uncovered.

4234 of 5255 relevant lines covered (80.57%)

124.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.22
/pkg/executor/codex.go
1
package executor
2

3
import (
4
        "bufio"
5
        "context"
6
        "errors"
7
        "fmt"
8
        "io"
9
        "os"
10
        "os/exec"
11
        "strings"
12
)
13

14
// CodexStreams holds both stderr and stdout from codex command.
15
type CodexStreams struct {
16
        Stderr io.Reader
17
        Stdout io.Reader
18
}
19

20
// CodexRunner abstracts command execution for codex.
21
// Returns both stderr (streaming progress) and stdout (final response).
22
type CodexRunner interface {
23
        Run(ctx context.Context, name string, args ...string) (streams CodexStreams, wait func() error, err error)
24
}
25

26
// execCodexRunner is the default command runner using os/exec for codex.
27
// codex outputs streaming progress to stderr, final response to stdout.
28
type execCodexRunner struct{}
29

30
func (r *execCodexRunner) Run(ctx context.Context, name string, args ...string) (CodexStreams, func() error, error) {
2✔
31
        // check context before starting to avoid spawning a process that will be immediately killed
2✔
32
        if err := ctx.Err(); err != nil {
2✔
33
                return CodexStreams{}, nil, fmt.Errorf("context already canceled: %w", err)
×
34
        }
×
35

36
        // use exec.Command (not CommandContext) because we handle cancellation ourselves
37
        // to ensure the entire process group is killed, not just the direct child
38
        cmd := exec.Command(name, args...) //nolint:noctx // intentional: we handle context cancellation via process group kill
2✔
39

2✔
40
        // create new process group so we can kill all descendants on cleanup
2✔
41
        setupProcessGroup(cmd)
2✔
42

2✔
43
        stderr, err := cmd.StderrPipe()
2✔
44
        if err != nil {
2✔
45
                return CodexStreams{}, nil, fmt.Errorf("stderr pipe: %w", err)
×
46
        }
×
47

48
        stdout, err := cmd.StdoutPipe()
2✔
49
        if err != nil {
2✔
50
                return CodexStreams{}, nil, fmt.Errorf("stdout pipe: %w", err)
×
51
        }
×
52

53
        if err := cmd.Start(); err != nil {
3✔
54
                return CodexStreams{}, nil, fmt.Errorf("start command: %w", err)
1✔
55
        }
1✔
56

57
        // setup process group cleanup with graceful shutdown on context cancellation
58
        cleanup := newProcessGroupCleanup(cmd, ctx.Done())
1✔
59

1✔
60
        return CodexStreams{Stderr: stderr, Stdout: stdout}, cleanup.Wait, nil
1✔
61
}
62

63
// CodexExecutor runs codex CLI commands and filters output.
64
type CodexExecutor struct {
65
        Command         string            // command to execute, defaults to "codex"
66
        Model           string            // model to use, defaults to gpt-5.2-codex
67
        ReasoningEffort string            // reasoning effort level, defaults to "xhigh"
68
        TimeoutMs       int               // stream idle timeout in ms, defaults to 3600000
69
        Sandbox         string            // sandbox mode, defaults to "read-only"
70
        ProjectDoc      string            // path to project documentation file
71
        OutputHandler   func(text string) // called for each filtered output line in real-time
72
        Debug           bool              // enable debug output
73
        ErrorPatterns   []string          // patterns to detect in output (e.g., rate limit messages)
74
        runner          CodexRunner       // for testing, nil uses default
75
}
76

77
// codexFilterState tracks header separator count for filtering.
78
type codexFilterState struct {
79
        headerCount int             // tracks "--------" separators seen (show content between first two)
80
        seen        map[string]bool // track all shown lines for deduplication
81
}
82

83
// Run executes codex CLI with the given prompt and returns filtered output.
84
// stderr is streamed line-by-line to OutputHandler for progress indication.
85
// stdout is captured entirely as the final response (returned in Result.Output).
86
func (e *CodexExecutor) Run(ctx context.Context, prompt string) Result {
17✔
87
        cmd := e.Command
17✔
88
        if cmd == "" {
33✔
89
                cmd = "codex"
16✔
90
        }
16✔
91

92
        model := e.Model
17✔
93
        if model == "" {
33✔
94
                model = "gpt-5.2-codex"
16✔
95
        }
16✔
96

97
        reasoningEffort := e.ReasoningEffort
17✔
98
        if reasoningEffort == "" {
33✔
99
                reasoningEffort = "xhigh"
16✔
100
        }
16✔
101

102
        timeoutMs := e.TimeoutMs
17✔
103
        if timeoutMs <= 0 {
33✔
104
                timeoutMs = 3600000
16✔
105
        }
16✔
106

107
        sandbox := e.Sandbox
17✔
108
        if sandbox == "" {
33✔
109
                sandbox = "read-only"
16✔
110
        }
16✔
111
        // disable sandbox in docker (landlock doesn't work in containers)
112
        if os.Getenv("RALPHEX_DOCKER") == "1" {
17✔
NEW
113
                sandbox = "danger-full-access"
×
NEW
114
        }
×
115

116
        args := []string{
17✔
117
                "exec",
17✔
118
                "--sandbox", sandbox,
17✔
119
                "-c", fmt.Sprintf("model=%q", model),
17✔
120
                "-c", "model_reasoning_effort=" + reasoningEffort,
17✔
121
                "-c", fmt.Sprintf("stream_idle_timeout_ms=%d", timeoutMs),
17✔
122
        }
17✔
123

17✔
124
        if e.ProjectDoc != "" {
18✔
125
                args = append(args, "-c", fmt.Sprintf("project_doc=%q", e.ProjectDoc))
1✔
126
        }
1✔
127

128
        args = append(args, prompt)
17✔
129

17✔
130
        runner := e.runner
17✔
131
        if runner == nil {
17✔
132
                runner = &execCodexRunner{}
×
133
        }
×
134

135
        streams, wait, err := runner.Run(ctx, cmd, args...)
17✔
136
        if err != nil {
18✔
137
                return Result{Error: fmt.Errorf("start codex: %w", err)}
1✔
138
        }
1✔
139

140
        // process stderr for progress display (header block + bold summaries)
141
        stderrDone := make(chan error, 1)
16✔
142
        go func() {
32✔
143
                stderrDone <- e.processStderr(ctx, streams.Stderr)
16✔
144
        }()
16✔
145

146
        // read stdout entirely as final response
147
        stdoutContent, stdoutErr := e.readStdout(streams.Stdout)
16✔
148

16✔
149
        // wait for stderr processing to complete
16✔
150
        stderrErr := <-stderrDone
16✔
151

16✔
152
        // wait for command completion
16✔
153
        waitErr := wait()
16✔
154

16✔
155
        // determine final error (prefer stderr/stdout errors over wait error)
16✔
156
        var finalErr error
16✔
157
        switch {
16✔
158
        case stderrErr != nil && !errors.Is(stderrErr, context.Canceled):
1✔
159
                finalErr = stderrErr
1✔
160
        case stdoutErr != nil:
×
161
                finalErr = stdoutErr
×
162
        case waitErr != nil:
2✔
163
                if ctx.Err() != nil {
3✔
164
                        finalErr = fmt.Errorf("context error: %w", ctx.Err())
1✔
165
                } else {
2✔
166
                        finalErr = fmt.Errorf("codex exited with error: %w", waitErr)
1✔
167
                }
1✔
168
        }
169

170
        // detect signal in stdout (the actual response)
171
        signal := detectSignal(stdoutContent)
16✔
172

16✔
173
        // check for error patterns in output
16✔
174
        if pattern := checkErrorPatterns(stdoutContent, e.ErrorPatterns); pattern != "" {
20✔
175
                return Result{
4✔
176
                        Output: stdoutContent,
4✔
177
                        Signal: signal,
4✔
178
                        Error:  &PatternMatchError{Pattern: pattern, HelpCmd: "codex /status"},
4✔
179
                }
4✔
180
        }
4✔
181

182
        // return stdout content as the result (the actual answer from codex)
183
        return Result{Output: stdoutContent, Signal: signal, Error: finalErr}
12✔
184
}
185

186
// processStderr reads stderr line-by-line, filters for progress display.
187
// shows header block (between first two "--------" separators) and bold summaries.
188
func (e *CodexExecutor) processStderr(ctx context.Context, r io.Reader) error {
21✔
189
        state := &codexFilterState{}
21✔
190
        scanner := bufio.NewScanner(r)
21✔
191
        // increase buffer size for large output lines
21✔
192
        buf := make([]byte, 0, 64*1024)
21✔
193
        scanner.Buffer(buf, maxScannerBuffer)
21✔
194

21✔
195
        for scanner.Scan() {
55✔
196
                select {
34✔
197
                case <-ctx.Done():
1✔
198
                        return fmt.Errorf("context done: %w", ctx.Err())
1✔
199
                default:
33✔
200
                }
201

202
                line := scanner.Text()
33✔
203
                if show, filtered := e.shouldDisplay(line, state); show {
62✔
204
                        if e.OutputHandler != nil {
49✔
205
                                e.OutputHandler(filtered + "\n")
20✔
206
                        }
20✔
207
                }
208
        }
209

210
        if err := scanner.Err(); err != nil {
22✔
211
                return fmt.Errorf("read stderr: %w", err)
2✔
212
        }
2✔
213
        return nil
18✔
214
}
215

216
// readStdout reads the entire stdout content as the final response.
217
func (e *CodexExecutor) readStdout(r io.Reader) (string, error) {
18✔
218
        data, err := io.ReadAll(r)
18✔
219
        if err != nil {
19✔
220
                return "", fmt.Errorf("read stdout: %w", err)
1✔
221
        }
1✔
222
        return string(data), nil
17✔
223
}
224

225
// shouldDisplay implements a simple filter for codex stderr output.
226
// shows: header block (between first two "--------" separators) and bold summaries.
227
// also deduplicates lines to avoid non-consecutive repeats.
228
func (e *CodexExecutor) shouldDisplay(line string, state *codexFilterState) (bool, string) {
70✔
229
        s := strings.TrimSpace(line)
70✔
230
        if s == "" {
74✔
231
                return false, ""
4✔
232
        }
4✔
233

234
        var show bool
66✔
235
        var filtered string
66✔
236
        var skipDedup bool // separators are not deduplicated
66✔
237

66✔
238
        switch {
66✔
239
        case strings.HasPrefix(s, "--------"):
26✔
240
                // track "--------" separators for header block
26✔
241
                state.headerCount++
26✔
242
                show = state.headerCount <= 2 // show first two separators
26✔
243
                filtered = line
26✔
244
                skipDedup = true // don't deduplicate separators
26✔
245
        case state.headerCount == 1:
17✔
246
                // show everything between first two separators (header block)
17✔
247
                show = true
17✔
248
                filtered = line
17✔
249
        case strings.HasPrefix(s, "**"):
16✔
250
                // show bold summaries after header (progress indication)
16✔
251
                show = true
16✔
252
                filtered = e.stripBold(s)
16✔
253
        }
254

255
        // check for duplicates before returning (except separators)
256
        if show && !skipDedup {
99✔
257
                if state.seen == nil {
48✔
258
                        state.seen = make(map[string]bool)
15✔
259
                }
15✔
260
                if state.seen[filtered] {
37✔
261
                        return false, "" // skip duplicate
4✔
262
                }
4✔
263
                state.seen[filtered] = true
29✔
264
        }
265

266
        return show, filtered
62✔
267
}
268

269
// stripBold removes markdown bold markers (**text**) from text.
270
func (e *CodexExecutor) stripBold(s string) string {
22✔
271
        // replace **text** with text
22✔
272
        result := s
22✔
273
        for {
65✔
274
                start := strings.Index(result, "**")
43✔
275
                if start == -1 {
64✔
276
                        break
21✔
277
                }
278
                end := strings.Index(result[start+2:], "**")
22✔
279
                if end == -1 {
23✔
280
                        break
1✔
281
                }
282
                // remove both markers
283
                result = result[:start] + result[start+2:start+2+end] + result[start+2+end+2:]
21✔
284
        }
285
        return result
22✔
286
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc