Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions internal/agent/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,10 @@ func (a *CodexAgent) commandArgs(opts codexArgOptions) []string {
args = append(args, codexDangerousFlag)
}
if opts.autoApprove {
// Use full-access sandbox for review mode. The read-only
// sandbox blocks loopback networking, which prevents git
// commands from working in CI review jobs. roborev runs in
// trusted environments where the code is the operator's own,
// so sandbox enforcement is unnecessary.
args = append(args, "--sandbox", "danger-full-access")
// Use read-only sandbox for review mode. The worker
// writes the diff to a file that Codex can read within
// the sandbox, removing the need for git commands.
args = append(args, "--sandbox", "read-only")
}
if !opts.preview {
args = append(args, "-C", opts.repoPath)
Expand Down Expand Up @@ -183,7 +181,7 @@ func codexSupportsDangerousFlag(ctx context.Context, command string) (bool, erro
}

// codexSupportsNonInteractive checks that codex supports --sandbox,
// needed for non-agentic review mode (--sandbox danger-full-access).
// needed for non-agentic review mode (--sandbox read-only).
func codexSupportsNonInteractive(ctx context.Context, command string) (bool, error) {
if cached, ok := codexAutoApproveSupport.Load(command); ok {
return cached.(bool), nil
Expand Down Expand Up @@ -212,8 +210,8 @@ func (a *CodexAgent) Review(ctx context.Context, repoPath, commitSHA, prompt str
}
}

// Non-agentic review mode uses --sandbox danger-full-access for
// non-interactive execution that can still run git commands.
// Non-agentic review mode uses --sandbox read-only for
// non-interactive sandboxed execution.
autoApprove := false
if !agenticMode {
supported, err := codexSupportsNonInteractive(ctx, a.Command)
Expand Down
8 changes: 4 additions & 4 deletions internal/agent/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestCodex_buildArgs(t *testing.T) {
name: "NonAgenticAutoApprove",
agentic: false,
autoApprove: true,
wantFlags: []string{"--sandbox", "danger-full-access", "--json"},
wantFlags: []string{"--sandbox", "read-only", "--json"},
wantMissingFlags: []string{codexDangerousFlag, codexAutoApproveFlag},
},
{
Expand Down Expand Up @@ -96,7 +96,7 @@ func TestCodexCommandLineOmitsRuntimeOnlyArgs(t *testing.T) {

assert.Contains(t, cmdLine, "exec resume --json")
assert.Contains(t, cmdLine, "session-123")
assert.Contains(t, cmdLine, "--sandbox danger-full-access")
assert.Contains(t, cmdLine, "--sandbox read-only")
assert.NotContains(t, cmdLine, " -C ")
assert.False(t, strings.HasSuffix(cmdLine, " -"), "command line should omit stdin marker: %q", cmdLine)
}
Expand Down Expand Up @@ -133,8 +133,8 @@ func TestCodexReviewUsesSandboxNone(t *testing.T) {
args, err := os.ReadFile(mock.ArgsFile)
require.NoError(t, err)
argsStr := string(args)
assert.Contains(t, argsStr, "--sandbox danger-full-access",
"expected --sandbox danger-full-access in args, got: %s", strings.TrimSpace(argsStr))
assert.Contains(t, argsStr, "--sandbox read-only",
"expected --sandbox read-only in args, got: %s", strings.TrimSpace(argsStr))
assert.NotContains(t, argsStr, codexAutoApproveFlag,
"expected no %s in review mode, got: %s", codexAutoApproveFlag, strings.TrimSpace(argsStr))
}
Expand Down
22 changes: 20 additions & 2 deletions internal/daemon/ci_poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,16 @@ func NewCIPoller(db *storage.DB, cfgGetter ConfigGetter, broadcaster Broadcaster
p.loadRepoConfigFn = loadCIRepoConfig
p.buildReviewPromptFn = func(repoPath, gitRef string, repoID int64, contextCount int, agentName, reviewType, additionalContext string, cfg *config.Config) (string, error) {
builder := prompt.NewBuilderWithConfig(p.db, cfg)
return builder.BuildWithAdditionalContext(repoPath, gitRef, repoID, contextCount, agentName, reviewType, additionalContext)
return builder.BuildWithAdditionalContextAndDiffFile(
repoPath,
gitRef,
repoID,
contextCount,
agentName,
reviewType,
additionalContext,
prompt.CodexDiffFilePathPlaceholder,
)
}
p.postPRCommentFn = p.postPRComment
p.synthesizeFn = p.synthesizeBatchResults
Expand Down Expand Up @@ -1824,7 +1833,16 @@ func (p *CIPoller) callBuildReviewPrompt(repoPath, gitRef string, repoID int64,
return p.buildReviewPromptFn(repoPath, gitRef, repoID, contextCount, agentName, reviewType, additionalContext, cfg)
}
builder := prompt.NewBuilderWithConfig(p.db, cfg)
return builder.BuildWithAdditionalContext(repoPath, gitRef, repoID, contextCount, agentName, reviewType, additionalContext)
return builder.BuildWithAdditionalContextAndDiffFile(
repoPath,
gitRef,
repoID,
contextCount,
agentName,
reviewType,
additionalContext,
prompt.CodexDiffFilePathPlaceholder,
)
}

func (p *CIPoller) callPostPRComment(ghRepo string, prNumber int, body string) error {
Expand Down
59 changes: 59 additions & 0 deletions internal/daemon/ci_poller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,65 @@ func TestCIPollerProcessPR_FallsBackWhenPromptPrebuildFails(t *testing.T) {
assert.Empty(t, jobs[0].Prompt)
}

func TestCIPollerProcessPR_PrebuildsLargeCodexPromptWithDiffFileInstructions(t *testing.T) {
h := newCIPollerHarness(t, "git@github.com:acme/api.git")
h.Cfg.CI.ReviewTypes = []string{"security"}
h.Cfg.CI.Agents = []string{"codex"}
h.Poller = NewCIPoller(h.DB, NewStaticConfig(h.Cfg), nil)
h.stubProcessPRGit()

testutil.InitTestGitRepo(t, h.RepoPath)

var content strings.Builder
for range 20000 {
content.WriteString("line ")
content.WriteString(strings.Repeat("x", 20))
content.WriteString(" ")
content.WriteString(strings.Repeat("y", 20))
content.WriteString("\n")
}
require.NoError(t, os.WriteFile(filepath.Join(h.RepoPath, "large.txt"), []byte(content.String()), 0o644))
cmd := exec.Command("git", "-C", h.RepoPath, "add", "large.txt")
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git add failed: %s", out)
cmd = exec.Command("git", "-C", h.RepoPath, "commit", "-m", "large followup")
out, err = cmd.CombinedOutput()
require.NoError(t, err, "git commit failed: %s", out)

headSHA := testutil.GetHeadSHA(t, h.RepoPath)
baseSHABytes, err := exec.Command("git", "-C", h.RepoPath, "rev-parse", "HEAD^").Output()
require.NoError(t, err)
baseSHA := strings.TrimSpace(string(baseSHABytes))

h.Poller.mergeBaseFn = func(_, _, _ string) (string, error) { return baseSHA, nil }
h.Poller.listTrustedActorsFn = func(context.Context, string) (map[string]struct{}, error) {
return map[string]struct{}{"alice": {}}, nil
}
h.Poller.listPRDiscussionFn = func(context.Context, string, int) ([]ghpkg.PRDiscussionComment, error) {
return []ghpkg.PRDiscussionComment{{
Author: "alice",
Body: "Recent maintainer guidance.",
Source: ghpkg.PRDiscussionSourceIssueComment,
CreatedAt: time.Date(2026, time.March, 27, 12, 0, 0, 0, time.UTC),
}}, nil
}

err = h.Poller.processPR(context.Background(), "acme/api", ghPR{
Number: 79, HeadRefOid: headSHA, BaseRefName: "main",
}, h.Cfg)
require.NoError(t, err)

jobs, err := h.DB.ListJobs("", h.RepoPath, 0, 0, storage.WithGitRef(baseSHA+".."+headSHA))
require.NoError(t, err)
require.Len(t, jobs, 1)

assert.Contains(t, jobs[0].Prompt, "## Pull Request Discussion")
assert.Contains(t, jobs[0].Prompt, "The full diff has been written to a file for review.")
assert.Contains(t, jobs[0].Prompt, "Read it with: `cat ")
assert.NotContains(t, jobs[0].Prompt, "inspect the commit range locally with read-only git commands")
assert.NotContains(t, jobs[0].Prompt, "git diff --unified=80")
}

func TestCIPollerSynthesizeBatchResults_WithTestAgent(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
Expand Down
185 changes: 183 additions & 2 deletions internal/daemon/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -388,6 +389,21 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) {
// discussion context survives retries and failover.
reviewPrompt = storedPromptValue
promptToPersist = storedPromptValue
var cleanup func()
excludes := config.ResolveExcludePatterns(
effectiveRepoPath, cfg, job.ReviewType,
)
reviewPrompt, cleanup, err = preparePrebuiltCodexPrompt(
effectiveRepoPath, job, reviewPrompt, excludes,
)
if cleanup != nil {
defer cleanup()
}
if err != nil {
log.Printf("[%s] Error preparing prebuilt prompt: %v", workerID, err)
wp.failOrRetry(workerID, job, job.Agent, fmt.Sprintf("prepare prebuilt prompt: %v", err))
return
}
} else if job.UsesStoredPrompt() && job.Prompt != "" {
// Prompt-native job (task, compact) — prepend agent-specific preamble
preamble := prompt.GetSystemPrompt(job.Agent, "run")
Expand All @@ -406,8 +422,42 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) {
// Dirty job - use pre-captured diff
reviewPrompt, err = pb.BuildDirty(effectiveRepoPath, *job.DiffContent, job.RepoID, cfg.ReviewContextCount, job.Agent, job.ReviewType)
} else {
// Normal job - build prompt from git ref
reviewPrompt, err = pb.Build(effectiveRepoPath, job.GitRef, job.RepoID, cfg.ReviewContextCount, job.Agent, job.ReviewType)
// Normal job - build prompt from git ref.
reviewPrompt, err = pb.Build(
effectiveRepoPath, job.GitRef, job.RepoID,
cfg.ReviewContextCount, job.Agent, job.ReviewType,
)
// Persist the portable prompt (without ephemeral file paths)
// so retries rebuild the diff file fresh.
promptToPersist = reviewPrompt
// If the diff was truncated for a Codex review, write a
// snapshot file so the sandboxed agent can read it directly
// instead of needing git commands.
if err == nil && strings.EqualFold(job.Agent, "codex") &&
strings.Contains(reviewPrompt, prompt.DiffTruncatedHint) {
excludes := config.ResolveExcludePatterns(
effectiveRepoPath, cfg, job.ReviewType,
)
diffFile, cleanup, diffFileErr := prepareDiffFileForCodex(
effectiveRepoPath, job, excludes,
)
if cleanup != nil {
defer cleanup()
}
if diffFile != "" {
reviewPrompt, err = pb.BuildWithDiffFile(
effectiveRepoPath, job.GitRef, job.RepoID,
cfg.ReviewContextCount, job.Agent,
job.ReviewType, diffFile,
)
} else if diffFileErr != nil &&
requiresSandboxedCodexDiffFile(job, cfg) {
err = fmt.Errorf("prepare codex diff file: %w", diffFileErr)
} else if diffFileErr != nil {
log.Printf("[%s] Warning: codex diff file: %v",
workerID, diffFileErr)
}
}
}
if err != nil {
log.Printf("[%s] Error building prompt: %v", workerID, err)
Expand Down Expand Up @@ -994,6 +1044,88 @@ func (wp *WorkerPool) failoverOrFail(
}
}

func preparePrebuiltCodexPrompt(
repoPath string, job *storage.ReviewJob, reviewPrompt string, excludes []string,
) (string, func(), error) {
hasPlaceholder := strings.Contains(
reviewPrompt, prompt.CodexDiffFilePathPlaceholder,
)
// Legacy prebuilt prompts (created before diff-file support) won't
// have the placeholder. For those, only write a diff file when the
// prompt indicates the diff was truncated.
if !hasPlaceholder {
if !strings.EqualFold(job.Agent, "codex") ||
!strings.Contains(reviewPrompt, prompt.DiffTruncatedHint) {
return reviewPrompt, nil, nil
}
diffFile, cleanup, err := prepareDiffFileForCodex(
repoPath, job, excludes,
)
if err != nil || diffFile == "" {
if cleanup != nil {
cleanup()
}
// Best-effort for legacy prompts: return as-is so the
// old git-command fallback can still be attempted.
return reviewPrompt, nil, nil
}
reviewPrompt += fmt.Sprintf(
"\n\nThe full diff is also available at: `cat %s`\n",
shellQuoteForPrompt(diffFile),
)
return reviewPrompt, cleanup, nil
}

diffFile, cleanup, err := prepareDiffFileForCodex(repoPath, job, excludes)
if err != nil {
if cleanup != nil {
cleanup()
}
return "", nil, fmt.Errorf("prepare prebuilt codex prompt diff file: %w", err)
}
if diffFile == "" {
if cleanup != nil {
cleanup()
}
return "", nil, fmt.Errorf(
"prebuilt codex prompt needs diff file but none could be prepared",
)
}
quotedPlaceholder := shellQuoteForPrompt(
prompt.CodexDiffFilePathPlaceholder,
)
reviewPrompt = strings.NewReplacer(
quotedPlaceholder, shellQuoteForPrompt(diffFile),
prompt.CodexDiffFilePathPlaceholder, diffFile,
).Replace(reviewPrompt)
return reviewPrompt, cleanup, nil
}

func shellQuoteForPrompt(s string) string {
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

func requiresSandboxedCodexDiffFile(
job *storage.ReviewJob, cfg *config.Config,
) bool {
if !strings.EqualFold(job.Agent, "codex") ||
job.Agentic || agent.AllowUnsafeAgents() {
return false
}
// Use the same config-aware resolution as the worker to check
// whether Codex is the effective agent. If it falls back to
// another agent (e.g., Codex is unavailable or overridden via
// codex_cmd), the diff file is not required.
a, err := agent.GetAvailableWithConfig(job.Agent, cfg)
if err != nil {
return false
}
return strings.EqualFold(a.Name(), "codex")
}

// logJobFailed logs a job failure to the activity log
func (wp *WorkerPool) logJobFailed(
jobID int64, workerID, agentName, errorMsg string,
Expand All @@ -1013,6 +1145,55 @@ func (wp *WorkerPool) logJobFailed(
)
}

// prepareDiffFileForCodex writes the full diff for a job into the
// checkout's git dir so a sandboxed Codex agent can read it without
// needing git commands or access outside the repository boundary.
// Returns the file path and a cleanup function, or ("", nil) when no
// file was written.
func prepareDiffFileForCodex(
repoPath string, job *storage.ReviewJob, excludes []string,
) (string, func(), error) {
if !strings.EqualFold(job.Agent, "codex") {
return "", nil, nil
}
// Job-specific agentic mode keeps the existing direct-tooling path.
if job.Agentic {
return "", nil, nil
}
var fullDiff string
var err error
if gitpkg.IsRange(job.GitRef) {
fullDiff, err = gitpkg.GetRangeDiff(
repoPath, job.GitRef, excludes...,
)
} else {
fullDiff, err = gitpkg.GetDiff(
repoPath, job.GitRef, excludes...,
)
}
if err != nil {
return "", nil, fmt.Errorf("capture full diff: %w", err)
}
if fullDiff == "" {
return "", nil, nil
}
gitDir, err := gitpkg.ResolveGitDir(repoPath)
if err != nil {
return "", nil, fmt.Errorf("resolve git dir: %w", err)
}
if info, err := os.Stat(gitDir); err != nil || !info.IsDir() {
if err != nil {
return "", nil, fmt.Errorf("stat git dir: %w", err)
}
return "", nil, fmt.Errorf("git dir %s is not a directory", gitDir)
}
diffFile := filepath.Join(gitDir, fmt.Sprintf("roborev-review-%d.diff", job.ID))
if err := os.WriteFile(diffFile, []byte(fullDiff), 0o644); err != nil {
return "", nil, fmt.Errorf("write diff file %s: %w", diffFile, err)
}
return diffFile, func() { os.Remove(diffFile) }, nil
}

// markCompactSourceJobs marks all source jobs as closed for a completed compact job
func (wp *WorkerPool) markCompactSourceJobs(workerID string, jobID int64) error {
// Read metadata file, retrying briefly in case the CLI hasn't finished
Expand Down
Loading
Loading