launch: codex app integration (#16120)

This commit is contained in:
Parth Sareen
2026-05-13 17:11:52 -07:00
committed by GitHub
parent 6398cd5b78
commit ac7295ccab
20 changed files with 3307 additions and 80 deletions
+10 -9
View File
@@ -1201,15 +1201,16 @@ func (db *database) getSettings() (Settings, error) {
func (db *database) setSettings(s Settings) error {
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
validLaunchView := map[string]struct{}{
"launch": {},
"openclaw": {},
"claude": {},
"hermes": {},
"codex": {},
"copilot": {},
"opencode": {},
"droid": {},
"pi": {},
"launch": {},
"openclaw": {},
"claude": {},
"hermes": {},
"codex": {},
"codex-app": {},
"copilot": {},
"opencode": {},
"droid": {},
"pi": {},
}
if lastHomeView != "chat" {
if _, ok := validLaunchView[lastHomeView]; !ok {
+15
View File
@@ -122,6 +122,21 @@ func TestStore(t *testing.T) {
}
})
t.Run("settings codex app home view is accepted", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.LastHomeView != "codex-app" {
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
}
})
t.Run("window size", func(t *testing.T) {
if err := s.SetWindowSize(1024, 768); err != nil {
t.Fatal(err)
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+13 -5
View File
@@ -22,11 +22,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
iconClassName: "h-7 w-7",
},
{
id: "openclaw",
name: "OpenClaw",
command: "ollama launch openclaw",
description: "Personal AI with 100+ skills",
icon: "/launch-icons/openclaw.svg",
id: "codex-app",
name: "Codex App",
command: "ollama launch codex-app",
description: "OpenAI's desktop coding agent",
icon: "/launch-icons/codex-app.png",
iconClassName: "h-full w-full",
},
{
id: "hermes",
@@ -36,6 +37,13 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
icon: "/launch-icons/hermes-agent.svg",
iconClassName: "h-7 w-7",
},
{
id: "openclaw",
name: "OpenClaw",
command: "ollama launch openclaw",
description: "Personal AI with 100+ skills",
icon: "/launch-icons/openclaw.svg",
},
{
id: "opencode",
name: "OpenCode",
+1 -1
View File
@@ -2231,7 +2231,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
func launcherActionExitsLoop(integration string) bool {
switch integration {
case "vscode":
case "codex-app", "vscode":
return true
default:
return false
+1 -1
View File
@@ -249,7 +249,7 @@ func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
for _, integration := range []string{"vscode"} {
for _, integration := range []string{"codex-app", "vscode"} {
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{
resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
+458 -41
View File
@@ -7,7 +7,9 @@ import (
"path/filepath"
"strings"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/pelletier/go-toml/v2"
"golang.org/x/mod/semver"
)
@@ -16,7 +18,15 @@ type Codex struct{}
func (c *Codex) String() string { return "Codex" }
const codexProfileName = "ollama-launch"
const (
codexProfileName = "ollama-launch"
codexProviderName = "Ollama"
codexRootProfileKey = "profile"
codexRootModelKey = "model"
codexRootModelProviderKey = "model_provider"
codexRootModelCatalogJSONKey = "model_catalog_json"
)
func (c *Codex) args(model string, extra []string) []string {
args := []string{"--profile", codexProfileName}
@@ -49,76 +59,483 @@ func (c *Codex) Run(model string, args []string) error {
// ensureCodexConfig writes a [profiles.ollama-launch] section to ~/.codex/config.toml
// with openai_base_url pointing to the local Ollama server.
func ensureCodexConfig() error {
home, err := os.UserHomeDir()
configPath, err := codexConfigPath()
if err != nil {
return err
}
codexDir := filepath.Join(home, ".codex")
if err := os.MkdirAll(codexDir, 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
configPath := filepath.Join(codexDir, "config.toml")
return writeCodexProfile(configPath)
}
func codexConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".codex", "config.toml"), nil
}
// writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile
// and model provider sections with the correct base URL.
func writeCodexProfile(configPath string) error {
baseURL := envconfig.Host().String() + "/v1/"
return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
forceAPIAuth: true,
})
}
type codexLaunchProfileOptions struct {
activate bool
profileName string
forceAPIAuth bool
setRootModelConfig bool
model string
modelCatalogPath string
backupIntegration string
}
func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error {
baseURL := codexBaseURL()
profileName := codexLaunchProfileName(opts)
profileHeader := codexProfileHeaderFor(profileName)
providerHeader := codexProviderHeaderFor(profileName)
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
} else if !os.IsNotExist(readErr) {
return readErr
}
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
model := strings.TrimSpace(opts.model)
if model == "" {
model = parsed.ProfileString(profileName, codexRootModelKey)
}
modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath)
if modelCatalogPath == "" {
modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey)
}
profileLines := []string{}
if model != "" {
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
}
profileLines = append(profileLines,
fmt.Sprintf("openai_base_url = %q", baseURL),
fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName),
)
if opts.forceAPIAuth {
profileLines = append(profileLines, `forced_login_method = "api"`)
}
if modelCatalogPath != "" {
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
sections := []struct {
header string
lines []string
}{
{
header: fmt.Sprintf("[profiles.%s]", codexProfileName),
lines: []string{
fmt.Sprintf("openai_base_url = %q", baseURL),
`forced_login_method = "api"`,
fmt.Sprintf("model_provider = %q", codexProfileName),
},
header: profileHeader,
lines: profileLines,
},
{
header: fmt.Sprintf("[model_providers.%s]", codexProfileName),
header: providerHeader,
lines: []string{
`name = "Ollama"`,
fmt.Sprintf("name = %q", codexProviderName),
fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
},
},
}
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
if opts.activate {
text = codexSetRootStringValue(text, codexRootProfileKey, profileName)
}
for _, s := range sections {
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n"
if idx := strings.Index(text, s.header); idx >= 0 {
// Replace the existing section up to the next section header.
rest := text[idx+len(s.header):]
if endIdx := strings.Index(rest, "\n["); endIdx >= 0 {
text = text[:idx] + block + rest[endIdx+1:]
} else {
text = text[:idx] + block
}
} else {
// Append the section.
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
if text != "" {
text += "\n"
}
text += block
if opts.setRootModelConfig {
if model != "" {
text = codexSetRootStringValue(text, codexRootModelKey, model)
}
text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName)
if modelCatalogPath != "" {
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
}
}
return os.WriteFile(configPath, []byte(text), 0o644)
for _, s := range sections {
text = codexUpsertSection(text, s.header, s.lines)
}
parsed, err = codexParseConfig(text)
if err != nil {
return err
}
if err := codexValidateLaunchProfileText(parsed, profileName, opts, model, modelCatalogPath, baseURL); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, []byte(text), opts.backupIntegration)
}
func codexLaunchProfileName(opts codexLaunchProfileOptions) string {
if name := strings.TrimSpace(opts.profileName); name != "" {
return name
}
return codexProfileName
}
func codexBaseURL() string {
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1/"
}
func codexProfileHeader() string {
return codexProfileHeaderFor(codexProfileName)
}
func codexProviderHeader() string {
return codexProviderHeaderFor(codexProfileName)
}
func codexProfileHeaderFor(profileName string) string {
return fmt.Sprintf("[profiles.%s]", profileName)
}
func codexProviderHeaderFor(profileName string) string {
return fmt.Sprintf("[model_providers.%s]", profileName)
}
func codexValidateLaunchProfileText(config codexParsedConfig, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error {
for _, check := range []struct {
path []string
want string
}{
{[]string{"profiles", profileName, "openai_base_url"}, baseURL},
{[]string{"profiles", profileName, codexRootModelProviderKey}, profileName},
{[]string{"model_providers", profileName, "name"}, codexProviderName},
{[]string{"model_providers", profileName, "base_url"}, baseURL},
{[]string{"model_providers", profileName, "wire_api"}, "responses"},
} {
if got, ok := config.String(check.path...); !ok || got != check.want {
return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want)
}
}
if opts.forceAPIAuth {
if got, ok := config.String("profiles", profileName, "forced_login_method"); !ok || got != "api" {
return fmt.Errorf("generated Codex config missing profiles.%s.forced_login_method = %q", profileName, "api")
}
}
if model != "" {
if got, ok := config.String("profiles", profileName, codexRootModelKey); !ok || got != model {
return fmt.Errorf("generated Codex config missing profiles.%s.model = %q", profileName, model)
}
}
if modelCatalogPath != "" {
if got, ok := config.String("profiles", profileName, codexRootModelCatalogJSONKey); !ok || got != modelCatalogPath {
return fmt.Errorf("generated Codex config missing profiles.%s.model_catalog_json = %q", profileName, modelCatalogPath)
}
}
if opts.activate {
if got := config.RootString(codexRootProfileKey); got != profileName {
return fmt.Errorf("generated Codex config missing profile = %q", profileName)
}
}
if opts.setRootModelConfig {
if model != "" {
if got := config.RootString(codexRootModelKey); got != model {
return fmt.Errorf("generated Codex config missing model = %q", model)
}
}
if got := config.RootString(codexRootModelProviderKey); got != profileName {
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
}
if modelCatalogPath != "" {
if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath {
return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath)
}
}
}
return nil
}
func codexUpsertSection(text, header string, lines []string) string {
block := strings.Join(append([]string{header}, lines...), "\n") + "\n"
if targetPath, ok := codexTableHeaderPath(header); ok {
if start, end, found := codexSectionRange(text, targetPath); found {
return text[:start] + block + text[end:]
}
}
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
if text != "" {
text += "\n"
}
return text + block
}
func codexRemoveSection(text, header string) string {
targetPath, ok := codexTableHeaderPath(header)
if !ok {
return text
}
start, end, found := codexSectionRange(text, targetPath)
if !found {
return text
}
return text[:start] + text[end:]
}
type codexParsedConfig struct {
values map[string]any
}
func (c codexParsedConfig) String(path ...string) (string, bool) {
if len(path) == 0 {
return "", false
}
var current any = c.values
for _, part := range path {
table, ok := current.(map[string]any)
if !ok {
return "", false
}
current, ok = table[part]
if !ok {
return "", false
}
}
value, ok := current.(string)
if !ok {
return "", false
}
return value, true
}
func (c codexParsedConfig) RootString(key string) string {
value, _ := c.RootStringOK(key)
return value
}
func (c codexParsedConfig) RootStringOK(key string) (string, bool) {
return c.String(key)
}
func (c codexParsedConfig) ProfileString(profileName, key string) string {
value, _ := c.String("profiles", profileName, key)
return value
}
func (c codexParsedConfig) ProviderString(profileName, key string) string {
value, _ := c.String("model_providers", profileName, key)
return value
}
func codexRootStringValue(text, key string) string {
config, err := codexParseConfig(text)
if err != nil {
return ""
}
return config.RootString(key)
}
func codexRootStringValueOK(text, key string) (string, bool) {
config, err := codexParseConfig(text)
if err != nil {
return "", false
}
return config.RootStringOK(key)
}
func codexStringValue(text string, path ...string) (string, bool) {
config, err := codexParseConfig(text)
if err != nil {
return "", false
}
return config.String(path...)
}
func codexSectionStringValue(text, header, key string) string {
path, ok := codexTableHeaderPath(header)
if !ok {
return ""
}
value, _ := codexStringValue(text, append(path, key)...)
return value
}
func codexParseConfig(text string) (codexParsedConfig, error) {
values, err := codexParseConfigText(text)
if err != nil {
return codexParsedConfig{}, err
}
return codexParsedConfig{values: values}, nil
}
func codexParseConfigText(text string) (map[string]any, error) {
cfg := map[string]any{}
if strings.TrimSpace(text) == "" {
return cfg, nil
}
if err := toml.Unmarshal([]byte(text), &cfg); err != nil {
return nil, fmt.Errorf("invalid Codex config TOML: %w", err)
}
return cfg, nil
}
func codexValidateConfigText(text string) error {
_, err := codexParseConfig(text)
return err
}
func codexSectionRange(text string, targetPath []string) (int, int, bool) {
lines := strings.SplitAfter(text, "\n")
offset := 0
start := -1
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "#") {
offset += len(line)
continue
}
if start >= 0 {
return start, offset, true
}
if path, ok := codexTableHeaderPath(trimmed); ok && codexSamePath(path, targetPath) {
start = offset
}
offset += len(line)
}
if start >= 0 {
return start, len(text), true
}
return 0, 0, false
}
func codexTableHeaderPath(header string) ([]string, bool) {
trimmed := strings.TrimSpace(header)
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[") {
return nil, false
}
const probeKey = "__ollama_launch_probe"
cfg := map[string]any{}
if err := toml.Unmarshal([]byte(trimmed+"\n"+probeKey+" = true\n"), &cfg); err != nil {
return nil, false
}
return codexFindProbePath(cfg, probeKey, nil)
}
func codexFindProbePath(value any, probeKey string, path []string) ([]string, bool) {
table, ok := value.(map[string]any)
if !ok {
return nil, false
}
if probe, ok := table[probeKey].(bool); ok && probe {
return path, true
}
for key, child := range table {
if key == probeKey {
continue
}
if childPath, ok := codexFindProbePath(child, probeKey, append(path, key)); ok {
return childPath, true
}
}
return nil, false
}
func codexSamePath(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func codexSetRootStringValue(text, key, value string) string {
lines := strings.SplitAfter(text, "\n")
rootEnd := len(lines)
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "[") {
rootEnd = i
break
}
}
assignment := fmt.Sprintf("%s = %q", key, value)
for i := range rootEnd {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if codexRootLineHasKey(trimmed, key) {
if strings.HasSuffix(line, "\n") {
lines[i] = assignment + "\n"
} else {
lines[i] = assignment
}
return strings.Join(lines, "")
}
}
insert := assignment + "\n"
root := strings.Join(lines[:rootEnd], "")
rest := strings.Join(lines[rootEnd:], "")
if root != "" && !strings.HasSuffix(root, "\n") {
root += "\n"
}
if rest != "" && !strings.HasSuffix(insert, "\n\n") {
insert += "\n"
}
return root + insert + rest
}
func codexRemoveRootValue(text, key string) string {
lines := strings.SplitAfter(text, "\n")
rootEnd := len(lines)
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "[") {
rootEnd = i
break
}
}
out := make([]string, 0, len(lines))
for i, line := range lines {
if i < rootEnd {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && codexRootLineHasKey(trimmed, key) {
continue
}
}
out = append(out, line)
}
return strings.Join(out, "")
}
func codexRootLineHasKey(line, key string) bool {
cfg := map[string]any{}
if err := toml.Unmarshal([]byte(line+"\n"), &cfg); err != nil {
return false
}
_, ok := cfg[key]
return ok
}
func checkCodexVersion() error {
+989
View File
@@ -0,0 +1,989 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
modelpkg "github.com/ollama/ollama/types/model"
)
const (
codexAppIntegrationName = "codex-app"
codexAppProfileName = "ollama-launch-codex-app"
codexAppBundleID = "com.openai.codex"
codexAppModelCatalogFilename = "ollama-launch-models.json"
codexAppRestoreHint = "To restore your usual Codex profile, run: ollama launch codex-app --restore"
codexAppConfigurationSuccess = "Codex App profile changed to Ollama."
codexAppRestoreSuccess = "Codex App restored to your usual profile."
)
var (
codexAppGOOS = runtime.GOOS
codexAppStat = os.Stat
codexAppGlob = filepath.Glob
codexAppOpenApp = defaultCodexAppOpenApp
codexAppOpenPath = defaultCodexAppOpenAppPath
codexAppOpenStart = defaultCodexAppOpenStartAppID
codexAppQuitApp = defaultCodexAppQuitApp
codexAppIsRunning = defaultCodexAppIsRunning
codexAppRunPath = defaultCodexAppRunningAppPath
codexAppStartID = defaultCodexAppStartAppID
codexAppCanOpenID = defaultCodexAppCanOpenBundleID
codexAppSleep = time.Sleep
)
// CodexApp configures the desktop Codex app with one launch-selected default
// model while leaving model discovery and switching to Codex's Ollama provider.
type CodexApp struct{}
func (c *CodexApp) String() string { return "Codex App" }
func (c *CodexApp) Supported() error { return codexAppSupported() }
func (c *CodexApp) Paths() []string {
configPath, err := codexConfigPath()
if err != nil {
return nil
}
return []string{configPath}
}
func (c *CodexApp) Configure(model string) error {
return c.ConfigureWithModels(model, []string{model})
}
func (c *CodexApp) ConfigureWithModels(primary string, models []string) error {
primary = strings.TrimSpace(primary)
if primary == "" {
return fmt.Errorf("codex-app requires a model")
}
configPath, err := codexConfigPath()
if err != nil {
return err
}
if err := saveCodexAppRestoreState(configPath); err != nil {
return err
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
return err
}
if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModelNames(primary, models)); err != nil {
return err
}
return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
activate: true,
profileName: codexAppProfileName,
setRootModelConfig: true,
model: primary,
modelCatalogPath: catalogPath,
backupIntegration: codexAppIntegrationName,
})
}
func (c *CodexApp) CurrentModel() string {
configPath, err := codexConfigPath()
if err != nil {
return ""
}
data, err := os.ReadFile(configPath)
if err != nil {
return ""
}
text := string(data)
parsed, err := codexParseConfig(text)
if err != nil {
return ""
}
for _, profileName := range codexAppManagedProfileNames() {
if parsed.RootString(codexRootModelProviderKey) == profileName {
baseURL := parsed.ProviderString(profileName, "base_url")
if codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) && codexAppCatalogHealthy(parsed, profileName) {
return strings.TrimSpace(parsed.RootString(codexRootModelKey))
}
}
}
profileName := parsed.RootString(codexRootProfileKey)
if !codexAppIsManagedProfileName(profileName) {
return ""
}
if parsed.ProfileString(profileName, codexRootModelProviderKey) != profileName {
return ""
}
baseURL := parsed.ProviderString(profileName, "base_url")
if codexNormalizeURL(baseURL) != codexNormalizeURL(codexBaseURL()) {
return ""
}
if !codexAppCatalogHealthy(parsed, profileName) {
return ""
}
return strings.TrimSpace(parsed.ProfileString(profileName, codexRootModelKey))
}
func codexAppManagedProfileNames() []string {
return []string{codexAppProfileName, codexProfileName}
}
func codexAppIsManagedProfileName(profileName string) bool {
for _, candidate := range codexAppManagedProfileNames() {
if profileName == candidate {
return true
}
}
return false
}
func codexAppIsOwnedProfileName(profileName string) bool {
return profileName == codexAppProfileName
}
func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool {
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
return false
}
if config.RootString(codexRootModelCatalogJSONKey) != catalogPath {
return false
}
if config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath {
return false
}
data, err := os.ReadFile(catalogPath)
if err != nil {
return false
}
var catalog struct {
Models []json.RawMessage `json:"models"`
}
if err := json.Unmarshal(data, &catalog); err != nil {
return false
}
return len(catalog.Models) > 0
}
func (c *CodexApp) Onboard() error {
return config.MarkIntegrationOnboarded(codexAppIntegrationName)
}
func (c *CodexApp) RequiresInteractiveOnboarding() bool {
return false
}
func (c *CodexApp) RestoreHint() string {
return codexAppRestoreHint
}
func (c *CodexApp) ConfigurationSuccessMessage() string {
return codexAppConfigurationSuccess + "\n" + codexAppRestoreHint
}
func (c *CodexApp) RestoreSuccessMessage() string {
return codexAppRestoreSuccess
}
func (c *CodexApp) Run(_ string, args []string) error {
if err := codexAppSupported(); err != nil {
return err
}
if len(args) > 0 {
return fmt.Errorf("codex-app does not accept extra arguments")
}
return codexAppLaunchOrRestart("Restart Codex to use Ollama?")
}
func (c *CodexApp) Restore() error {
if err := codexAppSupported(); err != nil {
return err
}
configPath, err := codexConfigPath()
if err != nil {
return err
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
}
return codexAppRestoreFailure(configPath, err)
}
text := string(data)
if err := codexValidateConfigText(text); err != nil {
return codexAppRestoreFailure(configPath, err)
}
state, stateErr := loadCodexAppRestoreState()
if stateErr == nil {
text = codexAppRestoreRootValues(text, state)
} else if os.IsNotExist(stateErr) {
text = codexAppRemoveOwnedRootValues(text)
} else {
return codexAppRestoreFailure(configPath, stateErr)
}
if !codexAppRootReferencesOwnedConfig(text) {
text = codexAppRemoveOwnedSections(text)
}
if err := codexValidateConfigText(text); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := codexAppRemoveOwnedCatalogIfUnused(text); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
}
func codexAppRestoreFailure(configPath string, err error) error {
return fmt.Errorf("restore Codex App config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n Restore state: %s\n Model catalog: %s\n Backups: %s",
err,
configPath,
codexAppRestoreStatePath(),
codexAppModelCatalogPathForConfig(configPath),
filepath.Join(fileutil.BackupDir(), codexAppIntegrationName),
)
}
func codexAppSupported() error {
switch codexAppGOOS {
case "darwin", "windows":
return nil
default:
return fmt.Errorf("Codex App launch is only supported on macOS and Windows")
}
}
func codexAppInstalled() bool {
if codexAppAppPath() != "" {
return true
}
switch codexAppGOOS {
case "darwin":
return codexAppCanOpenID()
case "windows":
return codexAppIsRunning() || codexAppStartID() != ""
default:
return false
}
}
func codexAppModelCatalogPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexAppModelCatalogPathForConfig(configPath), nil
}
func codexAppModelCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename)
}
func writeCodexAppModelCatalog(path, primary string, models []string) error {
if len(models) == 0 {
return fmt.Errorf("codex-app model catalog cannot be empty")
}
client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
baseInstructions := codexAppBaseInstructions()
primaryMetadata := codexAppSelectedModelMetadata(ctx, client, primary)
entries := make([]map[string]any, 0, len(models))
for i, model := range models {
metadata := codexAppDefaultModelMetadata()
if model == primary {
metadata = primaryMetadata
}
entries = append(entries, codexAppCatalogEntry(model, metadata, i, baseInstructions))
}
data, err := json.MarshalIndent(map[string]any{"models": entries}, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(path, append(data, '\n'), codexAppIntegrationName)
}
func codexAppCatalogModelNames(primary string, fallback []string) []string {
models := codexAppTagModelNames()
if len(models) == 0 {
models = fallback
}
return dedupeModelList(append([]string{primary}, models...))
}
func codexAppTagModelNames() []string {
client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.List(ctx)
if err != nil {
return nil
}
models := make([]string, 0, len(resp.Models))
for _, model := range resp.Models {
name := strings.TrimSpace(model.Name)
if name != "" {
models = append(models, name)
}
}
return models
}
type codexAppModelMetadata struct {
contextWindow int
inputModalities []string
}
func codexAppDefaultModelMetadata() codexAppModelMetadata {
return codexAppModelMetadata{
contextWindow: 272_000,
inputModalities: []string{"text"},
}
}
func codexAppSelectedModelMetadata(ctx context.Context, client *api.Client, model string) codexAppModelMetadata {
metadata := codexAppDefaultModelMetadata()
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
if err != nil {
return metadata
}
if n, ok := modelInfoContextLength(resp.ModelInfo); ok {
metadata.contextWindow = n
}
if slices.Contains(resp.Capabilities, modelpkg.CapabilityVision) {
metadata.inputModalities = []string{"text", "image"}
}
return metadata
}
func codexAppCatalogEntry(model string, metadata codexAppModelMetadata, priority int, baseInstructions string) map[string]any {
return map[string]any{
"slug": model,
"display_name": model,
"description": "Ollama local model",
"default_reasoning_level": nil,
"supported_reasoning_levels": []any{},
"shell_type": "default",
"visibility": "list",
"supported_in_api": true,
"priority": priority,
"additional_speed_tiers": []any{},
"availability_nux": nil,
"upgrade": nil,
"base_instructions": baseInstructions,
"model_messages": nil,
"supports_reasoning_summaries": false,
"default_reasoning_summary": "auto",
"support_verbosity": false,
"default_verbosity": nil,
"apply_patch_tool_type": nil,
"web_search_tool_type": "text",
"truncation_policy": map[string]any{"mode": "bytes", "limit": 10_000},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": metadata.contextWindow,
"max_context_window": metadata.contextWindow,
"auto_compact_token_limit": nil,
"effective_context_window_percent": 95,
"experimental_supported_tools": []any{},
"input_modalities": metadata.inputModalities,
"supports_search_tool": false,
}
}
func codexAppBaseInstructions() string {
path, err := codexModelCachePath()
if err == nil {
var cached struct {
Models []struct {
BaseInstructions string `json:"base_instructions"`
} `json:"models"`
}
if data, readErr := os.ReadFile(path); readErr == nil {
if json.Unmarshal(data, &cached) == nil {
for _, model := range cached.Models {
if strings.TrimSpace(model.BaseInstructions) != "" {
return model.BaseInstructions
}
}
}
}
}
return "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals."
}
func codexModelCachePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".codex", "models_cache.json"), nil
}
func codexAppAppPath() string {
var candidates []string
switch codexAppGOOS {
case "darwin":
candidates = codexAppDarwinAppCandidates()
case "windows":
candidates = codexAppWindowsAppCandidates()
default:
return ""
}
for _, candidate := range candidates {
if info, err := codexAppStat(candidate); err == nil {
if codexAppGOOS == "darwin" && !info.IsDir() {
continue
}
if codexAppGOOS == "windows" && info.IsDir() {
continue
}
return candidate
}
}
return ""
}
func codexAppDarwinAppCandidates() []string {
candidates := []string{"/Applications/Codex.app"}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates, filepath.Join(home, "Applications", "Codex.app"))
}
return candidates
}
func codexAppWindowsAppCandidates() []string {
local, err := codexAppLocalAppData()
if err != nil {
return nil
}
candidates := []string{
filepath.Join(local, "Programs", "Codex", "Codex.exe"),
filepath.Join(local, "Programs", "OpenAI Codex", "Codex.exe"),
filepath.Join(local, "Codex", "Codex.exe"),
filepath.Join(local, "OpenAI Codex", "Codex.exe"),
filepath.Join(local, "OpenAI", "Codex", "Codex.exe"),
filepath.Join(local, "openai-codex-electron", "Codex.exe"),
}
for _, pattern := range []string{
filepath.Join(local, "Programs", "Codex", "app-*", "Codex.exe"),
filepath.Join(local, "Programs", "OpenAI Codex", "app-*", "Codex.exe"),
filepath.Join(local, "Codex", "app-*", "Codex.exe"),
filepath.Join(local, "OpenAI Codex", "app-*", "Codex.exe"),
filepath.Join(local, "OpenAI", "Codex", "app-*", "Codex.exe"),
filepath.Join(local, "openai-codex-electron", "app-*", "Codex.exe"),
} {
matches, _ := codexAppGlob(pattern)
candidates = append(candidates, matches...)
}
return codexAppDedupePaths(candidates)
}
func codexAppDedupePaths(paths []string) []string {
out := make([]string, 0, len(paths))
seen := make(map[string]bool, len(paths))
for _, path := range paths {
if strings.TrimSpace(path) == "" {
continue
}
key := strings.ToLower(path)
if seen[key] {
continue
}
seen[key] = true
out = append(out, path)
}
return out
}
func codexAppLocalAppData() (string, error) {
if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" {
return local, nil
}
if home := strings.TrimSpace(os.Getenv("USERPROFILE")); home != "" {
return filepath.Join(home, "AppData", "Local"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "AppData", "Local"), nil
}
func codexAppLaunchOrRestart(prompt string) error {
if !codexAppIsRunning() {
return codexAppOpenApp()
}
restartAppID := ""
restartAppPath := ""
if codexAppGOOS == "windows" {
restartAppID = codexAppStartID()
if restartAppID == "" {
restartAppPath = codexAppRunPath()
}
}
restart, err := ConfirmPrompt(prompt)
if err != nil {
return err
}
if !restart {
fmt.Fprintln(os.Stderr, "\nQuit and reopen Codex when you're ready for the profile change to take effect.")
return nil
}
if err := codexAppQuitApp(); err != nil {
return fmt.Errorf("quit Codex: %w", err)
}
if err := waitForCodexAppExit(30 * time.Second); err != nil {
return err
}
if restartAppID != "" {
return codexAppOpenStart(restartAppID)
}
if restartAppPath != "" {
return codexAppOpenPath(restartAppPath)
}
return codexAppOpenApp()
}
func waitForCodexAppExit(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !codexAppIsRunning() {
return nil
}
codexAppSleep(200 * time.Millisecond)
}
return fmt.Errorf("Codex did not quit; quit it manually and re-run the command")
}
func defaultCodexAppOpenApp() error {
switch codexAppGOOS {
case "windows":
if path := codexAppAppPath(); path != "" {
return codexAppOpenPath(path)
}
if path := codexAppRunPath(); path != "" {
return codexAppOpenPath(path)
}
if appID := codexAppStartID(); appID != "" {
return codexAppOpenStart(appID)
}
return fmt.Errorf("Codex executable was not found; open Codex manually once and re-run 'ollama launch codex-app'")
case "darwin":
if path := codexAppAppPath(); path != "" {
cmd := exec.Command("open", path)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
cmd := exec.Command("open", "-b", codexAppBundleID)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
default:
return codexAppSupported()
}
}
func defaultCodexAppOpenAppPath(path string) error {
switch codexAppGOOS {
case "windows":
return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process -FilePath "+quotePowerShellString(path)).Run()
case "darwin":
cmd := exec.Command("open", path)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
default:
return codexAppSupported()
}
}
func defaultCodexAppOpenStartAppID(appID string) error {
return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process "+quotePowerShellString(`shell:AppsFolder\`+appID)).Run()
}
func defaultCodexAppQuitApp() error {
if codexAppGOOS == "windows" {
script := `Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }`
return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run()
}
scriptErr := exec.Command("osascript", "-e", `tell application "Codex" to quit`).Run()
if scriptErr != nil {
scriptErr = exec.Command("osascript", "-e", `tell application id "`+codexAppBundleID+`" to quit`).Run()
}
return scriptErr
}
func defaultCodexAppIsRunning() bool {
switch codexAppGOOS {
case "windows":
return len(codexAppMatchingProcessIDs()) > 0
case "darwin":
out, err := exec.Command("osascript", "-e", `tell application "System Events" to exists process "Codex"`).Output()
if err == nil && strings.TrimSpace(string(out)) == "true" {
return true
}
return len(codexAppMatchingProcessIDs()) > 0
default:
return false
}
}
func codexAppMatchingProcessIDs() []int {
if codexAppGOOS == "windows" {
return codexAppWindowsMatchingProcessIDs()
}
out, err := exec.Command("ps", "-axo", "pid=,command=").Output()
if err != nil {
return nil
}
var pids []int
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
pid, err := strconv.Atoi(fields[0])
if err != nil || pid == os.Getpid() {
continue
}
command := strings.TrimSpace(strings.TrimPrefix(line, fields[0]))
if codexAppProcessMatches(command) {
pids = append(pids, pid)
}
}
return pids
}
func codexAppWindowsMatchingProcessIDs() []int {
script := fmt.Sprintf(`$current = %d; Get-CimInstance Win32_Process -Filter "Name = 'Codex.exe' OR Name = 'codex.exe'" | Where-Object { $_.ProcessId -ne $current -and ((($_.Name -ieq 'Codex.exe') -and (($null -eq $_.CommandLine) -or ($_.CommandLine -notlike '* --type=*'))) -or (($_.Name -ieq 'codex.exe') -and ($_.CommandLine -like '*app-server*'))) } | Select-Object -ExpandProperty ProcessId`, os.Getpid())
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil {
return nil
}
var pids []int
for _, line := range strings.Split(string(out), "\n") {
pid, err := strconv.Atoi(strings.TrimSpace(line))
if err == nil && pid != os.Getpid() {
pids = append(pids, pid)
}
}
return pids
}
func defaultCodexAppRunningAppPath() string {
if codexAppGOOS != "windows" {
return ""
}
script := `(Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)`
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func defaultCodexAppStartAppID() string {
if codexAppGOOS != "windows" {
return ""
}
script := `(Get-StartApps Codex | Where-Object { $_.Name -eq 'Codex' -or $_.Name -like 'Codex*' } | Select-Object -First 1 -ExpandProperty AppID)`
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func defaultCodexAppCanOpenBundleID() bool {
if codexAppGOOS != "darwin" {
return false
}
query := fmt.Sprintf("kMDItemCFBundleIdentifier == %q", codexAppBundleID)
out, err := exec.Command("mdfind", query).Output()
return err == nil && strings.TrimSpace(string(out)) != ""
}
func codexAppProcessMatches(command string) bool {
if strings.Contains(command, `\Codex.exe`) && strings.Contains(command, " --type=") {
return false
}
for _, pattern := range codexAppProcessPatterns() {
if strings.Contains(command, pattern) {
return true
}
}
return false
}
func codexAppProcessPatterns() []string {
return []string{
"Codex.app/Contents/MacOS/Codex",
"Codex.app/Contents/Resources/codex app-server",
`\Codex.exe`,
`resources\codex.exe app-server`,
`resources\codex.exe" app-server`,
`resources\codex.exe" "app-server`,
}
}
func codexNormalizeURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
func codexAppRootStillManaged(text string) bool {
config, err := codexParseConfig(text)
if err != nil {
return false
}
return codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) ||
codexAppIsOwnedProfileName(config.RootString(codexRootModelProviderKey))
}
func codexAppRootReferencesOwnedConfig(text string) bool {
config, err := codexParseConfig(text)
if err != nil {
return false
}
return config.RootString(codexRootProfileKey) == codexAppProfileName ||
config.RootString(codexRootModelProviderKey) == codexAppProfileName
}
func codexAppRootReferencesCatalog(text string) bool {
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
return false
}
config, err := codexParseConfig(text)
if err != nil {
return false
}
return config.RootString(codexRootModelCatalogJSONKey) == catalogPath
}
func codexAppRemoveOwnedSections(text string) string {
text = codexRemoveSection(text, codexProfileHeaderFor(codexAppProfileName))
text = codexRemoveSection(text, codexProviderHeaderFor(codexAppProfileName))
return text
}
func codexAppRemoveOwnedCatalogIfUnused(text string) error {
if codexAppRootReferencesCatalog(text) {
return nil
}
if catalogPath, err := codexAppModelCatalogPath(); err == nil {
if err := os.Remove(catalogPath); err != nil && !os.IsNotExist(err) {
return err
}
} else {
return err
}
return nil
}
func codexAppRemoveOwnedRootValues(text string) string {
config, err := codexParseConfig(text)
if err != nil {
return text
}
modelProvider := config.RootString(codexRootModelProviderKey)
modelCatalogJSON := config.RootString(codexRootModelCatalogJSONKey)
if !codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) && !codexAppIsOwnedProfileName(modelProvider) {
return text
}
text = codexRemoveRootValue(text, codexRootProfileKey)
text = codexRemoveRootValue(text, codexRootModelKey)
if codexAppIsOwnedProfileName(modelProvider) {
text = codexRemoveRootValue(text, codexRootModelProviderKey)
}
if catalogPath, err := codexAppModelCatalogPath(); err == nil && modelCatalogJSON == catalogPath {
text = codexRemoveRootValue(text, codexRootModelCatalogJSONKey)
}
return text
}
func codexAppRestoreRootValues(text string, state codexAppRestoreState) string {
if !codexAppRootStillManaged(text) {
return text
}
text = codexRestoreRootStringValue(text, codexRootProfileKey, state.HadProfile, state.Profile)
text = codexRestoreRootStringValue(text, codexRootModelKey, state.HadModel, state.Model)
text = codexRestoreRootStringValue(text, codexRootModelProviderKey, state.HadModelProvider, state.ModelProvider)
text = codexRestoreRootStringValue(text, codexRootModelCatalogJSONKey, state.HadModelCatalogJSON, state.ModelCatalogJSON)
return text
}
type codexAppRestoreState struct {
HadProfile bool `json:"had_profile"`
Profile string `json:"profile,omitempty"`
HadModel bool `json:"had_model"`
Model string `json:"model,omitempty"`
HadModelProvider bool `json:"had_model_provider"`
ModelProvider string `json:"model_provider,omitempty"`
HadModelCatalogJSON bool `json:"had_model_catalog_json"`
ModelCatalogJSON string `json:"model_catalog_json,omitempty"`
}
func saveCodexAppRestoreState(configPath string) error {
configText := ""
configExists := false
if configData, err := os.ReadFile(configPath); err == nil {
configText = string(configData)
if err := codexValidateConfigText(configText); err != nil {
return err
}
configExists = true
} else if !os.IsNotExist(err) {
return err
}
if !configExists {
return writeCodexAppRestoreState(codexAppRestoreState{})
}
statePath := codexAppRestoreStatePath()
if stateData, err := os.ReadFile(statePath); err == nil {
hasRootConfig, err := codexAppRestoreStateHasRootConfig(stateData)
if err != nil {
return err
}
if hasRootConfig {
if configExists && !codexAppRootStillManaged(configText) {
return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText))
}
return nil
}
var existing codexAppRestoreState
if err := json.Unmarshal(stateData, &existing); err != nil {
return err
}
upgraded := codexAppRestoreStateFromText(configText)
upgraded.HadProfile = existing.HadProfile
upgraded.Profile = existing.Profile
return writeCodexAppRestoreState(upgraded)
} else if !os.IsNotExist(err) {
return err
}
return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText))
}
func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return false, err
}
_, hasModel := raw["had_model"]
_, hasModelProvider := raw["had_model_provider"]
_, hasModelCatalogJSON := raw["had_model_catalog_json"]
return hasModel && hasModelProvider && hasModelCatalogJSON, nil
}
func codexAppRestoreStateFromText(text string) codexAppRestoreState {
config, err := codexParseConfig(text)
if err != nil {
return codexAppRestoreState{}
}
profile, hadProfile := config.RootStringOK(codexRootProfileKey)
model, hadModel := config.RootStringOK(codexRootModelKey)
modelProvider, hadModelProvider := config.RootStringOK(codexRootModelProviderKey)
modelCatalogJSON, hadModelCatalogJSON := config.RootStringOK(codexRootModelCatalogJSONKey)
return codexAppRestoreState{
HadProfile: hadProfile,
Profile: profile,
HadModel: hadModel,
Model: model,
HadModelProvider: hadModelProvider,
ModelProvider: modelProvider,
HadModelCatalogJSON: hadModelCatalogJSON,
ModelCatalogJSON: modelCatalogJSON,
}
}
func codexRestoreRootStringValue(text, key string, hadValue bool, value string) string {
if hadValue {
return codexSetRootStringValue(text, key, value)
}
return codexRemoveRootValue(text, key)
}
func writeCodexAppRestoreState(state codexAppRestoreState) error {
path := codexAppRestoreStatePath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(path, data, codexAppIntegrationName)
}
func loadCodexAppRestoreState() (codexAppRestoreState, error) {
data, err := os.ReadFile(codexAppRestoreStatePath())
if err != nil {
return codexAppRestoreState{}, err
}
var state codexAppRestoreState
if err := json.Unmarshal(data, &state); err != nil {
return codexAppRestoreState{}, err
}
return state, nil
}
func removeCodexAppRestoreState() error {
if err := os.Remove(codexAppRestoreStatePath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func codexAppRestoreStatePath() string {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(os.TempDir(), "ollama-codex-app-restore.json")
}
return filepath.Join(home, ".ollama", "launch", "codex-app-restore.json")
}
+1318
View File
@@ -0,0 +1,1318 @@
package launch
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
func withCodexAppPlatform(t *testing.T, goos string) {
t.Helper()
old := codexAppGOOS
codexAppGOOS = goos
t.Cleanup(func() {
codexAppGOOS = old
})
}
func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() error, open func() error) {
t.Helper()
oldIsRunning := codexAppIsRunning
oldQuit := codexAppQuitApp
oldOpen := codexAppOpenApp
oldOpenPath := codexAppOpenPath
oldOpenStart := codexAppOpenStart
oldRunPath := codexAppRunPath
oldStartID := codexAppStartID
oldCanOpenID := codexAppCanOpenID
codexAppIsRunning = isRunning
codexAppQuitApp = quit
codexAppOpenApp = open
t.Cleanup(func() {
codexAppIsRunning = oldIsRunning
codexAppQuitApp = oldQuit
codexAppOpenApp = oldOpen
codexAppOpenPath = oldOpenPath
codexAppOpenStart = oldOpenStart
codexAppRunPath = oldRunPath
codexAppStartID = oldStartID
codexAppCanOpenID = oldCanOpenID
})
}
func TestCodexAppIntegration(t *testing.T) {
c := &CodexApp{}
t.Run("implements runner", func(t *testing.T) {
var _ Runner = c
})
t.Run("implements supported integration", func(t *testing.T) {
var _ SupportedIntegration = c
})
t.Run("implements managed single model", func(t *testing.T) {
var _ ManagedSingleModel = c
})
t.Run("receives model list", func(t *testing.T) {
var _ ManagedModelListConfigurer = c
})
t.Run("onboarding is noninteractive", func(t *testing.T) {
var _ ManagedInteractiveOnboarding = c
if c.RequiresInteractiveOnboarding() {
t.Fatal("Codex App onboarding should only mark launch config")
}
})
t.Run("implements restore", func(t *testing.T) {
var _ RestorableIntegration = c
var _ RestoreHintIntegration = c
var _ ConfigurationSuccessIntegration = c
var _ RestoreSuccessIntegration = c
})
}
func TestCodexAppSupportedPlatforms(t *testing.T) {
for _, goos := range []string{"darwin", "windows"} {
t.Run(goos, func(t *testing.T) {
withCodexAppPlatform(t, goos)
if err := codexAppSupported(); err != nil {
t.Fatalf("codexAppSupported returned error: %v", err)
}
})
}
t.Run("linux unsupported", func(t *testing.T) {
withCodexAppPlatform(t, "linux")
err := codexAppSupported()
if err == nil || !strings.Contains(err.Error(), "macOS and Windows") {
t.Fatalf("codexAppSupported error = %v, want platform message", err)
}
})
}
func TestCodexAppWindowsAppPathCandidates(t *testing.T) {
withCodexAppPlatform(t, "windows")
local := filepath.Join(t.TempDir(), "LocalAppData")
t.Setenv("LOCALAPPDATA", local)
exe := filepath.Join(local, "Codex", "app-26.429.30905", "Codex.exe")
if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(exe, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
if got := codexAppAppPath(); got != exe {
t.Fatalf("codexAppAppPath = %q, want %q", got, exe)
}
}
func TestCodexAppInstalledUsesWindowsStartMenuFallback(t *testing.T) {
withCodexAppPlatform(t, "windows")
t.Setenv("LOCALAPPDATA", filepath.Join(t.TempDir(), "LocalAppData"))
oldStartID := codexAppStartID
oldIsRunning := codexAppIsRunning
codexAppStartID = func() string { return "OpenAI.Codex_12345!App" }
codexAppIsRunning = func() bool { return false }
t.Cleanup(func() {
codexAppStartID = oldStartID
codexAppIsRunning = oldIsRunning
})
if !codexAppInstalled() {
t.Fatal("expected Windows Start menu app id to count as installed")
}
}
func TestCodexAppInstalledUsesMacBundleIDFallback(t *testing.T) {
withCodexAppPlatform(t, "darwin")
oldCanOpenID := codexAppCanOpenID
oldStat := codexAppStat
codexAppCanOpenID = func() bool { return true }
codexAppStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
t.Cleanup(func() {
codexAppCanOpenID = oldCanOpenID
codexAppStat = oldStat
})
if !codexAppInstalled() {
t.Fatal("expected macOS LaunchServices bundle id fallback to count as installed")
}
}
func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "" +
"profile = \"default\"\n" +
"model = \"gpt-5.5\"\n\n" +
"[profiles.default]\n" +
"model = \"gpt-5.5\"\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
c := &CodexApp{}
if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2", "qwen3:8b"}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
fmt.Sprintf(`profile = %q`, codexAppProfileName),
`model = "llama3.2"`,
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
fmt.Sprintf(`model_catalog_json = %q`, catalogPath),
codexProfileHeaderFor(codexAppProfileName),
`model = "llama3.2"`,
`openai_base_url = "http://127.0.0.1:9999/v1/"`,
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
`model_catalog_json = "`,
codexProviderHeaderFor(codexAppProfileName),
`name = "Ollama"`,
`base_url = "http://127.0.0.1:9999/v1/"`,
`wire_api = "responses"`,
`[profiles.default]`,
} {
if !strings.Contains(content, want) {
t.Fatalf("expected config to contain %q, got:\n%s", want, content)
}
}
if got := c.CurrentModel(); got != "llama3.2" {
t.Fatalf("CurrentModel = %q, want llama3.2", got)
}
restoreData, err := os.ReadFile(codexAppRestoreStatePath())
if err != nil {
t.Fatalf("expected restore state: %v", err)
}
if !strings.Contains(string(restoreData), `"profile": "default"`) {
t.Fatalf("expected restore state to remember default profile, got %s", restoreData)
}
catalogData, err := os.ReadFile(catalogPath)
if err != nil {
t.Fatalf("expected model catalog: %v", err)
}
var catalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(catalogData, &catalog); err != nil {
t.Fatalf("catalog should be valid JSON: %v", err)
}
if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b" {
t.Fatalf("catalog slugs = %v, want fallback models", got)
}
}
func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "" +
`profile = "default"` + "\n\n" +
"[profiles.ollama-launch]\n" +
`model = "cli-model"` + "\n" +
`openai_base_url = "http://cli.invalid/v1/"` + "\n" +
`model_provider = "ollama-launch"` + "\n\n" +
"[model_providers.ollama-launch]\n" +
`name = "CLI Ollama"` + "\n" +
`base_url = "http://cli.invalid/v1/"` + "\n" +
`wire_api = "responses"` + "\n\n" +
"[profiles.default]\n" +
`model = "gpt-5.5"` + "\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if got := codexRootStringValue(content, "profile"); got != codexAppProfileName {
t.Fatalf("root profile = %q, want %q", got, codexAppProfileName)
}
if got := codexSectionStringValue(content, codexProfileHeader(), "openai_base_url"); got != "http://cli.invalid/v1/" {
t.Fatalf("CLI profile base URL = %q, want preserved CLI URL in:\n%s", got, content)
}
if got := codexSectionStringValue(content, codexProviderHeader(), "name"); got != "CLI Ollama" {
t.Fatalf("CLI provider name = %q, want preserved CLI provider in:\n%s", got, content)
}
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "model"); got != "llama3.2" {
t.Fatalf("app profile model = %q, want llama3.2", got)
}
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" {
t.Fatalf("app provider base URL = %q", got)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`)
}
func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "0.0.0.0") {
t.Fatalf("config should not write bind-only host, got:\n%s", content)
}
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "openai_base_url"); got != "http://127.0.0.1:11434/v1/" {
t.Fatalf("app profile openai_base_url = %q, want connectable loopback URL", got)
}
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:11434/v1/" {
t.Fatalf("app provider base_url = %q, want connectable loopback URL", got)
}
if got := codexRootStringValue(content, "model_provider"); got != codexAppProfileName {
t.Fatalf("root model_provider = %q, want %q", got, codexAppProfileName)
}
}
func TestCodexAppConfigureRejectsMalformedTomlBeforeSideEffects(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "profile = \n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"})
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != existing {
t.Fatalf("malformed config should be left untouched, got:\n%s", data)
}
if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) {
t.Fatalf("restore state should not be written before config validation, err=%v", err)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
t.Fatalf("model catalog should not be written before config validation, err=%v", err)
}
}
func TestCodexAppConfigureRejectsMalformedTomlEvenWithExistingRestoreState(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "[profiles.ollama-launch\nmodel = \"llama3.2\"\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
t.Fatal(err)
}
err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"})
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != existing {
t.Fatalf("malformed config should be left untouched, got:\n%s", data)
}
stateData, err := os.ReadFile(codexAppRestoreStatePath())
if err != nil {
t.Fatal(err)
}
if string(stateData) != restoreState {
t.Fatalf("restore state should be left untouched, got:\n%s", stateData)
}
}
func TestCodexAppCurrentModelRequiresManagedActiveProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
content := "" +
"profile = \"default\"\n\n" +
codexProfileHeaderFor(codexAppProfileName) + "\n" +
"model = \"llama3.2\"\n" +
fmt.Sprintf("model_provider = %q\n\n", codexAppProfileName) +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
"base_url = \"http://127.0.0.1:11434/v1/\"\n"
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if got := (&CodexApp{}).CurrentModel(); got != "" {
t.Fatalf("CurrentModel = %q, want empty when active profile is not managed", got)
}
}
func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
content := "" +
`model = "qwen3:8b"` + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" +
fmt.Sprintf(`model_catalog_json = %q`, mustWriteCodexAppTestCatalog(t, "qwen3:8b")) + "\n\n" +
codexProfileHeaderFor(codexAppProfileName) + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
fmt.Sprintf(`model_catalog_json = %q`, mustCodexAppModelCatalogPath(t)) + "\n\n" +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n"
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if got := (&CodexApp{}).CurrentModel(); got != "qwen3:8b" {
t.Fatalf("CurrentModel = %q, want qwen3:8b", got)
}
}
func TestCodexAppCurrentModelRequiresHealthyCatalog(t *testing.T) {
for _, tt := range []struct {
name string
rootCatalog bool
profileCatalog bool
writeCatalog bool
catalogData string
}{
{
name: "missing catalog reference",
rootCatalog: false,
profileCatalog: true,
writeCatalog: true,
catalogData: `{"models":[{"slug":"llama3.2"}]}`,
},
{
name: "deleted catalog file",
rootCatalog: true,
profileCatalog: true,
writeCatalog: false,
catalogData: `{"models":[{"slug":"llama3.2"}]}`,
},
{
name: "missing profile catalog reference",
rootCatalog: true,
profileCatalog: false,
writeCatalog: true,
catalogData: `{"models":[{"slug":"llama3.2"}]}`,
},
{
name: "corrupt catalog file",
rootCatalog: true,
profileCatalog: true,
writeCatalog: true,
catalogData: `{"models":`,
},
{
name: "empty catalog",
rootCatalog: true,
profileCatalog: true,
writeCatalog: true,
catalogData: `{"models":[]}`,
},
} {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
catalogPath := mustCodexAppModelCatalogPath(t)
if tt.writeCatalog {
if err := os.WriteFile(catalogPath, []byte(tt.catalogData), 0o644); err != nil {
t.Fatal(err)
}
}
var rootCatalogLine, profileCatalogLine string
if tt.rootCatalog {
rootCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n"
}
if tt.profileCatalog {
profileCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n"
}
content := "" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
rootCatalogLine + "\n" +
codexProfileHeaderFor(codexAppProfileName) + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
profileCatalogLine + "\n" +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n"
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if got := (&CodexApp{}).CurrentModel(); got != "" {
t.Fatalf("CurrentModel = %q, want empty when catalog is unhealthy", got)
}
})
}
}
func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
showCalls := make(map[string]int)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"},{"name":"llama3.2"}]}`)
case "/api/show":
var req struct {
Model string `json:"model"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode show request: %v", err)
}
showCalls[req.Model]++
fmt.Fprintf(w, `{"model_info":{"general.context_length":%d},"capabilities":["vision"]}`, 65536+len(req.Model))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := (&CodexApp{}).ConfigureWithModels("gemma4", []string{"fallback"}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(catalogPath)
if err != nil {
t.Fatal(err)
}
var catalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(data, &catalog); err != nil {
t.Fatalf("catalog should be valid JSON: %v", err)
}
if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2" {
t.Fatalf("catalog slugs = %v, want /api/tags models", got)
}
for _, model := range catalog.Models {
slug, _ := model["slug"].(string)
if model["display_name"] != slug {
t.Fatalf("display_name should match slug for %q: %v", slug, model["display_name"])
}
if model["visibility"] != "list" {
t.Fatalf("visibility for %q = %v, want list", slug, model["visibility"])
}
if model["default_reasoning_level"] != nil {
t.Fatalf("default_reasoning_level for %q = %v, want nil", slug, model["default_reasoning_level"])
}
levels, ok := model["supported_reasoning_levels"].([]any)
if !ok || len(levels) != 0 {
t.Fatalf("supported_reasoning_levels for %q = %v, want empty list", slug, model["supported_reasoning_levels"])
}
wantContext := float64(272000)
wantModalities := []string{"text"}
wantShowCalls := 0
if slug == "gemma4" {
wantContext = float64(65536 + len(slug))
wantModalities = []string{"text", "image"}
wantShowCalls = 1
}
if model["context_window"] != wantContext {
t.Fatalf("context_window for %q = %v, want %v", slug, model["context_window"], wantContext)
}
if got := catalogInputModalities(model); strings.Join(got, ",") != strings.Join(wantModalities, ",") {
t.Fatalf("input_modalities for %q = %v, want %v", slug, got, wantModalities)
}
if showCalls[slug] != wantShowCalls {
t.Fatalf("show calls for %q = %d, want %d", slug, showCalls[slug], wantShowCalls)
}
}
}
func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "" +
`model = "gpt-5.5"` + "\n" +
`model_provider = "odc-resp-dev"` + "\n\n" +
"[model_providers.odc-resp-dev]\n" +
`base_url = "https://example.invalid/v1/"` + "\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false}`), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
state, err := loadCodexAppRestoreState()
if err != nil {
t.Fatal(err)
}
if state.HadProfile {
t.Fatalf("HadProfile = true, want legacy false")
}
if !state.HadModel || state.Model != "gpt-5.5" {
t.Fatalf("model restore state = (%v, %q), want previous root model", state.HadModel, state.Model)
}
if !state.HadModelProvider || state.ModelProvider != "odc-resp-dev" {
t.Fatalf("model provider restore state = (%v, %q), want previous root provider", state.HadModelProvider, state.ModelProvider)
}
}
func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withCodexAppPlatform(t, "darwin")
var openCalls int
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error {
openCalls++
return nil
},
)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "" +
"profile = \"default\"\n" +
"model = \"gpt-5.5\"\n" +
"model_provider = \"openai\"\n" +
"model_catalog_json = \"/tmp/original-catalog.json\"\n\n" +
"[profiles.default]\n" +
"model = \"gpt-5.5\"\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
c := &CodexApp{}
if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
if err := c.Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `profile = "default"`) || strings.Contains(string(data), fmt.Sprintf(`profile = %q`, codexAppProfileName)) {
t.Fatalf("restore should restore previous active profile, got:\n%s", data)
}
restored := string(data)
if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) {
t.Fatalf("restore should remove owned app sections, got:\n%s", restored)
}
for key, want := range map[string]string{
"profile": "default",
"model": "gpt-5.5",
"model_provider": "openai",
"model_catalog_json": "/tmp/original-catalog.json",
} {
if got := codexRootStringValue(restored, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored)
}
}
if openCalls != 1 {
t.Fatalf("open calls = %d, want 1", openCalls)
}
if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) {
t.Fatalf("restore state should be removed, got err=%v", err)
}
}
func TestCodexAppRestoreMissingConfigRemovesRestoreState(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withCodexAppPlatform(t, "darwin")
var openCalls int
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error {
openCalls++
return nil
},
)
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) {
t.Fatalf("restore state should be removed when config is missing, got err=%v", err)
}
if openCalls != 1 {
t.Fatalf("open calls = %d, want 1", openCalls)
}
}
func TestCodexAppConfigureMissingConfigReplacesStaleRestoreState(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
state, err := loadCodexAppRestoreState()
if err != nil {
t.Fatal(err)
}
if state.HadProfile || state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON {
t.Fatalf("restore state = %+v, want empty snapshot when config was missing", state)
}
}
func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
withCodexAppPlatform(t, "darwin")
var openCalls int
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error {
openCalls++
return nil
},
)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
initial := "" +
`profile = "default"` + "\n" +
`model = "gpt-5.5"` + "\n" +
`model_provider = "openai"` + "\n\n" +
"[profiles.default]\n" +
`model = "gpt-5.5"` + "\n"
if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
t.Fatalf("first ConfigureWithModels returned error: %v", err)
}
manual := "" +
`profile = "manual"` + "\n" +
`model = "manual-model"` + "\n" +
`model_provider = "openai"` + "\n\n" +
"[profiles.manual]\n" +
`model = "manual-model"` + "\n"
if err := os.WriteFile(configPath, []byte(manual), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", []string{"qwen3:8b"}); err != nil {
t.Fatalf("second ConfigureWithModels returned error: %v", err)
}
if err := (&CodexApp{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
restored := string(data)
for key, want := range map[string]string{
"profile": "manual",
"model": "manual-model",
"model_provider": "openai",
} {
if got := codexRootStringValue(restored, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored)
}
}
if openCalls != 1 {
t.Fatalf("open calls = %d, want 1", openCalls)
}
}
func TestCodexAppRestoreRejectsMalformedTomlWithoutWriting(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withCodexAppPlatform(t, "darwin")
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "model = \"unterminated\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false,"had_model":false,"had_model_provider":false,"had_model_catalog_json":false}`), 0o644); err != nil {
t.Fatal(err)
}
err := (&CodexApp{}).Restore()
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("Restore error = %v, want invalid TOML", err)
}
catalogPath, pathErr := codexAppModelCatalogPath()
if pathErr != nil {
t.Fatal(pathErr)
}
for _, want := range []string{
"Restore did not complete",
"Codex config: " + configPath,
"Restore state: " + codexAppRestoreStatePath(),
"Model catalog: " + catalogPath,
"Backups: " + filepath.Join(fileutil.BackupDir(), codexAppIntegrationName),
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("Restore error missing %q:\n%v", want, err)
}
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != existing {
t.Fatalf("malformed config should be left untouched, got:\n%s", data)
}
if _, err := os.Stat(codexAppRestoreStatePath()); err != nil {
t.Fatalf("restore state should remain after failed restore: %v", err)
}
}
func TestCodexAppRestoreWithoutStateRemovesManagedRootModel(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withCodexAppPlatform(t, "darwin")
var openCalls int
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error {
openCalls++
return nil
},
)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
existing := "" +
fmt.Sprintf(`profile = %q`, codexAppProfileName) + "\n" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
codexProfileHeaderFor(codexAppProfileName) + "\n" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, key := range []string{"profile", "model", "model_provider", "model_catalog_json"} {
if got, ok := codexRootStringValueOK(content, key); ok {
t.Fatalf("root %s should be removed, got %q in:\n%s", key, got, content)
}
}
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) {
t.Fatalf("owned app sections should be removed, got:\n%s", content)
}
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
t.Fatalf("owned catalog should be removed when unused, err=%v", err)
}
if openCalls != 1 {
t.Fatalf("open calls = %d, want 1", openCalls)
}
}
func TestCodexAppRestoreDoesNotStompUserChangedRootConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withCodexAppPlatform(t, "darwin")
var openCalls int
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error {
openCalls++
return nil
},
)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
existing := "" +
`profile = "manual"` + "\n" +
`model = "gpt-5.5"` + "\n" +
`model_provider = "openai"` + "\n\n" +
codexProfileHeaderFor(codexAppProfileName) + "\n" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n\n" +
"[profiles.manual]\n" +
`model = "gpt-5.5"` + "\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"old","had_model_provider":true,"model_provider":"old-provider","had_model_catalog_json":false}`
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
for key, want := range map[string]string{
"profile": "manual",
"model": "gpt-5.5",
"model_provider": "openai",
} {
if got := codexRootStringValue(content, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
}
}
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) {
t.Fatalf("owned app sections should be removed when no longer active, got:\n%s", content)
}
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
t.Fatalf("owned catalog should be removed when unused, err=%v", err)
}
if openCalls != 1 {
t.Fatalf("open calls = %d, want 1", openCalls)
}
}
func TestCodexAppRestoreDoesNotTreatCLIProfileAsOwned(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withCodexAppPlatform(t, "darwin")
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error { return nil },
)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "" +
`profile = "ollama-launch"` + "\n" +
`model = "cli-model"` + "\n" +
`model_provider = "ollama-launch"` + "\n\n" +
"[profiles.ollama-launch]\n" +
`model = "cli-model"` + "\n" +
`openai_base_url = "http://cli.invalid/v1/"` + "\n" +
`model_provider = "ollama-launch"` + "\n\n" +
"[model_providers.ollama-launch]\n" +
`name = "CLI Ollama"` + "\n" +
`base_url = "http://cli.invalid/v1/"` + "\n" +
`wire_api = "responses"` + "\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
t.Fatal(err)
}
if err := (&CodexApp{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != existing {
t.Fatalf("CLI Codex profile should be left untouched, got:\n%s", data)
}
}
func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) {
withCodexAppPlatform(t, "darwin")
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
defer restoreConfirm()
running := true
var quitCalls, openCalls int
withCodexAppProcessHooks(t,
func() bool { return running },
func() error {
quitCalls++
running = false
return nil
},
func() error {
openCalls++
return nil
},
)
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if quitCalls != 1 || openCalls != 1 {
t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls)
}
}
func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) {
withCodexAppPlatform(t, "darwin")
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
defer restoreConfirm()
oldSleep := codexAppSleep
t.Cleanup(func() {
codexAppSleep = oldSleep
})
running := true
var quitCalls, openCalls, sleepCalls int
codexAppSleep = func(time.Duration) {
sleepCalls++
if sleepCalls == 2 {
running = false
}
}
withCodexAppProcessHooks(t,
func() bool { return running },
func() error {
quitCalls++
return nil
},
func() error {
openCalls++
return nil
},
)
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if quitCalls != 1 || openCalls != 1 {
t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls)
}
if sleepCalls == 0 {
t.Fatal("expected restart to wait for Codex to exit before reopening")
}
}
func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) {
withCodexAppPlatform(t, "windows")
var openCalls int
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error {
openCalls++
return nil
},
)
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if openCalls != 1 {
t.Fatalf("open calls = %d, want 1", openCalls)
}
}
func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
withCodexAppPlatform(t, "windows")
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
defer restoreConfirm()
running := true
var quitCalls int
withCodexAppProcessHooks(t,
func() bool { return running },
func() error {
quitCalls++
running = false
return nil
},
func() error {
t.Fatal("open app fallback should not be used")
return nil
},
)
codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" }
codexAppRunPath = func() string {
return `C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe`
}
var openedStartID, openedPath string
codexAppOpenStart = func(appID string) error {
openedStartID = appID
return nil
}
codexAppOpenPath = func(path string) error {
openedPath = path
return nil
}
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if quitCalls != 1 {
t.Fatalf("quit calls = %d, want 1", quitCalls)
}
if openedStartID != "OpenAI.Codex_2p2nqsd0c76g0!App" {
t.Fatalf("opened Start AppID = %q", openedStartID)
}
if openedPath != "" {
t.Fatalf("opened path = %q, want Start AppID path only", openedPath)
}
}
func TestCodexAppRunRejectsExtraArgs(t *testing.T) {
withCodexAppPlatform(t, "darwin")
err := (&CodexApp{}).Run("qwen3.5", []string{"--foo"})
if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") {
t.Fatalf("Run error = %v, want extra args rejection", err)
}
}
func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) {
for _, command := range []string{
"/Applications/Codex.app/Contents/MacOS/Codex",
"/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled",
`C:\Users\parth\AppData\Local\Programs\Codex\Codex.exe`,
`"C:\Users\parth\AppData\Local\Codex\app-26.429.30905\resources\codex.exe" app-server --analytics-default-enabled`,
`"C:\Users\parth\AppData\Local\openai-codex-electron\resources\codex.exe" "app-server"`,
} {
if !codexAppProcessMatches(command) {
t.Fatalf("expected command to match Codex App process: %s", command)
}
}
for _, command := range []string{
"/Applications/Codex.app/Contents/Frameworks/Codex Helper.app/Contents/MacOS/Codex Helper",
"/Applications/Codex.app/Contents/Frameworks/Electron Framework.framework/Helpers/chrome_crashpad_handler",
`"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=renderer --user-data-dir="C:\Users\parth\AppData\Roaming\Codex"`,
`"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=crashpad-handler`,
} {
if codexAppProcessMatches(command) {
t.Fatalf("expected helper command not to match Codex App process: %s", command)
}
}
}
func catalogSlugs(models []map[string]any) []string {
slugs := make([]string, 0, len(models))
for _, model := range models {
if slug, _ := model["slug"].(string); slug != "" {
slugs = append(slugs, slug)
}
}
return slugs
}
func catalogInputModalities(entry map[string]any) []string {
raw, _ := entry["input_modalities"].([]any)
modalities := make([]string, 0, len(raw))
for _, item := range raw {
if modality, _ := item.(string); modality != "" {
modalities = append(modalities, modality)
}
}
return modalities
}
func mustCodexAppModelCatalogPath(t *testing.T) string {
t.Helper()
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
return catalogPath
}
func mustWriteCodexAppTestCatalog(t *testing.T, slugs ...string) string {
t.Helper()
catalogPath := mustCodexAppModelCatalogPath(t)
if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil {
t.Fatal(err)
}
models := make([]map[string]string, 0, len(slugs))
for _, slug := range slugs {
models = append(models, map[string]string{"slug": slug})
}
data, err := json.Marshal(map[string]any{"models": models})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(catalogPath, data, 0o644); err != nil {
t.Fatal(err)
}
return catalogPath
}
+187
View File
@@ -6,6 +6,8 @@ import (
"slices"
"strings"
"testing"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
func TestCodexArgs(t *testing.T) {
@@ -69,6 +71,9 @@ func TestWriteCodexProfile(t *testing.T) {
if !strings.Contains(content, `name = "Ollama"`) {
t.Error("missing model provider name")
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("appends profile to existing file without profile", func(t *testing.T) {
@@ -114,6 +119,150 @@ func TestWriteCodexProfile(t *testing.T) {
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("replaces equivalent quoted profile table", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "" +
`profile = "default"` + "\n\n" +
`[profiles."ollama-launch"]` + "\n" +
`openai_base_url = "http://old:1234/v1/"` + "\n\n" +
`[model_providers."ollama-launch"]` + "\n" +
`name = "Old"` + "\n" +
`base_url = "http://old:1234/v1/"` + "\n\n" +
`[profiles.default]` + "\n" +
`model = "gpt-5.5"` + "\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, `profiles."ollama-launch"`) {
t.Fatalf("quoted profile table should be replaced, got:\n%s", content)
}
if strings.Contains(content, "old:1234") {
t.Fatalf("old URL was not replaced, got:\n%s", content)
}
if got := codexSectionStringValue(content, codexProfileHeader(), "model_provider"); got != codexProfileName {
t.Fatalf("profile model_provider = %q, want %q", got, codexProfileName)
}
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("rejects invalid existing toml without writing", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "profile = \n"
os.WriteFile(configPath, []byte(existing), 0o644)
err := writeCodexProfile(configPath)
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
}
})
t.Run("rejects malformed existing toml variants without writing", func(t *testing.T) {
tests := map[string]string{
"duplicate root key": "profile = \"default\"\nprofile = \"other\"\n",
"unterminated string": "model = \"gpt-5.5\n",
"bad table": "[profiles.ollama-launch\nmodel = \"llama3.2\"\n",
"duplicate table key": "[profiles.ollama-launch]\nmodel = \"a\"\nmodel = \"b\"\n",
}
for name, existing := range tests {
t.Run(name, func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
err := writeCodexProfile(configPath)
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
}
})
}
})
t.Run("backs up previous config before overwrite", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "# original-codex-backup-marker\n[profiles.default]\nmodel = \"gpt-5.5\"\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), "original-codex-backup-marker")
})
t.Run("updates equivalent quoted root keys", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "" +
`"profile" = "default"` + "\n" +
`"model" = "gpt-5.5"` + "\n" +
`"model_provider" = "openai"` + "\n\n" +
`[profiles.default]` + "\n" +
`model = "gpt-5.5"` + "\n"
os.WriteFile(configPath, []byte(existing), 0o644)
err := writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
activate: true,
setRootModelConfig: true,
model: "llama3.2",
})
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
for key, want := range map[string]string{
"profile": codexProfileName,
"model": "llama3.2",
"model_provider": codexProfileName,
} {
if got := codexRootStringValue(content, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
}
}
if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) {
t.Fatalf("quoted root keys should be rewritten once, got:\n%s", content)
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("replaces profile while preserving following sections", func(t *testing.T) {
@@ -178,6 +327,26 @@ func TestWriteCodexProfile(t *testing.T) {
t.Errorf("expected custom host in URL, got:\n%s", content)
}
})
t.Run("uses connectable host for unspecified bind address", func(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "0.0.0.0") {
t.Fatalf("config should not write bind-only host, got:\n%s", content)
}
if !strings.Contains(content, "127.0.0.1:11434/v1/") {
t.Fatalf("expected connectable loopback URL, got:\n%s", content)
}
})
}
func TestEnsureCodexConfig(t *testing.T) {
@@ -227,3 +396,21 @@ func TestEnsureCodexConfig(t *testing.T) {
}
})
}
func assertBackupContains(t *testing.T, pattern, marker string) {
t.Helper()
backups, err := filepath.Glob(pattern)
if err != nil {
t.Fatal(err)
}
for _, backupPath := range backups {
data, err := os.ReadFile(backupPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), marker) {
return
}
}
t.Fatalf("backup matching %q with marker %q not found", pattern, marker)
}
+34 -1
View File
@@ -58,6 +58,9 @@ func TestIntegrationLookup(t *testing.T) {
{"claude desktop", "claude-desktop", true, "Claude Desktop"},
{"claude desktop alias", "claude-app", true, "Claude Desktop"},
{"codex", "codex", true, "Codex"},
{"codex app", "codex-app", true, "Codex App"},
{"codex app desktop alias", "codex-desktop", true, "Codex App"},
{"codex app gui alias", "codex-gui", true, "Codex App"},
{"kimi", "kimi", true, "Kimi Code CLI"},
{"droid", "droid", true, "Droid"},
{"opencode", "opencode", true, "OpenCode"},
@@ -80,7 +83,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "kimi", "droid", "opencode", "hermes", "pool"}
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -1813,11 +1816,38 @@ func TestListIntegrationInfos(t *testing.T) {
}
want = filtered
}
if codexAppSupported() != nil {
filtered := make([]string, 0, len(want))
for _, name := range want {
if name != "codex-app" {
filtered = append(filtered, name)
}
}
want = filtered
}
if diff := compareStrings(got, want); diff != "" {
t.Fatalf("launcher integration order mismatch: %s", diff)
}
})
t.Run("prioritizes primary launcher integrations", func(t *testing.T) {
got := make([]string, 0, len(infos))
for _, info := range infos {
got = append(got, info.Name)
}
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"}
if codexAppSupported() != nil {
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"}
}
if len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
}
if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
t.Fatalf("unexpected primary launcher order: %s", diff)
}
})
t.Run("all fields populated", func(t *testing.T) {
for _, info := range infos {
if info.Name == "" {
@@ -1831,6 +1861,9 @@ func TestListIntegrationInfos(t *testing.T) {
t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
if codexAppSupported() == nil {
known["codex-app"] = false
}
if poolsideGOOS != "windows" {
known["pool"] = false
}
+22 -6
View File
@@ -285,22 +285,25 @@ Flags and extra arguments require an integration name.
Supported integrations:
claude Claude Code
cline Cline
codex-app Codex App (aliases: codex-desktop, codex-gui)
hermes Hermes Agent
openclaw OpenClaw (aliases: clawdbot, moltbot)
opencode OpenCode
codex Codex
copilot Copilot CLI (aliases: copilot-cli)
droid Droid
hermes Hermes Agent
kimi Kimi Code CLI
opencode OpenCode
openclaw OpenClaw (aliases: clawdbot, moltbot)
pi Pi
pool Pool
cline Cline
vscode VS Code (aliases: code)
Examples:
ollama launch
ollama launch claude
ollama launch claude --model <model>
ollama launch codex-app
ollama launch codex-app --restore
ollama launch hermes
ollama launch droid --config (does not auto-launch)
ollama launch codex -- -p myprofile (pass extra args to integration)
@@ -769,7 +772,13 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
return nil
}
if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) {
// current is the live managed app config; target may come from saved launch
// state. Rewrite when the live config is missing or has drifted so the app
// config converges with the model which launch is about to use.
liveConfigMissing := current == ""
liveConfigDrifted := current != "" && target != current
configured := false
if needsConfigure || req.ModelOverride != "" || liveConfigMissing || liveConfigDrifted || !savedMatchesModels(saved, []string{target}) {
configureModels, err := c.managedSingleConfigureModels(ctx, managed, target)
if err != nil {
return err
@@ -782,6 +791,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
return err
}
}
configured = true
}
if !managedIntegrationOnboarded(saved, managed) {
@@ -793,6 +803,12 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
}
}
if configured {
if !printConfigurationSuccess(managed) {
printRestoreHint(managed)
}
}
if req.ConfigureOnly {
return nil
}
@@ -941,7 +957,7 @@ func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, run
}
}
if needsConfigure {
if needsConfigure && req.ModelOverride == "" {
selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness)
if err != nil {
return "", false, err
+180 -10
View File
@@ -68,15 +68,18 @@ func (r *launcherRestorableRunner) RestoreSuccessMessage() string {
}
type launcherManagedRunner struct {
paths []string
currentModel string
configured []string
ranModel string
onboarded bool
onboardCalls int
onboardingComplete bool
refreshCalls int
refreshErr error
paths []string
currentModel string
configured []string
ranModel string
onboarded bool
onboardCalls int
onboardingComplete bool
refreshCalls int
refreshErr error
restoreHint string
configSuccessMessage string
skipModelReadiness bool
}
func (r *launcherManagedRunner) Run(model string, args []string) error {
@@ -110,6 +113,14 @@ func (r *launcherManagedRunner) RefreshRuntimeAfterConfigure() error {
return r.refreshErr
}
func (r *launcherManagedRunner) RestoreHint() string { return r.restoreHint }
func (r *launcherManagedRunner) ConfigurationSuccessMessage() string {
return r.configSuccessMessage
}
func (r *launcherManagedRunner) SkipModelReadiness() bool { return r.skipModelReadiness }
type launcherHeadlessManagedRunner struct {
launcherManagedRunner
}
@@ -480,6 +491,116 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(t *te
}
}
func TestLaunchIntegration_ManagedSingleIntegrationPrintsConfigurationSuccessAfterConfigure(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
runner := &launcherManagedRunner{
configSuccessMessage: "configured successfully\nrestore via success message",
restoreHint: "run restore command",
skipModelReadiness: true,
}
withIntegrationOverride(t, "stubmanaged", runner)
stderr := captureStderr(t, func() {
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "stubmanaged",
ModelOverride: "gemma4",
ForceConfigure: true,
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
})
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("configured models mismatch: %s", diff)
}
if !strings.Contains(stderr, "configured successfully") {
t.Fatalf("expected configuration success in stderr, got %q", stderr)
}
if !strings.Contains(stderr, "restore via success message") {
t.Fatalf("expected restore guidance in configuration success, got %q", stderr)
}
if strings.Contains(stderr, "run restore command") {
t.Fatalf("restore hint should not print separately after configure, got %q", stderr)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationDoesNotPrintRestoreHintWhenUnchanged(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
runner := &launcherManagedRunner{
currentModel: "gemma4",
onboardingComplete: true,
configSuccessMessage: "configured successfully",
restoreHint: "run restore command",
skipModelReadiness: true,
}
withIntegrationOverride(t, "stubmanaged", runner)
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
t.Fatalf("failed to save managed integration config: %v", err)
}
if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil {
t.Fatalf("failed to mark integration onboarded: %v", err)
}
stderr := captureStderr(t, func() {
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
})
if len(runner.configured) != 0 {
t.Fatalf("expected Configure to be skipped when saved matches, got %v", runner.configured)
}
if strings.Contains(stderr, "configured successfully") {
t.Fatalf("configuration success should not print when config is unchanged, got %q", stderr)
}
if strings.Contains(stderr, "run restore command") {
t.Fatalf("restore hint should not print when config is unchanged, got %q", stderr)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationForceConfigureUsesModelOverride(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
runner := &launcherManagedRunner{
paths: nil,
skipModelReadiness: true,
}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
return "", fmt.Errorf("selector should not run with an explicit model override")
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "stubmanaged",
ModelOverride: "gemma4",
ForceConfigure: true,
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("configured models mismatch: %s", diff)
}
if runner.ranModel != "" {
t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -505,7 +626,9 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(
t.Fatalf("failed to save managed integration config: %v", err)
}
runner := &launcherManagedRunner{}
runner := &launcherManagedRunner{
currentModel: "gemma4",
}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
@@ -532,6 +655,53 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(
}
}
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedMatchesButLiveConfigMissing(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
t.Fatalf("failed to save managed integration config: %v", err)
}
runner := &launcherManagedRunner{}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
t.Fatal("selector should not be called when saved model is usable")
return "", nil
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return true, nil
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("expected Configure to rewrite missing live config: %s", diff)
}
if runner.refreshCalls != 1 {
t.Fatalf("expected runtime refresh once after rewrite, got %d", runner.refreshCalls)
}
if runner.ranModel != "gemma4" {
t.Fatalf("expected launch to run saved model, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedDiffers(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
+13 -1
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"claude", "openclaw", "hermes", "opencode", "codex", "copilot", "droid", "pi", "pool"}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -87,6 +87,18 @@ var integrationSpecs = []*IntegrationSpec{
Command: []string{"npm", "install", "-g", "@openai/codex"},
},
},
{
Name: "codex-app",
Runner: &CodexApp{},
Aliases: []string{"codex-desktop", "codex-gui"},
Description: "OpenAI's desktop coding agent",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
return codexAppInstalled()
},
URL: "https://developers.openai.com/codex/app/overview/",
},
},
{
Name: "kimi",
Runner: &Kimi{},
+21 -1
View File
@@ -29,6 +29,13 @@ func launcherTestState() *launch.LauncherState {
Selectable: true,
Changeable: true,
},
"codex-app": {
Name: "codex-app",
DisplayName: "Codex App",
Description: "OpenAI's desktop coding agent",
Selectable: true,
Changeable: true,
},
"openclaw": {
Name: "openclaw",
DisplayName: "OpenClaw",
@@ -122,12 +129,25 @@ func expectedExpandedSequence(state *launch.LauncherState) []string {
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
state := launcherTestState()
menu := newModel(state)
wantPrefix := []string{"run", "claude", "codex-app", "hermes", "openclaw"}
if findMenuCursorByIntegration(menu.items, "codex-app") == -1 {
wantPrefix = []string{"run", "claude", "hermes", "openclaw", "opencode"}
}
if got := integrationSequence(menu.items); len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d menu items, got %v", len(wantPrefix), got)
} else if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
t.Fatalf("unexpected primary TUI order: %s", diff)
}
view := menu.View()
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch OpenClaw", "Launch Hermes Agent", "More..."} {
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
if !strings.Contains(view, want) {
t.Fatalf("expected menu view to contain %q\n%s", want, view)
}
}
if findMenuCursorByIntegration(menu.items, "codex-app") != -1 && !strings.Contains(view, "Launch Codex App") {
t.Fatalf("expected menu view to contain Codex App\n%s", view)
}
if strings.Contains(view, "Launch Claude Desktop") {
t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view)
}
+1
View File
@@ -124,6 +124,7 @@
"pages": [
"/integrations/claude-code",
"/integrations/codex",
"/integrations/codex-app",
"/integrations/copilot-cli",
"/integrations/opencode",
"/integrations/droid",
+39
View File
@@ -0,0 +1,39 @@
---
title: Codex App
---
## Install
Install the [Codex App](https://developers.openai.com/codex/app/overview/) for macOS or Windows.
## Usage with Ollama
<Note>Codex works best with a larger context window. It is recommended to use a context window of at least 64k tokens.</Note>
### Quick setup
```shell
ollama launch codex-app
```
To choose a model:
```shell
ollama launch codex-app --model gpt-oss:120b
```
To configure without launching:
```shell
ollama launch codex-app --config
```
To restore your usual Codex profile:
```shell
ollama launch codex-app --restore
```
## Backups
Before overwriting Codex App config files, Ollama Launch saves backups under `~/.ollama/backup/codex-app/`. On Windows, `~` resolves to your user profile directory.
+2 -2
View File
@@ -1,11 +1,11 @@
---
title: Codex
title: Codex CLI
---
## Install
Install the [Codex CLI](https://developers.openai.com/codex/cli/):
Install the [Codex CLI](https://developers.openai.com/codex/cli/). For the desktop app, see [Codex App](/integrations/codex-app).
```
npm install -g @openai/codex
+2 -1
View File
@@ -9,7 +9,8 @@ Ollama integrates with a wide range of tools.
Coding assistants that can read, modify, and execute code in your projects.
- [Claude Code](/integrations/claude-code)
- [Codex](/integrations/codex)
- [Codex CLI](/integrations/codex)
- [Codex App](/integrations/codex-app)
- [Copilot CLI](/integrations/copilot-cli)
- [OpenCode](/integrations/opencode)
- [Droid](/integrations/droid)
+1 -1
View File
@@ -30,6 +30,7 @@ require (
github.com/mattn/go-runewidth v0.0.16
github.com/nlpodyssey/gopickle v0.3.0
github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c
github.com/pelletier/go-toml/v2 v2.2.2
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/tkrajina/typescriptify-golang-structs v0.2.0
github.com/tree-sitter/go-tree-sitter v0.25.0
@@ -95,7 +96,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect