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
2 changes: 1 addition & 1 deletion .github/workflows/e2e-docker-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand Down
24 changes: 24 additions & 0 deletions e2e/go/go.mod
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions e2e/go/go.sum
Original file line number Diff line number Diff line change
@@ -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=
279 changes: 279 additions & 0 deletions e2e/go/harness_test.go
Original file line number Diff line number Diff line change
@@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: TestMain turns a missing OPENSHELL_GATEWAY into per-test skips. The e2e:go and e2e:go:docker lanes run go test without -v, so if the wrapper ever stops exporting the variable, the lane still reports a green ok with no gateway coverage at all.

Why this matters: An e2e lane should fail when it cannot reach the system under test. With the skip in requireClient (lines 95-101), the job prints only ok github.com/NVIDIA/OpenShell/e2e/go whether 16 tests ran or all of them were skipped; only TestIsPlaceholderForEnvKey always runs. This is not reachable today, since both wrappers export the variable (with-docker-gateway.sh:708, with-podman-gateway.sh:645). A later change to either wrapper, or a manual go test -tags=e2e ./..., would silently drop the Go SDK coverage from a required CI job.

Suggested fix: The file is already gated by the e2e build tag, so fail fast instead of skipping. In TestMain:

if gatewayName == "" {
    fmt.Fprintln(os.Stderr, "e2e: OPENSHELL_GATEWAY must be set (run via mise run e2e:go)")
    os.Exit(1)
}

and drop the t.Skip branch in requireClient. Adding -v to the go test call would at least make the skips visible in CI logs. Reproducer: cd e2e/go && env -u OPENSHELL_GATEWAY go test -tags=e2e ./... should print ok and exit 0 although no gateway test ran.

Source: gator agent

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 <dir>/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)
}
Comment on lines +107 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: waitForPersistenceReady uses context.Background() with no deadline, so a hung gRPC call blocks TestMain indefinitely.

Why this matters: Line 105 creates ctx := context.Background(). The 60-iteration attempt cap on line 107 only guards against repeated short failures. If a single ListAll call hangs (TCP socket open but gateway process stuck), the loop blocks forever because the context has no deadline. This runs in TestMain before any test executes, so it blocks the whole binary with no diagnostics.

Suggested fix: ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute); defer cancel()

Source: production agent

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a three-minute deadline to gateway persistence readiness checks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. harness_test.go:108 now wraps the readiness loop in context.WithTimeout(context.Background(), 3*time.Minute), so a hung ListAll can no longer block TestMain.


// uniqueName returns a process-unique resource name so parallel tests never

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The doc comment says uniqueName returns a "process-unique" name, but the value is wall-clock UnixNano modulo 1e9. That is neither monotonic nor guaranteed unique, so the comment promises more than the code delivers.

Why this matters: Two calls with the same prefix in the same clock tick return the same name. macOS reports time at microsecond resolution, so UnixNano values move in steps of 1000. Parallel subtests or a future helper that calls uniqueName("sb-crud") twice could collide, although the comment says that cannot happen.

Suggested fix: Append a package-level atomic counter, for example fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano()%1e9, seq.Add(1)), so the comment becomes true. Otherwise reword the comment to say the name is probabilistically unique.

Source: architecture agent

// 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)
}
Comment on lines +133 to +135

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notable: Draft RFC 0015 (PR #3238) proposes shared scenario identifiers across SDK E2E suites and a task name of the form e2e:sdk:go. The current task is e2e:go and the tests carry no scenario identifiers.

Why this matters: The RFC is not normative yet so no action is required. The file-per-domain structure (sandbox, workspace, providers) aligns with the RFC's proposed groupings, which would ease a later migration.

Suggested fix: No fix now. If RFC 0015 is adopted, rename the task and annotate each test with its scenario identifier.

Source: architecture agent

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The current task split predates the RFC contract; it still needs e2e:sdk:go:adapter, Docker-backed e2e:sdk:go, and the e2e:go alias.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, and your read matches mine. Nothing to do here until RFC 0015 is accepted; the task split and scenario identifiers belong in the same change that introduces the adapter contract.


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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notable: TestIsPlaceholderForEnvKey is a pure unit test of a parsing helper, but it lives in the harness file behind the e2e build tag. It only runs through the gateway-booting e2e:go tasks and never in the fast unit-test path.

Why this matters: If a change to the placeholder grammar (the v<digits> and s<64 hex> discriminators) breaks isPlaceholderForEnvKey, the table test that would catch it runs only after a full Docker or Podman gateway has booted. go:test and go:ci never run it.

Suggested fix: Consider moving the parser and its test into a file without the build tag, or into sdk/go if the SDK should own placeholder recognition.

Source: architecture agent

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The 35s deadline in pollUntil and the attempt loop in waitForPersistenceReady only take effect between calls. Each fetch or ListAll call runs on the caller's long-lived context with no per-attempt timeout, so a single hung RPC blocks for the full outer context (3 minutes) and is then misreported.

Why this matters: In providers_test.go:235, pollUntil calls readEnvVar(ctx, ...) with the test's 3-minute ctx. If the exec relay hangs (for example during a supervisor reconnect while attaching), Exec().Run blocks until that ctx expires, and the 35s timer at harness_test.go:247 is never selected. The require.NoError inside the fetch closure then fails with DeadlineExceeded, which hides where the hang happened. waitForPersistenceReady (harness_test.go:112-127) has the same shape: a ListAll call against a gateway that accepts connections but never answers uses the whole budget, and because DeadlineExceeded is not IsUnavailable, it reports "unexpected error waiting for persistence" instead of a readiness timeout.

Suggested fix: Derive a short per-attempt context inside each fetch or ListAll attempt, for example context.WithTimeout(ctx, 10*time.Second), and check ctx.Err() before classifying the error.

Source: production agent

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"},
}
}
Loading
Loading