From 9cb692f21ca38a06a6ac59fd00c4f5179da45ae1 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 22 Aug 2026 16:10:18 +0000 Subject: [PATCH] fix: support provider-aware direct launches --- launch.go | 7 ++++ layout.go | 9 +++++ main.go | 3 ++ main_test.go | 84 +++++++++++++++++++++++++++++++++++++++----- model.go | 39 ++++++++++++++++++--- render.go | 4 ++- routing.go | 98 +++++++++++++++++++++++++++++++++++++++++++++++++--- update.go | 10 ++++++ vault.go | 4 +++ 9 files changed, 240 insertions(+), 18 deletions(-) diff --git a/launch.go b/launch.go index ae1f3c2..46ed247 100644 --- a/launch.go +++ b/launch.go @@ -92,6 +92,13 @@ func runTrusted(envName string, fallbacks []string, fmt.Fprintln(os.Stderr, "code: trusted launcher not found:", err) return 1 } + if !broker.configured() { + err = runChild(path, argv(path, os.Args[1:], prompt), withoutAuthEnv(os.Environ())) + if err != nil { + fmt.Fprintln(os.Stderr, "code: trusted child:", err) + } + return childStatus(err) + } accounts, err := loadAccounts(broker) if err != nil { fmt.Fprintln(os.Stderr, "code: account snapshot unavailable; refusing unrestricted launch:", err) diff --git a/layout.go b/layout.go index 1e681d8..0eba000 100644 --- a/layout.go +++ b/layout.go @@ -174,6 +174,15 @@ func (m model) bodyLines() ([]string, int) { // target gets the same footer shape with an honest summary instead of meters: // its tokens are free and code has no measurement to quote. func (m model) launchFooter() []string { + if m.noProviders { + return []string{ + "", + stDim.Render(" no connected OMP providers"), + "", + "", + stDim.Render(" m open managed OMP to log in"), + } + } acc := lipgloss.NewStyle().Foreground(lipgloss.Color(m.accent())).Bold(true).Render(" ⏎ launch") if _, local := m.selectedRuntime(); local { return []string{ diff --git a/main.go b/main.go index d4f6b26..5088d62 100644 --- a/main.go +++ b/main.go @@ -100,6 +100,9 @@ func main() { // The catalog decides which dials exist at all; a persisted or default // selection must not open on a combo it never generated. m.applyCatalog() + if cachedAvailability.accountsOK { + m.applyProviderAvailability(connectedPools(cachedAvailability.accounts)) + } // First run: no catalog anywhere and no explicit CODE_GENERATED — wrap the // TUI in the guided onboarding that builds one (an explicit but broken // CODE_GENERATED is an operator config error and is left visible as the diff --git a/main_test.go b/main_test.go index 0d3a477..10fa17b 100644 --- a/main_test.go +++ b/main_test.go @@ -1226,6 +1226,7 @@ func TestRawMouseBurstRemainsResponsive(t *testing.T) { wide, _, _, _ := layoutSizes(t, m) m = resize(t, m, wide.w, wide.h) m.broker = brokerConfig{} // keep unrelated fetch/ticks out of the program + m.providersResolved = true var views atomic.Int64 keySeen := make(chan burstKeyState, 1) filter := wheelInputFilter{} @@ -3593,20 +3594,27 @@ cat "$OMP_AUTH_BROKER_ACCOUNT_POOL_FILE" > "$ACCOUNT_POOL_COPY" } } -func TestTrustedLaunchAbortsWithoutSnapshot(t *testing.T) { +func TestTrustedLaunchWithoutBrokerUsesLocalOMPAuth(t *testing.T) { dir := t.TempDir() - marker := filepath.Join(dir, "started") + capture := filepath.Join(dir, "env") script := filepath.Join(dir, "omp") - if err := os.WriteFile(script, []byte("#!/bin/sh\n: > \"$MARKER\"\n"), 0o700); err != nil { + body := "#!/bin/sh\nprintf '%s|%s|%s\\n' \"${OMP_AUTH_BROKER_URL+set}\" \"${OMP_AUTH_BROKER_TOKEN+set}\" \"${OMP_AUTH_BROKER_ACCOUNT_POOL_FILE+set}\" > \"$CAPTURE\"\n" + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { t.Fatal(err) } t.Setenv("CODE_OMP", script) - t.Setenv("MARKER", marker) - if status := runTrusted("CODE_OMP", nil, managedLaunchArgv, "", brokerConfig{}, defaultAccountSelectionState()); status == 0 { - t.Fatal("trusted launch without a snapshot unexpectedly succeeded") + t.Setenv("CAPTURE", capture) + t.Setenv("OMP_AUTH_BROKER_URL", "http://incomplete") + t.Setenv("OMP_AUTH_BROKER_ACCOUNT_POOL_FILE", "/tmp/stale-pool") + if status := runTrusted("CODE_OMP", nil, managedLaunchArgv, "", brokerConfig{}, defaultAccountSelectionState()); status != 0 { + t.Fatalf("direct trusted launch status = %d", status) + } + raw, err := os.ReadFile(capture) + if err != nil { + t.Fatal(err) } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatalf("trusted child started without a snapshot: %v", err) + if got := strings.TrimSpace(string(raw)); got != "||" { + t.Fatalf("direct child retained broker environment: %q", got) } } @@ -3744,6 +3752,66 @@ func TestApplyCatalogGrowsLaneDial(t *testing.T) { } } +func TestProviderAvailabilityFiltersDisconnectedLanes(t *testing.T) { + m := model{ + generated: map[string][]string{ + "gpt-only_fast_low_nosp_nofa": {" default gpt:low"}, + "gpt-led_fast_low_nosp_nofa": {" default gpt:low"}, + "mixed_fast_low_nosp_nofa": {" default gpt:low"}, + "claude-only_fast_low_nosp_nofa": {" default claude:low"}, + "ox-only_fast_low_nosp_nofa": {" default ox:low"}, + }, + facets: facetDefs(defaultGlyphs()), + sel: defaultSel(), + } + m.applyCatalog() + m.applyProviderAvailability(map[string]bool{"R": true}) + + var lanes []string + for _, f := range m.facets { + if f.key == "lane" { + lanes = f.values + } + } + if !reflect.DeepEqual(lanes, []string{"ox-only"}) { + t.Fatalf("OpenRouter-only lanes = %v, want [ox-only]", lanes) + } + if m.sel["lane"] != "ox-only" || m.noProviders { + t.Fatalf("OpenRouter-only selection = %q noProviders=%v", m.sel["lane"], m.noProviders) + } + for _, f := range m.visibleFacets() { + if f.key == "blend" { + t.Fatalf("single available lane exposed an unavailable blend: %v", f.values) + } + } + + m.applyProviderAvailability(nil) + if !m.noProviders || len(m.visibleFacets()) != 0 { + t.Fatalf("accountless generator remained actionable: noProviders=%v facets=%v", m.noProviders, m.visibleFacets()) + } + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if got := next.(model); got.genConfig != "" || cmd != nil { + t.Fatalf("accountless Enter launched: config=%q cmd=%v", got.genConfig, cmd) + } + if footer := strings.Join(m.launchFooter(), "\n"); !strings.Contains(footer, "m open managed OMP to log in") { + t.Fatalf("accountless footer is not actionable: %q", footer) + } +} + +func TestDirectProviderProbeUsesOMPToken(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "omp") + body := "#!/bin/sh\n[ \"$1\" = token ] && [ \"$2\" = openrouter ]\n" + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("CODE_OMP", script) + msg := probeProviderAvailabilityCmd()().(providerAvailabilityMsg) + if !reflect.DeepEqual(msg.pools, map[string]bool{"R": true}) { + t.Fatalf("provider probe pools = %v, want R only", msg.pools) + } +} + // TestUsageBodyDeepSeekBalanceGroup: the DeepSeek usage group renders only // when a credential exists (an absent API key is the normal state, not "not // authenticated"), shows the prepaid balance with no bar or reset, and diff --git a/model.go b/model.go index f75056d..84afb0c 100644 --- a/model.go +++ b/model.go @@ -1,16 +1,17 @@ package main import ( + "io" + "os" "os/exec" "regexp" "strconv" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" ) type model struct { @@ -28,7 +29,9 @@ type model struct { noFable bool // no _fa_/_famain_ combos — same for fable and its main child // hasRelief is positive-polarity: the relief segment only exists in // catalogs with an optional pool, so the zero value keeps old ids intact. - hasRelief bool // _rel_/_norel combos exist — show the relief dial + hasRelief bool // _rel_/_norel combos exist — show the relief dial + providersResolved bool // connected-provider discovery completed + noProviders bool // discovery found no provider usable by this catalog depth int // 0 lead · 1 full collapse bool // p: hide the Routing section @@ -98,6 +101,32 @@ func fetchUsageCmd(broker brokerConfig) tea.Cmd { return func() tea.Msg { return usageMsg{avail: loadAvailability(broker)} } } +type providerAvailabilityMsg struct { + pools map[string]bool +} + +// probeProviderAvailabilityCmd asks the same OMP binary Code launches which +// providers have usable local credentials. Broker-backed installations get +// this information from their account snapshot instead. +func probeProviderAvailabilityCmd() tea.Cmd { + return func() tea.Msg { + pools := map[string]bool{} + path, err := resolveLaunchPath("CODE_OMP", []string{"omp"}) + if err != nil { + return providerAvailabilityMsg{pools: pools} + } + for _, provider := range providerRegistry { + cmd := exec.Command(path, "token", provider.ID) + cmd.Env = withoutAuthEnv(os.Environ()) + cmd.Stdout, cmd.Stderr = io.Discard, io.Discard + if cmd.Run() == nil { + pools[provider.Pool] = true + } + } + return providerAvailabilityMsg{pools: pools} + } +} + func (m *model) startUsageFetch() tea.Cmd { cmd := fetchUsageCmd(m.broker) if cmd != nil { @@ -165,8 +194,10 @@ func (m model) ompVersionAtLeast(major, minor int) bool { func (m model) Init() tea.Cmd { cmds := []tea.Cmd{probeOmpVersionCmd()} - if m.broker.URL != "" && m.broker.Token != "" { + if m.broker.configured() { cmds = append(cmds, m.startUsageFetch(), m.spin.Tick, tickCmd()) + } else if !m.providersResolved { + cmds = append(cmds, probeProviderAvailabilityCmd()) } return tea.Batch(cmds...) } diff --git a/render.go b/render.go index 5f5007c..5a1332d 100644 --- a/render.go +++ b/render.go @@ -143,7 +143,9 @@ func (m *model) syncPreviewAt(yoff int) { return } id := comboID(m.sel, m.hasRelief) - if base, ok := m.generated[id]; ok { + if m.noProviders { + b.WriteString(stDim.Render("no connected OMP providers") + "\n") + } else if base, ok := m.generated[id]; ok { _, roles := splitMeta(base) roles = m.applyAdvisor(roles, m.sel["advisor"]) b.WriteString(m.renderRoute(roles, m.depth, m.selectedLaunchAvailability(), rw)) diff --git a/routing.go b/routing.go index 2448ca5..d053a2d 100644 --- a/routing.go +++ b/routing.go @@ -315,16 +315,24 @@ func (m model) visibleFacets() []facet { } return out } + if m.noProviders { + return nil + } lane := m.sel["lane"] var out []facet for _, f := range m.facets { if f.key == "lane" { lead, blend := laneSplit(lane) m.sel["lead"], m.sel["blend"] = lead, blend - // mixed leads the dial: it is the default and the only lead - // without a blend child, so it anchors the left edge. - leads := []string{"mixed"} - seen := map[string]bool{"mixed": true} + var leads []string + seen := map[string]bool{} + for _, v := range f.values { + if v == "mixed" { + leads = append(leads, "mixed") + seen["mixed"] = true + break + } + } for _, v := range f.values { l, _ := laneSplit(v) if !seen[l] { @@ -334,7 +342,22 @@ func (m model) visibleFacets() []facet { } out = append(out, facet{"lead", leads, f.glyph}) if lead != "mixed" { - out = append(out, facet{"blend", []string{"led", "only"}, f.glyph}) + availableBlends := map[string]bool{} + for _, v := range f.values { + l, b := laneSplit(v) + if l == lead { + availableBlends[b] = true + } + } + var blends []string + for _, b := range []string{"led", "only"} { + if availableBlends[b] { + blends = append(blends, b) + } + } + if len(blends) > 1 { + out = append(out, facet{"blend", blends, f.glyph}) + } } continue } @@ -412,6 +435,71 @@ func comboID(sel map[string]string, hasRelief bool) string { return id } +func connectedPools(accounts map[string][]account) map[string]bool { + pools := map[string]bool{} + for providerID, providerAccounts := range accounts { + if len(providerAccounts) == 0 { + continue + } + if provider := providerByID(providerID); provider != nil { + pools[provider.Pool] = true + } + } + return pools +} + +// applyProviderAvailability narrows the generated lane catalog to credentials +// OMP can actually use. Pure lanes need only their own provider; blended lanes +// need every provider represented by the catalog because their role and +// fallback chains can cross the full pool set. +func (m *model) applyProviderAvailability(connected map[string]bool) { + m.providersResolved = true + catalog := catalogLanes(m.generated) + catalogPools := map[string]bool{} + for _, lane := range catalog { + if lanePure(lane) { + if provider := providerByLane(lane); provider != nil { + catalogPools[provider.Pool] = true + } + } + } + var available []string + for _, lane := range catalog { + if provider := providerByLane(lane); lanePure(lane) && provider != nil { + if connected[provider.Pool] { + available = append(available, lane) + } + continue + } + usable := len(catalogPools) > 0 + for pool := range catalogPools { + if !connected[pool] { + usable = false + break + } + } + if usable { + available = append(available, lane) + } + } + m.noProviders = len(available) == 0 + if m.noProviders { + return + } + for i := range m.facets { + if m.facets[i].key == "lane" { + m.facets[i].values = available + break + } + } + served := make(map[string]bool, len(available)) + for _, lane := range available { + served[lane] = true + } + m.trimLanes(served) + m.clampSel() +} + // applyCatalog records which dials this catalog can actually serve, then forces // the rest off. A models file with no tier-0 model yields no _sp_ combos at all, // so the shipped default (spark on) would open the TUI on a combo that was never diff --git a/update.go b/update.go index a47a5f8..6d3a9ba 100644 --- a/update.go +++ b/update.go @@ -19,6 +19,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ompMajor, m.ompMinor = msg.major, msg.minor } return m, nil + case providerAvailabilityMsg: + m.applyProviderAvailability(msg.pools) + m.relayout() + return m, nil case usageMsg: scoped, scopedStale := reconcileUsage(m.avail, msg.avail) refreshAt := time.Now().Add(refreshEvery) @@ -30,6 +34,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { saveUsageCache(m.usageCache, scoped) } m.nextRefresh = refreshAt + if msg.avail.accountsOK { + m.applyProviderAvailability(connectedPools(msg.avail.accounts)) + } m.relayout() if first { // The first real data replaces the skeleton: run the one-time @@ -179,6 +186,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.launchRuntime = target.Name return m, tea.Quit } + if m.noProviders { + return m, nil + } // Enter always launches the generated profile for the current facets — // the untouched default combo is a generated profile like any other. // Never for a combo the catalog doesn't carry, though: genConfigYAML diff --git a/vault.go b/vault.go index 25ada86..66c5e48 100644 --- a/vault.go +++ b/vault.go @@ -38,6 +38,10 @@ type brokerConfig struct { SnapshotCache string } +func (b brokerConfig) configured() bool { + return strings.TrimSpace(b.URL) != "" && strings.TrimSpace(b.Token) != "" +} + // resolveBroker uses the inherited central broker whenever any central broker // variable is set. The legacy manifest is consulted only as a staged fallback // for installations which have not yet exported the central variables.