Files
codex-bot d6ee8942f1
Foundry Go checks / check (push) Successful in 2m40s
Foundry Go checks / check (pull_request) Successful in 2m36s
Implement Canvas V2 subgraphs, assets and asynchronous AI execution
2026-11-06 18:36:00 +08:00

198 lines
5.8 KiB
Go

package codex
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
func executeCanvasTask(w http.ResponseWriter, r *http.Request, input CanvasTask, model CanvasModel) {
imageOutput := input.OutputModality == "image"
if input.OutputModality != "" && input.OutputModality != "text" && !imageOutput {
fail(w, 400, "invalid_ai_request")
return
}
if imageOutput != (len(model.OutputModalities) == 1 && model.OutputModalities[0] == "image") {
fail(w, 422, "model_incompatible")
return
}
duration := 90 * time.Second
if imageOutput {
duration = 180 * time.Second
}
ctx, cancel := context.WithTimeout(r.Context(), duration)
defer cancel()
job, err := os.MkdirTemp("", "foundry-canvas-")
if err != nil {
fail(w, 503, "codex_unavailable")
return
}
defer os.RemoveAll(job)
home := filepath.Join(job, "home")
if os.Mkdir(home, 0700) != nil || os.Symlink("/home/node/.codex/auth.json", filepath.Join(home, "auth.json")) != nil {
fail(w, 503, "codex_unavailable")
return
}
args := []string{"--ask-for-approval", "never", "--disable", "shell_tool", "--disable", "apps", "--disable", "plugins", "--disable", "multi_agent", "--disable", "hooks", "--disable", "memories", "-c", `web_search="disabled"`}
if !imageOutput {
args = append(args, "--disable", "image_generation")
}
args = append(args, "exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--model", model.Backend)
total := 0
for i, image := range input.Images {
total += len(image.Data)
if total > CanvasImageBytesLimit || len(image.Data) == 0 || http.DetectContentType(image.Data) != image.MIME {
fail(w, 400, "invalid_ai_request")
return
}
extension := map[string]string{"image/png": "png", "image/jpeg": "jpeg", "image/webp": "webp", "image/gif": "gif"}[image.MIME]
if extension == "" {
fail(w, 400, "invalid_ai_request")
return
}
path := filepath.Join(job, fmt.Sprintf("input-%d.%s", i, extension))
if os.WriteFile(path, image.Data, 0600) != nil {
fail(w, 503, "codex_unavailable")
return
}
args = append(args, "--image", path)
}
args = append(args, "-")
prompt := input.Prompt
if imageOutput {
prompt = "Use the built-in image_generation tool to generate exactly one raster image following the supplied canvas instruction. Do not use shell, SVG, external URLs or substitute a textual description. Return a brief confirmation after generating the image.\n" + prompt
}
if len(prompt) > CanvasInputLimit {
fail(w, 413, "context_too_large")
return
}
cmd := exec.CommandContext(ctx, executable, args...)
cmd.Dir = "/work"
cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/home/node", "CODEX_HOME=" + home, "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"}
cmd.Stdin = strings.NewReader(prompt)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
cmd.WaitDelay = 2 * time.Second
output := outputBuffer{limit: 2 << 20}
cmd.Stdout = &output
if err = cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
fail(w, 504, "codex_timeout")
} else {
fail(w, 502, "generation_failed")
}
return
}
if output.exceeded {
fail(w, 502, "generation_failed")
return
}
text, completed := canvasAgentText(output.buffer.Bytes())
if !completed {
fail(w, 502, "generation_failed")
return
}
result := CanvasOutput{Kind: "text", Text: text}
if imageOutput {
image, err := readCanvasGeneratedImage(filepath.Join(home, "generated_images"))
if err != nil {
fail(w, 502, "generation_failed")
return
}
result = CanvasOutput{Kind: "image", Image: image}
} else if len(text) > maxOutput {
fail(w, 502, "generation_failed")
return
}
writeJSON(w, 200, map[string]any{"output": result})
}
func canvasAgentText(raw []byte) (string, bool) {
result := ""
completed := false
for _, line := range bytes.Split(bytes.TrimSpace(raw), []byte("\n")) {
var event struct {
Type string `json:"type"`
Item struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"item"`
}
if json.Unmarshal(line, &event) != nil {
return "", false
}
if event.Type == "turn.failed" || event.Type == "error" {
return "", false
}
if event.Type == "item.completed" && event.Item.Type == "agent_message" {
result = event.Item.Text
}
if event.Type == "turn.completed" {
completed = true
}
}
return result, completed
}
func readCanvasGeneratedImage(root string) (*CanvasImage, error) {
var result *CanvasImage
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
return errors.New("invalid_artifact")
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("invalid_artifact")
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".png" && ext != ".jpeg" && ext != ".jpg" && ext != ".webp" && ext != ".gif" {
return nil
}
if result != nil || info.Size() <= 0 || info.Size() > CanvasImageBytesLimit {
return errors.New("invalid_artifact")
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
mime := http.DetectContentType(data)
switch mime {
case "image/png", "image/jpeg", "image/webp", "image/gif":
default:
return errors.New("invalid_artifact")
}
result = &CanvasImage{MIME: mime, Data: data}
return nil
})
if err != nil {
return nil, err
}
if result == nil {
return nil, errors.New("image_missing")
}
return result, nil
}