Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/sdk/go.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions sdk/go/openshell/v1/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions sdk/go/openshell/v1/gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,13 +387,29 @@ 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("")
require.Error(t, err)
assert.ErrorIs(t, err, ErrNoActiveGateway)
}

func TestLoadConfig_SystemActiveGateway(t *testing.T) {
Comment thread
TaylorMutch marked this conversation as resolved.
Resolved
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")
Expand Down Expand Up @@ -463,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])
}
23 changes: 18 additions & 5 deletions sdk/go/openshell/v1/gateway/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
TaylorMutch marked this conversation as resolved.
Resolved
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 {
Comment thread
TaylorMutch marked this conversation as resolved.
Resolved
return name, nil
}
}

if name, sysErr := readActiveGatewayFile(filepath.Join(systemConfigDir(), activeGatewayFile)); sysErr == nil {
Comment thread
TaylorMutch marked this conversation as resolved.
Resolved
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)
Expand Down
69 changes: 69 additions & 0 deletions sdk/go/openshell/v1/gateway/paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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))

Expand All @@ -216,6 +218,73 @@ func TestResolveActiveGateway_EmptyFile(t *testing.T) {
assert.ErrorIs(t, err, ErrNoActiveGateway)
}

// 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)
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)
assert.Equal(t, "corp", name)
}

func TestResolveActiveGateway_PrefersUser(t *testing.T) {
setActiveFiles(t, "mine", "corp")

name, err := resolveActiveGateway()
require.NoError(t, err)
assert.Equal(t, "mine", name)
}

func TestResolveActiveGateway_InvalidUserFallsBackToSystem(t *testing.T) {
setActiveFiles(t, "../escape", "corp")

name, err := resolveActiveGateway()
require.NoError(t, err)
assert.Equal(t, "corp", name)
}

func TestResolveActiveGateway_InvalidSystemIsIgnored(t *testing.T) {
setActiveFiles(t, "bad/user", "bad/system")

_, err := resolveActiveGateway()
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) {
cases := []struct {
name string
Expand Down
Loading