diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml
index 24eb4a54f7..883c1f07b2 100644
--- a/.github/workflows/e2e-docker-test.yml
+++ b/.github/workflows/e2e-docker-test.yml
@@ -33,7 +33,7 @@ on:
required: false
type: string
default: >-
- [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]
+ [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"go","cmd":"mise run --no-deps --skip-deps e2e:go:docker","apt_packages":"","python_proto":false,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]
permissions:
actions: read
diff --git a/AGENTS.md b/AGENTS.md
index b049c28f50..f31b97c03f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -187,6 +187,7 @@ ocsf_emit!(event);
- Domain types in `sdk/go/openshell/v1/types/` must not import proto packages.
- Converters in `sdk/go/openshell/v1/internal/converter/` deep-copy slices and maps at boundaries.
- Tests use bufconn for in-process gRPC and testify for assertions.
+- E2E tests live in `e2e/go/` (a separate Go module using a `replace` directive to `sdk/go/`, gated by the `e2e` build tag) and connect to a real gateway via `gateway.NewClient`. Run `mise run e2e:go` with Podman or `mise run e2e:go:docker` with Docker.
## TypeScript SDK (`sdk/typescript/`)
diff --git a/e2e/go/go.mod b/e2e/go/go.mod
new file mode 100644
index 0000000000..50c23184e6
--- /dev/null
+++ b/e2e/go/go.mod
@@ -0,0 +1,24 @@
+module github.com/NVIDIA/OpenShell/e2e/go
+
+go 1.25.13
+
+require (
+ github.com/NVIDIA/OpenShell/sdk/go v0.0.0-00010101000000-000000000000
+ github.com/stretchr/testify v1.11.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/net v0.58.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/grpc v1.83.2 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+replace github.com/NVIDIA/OpenShell/sdk/go => ../../sdk/go
diff --git a/e2e/go/go.sum b/e2e/go/go.sum
new file mode 100644
index 0000000000..184fd72f18
--- /dev/null
+++ b/e2e/go/go.sum
@@ -0,0 +1,52 @@
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/e2e/go/harness_test.go b/e2e/go/harness_test.go
new file mode 100644
index 0000000000..42694a22af
--- /dev/null
+++ b/e2e/go/harness_test.go
@@ -0,0 +1,279 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+// Package e2e contains end-to-end tests for the Go SDK, run against a real
+// OpenShell gateway with `mise run e2e:go` (Podman) or
+// `mise run e2e:go:docker` (Docker).
+package e2e
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
+ "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway"
+ "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
+)
+
+var (
+ sharedClient *v1.Client
+ gatewayName string
+)
+
+func TestMain(m *testing.M) {
+ gatewayName = os.Getenv("OPENSHELL_GATEWAY")
+ if gatewayName == "" {
+ // No gateway configured; requireClient skips every test individually.
+ os.Exit(m.Run())
+ }
+
+ cfg, err := gateway.LoadConfig(gatewayName)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "e2e: failed to load gateway config: %v\n", err)
+ os.Exit(1)
+ }
+
+ var opts []gateway.ClientOption
+ if opt := mtlsClientOption(cfg.Dir); opt != nil {
+ opts = append(opts, opt)
+ }
+
+ client, err := gateway.NewClient(gatewayName, opts...)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "e2e: failed to build gateway client: %v\n", err)
+ os.Exit(1)
+ }
+ sharedClient = client
+
+ if err := waitForPersistenceReady(sharedClient); err != nil {
+ fmt.Fprintf(os.Stderr, "e2e: %v\n", err)
+ _ = sharedClient.Close()
+ os.Exit(1)
+ }
+
+ code := m.Run()
+ _ = sharedClient.Close()
+ os.Exit(code)
+}
+
+// mtlsClientOption inspects
/mtls for an on-disk certificate bundle
+// written by e2e_register_mtls_gateway (see e2e/support/gateway-common.sh)
+// and returns a ClientOption applying it. Returns nil when no bundle is
+// present (e.g. plaintext or OIDC e2e lanes), so callers should skip it.
+func mtlsClientOption(dir string) gateway.ClientOption {
+ mtlsDir := filepath.Join(dir, "mtls")
+ caPath := filepath.Join(mtlsDir, "ca.crt")
+ if _, err := os.Stat(caPath); err != nil {
+ return nil
+ }
+
+ cfg := &types.TLSConfig{CAFile: caPath}
+
+ certPath := filepath.Join(mtlsDir, "tls.crt")
+ keyPath := filepath.Join(mtlsDir, "tls.key")
+ if _, err := os.Stat(certPath); err == nil {
+ if _, err := os.Stat(keyPath); err == nil {
+ cfg.CertFile = certPath
+ cfg.KeyFile = keyPath
+ }
+ }
+
+ return gateway.WithTLS(cfg)
+}
+
+// requireClient returns the shared gateway client, skipping the calling test
+// when OPENSHELL_GATEWAY was not set at process startup.
+func requireClient(t *testing.T) *v1.Client {
+ t.Helper()
+ if gatewayName == "" {
+ t.Skip("OPENSHELL_GATEWAY not set")
+ }
+ return sharedClient
+}
+
+// waitForPersistenceReady polls the gateway until its persistence layer is
+// initialized, tolerating the transient errors observed right after the
+// gateway process starts (transport not yet listening, sqlite migrations
+// not yet applied).
+func waitForPersistenceReady(client *v1.Client) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+
+ var lastErr error
+ for range 60 {
+ _, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{PageSize: 1})
+ if err == nil {
+ return nil
+ }
+ lastErr = err
+ if v1.IsUnavailable(err) {
+ time.Sleep(2 * time.Second)
+ continue
+ }
+ if strings.Contains(err.Error(), "no such table: objects") {
+ time.Sleep(1 * time.Second)
+ continue
+ }
+ return fmt.Errorf("unexpected error waiting for persistence: %w", err)
+ }
+ return fmt.Errorf("openshell-server persistence is not initialized after 60 attempts: %w", lastErr)
+}
+
+// uniqueName returns a process-unique resource name so parallel tests never
+// collide on the same sandbox/workspace/provider name.
+func uniqueName(prefix string) string {
+ return fmt.Sprintf("%s-%09d", prefix, time.Now().UnixNano()%1_000_000_000)
+}
+
+func requireSandboxResponse(t *testing.T, sandbox *v1.Sandbox, workspace, name string) {
+ t.Helper()
+ require.NotNil(t, sandbox)
+ require.NotEmpty(t, sandbox.ID)
+ require.Equal(t, workspace, sandbox.Workspace)
+ require.Equal(t, name, sandbox.Name)
+ require.False(t, sandbox.CreatedAt.IsZero())
+ require.NotZero(t, sandbox.ResourceVersion)
+}
+
+func requireReadySandbox(t *testing.T, sandbox *v1.Sandbox, id string) {
+ t.Helper()
+ require.NotNil(t, sandbox)
+ require.Equal(t, id, sandbox.ID)
+ require.Equal(t, v1.SandboxReady, sandbox.Status.Phase)
+}
+
+func readEnvVar(ctx context.Context, client *v1.Client, workspace, sandboxName, key string) (string, error) {
+ command := []string{"sh", "-c", `if value=$(printenv "$1"); then printf "%s" "$value"; else printf NOT_SET; fi`, "--", key}
+ result, err := client.Exec().Run(ctx, workspace, sandboxName, command, v1.ExecOptions{})
+ // A supervisor reconnect can make one reverse relay miss its opening window.
+ // Retry only that transport timeout; repeated failures still fail the test.
+ if err != nil && ctx.Err() == nil && v1.IsDeadlineExceeded(err) && strings.Contains(err.Error(), "relay open timed out") {
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case <-time.After(250 * time.Millisecond):
+ }
+ result, err = client.Exec().Run(ctx, workspace, sandboxName, command, v1.ExecOptions{})
+ }
+ if err != nil {
+ return "", err
+ }
+ if result.ExitCode != 0 {
+ return "", fmt.Errorf("exec exited %d: %s", result.ExitCode, string(result.Stderr))
+ }
+ if len(result.Stderr) != 0 {
+ return "", fmt.Errorf("exec wrote unexpected stderr: %s", string(result.Stderr))
+ }
+ return string(result.Stdout), nil
+}
+
+func isPlaceholderForEnvKey(value, key string) bool {
+ const prefix = "openshell:resolve:env:"
+ suffix, ok := strings.CutPrefix(value, prefix)
+ if !ok {
+ return false
+ }
+ if suffix == key {
+ return true
+ }
+
+ discriminator, placeholderKey, ok := strings.Cut(suffix, "_")
+ if !ok || placeholderKey != key || len(discriminator) < 2 {
+ return false
+ }
+
+ switch discriminator[0] {
+ case 'v':
+ for _, char := range discriminator[1:] {
+ if char < '0' || char > '9' {
+ return false
+ }
+ }
+ return true
+ case 's':
+ if len(discriminator) != 65 {
+ return false
+ }
+ for _, char := range discriminator[1:] {
+ if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
+ return false
+ }
+ }
+ return true
+ default:
+ return false
+ }
+}
+
+func TestIsPlaceholderForEnvKey(t *testing.T) {
+ stableHandle := strings.Repeat("a1", 32)
+ tests := []struct {
+ name string
+ value string
+ key string
+ want bool
+ }{
+ {name: "plain", value: "openshell:resolve:env:API_KEY", key: "API_KEY", want: true},
+ {name: "revisioned", value: "openshell:resolve:env:v12_API_KEY", key: "API_KEY", want: true},
+ {name: "stable handle", value: "openshell:resolve:env:s" + stableHandle + "_API_KEY", key: "API_KEY", want: true},
+ {name: "overlapping key suffix", value: "openshell:resolve:env:v12_LONG_API_KEY", key: "API_KEY", want: false},
+ {name: "revision requires digits", value: "openshell:resolve:env:vnext_API_KEY", key: "API_KEY", want: false},
+ {name: "stable handle requires lowercase hex", value: "openshell:resolve:env:s" + strings.ToUpper(stableHandle) + "_API_KEY", key: "API_KEY", want: false},
+ {name: "stable handle requires 64 characters", value: "openshell:resolve:env:sabc_API_KEY", key: "API_KEY", want: false},
+ {name: "wrong key", value: "openshell:resolve:env:v12_OTHER_KEY", key: "API_KEY", want: false},
+ {name: "raw secret", value: "secret", key: "API_KEY", want: false},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ require.Equal(t, test.want, isPlaceholderForEnvKey(test.value, test.key))
+ })
+ }
+}
+
+// pollUntil calls fetch until the predicate is satisfied, the local polling
+// timeout expires, or ctx is done, whichever happens first.
+func pollUntil[T any](t *testing.T, ctx context.Context, fetch func() T, predicate func(T) bool) T {
+ t.Helper()
+ deadline := time.NewTimer(35 * time.Second)
+ defer deadline.Stop()
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+
+ for {
+ value := fetch()
+ if predicate(value) {
+ return value
+ }
+ select {
+ case <-ctx.Done():
+ t.Fatalf("context done while polling; last observed %v: %v", value, ctx.Err())
+ case <-deadline.C:
+ t.Fatalf("timed out waiting for expected value, last observed %v", value)
+ case <-ticker.C:
+ }
+ }
+}
+
+// defaultPolicy returns a baseline sandbox policy sufficient for exec-based tests.
+func defaultPolicy() *v1.SandboxPolicy {
+ return &v1.SandboxPolicy{
+ Version: 1,
+ Filesystem: &v1.FilesystemPolicy{
+ IncludeWorkdir: true,
+ ReadOnly: []string{"/usr", "/lib", "/lib64", "/proc", "/etc", "/app", "/dev/urandom"},
+ ReadWrite: []string{"/sandbox", "/tmp", "/dev/null"},
+ },
+ Landlock: &v1.LandlockPolicy{Compatibility: "best_effort"},
+ Process: &v1.ProcessPolicy{RunAsUser: "10001", RunAsGroup: "10001"},
+ }
+}
diff --git a/e2e/go/providers_test.go b/e2e/go/providers_test.go
new file mode 100644
index 0000000000..4e41aa8a51
--- /dev/null
+++ b/e2e/go/providers_test.go
@@ -0,0 +1,308 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+// Provider credentials are fetched at runtime by the sandbox supervisor.
+// Sandboxed child processes must see placeholder values (never raw secrets),
+// and only when a provider is actually attached to the sandbox.
+package e2e
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
+)
+
+// requireProviderResponse checks the stable public fields returned by provider
+// APIs. Credentials are write-only and intentionally omitted from Go SDK
+// provider responses.
+func requireProviderResponse(t *testing.T, provider *v1.Provider, workspace, name, providerType string) {
+ t.Helper()
+ require.NotNil(t, provider)
+ require.NotEmpty(t, provider.ID)
+ require.Equal(t, workspace, provider.Workspace)
+ require.Equal(t, name, provider.Name)
+ require.Equal(t, providerType, provider.Type)
+ require.NotZero(t, provider.ResourceVersion)
+ require.Empty(t, provider.Spec.Credentials)
+}
+
+func deleteProviderIgnoreNotFound(ctx context.Context, t *testing.T, client *v1.Client, workspace, name string) {
+ t.Helper()
+ _, err := client.Providers().Delete(ctx, workspace, name)
+ if err != nil && !v1.IsNotFound(err) {
+ require.NoError(t, err)
+ }
+}
+
+// detachProviderBestEffort clears a provider from a sandbox's spec before the
+// sandbox is deleted. Sandbox deletion removes the gateway's Sandbox record
+// asynchronously (via a background watcher), so a provider-delete cleanup
+// racing right behind a sandbox-delete cleanup can still see the sandbox as
+// attached and fail with a conflict unless the attachment is cleared
+// synchronously first.
+func detachProviderBestEffort(ctx context.Context, client *v1.Client, workspace, sandboxName, providerName string) {
+ if current, err := client.Sandboxes().Get(ctx, workspace, sandboxName); err == nil {
+ _, _ = client.Sandboxes().DetachProvider(ctx, workspace, sandboxName, providerName, current.ResourceVersion)
+ }
+}
+
+// attachProviderRetry attaches a provider to a sandbox, retrying with a
+// freshly fetched resource version whenever the gateway reports a
+// concurrent-modification conflict (e.g. a status update racing the
+// optimistic-concurrency check between Get and AttachProvider).
+func attachProviderRetry(ctx context.Context, t *testing.T, client *v1.Client, workspace, sandboxName, providerName string) *v1.AttachProviderResult {
+ t.Helper()
+ const maxAttempts = 50
+ for attempt := 1; attempt <= maxAttempts; attempt++ {
+ current, err := client.Sandboxes().Get(ctx, workspace, sandboxName)
+ require.NoError(t, err)
+ result, err := client.Sandboxes().AttachProvider(ctx, workspace, sandboxName, providerName, current.ResourceVersion)
+ if err == nil {
+ return result
+ }
+ if !v1.IsConflict(err) {
+ require.NoError(t, err)
+ }
+ if attempt%10 == 0 {
+ t.Logf("attach provider still conflicted after %d attempts: %v", attempt, err)
+ }
+ if attempt == maxAttempts {
+ t.Fatalf("attach provider still conflicted after %d attempts: %v", attempt, err)
+ }
+ select {
+ case <-ctx.Done():
+ t.Fatalf("context done while retrying attach provider: %v", ctx.Err())
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ return nil
+}
+
+// detachProviderRetry mirrors attachProviderRetry for detach.
+func detachProviderRetry(ctx context.Context, t *testing.T, client *v1.Client, workspace, sandboxName, providerName string) *v1.DetachProviderResult {
+ t.Helper()
+ const maxAttempts = 50
+ for attempt := 1; attempt <= maxAttempts; attempt++ {
+ current, err := client.Sandboxes().Get(ctx, workspace, sandboxName)
+ require.NoError(t, err)
+ result, err := client.Sandboxes().DetachProvider(ctx, workspace, sandboxName, providerName, current.ResourceVersion)
+ if err == nil {
+ return result
+ }
+ if !v1.IsConflict(err) {
+ require.NoError(t, err)
+ }
+ if attempt%10 == 0 {
+ t.Logf("detach provider still conflicted after %d attempts: %v", attempt, err)
+ }
+ if attempt == maxAttempts {
+ t.Fatalf("detach provider still conflicted after %d attempts: %v", attempt, err)
+ }
+ select {
+ case <-ctx.Done():
+ t.Fatalf("context done while retrying detach provider: %v", ctx.Err())
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+ return nil
+}
+
+func TestProviderCredentialsAvailableAsEnvVar(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+
+ providerName := uniqueName("prov-env")
+ const secret = "sk-e2e-test-key-12345"
+ provider, err := client.Providers().Create(ctx, "default", &v1.Provider{
+ Name: providerName,
+ Type: "claude-code",
+ Spec: v1.ProviderSpec{Credentials: map[string]string{"ANTHROPIC_API_KEY": secret}},
+ })
+ require.NoError(t, err)
+ requireProviderResponse(t, provider, "default", providerName, "claude-code")
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ deleteProviderIgnoreNotFound(cleanupCtx, t, client, "default", providerName)
+ })
+
+ sandboxName := uniqueName("sb-penv")
+ sandbox, err := client.Sandboxes().Create(ctx, "default", sandboxName, &v1.SandboxSpec{
+ Policy: defaultPolicy(),
+ Providers: []string{providerName},
+ Command: []string{"sleep", "infinity"},
+ }, nil)
+ require.NoError(t, err)
+ requireSandboxResponse(t, sandbox, "default", sandboxName)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ detachProviderBestEffort(cleanupCtx, client, "default", sandboxName, providerName)
+ _, err := client.Sandboxes().Delete(cleanupCtx, "default", sandboxName)
+ require.NoError(t, err)
+ })
+
+ ready, err := client.Sandboxes().WaitReady(ctx, "default", sandboxName)
+ require.NoError(t, err)
+ requireReadySandbox(t, ready, sandbox.ID)
+
+ value, err := readEnvVar(ctx, client, "default", sandboxName, "ANTHROPIC_API_KEY")
+ require.NoError(t, err)
+ require.True(t, isPlaceholderForEnvKey(value, "ANTHROPIC_API_KEY"), "expected a placeholder, got %q", value)
+ require.NotEqual(t, secret, value)
+}
+
+func TestProfilelessProviderCreationIsRejected(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ providerName := uniqueName("prov-generic")
+ _, err := client.Providers().Create(ctx, "default", &v1.Provider{
+ Name: providerName,
+ Type: "generic",
+ Spec: v1.ProviderSpec{Credentials: map[string]string{
+ "CUSTOM_SERVICE_TOKEN": "token-generic-123",
+ "CUSTOM_SERVICE_URL": "https://internal.example.test/api",
+ }},
+ })
+ require.Error(t, err)
+ require.True(t, v1.IsInvalidArgument(err), "expected InvalidArgument, got: %v", err)
+ require.ErrorContains(t, err, "provider profile 'generic' was not found")
+}
+
+func TestAttachDetachProviderUpdatesCredentials(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+
+ providerName := uniqueName("prov-attach")
+ provider, err := client.Providers().Create(ctx, "default", &v1.Provider{
+ Name: providerName,
+ Type: "nvidia",
+ Spec: v1.ProviderSpec{Credentials: map[string]string{"NVIDIA_API_KEY": "nvapi-e2e-test-key"}},
+ })
+ require.NoError(t, err)
+ requireProviderResponse(t, provider, "default", providerName, "nvidia")
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ deleteProviderIgnoreNotFound(cleanupCtx, t, client, "default", providerName)
+ })
+
+ sandboxName := uniqueName("sb-patt")
+ sandbox, err := client.Sandboxes().Create(ctx, "default", sandboxName, &v1.SandboxSpec{
+ Policy: defaultPolicy(),
+ Command: []string{"sleep", "infinity"},
+ }, nil)
+ require.NoError(t, err)
+ requireSandboxResponse(t, sandbox, "default", sandboxName)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ detachProviderBestEffort(cleanupCtx, client, "default", sandboxName, providerName)
+ _, err := client.Sandboxes().Delete(cleanupCtx, "default", sandboxName)
+ require.NoError(t, err)
+ })
+
+ ready, err := client.Sandboxes().WaitReady(ctx, "default", sandboxName)
+ require.NoError(t, err)
+ requireReadySandbox(t, ready, sandbox.ID)
+
+ value, err := readEnvVar(ctx, client, "default", sandboxName, "NVIDIA_API_KEY")
+ require.NoError(t, err)
+ require.Equal(t, "NOT_SET", value)
+
+ attachResult := attachProviderRetry(ctx, t, client, "default", sandboxName, providerName)
+ require.True(t, attachResult.Attached)
+ requireSandboxResponse(t, attachResult.Sandbox, "default", sandboxName)
+ require.Equal(t, sandbox.ID, attachResult.Sandbox.ID)
+ require.Contains(t, attachResult.Sandbox.Spec.Providers, providerName)
+
+ value = pollUntil(t, ctx, func() string {
+ v, err := readEnvVar(ctx, client, "default", sandboxName, "NVIDIA_API_KEY")
+ require.NoError(t, err)
+ return v
+ }, func(v string) bool { return v != "NOT_SET" })
+ require.True(t, isPlaceholderForEnvKey(value, "NVIDIA_API_KEY"), "expected a placeholder, got %q", value)
+
+ detachResult := detachProviderRetry(ctx, t, client, "default", sandboxName, providerName)
+ require.True(t, detachResult.Detached)
+ requireSandboxResponse(t, detachResult.Sandbox, "default", sandboxName)
+ require.Equal(t, sandbox.ID, detachResult.Sandbox.ID)
+ require.NotContains(t, detachResult.Sandbox.Spec.Providers, providerName)
+
+ value = pollUntil(t, ctx, func() string {
+ v, err := readEnvVar(ctx, client, "default", sandboxName, "NVIDIA_API_KEY")
+ require.NoError(t, err)
+ return v
+ }, func(v string) bool { return v == "NOT_SET" })
+ require.Equal(t, "NOT_SET", value)
+}
+
+func TestProviderCRUDResponseContract(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+
+ providerName := uniqueName("prov-crud")
+ const secret = "sk-e2e-provider-crud-12345"
+ created, err := client.Providers().Create(ctx, "default", &v1.Provider{
+ Name: providerName,
+ Type: "claude-code",
+ Labels: map[string]string{"e2e": "provider-response-contract"},
+ Spec: v1.ProviderSpec{
+ Credentials: map[string]string{"ANTHROPIC_API_KEY": secret},
+ },
+ })
+ require.NoError(t, err)
+ requireProviderResponse(t, created, "default", providerName, "claude-code")
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ deleteProviderIgnoreNotFound(cleanupCtx, t, client, "default", providerName)
+ })
+ require.Equal(t, "provider-response-contract", created.Labels["e2e"])
+
+ fetched, err := client.Providers().Get(ctx, "default", providerName)
+ require.NoError(t, err)
+ requireProviderResponse(t, fetched, "default", providerName, "claude-code")
+ require.Equal(t, created.ID, fetched.ID)
+
+ updatedInput := *fetched
+ // Get omits write-only credentials, so leave them unchanged in this update.
+ updatedInput.Spec.Credentials = nil
+ updatedInput.Spec.Config = map[string]string{"e2e-response-contract": "updated"}
+ updated, err := client.Providers().Update(ctx, "default", &updatedInput)
+ require.NoError(t, err)
+ requireProviderResponse(t, updated, "default", providerName, "claude-code")
+ require.Equal(t, "updated", updated.Spec.Config["e2e-response-contract"])
+ require.Greater(t, updated.ResourceVersion, fetched.ResourceVersion)
+
+ providers, err := client.Providers().ListAll(ctx, "default")
+ require.NoError(t, err)
+ for _, listed := range providers {
+ if listed.Name == providerName {
+ requireProviderResponse(t, listed, "default", providerName, "claude-code")
+ require.Equal(t, updated.ID, listed.ID)
+ require.Equal(t, "updated", listed.Spec.Config["e2e-response-contract"])
+ return
+ }
+ }
+ t.Fatalf("expected provider %q in List response", providerName)
+}
diff --git a/e2e/go/sandbox_lifecycle_test.go b/e2e/go/sandbox_lifecycle_test.go
new file mode 100644
index 0000000000..3bdd801c1d
--- /dev/null
+++ b/e2e/go/sandbox_lifecycle_test.go
@@ -0,0 +1,314 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package e2e
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
+)
+
+func TestSandboxCRUDAndExec(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ name := uniqueName("sb-crud")
+ policy := defaultPolicy()
+ sb, err := client.Sandboxes().Create(ctx, "default", name, &v1.SandboxSpec{
+ Policy: policy,
+ Command: []string{"sleep", "infinity"},
+ }, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cleanupCancel()
+ _, err := client.Sandboxes().Delete(cleanupCtx, "default", name)
+ require.NoError(t, err)
+ })
+ requireSandboxResponse(t, sb, "default", name)
+ require.Equal(t, policy, sb.Spec.Policy)
+
+ ready, err := client.Sandboxes().WaitReady(ctx, "default", name)
+ require.NoError(t, err)
+ requireReadySandbox(t, ready, sb.ID)
+
+ fetched, err := client.Sandboxes().Get(ctx, "default", name)
+ require.NoError(t, err)
+ requireSandboxResponse(t, fetched, "default", name)
+ require.Equal(t, sb.ID, fetched.ID)
+ require.Equal(t, policy, fetched.Spec.Policy)
+
+ sandboxes, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{PageSize: 100})
+ require.NoError(t, err)
+ require.True(t, containsID(sandboxes, sb.ID))
+
+ result, err := client.Exec().Run(ctx, "default", name, []string{"sh", "-c", "printf sandbox-ok"}, v1.ExecOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 0, result.ExitCode)
+ require.Equal(t, "sandbox-ok", string(result.Stdout))
+ require.Empty(t, result.Stderr)
+
+ failedResult, err := client.Exec().Run(ctx, "default", name,
+ []string{"sh", "-c", "printf exec-failed >&2; exit 42"}, v1.ExecOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 42, failedResult.ExitCode)
+ require.Empty(t, failedResult.Stdout)
+ require.Contains(t, string(failedResult.Stderr), "exec-failed")
+
+ // Exec launches share the same sandbox filesystem across calls.
+ writeResult, err := client.Exec().Run(ctx, "default", name,
+ []string{"sh", "-c", "echo persisted > /sandbox/exec-persistence.txt"}, v1.ExecOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 0, writeResult.ExitCode)
+ require.Empty(t, writeResult.Stderr)
+
+ readResult, err := client.Exec().Run(ctx, "default", name,
+ []string{"cat", "/sandbox/exec-persistence.txt"}, v1.ExecOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 0, readResult.ExitCode)
+ require.Equal(t, "persisted\n", string(readResult.Stdout))
+ require.Empty(t, readResult.Stderr)
+}
+
+func TestSandboxGetNonexistentNotFound(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ _, err := client.Sandboxes().Get(ctx, "default", uniqueName("no-such-sb"))
+ require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err)
+}
+
+func TestSandboxDeleteNonexistentNotFound(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ _, err := client.Sandboxes().Delete(ctx, "default", uniqueName("no-such-sb"))
+ require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err)
+}
+
+func TestSandboxCreateDuplicateAlreadyExists(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ name := uniqueName("sb-dup")
+ sandbox, err := client.Sandboxes().Create(ctx, "default", name, &v1.SandboxSpec{}, nil)
+ require.NoError(t, err)
+ requireSandboxResponse(t, sandbox, "default", name)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cleanupCancel()
+ _, err := client.Sandboxes().Delete(cleanupCtx, "default", name)
+ require.NoError(t, err)
+ })
+
+ _, err = client.Sandboxes().Create(ctx, "default", name, &v1.SandboxSpec{}, nil)
+ require.True(t, v1.IsAlreadyExists(err), "expected AlreadyExists, got %v", err)
+}
+
+func TestSandboxListScopedAndAllWorkspaces(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ otherWorkspace := uniqueName("list-ws")
+ _, err := client.Workspaces().Create(ctx, otherWorkspace, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ // Sandbox deletion (registered after this cleanup, so it runs first
+ // under LIFO ordering) only asynchronously clears the store row via a
+ // background watcher. Workspace deletion is blocked while any
+ // sandbox still references it, so wait for the workspace to drain
+ // before attempting the delete.
+ waitForWorkspaceSandboxesGone(cleanupCtx, client, otherWorkspace)
+ _, _ = client.Workspaces().Delete(cleanupCtx, otherWorkspace)
+ })
+
+ defaultName := uniqueName("ls-def")
+ defaultSandbox, err := client.Sandboxes().Create(ctx, "default", defaultName, &v1.SandboxSpec{}, nil)
+ require.NoError(t, err)
+ requireSandboxResponse(t, defaultSandbox, "default", defaultName)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ _, _ = client.Sandboxes().Delete(cleanupCtx, "default", defaultName)
+ })
+
+ otherName := uniqueName("ls-oth")
+ otherSandbox, err := client.Sandboxes().Create(ctx, otherWorkspace, otherName, &v1.SandboxSpec{}, nil)
+ require.NoError(t, err)
+ requireSandboxResponse(t, otherSandbox, otherWorkspace, otherName)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ _, _ = client.Sandboxes().Delete(cleanupCtx, otherWorkspace, otherName)
+ })
+
+ defaultList, err := client.Sandboxes().ListAll(ctx, "default")
+ require.NoError(t, err)
+ require.True(t, containsID(defaultList, defaultSandbox.ID))
+ require.False(t, containsID(defaultList, otherSandbox.ID))
+
+ otherList, err := client.Sandboxes().ListAll(ctx, otherWorkspace)
+ require.NoError(t, err)
+ require.True(t, containsID(otherList, otherSandbox.ID))
+ require.False(t, containsID(otherList, defaultSandbox.ID))
+
+ allList, err := client.Sandboxes().ListAll(ctx, "", v1.ListOptions{AllWorkspaces: true})
+ require.NoError(t, err)
+ require.True(t, containsID(allList, defaultSandbox.ID))
+ require.True(t, containsID(allList, otherSandbox.ID))
+}
+
+func TestSandboxLabelsAndSelectors(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ suffix := uniqueName("lbl")
+ jobA := fmt.Sprintf("%s-a", suffix)
+ jobB := fmt.Sprintf("%s-b", suffix)
+ groupSelector := fmt.Sprintf("aiq-test=%s", suffix)
+ primarySelector := fmt.Sprintf("aiq-test=%s,role=primary", suffix)
+
+ refA, err := client.Sandboxes().Create(ctx, "default", jobA, &v1.SandboxSpec{},
+ map[string]string{"aiq-test": suffix, "role": "primary"})
+ require.NoError(t, err)
+ requireSandboxResponse(t, refA, "default", jobA)
+ deletedA := false
+ t.Cleanup(func() {
+ if deletedA {
+ return
+ }
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ _, _ = client.Sandboxes().Delete(cleanupCtx, "default", jobA)
+ })
+
+ refB, err := client.Sandboxes().Create(ctx, "default", jobB, &v1.SandboxSpec{},
+ map[string]string{"aiq-test": suffix, "role": "secondary"})
+ require.NoError(t, err)
+ requireSandboxResponse(t, refB, "default", jobB)
+ deletedB := false
+ t.Cleanup(func() {
+ if deletedB {
+ return
+ }
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ _, _ = client.Sandboxes().Delete(cleanupCtx, "default", jobB)
+ })
+
+ require.Equal(t, "primary", refA.Labels["role"])
+
+ fetchedA, err := client.Sandboxes().Get(ctx, "default", jobA)
+ require.NoError(t, err)
+ requireSandboxResponse(t, fetchedA, "default", jobA)
+ require.Equal(t, "primary", fetchedA.Labels["role"])
+
+ fetchedB, err := client.Sandboxes().Get(ctx, "default", jobB)
+ require.NoError(t, err)
+ requireSandboxResponse(t, fetchedB, "default", jobB)
+ require.Equal(t, "secondary", fetchedB.Labels["role"])
+
+ primaryOnly, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: primarySelector})
+ require.NoError(t, err)
+ require.ElementsMatch(t, []string{jobA}, names(primaryOnly))
+
+ both, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector})
+ require.NoError(t, err)
+ require.ElementsMatch(t, []string{jobA, jobB}, names(both))
+
+ _, err = client.Sandboxes().Delete(ctx, "default", jobA)
+ require.NoError(t, err)
+ deletedA = true
+
+ // Sandbox deletion becomes list-visible asynchronously, so poll until the
+ // compute driver's deletion event drains through the background watcher.
+ remaining := pollUntil(t, ctx, func() []string {
+ list, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector})
+ require.NoError(t, err)
+ return names(list)
+ }, func(values []string) bool { return elementsMatch(values, []string{jobB}) })
+ require.ElementsMatch(t, []string{jobB}, remaining)
+
+ _, err = client.Sandboxes().Delete(ctx, "default", jobB)
+ require.NoError(t, err)
+ deletedB = true
+
+ empty := pollUntil(t, ctx, func() []string {
+ list, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector})
+ require.NoError(t, err)
+ return names(list)
+ }, func(values []string) bool { return len(values) == 0 })
+ require.Empty(t, empty)
+}
+
+func elementsMatch(a, b []string) bool {
+ return slices.Equal(slices.Sorted(slices.Values(a)), slices.Sorted(slices.Values(b)))
+}
+
+// waitForWorkspaceSandboxesGone polls until a workspace has no sandboxes
+// left, up to a 20s deadline. It is best-effort: cleanup proceeds regardless
+// of whether the deadline is reached, since the subsequent workspace delete
+// is itself best-effort in callers of this helper.
+func waitForWorkspaceSandboxesGone(ctx context.Context, client *v1.Client, workspace string) {
+ deadline := time.Now().Add(20 * time.Second)
+ for {
+ list, err := client.Sandboxes().ListAll(ctx, workspace)
+ if err == nil && len(list) == 0 {
+ return
+ }
+ if time.Now().After(deadline) {
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(time.Second):
+ }
+ }
+}
+
+func containsID(sandboxes []*v1.Sandbox, id string) bool {
+ for _, sb := range sandboxes {
+ if sb.ID == id {
+ return true
+ }
+ }
+ return false
+}
+
+func names(sandboxes []*v1.Sandbox) []string {
+ result := make([]string, 0, len(sandboxes))
+ for _, sb := range sandboxes {
+ result = append(result, sb.Name)
+ }
+ return result
+}
diff --git a/e2e/go/workspace_test.go b/e2e/go/workspace_test.go
new file mode 100644
index 0000000000..b2abdc62c9
--- /dev/null
+++ b/e2e/go/workspace_test.go
@@ -0,0 +1,137 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package e2e
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
+)
+
+func requireWorkspaceResponse(t *testing.T, workspace *v1.Workspace, name string) {
+ t.Helper()
+ require.NotNil(t, workspace)
+ require.NotEmpty(t, workspace.ID)
+ require.Equal(t, name, workspace.Name)
+ require.False(t, workspace.CreatedAt.IsZero())
+ require.NotZero(t, workspace.ResourceVersion)
+}
+
+func TestWorkspaceCRUD(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ name := uniqueName("ws-crud")
+ ws, err := client.Workspaces().Create(ctx, name, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ _, err := client.Workspaces().Delete(cleanupCtx, name)
+ require.NoError(t, err)
+ })
+ require.Equal(t, name, ws.Name)
+ require.Equal(t, v1.WorkspaceActive, ws.Phase)
+ requireWorkspaceResponse(t, ws, name)
+
+ fetched, err := client.Workspaces().Get(ctx, name)
+ require.NoError(t, err)
+ require.Equal(t, name, fetched.Name)
+ require.Equal(t, v1.WorkspaceActive, fetched.Phase)
+ requireWorkspaceResponse(t, fetched, name)
+ require.Equal(t, ws.ID, fetched.ID)
+}
+
+func TestWorkspaceCreateWithLabels(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ name := uniqueName("ws-lbl")
+ ws, err := client.Workspaces().Create(ctx, name, map[string]string{"env": "test", "team": "infra"})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ _, err := client.Workspaces().Delete(cleanupCtx, name)
+ require.NoError(t, err)
+ })
+ require.Equal(t, "test", ws.Labels["env"])
+ require.Equal(t, "infra", ws.Labels["team"])
+ requireWorkspaceResponse(t, ws, name)
+
+ fetched, err := client.Workspaces().Get(ctx, name)
+ require.NoError(t, err)
+ require.Equal(t, "test", fetched.Labels["env"])
+ require.Equal(t, "infra", fetched.Labels["team"])
+ requireWorkspaceResponse(t, fetched, name)
+ require.Equal(t, ws.ID, fetched.ID)
+}
+
+func TestWorkspaceListIncludesCreated(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ name := uniqueName("ws-list")
+ _, err := client.Workspaces().Create(ctx, name, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ _, err := client.Workspaces().Delete(cleanupCtx, name)
+ require.NoError(t, err)
+ })
+
+ workspaces, err := client.Workspaces().ListAll(ctx)
+ require.NoError(t, err)
+
+ found := false
+ hasDefault := false
+ for _, ws := range workspaces {
+ if ws.Name == name {
+ found = true
+ }
+ if ws.Name == "default" {
+ hasDefault = true
+ }
+ }
+ require.True(t, found, "expected created workspace %q in list", name)
+ require.True(t, hasDefault, "expected \"default\" workspace in list")
+}
+
+func TestWorkspaceDeleteNonexistentNotFound(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ _, err := client.Workspaces().Delete(ctx, uniqueName("no-such-ws"))
+ require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err)
+}
+
+func TestWorkspaceGetNonexistentNotFound(t *testing.T) {
+ client := requireClient(t)
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ _, err := client.Workspaces().Get(ctx, uniqueName("no-ws"))
+ require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err)
+}
diff --git a/tasks/test.toml b/tasks/test.toml
index 8a90f3c084..ec3d845a4d 100644
--- a/tasks/test.toml
+++ b/tasks/test.toml
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
-# Test tasks (Rust + Python + TypeScript SDK)
+# Test tasks
[test]
description = "Run all tests (Rust + Python + TypeScript SDK)"
@@ -79,8 +79,8 @@ run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" ta
hide = true
[e2e]
-description = "Run all end-to-end tests (Rust + Python + MCP)"
-depends = ["e2e:rust", "e2e:python", "e2e:mcp"]
+description = "Run all end-to-end tests (Rust + Python + Go + MCP)"
+depends = ["e2e:rust", "e2e:python", "e2e:go:docker", "e2e:mcp"]
["e2e:test"]
description = "Build the current checkout and run a named host or Nix test-guest E2E suite"
@@ -161,6 +161,18 @@ run = [
"OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=openshell/e2e-python:dev OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=never e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_test.py' -m 'not gpu' -n ${E2E_PARALLEL:-5} e2e/python",
]
+["e2e:go"]
+description = "Run Go SDK e2e tests against a Podman-backed gateway"
+run = [
+ "e2e/with-podman-gateway.sh bash -c 'cd e2e/go && go vet -tags=e2e ./... && go test -tags=e2e -timeout 8m -parallel ${E2E_PARALLEL:-5} ./...'",
+]
+
+["e2e:go:docker"]
+description = "Run Go SDK e2e tests against a Docker-backed gateway"
+run = [
+ "e2e/with-docker-gateway.sh bash -c 'cd e2e/go && go vet -tags=e2e ./... && go test -tags=e2e -timeout 8m -parallel ${E2E_PARALLEL:-5} ./...'",
+]
+
["e2e:podman"]
description = "Run Rust CLI e2e tests against a Podman-backed gateway"
depends = ["e2e:conformance:build"]