399 lines
12 KiB
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
|
|
}
|