From 0e7e39705acef294a0ead1add9fc4ef4fed406a0 Mon Sep 17 00:00:00 2001 From: Luke Kosewski Date: Fri, 11 Sep 2026 02:46:16 +0000 Subject: [PATCH 1/5] Preserve Codex user configuration --- internal/clients/codex/codex.go | 33 ++--------- internal/clients/codex/codex_test.go | 82 +++++----------------------- internal/clients/codex/config.go | 62 ++++++--------------- internal/config/client_config.go | 4 +- 4 files changed, 39 insertions(+), 142 deletions(-) diff --git a/internal/clients/codex/codex.go b/internal/clients/codex/codex.go index 85362a3..e70cbf8 100644 --- a/internal/clients/codex/codex.go +++ b/internal/clients/codex/codex.go @@ -1,14 +1,12 @@ // Package codex is the OpenAI Codex client. It speaks OpenAI's /v1/responses -// API and is registered only with providers that advertise /v1/responses. On -// launch it writes a CODEX_HOME containing auth.json -// (pre-populated so the first run skips interactive login) and config.toml -// (pointing Codex at the aperture gateway). +// API and is registered only with providers that advertise /v1/responses. It +// points Codex at the Aperture gateway with per-launch configuration overrides +// while preserving the user's normal Codex configuration and state. package codex import ( "os/exec" "slices" - "strings" tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/clients" @@ -123,27 +121,13 @@ func (c *Client) modelStep(g *config.Global, p config.ProviderInfo) menu.Result }} } -// launch writes CODEX_HOME, builds the exec spec, records the launch state, -// and returns a tea.Cmd. +// launch builds the exec spec, records the launch state, and returns a tea.Cmd. func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) menu.Result { bin := clients.FindBinary(binaryName, c.CommonPaths()) if bin == "" { bin = binaryName } - codexHome, err := writeConfig(g.ApertureHost) - if err != nil { - return errorResult("Failed to write Codex config: " + err.Error()) - } - env := map[string]string{ - "OPENAI_BASE_URL": g.ApertureHost + "/v1", - "OPENAI_API_KEY": "not-needed", - "CODEX_HOME": codexHome, - } - if model != "" { - env["OPENAI_MODEL"] = stripProviderPrefix(model) - } - - args := []string{} + args, env := apertureLaunchConfig(g.ApertureHost) if model != "" { args = append(args, "--model", model) } @@ -221,13 +205,6 @@ func fqnModels(p config.ProviderInfo) []string { return out } -func stripProviderPrefix(fqn string) string { - if _, after, ok := strings.Cut(fqn, "/"); ok { - return after - } - return fqn -} - // errorResult returns a Result that pops the current stack and emits an // error via the TUI's generic error mechanism. The TUI interprets a Cmd // that returns an error-bearing SimpleDoneMsg as "show this error". diff --git a/internal/clients/codex/codex_test.go b/internal/clients/codex/codex_test.go index 87b88ce..0785160 100644 --- a/internal/clients/codex/codex_test.go +++ b/internal/clients/codex/codex_test.go @@ -1,9 +1,7 @@ package codex import ( - "encoding/json" - "os" - "path/filepath" + "reflect" "testing" "github.com/tailscale/aperture-cli/internal/config" @@ -32,55 +30,23 @@ func TestFqnModels(t *testing.T) { } } -func TestStripProviderPrefix(t *testing.T) { - cases := map[string]string{ - "openai/gpt-5": "gpt-5", - "vertex/gemini-2.5-pro": "gemini-2.5-pro", - "bare-model": "bare-model", - "provider/nested/model": "nested/model", +func TestApertureLaunchConfig(t *testing.T) { + args, env := apertureLaunchConfig(testHost) + wantArgs := []string{ + "--config", `model_provider="tailscale_aperture_cli"`, + "--config", `model_providers.tailscale_aperture_cli={ name = "Aperture", base_url = "http://ai.example.com/v1", env_key = "APERTURE_CODEX_API_KEY", supports_websockets = false }`, } - for in, want := range cases { - if got := stripProviderPrefix(in); got != want { - t.Errorf("stripProviderPrefix(%q) = %q, want %q", in, got, want) - } - } -} - -func TestWriteConfig(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) - - codexHome, err := writeConfig(testHost) - if err != nil { - t.Fatalf("writeConfig: %v", err) - } - - authData, err := os.ReadFile(filepath.Join(codexHome, "auth.json")) - if err != nil { - t.Fatalf("auth.json: %v", err) - } - var auth map[string]string - if err := json.Unmarshal(authData, &auth); err != nil { - t.Fatal(err) - } - if auth["auth_mode"] != "apikey" { - t.Errorf("auth_mode = %q, want apikey", auth["auth_mode"]) - } - if auth["OPENAI_API_KEY"] != "not-needed" { - t.Errorf("OPENAI_API_KEY = %q, want not-needed", auth["OPENAI_API_KEY"]) + if !reflect.DeepEqual(args, wantArgs) { + t.Errorf("args = %#v, want %#v", args, wantArgs) } - - tomlData, err := os.ReadFile(filepath.Join(codexHome, "config.toml")) - if err != nil { - t.Fatalf("config.toml: %v", err) + wantEnv := map[string]string{apertureAPIKeyEnv: "not-needed"} + if !reflect.DeepEqual(env, wantEnv) { + t.Errorf("env = %#v, want %#v", env, wantEnv) } - if got := string(tomlData); !containsAll(got, []string{ - "model_provider = \"aperture\"", - "base_url = \"" + testHost + "/v1\"", - "env_key = \"OPENAI_API_KEY\"", - }) { - t.Errorf("config.toml missing expected entries:\n%s", got) + for _, key := range []string{"CODEX_HOME", "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL"} { + if _, ok := env[key]; ok { + t.Errorf("env unexpectedly overrides %s", key) + } } } @@ -115,21 +81,3 @@ func TestReplay_StaleProvider(t *testing.T) { t.Error("Replay with missing binary should return nil") } } - -func containsAll(haystack string, needles []string) bool { - for _, n := range needles { - if !contains(haystack, n) { - return false - } - } - return true -} - -func contains(haystack, needle string) bool { - for i := 0; i+len(needle) <= len(haystack); i++ { - if haystack[i:i+len(needle)] == needle { - return true - } - } - return false -} diff --git a/internal/clients/codex/config.go b/internal/clients/codex/config.go index 9ddb28f..349b975 100644 --- a/internal/clients/codex/config.go +++ b/internal/clients/codex/config.go @@ -1,55 +1,27 @@ package codex import ( - "encoding/json" - "os" - "path/filepath" "strconv" ) -// writeConfig creates (or refreshes) the persistent CODEX_HOME directory -// holding auth.json and config.toml. Returns the directory path suitable -// for the CODEX_HOME environment variable. -// -// auth.json is pre-populated so Codex's first-run login prompt is skipped. -// config.toml pins the model provider to "aperture" pointing at the current -// aperture gateway. -// -// The path is the legacy "/aperture/codex-home" used before the -// clients refactor, preserved so any per-home state Codex has stored under -// it continues to resolve. -func writeConfig(apertureHost string) (string, error) { - cfgDir, err := os.UserConfigDir() - if err != nil { - return "", err - } - codexHome := filepath.Join(cfgDir, "aperture", "codex-home") - if err := os.MkdirAll(codexHome, 0o700); err != nil { - return "", err - } +const ( + apertureModelProvider = "tailscale_aperture_cli" + apertureAPIKeyEnv = "APERTURE_CODEX_API_KEY" +) - auth := map[string]any{ - "auth_mode": "apikey", - "OPENAI_API_KEY": "not-needed", - } - data, err := json.MarshalIndent(auth, "", " ") - if err != nil { - return "", err +// apertureLaunchConfig returns the CLI overrides and environment needed to +// route Codex through Aperture. The CLI overrides take precedence over the +// user's Codex configuration without replacing CODEX_HOME or rewriting any of +// its files. +func apertureLaunchConfig(apertureHost string) ([]string, map[string]string) { + provider := "{ name = \"Aperture\", base_url = " + strconv.Quote(apertureHost+"/v1") + + ", env_key = \"" + apertureAPIKeyEnv + "\", supports_websockets = false }" + args := []string{ + "--config", "model_provider=" + strconv.Quote(apertureModelProvider), + "--config", "model_providers." + apertureModelProvider + "=" + provider, } - if err := os.WriteFile(filepath.Join(codexHome, "auth.json"), data, 0o600); err != nil { - return "", err + env := map[string]string{ + apertureAPIKeyEnv: "not-needed", } - - baseURL := apertureHost + "/v1" - cfg := "model_provider = \"aperture\"\n\n" + - "[model_providers.aperture]\n" + - "name = \"Aperture\"\n" + - "base_url = " + strconv.Quote(baseURL) + "\n" + - "env_key = \"OPENAI_API_KEY\"\n" + - "supports_websockets = false\n" - if err := os.WriteFile(filepath.Join(codexHome, "config.toml"), []byte(cfg), 0o600); err != nil { - return "", err - } - - return codexHome, nil + return args, env } diff --git a/internal/config/client_config.go b/internal/config/client_config.go index 5895560..e45f1c4 100644 --- a/internal/config/client_config.go +++ b/internal/config/client_config.go @@ -8,8 +8,8 @@ import ( // ClientConfigDir returns the directory where a client may store its own // isolated state. The directory is created if it does not exist. Typical -// usage: clients that manage their own on-disk home (e.g. Codex's CODEX_HOME, -// Gemini's GEMINI_CLI_HOME) pass the returned path to the agent binary. +// usage: clients that manage their own on-disk home (e.g. Gemini's +// GEMINI_CLI_HOME) pass the returned path to the agent binary. func ClientConfigDir(name string) (string, error) { dir, err := os.UserConfigDir() if err != nil { From 060e4bdb740abf757012808a6c3b7e5f50becb9d Mon Sep 17 00:00:00 2001 From: Luke Kosewski Date: Fri, 11 Sep 2026 03:48:20 +0000 Subject: [PATCH 2/5] codex: support user installs and FQN model metadata --- internal/clients/codex/catalog.go | 191 ++++++++++++++++++++++ internal/clients/codex/catalog_test.go | 216 +++++++++++++++++++++++++ internal/clients/codex/codex.go | 31 +++- internal/clients/codex/codex_test.go | 46 ++++-- internal/clients/codex/config.go | 5 +- 5 files changed, 472 insertions(+), 17 deletions(-) create mode 100644 internal/clients/codex/catalog.go create mode 100644 internal/clients/codex/catalog_test.go diff --git a/internal/clients/codex/catalog.go b/internal/clients/codex/catalog.go new file mode 100644 index 0000000..efc67d2 --- /dev/null +++ b/internal/clients/codex/catalog.go @@ -0,0 +1,191 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const modelCatalogReadTimeout = 5 * time.Second + +// prepareModelCatalog asks Codex for its effective model catalog, adds aliases +// for the fully-qualified model IDs Aperture expects on the wire, and writes +// the result to a temporary file. The caller must run cleanup after Codex +// exits. +func prepareModelCatalog(codexBin string, providers []config.ProviderInfo) (path string, cleanup func(), err error) { + catalog, err := readModelCatalog(codexBin) + if err != nil { + return "", nil, err + } + return writeModelCatalog(catalog, providers) +} + +func writeModelCatalog(catalog []byte, providers []config.ProviderInfo) (path string, cleanup func(), err error) { + catalog, added, err := augmentModelCatalog(catalog, providers) + if err != nil { + return "", nil, err + } + if added == 0 { + return "", nil, nil + } + + dir, err := os.MkdirTemp("", "aperture-codex-models-") + if err != nil { + return "", nil, fmt.Errorf("create temporary model catalog directory: %w", err) + } + cleanup = func() { _ = os.RemoveAll(dir) } + + path = filepath.Join(dir, "models.json") + if err := os.WriteFile(path, catalog, 0o600); err != nil { + cleanup() + return "", nil, fmt.Errorf("write temporary model catalog: %w", err) + } + return path, cleanup, nil +} + +// readModelCatalog returns the model catalog Codex would otherwise use. Using +// the effective catalog, rather than only the binary's bundled catalog, +// preserves user-supplied and remotely refreshed model metadata. +func readModelCatalog(codexBin string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), modelCatalogReadTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, codexBin, "debug", "models").Output() + if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("read Codex model catalog: %w", ctx.Err()) + } + return nil, fmt.Errorf("read Codex model catalog: %w", err) + } + return out, nil +} + +// augmentModelCatalog clones metadata for known Codex models under the FQN +// aliases Aperture needs. Codex then recognizes the FQN while continuing to +// send that exact value as the request's model field. +func augmentModelCatalog(catalog []byte, providers []config.ProviderInfo) ([]byte, int, error) { + var document map[string]json.RawMessage + if err := json.Unmarshal(catalog, &document); err != nil { + return nil, 0, fmt.Errorf("decode Codex model catalog: %w", err) + } + if document == nil { + return nil, 0, fmt.Errorf("decode Codex model catalog: top level must be an object") + } + + modelsJSON, ok := document["models"] + if !ok || string(modelsJSON) == "null" { + return nil, 0, fmt.Errorf("decode Codex model catalog: models must be an array") + } + var models []json.RawMessage + if err := json.Unmarshal(modelsJSON, &models); err != nil { + return nil, 0, fmt.Errorf("decode Codex model catalog models: %w", err) + } + + bySlug := make(map[string]json.RawMessage, len(models)) + existingSlugs := make(map[string]bool, len(models)) + for _, model := range models { + slug, ok := catalogModelSlug(model) + if !ok { + continue + } + existingSlugs[slug] = true + if _, exists := bySlug[slug]; !exists { + bySlug[slug] = model + } + } + + added := 0 + for _, provider := range providers { + if provider.ID == "" || !provider.SupportsEndpoint(config.EndpointOpenAIResponses) { + continue + } + for _, modelID := range provider.Models { + if modelID == "" { + continue + } + fqn := provider.ID + "/" + modelID + if existingSlugs[fqn] { + continue + } + + source, ok := matchingCatalogModel(modelID, bySlug) + if !ok { + continue + } + alias, err := catalogModelAlias(source, fqn) + if err != nil { + return nil, 0, fmt.Errorf("create Codex model catalog alias %q: %w", fqn, err) + } + models = append(models, alias) + existingSlugs[fqn] = true + added++ + } + } + + if added == 0 { + return catalog, 0, nil + } + modelsJSON, err := json.Marshal(models) + if err != nil { + return nil, 0, fmt.Errorf("encode Codex model catalog models: %w", err) + } + document["models"] = modelsJSON + out, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, 0, fmt.Errorf("encode Codex model catalog: %w", err) + } + return append(out, '\n'), added, nil +} + +func catalogModelSlug(model json.RawMessage) (string, bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(model, &fields); err != nil { + return "", false + } + var slug string + if err := json.Unmarshal(fields["slug"], &slug); err != nil || slug == "" { + return "", false + } + return slug, true +} + +// matchingCatalogModel accepts exact model IDs and provider-qualified forms +// such as "openai.gpt-5.6-luna" or "openai/gpt-5.6-luna". Requiring a +// delimiter before the catalog slug avoids partial-name matches. +func matchingCatalogModel(modelID string, bySlug map[string]json.RawMessage) (json.RawMessage, bool) { + if model, ok := bySlug[modelID]; ok { + return model, true + } + + var bestSlug string + for slug := range bySlug { + if len(slug) <= len(bestSlug) { + continue + } + if strings.HasSuffix(modelID, "."+slug) || strings.HasSuffix(modelID, "/"+slug) { + bestSlug = slug + } + } + model, ok := bySlug[bestSlug] + return model, ok +} + +func catalogModelAlias(model json.RawMessage, slug string) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(model, &fields); err != nil { + return nil, err + } + slugJSON, err := json.Marshal(slug) + if err != nil { + return nil, err + } + fields["slug"] = slugJSON + return json.Marshal(fields) +} diff --git a/internal/clients/codex/catalog_test.go b/internal/clients/codex/catalog_test.go new file mode 100644 index 0000000..1a3bf34 --- /dev/null +++ b/internal/clients/codex/catalog_test.go @@ -0,0 +1,216 @@ +package codex + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +func TestAugmentModelCatalog(t *testing.T) { + catalog := []byte(`{ + "models": [ + { + "slug": "gpt-5.6-luna", + "display_name": "GPT-5.6-Luna", + "context_window": 1050000, + "future_metadata": {"preserved": true} + }, + { + "slug": "gpt-5.6-sol", + "display_name": "GPT-5.6-Sol" + }, + { + "slug": "mantle/already-present", + "display_name": "Existing alias" + }, + {"entry_without_a_slug": true} + ], + "future_top_level": {"preserved": true} +}`) + providers := []config.ProviderInfo{ + { + ID: "mantle", + Models: []string{"openai.gpt-5.6-luna", "unknown", "already-present"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }, + { + ID: "direct", + Models: []string{"gpt-5.6-sol"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }, + { + ID: "chat-only", + Models: []string{"gpt-5.6-luna"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIChat: true}, + }, + } + + got, added, err := augmentModelCatalog(catalog, providers) + if err != nil { + t.Fatal(err) + } + if added != 2 { + t.Fatalf("added = %d, want 2", added) + } + + document := decodeCatalog(t, got) + if len(document.Models) != 6 { + t.Fatalf("models = %d, want 6", len(document.Models)) + } + if !document.FutureTopLevel.Preserved { + t.Error("future top-level catalog data was not preserved") + } + + luna := document.model(t, "gpt-5.6-luna") + lunaAlias := document.model(t, "mantle/openai.gpt-5.6-luna") + delete(luna, "slug") + delete(lunaAlias, "slug") + if !reflect.DeepEqual(lunaAlias, luna) { + t.Errorf("Luna alias metadata differs from source:\n got: %#v\nwant: %#v", lunaAlias, luna) + } + + document.model(t, "direct/gpt-5.6-sol") + if document.hasModel("mantle/unknown") { + t.Error("unknown model unexpectedly received fallback metadata") + } + if document.hasModel("chat-only/gpt-5.6-luna") { + t.Error("chat-only provider unexpectedly received a Codex alias") + } +} + +func TestAugmentModelCatalogMatchesSlashQualifiedModel(t *testing.T) { + catalog := []byte(`{"models":[{"slug":"gpt-5.6-luna","context_window":1050000}]}`) + providers := []config.ProviderInfo{{ + ID: "router", + Models: []string{"openai/gpt-5.6-luna"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }} + + got, added, err := augmentModelCatalog(catalog, providers) + if err != nil { + t.Fatal(err) + } + if added != 1 { + t.Fatalf("added = %d, want 1", added) + } + decodeCatalog(t, got).model(t, "router/openai/gpt-5.6-luna") +} + +func TestAugmentModelCatalogLeavesUnknownModelsAlone(t *testing.T) { + catalog := []byte("{\n \"models\": [{\"slug\": \"gpt-5.6-luna\"}]\n}\n") + providers := []config.ProviderInfo{{ + ID: "custom", + Models: []string{"not-gpt-5.6-luna"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }} + + got, added, err := augmentModelCatalog(catalog, providers) + if err != nil { + t.Fatal(err) + } + if added != 0 { + t.Fatalf("added = %d, want 0", added) + } + if !bytes.Equal(got, catalog) { + t.Error("catalog changed even though no safe aliases were found") + } +} + +func TestAugmentModelCatalogRejectsInvalidCatalog(t *testing.T) { + tests := map[string][]byte{ + "invalid JSON": []byte(`{`), + "null": []byte(`null`), + "missing models": []byte(`{}`), + "null models": []byte(`{"models":null}`), + "object models": []byte(`{"models":{}}`), + } + for name, catalog := range tests { + t.Run(name, func(t *testing.T) { + if _, _, err := augmentModelCatalog(catalog, nil); err == nil { + t.Fatal("augmentModelCatalog unexpectedly succeeded") + } + }) + } +} + +func TestWriteModelCatalogLifecycle(t *testing.T) { + catalog := []byte(`{"models":[{"slug":"gpt-5.6-luna","context_window":1050000}]}`) + providers := []config.ProviderInfo{{ + ID: "mantle", + Models: []string{"openai.gpt-5.6-luna"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }} + + path, cleanup, err := writeModelCatalog(catalog, providers) + if err != nil { + t.Fatal(err) + } + if path == "" { + t.Fatal("writeModelCatalog returned an empty path") + } + if cleanup == nil { + t.Fatal("writeModelCatalog returned a nil cleanup function") + } + dir := filepath.Dir(path) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { + t.Errorf("catalog mode = %v, want %v", got, want) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + decodeCatalog(t, data).model(t, "mantle/openai.gpt-5.6-luna") + + cleanup() + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Errorf("temporary catalog directory still exists after cleanup: %v", err) + } +} + +type testCatalog struct { + Models []map[string]any `json:"models"` + FutureTopLevel struct { + Preserved bool `json:"preserved"` + } `json:"future_top_level"` +} + +func decodeCatalog(t *testing.T, data []byte) testCatalog { + t.Helper() + var catalog testCatalog + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatal(err) + } + return catalog +} + +func (c testCatalog) hasModel(slug string) bool { + for _, model := range c.Models { + if model["slug"] == slug { + return true + } + } + return false +} + +func (c testCatalog) model(t *testing.T, slug string) map[string]any { + t.Helper() + for _, model := range c.Models { + if model["slug"] == slug { + return model + } + } + t.Fatalf("model %q not found", slug) + return nil +} diff --git a/internal/clients/codex/codex.go b/internal/clients/codex/codex.go index e70cbf8..2488628 100644 --- a/internal/clients/codex/codex.go +++ b/internal/clients/codex/codex.go @@ -5,7 +5,10 @@ package codex import ( + "fmt" + "os" "os/exec" + "runtime" "slices" tea "github.com/charmbracelet/bubbletea" @@ -44,6 +47,19 @@ func (c *Client) IsInstalled() bool { // Install implements clients.Client. func (c *Client) Install(_ *config.Global) clients.InstallPlan { + return installPlan(runtime.GOOS) +} + +func installPlan(goos string) clients.InstallPlan { + if goos == "linux" || goos == "darwin" { + const command = "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh" + return clients.InstallPlan{ + Hint: command, + Run: func() (*exec.Cmd, error) { + return exec.Command("bash", "-o", "pipefail", "-c", command), nil + }, + } + } return clients.InstallPlan{ Hint: "npm install -g @openai/codex", Run: func() (*exec.Cmd, error) { @@ -127,7 +143,11 @@ func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) m if bin == "" { bin = binaryName } - args, env := apertureLaunchConfig(g.ApertureHost) + modelCatalogPath, cleanup, err := prepareModelCatalog(bin, g.Providers) + if err != nil && g.Debug { + fmt.Fprintf(os.Stderr, "\r\n[debug] unable to prepare Codex model aliases: %v\r\n", err) + } + args, env := apertureLaunchConfig(g.ApertureHost, modelCatalogPath) if model != "" { args = append(args, "--model", model) } @@ -143,10 +163,11 @@ func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) m }) cmd := clients.Launch(clients.LaunchSpec{ - Binary: bin, - Args: args, - Env: env, - Debug: g.Debug, + Binary: bin, + Args: args, + Env: env, + Cleanup: cleanup, + Debug: g.Debug, }) return menu.Result{Cmd: cmd, PopOnDone: true} } diff --git a/internal/clients/codex/codex_test.go b/internal/clients/codex/codex_test.go index 0785160..6fbf51e 100644 --- a/internal/clients/codex/codex_test.go +++ b/internal/clients/codex/codex_test.go @@ -2,6 +2,7 @@ package codex import ( "reflect" + "slices" "testing" "github.com/tailscale/aperture-cli/internal/config" @@ -31,7 +32,7 @@ func TestFqnModels(t *testing.T) { } func TestApertureLaunchConfig(t *testing.T) { - args, env := apertureLaunchConfig(testHost) + args, env := apertureLaunchConfig(testHost, "") wantArgs := []string{ "--config", `model_provider="tailscale_aperture_cli"`, "--config", `model_providers.tailscale_aperture_cli={ name = "Aperture", base_url = "http://ai.example.com/v1", env_key = "APERTURE_CODEX_API_KEY", supports_websockets = false }`, @@ -50,19 +51,42 @@ func TestApertureLaunchConfig(t *testing.T) { } } -func TestInstallUninstall(t *testing.T) { - c := &Client{} - g := &config.Global{} - - install := c.Install(g) - if install.Hint != "npm install -g @openai/codex" { - t.Errorf("Install.Hint = %q", install.Hint) +func TestApertureLaunchConfigWithModelCatalog(t *testing.T) { + args, _ := apertureLaunchConfig(testHost, `/tmp/aperture "models".json`) + want := `model_catalog_json="/tmp/aperture \"models\".json"` + if got := args[len(args)-1]; got != want { + t.Errorf("model catalog override = %q, want %q", got, want) } - if install.Run == nil { - t.Error("Install.Run is nil") +} + +func TestInstallPlan(t *testing.T) { + const command = "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh" + for _, goos := range []string{"linux", "darwin"} { + t.Run(goos, func(t *testing.T) { + plan := installPlan(goos) + if plan.Hint != command { + t.Errorf("Hint = %q, want %q", plan.Hint, command) + } + cmd, err := plan.Run() + if err != nil { + t.Fatal(err) + } + if !slices.Equal(cmd.Args, []string{"bash", "-o", "pipefail", "-c", command}) { + t.Errorf("install command args = %q, want bash with pipefail", cmd.Args) + } + }) } - uninstall := c.Uninstall() + t.Run("other", func(t *testing.T) { + plan := installPlan("windows") + if plan.Hint != "npm install -g @openai/codex" { + t.Errorf("Hint = %q, want npm fallback", plan.Hint) + } + }) +} + +func TestUninstall(t *testing.T) { + uninstall := (&Client{}).Uninstall() if uninstall.Hint != "npm uninstall -g @openai/codex" { t.Errorf("Uninstall.Hint = %q", uninstall.Hint) } diff --git a/internal/clients/codex/config.go b/internal/clients/codex/config.go index 349b975..e0c46ad 100644 --- a/internal/clients/codex/config.go +++ b/internal/clients/codex/config.go @@ -13,13 +13,16 @@ const ( // route Codex through Aperture. The CLI overrides take precedence over the // user's Codex configuration without replacing CODEX_HOME or rewriting any of // its files. -func apertureLaunchConfig(apertureHost string) ([]string, map[string]string) { +func apertureLaunchConfig(apertureHost, modelCatalogPath string) ([]string, map[string]string) { provider := "{ name = \"Aperture\", base_url = " + strconv.Quote(apertureHost+"/v1") + ", env_key = \"" + apertureAPIKeyEnv + "\", supports_websockets = false }" args := []string{ "--config", "model_provider=" + strconv.Quote(apertureModelProvider), "--config", "model_providers." + apertureModelProvider + "=" + provider, } + if modelCatalogPath != "" { + args = append(args, "--config", "model_catalog_json="+strconv.Quote(modelCatalogPath)) + } env := map[string]string{ apertureAPIKeyEnv: "not-needed", } From 8a4c57713883093b44fb629ea76fe62685526377 Mon Sep 17 00:00:00 2001 From: Luke Kosewski Date: Fri, 11 Sep 2026 04:22:23 +0000 Subject: [PATCH 3/5] bridges: tolerate embedded network startup delays --- internal/bridges/manager.go | 63 ++++++++++++- internal/bridges/manager_test.go | 157 +++++++++++++++++++++++++++++++ internal/tui/tui.go | 15 ++- internal/tui/tui_test.go | 24 +++++ 4 files changed, 253 insertions(+), 6 deletions(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index ee69f3b..e2ec470 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -28,6 +28,11 @@ type Manager struct { newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode } +const ( + bridgeDNSRetryWindow = 5 * time.Second + bridgeDNSRetryInterval = 250 * time.Millisecond +) + type nodeRuntime struct { node tailnetNode proxies map[string]*proxyRuntime @@ -255,14 +260,21 @@ func startProxy(node tailnetNode, target *url.URL, logf func(string), debug bool if debug { logf(fmt.Sprintf("Bridge dialing network=%s address=%s", network, address)) } - conn, err := node.DialContext(ctx, network, address) + conn, attempts, err := dialWithDNSRetry( + ctx, + node.DialContext, + network, + address, + bridgeDNSRetryWindow, + bridgeDNSRetryInterval, + ) elapsed := time.Since(start).Round(time.Millisecond) if err != nil { - logf(fmt.Sprintf("Bridge dial failed: network=%s address=%s elapsed=%s error=%T: %v", network, address, elapsed, err, err)) + logf(fmt.Sprintf("Bridge dial failed: network=%s address=%s attempts=%d elapsed=%s error=%T: %v", network, address, attempts, elapsed, err, err)) return nil, err } if debug { - logf(fmt.Sprintf("Bridge dial connected: address=%s remote=%s elapsed=%s", address, conn.RemoteAddr(), elapsed)) + logf(fmt.Sprintf("Bridge dial connected: address=%s remote=%s attempts=%d elapsed=%s", address, conn.RemoteAddr(), attempts, elapsed)) } return conn, nil } @@ -291,6 +303,51 @@ func startProxy(node tailnetNode, target *url.URL, logf func(string), debug bool }, nil } +type bridgeDialFunc func(context.Context, string, string) (net.Conn, error) + +// dialWithDNSRetry gives an embedded tsnet node a short window to receive the +// target's peer map after Up reports Running. Until that map arrives, tsnet's +// MagicDNS lookup falls through to the host resolver and returns a DNSError. +// Non-DNS failures are returned immediately. +func dialWithDNSRetry( + ctx context.Context, + dial bridgeDialFunc, + network, address string, + retryWindow, retryInterval time.Duration, +) (net.Conn, int, error) { + deadline := time.Now().Add(retryWindow) + attempts := 0 + for { + conn, err := dial(ctx, network, address) + attempts++ + if err == nil { + return conn, attempts, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, attempts, ctxErr + } + var dnsErr *net.DNSError + if !errors.As(err, &dnsErr) || retryWindow <= 0 || retryInterval <= 0 { + return nil, attempts, err + } + + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, attempts, err + } + if retryInterval > remaining { + retryInterval = remaining + } + timer := time.NewTimer(retryInterval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, attempts, ctx.Err() + case <-timer.C: + } + } +} + func logBridgeStatus(logf func(string), status *ipnstate.Status, target *url.URL) { if status == nil { logf("Bridge network status is unavailable.") diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 3161e05..846c99a 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -9,7 +9,9 @@ import ( "net/http/httptest" "net/netip" "strings" + "sync/atomic" "testing" + "time" "github.com/tailscale/aperture-cli/internal/config" "tailscale.com/ipn/ipnstate" @@ -21,6 +23,7 @@ type fakeNode struct { upErr error statusErr error dialErr error + dialFn bridgeDialFunc up int closed bool } @@ -35,6 +38,9 @@ func (n *fakeNode) Status(context.Context) (*ipnstate.Status, error) { } func (n *fakeNode) DialContext(ctx context.Context, network, _ string) (net.Conn, error) { + if n.dialFn != nil { + return n.dialFn(ctx, network, n.backendAddr) + } if n.dialErr != nil { return nil, n.dialErr } @@ -169,6 +175,157 @@ func TestActivateNormalLoggingOmitsDebugDiagnostics(t *testing.T) { } } +func TestActivateRetriesDNSWhilePeerMapArrives(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + var attempts atomic.Int32 + node := &fakeNode{ + backendAddr: strings.TrimPrefix(backend.URL, "http://"), + status: &ipnstate.Status{ + BackendState: "Running", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, + }, + } + node.dialFn = func(ctx context.Context, network, address string) (net.Conn, error) { + if attempts.Add(1) == 1 { + return nil, &net.DNSError{ + Err: "server misbehaving", + Name: "ai", + Server: "127.0.0.53:53", + IsTemporary: true, + } + } + var d net.Dialer + return d.DialContext(ctx, network, address) + } + + m := NewManager(true) + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return node + } + defer m.Close() + + var logs []string + localURL, err := m.Activate( + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://ai", + func(line string) { logs = append(logs, line) }, + ) + if err != nil { + t.Fatal(err) + } + + resp, err := http.Get(localURL + "/v1/models") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent) + } + if got := attempts.Load(); got != 2 { + t.Fatalf("dial attempts = %d, want 2", got) + } + if got := strings.Join(logs, "\n"); !strings.Contains(got, "attempts=2") { + t.Fatalf("logs missing recovered dial attempt count:\n%s", got) + } +} + +func TestDialWithDNSRetry(t *testing.T) { + t.Run("recovers when embedded DNS receives the target", func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + attempts := 0 + dial := func(ctx context.Context, network, address string) (net.Conn, error) { + attempts++ + if attempts == 1 { + return nil, &net.DNSError{ + Err: "server misbehaving", + Name: "ai", + Server: "127.0.0.53:53", + IsTemporary: true, + } + } + var d net.Dialer + return d.DialContext(ctx, network, strings.TrimPrefix(backend.URL, "http://")) + } + + conn, gotAttempts, err := dialWithDNSRetry( + context.Background(), dial, "tcp", "ai:80", time.Second, time.Millisecond, + ) + if err != nil { + t.Fatal(err) + } + conn.Close() + if gotAttempts != 2 { + t.Fatalf("attempts = %d, want 2", gotAttempts) + } + }) + + t.Run("stops when the retry window expires", func(t *testing.T) { + wantErr := &net.DNSError{Err: "server misbehaving", Name: "ai"} + attempts := 0 + dial := func(context.Context, string, string) (net.Conn, error) { + attempts++ + return nil, wantErr + } + + _, gotAttempts, err := dialWithDNSRetry( + context.Background(), dial, "tcp", "ai:80", 5*time.Millisecond, time.Hour, + ) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if gotAttempts < 2 || gotAttempts != attempts { + t.Fatalf("attempts = %d/%d, want at least 2 matching attempts", gotAttempts, attempts) + } + }) + + t.Run("does not retry non-DNS failures", func(t *testing.T) { + wantErr := errors.New("connection refused") + attempts := 0 + dial := func(context.Context, string, string) (net.Conn, error) { + attempts++ + return nil, wantErr + } + + _, gotAttempts, err := dialWithDNSRetry( + context.Background(), dial, "tcp", "ai:80", time.Second, time.Millisecond, + ) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if gotAttempts != 1 || attempts != 1 { + t.Fatalf("attempts = %d/%d, want 1/1", gotAttempts, attempts) + } + }) + + t.Run("stops when activation is canceled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + attempts := 0 + dial := func(context.Context, string, string) (net.Conn, error) { + attempts++ + cancel() + return nil, &net.DNSError{Err: "server misbehaving", Name: "ai"} + } + + _, gotAttempts, err := dialWithDNSRetry( + ctx, dial, "tcp", "ai:80", time.Second, time.Second, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context canceled", err) + } + if gotAttempts != 1 || attempts != 1 { + t.Fatalf("attempts = %d/%d, want 1/1", gotAttempts, attempts) + } + }) +} + func (n *fakeNode) Close() error { n.closed = true return nil diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ce78475..12476c0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -44,6 +44,11 @@ var ( dotRed = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("●") ) +const ( + providerFetchTimeout = 10 * time.Second + bridgeProviderFetchTimeout = 30 * time.Second +) + // NewModel returns the TUI model. g holds the persisted launcher state // (settings, endpoints, last launch). buildVersion is shown at the bottom // of the client picker. @@ -127,9 +132,13 @@ func runPreflight(host string) tea.Cmd { } func fetchProviders(host string) ([]config.ProviderInfo, error) { - client := &http.Client{Timeout: 10 * time.Second} + return fetchProvidersContext(context.Background(), host, providerFetchTimeout) +} + +func fetchProvidersContext(ctx context.Context, host string, timeout time.Duration) ([]config.ProviderInfo, error) { + client := &http.Client{Timeout: timeout} url := strings.TrimRight(host, "/") + "/v1/models" - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } @@ -213,7 +222,7 @@ func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { if err != nil { return endpointActivationResult{endpoint: ep, host: ep.URL, err: err} } - provs, err := fetchProviders(localURL) + provs, err := fetchProvidersContext(ctx, localURL, bridgeProviderFetchTimeout) if err != nil { err = fmt.Errorf("bridge %s could not reach %s: %w", bridge.Name, ep.URL, err) } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e14ed44..76525e8 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2,12 +2,14 @@ package tui import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" "slices" "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" @@ -751,6 +753,28 @@ func TestFetchProvidersUsesModelsEndpoint(t *testing.T) { } } +func TestFetchProvidersContextHonorsCancellation(t *testing.T) { + requestStarted := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(requestStarted) + <-r.Context().Done() + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := fetchProvidersContext(ctx, srv.URL, time.Minute) + result <- err + }() + <-requestStarted + cancel() + + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("fetchProvidersContext error = %v, want context canceled", err) + } +} + func modelsServer(t *testing.T) *httptest.Server { t.Helper() return modelsServerWithHandler(t, nil) From 32f0ff0daa9c10a21b5bdbee6eacd73b4c47bce5 Mon Sep 17 00:00:00 2001 From: Luke Kosewski Date: Fri, 11 Sep 2026 04:58:01 +0000 Subject: [PATCH 4/5] codex: align standalone install lifecycle --- internal/clients/codex/codex.go | 14 +++- internal/clients/codex/codex_test.go | 117 ++++++++++++++++++++++++++- internal/clients/codex/install.go | 89 ++++++++++++++++++++ 3 files changed, 216 insertions(+), 4 deletions(-) diff --git a/internal/clients/codex/codex.go b/internal/clients/codex/codex.go index 2488628..00b1db2 100644 --- a/internal/clients/codex/codex.go +++ b/internal/clients/codex/codex.go @@ -56,7 +56,7 @@ func installPlan(goos string) clients.InstallPlan { return clients.InstallPlan{ Hint: command, Run: func() (*exec.Cmd, error) { - return exec.Command("bash", "-o", "pipefail", "-c", command), nil + return exec.Command("/bin/sh", "-c", command), nil }, } } @@ -70,6 +70,18 @@ func installPlan(goos string) clients.InstallPlan { // Uninstall implements clients.Client. func (c *Client) Uninstall() clients.UninstallPlan { + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + if install, ok := findStandaloneInstall(); ok { + return clients.UninstallPlan{ + Hint: "remove standalone Codex installation at " + install.binaryPath, + Run: install.remove, + } + } + } + return npmUninstallPlan() +} + +func npmUninstallPlan() clients.UninstallPlan { return clients.UninstallPlan{ Hint: "npm uninstall -g @openai/codex", Run: func() error { diff --git a/internal/clients/codex/codex_test.go b/internal/clients/codex/codex_test.go index 6fbf51e..c79412f 100644 --- a/internal/clients/codex/codex_test.go +++ b/internal/clients/codex/codex_test.go @@ -1,8 +1,13 @@ package codex import ( + "errors" + "os" + "path/filepath" "reflect" + "runtime" "slices" + "strings" "testing" "github.com/tailscale/aperture-cli/internal/config" @@ -71,8 +76,8 @@ func TestInstallPlan(t *testing.T) { if err != nil { t.Fatal(err) } - if !slices.Equal(cmd.Args, []string{"bash", "-o", "pipefail", "-c", command}) { - t.Errorf("install command args = %q, want bash with pipefail", cmd.Args) + if !slices.Equal(cmd.Args, []string{"/bin/sh", "-c", command}) { + t.Errorf("install command args = %q, want the documented POSIX shell command", cmd.Args) } }) } @@ -85,13 +90,119 @@ func TestInstallPlan(t *testing.T) { }) } -func TestUninstall(t *testing.T) { +func TestUninstallFallsBackToNPM(t *testing.T) { + if runtime.GOOS != "windows" { + t.Setenv("HOME", t.TempDir()) + } + t.Setenv("PATH", "") + t.Setenv("CODEX_HOME", "") + t.Setenv("CODEX_INSTALL_DIR", "") + uninstall := (&Client{}).Uninstall() if uninstall.Hint != "npm uninstall -g @openai/codex" { t.Errorf("Uninstall.Hint = %q", uninstall.Hint) } } +func TestStandaloneUninstall(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks requires privileges on Windows") + } + + home := t.TempDir() + codexHome := filepath.Join(home, "codex-home") + root := filepath.Join(codexHome, "packages", "standalone") + releaseBin := filepath.Join(root, "releases", "1.2.3", "bin") + if err := os.MkdirAll(releaseBin, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(releaseBin, "codex"), []byte("binary"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(releaseBin, "codex-code-mode-host"), []byte("binary"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "releases", "1.2.3"), filepath.Join(root, "current")); err != nil { + t.Fatal(err) + } + + installDir := filepath.Join(home, "bin") + if err := os.MkdirAll(installDir, 0o700); err != nil { + t.Fatal(err) + } + binaryPath := filepath.Join(installDir, "codex") + if err := os.Symlink(filepath.Join(root, "current", "bin", "codex"), binaryPath); err != nil { + t.Fatal(err) + } + codeModeHost := filepath.Join(installDir, "codex-code-mode-host") + if err := os.Symlink(filepath.Join(root, "current", "bin", "codex-code-mode-host"), codeModeHost); err != nil { + t.Fatal(err) + } + + configPath := filepath.Join(codexHome, "config.toml") + if err := os.WriteFile(configPath, []byte("model = \"test\"\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", "") + t.Setenv("CODEX_HOME", codexHome) + t.Setenv("CODEX_INSTALL_DIR", installDir) + + uninstall := (&Client{}).Uninstall() + if !strings.Contains(uninstall.Hint, "standalone Codex installation") { + t.Fatalf("Uninstall.Hint = %q, want standalone installer", uninstall.Hint) + } + if err := uninstall.Run(); err != nil { + t.Fatal(err) + } + for _, path := range []string{binaryPath, codeModeHost, root} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s still exists after uninstall: %v", path, err) + } + } + if _, err := os.Stat(configPath); err != nil { + t.Errorf("uninstall removed user configuration: %v", err) + } +} + +func TestStandaloneUninstallRejectsChangedSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks requires privileges on Windows") + } + + dir := t.TempDir() + root := filepath.Join(dir, "codex-home", "packages", "standalone") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + binaryPath := filepath.Join(dir, "codex") + if err := os.Symlink(filepath.Join(root, "current", "bin", "codex"), binaryPath); err != nil { + t.Fatal(err) + } + install := standaloneInstall{binaryPath: binaryPath, root: root} + if err := os.Remove(binaryPath); err != nil { + t.Fatal(err) + } + outside := filepath.Join(dir, "user-managed-codex") + if err := os.WriteFile(outside, []byte("keep"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, binaryPath); err != nil { + t.Fatal(err) + } + + err := install.remove() + if err == nil || !strings.Contains(err.Error(), "no longer a standalone installer symlink") { + t.Fatalf("remove error = %v, want changed-symlink error", err) + } + if _, err := os.Stat(outside); err != nil { + t.Errorf("user-managed binary was removed: %v", err) + } + if _, err := os.Stat(root); err != nil { + t.Errorf("standalone package root was removed after validation failed: %v", err) + } +} + func TestReplay_StaleProvider(t *testing.T) { c := &Client{} g := &config.Global{ diff --git a/internal/clients/codex/install.go b/internal/clients/codex/install.go index 186ed0f..394b9cc 100644 --- a/internal/clients/codex/install.go +++ b/internal/clients/codex/install.go @@ -1,10 +1,18 @@ package codex import ( + "fmt" "os" + "os/exec" "path/filepath" + "strings" ) +type standaloneInstall struct { + binaryPath string + root string +} + // commonBinaryPaths returns the non-PATH locations where `codex` is // commonly installed. func commonBinaryPaths() []string { @@ -16,3 +24,84 @@ func commonBinaryPaths() []string { filepath.Join(home, ".local", "bin", "codex"), } } + +// findStandaloneInstall recognizes only the symlink layout created by +// chatgpt.com/codex/install.sh. It intentionally does not infer ownership from +// the binary's location alone: ~/.local/bin/codex may be managed by the user or +// another installer. +func findStandaloneInstall() (standaloneInstall, bool) { + home, err := os.UserHomeDir() + if err != nil { + return standaloneInstall{}, false + } + + binaryPaths := []string{filepath.Join(home, ".local", "bin", "codex")} + if installDir := os.Getenv("CODEX_INSTALL_DIR"); installDir != "" { + binaryPaths = append(binaryPaths, filepath.Join(installDir, "codex")) + } + if path, err := exec.LookPath(binaryName); err == nil { + binaryPaths = append(binaryPaths, path) + } + + roots := []string{filepath.Join(home, ".codex", "packages", "standalone")} + if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { + roots = append(roots, filepath.Join(codexHome, "packages", "standalone")) + } + + for _, binaryPath := range binaryPaths { + for _, root := range roots { + if symlinkTargetsWithin(binaryPath, root) { + return standaloneInstall{binaryPath: binaryPath, root: root}, true + } + } + } + return standaloneInstall{}, false +} + +func symlinkTargetsWithin(path, root string) bool { + target, err := os.Readlink(path) + if err != nil { + return false + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(path), target) + } + + root, err = filepath.Abs(root) + if err != nil { + return false + } + target, err = filepath.Abs(target) + if err != nil { + return false + } + rel, err := filepath.Rel(root, target) + if err != nil || rel == "." || rel == ".." { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func (install standaloneInstall) remove() error { + // Revalidate immediately before removing anything so a changed or + // hand-edited symlink cannot cause an unrelated package tree to be deleted. + if !symlinkTargetsWithin(install.binaryPath, install.root) { + return fmt.Errorf("Codex installation at %s is no longer a standalone installer symlink", install.binaryPath) + } + if err := os.Remove(install.binaryPath); err != nil { + return fmt.Errorf("remove Codex command: %w", err) + } + + // The macOS standalone package may install this companion command. Leave a + // user-managed file at the same path untouched. + codeModeHost := filepath.Join(filepath.Dir(install.binaryPath), "codex-code-mode-host") + if symlinkTargetsWithin(codeModeHost, install.root) { + if err := os.Remove(codeModeHost); err != nil { + return fmt.Errorf("remove Codex code-mode host command: %w", err) + } + } + if err := os.RemoveAll(install.root); err != nil { + return fmt.Errorf("remove Codex standalone package cache: %w", err) + } + return nil +} From 5ff4d8052674351680364fb96015c1b54f5c240c Mon Sep 17 00:00:00 2001 From: Luke Kosewski Date: Fri, 11 Sep 2026 05:08:33 +0000 Subject: [PATCH 5/5] codex: order model catalog by provider --- internal/clients/codex/catalog.go | 243 +++++++++++++++++++++---- internal/clients/codex/catalog_test.go | 183 ++++++++++++++++++- internal/clients/codex/codex.go | 2 +- 3 files changed, 390 insertions(+), 38 deletions(-) diff --git a/internal/clients/codex/catalog.go b/internal/clients/codex/catalog.go index efc67d2..1ac1044 100644 --- a/internal/clients/codex/catalog.go +++ b/internal/clients/codex/catalog.go @@ -1,12 +1,14 @@ package codex import ( + "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" + "sort" "strings" "time" @@ -19,22 +21,23 @@ const modelCatalogReadTimeout = 5 * time.Second // for the fully-qualified model IDs Aperture expects on the wire, and writes // the result to a temporary file. The caller must run cleanup after Codex // exits. -func prepareModelCatalog(codexBin string, providers []config.ProviderInfo) (path string, cleanup func(), err error) { +func prepareModelCatalog(codexBin string, providers []config.ProviderInfo, selectedProviderID string) (path string, cleanup func(), err error) { catalog, err := readModelCatalog(codexBin) if err != nil { return "", nil, err } - return writeModelCatalog(catalog, providers) + return writeModelCatalog(catalog, providers, selectedProviderID) } -func writeModelCatalog(catalog []byte, providers []config.ProviderInfo) (path string, cleanup func(), err error) { - catalog, added, err := augmentModelCatalog(catalog, providers) +func writeModelCatalog(catalog []byte, providers []config.ProviderInfo, selectedProviderID string) (path string, cleanup func(), err error) { + updated, _, err := augmentModelCatalog(catalog, providers, selectedProviderID) if err != nil { return "", nil, err } - if added == 0 { + if bytes.Equal(updated, catalog) { return "", nil, nil } + catalog = updated dir, err := os.MkdirTemp("", "aperture-codex-models-") if err != nil { @@ -69,8 +72,11 @@ func readModelCatalog(codexBin string) ([]byte, error) { // augmentModelCatalog clones metadata for known Codex models under the FQN // aliases Aperture needs. Codex then recognizes the FQN while continuing to -// send that exact value as the request's model field. -func augmentModelCatalog(catalog []byte, providers []config.ProviderInfo) ([]byte, int, error) { +// send that exact value as the request's model field. Visible entries receive +// unique, dense priorities in provider-centric order: the launch-selected +// provider, the remaining provider IDs alphabetically, then native and +// otherwise unmanaged catalog entries. +func augmentModelCatalog(catalog []byte, providers []config.ProviderInfo, selectedProviderID string) ([]byte, int, error) { var document map[string]json.RawMessage if err := json.Unmarshal(catalog, &document); err != nil { return nil, 0, fmt.Errorf("decode Codex model catalog: %w", err) @@ -88,50 +94,135 @@ func augmentModelCatalog(catalog []byte, providers []config.ProviderInfo) ([]byt return nil, 0, fmt.Errorf("decode Codex model catalog models: %w", err) } - bySlug := make(map[string]json.RawMessage, len(models)) - existingSlugs := make(map[string]bool, len(models)) - for _, model := range models { + bySlug := make(map[string]int, len(models)) + existingSlugs := make(map[string]int, len(models)) + ranks := make([]catalogRank, len(models)) + for i, model := range models { + ranks[i] = rankCatalogModel(model, i) slug, ok := catalogModelSlug(model) if !ok { continue } - existingSlugs[slug] = true + existingSlugs[slug] = i if _, exists := bySlug[slug]; !exists { - bySlug[slug] = model + bySlug[slug] = i } } added := 0 - for _, provider := range providers { - if provider.ID == "" || !provider.SupportsEndpoint(config.EndpointOpenAIResponses) { - continue - } - for _, modelID := range provider.Models { + changed := false + var managed []catalogOrderItem + managedIndexes := make(map[int]bool) + managedSlugs := make(map[string]bool) + for providerOrder, provider := range orderedCatalogProviders(providers, selectedProviderID) { + for modelOrder, modelID := range provider.Models { if modelID == "" { continue } fqn := provider.ID + "/" + modelID - if existingSlugs[fqn] { + if managedSlugs[fqn] { continue } - source, ok := matchingCatalogModel(modelID, bySlug) - if !ok { + sourceIndex, sourceOK := matchingCatalogModel(modelID, bySlug) + targetIndex, exists := existingSlugs[fqn] + if !exists { + if !sourceOK { + continue + } + alias, err := catalogModelAlias(models[sourceIndex], fqn) + if err != nil { + return nil, 0, fmt.Errorf("create Codex model catalog alias %q: %w", fqn, err) + } + targetIndex = len(models) + models = append(models, alias) + ranks = append(ranks, rankCatalogModel(alias, targetIndex)) + existingSlugs[fqn] = targetIndex + added++ + changed = true + } + managedSlugs[fqn] = true + if !catalogModelIsVisible(models[targetIndex]) { continue } - alias, err := catalogModelAlias(source, fqn) - if err != nil { - return nil, 0, fmt.Errorf("create Codex model catalog alias %q: %w", fqn, err) + + rank := ranks[targetIndex] + if sourceOK { + rank = ranks[sourceIndex] } - models = append(models, alias) - existingSlugs[fqn] = true - added++ + managed = append(managed, catalogOrderItem{ + index: targetIndex, + rank: rank, + providerOrder: providerOrder, + modelOrder: modelOrder, + slug: fqn, + }) + managedIndexes[targetIndex] = true } } - if added == 0 { + if added == 0 && len(managed) == 0 { return catalog, 0, nil } + + if len(managed) > 0 { + sort.SliceStable(managed, func(i, j int) bool { + a, b := managed[i], managed[j] + if a.providerOrder != b.providerOrder { + return a.providerOrder < b.providerOrder + } + if lessCatalogRank(a.rank, b.rank) { + return true + } + if lessCatalogRank(b.rank, a.rank) { + return false + } + if a.modelOrder != b.modelOrder { + return a.modelOrder < b.modelOrder + } + return a.slug < b.slug + }) + + var unmanaged []catalogOrderItem + for i, model := range models { + if managedIndexes[i] || !catalogModelIsVisible(model) { + continue + } + slug, ok := catalogModelSlug(model) + if !ok { + continue + } + unmanaged = append(unmanaged, catalogOrderItem{index: i, rank: ranks[i], slug: slug}) + } + sort.SliceStable(unmanaged, func(i, j int) bool { + a, b := unmanaged[i], unmanaged[j] + if lessCatalogRank(a.rank, b.rank) { + return true + } + if lessCatalogRank(b.rank, a.rank) { + return false + } + return a.slug < b.slug + }) + + ordered := append(managed, unmanaged...) + for i, item := range ordered { + priority := int64(i + 1) + if current, ok := catalogModelPriority(models[item.index]); ok && current == priority { + continue + } + model, err := catalogModelWithPriority(models[item.index], i+1) + if err != nil { + return nil, 0, fmt.Errorf("set Codex model catalog priority for %q: %w", item.slug, err) + } + models[item.index] = model + changed = true + } + } + if !changed { + return catalog, added, nil + } + modelsJSON, err := json.Marshal(models) if err != nil { return nil, 0, fmt.Errorf("encode Codex model catalog models: %w", err) @@ -144,6 +235,94 @@ func augmentModelCatalog(catalog []byte, providers []config.ProviderInfo) ([]byt return append(out, '\n'), added, nil } +type catalogRank struct { + priority int64 + index int +} + +type catalogOrderItem struct { + index int + rank catalogRank + providerOrder int + modelOrder int + slug string +} + +func orderedCatalogProviders(providers []config.ProviderInfo, selectedProviderID string) []config.ProviderInfo { + ordered := make([]config.ProviderInfo, 0, len(providers)) + for _, provider := range providers { + if provider.ID != "" && provider.SupportsEndpoint(config.EndpointOpenAIResponses) { + ordered = append(ordered, provider) + } + } + sort.SliceStable(ordered, func(i, j int) bool { + a, b := ordered[i], ordered[j] + aSelected := a.ID == selectedProviderID + bSelected := b.ID == selectedProviderID + if aSelected != bSelected { + return aSelected + } + aID, bID := strings.ToLower(a.ID), strings.ToLower(b.ID) + if aID != bID { + return aID < bID + } + return a.ID < b.ID + }) + return ordered +} + +func rankCatalogModel(model json.RawMessage, index int) catalogRank { + rank := catalogRank{priority: int64(index), index: index} + if priority, ok := catalogModelPriority(model); ok { + rank.priority = priority + } + return rank +} + +func catalogModelPriority(model json.RawMessage) (int64, bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(model, &fields); err != nil { + return 0, false + } + var priority int64 + if err := json.Unmarshal(fields["priority"], &priority); err != nil { + return 0, false + } + return priority, true +} + +func lessCatalogRank(a, b catalogRank) bool { + if a.priority != b.priority { + return a.priority < b.priority + } + return a.index < b.index +} + +func catalogModelIsVisible(model json.RawMessage) bool { + var fields map[string]json.RawMessage + if err := json.Unmarshal(model, &fields); err != nil { + return false + } + var visibility string + if err := json.Unmarshal(fields["visibility"], &visibility); err != nil { + return true + } + return visibility != "hide" +} + +func catalogModelWithPriority(model json.RawMessage, priority int) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(model, &fields); err != nil { + return nil, err + } + priorityJSON, err := json.Marshal(priority) + if err != nil { + return nil, err + } + fields["priority"] = priorityJSON + return json.Marshal(fields) +} + func catalogModelSlug(model json.RawMessage) (string, bool) { var fields map[string]json.RawMessage if err := json.Unmarshal(model, &fields); err != nil { @@ -159,9 +338,9 @@ func catalogModelSlug(model json.RawMessage) (string, bool) { // matchingCatalogModel accepts exact model IDs and provider-qualified forms // such as "openai.gpt-5.6-luna" or "openai/gpt-5.6-luna". Requiring a // delimiter before the catalog slug avoids partial-name matches. -func matchingCatalogModel(modelID string, bySlug map[string]json.RawMessage) (json.RawMessage, bool) { - if model, ok := bySlug[modelID]; ok { - return model, true +func matchingCatalogModel(modelID string, bySlug map[string]int) (int, bool) { + if index, ok := bySlug[modelID]; ok { + return index, true } var bestSlug string @@ -173,8 +352,8 @@ func matchingCatalogModel(modelID string, bySlug map[string]json.RawMessage) (js bestSlug = slug } } - model, ok := bySlug[bestSlug] - return model, ok + index, ok := bySlug[bestSlug] + return index, ok } func catalogModelAlias(model json.RawMessage, slug string) (json.RawMessage, error) { diff --git a/internal/clients/codex/catalog_test.go b/internal/clients/codex/catalog_test.go index 1a3bf34..6d812f5 100644 --- a/internal/clients/codex/catalog_test.go +++ b/internal/clients/codex/catalog_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "reflect" + "sort" "testing" "github.com/tailscale/aperture-cli/internal/config" @@ -51,7 +52,7 @@ func TestAugmentModelCatalog(t *testing.T) { }, } - got, added, err := augmentModelCatalog(catalog, providers) + got, added, err := augmentModelCatalog(catalog, providers, "mantle") if err != nil { t.Fatal(err) } @@ -70,7 +71,9 @@ func TestAugmentModelCatalog(t *testing.T) { luna := document.model(t, "gpt-5.6-luna") lunaAlias := document.model(t, "mantle/openai.gpt-5.6-luna") delete(luna, "slug") + delete(luna, "priority") delete(lunaAlias, "slug") + delete(lunaAlias, "priority") if !reflect.DeepEqual(lunaAlias, luna) { t.Errorf("Luna alias metadata differs from source:\n got: %#v\nwant: %#v", lunaAlias, luna) } @@ -92,7 +95,7 @@ func TestAugmentModelCatalogMatchesSlashQualifiedModel(t *testing.T) { SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, }} - got, added, err := augmentModelCatalog(catalog, providers) + got, added, err := augmentModelCatalog(catalog, providers, "router") if err != nil { t.Fatal(err) } @@ -110,7 +113,7 @@ func TestAugmentModelCatalogLeavesUnknownModelsAlone(t *testing.T) { SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, }} - got, added, err := augmentModelCatalog(catalog, providers) + got, added, err := augmentModelCatalog(catalog, providers, "custom") if err != nil { t.Fatal(err) } @@ -122,6 +125,141 @@ func TestAugmentModelCatalogLeavesUnknownModelsAlone(t *testing.T) { } } +func TestAugmentModelCatalogOrdersVisibleModelsByProvider(t *testing.T) { + catalog := []byte(`{ + "models": [ + {"slug":"model-b","priority":20,"metadata":"native b"}, + {"slug":"user-short","priority":15}, + {"slug":"model-a","priority":10,"metadata":"native a"}, + {"slug":"hidden","priority":0,"visibility":"hide"}, + {"slug":"alpha/model-b","priority":99,"metadata":"existing alias","custom":true} + ] +}`) + providers := []config.ProviderInfo{ + { + ID: "zeta", + Models: []string{"model-b", "model-a"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }, + { + ID: "middle", + Models: []string{"vendor.model-b", "model-a"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }, + { + ID: "alpha", + Models: []string{"model-b", "model-a"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }, + { + ID: "chat-only", + Models: []string{"model-a"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIChat: true}, + }, + } + + got, added, err := augmentModelCatalog(catalog, providers, "middle") + if err != nil { + t.Fatal(err) + } + if added != 5 { + t.Fatalf("added = %d, want 5", added) + } + + document := decodeCatalog(t, got) + wantOrder := []string{ + "middle/model-a", + "middle/vendor.model-b", + "alpha/model-a", + "alpha/model-b", + "zeta/model-a", + "zeta/model-b", + "model-a", + "user-short", + "model-b", + } + if got := document.visiblePriorityOrder(t); !reflect.DeepEqual(got, wantOrder) { + t.Errorf("visible priority order:\n got: %q\nwant: %q", got, wantOrder) + } + for priority, slug := range wantOrder { + if got := document.priority(t, slug); got != priority+1 { + t.Errorf("priority for %q = %d, want %d", slug, got, priority+1) + } + } + if got := document.priority(t, "hidden"); got != 0 { + t.Errorf("hidden model priority = %d, want 0", got) + } + alphaB := document.model(t, "alpha/model-b") + if alphaB["metadata"] != "existing alias" || alphaB["custom"] != true { + t.Errorf("existing alias metadata was not preserved: %#v", alphaB) + } + if document.hasModel("chat-only/model-a") { + t.Error("chat-only provider unexpectedly received a Codex alias") + } +} + +func TestAugmentModelCatalogReordersExistingAliases(t *testing.T) { + catalog := []byte(`{"models":[ + {"slug":"model-b","priority":1}, + {"slug":"selected/model-b","priority":3}, + {"slug":"model-a","priority":2}, + {"slug":"selected/model-a","priority":4} +]}`) + providers := []config.ProviderInfo{{ + ID: "selected", + Models: []string{"model-b", "model-a"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }} + + got, added, err := augmentModelCatalog(catalog, providers, "selected") + if err != nil { + t.Fatal(err) + } + if added != 0 { + t.Fatalf("added = %d, want 0", added) + } + wantOrder := []string{"selected/model-b", "selected/model-a", "model-b", "model-a"} + if got := decodeCatalog(t, got).visiblePriorityOrder(t); !reflect.DeepEqual(got, wantOrder) { + t.Errorf("visible priority order:\n got: %q\nwant: %q", got, wantOrder) + } + + path, cleanup, err := writeModelCatalog(catalog, providers, "selected") + if err != nil { + t.Fatal(err) + } + if path == "" || cleanup == nil { + t.Fatal("writeModelCatalog did not write priority-only catalog changes") + } + t.Cleanup(cleanup) +} + +func TestAugmentModelCatalogLeavesOrderedExistingAliasesAlone(t *testing.T) { + catalog := []byte("{\n \"models\": [\n {\"slug\":\"selected/model\",\"priority\":1},\n {\"slug\":\"model\",\"priority\":2}\n ]\n}\n") + providers := []config.ProviderInfo{{ + ID: "selected", + Models: []string{"model"}, + SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, + }} + + got, added, err := augmentModelCatalog(catalog, providers, "selected") + if err != nil { + t.Fatal(err) + } + if added != 0 { + t.Fatalf("added = %d, want 0", added) + } + if !bytes.Equal(got, catalog) { + t.Error("catalog changed even though aliases and priorities were already current") + } + path, cleanup, err := writeModelCatalog(catalog, providers, "selected") + if err != nil { + t.Fatal(err) + } + if path != "" || cleanup != nil { + t.Error("writeModelCatalog wrote an unchanged catalog") + } +} + func TestAugmentModelCatalogRejectsInvalidCatalog(t *testing.T) { tests := map[string][]byte{ "invalid JSON": []byte(`{`), @@ -132,7 +270,7 @@ func TestAugmentModelCatalogRejectsInvalidCatalog(t *testing.T) { } for name, catalog := range tests { t.Run(name, func(t *testing.T) { - if _, _, err := augmentModelCatalog(catalog, nil); err == nil { + if _, _, err := augmentModelCatalog(catalog, nil, ""); err == nil { t.Fatal("augmentModelCatalog unexpectedly succeeded") } }) @@ -147,7 +285,7 @@ func TestWriteModelCatalogLifecycle(t *testing.T) { SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}, }} - path, cleanup, err := writeModelCatalog(catalog, providers) + path, cleanup, err := writeModelCatalog(catalog, providers, "mantle") if err != nil { t.Fatal(err) } @@ -214,3 +352,38 @@ func (c testCatalog) model(t *testing.T, slug string) map[string]any { t.Fatalf("model %q not found", slug) return nil } + +func (c testCatalog) priority(t *testing.T, slug string) int { + t.Helper() + model := c.model(t, slug) + priority, ok := model["priority"].(float64) + if !ok { + t.Fatalf("model %q has invalid priority %#v", slug, model["priority"]) + } + return int(priority) +} + +func (c testCatalog) visiblePriorityOrder(t *testing.T) []string { + t.Helper() + type prioritySlug struct { + priority int + slug string + } + var models []prioritySlug + for _, model := range c.Models { + if model["visibility"] == "hide" { + continue + } + slug, ok := model["slug"].(string) + if !ok || slug == "" { + continue + } + models = append(models, prioritySlug{priority: c.priority(t, slug), slug: slug}) + } + sort.Slice(models, func(i, j int) bool { return models[i].priority < models[j].priority }) + order := make([]string, len(models)) + for i, model := range models { + order[i] = model.slug + } + return order +} diff --git a/internal/clients/codex/codex.go b/internal/clients/codex/codex.go index 00b1db2..ff2afdb 100644 --- a/internal/clients/codex/codex.go +++ b/internal/clients/codex/codex.go @@ -155,7 +155,7 @@ func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) m if bin == "" { bin = binaryName } - modelCatalogPath, cleanup, err := prepareModelCatalog(bin, g.Providers) + modelCatalogPath, cleanup, err := prepareModelCatalog(bin, g.Providers, p.ID) if err != nil && g.Debug { fmt.Fprintf(os.Stderr, "\r\n[debug] unable to prepare Codex model aliases: %v\r\n", err) }