launch: isolate Codex launch configuration (#16437)

This commit is contained in:
Parth Sareen
2026-06-02 12:10:46 -07:00
committed by GitHub
parent c34a79a373
commit f57d111754
6 changed files with 719 additions and 334 deletions

View File

@@ -24,6 +24,7 @@ const (
codexProfileName = "ollama-launch"
codexProviderName = "Ollama"
codexFallbackContextWindow = 128_000
codexRestoreSuccess = "Codex launch configuration removed."
codexRootProfileKey = "profile"
codexRootModelKey = "model"
@@ -31,16 +32,20 @@ const (
codexRootModelCatalogJSONKey = "model_catalog_json"
)
func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
args := []string{}
if modelCatalogPath != "" {
args = append(args, "-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
func (c *Codex) args(model, modelCatalogPath string, extra []string) ([]string, error) {
if err := codexValidateExtraArgs(extra); err != nil {
return nil, err
}
args := []string{"--profile", codexProfileName}
for _, override := range codexManagedConfigOverrides(modelCatalogPath) {
args = append(args, "-c", override)
}
if model != "" {
args = append(args, "-m", model)
}
args = append(args, extra...)
return args
return args, nil
}
func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
@@ -57,7 +62,12 @@ func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
return fmt.Errorf("failed to configure codex: %w", err)
}
cmd := exec.Command("codex", c.args(model, catalogPath, args)...)
codexArgs, err := c.args(model, catalogPath, args)
if err != nil {
return fmt.Errorf("failed to configure codex: %w", err)
}
cmd := exec.Command("codex", codexArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -67,8 +77,134 @@ func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
return cmd.Run()
}
// ensureCodexConfig writes Codex root config and model catalog so Codex uses the
// local Ollama server and has model metadata available.
func (c *Codex) Restore() error {
configPath, err := codexConfigPath()
if err != nil {
return err
}
if err := removeCodexProfileConfig(); err != nil {
return codexRestoreFailure(configPath, err)
}
if err := removeCodexModelCatalogIfUnused(configPath); err != nil {
return codexRestoreFailure(configPath, err)
}
return nil
}
func (c *Codex) RestoreSuccessMessage() string {
return codexRestoreSuccess
}
func (c *Codex) SkipRestoreInstallCheck() bool {
return true
}
func codexRestoreFailure(configPath string, err error) error {
return fmt.Errorf("restore Codex config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n CLI profile: %s\n CLI model catalog: %s\n Backups: %s",
err,
configPath,
codexProfileConfigPathForConfig(configPath),
codexModelCatalogPathForConfig(configPath),
fileutil.BackupDir(),
)
}
func removeCodexProfileConfig() error {
profilePath, err := codexProfileConfigPath()
if err != nil {
return err
}
return removeCodexFile(profilePath)
}
func removeCodexModelCatalogIfUnused(configPath string) error {
catalogPath := codexModelCatalogPathForConfig(configPath)
data, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
config, parseErr := codexParseConfig(string(data))
if parseErr != nil {
return parseErr
}
if config.RootString(codexRootModelCatalogJSONKey) == catalogPath {
return nil
}
}
return removeCodexFile(catalogPath)
}
func removeCodexFile(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func codexValidateExtraArgs(args []string) error {
for i, arg := range args {
switch {
case arg == "-p", strings.HasPrefix(arg, "-p"):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
case arg == "--profile", strings.HasPrefix(arg, "--profile="):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
case arg == "-m", strings.HasPrefix(arg, "-m"):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
case arg == "--model", strings.HasPrefix(arg, "--model="):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
case arg == "-c", arg == "--config":
if i+1 < len(args) && codexConfigOverrideConflicts(args[i+1]) {
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", args[i+1])
}
case strings.HasPrefix(arg, "-c") && len(arg) > len("-c"):
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "-c")) {
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
}
case strings.HasPrefix(arg, "--config="):
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "--config=")) {
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
}
}
}
return nil
}
func codexManagedConfigOverrides(modelCatalogPath string) []string {
overrides := []string{
fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
}
if modelCatalogPath != "" {
overrides = append(overrides, fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
return overrides
}
func codexConfigOverrideConflicts(value string) bool {
key, _, ok := strings.Cut(strings.TrimSpace(value), "=")
if !ok {
return false
}
key = strings.TrimSpace(key)
key = strings.Trim(key, `"'`)
switch {
case key == codexRootProfileKey,
key == codexRootModelKey,
key == codexRootModelProviderKey,
key == codexRootModelCatalogJSONKey:
return true
case strings.HasPrefix(key, "model_providers."):
return true
}
return false
}
// ensureCodexConfig writes a Codex profile file and model catalog so Codex uses
// the local Ollama server without changing app-visible root config.
func ensureCodexConfig(modelName string, models []LaunchModel) error {
configPath, err := codexConfigPath()
if err != nil {
@@ -85,7 +221,8 @@ func ensureCodexConfig(modelName string, models []LaunchModel) error {
return err
}
return writeCodexConfig(configPath, modelName, catalogPath)
profilePath := codexProfileConfigPathForConfig(configPath)
return writeCodexProfileConfig(profilePath, modelName, catalogPath)
}
func codexConfigPath() (string, error) {
@@ -108,49 +245,60 @@ func codexModelCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), "model.json")
}
// writeCodexConfig ensures ~/.codex/config.toml uses Ollama as the root model
// provider without relying on Codex's legacy profile config.
func writeCodexConfig(configPath, model, modelCatalogPath string) error {
func codexProfileConfigPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexProfileConfigPathForConfig(configPath), nil
}
func codexProfileConfigPathForConfig(configPath string) string {
return codexNamedProfileConfigPathForConfig(configPath, codexProfileName)
}
func codexNamedProfileConfigPathForConfig(configPath, profileName string) string {
return filepath.Join(filepath.Dir(configPath), profileName+".config.toml")
}
// writeCodexProfileConfig ensures ~/.codex/ollama-launch.config.toml selects
// the Ollama provider and catalog for CLI launches without changing root config.
func writeCodexProfileConfig(profilePath, model, modelCatalogPath string) error {
return writeCodexNamedProfileConfig(profilePath, codexProfileName, model, modelCatalogPath, "")
}
func writeCodexNamedProfileConfig(profilePath, profileName, model, modelCatalogPath, backupSubdir string) error {
baseURL := codexBaseURL()
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
} else if !os.IsNotExist(readErr) {
return readErr
}
if _, err := codexParseConfig(text); err != nil {
return err
}
text = codexRemoveRootValue(text, codexRootProfileKey)
text = codexRemoveSection(text, codexProfileHeaderFor(codexProfileName))
var lines []string
if strings.TrimSpace(model) != "" {
text = codexSetRootStringValue(text, codexRootModelKey, model)
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
}
text = codexSetRootStringValue(text, codexRootModelProviderKey, codexProfileName)
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName))
if strings.TrimSpace(modelCatalogPath) != "" {
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
text = codexUpsertSection(text, codexProviderHeaderFor(codexProfileName), []string{
text := strings.Join(lines, "\n") + "\n\n"
text += strings.Join([]string{
codexProviderHeaderFor(profileName),
fmt.Sprintf("name = %q", codexProviderName),
fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
})
"",
}, "\n")
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
if err := codexValidateConfigTextForOllama(parsed, model, modelCatalogPath, baseURL); err != nil {
if err := codexValidateProfileConfigText(parsed, profileName, model, modelCatalogPath, baseURL); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, []byte(text), "")
return fileutil.WriteWithBackup(profilePath, []byte(text), backupSubdir)
}
func codexBaseURL() string {
@@ -173,26 +321,28 @@ func codexProviderHeaderFor(profileName string) string {
return fmt.Sprintf("[model_providers.%s]", profileName)
}
func codexValidateConfigTextForOllama(config codexParsedConfig, model, modelCatalogPath, baseURL string) error {
if got, ok := config.RootStringOK(codexRootProfileKey); ok {
return fmt.Errorf("generated Codex config still contains legacy profile = %q", got)
}
if config.Exists("profiles", codexProfileName) {
return fmt.Errorf("generated Codex config still contains legacy profiles.%s table", codexProfileName)
func codexValidateProfileConfigText(config codexParsedConfig, profileName, model, modelCatalogPath, baseURL string) error {
if config.Exists("profiles", profileName) {
return fmt.Errorf("generated Codex config still contains legacy profiles.%s table", profileName)
}
for _, check := range []struct {
path []string
want string
}{
{[]string{codexRootModelProviderKey}, codexProfileName},
{[]string{"model_providers", codexProfileName, "name"}, codexProviderName},
{[]string{"model_providers", codexProfileName, "base_url"}, baseURL},
{[]string{"model_providers", codexProfileName, "wire_api"}, "responses"},
{[]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 got, ok := config.RootStringOK(codexRootProfileKey); ok {
return fmt.Errorf("generated Codex config still contains legacy profile = %q", got)
}
if got := config.RootString(codexRootModelProviderKey); got != profileName {
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
}
if model != "" {
if got := config.RootString(codexRootModelKey); got != model {
return fmt.Errorf("generated Codex config missing model = %q", model)
@@ -586,10 +736,10 @@ func checkCodexVersion() error {
}
version := "v" + fields[len(fields)-1]
minVersion := "v0.81.0"
minVersion := "v0.134.0"
if semver.Compare(version, minVersion) < 0 {
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.81.0")
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.134.0")
}
return nil

View File

@@ -259,7 +259,7 @@ func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error {
if len(args) > 0 {
return fmt.Errorf("codex-app does not accept extra arguments")
}
return codexAppLaunchOrRestart("Restart Codex to use Ollama?")
return codexAppLaunchOrRestart("Restart Codex to use Ollama?", nil)
}
func (c *CodexApp) Restore() error {
@@ -277,7 +277,13 @@ func (c *CodexApp) Restore() error {
if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
if err := removeCodexAppProfileConfig(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := codexAppRemoveOwnedCatalog(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil)
}
return codexAppRestoreFailure(configPath, err)
}
@@ -304,13 +310,16 @@ func (c *CodexApp) Restore() error {
if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppProfileConfig(); 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?")
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil)
}
func codexAppRestoreFailure(configPath string, err error) error {
@@ -354,6 +363,18 @@ func codexAppModelCatalogPath() (string, error) {
return codexAppModelCatalogPathForConfig(configPath), nil
}
func codexAppProfileConfigPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexAppProfileConfigPathForConfig(configPath), nil
}
func codexAppProfileConfigPathForConfig(configPath string) string {
return codexNamedProfileConfigPathForConfig(configPath, codexAppProfileName)
}
func codexAppModelCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename)
}
@@ -383,14 +404,20 @@ func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
seen := make(map[string]bool, len(models)+1)
out := make([]LaunchModel, 0, len(models)+1)
add := func(model LaunchModel) {
if model.Name == "" || seen[model.Name] {
model.Name = strings.TrimSpace(model.Name)
if model.Name == "" {
return
}
seen[model.Name] = true
key := codexAppCatalogModelKey(model.Name)
if seen[key] {
return
}
seen[key] = true
out = append(out, model)
}
if model, ok := findLaunchModel(models, primary); ok {
model.Name = primary
add(model)
} else {
add(fallbackLaunchModel(primary))
@@ -401,6 +428,10 @@ func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
return out
}
func codexAppCatalogModelKey(name string) string {
return strings.TrimSuffix(name, ":latest")
}
type codexAppModelMetadata struct {
contextWindow int
inputModalities []string
@@ -579,13 +610,13 @@ func codexAppLocalAppData() (string, error) {
return filepath.Join(home, "AppData", "Local"), nil
}
func codexAppLaunchOrRestart(prompt string) error {
func codexAppLaunchOrRestart(prompt string, launchArgs []string) error {
if !codexAppIsRunning() {
return codexAppOpenApp()
return codexAppOpenApp(launchArgs)
}
restartAppID := ""
restartAppPath := ""
if codexAppGOOS == "windows" {
if len(launchArgs) == 0 && codexAppGOOS == "windows" {
restartAppID = codexAppStartID()
if restartAppID == "" {
restartAppPath = codexAppRunPath()
@@ -626,7 +657,7 @@ func codexAppLaunchOrRestart(prompt string) error {
if restartAppPath != "" {
return codexAppOpenPath(restartAppPath)
}
return codexAppOpenApp()
return codexAppOpenApp(launchArgs)
}
func codexAppForceQuitSupported() bool {
@@ -659,7 +690,15 @@ func waitForCodexAppCondition(timeout time.Duration, done func() bool) error {
return fmt.Errorf("Codex did not quit; quit it manually and re-run the command")
}
func defaultCodexAppOpenApp() error {
func defaultCodexAppOpenApp(args []string) error {
if len(args) > 0 {
cmd := exec.Command("codex", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "OPENAI_API_KEY=ollama")
return cmd.Run()
}
switch codexAppGOOS {
case "windows":
if path := codexAppAppPath(); path != "" {
@@ -922,6 +961,10 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) error {
if codexAppRootReferencesCatalog(text) {
return nil
}
return codexAppRemoveOwnedCatalog()
}
func codexAppRemoveOwnedCatalog() error {
if catalogPath, err := codexAppModelCatalogPath(); err == nil {
if err := os.Remove(catalogPath); err != nil && !os.IsNotExist(err) {
return err
@@ -932,6 +975,17 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) error {
return nil
}
func removeCodexAppProfileConfig() error {
profilePath, err := codexAppProfileConfigPath()
if err != nil {
return err
}
if err := os.Remove(profilePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func codexAppRemoveOwnedRootValues(text string) string {
config, err := codexParseConfig(text)
if err != nil {
@@ -1022,7 +1076,11 @@ func saveCodexAppRestoreState(configPath string) error {
return err
}
return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText))
state := codexAppRestoreStateFromText(configText)
if codexAppRootStillManaged(configText) {
state = codexAppRestoreState{}
}
return writeCodexAppRestoreState(state)
}
func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) {

View File

@@ -39,7 +39,7 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e
codexAppIsRunning = isRunning
codexAppHasWindow = isRunning
codexAppQuitApp = quit
codexAppOpenApp = open
codexAppOpenApp = func([]string) error { return open() }
t.Cleanup(func() {
codexAppIsRunning = oldIsRunning
codexAppQuitApp = oldQuit
@@ -180,15 +180,15 @@ func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(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{
`model = "llama3.2"`,
@@ -291,6 +291,98 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`)
}
func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
appModels := testLaunchModels("llama3.2", "gemma4")
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", appModels); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
appCatalogPath := mustCodexAppModelCatalogPath(t)
if err := ensureCodexConfig("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil {
t.Fatalf("ensureCodexConfig returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if got, ok := codexRootStringValueOK(content, "profile"); ok {
t.Fatalf("CLI config refresh should not activate a root profile, got %q in:\n%s", got, content)
}
for key, want := range map[string]string{
"model": "llama3.2",
"model_provider": codexAppProfileName,
"model_catalog_json": appCatalogPath,
} {
if got := codexRootStringValue(content, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
}
}
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" {
t.Fatalf("app provider base URL = %q", got)
}
cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json")
if strings.Contains(content, codexProfileHeader()) {
t.Fatalf("CLI legacy profile section should not be generated, got:\n%s", content)
}
if strings.Contains(content, codexProviderHeader()) {
t.Fatalf("CLI provider should be isolated from app root config, got:\n%s", content)
}
cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
cliProfileData, err := os.ReadFile(cliProfilePath)
if err != nil {
t.Fatalf("CLI profile config not created: %v", err)
}
cliProfile := string(cliProfileData)
for key, want := range map[string]string{
"model": "qwen3:8b",
"model_provider": codexProfileName,
"model_catalog_json": cliCatalogPath,
} {
if got := codexRootStringValue(cliProfile, key); got != want {
t.Fatalf("CLI profile %s = %q, want %q in:\n%s", key, got, want, cliProfile)
}
}
if got := codexSectionStringValue(cliProfile, codexProviderHeader(), "base_url"); got != "http://127.0.0.1:9999/v1/" {
t.Fatalf("CLI profile provider base URL = %q", got)
}
appCatalogData, err := os.ReadFile(appCatalogPath)
if err != nil {
t.Fatal(err)
}
var appCatalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(appCatalogData, &appCatalog); err != nil {
t.Fatalf("app catalog should be valid JSON: %v", err)
}
if got := catalogSlugs(appCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4" {
t.Fatalf("app catalog slugs = %v, want original app models", got)
}
cliCatalogData, err := os.ReadFile(cliCatalogPath)
if err != nil {
t.Fatal(err)
}
var cliCatalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(cliCatalogData, &cliCatalog); err != nil {
t.Fatalf("CLI catalog should be valid JSON: %v", err)
}
if got := catalogSlugs(cliCatalog.Models); strings.Join(got, ",") != "qwen3:8b" {
t.Fatalf("CLI catalog slugs = %v, want qwen3:8b", got)
}
}
func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -595,6 +687,52 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) {
}
}
func TestCodexAppConfigureCatalogIncludesExactSelectedModel(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
models := []LaunchModel{
{Name: "llama3.2:latest", ContextLength: 65_536},
{Name: "qwen3:8b"},
}
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", models); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
configPath, err := codexConfigPath()
if err != nil {
t.Fatal(err)
}
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if got := codexRootStringValue(string(configData), codexRootModelKey); got != "llama3.2" {
t.Fatalf("root model = %q, want llama3.2", got)
}
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, ",") != "llama3.2,qwen3:8b" {
t.Fatalf("catalog slugs = %v, want exact selected model without :latest duplicate", got)
}
if got := catalog.Models[0]["context_window"]; got != float64(65_536) {
t.Fatalf("selected model context_window = %v, want 65536", got)
}
}
func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -1339,7 +1477,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
defer restoreConfirm()
running := true
var quitCalls int
var quitCalls, openCalls int
withCodexAppProcessHooks(t,
func() bool { return running },
func() error {
@@ -1348,7 +1486,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
return nil
},
func() error {
t.Fatal("open app fallback should not be used")
openCalls++
return nil
},
)

View File

@@ -1,6 +1,7 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -14,10 +15,30 @@ import (
modelpkg "github.com/ollama/ollama/types/model"
)
func TestCodexIntegration(t *testing.T) {
c := &Codex{}
t.Run("implements runner", func(t *testing.T) {
var _ Runner = c
})
t.Run("implements restore", func(t *testing.T) {
var _ RestorableIntegration = c
var _ RestoreSuccessIntegration = c
var _ RestoreInstallCheckSkipper = c
})
}
func TestCodexArgs(t *testing.T) {
c := &Codex{}
catalogPath := filepath.Join("tmp", "model.json")
catalogArg := fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath)
managedArgs := []string{
"--profile", "ollama-launch",
"-c", fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
"-c", fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
"-c", fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
"-c", fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
"-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath),
}
tests := []struct {
name string
@@ -25,15 +46,17 @@ func TestCodexArgs(t *testing.T) {
args []string
want []string
}{
{"with model", "llama3.2", nil, []string{"-c", catalogArg, "-m", "llama3.2"}},
{"empty model", "", nil, []string{"-c", catalogArg}},
{"with profile flag", "qwen3.5", []string{"-p", "myprofile"}, []string{"-c", catalogArg, "-m", "qwen3.5", "-p", "myprofile"}},
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"-c", catalogArg, "-m", "llama3.2", "--sandbox", "workspace-write"}},
{"with model", "llama3.2", nil, append(slices.Clone(managedArgs), "-m", "llama3.2")},
{"empty model", "", nil, managedArgs},
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, append(append(slices.Clone(managedArgs), "-m", "llama3.2"), "--sandbox", "workspace-write")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.args(tt.model, catalogPath, tt.args)
got, err := c.args(tt.model, catalogPath, tt.args)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(got, tt.want) {
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
}
@@ -41,17 +64,54 @@ func TestCodexArgs(t *testing.T) {
}
}
func TestWriteCodexConfig(t *testing.T) {
func TestCodexArgsRejectManagedProfile(t *testing.T) {
c := &Codex{}
for _, extra := range [][]string{
{"-p", "myprofile"},
{"-pmyprofile"},
{"--profile", "myprofile"},
{"--profile=myprofile"},
} {
t.Run(strings.Join(extra, " "), func(t *testing.T) {
_, err := c.args("llama3.2", "", extra)
if err == nil || !strings.Contains(err.Error(), "manages --profile") {
t.Fatalf("args error = %v, want profile conflict", err)
}
})
}
}
func TestCodexArgsRejectManagedOverrides(t *testing.T) {
c := &Codex{}
for _, extra := range [][]string{
{"-m", "other"},
{"-mother"},
{"--model", "other"},
{"--model=other"},
{"-c", `model_catalog_json="/tmp/other.json"`},
{"--config", `model_provider="openai"`},
{"--config=model_providers.ollama-launch.base_url=\"http://other.invalid/v1/\""},
} {
t.Run(strings.Join(extra, " "), func(t *testing.T) {
_, err := c.args("llama3.2", "", extra)
if err == nil {
t.Fatalf("args error = nil, want managed config conflict")
}
})
}
}
func TestWriteCodexProfileConfig(t *testing.T) {
t.Run("creates new file when none exists", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
if err := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
if err := writeCodexProfileConfig(profilePath, "llama3.2", catalogPath); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(configPath)
data, err := os.ReadFile(profilePath)
if err != nil {
t.Fatal(err)
}
@@ -81,263 +141,40 @@ func TestWriteCodexConfig(t *testing.T) {
}
})
t.Run("appends provider to existing file without provider", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[some_other_section]\nkey = \"value\"\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if !strings.Contains(content, "[some_other_section]") {
t.Error("existing section was removed")
}
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
}
if !strings.Contains(content, "[model_providers.ollama-launch]") {
t.Error("missing [model_providers.ollama-launch] header")
}
})
t.Run("removes existing profile section and replaces provider section", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n\n[model_providers.ollama-launch]\nname = \"Ollama\"\nbase_url = \"http://old:1234/v1/\"\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "old:1234") {
t.Error("old URL was not replaced")
}
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should be removed, got:\n%s", content)
}
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("removes 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 := writeCodexConfig(configPath, "", ""); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, `profiles."ollama-launch"`) || strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("quoted profile table should be removed, got:\n%s", content)
}
if strings.Contains(content, "old:1234") {
t.Fatalf("old URL was not replaced, got:\n%s", content)
}
if got, ok := codexRootStringValueOK(content, "profile"); ok {
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
}
if got := codexRootStringValue(content, "model_provider"); got != codexProfileName {
t.Fatalf("root 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 := writeCodexConfig(configPath, "", "")
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexConfig 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 := writeCodexConfig(configPath, "", "")
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexConfig 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) {
t.Run("overwrites owned profile and backs up previous profile", 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 {
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if err := os.MkdirAll(filepath.Dir(profilePath), 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 {
existing := "# original-codex-profile-backup-marker\nmodel = \"old\"\nmodel_provider = \"old-provider\"\n"
if err := os.WriteFile(profilePath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := writeCodexConfig(configPath, "", ""); err != nil {
if err := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
t.Fatal(err)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), "original-codex-backup-marker")
})
t.Run("updates equivalent quoted root keys and removes legacy profile", 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 := writeCodexConfig(configPath, "llama3.2", "")
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
data, _ := os.ReadFile(profilePath)
content := string(data)
for key, want := range map[string]string{
"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 got, ok := codexRootStringValueOK(content, "profile"); ok {
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
}
if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) {
t.Fatalf("quoted root keys should be removed or 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("removes profile while preserving following sections", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n[another_section]\nfoo = \"bar\"\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "old:1234") {
t.Error("old URL was not replaced")
}
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should be removed, got:\n%s", content)
}
if !strings.Contains(content, "[another_section]") {
t.Error("following section was removed")
}
if !strings.Contains(content, "foo = \"bar\"") {
t.Error("following section content was removed")
}
})
t.Run("appends newline to file not ending with newline", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[other]\nkey = \"val\""
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
}
// Should not have double blank lines from missing trailing newline
if strings.Contains(content, "\n\n\n") {
t.Error("unexpected triple newline in output")
if strings.Contains(content, "old-provider") {
t.Fatalf("profile should be replaced, got:\n%s", content)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "ollama-launch.config.toml.*"), "original-codex-profile-backup-marker")
})
t.Run("uses custom OLLAMA_HOST", func(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
if err := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
if err := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
data, _ := os.ReadFile(profilePath)
content := string(data)
if !strings.Contains(content, "myhost:9999/v1/") {
@@ -348,13 +185,13 @@ func TestWriteCodexConfig(t *testing.T) {
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")
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
if err := writeCodexConfig(configPath, "", ""); err != nil {
if err := writeCodexProfileConfig(profilePath, "", ""); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
data, _ := os.ReadFile(profilePath)
content := string(data)
if strings.Contains(content, "0.0.0.0") {
@@ -367,7 +204,7 @@ func TestWriteCodexConfig(t *testing.T) {
}
func TestEnsureCodexConfig(t *testing.T) {
t.Run("creates .codex dir and config.toml", func(t *testing.T) {
t.Run("creates .codex dir, profile config, and model catalog", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -376,26 +213,33 @@ func TestEnsureCodexConfig(t *testing.T) {
}
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("config.toml not created: %v", err)
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
}
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
data, err := os.ReadFile(profilePath)
if err != nil {
t.Fatalf("profile config not created: %v", err)
}
content := string(data)
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
}
if got := codexRootStringValue(content, "model"); got != "llama3.2" {
t.Fatalf("root model = %q, want llama3.2 in:\n%s", got, content)
t.Fatalf("profile model = %q, want llama3.2 in:\n%s", got, content)
}
if got := codexRootStringValue(content, "model_provider"); got != codexProfileName {
t.Fatalf("root model_provider = %q, want %q in:\n%s", got, codexProfileName, content)
t.Fatalf("profile model_provider = %q, want %q in:\n%s", got, codexProfileName, content)
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
if got := codexRootStringValue(content, "model_catalog_json"); got != catalogPath {
t.Fatalf("profile model_catalog_json = %q, want %q in:\n%s", got, catalogPath, content)
}
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
data, err = os.ReadFile(catalogPath)
if err != nil {
t.Fatalf("model.json not created: %v", err)
@@ -451,7 +295,14 @@ func TestEnsureCodexConfig(t *testing.T) {
}
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
data, _ := os.ReadFile(configPath)
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
}
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
data, err := os.ReadFile(profilePath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "[profiles.ollama-launch]") {
@@ -463,6 +314,184 @@ func TestEnsureCodexConfig(t *testing.T) {
})
}
func TestCodexRestoreRemovesCLIProfileAndCatalogWithoutChangingUserRootConfig(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)
}
userConfig := "" +
`model = "gpt-5.5"` + "\n" +
`model_provider = "openai"` + "\n\n" +
"[model_providers.openai]\n" +
`name = "OpenAI"` + "\n"
if err := os.WriteFile(configPath, []byte(userConfig), 0o644); err != nil {
t.Fatal(err)
}
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
t.Fatal(err)
}
if err := (&Codex{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
t.Fatalf("CLI profile should be removed, got err=%v", err)
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
t.Fatalf("CLI catalog should be removed, got err=%v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != userConfig {
t.Fatalf("user root config should be unchanged, got:\n%s", data)
}
}
func TestCodexRestoreDoesNotRewriteRootConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
legacyConfig := "" +
`profile = "ollama-launch"` + "\n" +
`model = "llama3.2"` + "\n" +
`model_provider = "ollama-launch"` + "\n" +
fmt.Sprintf("model_catalog_json = %q\n\n", catalogPath) +
"[model_providers.ollama-launch]\n" +
`name = "Ollama"` + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
`wire_api = "responses"` + "\n\n" +
"[profiles.ollama-launch]\n" +
`model = "llama3.2"` + "\n\n" +
"[tools]\n" +
`web_search = true` + "\n"
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
t.Fatal(err)
}
if err := (&Codex{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != legacyConfig {
t.Fatalf("root config should be left untouched, got:\n%s", data)
}
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
t.Fatalf("CLI profile should be removed, got err=%v", err)
}
if _, err := os.Stat(catalogPath); err != nil {
t.Fatalf("CLI catalog should be left while root config references it: %v", err)
}
}
func TestCodexRestoreDoesNotTouchCodexAppConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json")
appCatalogPath := filepath.Join(tmpDir, ".codex", codexAppModelCatalogFilename)
cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
appProfilePath := filepath.Join(tmpDir, ".codex", codexAppProfileName+".config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
appManagedConfig := "" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf("model_provider = %q\n", codexAppProfileName) +
fmt.Sprintf("model_catalog_json = %q\n\n", appCatalogPath) +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
`name = "Ollama"` + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
`wire_api = "responses"` + "\n\n" +
codexProviderHeader() + "\n" +
`name = "Ollama"` + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
`wire_api = "responses"` + "\n"
if err := os.WriteFile(configPath, []byte(appManagedConfig), 0o644); err != nil {
t.Fatal(err)
}
restoreState := fmt.Sprintf(`{"had_profile":false,"had_model":true,"model":"qwen3:8b","had_model_provider":true,"model_provider":%q,"had_model_catalog_json":true,"model_catalog_json":%q}`, codexProfileName, cliCatalogPath)
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
t.Fatal(err)
}
for _, path := range []string{cliCatalogPath, appCatalogPath, cliProfilePath, appProfilePath} {
if err := os.WriteFile(path, []byte(`{"models":[]}`), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
if err := (&Codex{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != appManagedConfig {
t.Fatalf("Codex App root config should be left untouched, got:\n%s", data)
}
if _, err := os.Stat(cliProfilePath); !os.IsNotExist(err) {
t.Fatalf("CLI profile should be removed, got err=%v", err)
}
if _, err := os.Stat(cliCatalogPath); !os.IsNotExist(err) {
t.Fatalf("CLI catalog should be removed when root config does not reference it, got err=%v", err)
}
for _, path := range []string{appCatalogPath, appProfilePath, codexAppRestoreStatePath()} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("%s should be left untouched, got err=%v", path, err)
}
}
}
func TestLaunchIntegrationCodexRestoreDoesNotRequireInstalledCLI(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
t.Fatal(err)
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "codex", Restore: true}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
t.Fatalf("CLI restore should run without codex installed and remove profile, got err=%v", err)
}
}
func assertBackupContains(t *testing.T, pattern, marker string) {
t.Helper()
backups, err := filepath.Glob(pattern)

View File

@@ -204,6 +204,12 @@ type RestoreSuccessIntegration interface {
RestoreSuccessMessage() string
}
// RestoreInstallCheckSkipper lets cleanup-only restore flows run even when the
// external integration binary has already been removed.
type RestoreInstallCheckSkipper interface {
SkipRestoreInstallCheck() bool
}
// ManagedRuntimeRefresher lets managed integrations refresh any long-lived
// background runtime after launch rewrites their config.
type ManagedRuntimeRefresher interface {
@@ -303,7 +309,7 @@ Examples:
ollama launch codex-app --restore
ollama launch hermes
ollama launch droid --config (does not auto-launch)
ollama launch codex -- -p myprofile (pass extra args to integration)
ollama launch codex --restore
ollama launch codex -- --sandbox workspace-write`,
Args: cobra.ArbitraryArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
@@ -527,8 +533,10 @@ func restoreIntegration(name string, runner Runner, req IntegrationLaunchRequest
if !ok {
return fmt.Errorf("%s does not support --restore", name)
}
if err := EnsureIntegrationInstalled(name, runner); err != nil {
return err
if skipper, ok := runner.(RestoreInstallCheckSkipper); !ok || !skipper.SkipRestoreInstallCheck() {
if err := EnsureIntegrationInstalled(name, runner); err != nil {
return err
}
}
if err := restorable.Restore(); err != nil {
return err

View File

@@ -22,7 +22,7 @@ ollama launch codex
```
When launched through `ollama launch codex`, Ollama refreshes the model catalog
and passes it to Codex for that session.
and uses a dedicated Codex profile for that session.
To configure without launching:
@@ -30,6 +30,12 @@ To configure without launching:
ollama launch codex --config
```
To remove the Ollama launch profile and generated model catalog:
```shell
ollama launch codex --restore
```
### Manual setup
To use `codex` with Ollama, use the `--oss` flag:
@@ -52,25 +58,21 @@ codex --oss -m gpt-oss:120b-cloud
### Profile-based setup
For a persistent configuration, add an Ollama provider and profiles to `~/.codex/config.toml`:
For a persistent Codex CLI configuration, create `~/.codex/ollama-launch.config.toml`:
```toml
[model_providers.ollama-launch]
name = "Ollama"
base_url = "http://localhost:11434/v1"
[profiles.ollama-launch]
model = "gpt-oss:120b"
model_provider = "ollama-launch"
model_catalog_json = "/Users/you/.codex/model.json"
[profiles.ollama-cloud]
model = "gpt-oss:120b-cloud"
model_provider = "ollama-launch"
[model_providers.ollama-launch]
name = "Ollama"
base_url = "http://localhost:11434/v1/"
wire_api = "responses"
```
Then run:
```
codex --profile ollama-launch
codex --profile ollama-cloud
```