Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,9 @@ func fillAppDeps(deps appDeps) appDeps {
baseProbeProviderHealth := deps.probeProviderHealth
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
if options.OAuthResolver == nil {
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
}
return baseProbeProviderHealth(ctx, options)
}
return deps
Expand Down
61 changes: 61 additions & 0 deletions internal/cli/observability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/sessions"
)
Expand Down Expand Up @@ -109,6 +112,64 @@ func TestRunDoctorConnectivityProbesProvider(t *testing.T) {
}
}

func TestRunDoctorConnectivityUsesOAuthLogin(t *testing.T) {
tokenPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: tokenPath})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{
AccessToken: "oauth-doctor-token",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("seed OAuth token: %v", err)
}

var gotAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()

profile := config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
Model: "grok-4",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"doctor", "--connectivity"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: profile,
Providers: []config.ProviderProfile{profile},
ActiveProvider: profile.Name,
}, nil
},
probeProviderHealth: providerhealth.Probe,
now: fixedCLITime("2026-07-29T20:00:00Z"),
})

if exitCode != exitSuccess {
t.Fatalf("doctor exit = %d, want success; stderr=%q\n%s", exitCode, stderr.String(), stdout.String())
}
if gotAuth != "Bearer oauth-doctor-token" {
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
}
if !strings.Contains(stdout.String(), "[pass] provider.connectivity") {
t.Fatalf("doctor did not report passing connectivity:\n%s", stdout.String())
}
}

func TestRunDoctorConnectivityReportsProviderHealthFailure(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
Expand Down
82 changes: 71 additions & 11 deletions internal/providerhealth/providerhealth.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ const (
const defaultTimeout = 5 * time.Second

type Options struct {
Profile config.ProviderProfile
Connectivity bool
HTTPClient *http.Client
Resolver Resolver
Timeout time.Duration
UserAgent string
Profile config.ProviderProfile
Connectivity bool
HTTPClient *http.Client
Resolver Resolver
OAuthResolver providerio.TokenResolver
Timeout time.Duration
UserAgent string
}

type Resolver interface {
Expand Down Expand Up @@ -207,11 +208,26 @@ func Probe(ctx context.Context, options Options) Result {
"providerKind": metadata.ProviderKind,
}, profile))

if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) {
oauthConfigured := options.OAuthResolver != nil
if oauthConfigured {
var resolveErr error
profile, oauthConfigured, resolveErr = profileWithOAuthCredential(ctx, profile, options.OAuthResolver, false)
if resolveErr != nil {
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, "Provider OAuth credential could not be resolved: "+resolveErr.Error(), map[string]any{
"oauthLogin": true,
}, profile))
return result.finalize()
}
}
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) && !oauthConfigured {
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, fmt.Sprintf("Provider %s requires API credentials.", providerName(profile)), credentialDetails(profile), profile))
return result.finalize()
}
if hasCredential(profile) {
if oauthConfigured {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has OAuth credentials configured.", providerName(profile)), map[string]any{
"oauthLogin": true,
}, profile))
} else if hasCredential(profile) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has credentials configured.", providerName(profile)), credentialDetails(profile), profile))
} else {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s does not require API credentials.", providerName(profile)), credentialDetails(profile), profile))
Expand Down Expand Up @@ -286,9 +302,35 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
if err != nil {
return classifyTransportError(err, profile)
}
defer func() {
_ = response.Body.Close()
}()
defer func(body io.ReadCloser) {
_ = body.Close()
}(response.Body)
if response.StatusCode == http.StatusUnauthorized && options.OAuthResolver != nil {
refreshed, ok, refreshErr := profileWithOAuthCredential(requestCtx, options.Profile, options.OAuthResolver, true)
if refreshErr != nil {
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryAuth, "Provider OAuth credential refresh failed: "+refreshErr.Error(), map[string]any{
"statusCode": response.StatusCode,
}, profile)
}
if ok {
_ = response.Body.Close()
profile = refreshed
request, allowLoopbackOrPrivate, err = healthRequest(requestCtx, profile, kind, options)
if err != nil {
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryConfig, err.Error(), nil, profile)
}
if options.HTTPClient == nil {
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
}
response, err = client.Do(request)
if err != nil {
return classifyTransportError(err, profile)
}
defer func(body io.ReadCloser) {
_ = body.Close()
}(response.Body)
}
}

body, err := io.ReadAll(io.LimitReader(response.Body, 64*1024))
if err != nil {
Expand Down Expand Up @@ -320,6 +362,24 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
}, profile)
}

func profileWithOAuthCredential(ctx context.Context, profile config.ProviderProfile, resolver providerio.TokenResolver, forceRefresh bool) (config.ProviderProfile, bool, error) {
if resolver == nil {
return profile, false, nil
}
header, value, ok, err := resolver(ctx, forceRefresh)
if err != nil || !ok {
return profile, false, err
}
if strings.TrimSpace(header) == "" {
header = "Authorization"
}
profile.APIKey = ""
profile.AuthHeader = header
profile.AuthScheme = ""
profile.AuthHeaderValue = value
return profile, true, nil
}

func healthRequest(ctx context.Context, profile config.ProviderProfile, kind config.ProviderKind, options Options) (*http.Request, bool, error) {
baseURL, err := resolvedBaseURL(profile, kind)
if err != nil {
Expand Down
120 changes: 120 additions & 0 deletions internal/providerhealth/providerhealth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,126 @@ func TestProbeResolvedKindConnectivityAuthErrorRedactsSecret(t *testing.T) {
}
}

func TestProbeConnectivityUsesOAuthCredential(t *testing.T) {
var gotAuth string
resolverCalled := false
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotAuth = r.Header.Get("Authorization")
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
Header: make(http.Header),
}, nil
})}

result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
resolverCalled = true
return "Authorization", "Bearer oauth-test-token", true, nil
},
})

if result.Status != StatusPass {
t.Fatalf("Status = %q, want pass: %#v", result.Status, result.Checks)
}
if !resolverCalled {
t.Fatal("OAuth resolver was not called")
}
if gotAuth != "Bearer oauth-test-token" {
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
}
}

func TestProbeWithoutConnectivityRejectsUnavailableOAuthCredential(t *testing.T) {
resolverCalled := false
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
resolverCalled = true
return "", "", false, nil
},
})

if !resolverCalled {
t.Fatal("OAuth resolver was not called")
}
if result.Status != StatusFail {
t.Fatalf("Status = %q, want fail: %#v", result.Status, result.Checks)
}
check := result.Check("provider.auth")
if check == nil || check.Status != StatusFail || check.Category != CategoryAuth {
t.Fatalf("provider.auth = %#v, want auth failure", check)
}
}

func TestProbeConnectivityRefreshesOAuthCredentialAfterUnauthorized(t *testing.T) {
var authHeaders []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
status := http.StatusOK
body := `{"data":[]}`
if len(authHeaders) == 1 {
status = http.StatusUnauthorized
body = `{"error":{"message":"expired"}}`
}
return &http.Response{
StatusCode: status,
Status: http.StatusText(status),
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
})}

var refreshFlags []bool
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(_ context.Context, forceRefresh bool) (string, string, bool, error) {
refreshFlags = append(refreshFlags, forceRefresh)
token := "stale-token"
if forceRefresh {
token = "fresh-token"
}
return "Authorization", "Bearer " + token, true, nil
},
})

if result.Status != StatusPass {
t.Fatalf("Status = %q, want pass after refresh: %#v", result.Status, result.Checks)
}
if len(refreshFlags) != 2 || refreshFlags[0] || !refreshFlags[1] {
t.Fatalf("OAuth refresh flags = %#v, want [false true]", refreshFlags)
}
if len(authHeaders) != 2 || authHeaders[0] != "Bearer stale-token" || authHeaders[1] != "Bearer fresh-token" {
t.Fatalf("Authorization headers = %#v, want stale then fresh bearer", authHeaders)
}
}

func TestProbeConnectivityClassifiesTimeout(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
Expand Down
Loading