Files
Han 355793eaee refactor(agent): centralize cmd/env option parsing into core with unified cmd field (#1297)
* refactor: centralize cmd/env option parsing into core

- Add core.ParseCmdOpts() to unify cmd/cli_path/command option
  parsing across all agents, with deprecation warnings for old keys
- Add core.ParseConfigEnv() to parse [projects.agent.options.env]
  from config into []string KEY=VALUE format
- Rename cliBin/command struct fields to cmd consistently
- Add cliExtraArgs support so cmd="binary arg1 arg2" works
- Add configEnv field for static env that persists across SetSessionEnv
- Fix env merge order: configEnv < providerEnv < sessionEnv (was
  inconsistent in copilot and opencode agents)
- Update all tests for renamed fields

* fix(claudecode): add backward compat for deprecated cli_args_flag

Users with cli_args_flag in their config.toml will see a deprecation
warning directing them to the new cmd_args_flag key.

* docs(config): update config examples to use cmd instead of deprecated keys

- config.example.toml: cli_path → cmd, command(S) → S(6 occurrences)
- claudecode/claudecode.go: comment cli_path → cmd
- copilot/copilot_test.go: comment cliBin/cli_path → cmd

* test(core): add direct unit tests for ParseCmdOpts and ParseConfigEnv

Address review feedback on PR #1297 (P2). The two helper functions in
core/cmdopts.go are depended on by 13 agent packages; previously their
behavior was only covered indirectly via agent-level New() tests. This
commit adds direct unit tests covering:

- ParseCmdOpts: four-tier priority (cmd / cli_path / command / default),
  empty/whitespace boundaries, tokenization of extra args, no-warning
  for canonical cmd field, and capture of deprecation warnings.
- ParseConfigEnv: nil / missing key, map[string]string, map[string]any
  (TOML parser output), non-string value filtering, unsupported types,
  and input non-mutation.

Also adds structured slog attrs (deprecated_key, new_key) to all three
deprecation warnings (cli_path, command, cli_args_flag) so that future
log aggregation can scan deprecated-key usage without code changes
(review feedback P3).

Ref: PR #1297 review.

* fix(codex): use local cmd var (not stale cliBin name) in struct init

Post-rebase fixup: the struct field was renamed cliBin -> cmd in
PR #1297, and the local variable returned by core.ParseCmdOpts is
also named cmd. The rebased struct initializer still referenced
the old name 'cliBin', which no longer exists in scope. Assign
the local cmd variable to the cmd struct field.
2026-06-23 07:05:16 +08:00

773 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package codex
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/BurntSushi/toml"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("codex", New)
}
// Agent drives OpenAI Codex CLI using `codex exec --json`.
//
// `codex exec` has no approval IPC, so approvals are not interactive on the
// exec backend. To get interactive approvals, switch to backend="app_server".
//
// Modes on the exec backend (maps to codex exec flags):
// - "suggest": --sandbox read-only + approval_policy=never (no prompts)
// - "auto-edit": --sandbox workspace-write + approval_policy=never (alias of full-auto)
// - "full-auto": --sandbox workspace-write + approval_policy=never
// - "yolo": --dangerously-bypass-approvals-and-sandbox
type Agent struct {
workDir string
model string
reasoningEffort string
mode string // "suggest" | "auto-edit" | "full-auto" | "yolo"
backend string // "exec" | "app_server"
appServerURL string
codexHome string
systemPrompt string
appendPrompt string
cmd string // CLI binary name, default "codex"
cliExtraArgs []string // extra args parsed from cmd after the binary
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
if workDir == "" {
workDir = "."
}
model, _ := opts["model"].(string)
reasoningEffort, _ := opts["reasoning_effort"].(string)
mode, _ := opts["mode"].(string)
backend, _ := opts["backend"].(string)
appServerURL, _ := opts["app_server_url"].(string)
codexHome, _ := opts["codex_home"].(string)
systemPrompt, _ := opts["system_prompt"].(string)
appendPrompt, _ := opts["append_system_prompt"].(string)
mode = normalizeMode(mode)
backend = normalizeBackend(backend)
appServerURL = normalizeAppServerURL(appServerURL)
cmd, cliExtraArgs := core.ParseCmdOpts(opts, "codex")
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("codex: %q CLI not found in PATH, install with: npm install -g @openai/codex", cmd)
}
// Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml).
// Stored separately from runtime sessionEnv so SetSessionEnv calls cannot overwrite it.
// MergeEnv semantics ensure these override any same-named keys inherited from os.Environ()
// when the codex subprocess is spawned (e.g. user-scoped HTTPS_PROXY leaking into the agent).
var configEnv []string
if envMap, ok := opts["env"].(map[string]string); ok {
for k, v := range envMap {
configEnv = append(configEnv, k+"="+v)
}
} else if envMap, ok := opts["env"].(map[string]any); ok {
for k, v := range envMap {
if s, ok := v.(string); ok {
configEnv = append(configEnv, k+"="+s)
}
}
}
return &Agent{
workDir: workDir,
model: model,
reasoningEffort: normalizeReasoningEffort(reasoningEffort),
mode: mode,
backend: backend,
appServerURL: appServerURL,
codexHome: strings.TrimSpace(codexHome),
systemPrompt: strings.TrimSpace(systemPrompt),
appendPrompt: strings.TrimSpace(appendPrompt),
cmd: cmd,
cliExtraArgs: cliExtraArgs,
configEnv: configEnv,
activeIdx: -1,
}, nil
}
func normalizeBackend(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "app-server", "app_server", "appserver", "ws":
return "app_server"
default:
return "exec"
}
}
func normalizeAppServerURL(raw string) string {
url := strings.TrimSpace(raw)
if url == "" {
return "ws://127.0.0.1:3845"
}
if strings.EqualFold(url, "stdio") {
return "stdio://"
}
return url
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "auto-edit", "autoedit", "auto_edit", "edit":
return "auto-edit"
case "full-auto", "fullauto", "full_auto", "auto":
return "full-auto"
case "yolo", "bypass", "dangerously-bypass":
return "yolo"
default:
return "suggest"
}
}
func normalizeReasoningEffort(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return ""
case "low":
return "low"
case "medium", "med":
return "medium"
case "high":
return "high"
case "xhigh", "x-high", "very-high":
return "xhigh"
default:
return ""
}
}
func (a *Agent) Name() string { return "codex" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("codex: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.workDir
}
func (a *Agent) SetModel(model string) {
a.mu.Lock()
defer a.mu.Unlock()
a.model = model
slog.Info("codex: model changed", "model", model)
}
func (a *Agent) GetModel() string {
a.mu.Lock()
defer a.mu.Unlock()
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
}
func (a *Agent) SetReasoningEffort(effort string) {
a.mu.Lock()
defer a.mu.Unlock()
a.reasoningEffort = normalizeReasoningEffort(effort)
slog.Info("codex: reasoning effort changed", "reasoning_effort", a.reasoningEffort)
}
func (a *Agent) GetReasoningEffort() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.reasoningEffort
}
func (a *Agent) AvailableReasoningEfforts() []string {
return []string{"low", "medium", "high", "xhigh"}
}
func (a *Agent) configuredModels() []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
return core.GetProviderModels(a.providers, a.activeIdx)
}
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
if models := readCodexModelCatalog(); len(models) > 0 {
return models
}
if models := a.configuredModels(); len(models) > 0 {
return models
}
if models := a.fetchModelsFromAPI(ctx); len(models) > 0 {
return models
}
if models := readCodexCachedModels(); len(models) > 0 {
return models
}
return []core.ModelOption{
{Name: "o4-mini", Desc: "O4 Mini (fast reasoning)"},
{Name: "o3", Desc: "O3 (most capable reasoning)"},
{Name: "gpt-4.1", Desc: "GPT-4.1 (balanced)"},
{Name: "gpt-4.1-mini", Desc: "GPT-4.1 Mini (fast)"},
{Name: "gpt-4.1-nano", Desc: "GPT-4.1 Nano (fastest)"},
{Name: "codex-mini-latest", Desc: "Codex Mini (code-optimized)"},
}
}
var openaiChatModels = map[string]bool{
"o4-mini": true, "o3": true, "o3-mini": true, "o1": true, "o1-mini": true,
"gpt-4.1": true, "gpt-4.1-mini": true, "gpt-4.1-nano": true,
"gpt-4o": true, "gpt-4o-mini": true,
"codex-mini-latest": true,
}
func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
a.mu.Lock()
apiKey := ""
baseURL := ""
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
apiKey = a.providers[a.activeIdx].APIKey
baseURL = a.providers[a.activeIdx].BaseURL
}
a.mu.Unlock()
if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY")
}
if apiKey == "" {
return nil
}
if baseURL == "" {
baseURL = os.Getenv("OPENAI_BASE_URL")
}
if baseURL == "" {
baseURL = "https://api.openai.com"
}
baseURL = strings.TrimRight(baseURL, "/")
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil)
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Debug("codex: failed to fetch models", "error", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil
}
var models []core.ModelOption
for _, m := range result.Data {
if openaiChatModels[m.ID] {
models = append(models, core.ModelOption{Name: m.ID})
}
}
sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name })
return models
}
func readCodexCachedModels() []core.ModelOption {
codexHome, _ := resolveCodexHome(nil)
if codexHome == "" {
return nil
}
path := filepath.Join(codexHome, "models_cache.json")
b, err := os.ReadFile(path)
if err != nil {
return nil
}
return parseCodexModelsJSON(b)
}
// parseCodexModelsJSON parses a Codex models JSON file (model_catalog.json
// or models_cache.json) into a deduplicated, filtered slice of ModelOption.
// It is shared by readCodexCachedModels and readCodexModelCatalog.
func parseCodexModelsJSON(data []byte) []core.ModelOption {
var payload struct {
Models []struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
SupportedInAPI bool `json:"supported_in_api"`
} `json:"models"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
var models []core.ModelOption
seen := make(map[string]struct{}, len(payload.Models))
for _, m := range payload.Models {
name := strings.TrimSpace(m.Slug)
if name == "" {
name = strings.TrimSpace(m.DisplayName)
}
if name == "" {
continue
}
if m.Visibility != "" && m.Visibility != "list" {
continue
}
if !m.SupportedInAPI {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
models = append(models, core.ModelOption{
Name: name,
Desc: strings.TrimSpace(m.Description),
})
}
return models
}
// readCodexModelCatalog reads $CODEX_HOME/config.toml to find the
// model_catalog_json setting, then reads and parses that JSON file.
// This is the authoritative source of model metadata for Codex CLI,
// maintained by the Codex distribution itself.
func readCodexModelCatalog() []core.ModelOption {
codexHome, _ := resolveCodexHome(nil)
if codexHome == "" {
return nil
}
// Parse CODEX_HOME/config.toml to find model_catalog_json path
cfgPath := filepath.Join(codexHome, "config.toml")
var cfg struct {
ModelCatalogJSON string `toml:"model_catalog_json"`
}
if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil {
slog.Debug("codex: failed to read config.toml for model_catalog_json", "error", err)
return nil
}
if cfg.ModelCatalogJSON == "" {
return nil
}
// Expand ~ and resolve relative paths against CODEX_HOME
catalogPath := cfg.ModelCatalogJSON
switch {
case strings.HasPrefix(catalogPath, "~/"):
home, err := os.UserHomeDir()
if err != nil {
slog.Debug("codex: cannot resolve home dir for model_catalog_json", "error", err)
return nil
}
catalogPath = filepath.Join(home, catalogPath[2:])
case catalogPath == "~":
home, err := os.UserHomeDir()
if err != nil {
return nil
}
catalogPath = home
case !filepath.IsAbs(catalogPath):
catalogPath = filepath.Join(codexHome, catalogPath)
}
b, err := os.ReadFile(catalogPath)
if err != nil {
slog.Debug("codex: failed to read model_catalog_json", "path", catalogPath, "error", err)
return nil
}
models := parseCodexModelsJSON(b)
if models == nil {
slog.Debug("codex: failed to parse model_catalog_json", "path", catalogPath)
}
return models
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
defer a.mu.Unlock()
a.sessionEnv = env
}
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.Lock()
mode := a.mode
model := a.model
reasoningEffort := a.reasoningEffort
backend := a.backend
appServerURL := a.appServerURL
codexHome := a.codexHome
systemPrompt := a.systemPrompt
appendPrompt := a.appendPrompt
cliBin := a.cmd
cliExtraArgs := a.cliExtraArgs
workDir := a.workDir
// Order matters for MergeEnv override semantics (later wins):
// 1. configEnv — static env from [projects.agent.options.env]
// 2. providerEnv — per-provider keys (OPENAI_API_KEY etc.)
// 3. sessionEnv — runtime overrides from /env or admin actions
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
var baseURL string
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
model = m
}
baseURL = a.providers[a.activeIdx].BaseURL
}
provName, provAPIKey, provWireAPI, provHeaders := a.activeProviderCodexConfig()
a.mu.Unlock()
if provName != "" {
if err := ensureCodexProviderConfig(codexHome, provName, baseURL, provWireAPI, provHeaders); err != nil {
slog.Warn("codex: failed to write provider config", "provider", provName, "error", err)
}
if err := ensureCodexAuth(codexHome, provAPIKey); err != nil {
slog.Warn("codex: failed to write auth.json", "provider", provName, "error", err)
}
}
if backend == "app_server" {
return newAppServerSession(ctx, appServerURL, workDir, model, reasoningEffort, mode, sessionID, baseURL, provName, extraEnv, codexHome, systemPrompt, appendPrompt)
}
if codexHome != "" {
extraEnv = append(extraEnv, "CODEX_HOME="+codexHome)
}
return newCodexSession(ctx, cliBin, cliExtraArgs, workDir, model, reasoningEffort, mode, sessionID, baseURL, extraEnv, provName, systemPrompt, appendPrompt)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
a.mu.RLock()
codexHome := a.codexHome
workDir := a.workDir
a.mu.RUnlock()
return listCodexSessions(workDir, codexHome)
}
func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) {
a.mu.RLock()
codexHome := a.codexHome
a.mu.RUnlock()
return getSessionHistory(sessionID, codexHome, limit)
}
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
a.mu.RLock()
codexHome := a.codexHome
a.mu.RUnlock()
path := findSessionFile(sessionID, codexHome)
if path == "" {
return fmt.Errorf("session file not found: %s", sessionID)
}
return os.Remove(path)
}
func (a *Agent) Stop() error { return nil }
// SetMode changes the approval mode for future sessions.
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("codex: approval mode changed", "mode", a.mode)
}
func (a *Agent) GetMode() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.mode
}
func (a *Agent) WorkspaceAgentOptions() map[string]any {
a.mu.RLock()
defer a.mu.RUnlock()
opts := map[string]any{
"mode": a.mode,
"backend": a.backend,
}
if a.model != "" {
opts["model"] = a.model
}
if a.reasoningEffort != "" {
opts["reasoning_effort"] = a.reasoningEffort
}
if a.appServerURL != "" {
opts["app_server_url"] = a.appServerURL
}
if a.codexHome != "" {
opts["codex_home"] = a.codexHome
}
return opts
}
// ── SkillProvider implementation ──────────────────────────────
func (a *Agent) SkillDirs() []string {
a.mu.RLock()
workDir := a.workDir
codexHome := a.codexHome
a.mu.RUnlock()
absDir, err := filepath.Abs(workDir)
if err != nil {
absDir = workDir
}
return codexSkillDirs(absDir, codexHome)
}
// ── ContextCompressor implementation ──────────────────────────
// CompressCommand returns "" because Codex native slash commands (/compact, /clear)
// are not reliably executed in exec/resume mode — they may be treated as plain text.
// See: https://github.com/chenhg5/cc-connect/issues/378
func (a *Agent) CompressCommand() string { return "" }
func codexSkillDirs(workDir, explicitCodexHome string) []string {
homeDir, _ := os.UserHomeDir()
codexHome := strings.TrimSpace(explicitCodexHome)
if codexHome == "" {
codexHome = strings.TrimSpace(os.Getenv("CODEX_HOME"))
}
if codexHome == "" && homeDir != "" {
codexHome = filepath.Join(homeDir, ".codex")
}
projectDirs := walkUpCodexProjectSkillDirs(workDir, homeDir)
userDirs := make([]string, 0, 2)
if codexHome != "" {
userDirs = append(userDirs, filepath.Join(codexHome, "skills"))
}
if homeDir != "" {
userDirs = append(userDirs, filepath.Join(homeDir, ".agents", "skills"))
}
return uniqueCodexSkillDirs(append(projectDirs, userDirs...))
}
func walkUpCodexProjectSkillDirs(workDir, homeDir string) []string {
current := filepath.Clean(workDir)
homeDir = filepath.Clean(homeDir)
stopAt := findCodexProjectRoot(current)
var dirs []string
for {
if homeDir != "" && sameCodexPath(current, homeDir) {
break
}
dirs = append(dirs,
filepath.Join(current, ".agents", "skills"),
filepath.Join(current, ".codex", "skills"),
)
if stopAt != "" && sameCodexPath(current, stopAt) {
break
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return uniqueCodexSkillDirs(dirs)
}
func findCodexProjectRoot(start string) string {
current := filepath.Clean(start)
for {
for _, marker := range []string{".git", ".jj"} {
if _, err := os.Stat(filepath.Join(current, marker)); err == nil {
return current
}
}
parent := filepath.Dir(current)
if parent == current {
return ""
}
current = parent
}
}
func sameCodexPath(a, b string) bool {
if a == "" || b == "" {
return false
}
return filepath.Clean(a) == filepath.Clean(b)
}
func uniqueCodexSkillDirs(paths []string) []string {
seen := make(map[string]struct{}, len(paths))
out := make([]string, 0, len(paths))
for _, path := range paths {
if path == "" {
continue
}
clean := filepath.Clean(path)
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
out = append(out, clean)
}
return out
}
// ── MemoryFileProvider implementation ─────────────────────────
func (a *Agent) ProjectMemoryFile() string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
return filepath.Join(absDir, "AGENTS.md")
}
func (a *Agent) GlobalMemoryFile() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
codexHome = filepath.Join(homeDir, ".codex")
}
return filepath.Join(codexHome, "AGENTS.md")
}
// ── ProviderSwitcher implementation ──────────────────────────
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
a.mu.Lock()
defer a.mu.Unlock()
a.providers = providers
}
func (a *Agent) SetActiveProvider(name string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if name == "" {
a.activeIdx = -1
slog.Info("codex: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("codex: provider switched", "provider", name)
return true
}
}
return false
}
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
return &p
}
func (a *Agent) ListProviders() []core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]core.ProviderConfig, len(a.providers))
copy(result, a.providers)
return result
}
func (a *Agent) providerEnvLocked() []string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
var env []string
if p.APIKey != "" {
env = append(env, "OPENAI_API_KEY="+p.APIKey)
}
if p.BaseURL != "" {
env = append(env, "OPENAI_BASE_URL="+p.BaseURL)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
// activeProviderCodexConfig returns Codex-specific config for the active provider.
// Returns non-empty name when the provider has codex config (wire_api, headers)
// OR when it has a BaseURL (third-party provider needing auth.json).
func (a *Agent) activeProviderCodexConfig() (name string, apiKey string, wireAPI string, headers map[string]string) {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return
}
p := a.providers[a.activeIdx]
hasCodexConfig := p.CodexWireAPI != "" || len(p.CodexHTTPHeaders) > 0
isThirdParty := p.BaseURL != "" && p.APIKey != ""
if !hasCodexConfig && !isThirdParty {
return
}
return p.Name, p.APIKey, p.CodexWireAPI, p.CodexHTTPHeaders
}
// PermissionModes returns the supported codex permission modes.
//
// Behavior depends on backend:
// - exec backend (default): codex exec has no approval IPC, so all modes run
// with approval_policy=never. Sandbox tier is what controls access. The
// "suggest" label refers to the *intent* (read-only safety) — the CLI does
// not pop interactive approval prompts on this backend.
// - app_server backend: "suggest" enables real interactive approval requests
// (execCommandApproval / applyPatchApproval / permissionsApproval).
//
// Note: auto-edit and full-auto produce the same flags on the exec backend
// (codex CLI has no separate "ask for shell only" mode); auto-edit is kept as
// an alias for backward compatibility with existing user configs.
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
return []core.PermissionModeInfo{
{Key: "suggest", Name: "Suggest", NameZh: "建议",
Desc: "Read-only sandbox; on exec backend no prompts, on app_server backend asks for every tool call",
DescZh: "只读沙箱exec 后端不弹审批app_server 后端每次工具调用都会询问"},
{Key: "auto-edit", Name: "Auto Edit", NameZh: "自动编辑",
Desc: "Workspace-write sandbox, no approval prompts (alias of Full Auto)",
DescZh: "工作区可写沙箱,不弹审批(等同于全自动)"},
{Key: "full-auto", Name: "Full Auto", NameZh: "全自动",
Desc: "Workspace-write sandbox, no approval prompts",
DescZh: "工作区可写沙箱,不弹审批"},
{Key: "yolo", Name: "YOLO", NameZh: "YOLO 模式",
Desc: "Bypass all approvals and sandbox (DANGEROUS)",
DescZh: "跳过所有审批和沙箱(危险)"},
}
}