package runner import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" "time" "gitea-codex-bot/internal/config" "gitea-codex-bot/internal/domain" "gitea-codex-bot/internal/review" ) const startMarker = "__CODEX_REVIEW_RESULT_BEGIN__" const endMarker = "__CODEX_REVIEW_RESULT_END__" const maxRunnerOutput = 4 << 20 type DockerRunner struct{ settings config.Settings } func NewDockerRunner(settings config.Settings) *DockerRunner { return &DockerRunner{settings: settings} } func (r *DockerRunner) Run(parent context.Context, pr domain.PullRequestContext, cmd domain.ParsedCommand, cfg domain.RepoReviewConfig) (domain.ReviewResult, error) { ctx, cancel := context.WithTimeout(parent, time.Duration(r.settings.MaxReviewMinutes)*time.Minute) defer cancel() nonce := fmt.Sprintf("%d", time.Now().UnixNano()) begin, end := startMarker+"_"+nonce, endMarker+"_"+nonce prompt := review.BuildPrompt(cmd, cfg, pr) script := r.script(pr, prompt, begin, end, cfg.MaxDiffBytes) name := "codex-review-" + nonce args := []string{"run", "--rm", "-i", "--name", name, "--cap-drop=ALL", "--security-opt", "no-new-privileges", "--tmpfs", "/tmp:rw,noexec,nosuid,size=512m", "--tmpfs", "/work:rw,nosuid,size=1g", "-e", "CODEX_DISABLE_TELEMETRY=1"} if r.settings.CodexAuthMode == "chatgpt" { args = append(args, "--tmpfs", "/root/.codex:rw,nosuid,size=16m", "-e", "CODEX_AUTH_JSON_B64") } else { args = append(args, "-e", "OPENAI_API_KEY") } args = append(args, "-e", "OPENAI_ORG_ID", "-e", "OPENAI_PROJECT_ID", "-e", "GITEA_TOKEN", "-e", "GITEA_GIT_USERNAME", r.settings.RunnerImage, "bash", "-lc", script) cmdExec := exec.CommandContext(ctx, "docker", args...) cmdExec.Env = append(os.Environ(), "OPENAI_API_KEY="+r.settings.OpenAIAPIKey, "OPENAI_ORG_ID="+r.settings.OpenAIOrgID, "OPENAI_PROJECT_ID="+r.settings.OpenAIProjectID, "GITEA_TOKEN="+r.settings.GiteaToken, "GITEA_GIT_USERNAME="+r.settings.GiteaBotUsername) if r.settings.CodexAuthMode == "chatgpt" { data, err := readAuthJSON(r.settings.CodexAuthJSONPath) if err != nil { return domain.ReviewResult{}, err } cmdExec.Env = append(cmdExec.Env, "CODEX_AUTH_JSON_B64="+base64.StdEncoding.EncodeToString(data)) } var output limitedBuffer output.limit = maxRunnerOutput cmdExec.Stdout = &output cmdExec.Stderr = &output if err := cmdExec.Run(); err != nil { _ = exec.CommandContext(context.Background(), "docker", "rm", "-f", name).Run() if ctx.Err() != nil { return domain.ReviewResult{}, fmt.Errorf("review runner timeout: %w", ctx.Err()) } return domain.ReviewResult{}, fmt.Errorf("review runner failed: %w", err) } if output.Truncated() { return domain.ReviewResult{}, fmt.Errorf("review runner output exceeded %d bytes", maxRunnerOutput) } text := output.String() start := strings.Index(text, begin) endPos := strings.LastIndex(text, end) if start < 0 || endPos <= start { return domain.ReviewResult{}, fmt.Errorf("review runner returned no result artifact") } artifact := strings.TrimSpace(text[start+len(begin) : endPos]) var result domain.ReviewResult if err := json.Unmarshal([]byte(artifact), &result); err != nil { return domain.ReviewResult{}, err } if err := review.ValidateResult(result); err != nil { return domain.ReviewResult{}, err } return result, nil } func readAuthJSON(rawPath string) ([]byte, error) { path := os.ExpandEnv(rawPath) if strings.HasPrefix(path, "~") && len(path) > 1 && (path[1] == '/' || path[1] == '\\') { home, err := os.UserHomeDir() if err != nil { return nil, err } path = filepath.Join(home, strings.TrimLeft(path[1:], "/\\")) } data, err := os.ReadFile(filepath.Clean(path)) if err != nil { return nil, err } if !json.Valid(data) { return nil, fmt.Errorf("CODEX_AUTH_JSON_PATH is not valid JSON") } return data, nil } func (r *DockerRunner) script(pr domain.PullRequestContext, prompt, begin, end string, maxDiffBytes int) string { auth := base64.StdEncoding.EncodeToString([]byte(r.settings.GiteaBotUsername + ":" + r.settings.GiteaToken)) schema := `{"type":"object","additionalProperties":false,"required":["verdict","confidence","summary","findings","markdown_comment"],"properties":{"verdict":{"type":"string","enum":["correct","has_issues"]},"confidence":{"type":"number"},"summary":{"type":"string"},"markdown_comment":{"type":"string"},"findings":{"type":"array"}}}` quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } baseRemote := "origin" remoteSetup := "" if pr.BaseCloneURL != "" && pr.BaseCloneURL != pr.CloneURL { baseRemote = "upstream" remoteSetup = "git remote add upstream " + quote(pr.BaseCloneURL) } authSetup := "" if r.settings.CodexAuthMode == "chatgpt" { authSetup = "mkdir -p /root/.codex; printf '%s' \"$CODEX_AUTH_JSON_B64\" | base64 -d > /root/.codex/auth.json; chmod 600 /root/.codex/auth.json" } bootstrap := "if ! command -v git >/dev/null 2>&1; then apt-get update >/tmp/apt-update.log 2>&1 && apt-get install -y --no-install-recommends ca-certificates git >/tmp/apt-install.log 2>&1; fi; if ! command -v codex >/dev/null 2>&1; then npm install -g @openai/codex@latest >/tmp/codex-install.log 2>&1; fi; command -v git >/dev/null 2>&1; command -v codex >/dev/null 2>&1" fetchHead := "git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags origin " + quote(pr.HeadRef) + " || git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags origin " + quote(pr.HeadSHA) fetchBase := "git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags " + baseRemote + " " + quote(pr.BaseRef) + " || git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags " + baseRemote + " " + quote(pr.BaseSHA) steps := []string{"set -eu", "printf '%s' " + quote(schema) + " > /tmp/schema.json", bootstrap} if authSetup != "" { steps = append(steps, authSetup) } steps = append(steps, "git -c http.extraHeader="+quote("Authorization: Basic "+auth)+" clone --no-tags --depth 80 "+quote(pr.CloneURL)+" /work/repo", "cd /work/repo") if remoteSetup != "" { steps = append(steps, remoteSetup) } steps = append(steps, fetchHead, fetchBase, "git checkout --detach "+quote(pr.HeadSHA), "test \"$(git rev-parse HEAD)\" = "+quote(pr.HeadSHA)) if maxDiffBytes > 0 { steps = append(steps, "diff_bytes=$(git diff --binary "+quote(pr.BaseSHA)+" "+quote(pr.HeadSHA)+" | wc -c); test \"$diff_bytes\" -le "+fmt.Sprintf("%d", maxDiffBytes)) } steps = append(steps, "unset GITEA_TOKEN", "codex exec --sandbox danger-full-access --json --output-schema /tmp/schema.json -o /tmp/result.json -m "+quote(r.settings.OpenAIReviewModel)+" "+quote(prompt), "test -s /tmp/result.json", "printf '%s\\n' "+quote(begin), "cat /tmp/result.json", "printf '%s\\n' "+quote(end)) return strings.Join(steps, "; ") } type limitedBuffer struct { mu sync.Mutex buffer bytes.Buffer limit int truncated bool } func (b *limitedBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() remaining := b.limit - b.buffer.Len() if remaining <= 0 { b.truncated = true return len(p), nil } if len(p) > remaining { _, _ = b.buffer.Write(p[:remaining]) b.truncated = true return len(p), nil } return b.buffer.Write(p) } func (b *limitedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.buffer.String() } func (b *limitedBuffer) Truncated() bool { b.mu.Lock(); defer b.mu.Unlock(); return b.truncated } var _ domain.ReviewRunner = (*DockerRunner)(nil)