Skip to content

Commit ef9d858

Browse files
wesmclaude
andauthored
fix: Codex sandbox compatibility and command line accuracy (#633)
## Summary - **Inline snapshot diffs for sandboxed Codex**: Codex `--sandbox read-only` blocks `.git/` access, making large-diff snapshot files unreadable. The worker now reads the `.git/` snapshot and inlines the diff content into the prompt sent via stdin. The DB keeps the truncated prompt; only the agent sees the full diff. Non-Codex agents continue using `.git/` snapshots directly. - **`disable_codex_sandbox` config option**: On systems where bwrap fails (missing unprivileged user namespace support), add a global config toggle that switches Codex from `--sandbox read-only` to `--dangerously-bypass-approvals-and-sandbox`. Hot-reloadable via config watcher. - **Store actual command line on jobs**: The daemon worker now saves the real `CommandLine()` to the job record when starting each run. The TUI displays this stored value instead of reconstructing it client-side, which missed daemon-only config like `disable_codex_sandbox`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3409106 commit ef9d858

13 files changed

Lines changed: 213 additions & 27 deletions

File tree

cmd/roborev/tui/render_queue.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,11 @@ func commandLineForJob(job *storage.ReviewJob) string {
725725
if job == nil {
726726
return ""
727727
}
728+
// Prefer the actual command line saved by the daemon worker.
729+
if job.CommandLine != "" {
730+
return stripControlChars(job.CommandLine)
731+
}
732+
// Fallback: reconstruct locally (may not reflect daemon config).
728733
a, err := agent.Get(job.Agent)
729734
if err != nil {
730735
return ""

internal/agent/agent.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ var (
9999
)
100100

101101
var allowUnsafeAgents atomic.Bool
102+
var codexSandboxDisabled atomic.Bool
102103
var anthropicAPIKey atomic.Value
103104

104105
func AllowUnsafeAgents() bool {
@@ -109,6 +110,18 @@ func SetAllowUnsafeAgents(allow bool) {
109110
allowUnsafeAgents.Store(allow)
110111
}
111112

113+
// CodexSandboxDisabled returns true when the Codex bwrap sandbox
114+
// should be skipped (e.g. on machines without unprivileged user
115+
// namespace support). When true, Codex runs with --full-auto
116+
// instead of --sandbox read-only.
117+
func CodexSandboxDisabled() bool {
118+
return codexSandboxDisabled.Load()
119+
}
120+
121+
func SetCodexSandboxDisabled(v bool) {
122+
codexSandboxDisabled.Store(v)
123+
}
124+
112125
// AnthropicAPIKey returns the configured Anthropic API key, or empty string if not set
113126
func AnthropicAPIKey() string {
114127
if v := anthropicAPIKey.Load(); v != nil {

internal/agent/codex.go

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"io"
9+
"log"
910
"os/exec"
1011
"strings"
1112
"sync"
@@ -106,26 +107,32 @@ func (a *CodexAgent) CommandName() string {
106107
func (a *CodexAgent) CommandLine() string {
107108
agenticMode := a.Agentic || AllowUnsafeAgents()
108109
args := a.commandArgs(codexArgOptions{
109-
agenticMode: agenticMode,
110-
autoApprove: !agenticMode,
111-
preview: true,
110+
agenticMode: agenticMode,
111+
autoApprove: !agenticMode,
112+
sandboxBroken: CodexSandboxDisabled(),
113+
preview: true,
112114
})
113115
return a.Command + " " + strings.Join(args, " ")
114116
}
115117

116-
func (a *CodexAgent) buildArgs(repoPath string, agenticMode, autoApprove bool) []string {
118+
func (a *CodexAgent) buildArgs(
119+
repoPath string,
120+
agenticMode, autoApprove, sandboxBroken bool,
121+
) []string {
117122
return a.commandArgs(codexArgOptions{
118-
repoPath: repoPath,
119-
agenticMode: agenticMode,
120-
autoApprove: autoApprove,
123+
repoPath: repoPath,
124+
agenticMode: agenticMode,
125+
autoApprove: autoApprove,
126+
sandboxBroken: sandboxBroken,
121127
})
122128
}
123129

124130
type codexArgOptions struct {
125-
repoPath string
126-
agenticMode bool
127-
autoApprove bool
128-
preview bool
131+
repoPath string
132+
agenticMode bool
133+
autoApprove bool
134+
sandboxBroken bool
135+
preview bool
129136
}
130137

131138
func (a *CodexAgent) commandArgs(opts codexArgOptions) []string {
@@ -141,10 +148,13 @@ func (a *CodexAgent) commandArgs(opts codexArgOptions) []string {
141148
args = append(args, codexDangerousFlag)
142149
}
143150
if opts.autoApprove {
144-
// Use read-only sandbox for review mode. The worker
145-
// writes the diff to a file that Codex can read within
146-
// the sandbox, removing the need for git commands.
147-
args = append(args, "--sandbox", "read-only")
151+
if opts.sandboxBroken {
152+
// --full-auto still uses bwrap internally, so we
153+
// need the full bypass flag on broken systems.
154+
args = append(args, codexDangerousFlag)
155+
} else {
156+
args = append(args, "--sandbox", "read-only")
157+
}
148158
}
149159
if !opts.preview {
150160
args = append(args, "-C", opts.repoPath)
@@ -226,7 +236,11 @@ func (a *CodexAgent) Review(ctx context.Context, repoPath, commitSHA, prompt str
226236

227237
// Use codex exec with --json for JSONL streaming output
228238
// The prompt is piped via stdin using "-" to avoid command line length limits on Windows
229-
args := a.buildArgs(repoPath, agenticMode, autoApprove)
239+
sandboxBroken := CodexSandboxDisabled()
240+
if sandboxBroken && autoApprove {
241+
log.Printf("codex: sandbox disabled via config, using %s", codexAutoApproveFlag)
242+
}
243+
args := a.buildArgs(repoPath, agenticMode, autoApprove, sandboxBroken)
230244

231245
runResult, runErr := runStreamingCLI(ctx, streamingCLISpec{
232246
Name: "codex",

internal/agent/codex_test.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func TestCodex_buildArgs(t *testing.T) {
2929
name string
3030
agentic bool
3131
autoApprove bool
32+
sandboxBroken bool
3233
wantFlags []string
3334
wantMissingFlags []string
3435
}{
@@ -46,11 +47,19 @@ func TestCodex_buildArgs(t *testing.T) {
4647
wantFlags: []string{codexDangerousFlag, "--json"},
4748
wantMissingFlags: []string{codexAutoApproveFlag},
4849
},
50+
{
51+
name: "SandboxBrokenFallback",
52+
agentic: false,
53+
autoApprove: true,
54+
sandboxBroken: true,
55+
wantFlags: []string{codexDangerousFlag, "--json"},
56+
wantMissingFlags: []string{"--sandbox", codexAutoApproveFlag},
57+
},
4958
}
5059

5160
for _, tt := range tests {
5261
t.Run(tt.name, func(t *testing.T) {
53-
args := a.buildArgs("/repo", tt.agentic, tt.autoApprove)
62+
args := a.buildArgs("/repo", tt.agentic, tt.autoApprove, tt.sandboxBroken)
5463

5564
for _, flag := range tt.wantFlags {
5665
assert.Contains(t, args, flag, "buildArgs() missing expected flag %q, args: %v", flag, args)
@@ -68,7 +77,7 @@ func TestCodex_buildArgs(t *testing.T) {
6877
func TestCodexBuildArgsWithSessionResume(t *testing.T) {
6978
a := NewCodexAgent("codex").WithSessionID("session-123").(*CodexAgent)
7079

71-
args := a.buildArgs("/repo", false, true)
80+
args := a.buildArgs("/repo", false, true, false)
7281

7382
require.GreaterOrEqual(t, len(args), 3)
7483
assert.Equal(t, "exec", args[0])
@@ -81,7 +90,7 @@ func TestCodexBuildArgsWithSessionResume(t *testing.T) {
8190
func TestCodexBuildArgsRejectsInvalidSessionResume(t *testing.T) {
8291
a := NewCodexAgent("codex").WithSessionID("-bad-session").(*CodexAgent)
8392

84-
args := a.buildArgs("/repo", false, true)
93+
args := a.buildArgs("/repo", false, true, false)
8594

8695
require.GreaterOrEqual(t, len(args), 2)
8796
assert.Equal(t, "exec", args[0])

internal/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,9 @@ type Config struct {
144144
SecurityBackupModel string `toml:"security_backup_model"`
145145
DesignBackupModel string `toml:"design_backup_model"`
146146

147-
AllowUnsafeAgents *bool `toml:"allow_unsafe_agents"` // nil = not set, allows commands to choose their own default
148-
ReuseReviewSession *bool `toml:"reuse_review_session"` // nil = not set; when true, reuse prior branch review sessions when possible
147+
AllowUnsafeAgents *bool `toml:"allow_unsafe_agents"` // nil = not set, allows commands to choose their own default
148+
DisableCodexSandbox bool `toml:"disable_codex_sandbox"` // use --full-auto instead of --sandbox read-only (for systems where bwrap is broken)
149+
ReuseReviewSession *bool `toml:"reuse_review_session"` // nil = not set; when true, reuse prior branch review sessions when possible
149150

150151
// Agent commands
151152
CodexCmd string `toml:"codex_cmd"`

internal/daemon/config_watcher.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ func (cw *ConfigWatcher) reloadConfig() {
210210

211211
// Update global agent settings
212212
agent.SetAllowUnsafeAgents(newCfg.AllowUnsafeAgents != nil && *newCfg.AllowUnsafeAgents)
213+
agent.SetCodexSandboxDisabled(newCfg.DisableCodexSandbox)
213214
agent.SetAnthropicAPIKey(newCfg.AnthropicAPIKey)
214215

215216
// Log what changed (for debugging)

internal/daemon/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type Server struct {
5555
func NewServer(db *storage.DB, cfg *config.Config, configPath string) *Server {
5656
// Always set for deterministic state - default to false (conservative)
5757
agent.SetAllowUnsafeAgents(cfg.AllowUnsafeAgents != nil && *cfg.AllowUnsafeAgents)
58+
agent.SetCodexSandboxDisabled(cfg.DisableCodexSandbox)
5859
agent.SetAnthropicAPIKey(cfg.AnthropicAPIKey)
5960
broadcaster := NewBroadcaster()
6061

internal/daemon/worker.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"log"
99
"os"
10+
"regexp"
1011
"strings"
1112
"sync"
1213
"sync/atomic"
@@ -501,6 +502,21 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) {
501502
log.Printf("[%s] Agent %s not available, using %s", workerID, job.Agent, agentName)
502503
}
503504

505+
// Store the actual command line so the TUI displays what the
506+
// daemon executes, not a client-side reconstruction.
507+
if err := wp.db.SaveJobCommandLine(job.ID, a.CommandLine()); err != nil {
508+
log.Printf("[%s] Error saving command line: %v", workerID, err)
509+
}
510+
511+
// Codex --sandbox read-only cannot read files inside .git/.
512+
// When the prompt references a snapshot file (oversized diff),
513+
// read the file and inline its content so Codex gets the full
514+
// diff via stdin without needing filesystem access. The DB
515+
// keeps the truncated prompt; only the agent prompt expands.
516+
if agentName == "codex" && !agentic {
517+
reviewPrompt = expandSnapshotInline(reviewPrompt)
518+
}
519+
504520
// Use the effective worktree path for events (empty when worktree is gone or not a worktree job).
505521
eventWorktreePath := ""
506522
if effectiveRepoPath != job.RepoPath {
@@ -1047,6 +1063,63 @@ func preparePrebuiltPrompt(
10471063
return replacer.Replace(reviewPrompt), cleanup, nil
10481064
}
10491065

1066+
// snapshotFileRefRE matches the file-reference lines emitted by
1067+
// diffFileFallbackVariants in the prompt builder when a diff is too
1068+
// large to inline. Example:
1069+
//
1070+
// Read the diff from: `/path/to/.git/roborev-snapshot-1234.diff`
1071+
var snapshotFileRefRE = regexp.MustCompile(
1072+
"`([^`]+roborev-snapshot-[^`]+\\.diff)`",
1073+
)
1074+
1075+
// expandSnapshotInline reads snapshot files referenced in a prompt
1076+
// and replaces the file-reference section with the actual diff
1077+
// content. This allows sandboxed agents that cannot access .git/ to
1078+
// receive the full diff via stdin.
1079+
func expandSnapshotInline(reviewPrompt string) string {
1080+
m := snapshotFileRefRE.FindStringSubmatch(reviewPrompt)
1081+
if m == nil {
1082+
return reviewPrompt
1083+
}
1084+
snapshotPath := m[1]
1085+
data, err := os.ReadFile(snapshotPath)
1086+
if err != nil {
1087+
// File unreadable — return the prompt as-is; the agent
1088+
// will see the truncated reference (same as before).
1089+
log.Printf("expand snapshot inline: read %s: %v", snapshotPath, err)
1090+
return reviewPrompt
1091+
}
1092+
1093+
// Replace the entire "(Diff too large …) … Read the diff from …"
1094+
// block with an inline diff fence.
1095+
inline := "```diff\n" + string(data)
1096+
if len(data) > 0 && data[len(data)-1] != '\n' {
1097+
inline += "\n"
1098+
}
1099+
inline += "```\n"
1100+
1101+
// The fallback block always starts with "(Diff too large" and
1102+
// ends after the file-reference line. Replace that span.
1103+
start := strings.Index(reviewPrompt, "(Diff too large")
1104+
if start < 0 {
1105+
// Dirty-review fallback uses different wording.
1106+
start = strings.Index(reviewPrompt, "The full diff is also available at:")
1107+
if start < 0 {
1108+
return reviewPrompt
1109+
}
1110+
}
1111+
refEnd := strings.Index(reviewPrompt[start:], m[0])
1112+
if refEnd < 0 {
1113+
return reviewPrompt
1114+
}
1115+
// Include the trailing backtick and any newlines after it.
1116+
end := start + refEnd + len(m[0])
1117+
for end < len(reviewPrompt) && reviewPrompt[end] == '\n' {
1118+
end++
1119+
}
1120+
return reviewPrompt[:start] + inline + reviewPrompt[end:]
1121+
}
1122+
10501123
func shellQuoteForPrompt(s string) string {
10511124
if s == "" {
10521125
return "''"

internal/daemon/worker_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,49 @@ func TestProcessJob_LargeDiffRetriesWhenSnapshotFails(t *testing.T) {
751751
assert.False(t, agentCalled, "agent should not run when large diff has no snapshot")
752752
}
753753

754+
func TestExpandSnapshotInline_ReplacesFileRef(t *testing.T) {
755+
// Write a temp snapshot file
756+
f, err := os.CreateTemp(t.TempDir(), "roborev-snapshot-*.diff")
757+
require.NoError(t, err)
758+
_, err = f.WriteString("diff --git a/file.go b/file.go\n+added line\n")
759+
require.NoError(t, err)
760+
require.NoError(t, f.Close())
761+
762+
prompt := fmt.Sprintf(
763+
"### Diff\n\n"+
764+
"(Diff too large to include inline)\n\n"+
765+
"The full diff has been written to a file for review.\n"+
766+
"Read the diff from: `%s`\n\n"+
767+
"Review the actual diff before writing findings.\n"+
768+
"\n## Summary\n",
769+
f.Name(),
770+
)
771+
772+
result := expandSnapshotInline(prompt)
773+
774+
assert := assert.New(t)
775+
assert.NotContains(result, "Diff too large")
776+
assert.NotContains(result, "roborev-snapshot-")
777+
assert.Contains(result, "```diff")
778+
assert.Contains(result, "+added line")
779+
assert.Contains(result, "## Summary")
780+
}
781+
782+
func TestExpandSnapshotInline_NoRef(t *testing.T) {
783+
prompt := "### Diff\n\n```diff\n+inline diff\n```\n"
784+
result := expandSnapshotInline(prompt)
785+
assert.Equal(t, prompt, result)
786+
}
787+
788+
func TestExpandSnapshotInline_MissingFile(t *testing.T) {
789+
prompt := "### Diff\n\n" +
790+
"(Diff too large to include inline)\n\n" +
791+
"Read the diff from: `/nonexistent/roborev-snapshot-123.diff`\n"
792+
result := expandSnapshotInline(prompt)
793+
// Should return unchanged when file doesn't exist
794+
assert.Equal(t, prompt, result)
795+
}
796+
754797
func TestWorkerPoolCancelJobFinalCheckDeadlockSafe(t *testing.T) {
755798
tc := newWorkerTestContext(t, 1)
756799
job := tc.createAndClaimJob(t, "deadlock-test", testWorkerID)

internal/storage/db.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,18 @@ func (db *DB) migrate() error {
775775
}
776776
}
777777

778+
// Migration: add command_line column to review_jobs if missing
779+
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('review_jobs') WHERE name = 'command_line'`).Scan(&count)
780+
if err != nil {
781+
return fmt.Errorf("check command_line column: %w", err)
782+
}
783+
if count == 0 {
784+
_, err = db.Exec(`ALTER TABLE review_jobs ADD COLUMN command_line TEXT`)
785+
if err != nil {
786+
return fmt.Errorf("add command_line column: %w", err)
787+
}
788+
}
789+
778790
// Run sync-related migrations
779791
if err := db.migrateSyncColumns(); err != nil {
780792
return err

0 commit comments

Comments
 (0)