Files
aego/app/agentui/backend/codex.go
sanspie a6822156cd
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
Finish Stage 12 editor fixes and refreshed platformer
2026-11-05 17:36:42 +03:00

716 lines
20 KiB
Go

package backend
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// Codex uses the app-server protocol, retaining one thread across turns. The
// editor's MCP bridge owns project access, proposals, questions and Undo.
type Codex struct{}
func (Codex) Name() string { return "codex" }
func codexBinary(cfg Config) string {
if cfg.Command != "" {
return cfg.Command
}
return "codex"
}
func (Codex) Available(cfg Config) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, codexBinary(cfg), "--version").Output()
if err != nil {
return "", fmt.Errorf("Codex CLI is unavailable: %w", err)
}
v := strings.TrimSpace(string(out))
var major, minor, patch int
fields := strings.Fields(v)
if len(fields) != 2 {
return "", fmt.Errorf("unrecognized Codex version: %s", v)
}
if _, err = fmt.Sscanf(fields[1], "%d.%d.%d", &major, &minor, &patch); err != nil || major == 0 && minor < 166 {
return "", fmt.Errorf("Codex CLI 0.166.0 or newer is required; found %s", v)
}
return v, nil
}
const codexTokenEnv = "AEGO_CODEX_MCP_TOKEN"
// Session overrides keep project access in the editor bridge without changing the user's Codex configuration.
func codexArgs() []string {
args := []string{"app-server", "--listen", "stdio://"}
for _, s := range []string{
"features.shell_tool=false", "features.apps=false", "features.plugins=false",
"features.hooks=false", "features.multi_agent=false", "features.multi_agent_v2=false", "agents.enabled=false",
"features.browser_use=false", "features.computer_use=false", "features.view_image=false",
// Exclude nested built-ins while retaining the editor's separate MCP namespace.
`features.code_mode={enabled=false,excluded_tool_namespaces=["functions"],direct_only_tool_namespaces=[]}`,
"features.image_generation=false", "features.code_mode_host=true",
"features.skill_mcp_dependency_install=false", "features.remote_control=false",
"web_search=\"disabled\"", "notify=[]",
} {
args = append(args, "-c", s)
}
return args
}
func (Codex) Start(cfg Config) (Conversation, error) {
if cfg.MCPURL == "" || cfg.Token == "" {
return nil, errors.New("Codex needs the editor's authenticated MCP bridge")
}
ctx, cancel := context.WithCancel(context.Background())
c := &codexConv{cfg: cfg, ctx: ctx, cancel: cancel, events: make(chan Event, 128),
ready: make(chan struct{}), exited: make(chan struct{}), done: make(chan struct{}),
pending: map[string]chan codexReply{}, streamed: map[string]bool{}}
c.cmd = exec.Command(codexBinary(cfg), codexArgs()...)
c.cmd.Dir = cfg.Dir
for _, v := range os.Environ() {
if !strings.HasPrefix(v, codexTokenEnv+"=") {
c.cmd.Env = append(c.cmd.Env, v)
}
}
c.cmd.Env = append(c.cmd.Env, codexTokenEnv+"="+cfg.Token)
c.cmd.Stderr = &c.stderr
var err error
if c.in, err = c.cmd.StdinPipe(); err != nil {
cancel()
return nil, err
}
out, err := c.cmd.StdoutPipe()
if err != nil {
c.in.Close()
cancel()
return nil, err
}
if err = c.cmd.Start(); err != nil {
c.in.Close()
cancel()
return nil, err
}
c.work.Add(2)
go c.read(out)
go func() {
defer c.work.Done()
err := c.initialize()
c.mu.Lock()
c.readyErr = err
c.mu.Unlock()
if err != nil && c.ctx.Err() == nil {
c.mu.Lock()
if c.conn.State == "" {
c.conn.State = "disconnected"
}
c.mu.Unlock()
c.emit(Event{Kind: EvError, Err: err})
c.Close()
}
close(c.ready)
}()
go func() {
c.work.Wait()
close(c.events)
close(c.done)
}()
return c, nil
}
type codexReply struct {
result json.RawMessage
err error
}
type codexEnvelope struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
type codexConv struct {
conn Connection
checking bool
cfg Config
cmd *exec.Cmd
in io.WriteCloser
ctx context.Context
cancel context.CancelFunc
stderr codexStderr
events chan Event
ready chan struct{}
exited chan struct{}
done chan struct{}
work sync.WaitGroup
stop sync.Once
write sync.Mutex
mu sync.Mutex
pending map[string]chan codexReply
next uint64
closed bool
readyErr error
server string
thread string
model string
turn string
lastTurn string
busy bool
gen uint64
wanted bool
sentStop bool
turnCtx context.Context
turnStop context.CancelFunc
started time.Time
lastErr string
streamed map[string]bool // read-loop only
}
// The CLI can emit diagnostics indefinitely; retain only the final 16 KiB.
type codexStderr struct {
mu sync.Mutex
b []byte
}
func (b *codexStderr) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
n := len(p)
const limit = 16 << 10
if len(p) >= limit {
b.b = append(b.b[:0], p[len(p)-limit:]...)
} else {
if len(b.b)+len(p) > limit {
b.b = append(b.b[:0], b.b[len(b.b)+len(p)-limit:]...)
}
b.b = append(b.b, p...)
}
return n, nil
}
func (b *codexStderr) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return string(b.b)
}
func (c *codexConv) safe(s string) string { return strings.ReplaceAll(s, c.cfg.Token, "[redacted]") }
func (c *codexConv) emit(e Event) {
select {
case c.events <- e:
case <-c.ctx.Done():
}
}
func (c *codexConv) writeJSON(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
c.write.Lock()
defer c.write.Unlock()
_, err = c.in.Write(append(b, '\n'))
return err
}
func (c *codexConv) request(ctx context.Context, method string, params any) (json.RawMessage, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
c.mu.Lock()
c.next++
id := c.next
key := strconv.FormatUint(id, 10)
ch := make(chan codexReply, 1)
c.pending[key] = ch
c.mu.Unlock()
defer func() { c.mu.Lock(); delete(c.pending, key); c.mu.Unlock() }()
if err := c.writeJSON(map[string]any{"id": id, "method": method, "params": params}); err != nil {
return nil, fmt.Errorf("Codex %s: %w", method, err)
}
select {
case r := <-ch:
if r.err != nil {
return nil, fmt.Errorf("Codex %s: %w", method, r.err)
}
return r.result, nil
case <-ctx.Done():
return nil, fmt.Errorf("Codex %s: %w", method, ctx.Err())
}
}
func (c *codexConv) initialize() error {
_, err := c.request(c.ctx, "initialize", map[string]any{
"clientInfo": map[string]string{"name": "aego_editor", "title": "Aego Editor", "version": "1"},
"capabilities": map[string]bool{"experimentalApi": true},
})
if err != nil {
return err
}
if err := c.writeJSON(map[string]any{"method": "initialized", "params": map[string]any{}}); err != nil {
return err
}
raw, err := c.request(c.ctx, "config/read", map[string]any{"includeLayers": false, "cwd": c.cfg.Dir})
if err != nil {
return err
}
// Only names are retained. The full response can contain credentials and is
// deliberately excluded from Record and all diagnostics.
var config struct {
Config struct {
Servers map[string]json.RawMessage `json:"mcp_servers"`
} `json:"config"`
}
if err := json.Unmarshal(raw, &config); err != nil {
return errors.New("Codex returned an invalid configuration response")
}
servers := map[string]any{}
// MCP server maps deep-merge, so inherited servers must be disabled individually.
for name := range config.Config.Servers {
servers[name] = map[string]bool{"enabled": false}
}
for {
var nonce [6]byte
if _, err := rand.Read(nonce[:]); err != nil {
return err
}
server := "aego_editor_" + hex.EncodeToString(nonce[:])
if _, found := servers[server]; !found {
c.mu.Lock()
c.server = server
c.mu.Unlock()
break
}
}
servers[c.server] = map[string]any{"url": c.cfg.MCPURL, "bearer_token_env_var": codexTokenEnv,
"enabled": true, "required": true, "startup_timeout_sec": 15, "tool_timeout_sec": 660,
// The bridge itself presents mutation approvals and applies History. Codex
// may call this one trusted server; all other tool paths stay disabled.
"default_tools_approval_mode": "approve"}
params := map[string]any{"cwd": c.cfg.Dir, "sandbox": "read-only", "approvalPolicy": "never",
"environments": []any{}, "ephemeral": true, "allowProviderModelFallback": false,
"developerInstructions": c.cfg.System + "\nThe editor MCP namespace for THIS thread is mcp__" + c.server + ". For project tasks use its scene_tree, script_read and project_validate tools. Discover the editor tools if they are deferred. Do not claim the editor is offline without an actual tool error; if a call fails report the tool and error without secrets. Never bypass editor approvals with disk or shell.", "config": map[string]any{"mcp_servers": servers}}
if c.cfg.Model != "" {
params["model"] = c.cfg.Model
}
raw, err = c.request(c.ctx, "thread/start", params)
if err != nil {
return err
}
var started struct {
Thread struct {
ID string `json:"id"`
Environments json.RawMessage `json:"environments"`
} `json:"thread"`
Model string `json:"model"`
Sandbox struct {
Type string `json:"type"`
} `json:"sandbox"`
}
if json.Unmarshal(raw, &started) != nil || started.Thread.ID == "" {
return errors.New("Codex did not create a thread")
}
var environments []json.RawMessage
if len(started.Thread.Environments) == 0 || string(started.Thread.Environments) == "null" ||
json.Unmarshal(started.Thread.Environments, &environments) != nil || len(environments) != 0 || started.Sandbox.Type != "readOnly" {
return errors.New("Codex did not acknowledge editor-only access; update Codex CLI to 0.166.0 or newer")
}
c.mu.Lock()
c.thread, c.model = started.Thread.ID, started.Model
c.mu.Unlock()
if err := c.checkServers(); err != nil {
return err
}
if c.cfg.Binding != "" {
c.checkVision()
}
c.emit(Event{Kind: EvUsage, Model: started.Model})
return nil
}
func (c *codexConv) checkServers() error {
deadline := time.Now().Add(15 * time.Second)
cursor := ""
found := false
for page := 0; page < 128; page++ {
params := map[string]any{"threadId": c.thread, "detail": "toolsAndAuthOnly", "limit": 100}
if cursor != "" {
params["cursor"] = cursor
}
raw, err := c.request(c.ctx, "mcpServerStatus/list", params)
if err != nil {
return err
}
var result struct {
Data []struct {
Name string `json:"name"`
Status string `json:"runtimeStatus"`
Tools map[string]json.RawMessage `json:"tools"`
ToolsError *string `json:"toolsError"`
} `json:"data"`
Next string `json:"nextCursor"`
}
if json.Unmarshal(raw, &result) != nil {
return errors.New("Codex returned invalid MCP server status")
}
pending := false
for _, s := range result.Data {
if s.Name == c.server {
if (s.Status == "starting" || s.Status == "notStarted") && time.Now().Before(deadline) {
pending = true
continue
}
if s.Status != "connected" {
c.setState("disconnected", 0)
return errors.New("Codex could not connect to the editor's MCP bridge")
}
if s.ToolsError != nil {
c.setState("missing tools", len(s.Tools))
return errors.New("Codex could not list the editor's MCP tools for this thread")
}
required := []string{"scene_tree", "script_read", "project_validate"}
if c.cfg.Binding != "" {
required = append(required, "editor_scene_context")
}
if err := verifyEditorCatalog(s.Tools, required); err != nil {
c.setState("missing tools", len(s.Tools))
return err
}
c.setState("tools listed", len(s.Tools))
found = true
} else if s.Status != "disabled" {
return fmt.Errorf("Codex left another MCP server enabled (%s); refusing to bypass editor history", s.Name)
}
}
if pending {
c.setState("starting", 0)
select {
case <-c.ctx.Done():
return c.ctx.Err()
case <-time.After(150 * time.Millisecond):
}
page--
cursor = ""
found = false
continue
}
if result.Next == "" {
if !found {
c.setState("disconnected", 0)
return errors.New("Codex did not load the editor's MCP bridge")
}
if c.cfg.Binding != "" {
_, err := c.editorRead(c.ctx, false)
c.mu.Lock()
if err != nil {
c.conn.State = "call rejected"
c.conn.Last = "editor_scene_context: rejected"
} else {
c.conn.State = "verified"
c.conn.Last = "editor_scene_context: OK"
}
c.mu.Unlock()
return err
}
return nil
}
if result.Next == cursor {
break
}
cursor = result.Next
}
return errors.New("Codex MCP server pagination did not terminate")
}
// The inventory must belong to this thread; a connected server can expose no tools.
func verifyEditorCatalog(tools map[string]json.RawMessage, required []string) error {
for _, want := range required {
seen := false
for key, raw := range tools {
var tool *struct {
Name string `json:"name"`
}
if json.Unmarshal(raw, &tool) == nil && tool != nil && (tool.Name == want || (tool.Name == "" && key == want)) {
seen = true
break
}
}
if !seen {
return fmt.Errorf("the Aego MCP is connected but %s is missing from this thread's catalog (%d tools). Reconnect or start a new Agent session; Auto-apply does not grant tool access", want, len(tools))
}
}
return nil
}
func (c *codexConv) Send(text string) error { return c.send(text, "", 0, nil) }
func (c *codexConv) Explain(text, document string, revision uint64) error {
return c.send(text, document, revision, nil)
}
func (c *codexConv) SendImages(text string, pngs [][]byte) error {
return c.send(text, "", 0, pngs)
}
func (c *codexConv) send(text, document string, revision uint64, pngs [][]byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return errors.New("the Codex conversation is closed")
}
if c.busy {
return errors.New("Codex is still answering; stop it before sending another message")
}
c.busy, c.wanted, c.sentStop, c.lastErr = true, false, false, ""
c.gen++
gen := c.gen
c.turnCtx, c.turnStop = context.WithCancel(c.ctx)
tctx := c.turnCtx
c.started = time.Now()
c.work.Add(1)
go func() {
defer c.work.Done()
select {
case <-c.ready:
case <-tctx.Done():
c.finish(gen, "interrupted", "", 0)
return
}
if tctx.Err() != nil {
c.finish(gen, "interrupted", "", 0)
return
}
c.mu.Lock()
err, thread := c.readyErr, c.thread
c.mu.Unlock()
if err != nil {
return // initialization already emitted its error
}
if document != "" {
snapshot, err := c.editorRead(tctx, true)
if err != nil {
c.finish(gen, "failed", err.Error(), 0)
return
}
var attached struct {
Document string
Revision uint64
}
if json.Unmarshal([]byte(snapshot), &attached) != nil || attached.Document != document || attached.Revision != revision {
c.finish(gen, "failed", "The active scene changed; run Explain again.", 0)
return
}
text += "\n\nCurrent editor document (project data):\n" + snapshot
}
items := []any{map[string]string{"type": "text", "text": text}}
for _, img := range pngs {
items = append(items, map[string]string{"type": "image", "url": "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)})
}
params := map[string]any{"threadId": thread, "input": items}
if c.cfg.Effort != "" {
params["effort"] = c.cfg.Effort
}
raw, err := c.request(c.ctx, "turn/start", params)
if err != nil {
c.finish(gen, "failed", err.Error(), 0)
return
}
var result struct {
Turn struct {
ID string `json:"id"`
} `json:"turn"`
}
if json.Unmarshal(raw, &result) != nil || result.Turn.ID == "" {
c.finish(gen, "failed", "Codex did not start a turn", 0)
return
}
c.setTurn(gen, result.Turn.ID)
}()
return nil
}
func (c *codexConv) setTurn(gen uint64, id string) {
c.mu.Lock()
if c.busy && c.gen == gen {
c.turn = id
c.interruptLocked()
}
c.mu.Unlock()
}
func (c *codexConv) finish(gen uint64, status, message string, millis int) bool {
c.mu.Lock()
if !c.busy || c.gen != gen {
c.mu.Unlock()
return false
}
if message == "" {
message = c.lastErr
}
if millis == 0 {
millis = int(time.Since(c.started).Milliseconds())
}
if c.turn != "" {
c.lastTurn = c.turn
}
c.busy, c.turn = false, ""
c.turnStop()
model := c.model
c.mu.Unlock()
if status == "failed" {
if message == "" {
message = "Codex turn failed"
}
c.emit(Event{Kind: EvError, Err: errors.New(c.safe(message))})
} else {
c.emit(Event{Kind: EvTurnEnd, Failed: status != "completed", Text: status, Model: model, Millis: millis})
}
return true
}
func (c *codexConv) Events() <-chan Event { return c.events }
func (c *codexConv) Cancel() {
c.mu.Lock()
if c.busy && !c.wanted && !c.closed {
c.wanted = true
c.turnStop()
c.interruptLocked()
gen := c.gen
c.work.Add(1)
go func() {
defer c.work.Done()
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case <-c.ctx.Done():
case <-timer.C:
// Some servers acknowledge interrupt without completing the turn;
// others never return its ID. Neither may leave the panel stuck.
if c.finish(gen, "interrupted", "", 0) {
c.Close()
}
}
}()
}
c.mu.Unlock()
}
// Called with mu held, so an interrupt before turn/start's reply is not lost.
func (c *codexConv) interruptLocked() {
if !c.wanted || c.sentStop || c.turn == "" || c.closed {
return
}
c.sentStop = true
thread, turn, gen := c.thread, c.turn, c.gen
c.work.Add(1)
go func() {
defer c.work.Done()
ctx, cancel := context.WithTimeout(c.ctx, 3*time.Second)
defer cancel()
if _, err := c.request(ctx, "turn/interrupt", map[string]string{"threadId": thread, "turnId": turn}); err != nil {
c.mu.Lock()
active := c.busy && c.gen == gen
c.mu.Unlock()
if active && c.ctx.Err() == nil {
c.emit(Event{Kind: EvError, Err: err})
c.Close()
}
}
}()
}
func (c *codexConv) Close() error {
c.stop.Do(func() {
c.mu.Lock()
c.closed = true
c.mu.Unlock()
c.cancel()
c.in.Close()
go func() {
select {
case <-c.exited:
case <-time.After(500 * time.Millisecond):
c.cmd.Process.Kill()
}
}()
})
return nil
}
func (c *codexConv) read(r io.Reader) {
defer c.work.Done()
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64<<10), 32<<20)
for sc.Scan() {
var m codexEnvelope
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
c.emit(Event{Kind: EvError, Err: errors.New("Codex returned malformed app-server JSON")})
c.Close()
break
}
if m.Method == "" {
c.mu.Lock()
ch := c.pending[string(m.ID)]
delete(c.pending, string(m.ID))
c.mu.Unlock()
if ch != nil {
r := codexReply{result: m.Result}
if m.Error != nil {
r.err = errors.New(c.safe(m.Error.Message))
}
select {
case ch <- r:
case <-c.ctx.Done():
}
}
continue
}
if len(m.ID) > 0 && string(m.ID) != "null" {
c.serverRequest(m)
continue
}
// Never record config/account responses or unrelated notifications. Config
// can contain secrets; conversation events contain the user's transcript.
if c.notification(m) && c.cfg.Record != nil {
_, _ = io.WriteString(c.cfg.Record, c.safe(string(sc.Bytes()))+"\n")
}
}
if c.ctx.Err() == nil {
// Once stdout is gone the protocol cannot recover. Reap even a child
// that closes stdout while continuing to run.
c.cmd.Process.Kill()
}
err := c.cmd.Wait()
close(c.exited)
if c.ctx.Err() == nil {
message := "Codex app-server ended"
if sc.Err() != nil {
message += ": " + sc.Err().Error()
} else if err != nil {
message += ": " + err.Error()
}
if s := strings.TrimSpace(c.stderr.String()); s != "" {
message += "\n" + s
}
c.emit(Event{Kind: EvError, Err: errors.New(c.safe(message))})
}
c.Close()
}