mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
* 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.
773 lines
22 KiB
Go
773 lines
22 KiB
Go
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: "跳过所有审批和沙箱(危险)"},
|
||
}
|
||
}
|