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 { func (db *database) setSettings(s Settings) error {
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView)) lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
validLaunchView := map[string]struct{}{ validLaunchView := map[string]struct{}{
"launch": {}, "launch": {},
"openclaw": {}, "openclaw": {},
"claude": {}, "claude": {},
"hermes": {}, "hermes": {},
"codex": {}, "codex": {},
"copilot": {}, "codex-app": {},
"opencode": {}, "copilot": {},
"droid": {}, "opencode": {},
"pi": {}, "droid": {},
"pi": {},
} }
if lastHomeView != "chat" { if lastHomeView != "chat" {
if _, ok := validLaunchView[lastHomeView]; !ok { 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) { t.Run("window size", func(t *testing.T) {
if err := s.SetWindowSize(1024, 768); err != nil { if err := s.SetWindowSize(1024, 768); err != nil {
t.Fatal(err) 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", iconClassName: "h-7 w-7",
}, },
{ {
id: "openclaw", id: "codex-app",
name: "OpenClaw", name: "Codex App",
command: "ollama launch openclaw", command: "ollama launch codex-app",
description: "Personal AI with 100+ skills", description: "OpenAI's desktop coding agent",
icon: "/launch-icons/openclaw.svg", icon: "/launch-icons/codex-app.png",
iconClassName: "h-full w-full",
}, },
{ {
id: "hermes", id: "hermes",
@@ -36,6 +37,13 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
icon: "/launch-icons/hermes-agent.svg", icon: "/launch-icons/hermes-agent.svg",
iconClassName: "h-7 w-7", 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", id: "opencode",
name: "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 { func launcherActionExitsLoop(integration string) bool {
switch integration { switch integration {
case "vscode": case "codex-app", "vscode":
return true return true
default: default:
return false return false
+1 -1
View File
@@ -249,7 +249,7 @@ func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
cmd := &cobra.Command{} cmd := &cobra.Command{}
cmd.SetContext(context.Background()) 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{ continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{
resolveRunModel: unexpectedRunModelResolution(t), resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
+458 -41
View File
@@ -7,7 +7,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/envconfig"
"github.com/pelletier/go-toml/v2"
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
) )
@@ -16,7 +18,15 @@ type Codex struct{}
func (c *Codex) String() string { return "Codex" } 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 { func (c *Codex) args(model string, extra []string) []string {
args := []string{"--profile", codexProfileName} 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 // ensureCodexConfig writes a [profiles.ollama-launch] section to ~/.codex/config.toml
// with openai_base_url pointing to the local Ollama server. // with openai_base_url pointing to the local Ollama server.
func ensureCodexConfig() error { func ensureCodexConfig() error {
home, err := os.UserHomeDir() configPath, err := codexConfigPath()
if err != nil { if err != nil {
return err return err
} }
codexDir := filepath.Join(home, ".codex") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
if err := os.MkdirAll(codexDir, 0o755); err != nil {
return err return err
} }
configPath := filepath.Join(codexDir, "config.toml")
return writeCodexProfile(configPath) 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 // writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile
// and model provider sections with the correct base URL. // and model provider sections with the correct base URL.
func writeCodexProfile(configPath string) error { 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 { sections := []struct {
header string header string
lines []string lines []string
}{ }{
{ {
header: fmt.Sprintf("[profiles.%s]", codexProfileName), header: profileHeader,
lines: []string{ lines: profileLines,
fmt.Sprintf("openai_base_url = %q", baseURL),
`forced_login_method = "api"`,
fmt.Sprintf("model_provider = %q", codexProfileName),
},
}, },
{ {
header: fmt.Sprintf("[model_providers.%s]", codexProfileName), header: providerHeader,
lines: []string{ lines: []string{
`name = "Ollama"`, fmt.Sprintf("name = %q", codexProviderName),
fmt.Sprintf("base_url = %q", baseURL), fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
}, },
}, },
} }
content, readErr := os.ReadFile(configPath) if opts.activate {
text := "" text = codexSetRootStringValue(text, codexRootProfileKey, profileName)
if readErr == nil {
text = string(content)
} }
if opts.setRootModelConfig {
for _, s := range sections { if model != "" {
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n" text = codexSetRootStringValue(text, codexRootModelKey, model)
}
if idx := strings.Index(text, s.header); idx >= 0 { text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName)
// Replace the existing section up to the next section header. if modelCatalogPath != "" {
rest := text[idx+len(s.header):] text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
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
} }
} }
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 { 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" "slices"
"strings" "strings"
"testing" "testing"
"github.com/ollama/ollama/cmd/internal/fileutil"
) )
func TestCodexArgs(t *testing.T) { func TestCodexArgs(t *testing.T) {
@@ -69,6 +71,9 @@ func TestWriteCodexProfile(t *testing.T) {
if !strings.Contains(content, `name = "Ollama"`) { if !strings.Contains(content, `name = "Ollama"`) {
t.Error("missing model provider name") 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) { 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 { 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]")) 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) { 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.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) { 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", "claude-desktop", true, "Claude Desktop"},
{"claude desktop alias", "claude-app", true, "Claude Desktop"}, {"claude desktop alias", "claude-app", true, "Claude Desktop"},
{"codex", "codex", true, "Codex"}, {"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"}, {"kimi", "kimi", true, "Kimi Code CLI"},
{"droid", "droid", true, "Droid"}, {"droid", "droid", true, "Droid"},
{"opencode", "opencode", true, "OpenCode"}, {"opencode", "opencode", true, "OpenCode"},
@@ -80,7 +83,7 @@ func TestIntegrationLookup(t *testing.T) {
} }
func TestIntegrationRegistry(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 { for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
r, ok := integrations[name] r, ok := integrations[name]
@@ -1813,11 +1816,38 @@ func TestListIntegrationInfos(t *testing.T) {
} }
want = filtered 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 != "" { if diff := compareStrings(got, want); diff != "" {
t.Fatalf("launcher integration order mismatch: %s", 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) { t.Run("all fields populated", func(t *testing.T) {
for _, info := range infos { for _, info := range infos {
if info.Name == "" { if info.Name == "" {
@@ -1831,6 +1861,9 @@ func TestListIntegrationInfos(t *testing.T) {
t.Run("includes known integrations", func(t *testing.T) { t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "codex": false, "opencode": false} known := map[string]bool{"claude": false, "codex": false, "opencode": false}
if codexAppSupported() == nil {
known["codex-app"] = false
}
if poolsideGOOS != "windows" { if poolsideGOOS != "windows" {
known["pool"] = false known["pool"] = false
} }
+22 -6
View File
@@ -285,22 +285,25 @@ Flags and extra arguments require an integration name.
Supported integrations: Supported integrations:
claude Claude Code 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 codex Codex
copilot Copilot CLI (aliases: copilot-cli) copilot Copilot CLI (aliases: copilot-cli)
droid Droid droid Droid
hermes Hermes Agent
kimi Kimi Code CLI kimi Kimi Code CLI
opencode OpenCode
openclaw OpenClaw (aliases: clawdbot, moltbot)
pi Pi pi Pi
pool Pool pool Pool
cline Cline
vscode VS Code (aliases: code) vscode VS Code (aliases: code)
Examples: Examples:
ollama launch ollama launch
ollama launch claude ollama launch claude
ollama launch claude --model <model> ollama launch claude --model <model>
ollama launch codex-app
ollama launch codex-app --restore
ollama launch hermes ollama launch hermes
ollama launch droid --config (does not auto-launch) ollama launch droid --config (does not auto-launch)
ollama launch codex -- -p myprofile (pass extra args to integration) ollama launch codex -- -p myprofile (pass extra args to integration)
@@ -769,7 +772,13 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
return nil 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) configureModels, err := c.managedSingleConfigureModels(ctx, managed, target)
if err != nil { if err != nil {
return err return err
@@ -782,6 +791,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
return err return err
} }
} }
configured = true
} }
if !managedIntegrationOnboarded(saved, managed) { 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 { if req.ConfigureOnly {
return nil 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) selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness)
if err != nil { if err != nil {
return "", false, err return "", false, err
+180 -10
View File
@@ -68,15 +68,18 @@ func (r *launcherRestorableRunner) RestoreSuccessMessage() string {
} }
type launcherManagedRunner struct { type launcherManagedRunner struct {
paths []string paths []string
currentModel string currentModel string
configured []string configured []string
ranModel string ranModel string
onboarded bool onboarded bool
onboardCalls int onboardCalls int
onboardingComplete bool onboardingComplete bool
refreshCalls int refreshCalls int
refreshErr error refreshErr error
restoreHint string
configSuccessMessage string
skipModelReadiness bool
} }
func (r *launcherManagedRunner) Run(model string, args []string) error { func (r *launcherManagedRunner) Run(model string, args []string) error {
@@ -110,6 +113,14 @@ func (r *launcherManagedRunner) RefreshRuntimeAfterConfigure() error {
return r.refreshErr 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 { type launcherHeadlessManagedRunner struct {
launcherManagedRunner 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) { func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir) setLaunchTestHome(t, tmpDir)
@@ -505,7 +626,9 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(
t.Fatalf("failed to save managed integration config: %v", err) t.Fatalf("failed to save managed integration config: %v", err)
} }
runner := &launcherManagedRunner{} runner := &launcherManagedRunner{
currentModel: "gemma4",
}
withIntegrationOverride(t, "stubmanaged", runner) withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { 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) { func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedDiffers(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir) setLaunchTestHome(t, tmpDir)
+13 -1
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string 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{ var integrationSpecs = []*IntegrationSpec{
{ {
@@ -87,6 +87,18 @@ var integrationSpecs = []*IntegrationSpec{
Command: []string{"npm", "install", "-g", "@openai/codex"}, 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", Name: "kimi",
Runner: &Kimi{}, Runner: &Kimi{},
+21 -1
View File
@@ -29,6 +29,13 @@ func launcherTestState() *launch.LauncherState {
Selectable: true, Selectable: true,
Changeable: true, Changeable: true,
}, },
"codex-app": {
Name: "codex-app",
DisplayName: "Codex App",
Description: "OpenAI's desktop coding agent",
Selectable: true,
Changeable: true,
},
"openclaw": { "openclaw": {
Name: "openclaw", Name: "openclaw",
DisplayName: "OpenClaw", DisplayName: "OpenClaw",
@@ -122,12 +129,25 @@ func expectedExpandedSequence(state *launch.LauncherState) []string {
func TestMenuRendersPinnedItemsAndMore(t *testing.T) { func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
state := launcherTestState() state := launcherTestState()
menu := newModel(state) 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() 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) { if !strings.Contains(view, want) {
t.Fatalf("expected menu view to contain %q\n%s", want, view) 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") { if strings.Contains(view, "Launch Claude Desktop") {
t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view) t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view)
} }
+1
View File
@@ -124,6 +124,7 @@
"pages": [ "pages": [
"/integrations/claude-code", "/integrations/claude-code",
"/integrations/codex", "/integrations/codex",
"/integrations/codex-app",
"/integrations/copilot-cli", "/integrations/copilot-cli",
"/integrations/opencode", "/integrations/opencode",
"/integrations/droid", "/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
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 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. Coding assistants that can read, modify, and execute code in your projects.
- [Claude Code](/integrations/claude-code) - [Claude Code](/integrations/claude-code)
- [Codex](/integrations/codex) - [Codex CLI](/integrations/codex)
- [Codex App](/integrations/codex-app)
- [Copilot CLI](/integrations/copilot-cli) - [Copilot CLI](/integrations/copilot-cli)
- [OpenCode](/integrations/opencode) - [OpenCode](/integrations/opencode)
- [Droid](/integrations/droid) - [Droid](/integrations/droid)
+1 -1
View File
@@ -30,6 +30,7 @@ require (
github.com/mattn/go-runewidth v0.0.16 github.com/mattn/go-runewidth v0.0.16
github.com/nlpodyssey/gopickle v0.3.0 github.com/nlpodyssey/gopickle v0.3.0
github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/tkrajina/typescriptify-golang-structs v0.2.0 github.com/tkrajina/typescriptify-golang-structs v0.2.0
github.com/tree-sitter/go-tree-sitter v0.25.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/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect