Files
diple/ai_codex.go

399 lines
12 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type CodexCLIProvider struct {
command string
model string
timeout time.Duration
workspace string
mu sync.Mutex
status AIProviderStatus
statusAt time.Time
}
var errAIOutputLimit = errors.New("AI provider output limit exceeded")
type limitedBuffer struct {
buffer bytes.Buffer
limit int
}
func (w *limitedBuffer) Write(value []byte) (int, error) {
remaining := w.limit - w.buffer.Len()
if remaining <= 0 {
return 0, errAIOutputLimit
}
if len(value) > remaining {
_, _ = w.buffer.Write(value[:remaining])
return remaining, errAIOutputLimit
}
return w.buffer.Write(value)
}
func NewCodexCLIProvider(config AIConfig, workspace string) *CodexCLIProvider {
return &CodexCLIProvider{
command: config.Command, model: config.Model, timeout: config.Timeout.Duration,
workspace: workspace,
}
}
func (p *CodexCLIProvider) Name() string { return "codex-cli" }
func (p *CodexCLIProvider) Status(ctx context.Context) AIProviderStatus {
p.mu.Lock()
if time.Since(p.statusAt) < 30*time.Second {
status := p.status
p.mu.Unlock()
return status
}
p.mu.Unlock()
status := p.probe(ctx)
p.mu.Lock()
p.status, p.statusAt = status, time.Now()
p.mu.Unlock()
return status
}
func (p *CodexCLIProvider) probe(ctx context.Context) AIProviderStatus {
command, err := p.secureCommand()
if err != nil {
return AIProviderStatus{Summary: "unavailable", Detail: err.Error()}
}
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
output, err := exec.CommandContext(probeCtx, command, "login", "status").CombinedOutput()
if err != nil || !strings.Contains(strings.ToLower(string(output)), "logged in") {
return AIProviderStatus{Summary: "not authenticated", Detail: safeAIText(string(output))}
}
model := p.model
if model == "" {
model, err = bundledDefaultCodexModel(probeCtx, command)
if err != nil {
return AIProviderStatus{
Summary: "model discovery failed", Detail: err.Error(),
}
}
}
return AIProviderStatus{Ready: true, Summary: "ready via ChatGPT login", Detail: command, Model: model}
}
func bundledDefaultCodexModel(ctx context.Context, command string) (string, error) {
output, err := exec.CommandContext(ctx, command, "debug", "models", "--bundled").Output()
if err != nil {
return "", fmt.Errorf("query bundled Codex models: %w", err)
}
var catalog struct {
Models []struct {
Slug string `json:"slug"`
Visibility string `json:"visibility"`
Priority int `json:"priority"`
} `json:"models"`
}
if err := json.Unmarshal(output, &catalog); err != nil {
return "", fmt.Errorf("decode bundled Codex models: %w", err)
}
best := ""
bestPriority := int(^uint(0) >> 1)
for _, model := range catalog.Models {
if model.Slug != "" && model.Visibility == "list" && model.Priority < bestPriority {
best, bestPriority = model.Slug, model.Priority
}
}
if best == "" {
return "", fmt.Errorf("Codex reported no selectable bundled model")
}
return best, nil
}
func (p *CodexCLIProvider) secureCommand() (string, error) {
command, err := exec.LookPath(p.command)
if err != nil {
return "", fmt.Errorf("find Codex CLI: %w", err)
}
command, err = filepath.EvalSymlinks(command)
if err != nil {
return "", fmt.Errorf("resolve Codex CLI: %w", err)
}
info, err := os.Stat(command)
if err != nil {
return "", err
}
if info.Mode().Perm()&0o022 != 0 {
return "", fmt.Errorf("refusing group/world-writable Codex executable %s", command)
}
workspace, _ := filepath.Abs(p.workspace)
if pathWithin(workspace, command) {
return "", fmt.Errorf("refusing Codex executable inside the reviewed workspace")
}
for _, name := range []string{"HOME", "CODEX_HOME"} {
if value := os.Getenv(name); value != "" && pathWithin(workspace, value) {
return "", fmt.Errorf("refusing %s inside the reviewed workspace", name)
}
}
return command, nil
}
func pathWithin(parent, candidate string) bool {
parent, parentErr := filepath.Abs(parent)
candidate, candidateErr := filepath.Abs(candidate)
if parentErr != nil || candidateErr != nil {
return false
}
rel, err := filepath.Rel(parent, candidate)
return err == nil && rel != ".." &&
!strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func (p *CodexCLIProvider) Generate(ctx context.Context, request AIInferenceRequest) (AIInferenceResponse, error) {
return p.generate(ctx, request, nil)
}
func (p *CodexCLIProvider) GenerateWithProgress(
ctx context.Context,
request AIInferenceRequest,
report func(AIProviderProgress),
) (AIInferenceResponse, error) {
return p.generate(ctx, request, report)
}
func (p *CodexCLIProvider) generate(
ctx context.Context,
request AIInferenceRequest,
report func(AIProviderProgress),
) (AIInferenceResponse, error) {
command, err := p.secureCommand()
if err != nil {
return AIInferenceResponse{}, err
}
temp, err := os.MkdirTemp("", "diple-ai-*")
if err != nil {
return AIInferenceResponse{}, err
}
defer os.RemoveAll(temp)
if pathWithin(p.workspace, temp) {
return AIInferenceResponse{}, fmt.Errorf("refusing AI temporary directory inside the reviewed workspace")
}
if err := os.Chmod(temp, 0o700); err != nil {
return AIInferenceResponse{}, err
}
schemaPath := filepath.Join(temp, "response-schema.json")
if err := os.WriteFile(schemaPath, request.Schema, 0o600); err != nil {
return AIInferenceResponse{}, err
}
runCtx, cancel := context.WithTimeout(ctx, p.timeout)
defer cancel()
args := []string{
"exec", "--ignore-user-config", "--ignore-rules", "--strict-config", "--ephemeral",
"--skip-git-repo-check", "-C", temp, "--sandbox", "read-only",
"-c", `approval_policy="never"`,
"--disable", "shell_tool", "--disable", "unified_exec", "--disable", "code_mode",
"--disable", "code_mode_host", "--disable", "shell_snapshot",
"--disable", "apps", "--disable", "browser_use", "--disable", "browser_use_external",
"--disable", "browser_use_full_cdp_access", "--disable", "in_app_browser",
"--disable", "standalone_web_search", "--disable", "computer_use",
"--disable", "image_generation", "--disable", "plugins", "--disable", "skill_search",
"--disable", "skill_mcp_dependency_install", "--disable", "memories",
"--disable", "multi_agent", "--disable", "multi_agent_v2",
"--disable", "auth_elicitation", "--disable", "tool_call_mcp_elicitation",
"--disable", "request_permissions_tool", "--disable", "tool_suggest",
"--disable", "hooks", "--disable", "remote_plugin", "--disable", "network_proxy",
"--disable", "workspace_dependencies", "--disable", "goals",
"--output-schema", schemaPath, "--json",
}
if request.Model != "" {
args = append(args, "-m", request.Model)
}
args = append(args, "-")
cmd := exec.CommandContext(runCtx, command, args...)
cmd.Dir = temp
cmd.Env = codexSafeEnvironment()
cmd.Stdin = strings.NewReader(request.Prompt)
var stdout, stderr limitedBuffer
stdout.limit, stderr.limit = 4<<20, 64<<10
progressWriter := &codexProgressWriter{output: &stdout, report: report}
cmd.Stdout, cmd.Stderr = progressWriter, &stderr
if err := cmd.Run(); err != nil {
progressWriter.Flush()
if runCtx.Err() != nil {
return AIInferenceResponse{}, fmt.Errorf("Codex CLI: %w", runCtx.Err())
}
detail := codexFailureDetail(stdout.buffer.Bytes(), stderr.buffer.Bytes())
return AIInferenceResponse{}, fmt.Errorf("Codex CLI: %w: %s", err, detail)
}
progressWriter.Flush()
content, model, err := parseCodexEvents(stdout.buffer.Bytes())
if err != nil {
return AIInferenceResponse{}, err
}
if model == "" {
model = request.Model
}
return AIInferenceResponse{Model: model, Content: content}, nil
}
type codexProgressWriter struct {
output *limitedBuffer
report func(AIProviderProgress)
pending []byte
}
func (w *codexProgressWriter) Write(value []byte) (int, error) {
n, err := w.output.Write(value)
if n > 0 {
w.pending = append(w.pending, value[:n]...)
w.consume(false)
}
return n, err
}
func (w *codexProgressWriter) Flush() {
w.consume(true)
}
func (w *codexProgressWriter) consume(flush bool) {
for {
index := bytes.IndexByte(w.pending, '\n')
if index < 0 {
if flush && len(w.pending) > 0 {
w.reportLine(w.pending)
w.pending = nil
}
return
}
w.reportLine(w.pending[:index])
w.pending = w.pending[index+1:]
}
}
func (w *codexProgressWriter) reportLine(line []byte) {
if w.report == nil || len(bytes.TrimSpace(line)) == 0 {
return
}
var event map[string]any
if json.Unmarshal(line, &event) != nil {
return
}
eventType, _ := event["type"].(string)
if eventType == "turn.started" {
w.report(AIProviderProgress{Kind: "activity", Text: "Model started"})
return
}
item, _ := event["item"].(map[string]any)
itemType, _ := item["type"].(string)
if itemType != "reasoning" {
return
}
text, _ := item["text"].(string)
text = truncateAIInline(safeAIText(text), 600)
if text != "" {
w.report(AIProviderProgress{Kind: "reasoning", Text: text})
}
}
func codexFailureDetail(stdout, stderr []byte) string {
var messages []string
scanner := bufio.NewScanner(bytes.NewReader(stdout))
scanner.Buffer(make([]byte, 4096), 4<<20)
for scanner.Scan() {
var event map[string]any
if json.Unmarshal(scanner.Bytes(), &event) != nil {
continue
}
eventType, _ := event["type"].(string)
if eventType != "error" && eventType != "turn.failed" {
continue
}
if message, ok := event["message"].(string); ok && strings.TrimSpace(message) != "" {
messages = append(messages, message)
}
if failure, ok := event["error"].(map[string]any); ok {
if message, ok := failure["message"].(string); ok && strings.TrimSpace(message) != "" {
messages = append(messages, message)
}
}
}
if len(messages) > 0 {
return truncateAIInline(safeAIText(strings.Join(messages, "; ")), 2_000)
}
if detail := safeAIText(string(stderr)); detail != "" {
return truncateAIInline(detail, 2_000)
}
return "Codex returned no diagnostic output"
}
func codexSafeEnvironment() []string {
allowed := map[string]bool{
"HOME": true, "CODEX_HOME": true, "PATH": true,
"SSL_CERT_FILE": true, "SSL_CERT_DIR": true,
"TERM": true,
}
var result []string
for _, item := range os.Environ() {
name := strings.SplitN(item, "=", 2)[0]
if allowed[name] {
result = append(result, item)
}
}
return result
}
func parseCodexEvents(data []byte) ([]byte, string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 4096), 4<<20)
var content []byte
var model string
for scanner.Scan() {
var event map[string]any
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
return nil, "", fmt.Errorf("invalid Codex event stream: %w", err)
}
eventType, _ := event["type"].(string)
lower := strings.ToLower(eventType)
if strings.Contains(lower, "tool") || strings.Contains(lower, "command") ||
strings.Contains(lower, "file_change") {
return nil, "", fmt.Errorf("Codex attempted forbidden capability %q", eventType)
}
if value, ok := event["model"].(string); ok {
model = value
}
item, _ := event["item"].(map[string]any)
itemType, _ := item["type"].(string)
itemLower := strings.ToLower(itemType)
if strings.Contains(itemLower, "tool") || strings.Contains(itemLower, "command") ||
strings.Contains(itemLower, "file_change") {
return nil, "", fmt.Errorf("Codex attempted forbidden item %q", itemType)
}
switch itemType {
case "", "reasoning", "agent_message":
default:
return nil, "", fmt.Errorf("Codex emitted unsupported item %q", itemType)
}
if itemType == "agent_message" {
if text, ok := item["text"].(string); ok {
content = []byte(text)
}
}
}
if err := scanner.Err(); err != nil {
return nil, "", err
}
if len(content) == 0 {
return nil, "", fmt.Errorf("Codex returned no structured response")
}
return content, model, nil
}