From 3ded94f78ef8b2f216f56dcfdc962ba655e526aa Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Sun, 25 Oct 2026 21:15:02 +0000 Subject: [PATCH 1/2] fix(sdk/go): fall back to system active gateway Match the CLI: use the system active_gateway when the user one is missing or invalid. Signed-off-by: Eric Curtin --- sdk/go/openshell/v1/gateway/gateway.go | 6 +-- sdk/go/openshell/v1/gateway/gateway_test.go | 16 +++++++ sdk/go/openshell/v1/gateway/paths.go | 23 +++++++--- sdk/go/openshell/v1/gateway/paths_test.go | 49 +++++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go index dde3545664..888769c1c1 100644 --- a/sdk/go/openshell/v1/gateway/gateway.go +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -13,7 +13,7 @@ import ( // NewClient creates a fully wired SDK client from an on-disk gateway // configuration. If name is empty, the active gateway (set via -// `openshell gateway use`) is used. +// `openshell gateway use`, or the system default) is used. // // The function resolves the gateway directory, parses metadata.json, // loads tokens lazily, maps the auth mode to an SDK auth provider, and @@ -66,8 +66,8 @@ func NewClient(name string, opts ...ClientOption) (*v1.Client, error) { } // LoadConfig reads and parses a gateway's on-disk configuration without -// creating a client connection. If name is empty, the active gateway is -// used. +// creating a client connection. If name is empty, the active gateway (user +// selection, then system default) is used. // // The returned [Config] is an immutable snapshot; changes to the on-disk // files after this call are not reflected. diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go index e22a0a7873..72cb17f672 100644 --- a/sdk/go/openshell/v1/gateway/gateway_test.go +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -387,6 +387,7 @@ func TestNewClient_ActiveGateway(t *testing.T) { func TestNewClient_NoActiveGateway(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, t.TempDir()) require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) _, err := NewClient("") @@ -394,6 +395,21 @@ func TestNewClient_NoActiveGateway(t *testing.T) { assert.ErrorIs(t, err, ErrNoActiveGateway) } +func TestLoadConfig_SystemActiveGateway(t *testing.T) { + user, system := t.TempDir(), t.TempDir() + t.Setenv("XDG_CONFIG_HOME", user) + t.Setenv(systemGatewayDirEnv, system) + writeFile(t, system, "active_gateway", "corp") + gwDir := filepath.Join(system, "gateways", "corp") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"host:443"}`) + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "corp", cfg.Name) + assert.Equal(t, SourceSystem, cfg.Source) +} + func TestLoadConfig_ActiveGateway(t *testing.T) { tmp := setupGateway(t, "my-active", `{"gateway_endpoint":"host:443","auth_mode":"oidc"}`) writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "my-active") diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go index 7107db8bc7..00c67d050b 100644 --- a/sdk/go/openshell/v1/gateway/paths.go +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -137,16 +137,29 @@ func validateGatewayName(name string) error { return nil } -// resolveActiveGateway reads the active_gateway file from the user -// config directory and returns the validated gateway name. Returns -// ErrNoActiveGateway if the file is missing or empty. +// resolveActiveGateway returns the validated active gateway name. It reads +// the user active_gateway file and falls back to the system one when the user +// file is missing, empty, or invalid, matching the CLI. Returns +// ErrNoActiveGateway describing the user file when neither yields a name. func resolveActiveGateway() (string, error) { userBase, err := userConfigDir() if err != nil { - return "", fmt.Errorf("%w: cannot determine config directory: %v", ErrNoActiveGateway, err) + err = fmt.Errorf("%w: cannot determine config directory: %v", ErrNoActiveGateway, err) + } else { + var name string + if name, err = readActiveGatewayFile(filepath.Join(userBase, activeGatewayFile)); err == nil { + return name, nil + } + } + + if name, sysErr := readActiveGatewayFile(filepath.Join(systemConfigDir(), activeGatewayFile)); sysErr == nil { + return name, nil } + return "", err +} - path := filepath.Join(userBase, activeGatewayFile) +// readActiveGatewayFile reads and validates the gateway name stored at path. +func readActiveGatewayFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("%w", ErrNoActiveGateway) diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go index ab22b85f97..524d521f66 100644 --- a/sdk/go/openshell/v1/gateway/paths_test.go +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -198,6 +198,7 @@ func TestResolveActiveGateway_WhitespaceHandling(t *testing.T) { func TestResolveActiveGateway_FileMissing(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, t.TempDir()) require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) _, err := resolveActiveGateway() @@ -208,6 +209,7 @@ func TestResolveActiveGateway_FileMissing(t *testing.T) { func TestResolveActiveGateway_EmptyFile(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, t.TempDir()) require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" \n"), 0o644)) @@ -216,6 +218,53 @@ func TestResolveActiveGateway_EmptyFile(t *testing.T) { assert.ErrorIs(t, err, ErrNoActiveGateway) } +func TestResolveActiveGateway_FallsBackToSystem(t *testing.T) { + user, system := t.TempDir(), t.TempDir() + t.Setenv("XDG_CONFIG_HOME", user) + t.Setenv(systemGatewayDirEnv, system) + require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("corp\n"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "corp", name) +} + +func TestResolveActiveGateway_PrefersUser(t *testing.T) { + user, system := t.TempDir(), t.TempDir() + t.Setenv("XDG_CONFIG_HOME", user) + t.Setenv(systemGatewayDirEnv, system) + require.NoError(t, os.MkdirAll(filepath.Join(user, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(user, "openshell", "active_gateway"), []byte("mine"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("corp"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "mine", name) +} + +func TestResolveActiveGateway_InvalidUserFallsBackToSystem(t *testing.T) { + user, system := t.TempDir(), t.TempDir() + t.Setenv("XDG_CONFIG_HOME", user) + t.Setenv(systemGatewayDirEnv, system) + require.NoError(t, os.MkdirAll(filepath.Join(user, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(user, "openshell", "active_gateway"), []byte("../escape"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("corp"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "corp", name) +} + +func TestResolveActiveGateway_InvalidSystemIsIgnored(t *testing.T) { + user, system := t.TempDir(), t.TempDir() + t.Setenv("XDG_CONFIG_HOME", user) + t.Setenv(systemGatewayDirEnv, system) + require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("../escape"), 0o644)) + + _, err := resolveActiveGateway() + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + func TestValidateGatewayName_SpecialCharacters(t *testing.T) { cases := []struct { name string From 017959a020502452f4a36d6bb13119b63f9c272a Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 2 Nov 2026 21:34:15 +0000 Subject: [PATCH 2/2] test(sdk/go): cover system active gateway paths Signed-off-by: Eric Curtin --- docs/sdk/go.mdx | 7 +++ sdk/go/openshell/v1/gateway/gateway.go | 1 + sdk/go/openshell/v1/gateway/gateway_test.go | 16 ++++++ sdk/go/openshell/v1/gateway/paths_test.go | 58 ++++++++++++++------- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/docs/sdk/go.mdx b/docs/sdk/go.mdx index 86a011e00d..5c7779a142 100644 --- a/docs/sdk/go.mdx +++ b/docs/sdk/go.mdx @@ -72,6 +72,13 @@ Omit `CAFile` to use system roots. For renewable OIDC service credentials, use `oidc.NewClientCredentialsAuth` from `github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc`. +## Discover the Active Gateway + +`gateway.NewClient("")` and `gateway.LoadConfig("")` select the active gateway +from the user `active_gateway` file first, then fall back to the system +`/etc/openshell/active_gateway`. `gateway.ListGateways` marks the selected +gateway as active, including a system default. + ## Create and Use a Sandbox Resource methods take an explicit workspace. The following example creates a diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go index 888769c1c1..b0b62c7e64 100644 --- a/sdk/go/openshell/v1/gateway/gateway.go +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -111,6 +111,7 @@ func loadConfigInternal(name string) (*Config, error) { // directories. User gateways appear first. If the same name exists in // both directories, only the user gateway is returned (user precedence). // Returns an empty slice (not an error) when no gateways are configured. +// Active also reflects the system default when no user selection exists. // // ListGateways is safe for concurrent use from multiple goroutines. func ListGateways() ([]Info, error) { diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go index 72cb17f672..13894cfcfd 100644 --- a/sdk/go/openshell/v1/gateway/gateway_test.go +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -479,3 +479,19 @@ func TestListGateways_ActiveStatus(t *testing.T) { } } } + +func TestListGateways_SystemActiveStatus(t *testing.T) { + user, system := t.TempDir(), t.TempDir() + t.Setenv("XDG_CONFIG_HOME", user) + t.Setenv(systemGatewayDirEnv, system) + require.NoError(t, os.MkdirAll(filepath.Join(user, "openshell", "gateways", "mine"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(system, "gateways", "corp"), 0o755)) + writeFile(t, system, "active_gateway", "corp") + + gateways, err := ListGateways() + require.NoError(t, err) + require.Len(t, gateways, 2) + + assert.Equal(t, Info{Name: "mine", Active: false, Source: SourceUser}, gateways[0]) + assert.Equal(t, Info{Name: "corp", Active: true, Source: SourceSystem}, gateways[1]) +} diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go index 524d521f66..fa3fb0d18a 100644 --- a/sdk/go/openshell/v1/gateway/paths_test.go +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -218,11 +218,24 @@ func TestResolveActiveGateway_EmptyFile(t *testing.T) { assert.ErrorIs(t, err, ErrNoActiveGateway) } -func TestResolveActiveGateway_FallsBackToSystem(t *testing.T) { +// setActiveFiles points the user and system config dirs at temp dirs and +// writes active_gateway files when the given content is non-empty. +func setActiveFiles(t *testing.T, userContent, systemContent string) { + t.Helper() user, system := t.TempDir(), t.TempDir() t.Setenv("XDG_CONFIG_HOME", user) t.Setenv(systemGatewayDirEnv, system) - require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("corp\n"), 0o644)) + if userContent != "" { + require.NoError(t, os.MkdirAll(filepath.Join(user, "openshell"), 0o755)) + writeFile(t, filepath.Join(user, "openshell"), "active_gateway", userContent) + } + if systemContent != "" { + writeFile(t, system, "active_gateway", systemContent) + } +} + +func TestResolveActiveGateway_FallsBackToSystem(t *testing.T) { + setActiveFiles(t, "", "corp\n") name, err := resolveActiveGateway() require.NoError(t, err) @@ -230,12 +243,7 @@ func TestResolveActiveGateway_FallsBackToSystem(t *testing.T) { } func TestResolveActiveGateway_PrefersUser(t *testing.T) { - user, system := t.TempDir(), t.TempDir() - t.Setenv("XDG_CONFIG_HOME", user) - t.Setenv(systemGatewayDirEnv, system) - require.NoError(t, os.MkdirAll(filepath.Join(user, "openshell"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(user, "openshell", "active_gateway"), []byte("mine"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("corp"), 0o644)) + setActiveFiles(t, "mine", "corp") name, err := resolveActiveGateway() require.NoError(t, err) @@ -243,12 +251,7 @@ func TestResolveActiveGateway_PrefersUser(t *testing.T) { } func TestResolveActiveGateway_InvalidUserFallsBackToSystem(t *testing.T) { - user, system := t.TempDir(), t.TempDir() - t.Setenv("XDG_CONFIG_HOME", user) - t.Setenv(systemGatewayDirEnv, system) - require.NoError(t, os.MkdirAll(filepath.Join(user, "openshell"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(user, "openshell", "active_gateway"), []byte("../escape"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("corp"), 0o644)) + setActiveFiles(t, "../escape", "corp") name, err := resolveActiveGateway() require.NoError(t, err) @@ -256,13 +259,30 @@ func TestResolveActiveGateway_InvalidUserFallsBackToSystem(t *testing.T) { } func TestResolveActiveGateway_InvalidSystemIsIgnored(t *testing.T) { - user, system := t.TempDir(), t.TempDir() - t.Setenv("XDG_CONFIG_HOME", user) - t.Setenv(systemGatewayDirEnv, system) - require.NoError(t, os.WriteFile(filepath.Join(system, "active_gateway"), []byte("../escape"), 0o644)) + setActiveFiles(t, "bad/user", "bad/system") _, err := resolveActiveGateway() - assert.ErrorIs(t, err, ErrNoActiveGateway) + require.ErrorIs(t, err, ErrNoActiveGateway) + assert.Contains(t, err.Error(), "bad/user") + assert.NotContains(t, err.Error(), "bad/system") +} + +func TestResolveActiveGateway_UserDirErrorFallsBackToSystem(t *testing.T) { + setActiveFiles(t, "", "corp") + t.Setenv("XDG_CONFIG_HOME", "relative/path") + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "corp", name) +} + +func TestResolveActiveGateway_UserDirErrorNoSystem(t *testing.T) { + setActiveFiles(t, "", "") + t.Setenv("XDG_CONFIG_HOME", "relative/path") + + _, err := resolveActiveGateway() + require.ErrorIs(t, err, ErrNoActiveGateway) + assert.Contains(t, err.Error(), "cannot determine config directory") } func TestValidateGatewayName_SpecialCharacters(t *testing.T) {