From 58c9f93b9339ac9bbdee637f38cfae423fa1d03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 11 Jul 2026 16:21:30 +0200 Subject: [PATCH] docs(sdk/go): add documentation and CI job (3/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Go SDK documentation pages and CI integration. Documentation (Fern MDX): - Getting Started: installation, connection, first API call - Architecture: module structure, transport layer, sub-clients - Error Handling: error codes, type checking, retry configuration - Authentication: OIDC flows, token refresh, TLS configuration CI integration: - Go job in branch-checks workflow (lint, build, test, proto check) - Mise task for proto generation from monorepo context Builds on PRs 1/3 and 2/3. Merge those first. Resolves #2044 Signed-off-by: Roland Huß Assisted-By: 🤖 Claude Code --- .github/workflows/branch-checks.yml | 28 + docs/index.yml | 14 + docs/sdks/go/architecture.mdx | 105 + docs/sdks/go/authentication.mdx | 97 + docs/sdks/go/error-handling.mdx | 96 + docs/sdks/go/getting-started.mdx | 101 + sdk/go/Makefile | 45 + sdk/go/go.mod | 21 + sdk/go/go.sum | 52 + sdk/go/mise.toml | 242 + sdk/go/openshell/v1/auth.go | 48 + sdk/go/openshell/v1/auth_extra.go | 80 + sdk/go/openshell/v1/auth_extra_test.go | 177 + sdk/go/openshell/v1/auth_refresh.go | 127 + sdk/go/openshell/v1/auth_refresh_test.go | 373 + sdk/go/openshell/v1/auth_test.go | 36 + sdk/go/openshell/v1/client.go | 143 + sdk/go/openshell/v1/client_test.go | 61 + sdk/go/openshell/v1/config.go | 76 + sdk/go/openshell/v1/config_client.go | 67 + sdk/go/openshell/v1/config_client_test.go | 560 + sdk/go/openshell/v1/doc.go | 358 + sdk/go/openshell/v1/edge/cloudflare.go | 29 + sdk/go/openshell/v1/edge/cloudflare_test.go | 88 + sdk/go/openshell/v1/edge/doc.go | 88 + sdk/go/openshell/v1/edge/tunnel.go | 284 + sdk/go/openshell/v1/edge/tunnel_test.go | 494 + sdk/go/openshell/v1/errors.go | 56 + sdk/go/openshell/v1/errors_test.go | 125 + sdk/go/openshell/v1/example_fake_test.go | 155 + sdk/go/openshell/v1/example_test.go | 191 + sdk/go/openshell/v1/exec.go | 40 + sdk/go/openshell/v1/exec_client.go | 325 + sdk/go/openshell/v1/exec_client_test.go | 621 + sdk/go/openshell/v1/fake/broadcaster.go | 121 + sdk/go/openshell/v1/fake/broadcaster_test.go | 176 + sdk/go/openshell/v1/fake/config.go | 53 + sdk/go/openshell/v1/fake/config_test.go | 78 + sdk/go/openshell/v1/fake/doc.go | 37 + sdk/go/openshell/v1/fake/exec.go | 48 + sdk/go/openshell/v1/fake/exec_test.go | 70 + sdk/go/openshell/v1/fake/fake.go | 142 + sdk/go/openshell/v1/fake/fake_test.go | 231 + sdk/go/openshell/v1/fake/file.go | 40 + sdk/go/openshell/v1/fake/file_test.go | 52 + sdk/go/openshell/v1/fake/health.go | 45 + sdk/go/openshell/v1/fake/health_test.go | 49 + sdk/go/openshell/v1/fake/policy.go | 105 + sdk/go/openshell/v1/fake/policy_test.go | 109 + sdk/go/openshell/v1/fake/profile.go | 73 + sdk/go/openshell/v1/fake/profile_test.go | 100 + sdk/go/openshell/v1/fake/provider.go | 168 + sdk/go/openshell/v1/fake/provider_test.go | 276 + sdk/go/openshell/v1/fake/refresh.go | 57 + sdk/go/openshell/v1/fake/refresh_test.go | 78 + sdk/go/openshell/v1/fake/sandbox.go | 504 + sdk/go/openshell/v1/fake/sandbox_test.go | 814 + sdk/go/openshell/v1/fake/service.go | 57 + sdk/go/openshell/v1/fake/service_test.go | 72 + sdk/go/openshell/v1/fake/ssh.go | 58 + sdk/go/openshell/v1/fake/ssh_test.go | 87 + sdk/go/openshell/v1/fake/store.go | 137 + sdk/go/openshell/v1/fake/store_test.go | 248 + sdk/go/openshell/v1/fake/tcp.go | 59 + sdk/go/openshell/v1/fake/tcp_test.go | 104 + sdk/go/openshell/v1/file.go | 13 + sdk/go/openshell/v1/file_client.go | 116 + sdk/go/openshell/v1/file_client_test.go | 320 + sdk/go/openshell/v1/gateway/config.go | 157 + sdk/go/openshell/v1/gateway/config_test.go | 209 + sdk/go/openshell/v1/gateway/doc.go | 75 + sdk/go/openshell/v1/gateway/errors.go | 36 + sdk/go/openshell/v1/gateway/errors_test.go | 88 + sdk/go/openshell/v1/gateway/gateway.go | 182 + sdk/go/openshell/v1/gateway/gateway_test.go | 477 + sdk/go/openshell/v1/gateway/options.go | 63 + sdk/go/openshell/v1/gateway/paths.go | 168 + sdk/go/openshell/v1/gateway/paths_test.go | 217 + sdk/go/openshell/v1/gateway/token.go | 171 + sdk/go/openshell/v1/gateway/token_test.go | 252 + sdk/go/openshell/v1/grpc_errors.go | 6 + sdk/go/openshell/v1/health.go | 18 + sdk/go/openshell/v1/health_client.go | 32 + sdk/go/openshell/v1/health_client_test.go | 110 + sdk/go/openshell/v1/integration_test.go | 74 + .../openshell/v1/internal/converter/copy.go | 59 + .../openshell/v1/internal/converter/errors.go | 52 + .../v1/internal/converter/errors_test.go | 122 + .../openshell/v1/internal/converter/exec.go | 96 + .../v1/internal/converter/exec_test.go | 194 + sdk/go/openshell/v1/internal/converter/log.go | 47 + .../v1/internal/converter/log_test.go | 97 + .../v1/internal/converter/network_policy.go | 283 + .../internal/converter/network_policy_test.go | 272 + .../openshell/v1/internal/converter/policy.go | 304 + .../v1/internal/converter/policy_test.go | 608 + .../v1/internal/converter/profile.go | 288 + .../v1/internal/converter/profile_test.go | 411 + .../v1/internal/converter/provider.go | 71 + .../v1/internal/converter/provider_test.go | 132 + .../v1/internal/converter/refresh.go | 92 + .../v1/internal/converter/refresh_test.go | 166 + .../v1/internal/converter/sandbox.go | 181 + .../v1/internal/converter/sandbox_test.go | 365 + .../v1/internal/converter/service.go | 57 + .../v1/internal/converter/service_test.go | 131 + .../v1/internal/converter/setting.go | 299 + .../v1/internal/converter/setting_test.go | 825 + sdk/go/openshell/v1/internal/converter/ssh.go | 42 + .../v1/internal/converter/ssh_test.go | 113 + .../openshell/v1/internal/converter/time.go | 24 + .../v1/internal/converter/time_test.go | 42 + sdk/go/openshell/v1/internal/grpc/conn.go | 82 + sdk/go/openshell/v1/logger.go | 12 + sdk/go/openshell/v1/oidc/authcode.go | 228 + sdk/go/openshell/v1/oidc/authcode_test.go | 372 + sdk/go/openshell/v1/oidc/browser.go | 47 + sdk/go/openshell/v1/oidc/browser_test.go | 48 + sdk/go/openshell/v1/oidc/credentials.go | 142 + sdk/go/openshell/v1/oidc/credentials_test.go | 277 + sdk/go/openshell/v1/oidc/device.go | 285 + sdk/go/openshell/v1/oidc/device_test.go | 682 + sdk/go/openshell/v1/oidc/discovery.go | 119 + sdk/go/openshell/v1/oidc/discovery_test.go | 249 + sdk/go/openshell/v1/oidc/doc.go | 78 + sdk/go/openshell/v1/oidc/errors.go | 43 + sdk/go/openshell/v1/oidc/errors_test.go | 107 + sdk/go/openshell/v1/oidc/example_test.go | 160 + sdk/go/openshell/v1/oidc/keyboard.go | 64 + sdk/go/openshell/v1/oidc/keyboard_test.go | 138 + sdk/go/openshell/v1/oidc/oidc.go | 227 + sdk/go/openshell/v1/oidc/oidc_test.go | 496 + sdk/go/openshell/v1/oidc/options.go | 170 + sdk/go/openshell/v1/oidc/options_test.go | 155 + sdk/go/openshell/v1/oidc/token.go | 117 + sdk/go/openshell/v1/oidc/token_test.go | 182 + sdk/go/openshell/v1/options.go | 32 + sdk/go/openshell/v1/policy.go | 173 + sdk/go/openshell/v1/policy_client.go | 156 + sdk/go/openshell/v1/policy_client_test.go | 816 + sdk/go/openshell/v1/profile.go | 70 + sdk/go/openshell/v1/profile_client.go | 147 + sdk/go/openshell/v1/profile_client_test.go | 570 + sdk/go/openshell/v1/provider.go | 29 + sdk/go/openshell/v1/provider_client.go | 125 + sdk/go/openshell/v1/provider_client_test.go | 312 + sdk/go/openshell/v1/refresh.go | 41 + sdk/go/openshell/v1/refresh_client.go | 67 + sdk/go/openshell/v1/refresh_client_test.go | 426 + sdk/go/openshell/v1/sandbox.go | 73 + sdk/go/openshell/v1/sandbox_client.go | 270 + sdk/go/openshell/v1/sandbox_client_test.go | 971 ++ sdk/go/openshell/v1/service.go | 25 + sdk/go/openshell/v1/service_client.go | 82 + sdk/go/openshell/v1/service_client_test.go | 322 + sdk/go/openshell/v1/ssh.go | 58 + sdk/go/openshell/v1/ssh_client.go | 152 + sdk/go/openshell/v1/ssh_client_test.go | 609 + sdk/go/openshell/v1/tcp.go | 97 + sdk/go/openshell/v1/tcp_client.go | 376 + sdk/go/openshell/v1/tcp_client_test.go | 1286 ++ sdk/go/openshell/v1/types.go | 46 + sdk/go/openshell/v1/types/auth.go | 13 + sdk/go/openshell/v1/types/config.go | 16 + sdk/go/openshell/v1/types/doc.go | 10 + sdk/go/openshell/v1/types/errors.go | 122 + sdk/go/openshell/v1/types/exec.go | 17 + sdk/go/openshell/v1/types/health.go | 10 + sdk/go/openshell/v1/types/log.go | 98 + sdk/go/openshell/v1/types/logger.go | 12 + sdk/go/openshell/v1/types/network_policy.go | 157 + sdk/go/openshell/v1/types/options.go | 45 + sdk/go/openshell/v1/types/policy.go | 341 + sdk/go/openshell/v1/types/profile.go | 93 + sdk/go/openshell/v1/types/provider.go | 24 + sdk/go/openshell/v1/types/refresh.go | 43 + sdk/go/openshell/v1/types/sandbox.go | 71 + sdk/go/openshell/v1/types/service.go | 15 + sdk/go/openshell/v1/types/setting.go | 115 + sdk/go/openshell/v1/types/ssh.go | 34 + sdk/go/openshell/v1/types/types.go | 53 + sdk/go/openshell/v1/types/watch.go | 17 + sdk/go/openshell/v1/types_reexport.go | 57 + sdk/go/openshell/v1/watch.go | 46 + sdk/go/openshell/v1/watch_test.go | 134 + sdk/go/proto/UPSTREAM_VERSION | 1 + sdk/go/proto/datamodel.proto | 44 + sdk/go/proto/datamodelv1/datamodel.pb.go | 284 + sdk/go/proto/openshell.proto | 1899 +++ sdk/go/proto/openshellv1/openshell.pb.go | 12469 ++++++++++++++++ sdk/go/proto/openshellv1/openshell_grpc.pb.go | 2345 +++ sdk/go/proto/sandbox.proto | 275 + sdk/go/proto/sandboxv1/sandbox.pb.go | 1753 +++ tasks/go.toml | 27 + 194 files changed, 50259 insertions(+) create mode 100644 docs/sdks/go/architecture.mdx create mode 100644 docs/sdks/go/authentication.mdx create mode 100644 docs/sdks/go/error-handling.mdx create mode 100644 docs/sdks/go/getting-started.mdx create mode 100644 sdk/go/Makefile create mode 100644 sdk/go/go.mod create mode 100644 sdk/go/go.sum create mode 100644 sdk/go/mise.toml create mode 100644 sdk/go/openshell/v1/auth.go create mode 100644 sdk/go/openshell/v1/auth_extra.go create mode 100644 sdk/go/openshell/v1/auth_extra_test.go create mode 100644 sdk/go/openshell/v1/auth_refresh.go create mode 100644 sdk/go/openshell/v1/auth_refresh_test.go create mode 100644 sdk/go/openshell/v1/auth_test.go create mode 100644 sdk/go/openshell/v1/client.go create mode 100644 sdk/go/openshell/v1/client_test.go create mode 100644 sdk/go/openshell/v1/config.go create mode 100644 sdk/go/openshell/v1/config_client.go create mode 100644 sdk/go/openshell/v1/config_client_test.go create mode 100644 sdk/go/openshell/v1/doc.go create mode 100644 sdk/go/openshell/v1/edge/cloudflare.go create mode 100644 sdk/go/openshell/v1/edge/cloudflare_test.go create mode 100644 sdk/go/openshell/v1/edge/doc.go create mode 100644 sdk/go/openshell/v1/edge/tunnel.go create mode 100644 sdk/go/openshell/v1/edge/tunnel_test.go create mode 100644 sdk/go/openshell/v1/errors.go create mode 100644 sdk/go/openshell/v1/errors_test.go create mode 100644 sdk/go/openshell/v1/example_fake_test.go create mode 100644 sdk/go/openshell/v1/example_test.go create mode 100644 sdk/go/openshell/v1/exec.go create mode 100644 sdk/go/openshell/v1/exec_client.go create mode 100644 sdk/go/openshell/v1/exec_client_test.go create mode 100644 sdk/go/openshell/v1/fake/broadcaster.go create mode 100644 sdk/go/openshell/v1/fake/broadcaster_test.go create mode 100644 sdk/go/openshell/v1/fake/config.go create mode 100644 sdk/go/openshell/v1/fake/config_test.go create mode 100644 sdk/go/openshell/v1/fake/doc.go create mode 100644 sdk/go/openshell/v1/fake/exec.go create mode 100644 sdk/go/openshell/v1/fake/exec_test.go create mode 100644 sdk/go/openshell/v1/fake/fake.go create mode 100644 sdk/go/openshell/v1/fake/fake_test.go create mode 100644 sdk/go/openshell/v1/fake/file.go create mode 100644 sdk/go/openshell/v1/fake/file_test.go create mode 100644 sdk/go/openshell/v1/fake/health.go create mode 100644 sdk/go/openshell/v1/fake/health_test.go create mode 100644 sdk/go/openshell/v1/fake/policy.go create mode 100644 sdk/go/openshell/v1/fake/policy_test.go create mode 100644 sdk/go/openshell/v1/fake/profile.go create mode 100644 sdk/go/openshell/v1/fake/profile_test.go create mode 100644 sdk/go/openshell/v1/fake/provider.go create mode 100644 sdk/go/openshell/v1/fake/provider_test.go create mode 100644 sdk/go/openshell/v1/fake/refresh.go create mode 100644 sdk/go/openshell/v1/fake/refresh_test.go create mode 100644 sdk/go/openshell/v1/fake/sandbox.go create mode 100644 sdk/go/openshell/v1/fake/sandbox_test.go create mode 100644 sdk/go/openshell/v1/fake/service.go create mode 100644 sdk/go/openshell/v1/fake/service_test.go create mode 100644 sdk/go/openshell/v1/fake/ssh.go create mode 100644 sdk/go/openshell/v1/fake/ssh_test.go create mode 100644 sdk/go/openshell/v1/fake/store.go create mode 100644 sdk/go/openshell/v1/fake/store_test.go create mode 100644 sdk/go/openshell/v1/fake/tcp.go create mode 100644 sdk/go/openshell/v1/fake/tcp_test.go create mode 100644 sdk/go/openshell/v1/file.go create mode 100644 sdk/go/openshell/v1/file_client.go create mode 100644 sdk/go/openshell/v1/file_client_test.go create mode 100644 sdk/go/openshell/v1/gateway/config.go create mode 100644 sdk/go/openshell/v1/gateway/config_test.go create mode 100644 sdk/go/openshell/v1/gateway/doc.go create mode 100644 sdk/go/openshell/v1/gateway/errors.go create mode 100644 sdk/go/openshell/v1/gateway/errors_test.go create mode 100644 sdk/go/openshell/v1/gateway/gateway.go create mode 100644 sdk/go/openshell/v1/gateway/gateway_test.go create mode 100644 sdk/go/openshell/v1/gateway/options.go create mode 100644 sdk/go/openshell/v1/gateway/paths.go create mode 100644 sdk/go/openshell/v1/gateway/paths_test.go create mode 100644 sdk/go/openshell/v1/gateway/token.go create mode 100644 sdk/go/openshell/v1/gateway/token_test.go create mode 100644 sdk/go/openshell/v1/grpc_errors.go create mode 100644 sdk/go/openshell/v1/health.go create mode 100644 sdk/go/openshell/v1/health_client.go create mode 100644 sdk/go/openshell/v1/health_client_test.go create mode 100644 sdk/go/openshell/v1/integration_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/copy.go create mode 100644 sdk/go/openshell/v1/internal/converter/errors.go create mode 100644 sdk/go/openshell/v1/internal/converter/errors_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/exec.go create mode 100644 sdk/go/openshell/v1/internal/converter/exec_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/log.go create mode 100644 sdk/go/openshell/v1/internal/converter/log_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/network_policy.go create mode 100644 sdk/go/openshell/v1/internal/converter/network_policy_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/policy.go create mode 100644 sdk/go/openshell/v1/internal/converter/policy_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/profile.go create mode 100644 sdk/go/openshell/v1/internal/converter/profile_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/provider.go create mode 100644 sdk/go/openshell/v1/internal/converter/provider_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/refresh.go create mode 100644 sdk/go/openshell/v1/internal/converter/refresh_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/sandbox.go create mode 100644 sdk/go/openshell/v1/internal/converter/sandbox_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/service.go create mode 100644 sdk/go/openshell/v1/internal/converter/service_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/setting.go create mode 100644 sdk/go/openshell/v1/internal/converter/setting_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/ssh.go create mode 100644 sdk/go/openshell/v1/internal/converter/ssh_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/time.go create mode 100644 sdk/go/openshell/v1/internal/converter/time_test.go create mode 100644 sdk/go/openshell/v1/internal/grpc/conn.go create mode 100644 sdk/go/openshell/v1/logger.go create mode 100644 sdk/go/openshell/v1/oidc/authcode.go create mode 100644 sdk/go/openshell/v1/oidc/authcode_test.go create mode 100644 sdk/go/openshell/v1/oidc/browser.go create mode 100644 sdk/go/openshell/v1/oidc/browser_test.go create mode 100644 sdk/go/openshell/v1/oidc/credentials.go create mode 100644 sdk/go/openshell/v1/oidc/credentials_test.go create mode 100644 sdk/go/openshell/v1/oidc/device.go create mode 100644 sdk/go/openshell/v1/oidc/device_test.go create mode 100644 sdk/go/openshell/v1/oidc/discovery.go create mode 100644 sdk/go/openshell/v1/oidc/discovery_test.go create mode 100644 sdk/go/openshell/v1/oidc/doc.go create mode 100644 sdk/go/openshell/v1/oidc/errors.go create mode 100644 sdk/go/openshell/v1/oidc/errors_test.go create mode 100644 sdk/go/openshell/v1/oidc/example_test.go create mode 100644 sdk/go/openshell/v1/oidc/keyboard.go create mode 100644 sdk/go/openshell/v1/oidc/keyboard_test.go create mode 100644 sdk/go/openshell/v1/oidc/oidc.go create mode 100644 sdk/go/openshell/v1/oidc/oidc_test.go create mode 100644 sdk/go/openshell/v1/oidc/options.go create mode 100644 sdk/go/openshell/v1/oidc/options_test.go create mode 100644 sdk/go/openshell/v1/oidc/token.go create mode 100644 sdk/go/openshell/v1/oidc/token_test.go create mode 100644 sdk/go/openshell/v1/options.go create mode 100644 sdk/go/openshell/v1/policy.go create mode 100644 sdk/go/openshell/v1/policy_client.go create mode 100644 sdk/go/openshell/v1/policy_client_test.go create mode 100644 sdk/go/openshell/v1/profile.go create mode 100644 sdk/go/openshell/v1/profile_client.go create mode 100644 sdk/go/openshell/v1/profile_client_test.go create mode 100644 sdk/go/openshell/v1/provider.go create mode 100644 sdk/go/openshell/v1/provider_client.go create mode 100644 sdk/go/openshell/v1/provider_client_test.go create mode 100644 sdk/go/openshell/v1/refresh.go create mode 100644 sdk/go/openshell/v1/refresh_client.go create mode 100644 sdk/go/openshell/v1/refresh_client_test.go create mode 100644 sdk/go/openshell/v1/sandbox.go create mode 100644 sdk/go/openshell/v1/sandbox_client.go create mode 100644 sdk/go/openshell/v1/sandbox_client_test.go create mode 100644 sdk/go/openshell/v1/service.go create mode 100644 sdk/go/openshell/v1/service_client.go create mode 100644 sdk/go/openshell/v1/service_client_test.go create mode 100644 sdk/go/openshell/v1/ssh.go create mode 100644 sdk/go/openshell/v1/ssh_client.go create mode 100644 sdk/go/openshell/v1/ssh_client_test.go create mode 100644 sdk/go/openshell/v1/tcp.go create mode 100644 sdk/go/openshell/v1/tcp_client.go create mode 100644 sdk/go/openshell/v1/tcp_client_test.go create mode 100644 sdk/go/openshell/v1/types.go create mode 100644 sdk/go/openshell/v1/types/auth.go create mode 100644 sdk/go/openshell/v1/types/config.go create mode 100644 sdk/go/openshell/v1/types/doc.go create mode 100644 sdk/go/openshell/v1/types/errors.go create mode 100644 sdk/go/openshell/v1/types/exec.go create mode 100644 sdk/go/openshell/v1/types/health.go create mode 100644 sdk/go/openshell/v1/types/log.go create mode 100644 sdk/go/openshell/v1/types/logger.go create mode 100644 sdk/go/openshell/v1/types/network_policy.go create mode 100644 sdk/go/openshell/v1/types/options.go create mode 100644 sdk/go/openshell/v1/types/policy.go create mode 100644 sdk/go/openshell/v1/types/profile.go create mode 100644 sdk/go/openshell/v1/types/provider.go create mode 100644 sdk/go/openshell/v1/types/refresh.go create mode 100644 sdk/go/openshell/v1/types/sandbox.go create mode 100644 sdk/go/openshell/v1/types/service.go create mode 100644 sdk/go/openshell/v1/types/setting.go create mode 100644 sdk/go/openshell/v1/types/ssh.go create mode 100644 sdk/go/openshell/v1/types/types.go create mode 100644 sdk/go/openshell/v1/types/watch.go create mode 100644 sdk/go/openshell/v1/types_reexport.go create mode 100644 sdk/go/openshell/v1/watch.go create mode 100644 sdk/go/openshell/v1/watch_test.go create mode 100644 sdk/go/proto/UPSTREAM_VERSION create mode 100644 sdk/go/proto/datamodel.proto create mode 100644 sdk/go/proto/datamodelv1/datamodel.pb.go create mode 100644 sdk/go/proto/openshell.proto create mode 100644 sdk/go/proto/openshellv1/openshell.pb.go create mode 100644 sdk/go/proto/openshellv1/openshell_grpc.pb.go create mode 100644 sdk/go/proto/sandbox.proto create mode 100644 sdk/go/proto/sandboxv1/sandbox.pb.go create mode 100644 tasks/go.toml diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 7713febb6f..c5081b550d 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -190,3 +190,31 @@ jobs: - name: Lint run: mise run markdown:lint + + go: + name: Go + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Lint + run: mise run go:lint + + - name: Build + run: mise run go:build + + - name: Test + run: mise run go:test + + - name: Proto check + run: mise run go:proto:check diff --git a/docs/index.yml b/docs/index.yml index b2443e4afd..fe0d935e17 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -23,6 +23,20 @@ navigation: title: "Observability" - folder: kubernetes title: "Kubernetes" +- section: "SDKs" + slug: sdks + contents: + - section: "Go SDK" + slug: go + contents: + - page: "Getting Started" + path: sdks/go/getting-started.mdx + - page: "Architecture" + path: sdks/go/architecture.mdx + - page: "Error Handling" + path: sdks/go/error-handling.mdx + - page: "Authentication" + path: sdks/go/authentication.mdx - folder: reference title: "Reference" - folder: security diff --git a/docs/sdks/go/architecture.mdx b/docs/sdks/go/architecture.mdx new file mode 100644 index 0000000000..818b3651bd --- /dev/null +++ b/docs/sdks/go/architecture.mdx @@ -0,0 +1,105 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Architecture" +description: "Module structure, gRPC transport layer, and proto isolation pattern in the Go SDK." +keywords: "Go SDK, Architecture, gRPC, Protobuf, Module Structure, Transport" +--- + +The Go SDK follows a layered architecture that keeps protocol details +internal while exposing idiomatic Go types to consumers. + +## Module Structure + +``` +sdk/go/ +├── openshell/v1/ # Public API surface +│ ├── client.go # Top-level Client with sub-clients +│ ├── gateway/ # Gateway client factory (reads CLI config) +│ ├── edge/ # Edge API client +│ ├── fake/ # In-memory fakes for testing +│ ├── types/ # Domain types (Config, errors, options) +│ ├── oidc/ # OIDC authentication +│ └── internal/ # Internal helpers (not importable) +│ ├── converter/ # Proto-to-SDK type conversion +│ └── grpc/ # gRPC connection management +└── proto/ # Proto definitions + generated code + ├── openshellv1/ # Generated .pb.go files + ├── datamodelv1/ + └── sandboxv1/ +``` + +## Transport Layer + +The SDK communicates with the OpenShell gateway over gRPC. The transport +is fully internal, and consumers interact only with Go types. + +``` +Consumer Code + │ + ▼ +openshell/v1.Client ← Public API (Go types) + │ + ▼ +internal/converter ← Proto ↔ SDK type conversion + │ + ▼ +internal/grpc ← Connection management, TLS, auth + │ + ▼ +gRPC transport ← Wire protocol +``` + +## Proto Isolation + +Generated protobuf types live in `proto/` packages and are never exposed +through the public API. The `internal/converter` package handles all +translation between proto messages and SDK domain types. + +This means: + +- Consumers never import `proto/` packages directly. +- Proto schema changes do not break the public API. +- Deep copies happen at the boundary, so returned values are safe to mutate. + +## Sub-Clients + +The top-level `Client` provides access to domain-specific sub-clients: + +| Method | Returns | Purpose | +|--------|---------|---------| +| `Sandboxes()` | `SandboxInterface` | Create, list, get, delete sandboxes | +| `Providers()` | `ProviderInterface` | List and inspect compute providers | +| `Services()` | `ServiceInterface` | Manage sandbox services | +| `Exec()` | `ExecInterface` | Execute commands in sandboxes | +| `Files()` | `FileInterface` | Upload and download files | +| `Health()` | `HealthInterface` | Gateway health checks | +| `SSH()` | `SSHInterface` | SSH tunnel management | +| `TCP()` | `TCPInterface` | TCP port forwarding | +| `Config()` | `ConfigInterface` | Sandbox configuration management | +| `Policy()` | `PolicyInterface` | Network policy management | + +## Client Construction + +There are two ways to create a client: + +**From gateway configuration** (reads `~/.config/openshell/`): + +```go +client, err := gateway.NewClient("my-gateway") +``` + +**From explicit configuration**: + +```go +client, err := v1.NewClient(types.Config{ + Address: "localhost:8080", + Auth: myAuthProvider, +}) +``` + +## Concurrency + +All client methods are safe for concurrent use. The underlying gRPC +connection handles multiplexing. Call `client.Close()` when done to +release resources. diff --git a/docs/sdks/go/authentication.mdx b/docs/sdks/go/authentication.mdx new file mode 100644 index 0000000000..1440cbf5ab --- /dev/null +++ b/docs/sdks/go/authentication.mdx @@ -0,0 +1,97 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Authentication" +description: "OIDC authentication, token refresh, and gateway auth configuration for the Go SDK." +keywords: "Go SDK, Authentication, OIDC, Token Refresh, TLS, Security" +--- + +The Go SDK supports multiple authentication modes depending on how the +gateway is configured. The gateway client reads auth settings from the +CLI configuration automatically. + +## Authentication Modes + +| Mode | When to Use | Configuration | +|------|-------------|---------------| +| **OIDC** | Production gateways with identity provider | `openshell gateway auth` configures tokens | +| **API Key** | Simple deployments, development | Set via gateway config or `WithAuth` option | +| **None** | Local development, insecure gateways | Default when no auth is configured | + +## Automatic Auth (Recommended) + +When using `gateway.NewClient()`, authentication is resolved automatically +from the CLI configuration: + +```go +// Auth is read from ~/.config/openshell//config.yaml +client, err := gateway.NewClient("my-gateway") +``` + +The CLI stores OIDC tokens after `openshell gateway auth`. The SDK reads +these tokens from disk and wraps them with `RefreshableToken` for +automatic re-reading when the token file is updated. + +## Token Refresh + +You can wrap any auth provider with `NewTokenRefresher` to add +token-expiry-aware refresh logic with configurable leeway: + +```go +import v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + +refresher := v1.NewTokenRefresher(baseAuth, + v1.WithLeeway(30 * time.Second), + v1.WithLogger(myLogger), +) + +client, err := gateway.NewClient("my-gateway", + gateway.WithAuth(refresher), +) +``` + +## Custom Auth Provider + +For programmatic authentication, implement the `AuthProvider` interface +or use the built-in providers: + +```go +// Static API key +client, err := gateway.NewClient("my-gateway", + gateway.WithAuth(v1.StaticToken("my-api-key")), +) +``` + +The `AuthProvider` interface provides gRPC per-RPC credentials. Each +call attaches the token to the request metadata automatically. + +## TLS Configuration + +Control TLS settings when connecting to gateways: + +```go +client, err := gateway.NewClient("my-gateway", + gateway.WithTLS(&types.TLSConfig{ + Insecure: false, + CAFile: "/path/to/ca.crt", + }), +) +``` + +For local development with self-signed certificates, you can skip +verification, but this should never be used in production. + +## Testing with Fakes + +The fake client does not require authentication. Use it in tests to +avoid gateway dependencies: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" + +func TestMyFeature(t *testing.T) { + client := fake.NewClient() + // Use client.Sandboxes(), client.Exec(), etc. + // No gateway connection or auth needed. +} +``` diff --git a/docs/sdks/go/error-handling.mdx b/docs/sdks/go/error-handling.mdx new file mode 100644 index 0000000000..603e1f7bf3 --- /dev/null +++ b/docs/sdks/go/error-handling.mdx @@ -0,0 +1,96 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Error Handling" +description: "SDK error types, gRPC status code mapping, and retry patterns." +keywords: "Go SDK, Errors, gRPC, Status Codes, Retry, Error Handling" +--- + +The SDK translates gRPC status codes into typed Go errors. All errors +returned by client methods can be inspected with `errors.As` to extract +structured details. + +## StatusError + +The primary error type is `types.StatusError`: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + +sandbox, err := client.Sandboxes().Get(ctx, "missing") +if err != nil { + var se *types.StatusError + if errors.As(err, &se) { + fmt.Printf("Code: %d, Message: %s\n", se.Code, se.Message) + } +} +``` + +## Error Codes + +The SDK defines error codes that map to gRPC status codes: + +| SDK Error Code | gRPC Status | Meaning | +|----------------|-------------|---------| +| `ErrorNotFound` | `NOT_FOUND` | Resource does not exist | +| `ErrorAlreadyExists` | `ALREADY_EXISTS` | Resource name conflict | +| `ErrorInvalidArgument` | `INVALID_ARGUMENT` | Bad request parameters | +| `ErrorPermissionDenied` | `PERMISSION_DENIED` | Insufficient credentials | +| `ErrorUnavailable` | `UNAVAILABLE` | Gateway is unreachable | +| `ErrorDeadlineExceeded` | `DEADLINE_EXCEEDED` | Request timed out | +| `ErrorCancelled` | `CANCELLED` | Request was cancelled | +| `ErrorInternal` | `INTERNAL` | Server-side failure | +| `ErrorUnimplemented` | `UNIMPLEMENTED` | Operation not supported | +| `ErrorConflict` | `ABORTED` / `FAILED_PRECONDITION` | Conflicting operation | + +## Checking Error Types + +Use helper functions or direct comparison: + +```go +if err != nil { + var se *types.StatusError + if errors.As(err, &se) { + switch se.Code { + case types.ErrorNotFound: + // Handle missing resource + case types.ErrorUnavailable: + // Retry or fail over + default: + // Log and return + } + } +} +``` + +## Retry Configuration + +Configure automatic retries for transient failures: + +```go +client, err := gateway.NewClient("my-gateway", + gateway.WithRetryPolicy(&types.RetryPolicy{ + MaxRetries: 3, + InitialWait: 100 * time.Millisecond, + }), +) +``` + +The retry policy applies to `Unavailable` and `DeadlineExceeded` errors. +Other error codes are returned immediately without retrying. + +## Context Cancellation + +All client methods accept a `context.Context`. Cancelled or timed-out +contexts produce standard Go `context.Canceled` or +`context.DeadlineExceeded` errors: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +sandboxes, err := client.Sandboxes().List(ctx) +if errors.Is(err, context.DeadlineExceeded) { + // Request timed out +} +``` diff --git a/docs/sdks/go/getting-started.mdx b/docs/sdks/go/getting-started.mdx new file mode 100644 index 0000000000..90f3794304 --- /dev/null +++ b/docs/sdks/go/getting-started.mdx @@ -0,0 +1,101 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Getting Started" +description: "Install the OpenShell Go SDK, connect to a gateway, and make your first API call." +keywords: "Go SDK, Installation, Quickstart, Gateway, gRPC, Sandbox" +--- + +The Go SDK provides typed clients for the OpenShell gateway and edge APIs. +It wraps gRPC transport and exposes idiomatic Go types with functional option +configuration. + +## Installation + +```shell +go get github.com/NVIDIA/OpenShell/sdk/go@latest +``` + +Requires Go 1.24 or later. + +## Connect to a Gateway + +The gateway client reads connection details from the CLI configuration +(`~/.config/openshell/`). Pass the gateway name or leave it empty to use +the active gateway. + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +func main() { + // Connect to the active gateway (configured via `openshell gateway use`). + client, err := gateway.NewClient("") + if err != nil { + log.Fatal(err) + } + defer client.Close() + + // List sandboxes. + ctx := context.Background() + sandboxes, err := client.Sandboxes().List(ctx) + if err != nil { + log.Fatal(err) + } + for _, sb := range sandboxes { + fmt.Printf("Sandbox: %s (phase: %s)\n", sb.Name, sb.Status.Phase) + } +} +``` + +## Create a Sandbox + +```go +sandbox, err := client.Sandboxes().Create(ctx, "my-sandbox", &types.SandboxSpec{ + Template: types.SandboxTemplate{ + Image: "ubuntu:24.04", + }, +}, nil) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Created sandbox %s\n", sandbox.Name) +``` + +## Run a Command + +```go +result, err := client.Exec().Run(ctx, "my-sandbox", []string{"echo", "hello"}) +if err != nil { + log.Fatal(err) +} +fmt.Println(string(result.Stdout)) +``` + +## Client Options + +Configure the client with functional options: + +```go +client, err := gateway.NewClient("my-gateway", + gateway.WithTimeout(30 * time.Second), + gateway.WithLogger(myLogger), + gateway.WithRetryPolicy(&types.RetryPolicy{ + MaxRetries: 3, + InitialWait: 100 * time.Millisecond, + }), +) +``` + +## Next Steps + +- [Architecture](/sdks/go/architecture) for module structure and transport details +- [Error Handling](/sdks/go/error-handling) for SDK error types and retry patterns +- [Authentication](/sdks/go/authentication) for OIDC and token configuration diff --git a/sdk/go/Makefile b/sdk/go/Makefile new file mode 100644 index 0000000000..32170c0f09 --- /dev/null +++ b/sdk/go/Makefile @@ -0,0 +1,45 @@ +MISE := $(shell command -v mise 2>/dev/null) + +.PHONY: test test-integration lint fmt build ci docs-check + +test: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run test + +test-integration: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run test:integration + +lint: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run lint + +fmt: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run fmt + +build: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run build + +ci: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run ci + +docs-check: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run docs:check diff --git a/sdk/go/go.mod b/sdk/go/go.mod new file mode 100644 index 0000000000..dec3ef3fc9 --- /dev/null +++ b/sdk/go/go.mod @@ -0,0 +1,21 @@ +module github.com/NVIDIA/OpenShell/sdk/go + +go 1.24.0 + +require ( + github.com/coder/websocket v1.8.15 + github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.35.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/sdk/go/go.sum b/sdk/go/go.sum new file mode 100644 index 0000000000..0a382a0291 --- /dev/null +++ b/sdk/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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +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-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +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/sdk/go/mise.toml b/sdk/go/mise.toml new file mode 100644 index 0000000000..a5b90b1931 --- /dev/null +++ b/sdk/go/mise.toml @@ -0,0 +1,242 @@ +[tools] +go = "1.25" +protoc = "29.6" +"go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint" = "2.12" +"go:google.golang.org/protobuf/cmd/protoc-gen-go" = "1.36.11" +"go:google.golang.org/grpc/cmd/protoc-gen-go-grpc" = "1.6.2" + +[tasks.test] +description = "Run unit tests with coverage" +run = "go test -coverprofile=coverage.out -coverpkg=./openshell/... -race ./..." + +[tasks."test:integration"] +description = "Run integration tests" +run = "go test -tags=integration -race ./..." + +[tasks.lint] +description = "Run linter" +run = "golangci-lint run ./..." + +[tasks.fmt] +description = "Format code" +run = "goimports -w . && go fmt ./..." + +[tasks.build] +description = "Build all packages" +run = "go build ./..." + +[tasks.ci] +description = "Run full CI pipeline" +depends = ["lint", "build", "test", "proto:check", "docs:check"] + +[tasks."docs:check"] +description = "Verify every public package has a docs page" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +DOCS_DIR="docs/src/api" +SUMMARY="docs/src/SUMMARY.md" +MISSING=0 + +# Find all public packages with a doc.go (excluding internal, proto, types) +for docfile in openshell/v1/*/doc.go; do + pkg=$(basename "$(dirname "$docfile")") + + # Skip internal packages and types (no user-facing docs needed) + case "$pkg" in + internal|types) continue ;; + esac + + # Check for matching docs page + if [ ! -f "$DOCS_DIR/$pkg.md" ]; then + echo "MISSING: $DOCS_DIR/$pkg.md (package openshell/v1/$pkg has doc.go but no docs page)" + MISSING=$((MISSING + 1)) + fi + + # Check for SUMMARY.md entry + if ! grep -q "api/$pkg.md" "$SUMMARY" 2>/dev/null; then + echo "MISSING: SUMMARY.md entry for api/$pkg.md" + MISSING=$((MISSING + 1)) + fi +done + +if [ "$MISSING" -gt 0 ]; then + echo "" + echo "ERROR: $MISSING documentation gaps found." + echo "Every public package with doc.go needs a docs/src/api/.md page" + echo "and a SUMMARY.md entry. See Constitution XIII." + exit 1 +fi + +echo "Docs check passed: all public packages have documentation." +""" + +[tasks."proto:gen"] +description = "Generate Go bindings from proto files" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +# Check required tools +for tool in protoc protoc-gen-go protoc-gen-go-grpc; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Run 'mise install' to install it." + exit 1 + fi +done + +MODULE="github.com/NVIDIA/OpenShell/sdk/go" +PROTO_DIR="proto" + +# Create output directories +mkdir -p "$PROTO_DIR/openshellv1" "$PROTO_DIR/datamodelv1" "$PROTO_DIR/sandboxv1" + +# M flag mappings for Go package paths +M_FLAGS="" +M_FLAGS="$M_FLAGS --go_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go_opt=Msandbox.proto=$MODULE/proto/sandboxv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Msandbox.proto=$MODULE/proto/sandboxv1" + +# Generate Go code for all proto files +# Use module= option to strip the module prefix from the output path, +# so files land in proto/openshellv1/ etc. instead of the full module path +protoc \ + --proto_path="$PROTO_DIR" \ + --go_out=. \ + --go_opt=module=$MODULE \ + --go-grpc_out=. \ + --go-grpc_opt=module=$MODULE \ + $M_FLAGS \ + "$PROTO_DIR/openshell.proto" \ + "$PROTO_DIR/datamodel.proto" \ + "$PROTO_DIR/sandbox.proto" + +echo "Proto generation complete." +echo "Generated packages:" +for pkg in openshellv1 datamodelv1 sandboxv1; do + echo " $PROTO_DIR/$pkg/: $(ls "$PROTO_DIR/$pkg/"*.go 2>/dev/null | wc -l | tr -d ' ') files" +done +""" + +[tasks."proto:check"] +description = "Verify generated proto files are up to date" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +# Check required tools +for tool in protoc protoc-gen-go protoc-gen-go-grpc; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Run 'mise install' to install it." + exit 1 + fi +done + +MODULE="github.com/NVIDIA/OpenShell/sdk/go" +PROTO_DIR="proto" +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +# Create output directories in temp +mkdir -p "$WORK_DIR/proto/openshellv1" "$WORK_DIR/proto/datamodelv1" "$WORK_DIR/proto/sandboxv1" + +# M flag mappings (same as proto:gen) +M_FLAGS="" +M_FLAGS="$M_FLAGS --go_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go_opt=Msandbox.proto=$MODULE/proto/sandboxv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Msandbox.proto=$MODULE/proto/sandboxv1" + +# Generate to temp directory +protoc \ + --proto_path="$PROTO_DIR" \ + --go_out="$WORK_DIR" \ + --go_opt=module=$MODULE \ + --go-grpc_out="$WORK_DIR" \ + --go-grpc_opt=module=$MODULE \ + $M_FLAGS \ + "$PROTO_DIR/openshell.proto" \ + "$PROTO_DIR/datamodel.proto" \ + "$PROTO_DIR/sandbox.proto" + +# Diff against committed files +DIFF_OUTPUT=$(diff -r "$WORK_DIR/proto" "$PROTO_DIR" \ + --exclude="*.proto" \ + --exclude="UPSTREAM_VERSION" \ + 2>&1) || true + +if [ -n "$DIFF_OUTPUT" ]; then + echo "ERROR: Generated proto files are out of date." + echo "Run 'mise run proto:gen' to regenerate." + echo "" + echo "$DIFF_OUTPUT" + exit 1 +fi + +echo "Proto check passed: generated files are up to date." +""" + +[tasks."proto:clean"] +description = "Remove all generated proto files" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +PROTO_DIR="proto" + +# Remove all generated .pb.go files +find "$PROTO_DIR" -name "*.pb.go" -delete 2>/dev/null || true + +# Remove empty generated subdirectories +for dir in "$PROTO_DIR"/*/; do + [ -d "$dir" ] && rmdir "$dir" 2>/dev/null || true +done + +echo "Proto clean complete." +""" + +[tasks."proto:sync"] +description = "Sync proto files from upstream OpenShell repo" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +UPSTREAM_PATH="${UPSTREAM_PATH:-../OpenShell/proto}" + +if [ ! -d "$UPSTREAM_PATH" ]; then + echo "ERROR: Upstream proto directory not found: $UPSTREAM_PATH" + echo "Set UPSTREAM_PATH env var to the correct location." + exit 1 +fi + +PROTO_DIR="proto" +mkdir -p "$PROTO_DIR" + +PROTO_FILES="openshell.proto datamodel.proto sandbox.proto" +for f in $PROTO_FILES; do + src="$UPSTREAM_PATH/$f" + if [ ! -f "$src" ]; then + echo "ERROR: Proto file not found: $src" + exit 1 + fi + cp "$src" "$PROTO_DIR/$f" + echo "Copied $f" +done + +# Record upstream commit SHA +UPSTREAM_REPO_DIR="$(cd "$UPSTREAM_PATH/.." && pwd)" +if [ -d "$UPSTREAM_REPO_DIR/.git" ]; then + SHA=$(git -C "$UPSTREAM_REPO_DIR" rev-parse HEAD) +else + SHA="unknown" +fi +echo "$SHA" > "$PROTO_DIR/UPSTREAM_VERSION" +echo "Upstream version: $SHA" +echo "Proto sync complete." +""" diff --git a/sdk/go/openshell/v1/auth.go b/sdk/go/openshell/v1/auth.go new file mode 100644 index 0000000000..95a7838f08 --- /dev/null +++ b/sdk/go/openshell/v1/auth.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// AuthProvider supplies per-RPC credentials. It implements the +// grpc credentials.PerRPCCredentials interface. +type AuthProvider = types.AuthProvider + +type noAuth struct{} + +// NoAuth returns an AuthProvider that sends no credentials. +func NoAuth() AuthProvider { + return &noAuth{} +} + +func (n *noAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return nil, nil +} + +func (n *noAuth) RequireTransportSecurity() bool { + return false +} + +type staticToken struct { + token string +} + +// StaticToken returns an AuthProvider that sends a fixed Bearer token. +func StaticToken(token string) AuthProvider { + return &staticToken{token: token} +} + +func (s *staticToken) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{ + "authorization": "Bearer " + s.token, + }, nil +} + +func (s *staticToken) RequireTransportSecurity() bool { + return true +} diff --git a/sdk/go/openshell/v1/auth_extra.go b/sdk/go/openshell/v1/auth_extra.go new file mode 100644 index 0000000000..21a9e9e2cf --- /dev/null +++ b/sdk/go/openshell/v1/auth_extra.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "maps" + "strings" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// extraHeadersAuth wraps a base AuthProvider with additional static headers +// that are merged into every GetRequestMetadata call. Extra headers take +// precedence over base headers on key collision (case-insensitive). +type extraHeadersAuth struct { + base types.AuthProvider + headers map[string]string // keys already lowercase, empty values filtered out +} + +// WithExtraHeaders wraps base with additional per-RPC headers. Keys are +// normalized to lowercase per HTTP/2 (RFC 9113). Empty-string values are +// silently dropped. The headers map is deep-copied at construction time, +// so later mutations to the caller's map have no effect. +// +// Returns an error if base is nil or if headers is nil, empty, or contains +// only empty-string values. +func WithExtraHeaders(base AuthProvider, headers map[string]string) (AuthProvider, error) { + if base == nil { + return nil, errors.New("base auth provider must not be nil") + } + if len(headers) == 0 { + return nil, errors.New("headers must not be nil or empty") + } + + // Deep-copy and normalize: lowercase keys, skip empty values. + normalized := make(map[string]string, len(headers)) + for k, v := range headers { + if v == "" { + continue + } + normalized[strings.ToLower(k)] = v + } + + if len(normalized) == 0 { + return nil, errors.New("headers must contain at least one non-empty value") + } + + return &extraHeadersAuth{ + base: base, + headers: normalized, + }, nil +} + +// GetRequestMetadata merges base metadata with extra headers. Extra headers +// win on key collision because they are applied after the base metadata. +func (e *extraHeadersAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { + baseMD, err := e.base.GetRequestMetadata(ctx, uri...) + if err != nil { + return nil, err + } + + // Start with base metadata (may be nil for NoAuth). + // Normalize base keys to lowercase for case-insensitive collision. + merged := make(map[string]string, len(baseMD)+len(e.headers)) + for k, v := range baseMD { + merged[strings.ToLower(k)] = v + } + // Extra headers overwrite base on collision. + maps.Copy(merged, e.headers) + + return merged, nil +} + +// RequireTransportSecurity delegates to the base auth provider. +func (e *extraHeadersAuth) RequireTransportSecurity() bool { + return e.base.RequireTransportSecurity() +} diff --git a/sdk/go/openshell/v1/auth_extra_test.go b/sdk/go/openshell/v1/auth_extra_test.go new file mode 100644 index 0000000000..09bd4d33d2 --- /dev/null +++ b/sdk/go/openshell/v1/auth_extra_test.go @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithExtraHeaders_NilBase(t *testing.T) { + _, err := WithExtraHeaders(nil, map[string]string{"x-key": "val"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "base") +} + +func TestWithExtraHeaders_NilHeaders(t *testing.T) { + _, err := WithExtraHeaders(NoAuth(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "headers") +} + +func TestWithExtraHeaders_EmptyHeaders(t *testing.T) { + _, err := WithExtraHeaders(NoAuth(), map[string]string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "headers") +} + +func TestWithExtraHeaders_AllEmptyValues(t *testing.T) { + // All values are empty strings, so after filtering, headers map is empty. + _, err := WithExtraHeaders(NoAuth(), map[string]string{"x-key": ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "headers") +} + +func TestWithExtraHeaders_MergesWithBase(t *testing.T) { + base := StaticToken("my-token") + auth, err := WithExtraHeaders(base, map[string]string{ + "x-proxy-key": "proxy-secret", + "x-tenant-id": "acme-corp", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "Bearer my-token", md["authorization"]) + assert.Equal(t, "proxy-secret", md["x-proxy-key"]) + assert.Equal(t, "acme-corp", md["x-tenant-id"]) +} + +func TestWithExtraHeaders_ExtraPrecedenceOnCollision(t *testing.T) { + base := StaticToken("my-token") + auth, err := WithExtraHeaders(base, map[string]string{ + "authorization": "Custom override-token", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Extra header wins over base. + assert.Equal(t, "Custom override-token", md["authorization"]) +} + +func TestWithExtraHeaders_CaseInsensitiveCollision(t *testing.T) { + base := StaticToken("my-token") + // "Authorization" with uppercase should still override "authorization". + auth, err := WithExtraHeaders(base, map[string]string{ + "Authorization": "Custom override-token", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "Custom override-token", md["authorization"]) +} + +func TestWithExtraHeaders_EmptyValueSkipped(t *testing.T) { + base := StaticToken("my-token") + auth, err := WithExtraHeaders(base, map[string]string{ + "x-proxy-key": "proxy-secret", + "x-empty": "", // Should be silently skipped. + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "proxy-secret", md["x-proxy-key"]) + _, hasEmpty := md["x-empty"] + assert.False(t, hasEmpty, "empty-string header values should be skipped") +} + +func TestWithExtraHeaders_RequireTransportSecurity_Delegates(t *testing.T) { + tests := []struct { + name string + base AuthProvider + expected bool + }{ + { + name: "delegates to NoAuth (false)", + base: NoAuth(), + expected: false, + }, + { + name: "delegates to StaticToken (true)", + base: StaticToken("tok"), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth, err := WithExtraHeaders(tt.base, map[string]string{"x-key": "val"}) + require.NoError(t, err) + assert.Equal(t, tt.expected, auth.RequireTransportSecurity()) + }) + } +} + +func TestWithExtraHeaders_WithNoAuth(t *testing.T) { + auth, err := WithExtraHeaders(NoAuth(), map[string]string{ + "x-proxy-key": "proxy-secret", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // NoAuth returns nil metadata, extra headers should still appear. + assert.Equal(t, "proxy-secret", md["x-proxy-key"]) +} + +func TestWithExtraHeaders_BaseError_Propagated(t *testing.T) { + base := &errAuth{err: errors.New("auth failure")} + auth, err := WithExtraHeaders(base, map[string]string{"x-key": "val"}) + require.NoError(t, err) + + _, err = auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "auth failure") +} + +func TestWithExtraHeaders_DeepCopiesHeaders(t *testing.T) { + original := map[string]string{ + "x-key": "original-value", + } + auth, err := WithExtraHeaders(NoAuth(), original) + require.NoError(t, err) + + // Mutate the original map after construction. + original["x-key"] = "mutated-value" + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // The wrapper should use the value at construction time, not the mutated value. + assert.Equal(t, "original-value", md["x-key"]) +} + +// errAuth is a test helper that always returns an error. +type errAuth struct { + err error +} + +func (e *errAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return nil, e.err +} + +func (e *errAuth) RequireTransportSecurity() bool { + return false +} diff --git a/sdk/go/openshell/v1/auth_refresh.go b/sdk/go/openshell/v1/auth_refresh.go new file mode 100644 index 0000000000..843e920b81 --- /dev/null +++ b/sdk/go/openshell/v1/auth_refresh.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const defaultLeeway = 10 * time.Second + +var errNilTokenSource = errors.New("openshell: TokenSource must not be nil") + +// RefreshOption configures the behavior of RefreshableToken. +type RefreshOption func(*refreshConfig) + +type refreshConfig struct { + leeway time.Duration + logger types.Logger +} + +func defaultRefreshConfig() refreshConfig { + return refreshConfig{ + leeway: defaultLeeway, + } +} + +// WithLeeway sets the duration before token expiry at which a proactive +// refresh is triggered. Default is 10 seconds. +func WithLeeway(d time.Duration) RefreshOption { + return func(c *refreshConfig) { + if d < 0 { + d = 0 + } + c.leeway = d + } +} + +// WithLogger sets the logger used for stale-token fallback warnings. +// When not set, warnings are silently dropped. +func WithLogger(l types.Logger) RefreshOption { + return func(c *refreshConfig) { + c.logger = l + } +} + +type refreshableAuth struct { + source oauth2.TokenSource + mu sync.RWMutex + tok *oauth2.Token + leeway time.Duration + logger types.Logger +} + +func (r *refreshableAuth) isTokenValid() bool { + if r.tok == nil { + return false + } + if r.tok.Expiry.IsZero() { + return true + } + return time.Now().Before(r.tok.Expiry.Add(-r.leeway)) +} + +func (r *refreshableAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + // Fast path: RLock, return cached token if valid. + r.mu.RLock() + if r.isTokenValid() { + tok := r.tok.AccessToken + r.mu.RUnlock() + return map[string]string{"authorization": "Bearer " + tok}, nil + } + r.mu.RUnlock() + + // Slow path: Lock, re-check, fetch if still stale. + r.mu.Lock() + defer r.mu.Unlock() + + if r.isTokenValid() { + return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil + } + + newTok, err := r.source.Token() + if err != nil { + if r.tok != nil { + if r.logger != nil { + r.logger.Error(err, "token refresh failed, using cached token") + } + return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil + } + return nil, err + } + + r.tok = newTok + return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil +} + +func (r *refreshableAuth) RequireTransportSecurity() bool { + return true +} + +// RefreshableToken returns an AuthProvider that caches tokens from src +// and refreshes them before expiry. Concurrent callers share a single +// refresh call (coalesced via RWMutex double-checked locking). +func RefreshableToken(src oauth2.TokenSource, opts ...RefreshOption) (AuthProvider, error) { + if src == nil { + return nil, errNilTokenSource + } + + cfg := defaultRefreshConfig() + for _, o := range opts { + o(&cfg) + } + + return &refreshableAuth{ + source: src, + leeway: cfg.leeway, + logger: cfg.logger, + }, nil +} diff --git a/sdk/go/openshell/v1/auth_refresh_test.go b/sdk/go/openshell/v1/auth_refresh_test.go new file mode 100644 index 0000000000..451e8e63df --- /dev/null +++ b/sdk/go/openshell/v1/auth_refresh_test.go @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// mockTokenSource implements oauth2.TokenSource for testing. +type mockTokenSource struct { + mu sync.Mutex + tokenFunc func() (*oauth2.Token, error) + callCount int +} + +func (m *mockTokenSource) Token() (*oauth2.Token, error) { + m.mu.Lock() + m.callCount++ + m.mu.Unlock() + return m.tokenFunc() +} + +func (m *mockTokenSource) calls() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.callCount +} + +// --- Phase 2 tests: constructor validation --- + +func TestRefreshableToken_NilSource(t *testing.T) { + _, err := RefreshableToken(nil) + require.Error(t, err) + assert.Equal(t, "openshell: TokenSource must not be nil", err.Error()) +} + +func TestRefreshableToken_ValidSource(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + assert.NotNil(t, provider) +} + +// --- Phase 3 / US1 tests: automatic token refresh --- + +func TestGetRequestMetadata_FirstCallFetchesToken(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "fresh-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer fresh-token", md["authorization"]) + assert.Equal(t, 1, src.calls()) +} + +func TestGetRequestMetadata_CachedTokenNoExtraCall(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "cached-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer cached-token", md["authorization"]) + assert.Equal(t, 1, src.calls(), "second call should use cache, not invoke TokenSource") +} + +func TestGetRequestMetadata_RefreshesExpiredToken(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "old", Expiry: time.Now().Add(-time.Minute)}, nil + } + return &oauth2.Token{AccessToken: "new", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + // First call gets the expired token, which is immediately stale. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer old", md["authorization"]) + + // Second call should trigger a refresh since the cached token is expired. + md, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer new", md["authorization"]) + assert.Equal(t, 2, src.calls()) +} + +func TestGetRequestMetadata_ConcurrentSingleFlight(t *testing.T) { + var fetchCount atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + fetchCount.Add(1) + time.Sleep(10 * time.Millisecond) // simulate slow token fetch + return &oauth2.Token{AccessToken: "shared-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + const goroutines = 1000 + var wg sync.WaitGroup + wg.Add(goroutines) + results := make([]string, goroutines) + errs := make([]error, goroutines) + + for i := range goroutines { + go func(idx int) { + defer wg.Done() + md, e := provider.GetRequestMetadata(context.Background()) + errs[idx] = e + if md != nil { + results[idx] = md["authorization"] + } + }(i) + } + wg.Wait() + + for i := range goroutines { + require.NoError(t, errs[i], "goroutine %d failed", i) + assert.Equal(t, "Bearer shared-token", results[i], "goroutine %d got wrong token", i) + } + assert.Equal(t, int32(1), fetchCount.Load(), "expected exactly 1 TokenSource.Token() call, got %d", fetchCount.Load()) +} + +func TestRefreshableAuth_RequireTransportSecurity(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "t"}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + assert.True(t, provider.RequireTransportSecurity()) +} + +// --- Phase 4 / US2 tests: graceful degradation --- + +func TestGetRequestMetadata_RefreshFailureReturnsStaleCachedToken(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil + } + return nil, fmt.Errorf("idp unavailable") + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + // First call succeeds but returns already-expired token. + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Second call: refresh fails, should return stale token. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer stale", md["authorization"]) +} + +func TestGetRequestMetadata_RefreshFailureLogsWarning(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil + } + return nil, fmt.Errorf("idp unavailable") + }, + } + + logger := &captureLogger{} + provider, err := RefreshableToken(src, WithLeeway(0), WithLogger(logger)) + require.NoError(t, err) + + _, _ = provider.GetRequestMetadata(context.Background()) + _, _ = provider.GetRequestMetadata(context.Background()) + + require.Len(t, logger.errors, 1) + assert.Contains(t, logger.errors[0].msg, "token refresh failed") +} + +func TestGetRequestMetadata_RefreshFailureNoCachedTokenReturnsError(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return nil, fmt.Errorf("idp unavailable") + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "idp unavailable") +} + +func TestGetRequestMetadata_RefreshFailureNoLoggerNoPanic(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil + } + return nil, fmt.Errorf("idp unavailable") + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + _, _ = provider.GetRequestMetadata(context.Background()) + + assert.NotPanics(t, func() { + _, _ = provider.GetRequestMetadata(context.Background()) + }) +} + +// --- Phase 5 / US3 tests: configurable leeway --- + +func TestGetRequestMetadata_DefaultLeewayTriggersRefresh(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + return &oauth2.Token{ + AccessToken: fmt.Sprintf("token-%d", n), + Expiry: time.Now().Add(5 * time.Second), // within default 10s leeway + }, nil + }, + } + provider, err := RefreshableToken(src) // default 10s leeway + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Token expires in 5s, which is within 10s leeway, so next call should refresh. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer token-2", md["authorization"]) + assert.Equal(t, 2, src.calls()) +} + +func TestGetRequestMetadata_CustomLeewayTriggersRefresh(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + return &oauth2.Token{ + AccessToken: fmt.Sprintf("token-%d", n), + Expiry: time.Now().Add(25 * time.Second), // within custom 30s leeway + }, nil + }, + } + provider, err := RefreshableToken(src, WithLeeway(30*time.Second)) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Token expires in 25s, which is within 30s leeway, so next call should refresh. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer token-2", md["authorization"]) + assert.Equal(t, 2, src.calls()) +} + +func TestGetRequestMetadata_ZeroExpiryNeverRefreshes(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "forever-token"}, nil // zero Expiry + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Call multiple times; should never refresh since expiry is zero. + for range 10 { + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + } + assert.Equal(t, 1, src.calls(), "zero-expiry token should never be refreshed") +} + +// --- benchmarks --- + +func BenchmarkGetRequestMetadata_CachedToken(b *testing.B) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "bench-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(b, err) + + // Prime the cache. + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(b, err) + + b.ResetTimer() + b.ReportAllocs() + for range b.N { + _, _ = provider.GetRequestMetadata(context.Background()) + } +} + +// --- helpers --- + +type logEntry struct { + err error + msg string +} + +type captureLogger struct { + mu sync.Mutex + debugs []string + infos []string + errors []logEntry +} + +func (l *captureLogger) Debug(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.debugs = append(l.debugs, msg) +} + +func (l *captureLogger) Info(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.infos = append(l.infos, msg) +} + +func (l *captureLogger) Error(err error, msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.errors = append(l.errors, logEntry{err: err, msg: msg}) +} diff --git a/sdk/go/openshell/v1/auth_test.go b/sdk/go/openshell/v1/auth_test.go new file mode 100644 index 0000000000..2981fd7921 --- /dev/null +++ b/sdk/go/openshell/v1/auth_test.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoAuth_GetRequestMetadata(t *testing.T) { + auth := NoAuth() + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Empty(t, md) +} + +func TestNoAuth_RequireTransportSecurity(t *testing.T) { + auth := NoAuth() + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestStaticToken_GetRequestMetadata(t *testing.T) { + auth := StaticToken("my-secret-token") + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer my-secret-token", md["authorization"]) +} + +func TestStaticToken_RequireTransportSecurity(t *testing.T) { + auth := StaticToken("token") + assert.True(t, auth.RequireTransportSecurity()) +} diff --git a/sdk/go/openshell/v1/client.go b/sdk/go/openshell/v1/client.go new file mode 100644 index 0000000000..cfa907290f --- /dev/null +++ b/sdk/go/openshell/v1/client.go @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + + internalgrpc "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/grpc" + "google.golang.org/grpc" +) + +// Config holds all settings needed to create a Client. +type Config = types.Config + +// ClientInterface defines the top-level SDK surface. +type ClientInterface interface { + Sandboxes() SandboxInterface + Providers() ProviderInterface + Services() ServiceInterface + Exec() ExecInterface + Files() FileInterface + Health() HealthInterface + SSH() SSHInterface + TCP() TCPInterface + Config() ConfigInterface + Policy() PolicyInterface + Close() error +} + +// SandboxInterface is defined in sandbox.go + +// ProviderInterface is defined in provider.go + +// ExecInterface is defined in exec.go + +// FileInterface is defined in file.go + + +// Client implements ClientInterface. It holds a gRPC connection and provides +// sub-client accessors following the Kubernetes client-go pattern. +type Client struct { + conn *grpc.ClientConn + config Config + + closeOnce sync.Once + closeErr error + + sandboxes SandboxInterface + providers ProviderInterface + services ServiceInterface + exec ExecInterface + files FileInterface + health HealthInterface + ssh SSHInterface + tcp TCPInterface + cfg ConfigInterface + policy PolicyInterface +} + +// NewClient creates a new SDK client connected to the given gateway. +func NewClient(cfg Config) (*Client, error) { + if cfg.Address == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "address must not be empty"} + } + + if cfg.Auth == nil { + cfg.Auth = NoAuth() + } + + var tlsParams *internalgrpc.TLSParams + if cfg.TLS != nil { + tlsParams = &internalgrpc.TLSParams{ + CertFile: cfg.TLS.CertFile, + KeyFile: cfg.TLS.KeyFile, + CAFile: cfg.TLS.CAFile, + Insecure: cfg.TLS.Insecure, + } + } + + conn, err := internalgrpc.NewConnection(cfg.Address, tlsParams, cfg.Auth) + if err != nil { + return nil, err + } + + c := &Client{ + conn: conn, + config: cfg, + } + + c.sandboxes = newSandboxClient(conn) + c.providers = newProviderClient(conn) + c.services = newServiceClient(conn) + c.exec = newExecClient(conn, c.sandboxes) + c.files = newFileClient(conn, c.sandboxes) + c.health = newHealthClient(conn) + c.ssh = newSSHClient(conn, c.sandboxes) + c.tcp = newTCPClient(conn, c.sandboxes, c.ssh) + c.cfg = newConfigClient(conn, c.sandboxes) + c.policy = newPolicyClient(conn) + + return c, nil +} + +// Sandboxes returns the sandbox sub-client. +func (c *Client) Sandboxes() SandboxInterface { return c.sandboxes } + +// Providers returns the provider sub-client. +func (c *Client) Providers() ProviderInterface { return c.providers } + +// Services returns the service sub-client. +func (c *Client) Services() ServiceInterface { return c.services } + +// Exec returns the exec sub-client. +func (c *Client) Exec() ExecInterface { return c.exec } + +// Files returns the file sub-client. +func (c *Client) Files() FileInterface { return c.files } + +// Health returns the health sub-client. +func (c *Client) Health() HealthInterface { return c.health } + +// SSH returns the SSH session sub-client. +func (c *Client) SSH() SSHInterface { return c.ssh } + +// TCP returns the TCP port forwarding sub-client. +func (c *Client) TCP() TCPInterface { return c.tcp } + +// Config returns the configuration sub-client. +func (c *Client) Config() ConfigInterface { return c.cfg } + +// Policy returns the policy management sub-client. +func (c *Client) Policy() PolicyInterface { return c.policy } + +// Close closes the underlying gRPC connection. Safe to call multiple times. +func (c *Client) Close() error { + c.closeOnce.Do(func() { + c.closeErr = c.conn.Close() + }) + return c.closeErr +} diff --git a/sdk/go/openshell/v1/client_test.go b/sdk/go/openshell/v1/client_test.go new file mode 100644 index 0000000000..7d6029b053 --- /dev/null +++ b/sdk/go/openshell/v1/client_test.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewClient_EmptyAddress(t *testing.T) { + _, err := NewClient(Config{Address: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "address") +} + +func TestNewClient_ValidConfig(t *testing.T) { + client, err := NewClient(Config{ + Address: "localhost:50051", + Auth: NoAuth(), + TLS: &TLSConfig{Insecure: true}, + }) + require.NoError(t, err) + require.NotNil(t, client) + + assert.NotNil(t, client.Sandboxes()) + assert.NotNil(t, client.Providers()) + assert.NotNil(t, client.Exec()) + assert.NotNil(t, client.Files()) + assert.NotNil(t, client.Health()) + + err = client.Close() + assert.NoError(t, err) +} + +func TestClient_CloseIdempotent(t *testing.T) { + client, err := NewClient(Config{ + Address: "localhost:50051", + Auth: NoAuth(), + TLS: &TLSConfig{Insecure: true}, + }) + require.NoError(t, err) + + err = client.Close() + assert.NoError(t, err) + + err = client.Close() + assert.NoError(t, err) +} + +func TestNewClient_DefaultAuth(t *testing.T) { + client, err := NewClient(Config{ + Address: "localhost:50051", + TLS: &TLSConfig{Insecure: true}, + }) + require.NoError(t, err) + require.NotNil(t, client) + _ = client.Close() +} diff --git a/sdk/go/openshell/v1/config.go b/sdk/go/openshell/v1/config.go new file mode 100644 index 0000000000..2dd7fed4a2 --- /dev/null +++ b/sdk/go/openshell/v1/config.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// SandboxConfig represents the full configuration state of a sandbox. +type SandboxConfig = types.SandboxConfig + +// GatewayConfig represents gateway-global settings. +type GatewayConfig = types.GatewayConfig + +// ConfigUpdate represents a configuration mutation request. +type ConfigUpdate = types.ConfigUpdate + +// ConfigUpdateResult holds the result of a configuration update operation. +type ConfigUpdateResult = types.ConfigUpdateResult + +// SettingValue is a typed setting value (string, bool, int64, or bytes). +type SettingValue = types.SettingValue + +// SettingValueType identifies which typed field of a SettingValue is active. +type SettingValueType = types.SettingValueType + +// EffectiveSetting is a setting value paired with its resolved scope. +type EffectiveSetting = types.EffectiveSetting + +// SettingScope indicates whether a setting is sandbox or global. +type SettingScope = types.SettingScope + +// PolicySource indicates the source of a policy payload. +type PolicySource = types.PolicySource + +// SettingValueType constants re-exported from types package. +const ( + SettingValueString = types.SettingValueString + SettingValueBool = types.SettingValueBool + SettingValueInt = types.SettingValueInt + SettingValueBytes = types.SettingValueBytes +) + +// SettingScope constants re-exported from types package. +const ( + SettingScopeUnspecified = types.SettingScopeUnspecified + SettingScopeSandbox = types.SettingScopeSandbox + SettingScopeGlobal = types.SettingScopeGlobal +) + +// PolicySource constants re-exported from types package. +const ( + PolicySourceUnspecified = types.PolicySourceUnspecified + PolicySourceSandbox = types.PolicySourceSandbox + PolicySourceGlobal = types.PolicySourceGlobal +) + +// ConfigInterface defines operations for reading and updating gateway and +// sandbox configuration. +type ConfigInterface interface { + // GetSandbox retrieves the full configuration state for a sandbox, + // including policy, effective settings, and revision metadata. + // The sandbox is identified by name; the SDK resolves it to an ID internally. + GetSandbox(ctx context.Context, sandboxName string) (*SandboxConfig, error) + + // GetGateway retrieves gateway-global settings. + GetGateway(ctx context.Context) (*GatewayConfig, error) + + // Update applies a configuration mutation. For sandbox-scoped updates, + // set ConfigUpdate.Name to the sandbox name. For global-scoped updates, + // set ConfigUpdate.Global to true. + Update(ctx context.Context, update *ConfigUpdate) (*ConfigUpdateResult, error) +} diff --git a/sdk/go/openshell/v1/config_client.go b/sdk/go/openshell/v1/config_client.go new file mode 100644 index 0000000000..810a66b554 --- /dev/null +++ b/sdk/go/openshell/v1/config_client.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "google.golang.org/grpc" +) + +type configClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newConfigClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *configClient { + return &configClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes} +} + +func (c *configClient) GetSandbox(ctx context.Context, sandboxName string) (*SandboxConfig, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + // Resolve sandbox name to ID — the proto RPC takes SandboxId, not name. + sb, err := c.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + resp, err := c.client.GetSandboxConfig(ctx, &sbv1.GetSandboxConfigRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxConfigFromProto(resp), nil +} + +func (c *configClient) GetGateway(ctx context.Context) (*GatewayConfig, error) { + resp, err := c.client.GetGatewayConfig(ctx, &sbv1.GetGatewayConfigRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.GatewayConfigFromProto(resp), nil +} + +func (c *configClient) Update(ctx context.Context, update *ConfigUpdate) (*ConfigUpdateResult, error) { + if update == nil { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "update must not be nil", + } + } + req, convErr := converter.ConfigUpdateToProto(update) + if convErr != nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: convErr.Error()} + } + resp, err := c.client.UpdateConfig(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ConfigUpdateResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/config_client_test.go b/sdk/go/openshell/v1/config_client_test.go new file mode 100644 index 0000000000..b3dd22cd80 --- /dev/null +++ b/sdk/go/openshell/v1/config_client_test.go @@ -0,0 +1,560 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --- Mock server for Config RPCs --- + +type mockConfigServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + + // Canned responses. + sandboxResp *sbv1.GetSandboxConfigResponse + gatewayResp *sbv1.GetGatewayConfigResponse + updateResp *pb.UpdateConfigResponse + + // Recorded requests. + lastSandboxReq *sbv1.GetSandboxConfigRequest + lastGatewayReq *sbv1.GetGatewayConfigRequest + lastUpdateReq *pb.UpdateConfigRequest + + // Inject errors. + sandboxErr error + gatewayErr error + updateErr error +} + +func newMockConfigServer() *mockConfigServer { + return &mockConfigServer{} +} + +func (s *mockConfigServer) GetSandboxConfig(_ context.Context, req *sbv1.GetSandboxConfigRequest) (*sbv1.GetSandboxConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastSandboxReq = req + if s.sandboxErr != nil { + return nil, s.sandboxErr + } + return s.sandboxResp, nil +} + +func (s *mockConfigServer) GetGatewayConfig(_ context.Context, req *sbv1.GetGatewayConfigRequest) (*sbv1.GetGatewayConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGatewayReq = req + if s.gatewayErr != nil { + return nil, s.gatewayErr + } + return s.gatewayResp, nil +} + +func (s *mockConfigServer) UpdateConfig(_ context.Context, req *pb.UpdateConfigRequest) (*pb.UpdateConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastUpdateReq = req + if s.updateErr != nil { + return nil, s.updateErr + } + return s.updateResp, nil +} + +// --- Test setup --- + +func setupConfigTest(t *testing.T, mock *mockConfigServer) (*configClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newConfigClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- GetSandbox tests --- + +func TestConfigGetSandbox(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{ + Policy: &sbv1.SandboxPolicy{ + Version: 4, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + Version: 3, + PolicyHash: "sha256:deadbeef", + ConfigRevision: 42, + PolicySource: sbv1.PolicySource_POLICY_SOURCE_SANDBOX, + GlobalPolicyVersion: 1, + ProviderEnvRevision: 7, + Settings: map[string]*sbv1.EffectiveSetting{ + "max_tokens": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 4096}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + "debug": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, sc) + + // Verify request was forwarded with resolved ID (stubSandboxResolver returns "sb-"). + mock.mu.Lock() + assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId()) + mock.mu.Unlock() + + // Scalar fields. + assert.Equal(t, uint32(3), sc.PolicyVersion) + assert.Equal(t, "sha256:deadbeef", sc.PolicyHash) + assert.Equal(t, uint64(42), sc.ConfigRevision) + assert.Equal(t, PolicySource("sandbox"), sc.PolicySource) + assert.Equal(t, uint32(1), sc.GlobalPolicyVersion) + assert.Equal(t, uint64(7), sc.ProviderEnvRevision) + + // Typed SandboxPolicy. + require.NotNil(t, sc.Policy) + assert.Equal(t, uint32(4), sc.Policy.Version) + require.NotNil(t, sc.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, sc.Policy.Filesystem.ReadOnly) + + // Settings map. + require.Len(t, sc.Settings, 2) + + maxTok := sc.Settings["max_tokens"] + assert.Equal(t, SettingValueType("int"), maxTok.Value.Type) + assert.Equal(t, int64(4096), maxTok.Value.IntVal) + assert.Equal(t, SettingScope("sandbox"), maxTok.Scope) + + debug := sc.Settings["debug"] + assert.Equal(t, SettingValueType("bool"), debug.Value.Type) + assert.True(t, debug.Value.BoolVal) + assert.Equal(t, SettingScope("global"), debug.Scope) +} + +func TestConfigGetSandbox_DeepCopy(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{ + Version: 1, + Settings: map[string]*sbv1.EffectiveSetting{ + "key": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: []byte("original")}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "sb1") + require.NoError(t, err) + + // Mutate the returned setting — should not affect future calls. + sc.Settings["key"] = EffectiveSetting{} + + sc2, err := client.GetSandbox(context.Background(), "sb1") + require.NoError(t, err) + + // The server still returns the original value — verifies we're not + // sharing references between calls. + require.Contains(t, sc2.Settings, "key") + assert.Equal(t, []byte("original"), sc2.Settings["key"].Value.BytesVal) +} + +func TestConfigGetSandbox_Error(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxErr = status.Errorf(codes.Unavailable, "server unavailable") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "my-sandbox") + + assert.Nil(t, sc) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Name-to-ID resolution tests --- + +func TestConfigGetSandbox_ResolvesNameToID(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{Version: 1} + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "my-sandbox") + require.NoError(t, err) + require.NotNil(t, sc) + + // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name. + mock.mu.Lock() + assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId(), "GetSandbox should send resolved sandbox ID, not the name") + mock.mu.Unlock() +} + +func TestConfigGetSandbox_ResolutionError(t *testing.T) { + mock := newMockConfigServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newConfigClient(conn, resolver) + + sc, err := client.GetSandbox(context.Background(), "nonexistent") + assert.Nil(t, sc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- GetGateway tests --- + +func TestConfigGetGateway(t *testing.T) { + mock := newMockConfigServer() + mock.gatewayResp = &sbv1.GetGatewayConfigResponse{ + SettingsRevision: 99, + Settings: map[string]*sbv1.SettingValue{ + "rate_limit": { + Value: &sbv1.SettingValue_IntValue{IntValue: 1000}, + }, + "motd": { + Value: &sbv1.SettingValue_StringValue{StringValue: "welcome"}, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + gc, err := client.GetGateway(context.Background()) + + require.NoError(t, err) + require.NotNil(t, gc) + + assert.Equal(t, uint64(99), gc.SettingsRevision) + require.Len(t, gc.Settings, 2) + + rl := gc.Settings["rate_limit"] + assert.Equal(t, SettingValueType("int"), rl.Type) + assert.Equal(t, int64(1000), rl.IntVal) + + motd := gc.Settings["motd"] + assert.Equal(t, SettingValueType("string"), motd.Type) + assert.Equal(t, "welcome", motd.StringVal) +} + +func TestConfigGetGateway_Error(t *testing.T) { + mock := newMockConfigServer() + mock.gatewayErr = status.Errorf(codes.Internal, "internal error") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + gc, err := client.GetGateway(context.Background()) + + assert.Nil(t, gc) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +// --- Update tests --- + +func TestConfigUpdate_SandboxScope(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 5, + PolicyHash: "sha256:cafe", + SettingsRevision: 10, + Deleted: false, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "max_tokens", + SettingValue: &SettingValue{ + Type: SettingValueInt, + IntVal: 8192, + }, + ExpectedResourceVersion: 4, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, uint32(5), result.Version) + assert.Equal(t, "sha256:cafe", result.PolicyHash) + assert.Equal(t, uint64(10), result.SettingsRevision) + assert.False(t, result.Deleted) + + // Verify request was correctly converted. + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.GetName()) + assert.Equal(t, "max_tokens", req.GetSettingKey()) + assert.False(t, req.GetGlobal()) + assert.Equal(t, uint64(4), req.GetExpectedResourceVersion()) + assert.Equal(t, int64(8192), req.GetSettingValue().GetIntValue()) +} + +func TestConfigUpdate_GlobalScope(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + SettingsRevision: 20, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Global: true, + SettingKey: "global_flag", + SettingValue: &SettingValue{ + Type: SettingValueBool, + BoolVal: true, + }, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint64(20), result.SettingsRevision) + + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + assert.True(t, req.GetGlobal()) + assert.Empty(t, req.GetName()) +} + +func TestConfigUpdate_DeleteSetting(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + SettingsRevision: 15, + Deleted: true, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "deprecated_key", + DeleteSetting: true, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Deleted) + assert.Equal(t, uint64(15), result.SettingsRevision) + + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + assert.True(t, req.GetDeleteSetting()) +} + +func TestConfigUpdate_WithPolicy(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 2, + PolicyHash: "sha256:newpolicy", + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + Policy: &types.SandboxPolicy{ + Version: 5, + Filesystem: &types.FilesystemPolicy{ + ReadOnly: []string{"/usr"}, + }, + }, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(2), result.Version) + + // Verify the typed policy was converted and sent as proto. + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + require.NotNil(t, req.GetPolicy()) + assert.Equal(t, uint32(5), req.GetPolicy().GetVersion()) + require.NotNil(t, req.GetPolicy().GetFilesystem()) + assert.Equal(t, []string{"/usr"}, req.GetPolicy().GetFilesystem().GetReadOnly()) +} + +func TestConfigUpdate_Error(t *testing.T) { + mock := newMockConfigServer() + mock.updateErr = status.Errorf(codes.FailedPrecondition, "version mismatch") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "key", + ExpectedResourceVersion: 99, + } + + result, err := client.Update(context.Background(), update) + + assert.Nil(t, result) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorConflict, se.Code) +} + +func TestConfigUpdate_NilUpdate(t *testing.T) { + mock := newMockConfigServer() + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + result, err := client.Update(context.Background(), nil) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestConfigUpdate_MergeOperationsAccepted(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 3, + PolicyHash: "abc123", + } + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + MergeOperations: []types.PolicyMergeOperation{{RemoveRule: &types.RemoveNetworkRule{RuleName: "test"}}}, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(3), result.Version) + assert.Equal(t, "abc123", result.PolicyHash) + + // Verify the merge operations were serialized in the proto request + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + require.NotNil(t, req) + assert.NotEmpty(t, req.GetMergeOperations(), "MergeOperations should be serialized to proto") +} + +func TestConfigUpdate_ErrorConflict(t *testing.T) { + mock := newMockConfigServer() + mock.updateErr = status.Error(codes.Aborted, "resource version conflict") + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + ExpectedResourceVersion: 5, + } + + result, err := client.Update(context.Background(), update) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsConflict(err)) +} + +func TestConfigGetSandbox_EmptySandboxName(t *testing.T) { + mock := newMockConfigServer() + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + cfg, err := client.GetSandbox(context.Background(), "") + assert.Nil(t, cfg) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/doc.go b/sdk/go/openshell/v1/doc.go new file mode 100644 index 0000000000..f36bb154ce --- /dev/null +++ b/sdk/go/openshell/v1/doc.go @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package v1 provides a Go SDK for interacting with OpenShell servers. +// +// The SDK follows the Kubernetes client-go sub-client pattern: a single Client +// provides typed accessors for each resource domain (Sandboxes, Providers, Exec, +// Files, Health, Services, SSH, TCP, Config). All operations accept a context.Context and return idiomatic +// Go types. Proto-generated types never appear in the public API. +// +// # Quick Start +// +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: v1.StaticToken("my-token"), +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// # Sandbox Lifecycle +// +// sandbox, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{ +// Template: &v1.SandboxTemplate{Image: "python:3.12"}, +// Environment: map[string]string{"LANG": "en_US.UTF-8"}, +// }, nil) +// if err != nil { +// log.Fatal(err) +// } +// +// sandbox, err = client.Sandboxes().WaitReady(ctx, sandbox.Name) +// if err != nil { +// log.Fatal(err) +// } +// +// # Command Execution +// +// result, err := client.Exec().Run(ctx, sandbox.Name, []string{"echo", "hello"}, v1.ExecOptions{}) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(string(result.Stdout)) // "hello\n" +// +// # Error Handling +// +// _, err = client.Sandboxes().Get(ctx, "missing") +// if v1.IsNotFound(err) { +// // handle not found +// } +// +// # Watching +// +// watcher, err := client.Sandboxes().Watch(ctx, sandbox.Name) +// if err != nil { +// log.Fatal(err) +// } +// defer watcher.Stop() +// for event := range watcher.ResultChan() { +// fmt.Printf("%s: %s\n", event.Type, event.Object.Name) +// } +// +// # Watching with StopOnTerminal +// +// Use StopOnTerminal to auto-close the watcher when the sandbox reaches a +// terminal phase (Ready or Error): +// +// watcher, err := client.Sandboxes().Watch(ctx, sandbox.Name, +// v1.WatchOptions{StopOnTerminal: true}, +// ) +// if err != nil { +// log.Fatal(err) +// } +// for event := range watcher.ResultChan() { +// fmt.Printf("phase: %s\n", event.Object.Status.Phase) +// } +// // channel closes automatically after Ready or Error +// +// # Service Exposure +// +// Expose an HTTP service running inside a sandbox and retrieve its public URL: +// +// endpoint, err := client.Services().Expose(ctx, "my-sandbox", "api", 8080, true) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Service URL: %s\n", endpoint.URL) +// +// endpoints, err := client.Services().List(ctx, "my-sandbox") +// if err != nil { +// log.Fatal(err) +// } +// for _, ep := range endpoints { +// fmt.Printf(" %s → port %d (URL: %s)\n", ep.ServiceName, ep.TargetPort, ep.URL) +// } +// +// # Provider Profiles +// +// List available provider profiles and import new ones: +// +// profiles, err := client.Providers().Profiles().List(ctx) +// if err != nil { +// log.Fatal(err) +// } +// for _, p := range profiles { +// fmt.Printf("%s (%s): %s\n", p.DisplayName, p.Category, p.Description) +// } +// +// result, err := client.Providers().Profiles().Import(ctx, []v1.ProfileImportItem{ +// {Source: "openai-profile.yaml", Profile: v1.ProviderProfile{ +// DisplayName: "OpenAI", +// Category: v1.ProfileCategoryInference, +// }}, +// }) +// if err != nil { +// log.Fatal(err) +// } +// for _, d := range result.Diagnostics { +// fmt.Printf("[%s] %s: %s\n", d.Severity, d.Field, d.Message) +// } +// +// # Credential Refresh +// +// Configure gateway-owned credential refresh for a provider: +// +// status, err := client.Providers().Refresh().Configure(ctx, &v1.RefreshConfig{ +// Provider: "openai", +// CredentialKey: "api-key", +// Strategy: v1.RefreshStrategyOAuth2ClientCredentials, +// Material: map[string]string{"client_id": "xxx", "client_secret": "yyy"}, +// }) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Refresh status: %s (next: %s)\n", status.Status, status.NextRefreshAt) +// +// # Token Refresh +// +// Use RefreshableToken for automatic OAuth2 token caching and refresh. +// Concurrent callers share a single refresh call: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// auth, err := v1.RefreshableToken(tokenSource, +// v1.WithLeeway(30*time.Second), +// ) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// # Extra Headers +// +// Use WithExtraHeaders to attach additional per-RPC headers to any auth +// provider. This is useful for edge proxies, API gateways, or any middleware +// that requires custom headers alongside standard authentication: +// +// base := v1.StaticToken("my-token") +// auth, err := v1.WithExtraHeaders(base, map[string]string{ +// "x-proxy-key": "proxy-secret", +// "x-tenant-id": "acme-corp", +// }) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Keys are normalized to lowercase (per HTTP/2 RFC 9113). On key collision, +// extra headers take precedence over base auth headers. Empty-string values +// are silently dropped. WithExtraHeaders composes with any AuthProvider, +// including RefreshableToken: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// refreshAuth, err := v1.RefreshableToken(tokenSource) +// if err != nil { +// log.Fatal(err) +// } +// auth, err := v1.WithExtraHeaders(refreshAuth, map[string]string{ +// "x-proxy-key": "proxy-secret", +// }) +// +// # SSH Session Management +// +// Create an SSH session for a sandbox and use the returned connection details. +// Note: CreateSession accepts a sandbox ID, not a name. For name-based access +// with automatic session cleanup, prefer SSH().Tunnel() instead. +// +// session, err := client.SSH().CreateSession(ctx, sandbox.ID) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("SSH to %s:%d (scheme: %s)\n", +// session.GatewayHost, session.GatewayPort, session.GatewayScheme) +// fmt.Printf("Host key: %s\n", session.HostKeyFingerprint) +// // Use session.Token to authenticate the SSH connection. +// +// revoked, err := client.SSH().RevokeSession(ctx, session.Token) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Session revoked: %v\n", revoked) +// +// # TCP Port Forwarding +// +// Forward a local connection to a port inside a sandbox: +// +// conn, err := client.TCP().Forward(ctx, "my-sandbox", 5432) +// if err != nil { +// log.Fatal(err) +// } +// defer conn.Close() +// +// // conn implements io.ReadWriteCloser, use it like a net.Conn. +// _, err = conn.Write([]byte("PING\n")) +// if err != nil { +// log.Fatal(err) +// } +// buf := make([]byte, 1024) +// n, err := conn.Read(buf) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Response: %s\n", buf[:n]) +// +// Use WithForwardServiceID to tag the forwarding session with a service +// identifier for audit logging: +// +// conn, err := client.TCP().Forward(ctx, "my-sandbox", 5432, +// v1.WithForwardServiceID("billing-db"), +// ) +// +// # SSH Tunneling +// +// Create an SSH tunnel to a sandbox port in a single call. Tunnel combines +// session creation, TCP forwarding with an SSH relay target, and automatic +// session cleanup into one operation: +// +// tunnel, err := client.SSH().Tunnel(ctx, "my-sandbox", 22) +// if err != nil { +// log.Fatal(err) +// } +// defer tunnel.Close() +// +// // tunnel implements io.ReadWriteCloser. The underlying SSH session +// // is automatically revoked when Close is called. +// _, err = tunnel.Write([]byte("SSH-2.0-client\r\n")) +// if err != nil { +// log.Fatal(err) +// } +// buf := make([]byte, 256) +// n, err := tunnel.Read(buf) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Server banner: %s\n", buf[:n]) +// +// Use WithTunnelServiceID to associate a service identifier with the tunnel: +// +// tunnel, err := client.SSH().Tunnel(ctx, "my-sandbox", 22, +// v1.WithTunnelServiceID("dev-ssh"), +// ) +// +// # Sandbox Policy +// +// Set an initial security policy when creating a sandbox: +// +// sandbox, err := client.Sandboxes().Create(ctx, "secure-sandbox", &v1.SandboxSpec{ +// Template: &v1.SandboxTemplate{Image: "python:3.12"}, +// Policy: &v1.SandboxPolicy{ +// Version: 1, +// Filesystem: &v1.FilesystemPolicy{ +// IncludeWorkdir: true, +// ReadOnly: []string{"/usr", "/lib"}, +// }, +// Process: &v1.ProcessPolicy{ +// RunAsUser: "sandbox", +// RunAsGroup: "sandbox", +// }, +// NetworkPolicies: map[string]v1.NetworkPolicyRule{ +// "allow-api": { +// Name: "allow-api", +// Endpoints: []v1.PolicyNetworkEndpoint{ +// {Host: "api.example.com", Port: 443, Protocol: "tcp"}, +// }, +// }, +// }, +// }, +// }, nil) +// +// Replace the full policy at runtime via configuration update: +// +// result, err := client.Config().Update(ctx, &v1.ConfigUpdate{ +// Name: "secure-sandbox", +// Policy: &v1.SandboxPolicy{ +// Version: 2, +// NetworkPolicies: map[string]v1.NetworkPolicyRule{ +// "allow-all": {Name: "allow-all"}, +// }, +// }, +// }) +// +// Read a policy back from revision history: +// +// revisions, err := client.Policy().List(ctx, "secure-sandbox") +// if err != nil { +// log.Fatal(err) +// } +// for _, rev := range revisions { +// if rev.Policy != nil { +// fmt.Printf("v%d: %d network rules\n", rev.Version, len(rev.Policy.NetworkPolicies)) +// } +// } +// +// # Configuration Management +// +// Read sandbox and gateway configuration, and update settings: +// +// sbCfg, err := client.Config().GetSandbox(ctx, "my-sandbox") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Config revision: %d\n", sbCfg.ConfigRevision) +// for name, setting := range sbCfg.Settings { +// fmt.Printf(" %s = %v (scope: %s)\n", name, setting.Value, setting.Scope) +// } +// +// gwCfg, err := client.Config().GetGateway(ctx) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Gateway settings revision: %d\n", gwCfg.SettingsRevision) +// +// result, err := client.Config().Update(ctx, &v1.ConfigUpdate{ +// Name: "my-sandbox", +// SettingKey: "max_tokens", +// SettingValue: &v1.SettingValue{ +// Type: v1.SettingValueInt, +// IntVal: 8192, +// }, +// }) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("New settings revision: %d\n", result.SettingsRevision) +package v1 diff --git a/sdk/go/openshell/v1/edge/cloudflare.go b/sdk/go/openshell/v1/edge/cloudflare.go new file mode 100644 index 0000000000..5363fe07f4 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "errors" + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +// CloudflareAccess returns an AuthProvider that adds Cloudflare Access +// headers to every RPC. It sets: +// - cf-access-jwt-assertion: the edge JWT token +// - cookie: CF_Authorization= +// +// The edgeToken authenticates with the Cloudflare Access edge proxy. +// Returns an error if baseAuth is nil or edgeToken is empty. +func CloudflareAccess(baseAuth v1.AuthProvider, edgeToken string) (v1.AuthProvider, error) { + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + return v1.WithExtraHeaders(baseAuth, map[string]string{ + "cf-access-jwt-assertion": edgeToken, + "cookie": fmt.Sprintf("CF_Authorization=%s", edgeToken), + }) +} diff --git a/sdk/go/openshell/v1/edge/cloudflare_test.go b/sdk/go/openshell/v1/edge/cloudflare_test.go new file mode 100644 index 0000000000..902e599d69 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCloudflareAccess_ValidToken(t *testing.T) { + base := v1.StaticToken("my-token") + auth, err := CloudflareAccess(base, "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Base auth header preserved. + assert.Equal(t, "Bearer my-token", md["authorization"]) + + // Cloudflare-specific headers present. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_EmptyToken(t *testing.T) { + base := v1.StaticToken("my-token") + _, err := CloudflareAccess(base, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestCloudflareAccess_NilBase(t *testing.T) { + _, err := CloudflareAccess(nil, "cf-edge-jwt-xxx") + require.Error(t, err) + assert.Contains(t, err.Error(), "base") +} + +func TestCloudflareAccess_WithNoAuth(t *testing.T) { + auth, err := CloudflareAccess(v1.NoAuth(), "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // NoAuth provides no base metadata; only CF headers should appear. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_RequireTransportSecurity_Delegates(t *testing.T) { + tests := []struct { + name string + base v1.AuthProvider + expected bool + }{ + { + name: "delegates to NoAuth (false)", + base: v1.NoAuth(), + expected: false, + }, + { + name: "delegates to StaticToken (true)", + base: v1.StaticToken("tok"), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth, err := CloudflareAccess(tt.base, "cf-edge-jwt-xxx") + require.NoError(t, err) + assert.Equal(t, tt.expected, auth.RequireTransportSecurity()) + }) + } +} + +func TestCloudflareAccess_TokenNotInError(t *testing.T) { + // Verify the error for empty token does not leak actual token values. + _, err := CloudflareAccess(v1.StaticToken("s3cr3t-val"), "") + require.Error(t, err) + // The error should mention the parameter name, not any token value. + assert.NotContains(t, err.Error(), "s3cr3t-val") +} diff --git a/sdk/go/openshell/v1/edge/doc.go b/sdk/go/openshell/v1/edge/doc.go new file mode 100644 index 0000000000..92fd9b1f9f --- /dev/null +++ b/sdk/go/openshell/v1/edge/doc.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package edge provides utilities for connecting to OpenShell gateways +// through edge proxies such as Cloudflare Access. It includes convenience +// constructors for common edge auth patterns and a WebSocket tunnel proxy +// for gRPC transport through HTTP/1.1-only proxies. +// +// # Cloudflare Access +// +// CloudflareAccess wraps any AuthProvider with the headers required by +// Cloudflare Access (cf-access-jwt-assertion and CF_Authorization cookie). +// The edge token is typically a service token or application token obtained +// from Cloudflare: +// +// base := v1.StaticToken("my-gateway-token") +// auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN")) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// CloudflareAccess composes with any auth provider, including RefreshableToken +// for automatic token refresh: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// refreshAuth, err := v1.RefreshableToken(tokenSource) +// if err != nil { +// log.Fatal(err) +// } +// auth, err := edge.CloudflareAccess(refreshAuth, cfToken) +// if err != nil { +// log.Fatal(err) +// } +// +// # WebSocket Tunnel +// +// TunnelProxy bridges gRPC connections over a WebSocket tunnel for edge +// proxies that reject standard HTTP/2 POST requests. The tunnel carries +// its own edge token for proxy authentication, independent of the +// application-level auth provider. +// +// Create a tunnel proxy pointed at the gateway, then dial the proxy's +// local address from the gRPC client: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// os.Getenv("CF_ACCESS_TOKEN"), +// ) +// if err != nil { +// log.Fatal(err) +// } +// defer tunnel.Close() +// +// auth := v1.StaticToken("my-gateway-token") +// client, err := v1.NewClient(v1.Config{ +// Address: tunnel.Addr(), +// Auth: auth, +// TLS: &v1.TLSConfig{Insecure: true}, // local tunnel +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Use functional options to configure TLS, logging, and close timeout: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// cfToken, +// edge.WithTunnelTLS(&tls.Config{RootCAs: customCertPool}), +// edge.WithTunnelLogger(myLogger), +// edge.WithCloseTimeout(10*time.Second), +// ) +// +// Close drains in-flight connections gracefully. If draining exceeds the +// configured timeout (default 5 seconds), remaining connections are +// force-closed: +// +// err := tunnel.Close() // safe to call multiple times +package edge diff --git a/sdk/go/openshell/v1/edge/tunnel.go b/sdk/go/openshell/v1/edge/tunnel.go new file mode 100644 index 0000000000..2e50cd1570 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const defaultCloseTimeout = 5 * time.Second + +// tunnelConfig holds configuration set by TunnelOption functions. +type tunnelConfig struct { + logger types.Logger + tlsConfig *tls.Config + closeTimeout time.Duration +} + +// TunnelOption configures TunnelProxy behavior. +type TunnelOption func(*tunnelConfig) + +// WithTunnelLogger sets the structured logger for tunnel events. +func WithTunnelLogger(l types.Logger) TunnelOption { + return func(c *tunnelConfig) { + c.logger = l + } +} + +// WithTunnelTLS sets TLS configuration for the WebSocket connection (wss://). +func WithTunnelTLS(cfg *tls.Config) TunnelOption { + return func(c *tunnelConfig) { + c.tlsConfig = cfg + } +} + +// WithCloseTimeout sets the maximum time Close waits for in-flight +// connections to drain before force-closing. Default is 5 seconds. +func WithCloseTimeout(d time.Duration) TunnelOption { + return func(c *tunnelConfig) { + c.closeTimeout = d + } +} + +// TunnelProxy bridges gRPC connections over a WebSocket tunnel. +// The gRPC client dials TunnelProxy.Addr() instead of the remote gateway. +// Each accepted connection spawns a goroutine that dials the gateway over +// WebSocket and copies data bidirectionally. +type TunnelProxy struct { + listener net.Listener + gatewayURL string + edgeToken string + logger types.Logger + closeTimeout time.Duration + httpClient *http.Client + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeOnce sync.Once + closeErr error +} + +// NewTunnelProxy creates a tunnel proxy that forwards TCP connections +// through a WebSocket connection to gatewayURL. The edgeToken authenticates +// with the edge proxy via Cloudflare Access headers on the WebSocket +// handshake. +// +// Returns error if gatewayURL is empty or invalid, or if edgeToken is empty. +func NewTunnelProxy(gatewayURL, edgeToken string, opts ...TunnelOption) (*TunnelProxy, error) { + if gatewayURL == "" { + return nil, errors.New("gateway URL must not be empty") + } + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + // Validate the URL parses correctly. + u, err := url.Parse(gatewayURL) + if err != nil { + return nil, fmt.Errorf("invalid gateway URL: %w", err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return nil, fmt.Errorf("gateway URL must use ws:// or wss:// scheme, got %q", u.Scheme) + } + if u.Host == "" { + return nil, errors.New("gateway URL must include a host") + } + + cfg := tunnelConfig{ + closeTimeout: defaultCloseTimeout, + } + for _, o := range opts { + o(&cfg) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + var httpClient *http.Client + if cfg.tlsConfig != nil { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: cfg.tlsConfig, + }, + } + } + + tp := &TunnelProxy{ + listener: listener, + gatewayURL: gatewayURL, + edgeToken: edgeToken, + logger: cfg.logger, + closeTimeout: cfg.closeTimeout, + httpClient: httpClient, + ctx: ctx, + cancel: cancel, + } + + // Start the accept loop. + tp.wg.Add(1) + go tp.acceptLoop() + + return tp, nil +} + +// Addr returns the local address the gRPC client should dial. +func (tp *TunnelProxy) Addr() string { + return tp.listener.Addr().String() +} + +// Close drains in-flight connections (up to the configured timeout, +// default 5s) then force-closes any remaining connections. All goroutines +// are cleaned up. Safe to call multiple times; the second and subsequent +// calls return immediately. +func (tp *TunnelProxy) Close() error { + tp.closeOnce.Do(func() { + tp.mu.Lock() + tp.closing = true + tp.mu.Unlock() + + // Stop accepting new connections. + tp.closeErr = tp.listener.Close() + + // Wait for in-flight connections to drain, with a timeout. + done := make(chan struct{}) + go func() { + tp.wg.Wait() + close(done) + }() + + select { + case <-done: + // All goroutines drained cleanly. + case <-time.After(tp.closeTimeout): + // Timeout reached; cancel all bridge contexts to force-close. + if tp.logger != nil { + tp.logger.Info("tunnel close timeout reached, force-closing") + } + tp.cancel() + <-done + } + // Always cancel to release the context tree. + tp.cancel() + }) + return tp.closeErr +} + +// acceptLoop runs in a goroutine. It accepts local TCP connections and +// spawns a bridge goroutine for each one. +func (tp *TunnelProxy) acceptLoop() { + defer tp.wg.Done() + + for { + conn, err := tp.listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + if tp.logger != nil { + tp.logger.Error(err, "tunnel accept error") + } + time.Sleep(10 * time.Millisecond) + continue + } + + tp.mu.Lock() + if tp.closing { + tp.mu.Unlock() + _ = conn.Close() + return + } + tp.wg.Add(1) + tp.mu.Unlock() + + if tp.logger != nil { + tp.logger.Debug("tunnel connection accepted", "remote", conn.RemoteAddr().String()) + } + + go tp.bridge(conn) + } +} + +// bridge dials the gateway over WebSocket and copies data bidirectionally +// between the local TCP connection and the WebSocket connection. +func (tp *TunnelProxy) bridge(local net.Conn) { + defer tp.wg.Done() + defer func() { _ = local.Close() }() + + ctx, cancel := context.WithCancel(tp.ctx) + defer cancel() + + // Build WebSocket dial options with edge auth headers. + dialOpts := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "cf-access-jwt-assertion": []string{tp.edgeToken}, + "cookie": []string{fmt.Sprintf("CF_Authorization=%s", tp.edgeToken)}, + }, + } + if tp.httpClient != nil { + dialOpts.HTTPClient = tp.httpClient + } + + dialCtx, dialCancel := context.WithTimeout(ctx, 10*time.Second) + defer dialCancel() + + wsConn, _, err := websocket.Dial(dialCtx, tp.gatewayURL, dialOpts) + if err != nil { + if tp.logger != nil { + tp.logger.Error(err, "tunnel websocket dial failed") + } + return + } + defer func() { _ = wsConn.CloseNow() }() + + // Set a generous read limit for gRPC frames. + wsConn.SetReadLimit(64 * 1024 * 1024) // 64 MiB + + // Convert the WebSocket connection to a net.Conn for bidirectional I/O. + remote := websocket.NetConn(ctx, wsConn, websocket.MessageBinary) + + // Bidirectional copy. + done := make(chan struct{}, 2) + + // Local -> Remote (WebSocket) + go func() { + _, _ = io.Copy(remote, local) + done <- struct{}{} + }() + + // Remote (WebSocket) -> Local + go func() { + _, _ = io.Copy(local, remote) + done <- struct{}{} + }() + + // Wait for one direction to finish, then tear down both. + <-done + cancel() + _ = local.Close() + <-done + + if tp.logger != nil { + tp.logger.Debug("tunnel bridge closed") + } +} diff --git a/sdk/go/openshell/v1/edge/tunnel_test.go b/sdk/go/openshell/v1/edge/tunnel_test.go new file mode 100644 index 0000000000..79daa062f7 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel_test.go @@ -0,0 +1,494 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "slices" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// echoWSHandler accepts a WebSocket connection and echoes every binary +// message back to the sender until the client disconnects. +func echoWSHandler(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = conn.CloseNow() }() + + ctx := r.Context() + for { + typ, data, err := conn.Read(ctx) + if err != nil { + return + } + if err := conn.Write(ctx, typ, data); err != nil { + return + } + } +} + +// startEchoServer starts an HTTP test server that upgrades connections to +// WebSocket and echoes binary messages. Returns the server and its ws:// URL. +func startEchoServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + // Convert http://host:port to ws://host:port. + wsURL := "ws" + srv.URL[len("http"):] + return srv, wsURL +} + +// startTLSEchoServer starts a TLS HTTP test server. Returns the server, +// its wss:// URL, and a tls.Config that trusts the server's certificate. +func startTLSEchoServer(t *testing.T) (*httptest.Server, string, *tls.Config) { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + wssURL := "wss" + srv.URL[len("https"):] + + certPool := x509.NewCertPool() + certPool.AddCert(srv.Certificate()) + tlsCfg := &tls.Config{ + RootCAs: certPool, + } + return srv, wssURL, tlsCfg +} + +// testLogger captures log messages for assertions. +type testLogger struct { + mu sync.Mutex + messages []string +} + +func (l *testLogger) Debug(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "DEBUG: "+msg) +} + +func (l *testLogger) Info(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "INFO: "+msg) +} + +func (l *testLogger) Error(_ error, msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "ERROR: "+msg) +} + +func (l *testLogger) Messages() []string { + l.mu.Lock() + defer l.mu.Unlock() + cp := make([]string, len(l.messages)) + copy(cp, l.messages) + return cp +} + +// --- Test: NewTunnelProxy creation --- + +func TestNewTunnelProxy_ValidURL(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "edge-token-123") + require.NoError(t, err) + require.NotNil(t, tp) + defer func() { _ = tp.Close() }() + + // Addr() must return a non-empty, dialable address. + addr := tp.Addr() + assert.NotEmpty(t, addr) + + // Verify the address is dialable. + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestNewTunnelProxy_EmptyURL(t *testing.T) { + _, err := NewTunnelProxy("", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway URL") +} + +func TestNewTunnelProxy_InvalidURL(t *testing.T) { + _, err := NewTunnelProxy("://bad-url", "edge-token-123") + require.Error(t, err) +} + +func TestNewTunnelProxy_WrongScheme(t *testing.T) { + _, err := NewTunnelProxy("http://gateway.example.com", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "ws:// or wss://") +} + +func TestNewTunnelProxy_EmptyHost(t *testing.T) { + _, err := NewTunnelProxy("ws://", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "must include a host") +} + +func TestNewTunnelProxy_EmptyEdgeToken(t *testing.T) { + _, wsURL := startEchoServer(t) + + _, err := NewTunnelProxy(wsURL, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestNewTunnelProxy_TokenNotInError(t *testing.T) { + // Verify error messages do not leak the edge token. + _, err := NewTunnelProxy("", "super-secret-token-abc") + require.Error(t, err) + assert.NotContains(t, err.Error(), "super-secret-token-abc") +} + +// --- Test: Addr --- + +func TestTunnelProxy_Addr_Dialable(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + addr := tp.Addr() + host, port, err := net.SplitHostPort(addr) + require.NoError(t, err) + assert.NotEmpty(t, host) + assert.NotEmpty(t, port) +} + +// --- Test: Close on unused proxy --- + +func TestTunnelProxy_Close_Unused(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Close immediately without any connections should return nil. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close drains in-flight connections --- + +func TestTunnelProxy_Close_DrainsInFlight(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(5*time.Second)) + require.NoError(t, err) + + // Establish a connection through the tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + // Send data through the tunnel and verify echo. + testData := []byte("hello tunnel") + _, err = conn.Write(testData) + require.NoError(t, err) + + // Give the tunnel time to relay. + time.Sleep(100 * time.Millisecond) + + // Close the client connection first so the bridge goroutine can drain. + _ = conn.Close() + + // Now close the tunnel; should drain cleanly. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close force-closes after timeout --- + +func TestTunnelProxy_Close_ForceClosesAfterTimeout(t *testing.T) { + // Use a slow handler that holds connections open. + slowHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + return + } + defer func() { _ = wsConn.CloseNow() }() + // Hold the connection open for a long time. + ctx := r.Context() + select { + case <-ctx.Done(): + case <-time.After(30 * time.Second): + } + }) + srv := httptest.NewServer(slowHandler) + defer srv.Close() + wsURL := "ws" + srv.URL[len("http"):] + + // Use a very short close timeout and a logger to verify timeout logging. + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(200*time.Millisecond), WithTunnelLogger(logger)) + require.NoError(t, err) + + // Establish a connection that will be held open by the slow handler. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Write something to trigger the WebSocket dial. + _, _ = conn.Write([]byte("trigger")) + + // Give the tunnel time to establish the bridge. + time.Sleep(100 * time.Millisecond) + + // Close should force-close after the short timeout, not hang. + start := time.Now() + err = tp.Close() + elapsed := time.Since(start) + + // Should complete within a reasonable time (timeout + margin). + assert.Less(t, elapsed, 2*time.Second, "Close should not hang beyond timeout") + // err may or may not be nil depending on force-close; we don't assert on it. + _ = err + + // Verify the timeout was logged. + msgs := logger.Messages() + assert.True(t, slices.Contains(msgs, "INFO: tunnel close timeout reached, force-closing"), + "expected timeout log message, got: %v", msgs) +} + +// --- Test: Concurrent Close is safe --- + +func TestTunnelProxy_Close_ConcurrentSafe(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Call Close concurrently from multiple goroutines. + var wg sync.WaitGroup + errs := make([]error, 10) + for i := range errs { + wg.Add(1) + go func(idx int) { + defer wg.Done() + errs[idx] = tp.Close() + }(i) + } + wg.Wait() + + // All calls should succeed without panic. + for _, e := range errs { + assert.NoError(t, e) + } +} + +// --- Test: Goroutine cleanup --- + +func TestTunnelProxy_GoroutineCleanup(t *testing.T) { + _, wsURL := startEchoServer(t) + + // Record baseline goroutine count. + runtime.GC() + time.Sleep(50 * time.Millisecond) + baseline := runtime.NumGoroutine() + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Open several connections. + conns := make([]net.Conn, 5) + for i := range conns { + c, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + _, _ = fmt.Fprintf(c, "msg-%d", i) + conns[i] = c + } + + // Let bridges establish. + time.Sleep(100 * time.Millisecond) + + // Close all client connections. + for _, c := range conns { + _ = c.Close() + } + + // Close the tunnel. + err = tp.Close() + require.NoError(t, err) + + // Wait for goroutines to wind down. + time.Sleep(200 * time.Millisecond) + runtime.GC() + + // Goroutine count should return to near baseline. + // Allow a small margin for runtime goroutines. + final := runtime.NumGoroutine() + assert.LessOrEqual(t, final, baseline+3, + "goroutine leak: baseline=%d, final=%d", baseline, final) +} + +// --- Test: Concurrent streams --- + +func TestTunnelProxy_ConcurrentStreams(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + const streamCount = 10 + var wg sync.WaitGroup + errs := make(chan error, streamCount) + + for i := range streamCount { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + if err != nil { + errs <- fmt.Errorf("stream %d dial: %w", idx, err) + return + } + defer func() { _ = conn.Close() }() + + msg := fmt.Sprintf("stream-%d-data", idx) + _, err = conn.Write([]byte(msg)) + if err != nil { + errs <- fmt.Errorf("stream %d write: %w", idx, err) + return + } + + // Read echo response. + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, err := io.ReadFull(conn, buf) + if err != nil { + errs <- fmt.Errorf("stream %d read: %w (got %d bytes)", idx, err, n) + return + } + + if string(buf) != msg { + errs <- fmt.Errorf("stream %d: expected %q, got %q", idx, msg, string(buf)) + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } +} + +// --- Test: TLS option --- + +func TestTunnelProxy_TLSOption(t *testing.T) { + _, wssURL, tlsCfg := startTLSEchoServer(t) + + tp, err := NewTunnelProxy(wssURL, "tok", WithTunnelTLS(tlsCfg)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // Verify we can communicate through the TLS tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + msg := []byte("tls-echo-test") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + assert.Equal(t, msg, buf) +} + +// --- Test: Logger option --- + +func TestTunnelProxy_LoggerOption(t *testing.T) { + _, wsURL := startEchoServer(t) + + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithTunnelLogger(logger)) + require.NoError(t, err) + + // Open a connection to trigger log events. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + _, _ = conn.Write([]byte("log-test")) + + // Give the tunnel time to process. + time.Sleep(200 * time.Millisecond) + + _ = conn.Close() + + err = tp.Close() + require.NoError(t, err) + + // Logger should have received at least one message. + msgs := logger.Messages() + assert.NotEmpty(t, msgs, "logger should receive log events") +} + +// --- Test: WithCloseTimeout option --- + +func TestWithCloseTimeout(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(10*time.Second)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // We can't directly inspect the config, but creation should succeed. + assert.NotNil(t, tp) +} + +// --- Test: Data flows through the tunnel --- + +func TestTunnelProxy_DataRoundTrip(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Send data and verify round-trip through the WebSocket echo server. + msg := []byte("round-trip-payload-12345") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + + assert.Equal(t, msg, buf) +} diff --git a/sdk/go/openshell/v1/errors.go b/sdk/go/openshell/v1/errors.go new file mode 100644 index 0000000000..fd6d629a9f --- /dev/null +++ b/sdk/go/openshell/v1/errors.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ErrorCode classifies SDK errors by their gRPC origin. +type ErrorCode = types.ErrorCode + +// ErrorCode values for classifying gRPC errors. +const ( + ErrorNotFound = types.ErrorNotFound + ErrorAlreadyExists = types.ErrorAlreadyExists + ErrorUnavailable = types.ErrorUnavailable + ErrorPermissionDenied = types.ErrorPermissionDenied + ErrorInvalidArgument = types.ErrorInvalidArgument + ErrorDeadlineExceeded = types.ErrorDeadlineExceeded + ErrorCancelled = types.ErrorCancelled + ErrorInternal = types.ErrorInternal + ErrorUnimplemented = types.ErrorUnimplemented + ErrorConflict = types.ErrorConflict +) + +// StatusError is the typed error returned by all SDK operations. +type StatusError = types.StatusError + +// IsNotFound returns true if the error indicates a resource was not found. +func IsNotFound(err error) bool { return types.IsNotFound(err) } + +// IsAlreadyExists returns true if the error indicates a resource already exists. +func IsAlreadyExists(err error) bool { return types.IsAlreadyExists(err) } + +// IsUnavailable returns true if the error indicates the service is unavailable. +func IsUnavailable(err error) bool { return types.IsUnavailable(err) } + +// IsPermissionDenied returns true if the error indicates insufficient permissions. +func IsPermissionDenied(err error) bool { return types.IsPermissionDenied(err) } + +// IsInvalidArgument returns true if the error indicates an invalid argument. +func IsInvalidArgument(err error) bool { return types.IsInvalidArgument(err) } + +// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded. +func IsDeadlineExceeded(err error) bool { return types.IsDeadlineExceeded(err) } + +// IsCancelled returns true if the error indicates the operation was cancelled. +func IsCancelled(err error) bool { return types.IsCancelled(err) } + +// IsUnimplemented returns true if the error indicates the operation is not implemented. +func IsUnimplemented(err error) bool { return types.IsUnimplemented(err) } + +// IsConflict returns true if the error indicates a conflict, such as +// optimistic concurrency or an invalid state transition. +func IsConflict(err error) bool { return types.IsConflict(err) } diff --git a/sdk/go/openshell/v1/errors_test.go b/sdk/go/openshell/v1/errors_test.go new file mode 100644 index 0000000000..8565c0379b --- /dev/null +++ b/sdk/go/openshell/v1/errors_test.go @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatusError_Error(t *testing.T) { + err := &StatusError{ + Code: ErrorNotFound, + Message: "sandbox not found", + } + s := err.Error() + assert.Contains(t, s, "NotFound") + assert.Contains(t, s, "sandbox not found") +} + +func TestStatusError_ErrorWithDetails(t *testing.T) { + err := &StatusError{ + Code: ErrorInvalidArgument, + Message: "bad name", + Details: map[string]string{"field": "name"}, + } + s := err.Error() + assert.Contains(t, s, "InvalidArgument") + assert.Contains(t, s, "bad name") +} + +func TestIsNotFound(t *testing.T) { + err := &StatusError{Code: ErrorNotFound, Message: "not found"} + assert.True(t, IsNotFound(err)) + assert.False(t, IsAlreadyExists(err)) +} + +func TestIsAlreadyExists(t *testing.T) { + err := &StatusError{Code: ErrorAlreadyExists, Message: "exists"} + assert.True(t, IsAlreadyExists(err)) + assert.False(t, IsNotFound(err)) +} + +func TestIsUnavailable(t *testing.T) { + err := &StatusError{Code: ErrorUnavailable, Message: "down"} + assert.True(t, IsUnavailable(err)) +} + +func TestIsPermissionDenied(t *testing.T) { + err := &StatusError{Code: ErrorPermissionDenied, Message: "denied"} + assert.True(t, IsPermissionDenied(err)) +} + +func TestIsInvalidArgument(t *testing.T) { + err := &StatusError{Code: ErrorInvalidArgument, Message: "invalid"} + assert.True(t, IsInvalidArgument(err)) +} + +func TestIsDeadlineExceeded(t *testing.T) { + err := &StatusError{Code: ErrorDeadlineExceeded, Message: "timeout"} + assert.True(t, IsDeadlineExceeded(err)) +} + +func TestIsCancelled(t *testing.T) { + err := &StatusError{Code: ErrorCancelled, Message: "cancelled"} + assert.True(t, IsCancelled(err)) +} + +func TestIsConflict(t *testing.T) { + err := &StatusError{Code: ErrorConflict, Message: "version conflict"} + assert.True(t, IsConflict(err)) + assert.False(t, IsNotFound(err)) +} + +func TestIsHelpers_NonStatusError(t *testing.T) { + err := errors.New("plain error") + assert.False(t, IsNotFound(err)) + assert.False(t, IsAlreadyExists(err)) + assert.False(t, IsUnavailable(err)) + assert.False(t, IsPermissionDenied(err)) + assert.False(t, IsInvalidArgument(err)) + assert.False(t, IsDeadlineExceeded(err)) + assert.False(t, IsCancelled(err)) + assert.False(t, IsConflict(err)) +} + +func TestIsHelpers_NilError(t *testing.T) { + assert.False(t, IsNotFound(nil)) + assert.False(t, IsConflict(nil)) +} + +func TestStatusError_WrappedError(t *testing.T) { + inner := &StatusError{Code: ErrorNotFound, Message: "not found"} + wrapped := fmt.Errorf("operation failed: %w", inner) + assert.True(t, IsNotFound(wrapped)) + + var se *StatusError + require.True(t, errors.As(wrapped, &se)) + assert.Equal(t, ErrorNotFound, se.Code) +} + +func TestErrorCode_String(t *testing.T) { + tests := []struct { + code ErrorCode + want string + }{ + {ErrorNotFound, "NotFound"}, + {ErrorAlreadyExists, "AlreadyExists"}, + {ErrorUnavailable, "Unavailable"}, + {ErrorPermissionDenied, "PermissionDenied"}, + {ErrorInvalidArgument, "InvalidArgument"}, + {ErrorDeadlineExceeded, "DeadlineExceeded"}, + {ErrorCancelled, "Cancelled"}, + {ErrorInternal, "Internal"}, + {ErrorUnimplemented, "Unimplemented"}, + {ErrorConflict, "Conflict"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, tt.code.String()) + } +} diff --git a/sdk/go/openshell/v1/example_fake_test.go b/sdk/go/openshell/v1/example_fake_test.go new file mode 100644 index 0000000000..35c0e78c97 --- /dev/null +++ b/sdk/go/openshell/v1/example_fake_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + "context" + "fmt" + "log" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ExampleNewClient_addSandbox demonstrates pre-seeding a fake client with +// a sandbox fixture. +func ExampleNewClient_addSandbox() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + // Pre-seed a sandbox that already exists in Ready state + client.AddSandbox(&types.Sandbox{ + Name: "pre-existing", + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + ResourceVersion: 5, + }) + + ctx := context.Background() + + sb, err := client.Sandboxes().Get(ctx, "pre-existing") + if err != nil { + log.Fatal(err) + } + fmt.Println("Name:", sb.Name) + fmt.Println("Phase:", sb.Status.Phase) + // Output: + // Name: pre-existing + // Phase: Ready +} + +// ExampleNewClient_addProvider demonstrates pre-seeding a fake client with +// a provider fixture. +func ExampleNewClient_addProvider() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + // Pre-seed a provider + client.AddProvider(&types.Provider{ + Name: "seeded-provider", + Type: "openai", + }) + + ctx := context.Background() + + providers, err := client.Providers().List(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Count:", len(providers)) + fmt.Println("Name:", providers[0].Name) + // Output: + // Count: 1 + // Name: seeded-provider +} + +// ExampleNewClient_withHealthResult demonstrates configuring the fake +// health sub-client to return a custom result. +func ExampleNewClient_withHealthResult() { + client := fake.NewClient(fake.WithHealthResult(&types.HealthResult{ + Healthy: false, + Version: "1.2.3", + })) + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + result, err := client.Health().Check(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Healthy:", result.Healthy) + fmt.Println("Version:", result.Version) + // Output: + // Healthy: false + // Version: 1.2.3 +} + +// ExampleNewClient_watchEvents demonstrates watching for sandbox events +// using the fake client. +func ExampleNewClient_watchEvents() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Start watching before creating + watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox") + if err != nil { + log.Fatal(err) + } + defer watcher.Stop() + + // Create triggers an ADDED event + _, err = client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + + event := <-watcher.ResultChan() + fmt.Println("Type:", event.Type) + fmt.Println("Name:", event.Object.Name) + // Output: + // Type: ADDED + // Name: my-sandbox +} + +// ExampleNewClient_stopOnTerminal demonstrates the StopOnTerminal watch +// option that automatically closes the watcher when a sandbox reaches a +// terminal phase. +func ExampleNewClient_stopOnTerminal() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Watch with StopOnTerminal + watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox", v1.WatchOptions{ + StopOnTerminal: true, + }) + if err != nil { + log.Fatal(err) + } + + // Create and transition to Ready + _, err = client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + _, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") + if err != nil { + log.Fatal(err) + } + + // Drain events, channel closes after terminal phase + var count int + for range watcher.ResultChan() { + count++ + } + fmt.Println("Events received:", count) + // Output: + // Events received: 2 +} diff --git a/sdk/go/openshell/v1/example_test.go b/sdk/go/openshell/v1/example_test.go new file mode 100644 index 0000000000..cdbe2c63be --- /dev/null +++ b/sdk/go/openshell/v1/example_test.go @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + "context" + "fmt" + "log" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" +) + +// ExampleClient_Sandboxes demonstrates the sandbox lifecycle: create a sandbox, +// wait for it to become ready, and then clean up. +func ExampleClient_Sandboxes() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Create a sandbox + sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println("Phase after create:", sb.Status.Phase) + + // Wait for the sandbox to become ready + sb, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") + if err != nil { + log.Fatal(err) + } + fmt.Println("Phase after wait:", sb.Status.Phase) + + // Clean up + if err := client.Sandboxes().Delete(ctx, "my-sandbox"); err != nil { + log.Fatal(err) + } + fmt.Println("Deleted") + // Output: + // Phase after create: Provisioning + // Phase after wait: Ready + // Deleted +} + +// ExampleClient_Providers demonstrates registering and listing providers. +func ExampleClient_Providers() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Register a provider + _, err := client.Providers().Create(ctx, &v1.Provider{ + Name: "my-openai", + Type: "openai", + }) + if err != nil { + log.Fatal(err) + } + + // List all providers + providers, err := client.Providers().List(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Count:", len(providers)) + fmt.Println("Name:", providers[0].Name) + // Output: + // Count: 1 + // Name: my-openai +} + +// ExampleClient_Health demonstrates checking gateway health. +func ExampleClient_Health() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + result, err := client.Health().Check(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Healthy:", result.Healthy) + // Output: + // Healthy: true +} + +// ExampleClient_Exec demonstrates running a command in a sandbox. +// The fake client returns Unimplemented for exec operations, so this +// example shows the call pattern and error handling. +func ExampleClient_Exec() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := client.Exec().Run(ctx, "my-sandbox", []string{"echo", "hello"}) + if v1.IsUnimplemented(err) { + fmt.Println("Exec requires a real gateway") + } + // Output: + // Exec requires a real gateway +} + +// ExampleClient_TCP demonstrates binding a local port to a sandbox port +// using the net.Listener pattern. The returned listener tunnels every +// accepted connection to the remote port inside the sandbox. +// +// The fake client returns Unimplemented for Listen, so this example shows +// the call pattern and error handling rather than a live tunnel. +func ExampleClient_TCP() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Bind local port 0 (OS-assigned) to sandbox port 8080. + ln, err := client.TCP().Listen(ctx, "my-sandbox", 8080, 0) + if v1.IsUnimplemented(err) { + fmt.Println("Listen requires a real gateway") + } + if ln != nil { + // In production, use ln.Addr() to discover the assigned port, + // then accept connections in a loop: + // + // for { + // conn, err := ln.Accept() + // if err != nil { break } + // go handleConn(conn) + // } + defer ln.Close() //nolint:errcheck + } + // Output: + // Listen requires a real gateway +} + +// ExampleIsNotFound demonstrates handling a not-found error. +func ExampleIsNotFound() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := client.Sandboxes().Get(ctx, "nonexistent") + if v1.IsNotFound(err) { + fmt.Println("Sandbox not found") + } + // Output: + // Sandbox not found +} + +// ExampleIsAlreadyExists demonstrates handling a duplicate-creation error. +func ExampleIsAlreadyExists() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Create a sandbox + _, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + + // Try to create the same sandbox again + _, err = client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if v1.IsAlreadyExists(err) { + fmt.Println("Sandbox already exists") + } + // Output: + // Sandbox already exists +} + +// ExampleIsUnavailable demonstrates detecting a closed client. +func ExampleIsUnavailable() { + client := fake.NewClient() + _ = client.Close() + + ctx := context.Background() + + _, err := client.Sandboxes().Get(ctx, "any") + if v1.IsUnavailable(err) { + fmt.Println("Client is closed") + } + // Output: + // Client is closed +} diff --git a/sdk/go/openshell/v1/exec.go b/sdk/go/openshell/v1/exec.go new file mode 100644 index 0000000000..373f518eee --- /dev/null +++ b/sdk/go/openshell/v1/exec.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ExecResult holds the collected output of a completed command execution. +type ExecResult = types.ExecResult + +// ExecChunk represents a single chunk of output from a streaming command execution. +type ExecChunk = types.ExecChunk + +// ExecStream provides an iterator interface over streaming command output. +type ExecStream interface { + Next() (*ExecChunk, error) + ExitCode() (int, error) + Close() error +} + +// InteractiveSession provides bidirectional I/O for interactive command execution. +type InteractiveSession interface { + Read(p []byte) (int, error) + Write(p []byte) (int, error) + Resize(cols, rows uint32) error + ExitCode() (int, error) + Close() error +} + +// ExecInterface defines command execution operations on sandboxes. +// Methods accept a sandbox name and resolve it to an ID internally. +type ExecInterface interface { + Run(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error) + Stream(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error) + Interactive(ctx context.Context, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error) +} diff --git a/sdk/go/openshell/v1/exec_client.go b/sdk/go/openshell/v1/exec_client.go new file mode 100644 index 0000000000..4cf0bf240e --- /dev/null +++ b/sdk/go/openshell/v1/exec_client.go @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type execClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newExecClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *execClient { + return &execClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes} +} + +func (e *execClient) Run(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + req := converter.ExecRequestToProto(sb.ID, command, opt) + + stream, err := e.client.ExecSandbox(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + var events []*pb.ExecSandboxEvent + for { + ev, recvErr := stream.Recv() + if recvErr == io.EOF { + break + } + if recvErr != nil { + return nil, converter.FromGRPCError(recvErr) + } + events = append(events, ev) + } + + return converter.ExecResultFromEvents(events) +} + +func (e *execClient) Stream(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + req := converter.ExecRequestToProto(sb.ID, command, opt) + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := e.client.ExecSandbox(streamCtx, req) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + return &execStream{stream: stream, cancel: cancel}, nil +} + +func (e *execClient) Interactive(ctx context.Context, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + + stream, err := e.client.ExecSandboxInteractive(ctx) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + startReq := converter.ExecInteractiveRequestToProto(sb.ID, command, cols, rows, opt) + if sendErr := stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Start{Start: startReq}, + }); sendErr != nil { + return nil, converter.FromGRPCError(sendErr) + } + + return newInteractiveSession(stream), nil +} + +// execStream wraps a server-streaming RPC into the ExecStream interface. +type execStream struct { + stream grpc.ServerStreamingClient[pb.ExecSandboxEvent] + cancel context.CancelFunc + exitCode int + exited bool + hasExit bool +} + +func (s *execStream) Next() (*ExecChunk, error) { + if s.exited { + return nil, io.EOF + } + + ev, err := s.stream.Recv() + if err == io.EOF { + return nil, io.EOF + } + if err != nil { + return nil, converter.FromGRPCError(err) + } + + chunk, code, convErr := converter.ExecChunkFromEvent(ev) + if convErr != nil { + return nil, convErr + } + if chunk != nil { + return chunk, nil + } + // nil chunk with no error means exit event + s.exitCode = code + s.exited = true + s.hasExit = true + return nil, io.EOF +} + +func (s *execStream) ExitCode() (int, error) { + if !s.exited { + for { + _, err := s.Next() + if err == io.EOF { + break + } + if err != nil { + return -1, err + } + } + } + if !s.hasExit { + return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"} + } + return s.exitCode, nil +} + +func (s *execStream) Close() error { + if s.cancel != nil { + s.cancel() + } + return nil +} + +// interactiveSession wraps a bidirectional streaming RPC into the InteractiveSession interface. +// A background goroutine owns the Recv loop and routes events to dataCh (for Read) +// and exitCh (for ExitCode), preventing concurrent Recv calls on the stream. +type interactiveSession struct { + stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent] + sendMu sync.Mutex + dataCh chan []byte + exitCh chan int + done chan struct{} + cancel context.CancelFunc + streamCtx context.Context + errOnce sync.Once + err error + buf []byte + exitCode int + hasExit bool +} + +func newInteractiveSession(stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent]) *interactiveSession { + ctx, cancel := context.WithCancel(context.Background()) + s := &interactiveSession{ + stream: stream, + dataCh: make(chan []byte, 64), + exitCh: make(chan int, 1), + done: make(chan struct{}), + cancel: cancel, + streamCtx: ctx, + } + go s.readLoop() + return s +} + +func (s *interactiveSession) setErr(err error) { + s.errOnce.Do(func() { s.err = err }) +} + +func (s *interactiveSession) readLoop() { + defer close(s.dataCh) + defer close(s.done) + for { + ev, err := s.stream.Recv() + if err != nil { + if err != io.EOF { + s.setErr(converter.FromGRPCError(err)) + } + return + } + + chunk, code, convErr := converter.ExecChunkFromEvent(ev) + if convErr != nil { + s.setErr(convErr) + return + } + // nil chunk with no error means exit event + if chunk == nil { + select { + case s.exitCh <- code: + default: + } + return + } + select { + case s.dataCh <- chunk.Data: + case <-s.streamCtx.Done(): + return + } + } +} + +func (s *interactiveSession) Read(p []byte) (int, error) { + if len(s.buf) > 0 { + n := copy(p, s.buf) + s.buf = s.buf[n:] + return n, nil + } + + data, ok := <-s.dataCh + if !ok { + if s.err != nil { + return 0, s.err + } + return 0, io.EOF + } + n := copy(p, data) + if n < len(data) { + s.buf = append(s.buf, data[n:]...) + } + return n, nil +} + +func (s *interactiveSession) Write(p []byte) (int, error) { + s.sendMu.Lock() + defer s.sendMu.Unlock() + err := s.stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Stdin{Stdin: p}, + }) + if err != nil { + return 0, converter.FromGRPCError(err) + } + return len(p), nil +} + +func (s *interactiveSession) Resize(cols, rows uint32) error { + s.sendMu.Lock() + defer s.sendMu.Unlock() + err := s.stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Resize{ + Resize: &pb.ExecSandboxWindowResize{ + Cols: cols, + Rows: rows, + }, + }, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (s *interactiveSession) ExitCode() (int, error) { + if s.hasExit { + return s.exitCode, nil + } + select { + case code := <-s.exitCh: + s.exitCode = code + s.hasExit = true + return code, nil + case <-s.done: + select { + case code := <-s.exitCh: + s.exitCode = code + s.hasExit = true + return code, nil + default: + if s.err != nil { + return -1, s.err + } + return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"} + } + } +} + +func (s *interactiveSession) Close() error { + s.sendMu.Lock() + err := s.stream.CloseSend() + s.sendMu.Unlock() + s.cancel() + <-s.done + return err +} diff --git a/sdk/go/openshell/v1/exec_client_test.go b/sdk/go/openshell/v1/exec_client_test.go new file mode 100644 index 0000000000..fed9e5b121 --- /dev/null +++ b/sdk/go/openshell/v1/exec_client_test.go @@ -0,0 +1,621 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "net" + "sync" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// stubSandboxResolver implements SandboxInterface for testing name-to-ID resolution. +// Get returns a Sandbox with ID = "sb-" + name. All other methods panic. +type stubSandboxResolver struct { + getErr error // if non-nil, Get returns this error +} + +func (r *stubSandboxResolver) Get(_ context.Context, name string) (*Sandbox, error) { + if r.getErr != nil { + return nil, r.getErr + } + return &Sandbox{ID: "sb-" + name, Name: name}, nil +} + +func (r *stubSandboxResolver) Create(context.Context, string, *SandboxSpec, map[string]string) (*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) List(context.Context, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) Delete(context.Context, string) error { panic("not implemented") } +func (r *stubSandboxResolver) AttachProvider(context.Context, string, string, uint64) (*AttachProviderResult, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) DetachProvider(context.Context, string, string, uint64) (*DetachProviderResult, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) ListProviders(context.Context, string) ([]*Provider, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) WaitReady(context.Context, string, ...WaitOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) Watch(context.Context, string, ...WatchOptions) (WatchInterface[*Sandbox], error) { + panic("not implemented") +} +func (r *stubSandboxResolver) GetLogs(context.Context, string, ...LogOption) (*LogResult, error) { + panic("not implemented") +} + +type mockExecServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + execEvents []*pb.ExecSandboxEvent + execErr error + lastExecRequest *pb.ExecSandboxRequest + + interactiveEvents []*pb.ExecSandboxEvent + interactiveErr error + interactiveWaitInput bool + receivedInputs []*pb.ExecSandboxInput +} + +func newMockExecServer() *mockExecServer { + return &mockExecServer{} +} + +func (s *mockExecServer) ExecSandbox(req *pb.ExecSandboxRequest, stream grpc.ServerStreamingServer[pb.ExecSandboxEvent]) error { + s.mu.Lock() + s.lastExecRequest = req + events := make([]*pb.ExecSandboxEvent, len(s.execEvents)) + copy(events, s.execEvents) + execErr := s.execErr + s.mu.Unlock() + + if execErr != nil { + return execErr + } + + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + return nil +} + +func (s *mockExecServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[pb.ExecSandboxInput, pb.ExecSandboxEvent]) error { + s.mu.Lock() + interactiveErr := s.interactiveErr + events := make([]*pb.ExecSandboxEvent, len(s.interactiveEvents)) + copy(events, s.interactiveEvents) + s.mu.Unlock() + + if interactiveErr != nil { + return interactiveErr + } + + // Read the start message + startMsg, err := stream.Recv() + if err != nil { + return err + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, startMsg) + s.mu.Unlock() + + s.mu.Lock() + waitInput := s.interactiveWaitInput + s.mu.Unlock() + + if waitInput { + msg, recvErr := stream.Recv() + if recvErr != nil { + return recvErr + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, msg) + s.mu.Unlock() + } + + // Read subsequent messages until client closes, collecting them + go func() { + for { + msg, recvErr := stream.Recv() + if recvErr != nil { + return + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, msg) + s.mu.Unlock() + } + }() + + // Send canned events + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + return nil +} + +func setupExecTest(t *testing.T, mock *mockExecServer) (*execClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newExecClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T043: Run and Stream tests --- + +func TestExecRun(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("hello ")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("world\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("warn\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + result, err := client.Run(context.Background(), "test-sandbox", []string{"echo", "hello", "world"}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.ExitCode) + assert.Equal(t, []byte("hello world\n"), result.Stdout) + assert.Equal(t, []byte("warn\n"), result.Stderr) +} + +func TestExecRun_WithOptions(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + opts := ExecOptions{ + Env: map[string]string{"FOO": "bar"}, + WorkDir: "/tmp", + } + result, err := client.Run(context.Background(), "test-sandbox", []string{"ls"}, opts) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.ExitCode) + + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Equal(t, "sb-test-sandbox", mock.lastExecRequest.GetSandboxId()) + assert.Equal(t, []string{"ls"}, mock.lastExecRequest.GetCommand()) + assert.Equal(t, "/tmp", mock.lastExecRequest.GetWorkdir()) + assert.Equal(t, map[string]string{"FOO": "bar"}, mock.lastExecRequest.GetEnvironment()) +} + +func TestExecRun_NonZeroExit(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("fail\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + result, err := client.Run(context.Background(), "test-sandbox", []string{"false"}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 1, result.ExitCode) + assert.Empty(t, result.Stdout) + assert.Equal(t, []byte("fail\n"), result.Stderr) +} + +func TestExecRun_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.execErr = status.Error(codes.NotFound, "sandbox not found") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + _, err := client.Run(context.Background(), "missing-sandbox", []string{"ls"}) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecStream(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("err1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 42}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "test-sandbox", []string{"cat"}) + require.NoError(t, err) + require.NotNil(t, stream) + defer func() { _ = stream.Close() }() + + chunk1, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStdout, chunk1.Stream) + assert.Equal(t, []byte("line1\n"), chunk1.Data) + + chunk2, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStderr, chunk2.Stream) + assert.Equal(t, []byte("err1\n"), chunk2.Data) + + chunk3, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStdout, chunk3.Stream) + assert.Equal(t, []byte("line2\n"), chunk3.Data) + + // Next call after exit should return io.EOF + _, err = stream.Next() + assert.ErrorIs(t, err, io.EOF) + + exitCode, err := stream.ExitCode() + require.NoError(t, err) + assert.Equal(t, 42, exitCode) +} + +func TestExecStream_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.execErr = status.Error(codes.Internal, "internal error") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "test-sandbox", []string{"ls"}) + if err != nil { + return + } + _, err = stream.Next() + require.Error(t, err) +} + +func TestExecStream_EmptyOutput(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "test-sandbox", []string{"true"}) + require.NoError(t, err) + defer func() { _ = stream.Close() }() + + _, err = stream.Next() + assert.ErrorIs(t, err, io.EOF) + + exitCode, err := stream.ExitCode() + require.NoError(t, err) + assert.Equal(t, 0, exitCode) +} + +// --- T044: Interactive session tests --- + +func TestExecInteractive(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("output\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/bash"}, 80, 24) + require.NoError(t, err) + require.NotNil(t, session) + defer func() { _ = session.Close() }() + + // Read output + buf := make([]byte, 1024) + n, err := session.Read(buf) + require.NoError(t, err) + assert.Equal(t, "$ ", string(buf[:n])) + + // Verify start message was received + mock.mu.Lock() + require.GreaterOrEqual(t, len(mock.receivedInputs), 1) + startInput := mock.receivedInputs[0] + mock.mu.Unlock() + + startReq := startInput.GetStart() + require.NotNil(t, startReq) + assert.Equal(t, "sb-test-sandbox", startReq.GetSandboxId()) + assert.Equal(t, []string{"/bin/bash"}, startReq.GetCommand()) + assert.True(t, startReq.GetTty()) + assert.Equal(t, uint32(80), startReq.GetCols()) + assert.Equal(t, uint32(24), startReq.GetRows()) +} + +func TestExecInteractive_Write(t *testing.T) { + mock := newMockExecServer() + mock.interactiveWaitInput = true + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + n, err := session.Write([]byte("ls\n")) + require.NoError(t, err) + assert.Equal(t, 3, n) +} + +func TestExecInteractive_Resize(t *testing.T) { + mock := newMockExecServer() + mock.interactiveWaitInput = true + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + err = session.Resize(120, 40) + require.NoError(t, err) +} + +func TestExecInteractive_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.interactiveErr = status.Error(codes.PermissionDenied, "not allowed") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + if err != nil { + return + } + buf := make([]byte, 1024) + _, err = session.Read(buf) + require.Error(t, err) +} + +func TestExecInteractive_ExitCode(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("done\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 130}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + + // Drain output + buf := make([]byte, 1024) + for { + _, readErr := session.Read(buf) + if readErr != nil { + break + } + } + + exitCode, err := session.ExitCode() + require.NoError(t, err) + assert.Equal(t, 130, exitCode) + + _ = session.Close() +} + +func TestExecInteractive_ConcurrentReadAndExitCode(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line3\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"sh"}, 80, 24) + require.NoError(t, err) + + var wg sync.WaitGroup + var readData []byte + var readErr error + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 1024) + for { + n, err := session.Read(buf) + if err != nil { + readErr = err + return + } + readData = append(readData, buf[:n]...) + } + }() + + exitCode, exitErr := session.ExitCode() + wg.Wait() + + require.NoError(t, exitErr) + assert.Equal(t, 0, exitCode) + assert.Equal(t, io.EOF, readErr) + assert.Contains(t, string(readData), "line1\n") +} + +// --- Name-to-ID resolution tests --- + +func TestExecRun_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + _, err := client.Run(context.Background(), "my-sandbox", []string{"echo", "hi"}) + require.NoError(t, err) + + mock.mu.Lock() + defer mock.mu.Unlock() + // Verify the proto request contains the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) +} + +func TestExecRun_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Run(context.Background(), "nonexistent", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecStream_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "my-sandbox", []string{"echo"}) + require.NoError(t, err) + + _, _ = stream.ExitCode() + _ = stream.Close() + + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) +} + +func TestExecStream_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Stream(context.Background(), "nonexistent", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecInteractive_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "my-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + + _, _ = session.ExitCode() + _ = session.Close() + + mock.mu.Lock() + defer mock.mu.Unlock() + require.NotEmpty(t, mock.receivedInputs) + startReq := mock.receivedInputs[0].GetStart() + require.NotNil(t, startReq) + assert.Equal(t, "sb-my-sandbox", startReq.GetSandboxId()) +} + +func TestExecInteractive_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Interactive(context.Background(), "nonexistent", []string{"/bin/sh"}, 80, 24) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecRun_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Run(context.Background(), "", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestExecStream_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Stream(context.Background(), "", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestExecInteractive_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Interactive(context.Background(), "", []string{"/bin/sh"}, 80, 24) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// stubConn creates a minimal gRPC connection for resolution-error tests +// where the RPC is never reached. +func stubConn(t *testing.T) *grpc.ClientConn { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, newMockExecServer()) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { srv.Stop() }) + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} diff --git a/sdk/go/openshell/v1/fake/broadcaster.go b/sdk/go/openshell/v1/fake/broadcaster.go new file mode 100644 index 0000000000..f5d01d4743 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const watchChannelBuffer = 100 + +// watchBroadcaster manages a set of watchers and broadcasts events to them. +// Each watcher can optionally filter events by resource name. +type watchBroadcaster[T any] struct { + mu sync.Mutex + watchers []*fakeWatcher[T] +} + +// newWatchBroadcaster creates a new watchBroadcaster. +func newWatchBroadcaster[T any]() *watchBroadcaster[T] { + return &watchBroadcaster[T]{} +} + +// Watch registers a new watcher. If name is non-empty, the watcher only +// receives events matching that name. If name is empty, all events are +// delivered. The returned WatchInterface must be stopped by the caller. +func (b *watchBroadcaster[T]) Watch(name string) types.WatchInterface[T] { + ch := make(chan types.Event[T], watchChannelBuffer) + w := &fakeWatcher[T]{ + ch: ch, + name: name, + } + + b.mu.Lock() + b.watchers = append(b.watchers, w) + b.mu.Unlock() + + return w +} + +// Broadcast sends an event to all registered watchers whose name filter +// matches (or whose filter is empty). Stopped watchers are skipped and +// cleaned up lazily. +func (b *watchBroadcaster[T]) Broadcast(event types.Event[T], name string) { + b.mu.Lock() + defer b.mu.Unlock() + + active := b.watchers[:0] + for _, w := range b.watchers { + if w.isStopped() { + continue + } + active = append(active, w) + + if w.name != "" && w.name != name { + continue + } + + w.send(event) + } + b.watchers = active +} + +// StopAll closes all active watchers. +func (b *watchBroadcaster[T]) StopAll() { + b.mu.Lock() + defer b.mu.Unlock() + + for _, w := range b.watchers { + w.Stop() + } + b.watchers = nil +} + +// fakeWatcher implements types.WatchInterface[T] with a buffered channel +// and optional name filter. +type fakeWatcher[T any] struct { + ch chan types.Event[T] + name string + once sync.Once + stopped bool + mu sync.Mutex +} + +// ResultChan returns the channel delivering watch events. +func (w *fakeWatcher[T]) ResultChan() <-chan types.Event[T] { + return w.ch +} + +// send delivers an event to the watcher under its lock, preventing a +// race between Broadcast (send) and Stop (close) on w.ch. +func (w *fakeWatcher[T]) send(event types.Event[T]) { + w.mu.Lock() + defer w.mu.Unlock() + if w.stopped { + return + } + select { + case w.ch <- event: + default: + } +} + +// Stop closes the event channel. It is safe to call multiple times. +func (w *fakeWatcher[T]) Stop() { + w.once.Do(func() { + w.mu.Lock() + defer w.mu.Unlock() + w.stopped = true + close(w.ch) + }) +} + +// isStopped returns true if Stop has been called. +func (w *fakeWatcher[T]) isStopped() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.stopped +} diff --git a/sdk/go/openshell/v1/fake/broadcaster_test.go b/sdk/go/openshell/v1/fake/broadcaster_test.go new file mode 100644 index 0000000000..e3605503e5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster_test.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T005: watchBroadcaster tests --- + +func TestWatchBroadcaster_Watch_ReceivesEvents(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + defer w.Stop() + + item := &testItem{Name: "alpha", Value: "v1"} + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: item}, "alpha") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } +} + +func TestWatchBroadcaster_Watch_NameFiltering(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher filtered to "alpha" only + wAlpha := b.Watch("alpha") + defer wAlpha.Stop() + + // Watcher filtered to "beta" only + wBeta := b.Watch("beta") + defer wBeta.Stop() + + // Broadcast event for "alpha" + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // alpha watcher should receive event + select { + case ev := <-wAlpha.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("alpha watcher: timed out waiting for event") + } + + // beta watcher should NOT receive event + select { + case ev := <-wBeta.ResultChan(): + t.Fatalf("beta watcher: unexpected event %v", ev) + case <-time.After(50 * time.Millisecond): + // Expected: no event for beta + } +} + +func TestWatchBroadcaster_Watch_EmptyNameReceivesAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher with empty name receives all events + w := b.Watch("") + defer w.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "beta"}}, "beta") + + received := make([]string, 0, 2) + for i := 0; i < 2; i++ { + select { + case ev := <-w.ResultChan(): + received = append(received, ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } + assert.ElementsMatch(t, []string{"alpha", "beta"}, received) +} + +func TestWatchBroadcaster_MultipleWatchers(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + defer w1.Stop() + w2 := b.Watch("") + defer w2.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // Both watchers should receive the event + for _, w := range []types.WatchInterface[*testItem]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } +} + +func TestWatchBroadcaster_Stop_ClosesChannel(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Channel should be closed after Stop + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestWatchBroadcaster_Stop_Idempotent(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + + // Multiple stops should not panic + w.Stop() + w.Stop() +} + +func TestWatchBroadcaster_StopAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("alpha") + + b.StopAll() + + // Both channels should be closed + _, ok1 := <-w1.ResultChan() + assert.False(t, ok1, "w1 channel should be closed after StopAll") + + _, ok2 := <-w2.ResultChan() + assert.False(t, ok2, "w2 channel should be closed after StopAll") +} + +func TestWatchBroadcaster_BroadcastAfterStop_NoDelivery(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Broadcasting after a watcher stops should not panic + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") +} + +func TestWatchBroadcaster_StoppedWatcher_RemovedFromBroadcast(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("") + + // Stop w1, keep w2 + w1.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // w2 should still receive events + select { + case ev := <-w2.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on w2") + } + + w2.Stop() +} diff --git a/sdk/go/openshell/v1/fake/config.go b/sdk/go/openshell/v1/fake/config.go new file mode 100644 index 0000000000..3bb94605c5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/config.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeConfigClient implements v1.ConfigInterface. All methods return +// Unimplemented because configuration management requires a real gateway. +type fakeConfigClient struct { + closedFunc func() bool +} + +// newFakeConfigClient creates a new fakeConfigClient. +func newFakeConfigClient(closedFunc func() bool) *fakeConfigClient { + return &fakeConfigClient{closedFunc: closedFunc} +} + +// GetSandbox returns Unimplemented. +func (c *fakeConfigClient) GetSandbox(_ context.Context, _ string) (*types.SandboxConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetSandbox is not supported by the fake client"} +} + +// GetGateway returns Unimplemented. +func (c *fakeConfigClient) GetGateway(_ context.Context) (*types.GatewayConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetGateway is not supported by the fake client"} +} + +// Update returns Unimplemented. A nil update is rejected with InvalidArgument +// to match the real client's behavior. +func (c *fakeConfigClient) Update(_ context.Context, update *types.ConfigUpdate) (*types.ConfigUpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if update == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "update must not be nil"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Compile-time check that fakeConfigClient implements v1.ConfigInterface. +var _ v1.ConfigInterface = (*fakeConfigClient)(nil) diff --git a/sdk/go/openshell/v1/fake/config_test.go b/sdk/go/openshell/v1/fake/config_test.go new file mode 100644 index 0000000000..9f86e739ba --- /dev/null +++ b/sdk/go/openshell/v1/fake/config_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T020: fakeConfigClient stub tests --- + +func TestFakeConfig_GetSandbox_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetSandbox(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetGateway_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetSandbox_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetSandbox(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_GetGateway_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T033: MergeOperations acceptance test --- + +func TestFakeConfig_Update_MergeOperationsAccepted(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + MergeOperations: []types.PolicyMergeOperation{{RemoveRule: &types.RemoveNetworkRule{RuleName: "test"}}}, + }) + require.Error(t, err) + // Should return Unimplemented (not InvalidArgument) — MergeOperations are now accepted + assert.True(t, types.IsUnimplemented(err)) + assert.False(t, types.IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/fake/doc.go b/sdk/go/openshell/v1/fake/doc.go new file mode 100644 index 0000000000..2b17dfcd63 --- /dev/null +++ b/sdk/go/openshell/v1/fake/doc.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package fake provides an in-memory fake implementation of the OpenShell SDK +// client interfaces for use in consumer test suites. +// +// The fake client follows the client-go/kubernetes/fake pattern: it maintains +// in-memory stores for sandboxes and providers, supports watch event broadcasting, +// and returns the same StatusError codes as the real client for equivalent error +// conditions (NotFound, AlreadyExists, Unavailable, Unimplemented). +// +// All operations are safe for concurrent use from multiple goroutines. +// +// # Usage +// +// Create a FakeClient, exercise the sandbox lifecycle, and assert results: +// +// func TestSandboxLifecycle(t *testing.T) { +// client := fake.NewClient() +// defer client.Close() +// +// ctx := context.Background() +// +// // Create a sandbox — starts in Provisioning phase +// sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) +// require.NoError(t, err) +// assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) +// +// // Wait until ready — transitions synchronously in the fake +// sb, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") +// require.NoError(t, err) +// assert.Equal(t, types.SandboxReady, sb.Status.Phase) +// +// // Clean up +// require.NoError(t, client.Sandboxes().Delete(ctx, "my-sandbox")) +// } +package fake diff --git a/sdk/go/openshell/v1/fake/exec.go b/sdk/go/openshell/v1/fake/exec.go new file mode 100644 index 0000000000..d8a4627b53 --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.ExecInterface = (*fakeExecClient)(nil) + +// fakeExecClient implements v1.ExecInterface. All methods return +// Unimplemented because command execution requires a real sandbox runtime. +type fakeExecClient struct { + closedFunc func() bool +} + +// newFakeExecClient creates a new fakeExecClient. +func newFakeExecClient(closedFunc func() bool) *fakeExecClient { + return &fakeExecClient{closedFunc: closedFunc} +} + +// Run returns Unimplemented. +func (c *fakeExecClient) Run(_ context.Context, _ string, _ []string, _ ...v1.ExecOptions) (*types.ExecResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Run is not supported by the fake client"} +} + +// Stream returns Unimplemented. +func (c *fakeExecClient) Stream(_ context.Context, _ string, _ []string, _ ...v1.ExecOptions) (v1.ExecStream, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Stream is not supported by the fake client"} +} + +// Interactive returns Unimplemented. +func (c *fakeExecClient) Interactive(_ context.Context, _ string, _ []string, _, _ uint32, _ ...v1.ExecOptions) (v1.InteractiveSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Interactive is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/exec_test.go b/sdk/go/openshell/v1/fake/exec_test.go new file mode 100644 index 0000000000..028a4e09b4 --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec_test.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T021: Exec stub tests --- + +func TestExec_Run_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Run(ctx, "sandbox-1", []string{"echo", "hello"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Stream_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "sandbox-1", []string{"tail", "-f", "/var/log/app.log"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Interactive_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Run_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Run(ctx, "sandbox-1", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Stream_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "sandbox-1", []string{"tail"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Interactive_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/fake.go b/sdk/go/openshell/v1/fake/fake.go new file mode 100644 index 0000000000..c29634e25b --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Client implements v1.ClientInterface with in-memory stores. It is +// designed for testing consumers of the OpenShell SDK without requiring a +// real gRPC connection. Create one with NewClient. +type Client struct { + sandboxStore *objectStore[*types.Sandbox] + providerStore *objectStore[*types.Provider] + sandboxBroadcaster *watchBroadcaster[*types.Sandbox] + + sandboxes v1.SandboxInterface + providers v1.ProviderInterface + services v1.ServiceInterface + exec v1.ExecInterface + files v1.FileInterface + health v1.HealthInterface + ssh v1.SSHInterface + tcp v1.TCPInterface + cfg v1.ConfigInterface + policy v1.PolicyInterface + + closeOnce sync.Once + closed bool + mu sync.RWMutex // guards closed flag +} + +// ClientOption configures a Client during construction. +type ClientOption func(*Client) + +// WithHealthResult returns an option that configures the health sub-client +// to return the given result instead of the default healthy response. +func WithHealthResult(r *types.HealthResult) ClientOption { + return func(fc *Client) { + fc.health = newFakeHealthClient(r, fc.isClosed) + } +} + +// NewClient creates a new Client with all sub-clients wired up. +// Options (e.g., WithHealthResult) are applied after the default setup. +func NewClient(opts ...ClientOption) *Client { + fc := &Client{ + sandboxStore: newobjectStore(sandboxName, copySandbox), + providerStore: newobjectStore(providerName, copyProvider), + sandboxBroadcaster: newWatchBroadcaster[*types.Sandbox](), + } + + fc.sandboxes = newFakeSandboxClient(fc.sandboxStore, fc.sandboxBroadcaster, fc.isClosed) + fc.providers = newFakeProviderClient(fc.providerStore, fc.isClosed) + fc.services = newFakeServiceClient(fc.isClosed) + fc.exec = newFakeExecClient(fc.isClosed) + fc.files = newFakeFileClient(fc.isClosed) + fc.health = newFakeHealthClient(nil, fc.isClosed) + fc.ssh = newFakeSSHClient(fc.isClosed) + fc.tcp = newFakeTCPClient(fc.isClosed) + fc.cfg = newFakeConfigClient(fc.isClosed) + fc.policy = newFakePolicyClient(fc.isClosed) + + for _, opt := range opts { + opt(fc) + } + + return fc +} + +// isClosed returns true if the client has been closed. This is passed to +// all sub-clients as the closedFunc parameter. +func (fc *Client) isClosed() bool { + fc.mu.RLock() + defer fc.mu.RUnlock() + return fc.closed +} + +// Sandboxes returns the sandbox sub-client. +func (fc *Client) Sandboxes() v1.SandboxInterface { return fc.sandboxes } + +// Providers returns the provider sub-client. +func (fc *Client) Providers() v1.ProviderInterface { return fc.providers } + +// Services returns the service sub-client. +func (fc *Client) Services() v1.ServiceInterface { return fc.services } + +// Exec returns the exec sub-client. +func (fc *Client) Exec() v1.ExecInterface { return fc.exec } + +// Files returns the file sub-client. +func (fc *Client) Files() v1.FileInterface { return fc.files } + +// Health returns the health sub-client. +func (fc *Client) Health() v1.HealthInterface { return fc.health } + +// SSH returns the SSH session sub-client. +func (fc *Client) SSH() v1.SSHInterface { return fc.ssh } + +// TCP returns the TCP port forwarding sub-client. +func (fc *Client) TCP() v1.TCPInterface { return fc.tcp } + +// Config returns the configuration sub-client. +func (fc *Client) Config() v1.ConfigInterface { return fc.cfg } + +// Policy returns the policy management sub-client. +func (fc *Client) Policy() v1.PolicyInterface { return fc.policy } + +// Close marks the client as closed, stops all active watchers, and causes +// subsequent sub-client calls to return Unavailable. Safe to call multiple +// times. +func (fc *Client) Close() error { + fc.closeOnce.Do(func() { + fc.mu.Lock() + fc.closed = true + fc.mu.Unlock() + + fc.sandboxBroadcaster.StopAll() + }) + return nil +} + +// AddSandbox inserts a sandbox directly into the store without triggering +// watch events. This is intended for pre-seeding test fixtures before the +// test begins. The sandbox is deep-copied on insert. +func (fc *Client) AddSandbox(sb *types.Sandbox) { + fc.sandboxStore.Insert(sb) +} + +// AddProvider inserts a provider directly into the store without triggering +// any side effects. This is intended for pre-seeding test fixtures before +// the test begins. The provider is deep-copied on insert. +func (fc *Client) AddProvider(p *types.Provider) { + fc.providerStore.Insert(p) +} + +// Compile-time interface check. +var _ v1.ClientInterface = (*Client)(nil) diff --git a/sdk/go/openshell/v1/fake/fake_test.go b/sdk/go/openshell/v1/fake/fake_test.go new file mode 100644 index 0000000000..8be588119b --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake_test.go @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T025: FakeClient Close tests --- + +func TestFakeClient_Close(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Close_Idempotent(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) + + err = fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Sandboxes_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Sandboxes().Create(ctx, "test", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Providers_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Providers().Create(ctx, &types.Provider{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Health_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Health().Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Exec_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Exec().Run(ctx, "sandbox", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Files_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + err := fc.Files().Upload(ctx, "sandbox", "/local", "/remote") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Watch_StoppedOnClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "") + require.NoError(t, err) + + _ = fc.Close() + + // Channel should be closed after FakeClient.Close + _, ok := <-w.ResultChan() + assert.False(t, ok, "watcher channel should be closed after FakeClient.Close") +} + +func TestFakeClient_WithHealthResult(t *testing.T) { + custom := &types.HealthResult{Healthy: false, Version: "broken"} + fc := NewClient(WithHealthResult(custom)) + ctx := context.Background() + + result, err := fc.Health().Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "broken", result.Version) +} + +func TestFakeClient_SubClients(t *testing.T) { + fc := NewClient() + + assert.NotNil(t, fc.Sandboxes()) + assert.NotNil(t, fc.Providers()) + assert.NotNil(t, fc.Exec()) + assert.NotNil(t, fc.Files()) + assert.NotNil(t, fc.Health()) +} + +// --- T013: Pre-seed tests --- + +func TestFakeClient_AddSandbox(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Spec: types.SandboxSpec{LogLevel: "debug"}, + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + } + + fc.AddSandbox(sb) + + got, err := fc.Sandboxes().Get(ctx, "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "pre-seeded", got.Name) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestFakeClient_AddSandbox_InList(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + fc.AddSandbox(&types.Sandbox{Name: "sb-1"}) + fc.AddSandbox(&types.Sandbox{Name: "sb-2"}) + + list, err := fc.Sandboxes().List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestFakeClient_AddSandbox_NoWatchEvents(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + fc.AddSandbox(&types.Sandbox{Name: "pre-seeded"}) + + // No event should be received — AddSandbox bypasses the broadcaster + select { + case ev := <-w.ResultChan(): + t.Fatalf("unexpected event: %v", ev) + default: + // Good — no event received + } +} + +func TestFakeClient_AddSandbox_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Labels: map[string]string{"env": "test"}, + } + fc.AddSandbox(sb) + + // Mutate the input + sb.Labels["env"] = "mutated" + + got, err := fc.Sandboxes().Get(ctx, "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) +} + +func TestFakeClient_AddProvider(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + fc.AddProvider(p) + + got, err := fc.Providers().Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestFakeClient_AddProvider_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + fc.AddProvider(p) + + // Mutate input + p.Spec.Config["model"] = "mutated" + + got, err := fc.Providers().Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} diff --git a/sdk/go/openshell/v1/fake/file.go b/sdk/go/openshell/v1/fake/file.go new file mode 100644 index 0000000000..1834ceb404 --- /dev/null +++ b/sdk/go/openshell/v1/fake/file.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.FileInterface = (*fakeFileClient)(nil) + +// fakeFileClient implements v1.FileInterface. All methods return +// Unimplemented because file transfer requires a real sandbox runtime. +type fakeFileClient struct { + closedFunc func() bool +} + +// newFakeFileClient creates a new fakeFileClient. +func newFakeFileClient(closedFunc func() bool) *fakeFileClient { + return &fakeFileClient{closedFunc: closedFunc} +} + +// Upload returns Unimplemented. +func (c *fakeFileClient) Upload(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Upload is not supported by the fake client"} +} + +// Download returns Unimplemented. +func (c *fakeFileClient) Download(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Download is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/file_test.go b/sdk/go/openshell/v1/fake/file_test.go new file mode 100644 index 0000000000..40cbcaa8b6 --- /dev/null +++ b/sdk/go/openshell/v1/fake/file_test.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T022: File stub tests --- + +func TestFile_Upload_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Upload(ctx, "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Download_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Download(ctx, "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Upload_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Upload(ctx, "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFile_Download_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Download(ctx, "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/health.go b/sdk/go/openshell/v1/fake/health.go new file mode 100644 index 0000000000..75bda3b3ce --- /dev/null +++ b/sdk/go/openshell/v1/fake/health.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeHealthClient implements v1.HealthInterface with a configurable +// health result. When no custom result is provided, Check returns a +// default healthy response. +type fakeHealthClient struct { + result *types.HealthResult + closedFunc func() bool +} + +// newFakeHealthClient creates a new fakeHealthClient. If result is nil, +// Check will return the default healthy response. +func newFakeHealthClient(result *types.HealthResult, closedFunc func() bool) *fakeHealthClient { + return &fakeHealthClient{ + result: result, + closedFunc: closedFunc, + } +} + +// Check returns the configured health result. If no custom result was +// provided, it returns {Healthy: true, Version: "fake"}. +func (c *fakeHealthClient) Check(_ context.Context) (*types.HealthResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if c.result != nil { + cp := *c.result + return &cp, nil + } + + return &types.HealthResult{ + Healthy: true, + Version: "fake", + }, nil +} diff --git a/sdk/go/openshell/v1/fake/health_test.go b/sdk/go/openshell/v1/fake/health_test.go new file mode 100644 index 0000000000..1fcdbc03e9 --- /dev/null +++ b/sdk/go/openshell/v1/fake/health_test.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T017: Health check tests --- + +func TestHealth_DefaultHealthy(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.True(t, result.Healthy) + assert.Equal(t, "fake", result.Version) +} + +func TestHealth_ConfigurableResult(t *testing.T) { + custom := &types.HealthResult{ + Healthy: false, + Version: "v0.0.0-broken", + } + hc := newFakeHealthClient(custom, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "v0.0.0-broken", result.Version) +} + +func TestHealth_ClosedClient(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return true }) + ctx := context.Background() + + _, err := hc.Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/policy.go b/sdk/go/openshell/v1/fake/policy.go new file mode 100644 index 0000000000..df6a136901 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakePolicyClient implements v1.PolicyInterface. All methods return +// Unimplemented because policy management requires a real gateway. +type fakePolicyClient struct { + closedFunc func() bool +} + +// newFakePolicyClient creates a new fakePolicyClient. +func newFakePolicyClient(closedFunc func() bool) *fakePolicyClient { + return &fakePolicyClient{closedFunc: closedFunc} +} + +// GetDraft returns Unimplemented. +func (c *fakePolicyClient) GetDraft(_ context.Context, _ string, _ ...v1.GetDraftOption) (*types.DraftPolicy, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraft is not supported by the fake client"} +} + +// ApproveDraftChunk returns Unimplemented. +func (c *fakePolicyClient) ApproveDraftChunk(_ context.Context, _, _ string) (*types.ApproveResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveDraftChunk is not supported by the fake client"} +} + +// RejectDraftChunk returns Unimplemented. +func (c *fakePolicyClient) RejectDraftChunk(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "RejectDraftChunk is not supported by the fake client"} +} + +// ApproveAllDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ApproveAllDraftChunks(_ context.Context, _ string, _ ...v1.ApproveAllOption) (*types.ApproveAllResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveAllDraftChunks is not supported by the fake client"} +} + +// ClearDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ClearDraftChunks(_ context.Context, _ string) (*types.ClearResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ClearDraftChunks is not supported by the fake client"} +} + +// GetDraftHistory returns Unimplemented. +func (c *fakePolicyClient) GetDraftHistory(_ context.Context, _ string) ([]types.DraftHistoryEntry, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraftHistory is not supported by the fake client"} +} + +// GetStatus returns Unimplemented. +func (c *fakePolicyClient) GetStatus(_ context.Context, _ string, _ ...v1.GetStatusOption) (*types.PolicyStatusResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetStatus is not supported by the fake client"} +} + +// List returns Unimplemented. +func (c *fakePolicyClient) List(_ context.Context, _ string, _ ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// EditDraftChunk returns Unimplemented. +func (c *fakePolicyClient) EditDraftChunk(_ context.Context, _, _ string, _ *types.NetworkPolicyRule) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "EditDraftChunk is not supported by the fake client"} +} + +// UndoDraftChunk returns Unimplemented. +func (c *fakePolicyClient) UndoDraftChunk(_ context.Context, _, _ string) (*types.UndoResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "UndoDraftChunk is not supported by the fake client"} +} + +// Compile-time check that fakePolicyClient implements v1.PolicyInterface. +var _ v1.PolicyInterface = (*fakePolicyClient)(nil) diff --git a/sdk/go/openshell/v1/fake/policy_test.go b/sdk/go/openshell/v1/fake/policy_test.go new file mode 100644 index 0000000000..20a247d9c0 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy_test.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T031: fakePolicyClient stub tests --- + +func TestFakePolicy_GetDraft_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraft(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_RejectDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.RejectDraftChunk(context.Background(), "sb-1", "chunk-1", "bad rule") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveAllDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveAllDraftChunks(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ClearDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ClearDraftChunks(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetDraftHistory_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraftHistory(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetStatus_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_List_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.List(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_EditDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.EditDraftChunk(context.Background(), "sb-1", "chunk-1", &types.NetworkPolicyRule{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_UndoDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.UndoDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +// --- Closed client tests --- + +func TestFakePolicy_GetDraft_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.GetDraft(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.ApproveDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_RejectDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + err := c.RejectDraftChunk(context.Background(), "sb-1", "chunk-1", "reason") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/profile.go b/sdk/go/openshell/v1/fake/profile.go new file mode 100644 index 0000000000..34c7395096 --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeProfileClient implements v1.ProfileInterface. All methods return +// Unimplemented because profile management requires a real server. +type fakeProfileClient struct { + closedFunc func() bool +} + +// newFakeProfileClient creates a new fakeProfileClient. +func newFakeProfileClient(closedFunc func() bool) *fakeProfileClient { + return &fakeProfileClient{closedFunc: closedFunc} +} + +// List returns Unimplemented. +func (c *fakeProfileClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeProfileClient) Get(_ context.Context, _ string) (*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// Import returns Unimplemented. +func (c *fakeProfileClient) Import(_ context.Context, _ []types.ProfileImportItem) (*types.ImportResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Import is not supported by the fake client"} +} + +// Update returns Unimplemented. +func (c *fakeProfileClient) Update(_ context.Context, _ string, _ uint64, _ types.ProfileImportItem) (*types.UpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Lint returns Unimplemented. +func (c *fakeProfileClient) Lint(_ context.Context, _ []types.ProfileImportItem) (*types.LintResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Lint is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeProfileClient) Delete(_ context.Context, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeProfileClient implements v1.ProfileInterface. +var _ v1.ProfileInterface = (*fakeProfileClient)(nil) diff --git a/sdk/go/openshell/v1/fake/profile_test.go b/sdk/go/openshell/v1/fake/profile_test.go new file mode 100644 index 0000000000..9dddc175f9 --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T027: fakeProfileClient stub tests --- + +func TestFakeProfile_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.List(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Get(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Import_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Import(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Update(context.Background(), "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Lint_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Lint(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.List(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Get(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Import_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Import(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Update(context.Background(), "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Lint_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Lint(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/provider.go b/sdk/go/openshell/v1/fake/provider.go new file mode 100644 index 0000000000..c492dea1bf --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// providerName extracts the name from a Provider pointer for use as the +// objectStore key function. +func providerName(p *types.Provider) string { + return p.Name +} + +// copyProvider returns a deep copy of a Provider pointer. All maps are +// duplicated to prevent aliasing. +func copyProvider(p *types.Provider) *types.Provider { + if p == nil { + return nil + } + cp := *p + cp.Labels = copyStringMap(p.Labels) + cp.Spec = copyProviderSpec(p.Spec) + return &cp +} + +func copyProviderSpec(s types.ProviderSpec) types.ProviderSpec { + s.Credentials = copyStringMap(s.Credentials) + s.Config = copyStringMap(s.Config) + s.CredentialExpiresAt = copyTimeMap(s.CredentialExpiresAt) + return s +} + +// copyTimeMap returns a shallow copy of a string-to-time.Time map. +func copyTimeMap(m map[string]time.Time) map[string]time.Time { + if m == nil { + return nil + } + cp := make(map[string]time.Time, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// fakeProviderClient implements v1.ProviderInterface backed by an in-memory +// objectStore. +type fakeProviderClient struct { + store *objectStore[*types.Provider] + closedFunc func() bool + profiles *fakeProfileClient + refresh *fakeRefreshClient +} + +// newFakeProviderClient creates a new fakeProviderClient. +func newFakeProviderClient( + store *objectStore[*types.Provider], + closedFunc func() bool, +) *fakeProviderClient { + return &fakeProviderClient{ + store: store, + closedFunc: closedFunc, + profiles: newFakeProfileClient(closedFunc), + refresh: newFakeRefreshClient(closedFunc), + } +} + +// Profiles returns a sub-client for provider profile operations. +func (c *fakeProviderClient) Profiles() v1.ProfileInterface { + return c.profiles +} + +// Refresh returns a sub-client for credential refresh operations. +func (c *fakeProviderClient) Refresh() v1.RefreshInterface { + return c.refresh +} + +// Create adds a new provider. CreatedAt and ResourceVersion are set +// automatically. +func (c *fakeProviderClient) Create(_ context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + p := copyProvider(provider) + p.CreatedAt = time.Now() + p.ResourceVersion = 1 + + return c.store.Create(p) +} + +// Get retrieves a provider by name. +func (c *fakeProviderClient) Get(_ context.Context, name string) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(name) +} + +// List returns all providers. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeProviderClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.List(), nil +} + +// Update replaces an existing provider's data. ResourceVersion is +// incremented automatically. +func (c *fakeProviderClient) Update(_ context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + // Fetch existing to preserve CreatedAt and increment ResourceVersion + existing, err := c.store.Get(provider.Name) + if err != nil { + return nil, err + } + + p := copyProvider(provider) + p.CreatedAt = existing.CreatedAt + p.ResourceVersion = existing.ResourceVersion + 1 + + return c.store.Update(p) +} + +// Delete removes a provider by name. The operation is idempotent. +func (c *fakeProviderClient) Delete(_ context.Context, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + c.store.Delete(name) + return nil +} + +// Ensure creates a provider if it does not exist, or updates it if it does. +func (c *fakeProviderClient) Ensure(ctx context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + _, err := c.store.Get(provider.Name) + if err != nil { + // Not found — create + if types.IsNotFound(err) { + return c.Create(ctx, provider) + } + return nil, err + } + // Exists — update + return c.Update(ctx, provider) +} diff --git a/sdk/go/openshell/v1/fake/provider_test.go b/sdk/go/openshell/v1/fake/provider_test.go new file mode 100644 index 0000000000..b02ab36388 --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider_test.go @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake provider client for testing. +func newTestProviderClient() *fakeProviderClient { + store := newobjectStore(providerName, copyProvider) + return newFakeProviderClient(store, func() bool { return false }) +} + +// --- T015: Provider CRUD tests --- + +func TestProvider_Create(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{ + Credentials: map[string]string{"api_key": "sk-test"}, + Config: map[string]string{"model": "gpt-4"}, + }, + } + + result, err := pc.Create(ctx, p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "openai", result.Type) + assert.Equal(t, "sk-test", result.Spec.Credentials["api_key"]) + assert.NotZero(t, result.CreatedAt) + assert.Equal(t, uint64(1), result.ResourceVersion) +} + +func TestProvider_Create_AlreadyExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{Name: "openai", Type: "openai"} + _, err := pc.Create(ctx, p) + require.NoError(t, err) + + _, err = pc.Create(ctx, p) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestProvider_Get(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai", Type: "openai"}) + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) +} + +func TestProvider_Get_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Get(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_List_Empty(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + list, err := pc.List(ctx) + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestProvider_List(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai", Type: "openai"}) + _, _ = pc.Create(ctx, &types.Provider{Name: "anthropic", Type: "anthropic"}) + + list, err := pc.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestProvider_Update(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + updated, err := pc.Update(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", updated.Spec.Config["model"]) + assert.Equal(t, uint64(2), updated.ResourceVersion) +} + +func TestProvider_Update_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Update(ctx, &types.Provider{Name: "nonexistent"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai"}) + + err := pc.Delete(ctx, "openai") + require.NoError(t, err) + + _, err = pc.Get(ctx, "openai") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete_Idempotent(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + err := pc.Delete(ctx, "nonexistent") + require.NoError(t, err) +} + +func TestProvider_Ensure_CreatesIfMissing(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + result, err := pc.Ensure(ctx, p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) + + // Verify it was stored + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestProvider_Ensure_UpdatesIfExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + result, err := pc.Ensure(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) +} + +func TestProvider_DeepCopy_OnCreate(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + spec := types.ProviderSpec{ + Credentials: map[string]string{"key": "secret"}, + Config: map[string]string{"model": "gpt-4"}, + CredentialExpiresAt: map[string]time.Time{"key": time.Now()}, + } + p := &types.Provider{ + Name: "openai", + Labels: map[string]string{"env": "test"}, + Spec: spec, + } + + result, err := pc.Create(ctx, p) + require.NoError(t, err) + + // Mutate inputs + p.Labels["env"] = "mutated" + p.Spec.Credentials["key"] = "mutated" + p.Spec.Config["model"] = "mutated" + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "secret", got.Spec.Credentials["key"]) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) + + // Mutate returned object + result.Labels["env"] = "mutated-return" + got2, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestProvider_DeepCopy_OnGet(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + + got.Spec.Config["model"] = "mutated" + + got2, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got2.Spec.Config["model"]) +} + +// --- T020: Concurrent provider access tests --- + +func TestProvider_ConcurrentCreateGetListDeleteEnsure(_ *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("prov-%d-%d", id, j) + p := &types.Provider{Name: name, Type: "test"} + _, _ = pc.Create(ctx, p) + _, _ = pc.Get(ctx, name) + _, _ = pc.List(ctx) + _, _ = pc.Update(ctx, &types.Provider{Name: name, Type: "updated"}) + _, _ = pc.Ensure(ctx, &types.Provider{Name: name, Type: "ensured"}) + _ = pc.Delete(ctx, name) + } + }(i) + } + wg.Wait() +} diff --git a/sdk/go/openshell/v1/fake/refresh.go b/sdk/go/openshell/v1/fake/refresh.go new file mode 100644 index 0000000000..6f1dedd1ef --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeRefreshClient implements v1.RefreshInterface. All methods return +// Unimplemented because credential refresh requires a real server. +type fakeRefreshClient struct { + closedFunc func() bool +} + +// newFakeRefreshClient creates a new fakeRefreshClient. +func newFakeRefreshClient(closedFunc func() bool) *fakeRefreshClient { + return &fakeRefreshClient{closedFunc: closedFunc} +} + +// GetStatus returns Unimplemented. +func (c *fakeRefreshClient) GetStatus(_ context.Context, _, _ string) ([]*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetStatus is not supported by the fake client"} +} + +// Configure returns Unimplemented. +func (c *fakeRefreshClient) Configure(_ context.Context, _ *types.RefreshConfig) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Configure is not supported by the fake client"} +} + +// Rotate returns Unimplemented. +func (c *fakeRefreshClient) Rotate(_ context.Context, _, _ string) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Rotate is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeRefreshClient) Delete(_ context.Context, _, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeRefreshClient implements v1.RefreshInterface. +var _ v1.RefreshInterface = (*fakeRefreshClient)(nil) diff --git a/sdk/go/openshell/v1/fake/refresh_test.go b/sdk/go/openshell/v1/fake/refresh_test.go new file mode 100644 index 0000000000..bc131816d3 --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T028: fakeRefreshClient stub tests --- + +func TestFakeRefresh_GetStatus_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Configure_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Configure(context.Background(), &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Rotate_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Rotate(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_GetStatus_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.GetStatus(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Configure_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Configure(context.Background(), &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Rotate_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Rotate(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go new file mode 100644 index 0000000000..0e1a97c138 --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -0,0 +1,504 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// sandboxName extracts the name from a Sandbox pointer for use as the +// objectStore key function. +func sandboxName(sb *types.Sandbox) string { + return sb.Name +} + +// copySandbox returns a deep copy of a Sandbox pointer. All maps, slices, +// and nested pointer fields are duplicated to prevent aliasing. +func copySandbox(sb *types.Sandbox) *types.Sandbox { + if sb == nil { + return nil + } + cp := *sb + cp.Labels = copyStringMap(sb.Labels) + cp.Spec = copySandboxSpec(sb.Spec) + cp.Status = copySandboxStatus(sb.Status) + return &cp +} + +func copySandboxSpec(s types.SandboxSpec) types.SandboxSpec { + s.Environment = copyStringMap(s.Environment) + s.Providers = copyStringSlice(s.Providers) + if s.Template != nil { + t := copySandboxTemplate(*s.Template) + s.Template = &t + } + if s.GPUCount != nil { + v := *s.GPUCount + s.GPUCount = &v + } + s.Policy = copySandboxPolicy(s.Policy) + return s +} + +// copySandboxPolicy returns a deep copy of a SandboxPolicy pointer. +// All sub-policies, slices, and map entries are duplicated. +func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { + if p == nil { + return nil + } + cp := *p + if p.Filesystem != nil { + fs := *p.Filesystem + fs.ReadOnly = copyStringSlice(p.Filesystem.ReadOnly) + fs.ReadWrite = copyStringSlice(p.Filesystem.ReadWrite) + cp.Filesystem = &fs + } + if p.Landlock != nil { + ll := *p.Landlock + cp.Landlock = &ll + } + if p.Process != nil { + pr := *p.Process + cp.Process = &pr + } + if p.NetworkPolicies != nil { + np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) + for k, rule := range p.NetworkPolicies { + r := rule + if rule.Endpoints != nil { + eps := make([]types.PolicyNetworkEndpoint, len(rule.Endpoints)) + for i, ep := range rule.Endpoints { + eps[i] = copyPolicyNetworkEndpoint(ep) + } + r.Endpoints = eps + } + if rule.Binaries != nil { + bins := make([]types.PolicyNetworkBinary, len(rule.Binaries)) + copy(bins, rule.Binaries) + r.Binaries = bins + } + np[k] = r + } + cp.NetworkPolicies = np + } + return &cp +} + +func copyPolicyNetworkEndpoint(ep types.PolicyNetworkEndpoint) types.PolicyNetworkEndpoint { + if ep.Ports != nil { + ports := make([]uint32, len(ep.Ports)) + copy(ports, ep.Ports) + ep.Ports = ports + } + if ep.Rules != nil { + rules := make([]types.L7Rule, len(ep.Rules)) + for i, r := range ep.Rules { + if r.Allow != nil { + a := *r.Allow + a.Query = copyL7QueryMap(r.Allow.Query) + a.Fields = copyStringSlice(r.Allow.Fields) + rules[i] = types.L7Rule{Allow: &a} + } + } + ep.Rules = rules + } + ep.AllowedIPs = copyStringSlice(ep.AllowedIPs) + if ep.DenyRules != nil { + drs := make([]types.L7DenyRule, len(ep.DenyRules)) + for i, dr := range ep.DenyRules { + dr.Query = copyL7QueryMap(dr.Query) + dr.Fields = copyStringSlice(dr.Fields) + drs[i] = dr + } + ep.DenyRules = drs + } + if ep.GraphqlPersistedQueries != nil { + gq := make(map[string]types.GraphqlOperation, len(ep.GraphqlPersistedQueries)) + for k, v := range ep.GraphqlPersistedQueries { + v.Fields = copyStringSlice(v.Fields) + gq[k] = v + } + ep.GraphqlPersistedQueries = gq + } + return ep +} + +func copyL7QueryMap(m map[string]types.L7QueryMatcher) map[string]types.L7QueryMatcher { + if m == nil { + return nil + } + cp := make(map[string]types.L7QueryMatcher, len(m)) + for k, v := range m { + v.Any = copyStringSlice(v.Any) + cp[k] = v + } + return cp +} + +func copySandboxTemplate(t types.SandboxTemplate) types.SandboxTemplate { + t.Labels = copyStringMap(t.Labels) + t.Annotations = copyStringMap(t.Annotations) + t.Environment = copyStringMap(t.Environment) + if t.UserNamespaces != nil { + v := *t.UserNamespaces + t.UserNamespaces = &v + } + return t +} + +func copySandboxStatus(s types.SandboxStatus) types.SandboxStatus { + if s.Conditions != nil { + conds := make([]types.SandboxCondition, len(s.Conditions)) + copy(conds, s.Conditions) + s.Conditions = conds + } + return s +} + +// copyStringMap returns a shallow copy of a string-to-string map. +func copyStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + cp := make(map[string]string, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// copyStringSlice returns a copy of a string slice. +func copyStringSlice(s []string) []string { + if s == nil { + return nil + } + cp := make([]string, len(s)) + copy(cp, s) + return cp +} + +// fakeSandboxClient implements v1.SandboxInterface backed by an in-memory +// objectStore and watchBroadcaster. +type fakeSandboxClient struct { + store *objectStore[*types.Sandbox] + broadcaster *watchBroadcaster[*types.Sandbox] + closedFunc func() bool +} + +// newFakeSandboxClient creates a new fakeSandboxClient. +func newFakeSandboxClient( + store *objectStore[*types.Sandbox], + broadcaster *watchBroadcaster[*types.Sandbox], + closedFunc func() bool, +) *fakeSandboxClient { + return &fakeSandboxClient{ + store: store, + broadcaster: broadcaster, + closedFunc: closedFunc, + } +} + +// Create creates a new sandbox with Provisioning phase. +func (c *fakeSandboxClient) Create(_ context.Context, name string, spec *types.SandboxSpec, labels map[string]string) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if spec == nil { + spec = &types.SandboxSpec{} + } + + sb := &types.Sandbox{ + Name: name, + CreatedAt: time.Now(), + Labels: copyStringMap(labels), + ResourceVersion: 1, + Spec: copySandboxSpec(*spec), + Status: types.SandboxStatus{ + SandboxName: name, + Phase: types.SandboxProvisioning, + }, + } + + result, err := c.store.Create(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventAdded, + Object: copySandbox(result), + }, name) + + return result, nil +} + +// Get retrieves a sandbox by name. +func (c *fakeSandboxClient) Get(_ context.Context, name string) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(name) +} + +// List returns all sandboxes. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeSandboxClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.List(), nil +} + +// Delete removes a sandbox by name. The operation is idempotent. +func (c *fakeSandboxClient) Delete(_ context.Context, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + // Atomically remove and retrieve the last-known object for the DELETED event. + deleted, existed := c.store.DeleteAndGet(name) + if !existed { + // Not found — idempotent delete + return nil + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventDeleted, + Object: deleted, + }, name) + + return nil +} + +// WaitReady transitions a sandbox to the Ready phase. In the fake +// implementation this happens synchronously — context cancellation is +// checked first to support timeout testing. +func (c *fakeSandboxClient) WaitReady(ctx context.Context, name string, _ ...v1.WaitOptions) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + // Check context before proceeding + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + sb, err := c.store.Get(name) + if err != nil { + return nil, err + } + + // If already ready, return immediately + if sb.Status.Phase == types.SandboxReady { + return sb, nil + } + + // Transition to Ready + sb.Status.Phase = types.SandboxReady + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, fmt.Errorf("updating sandbox phase: %w", err) + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, name) + + return updated, nil +} + +// Watch registers a watcher for sandbox events. If name is non-empty, only +// events for that sandbox are delivered. When StopOnTerminal is set, the +// watcher auto-closes after delivering a terminal phase event (SandboxReady +// or SandboxError). +func (c *fakeSandboxClient) Watch(_ context.Context, name string, opts ...v1.WatchOptions) (types.WatchInterface[*types.Sandbox], error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + inner := c.broadcaster.Watch(name) + + var stopOnTerminal bool + if len(opts) > 0 { + stopOnTerminal = opts[0].StopOnTerminal + } + + if !stopOnTerminal { + return inner, nil + } + + // Wrap with a filtering watcher that auto-stops after terminal events. + out := make(chan types.Event[*types.Sandbox], watchChannelBuffer) + tw := &terminalWatcher{ + ch: out, + inner: inner, + } + go func() { + defer close(out) + for ev := range inner.ResultChan() { + select { + case out <- ev: + default: + } + if ev.Object != nil && + (ev.Object.Status.Phase == types.SandboxReady || ev.Object.Status.Phase == types.SandboxError) { + inner.Stop() + return + } + } + }() + return tw, nil +} + +// terminalWatcher wraps an inner watcher and exposes its own output channel. +type terminalWatcher struct { + ch chan types.Event[*types.Sandbox] + inner types.WatchInterface[*types.Sandbox] + once sync.Once +} + +func (w *terminalWatcher) ResultChan() <-chan types.Event[*types.Sandbox] { + return w.ch +} + +func (w *terminalWatcher) Stop() { + w.once.Do(func() { + w.inner.Stop() + }) +} + +// AttachProvider adds a provider name to the sandbox's Spec.Providers list. +// If the provider is already attached, Attached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast. +func (c *fakeSandboxClient) AttachProvider(_ context.Context, sandboxName, providerName string, _ uint64) (*types.AttachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + // Check if already attached + for _, p := range sb.Spec.Providers { + if p == providerName { + return &types.AttachProviderResult{ + Sandbox: sb, + Attached: false, + }, nil + } + } + + sb.Spec.Providers = append(sb.Spec.Providers, providerName) + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.AttachProviderResult{ + Sandbox: updated, + Attached: true, + }, nil +} + +// DetachProvider removes a provider name from the sandbox's Spec.Providers +// list. If the provider is not attached, Detached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast when a provider is actually removed. +func (c *fakeSandboxClient) DetachProvider(_ context.Context, sandboxName, providerName string, _ uint64) (*types.DetachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + // Find and remove the provider + found := false + providers := make([]string, 0, len(sb.Spec.Providers)) + for _, p := range sb.Spec.Providers { + if p == providerName { + found = true + continue + } + providers = append(providers, p) + } + + if !found { + return &types.DetachProviderResult{ + Sandbox: sb, + Detached: false, + }, nil + } + + sb.Spec.Providers = providers + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.DetachProviderResult{ + Sandbox: updated, + Detached: true, + }, nil +} + +// GetLogs returns Unimplemented — fake log retrieval is not yet supported. +func (c *fakeSandboxClient) GetLogs(_ context.Context, _ string, _ ...v1.LogOption) (*types.LogResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetLogs not implemented in fake client"} +} + +// ListProviders returns stub Provider objects for each provider name +// attached to the sandbox. The returned providers contain only the Name +// field, since the fake client does not maintain a full provider registry +// per sandbox. +func (c *fakeSandboxClient) ListProviders(_ context.Context, sandboxName string) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + result := make([]*types.Provider, len(sb.Spec.Providers)) + for i, name := range sb.Spec.Providers { + result[i] = &types.Provider{Name: name} + } + return result, nil +} diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go new file mode 100644 index 0000000000..635455067a --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -0,0 +1,814 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake sandbox client for testing. +func newTestSandboxClient() *fakeSandboxClient { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + return newFakeSandboxClient(store, broadcaster, func() bool { return false }) +} + +// --- T008: Sandbox CRUD tests --- + +func TestSandbox_Create(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) + assert.Equal(t, "debug", sb.Spec.LogLevel) + assert.Equal(t, "test", sb.Labels["env"]) + assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) + assert.NotZero(t, sb.CreatedAt) + assert.Equal(t, uint64(1), sb.ResourceVersion) +} + +func TestSandbox_Create_AlreadyExists(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + _, err = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestSandbox_Create_NilSpec(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", nil, nil) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) +} + +func TestSandbox_Get(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test-sb", got.Name) + assert.Equal(t, "info", got.Spec.LogLevel) +} + +func TestSandbox_Get_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Get(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_List_Empty(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + list, err := sc.List(ctx) + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestSandbox_List(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "sb-1", &types.SandboxSpec{}, nil) + _, _ = sc.Create(ctx, "sb-2", &types.SandboxSpec{}, nil) + + list, err := sc.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestSandbox_Delete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + err := sc.Delete(ctx, "test-sb") + require.NoError(t, err) + + _, err = sc.Get(ctx, "test-sb") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_Delete_Idempotent(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Delete non-existent sandbox should not error + err := sc.Delete(ctx, "nonexistent") + require.NoError(t, err) +} + +func TestSandbox_DeepCopy_OnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + labels := map[string]string{"env": "test"} + spec := &types.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"KEY": "value"}, + } + + sb, err := sc.Create(ctx, "test-sb", spec, labels) + require.NoError(t, err) + + // Mutating inputs should not affect stored object + labels["env"] = "mutated" + spec.LogLevel = "mutated" + spec.Environment["KEY"] = "mutated" + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, "value", got.Spec.Environment["KEY"]) + + // Mutating returned object should not affect stored object + sb.Labels["env"] = "mutated-return" + got2, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestSandbox_DeepCopy_OnGet(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{ + Environment: map[string]string{"KEY": "value"}, + }, nil) + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + + got.Spec.Environment["KEY"] = "mutated" + + got2, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "value", got2.Spec.Environment["KEY"]) +} + +// --- T009: WaitReady tests --- + +func TestSandbox_WaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + sb, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) + + // Verify the store is also updated + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestSandbox_WaitReady_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.WaitReady(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_WaitReady_ContextCancellation(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err = sc.WaitReady(ctx, "test-sb") + require.Error(t, err) + // Should return a context error, not a status error + assert.ErrorIs(t, err, context.Canceled) +} + +func TestSandbox_WaitReady_AlreadyReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + // Make it ready + _, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // WaitReady on an already-ready sandbox should return immediately + sb, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) +} + +func TestSandbox_WaitReady_IncrementsResourceVersion(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + initialVersion := created.ResourceVersion + + ready, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Greater(t, ready.ResourceVersion, initialVersion) +} + +func TestSandbox_WaitReady_ContextTimeout(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + // Override the sandbox phase to Error so WaitReady doesn't auto-transition + // Actually, with our simple fake, WaitReady transitions immediately unless context is done. + // So just test the context-cancelled path: + cancel() + _, err = sc.WaitReady(ctx, "test-sb") + require.Error(t, err) +} + +// --- T011: Watch tests --- + +func TestSandbox_Watch_AddedOnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + assert.Equal(t, "info", ev.Object.Spec.LogLevel) + case <-time.After(time.Second): + t.Fatal("timed out waiting for ADDED event") + } +} + +func TestSandbox_Watch_DeletedOnDelete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + err = sc.Delete(ctx, "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +func TestSandbox_Watch_ModifiedOnWaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event") + } +} + +func TestSandbox_Watch_NameFiltering(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Watch only "alpha" + w, err := sc.Watch(ctx, "alpha") + require.NoError(t, err) + defer w.Stop() + + // Create "beta" — should not be received + _, _ = sc.Create(ctx, "beta", &types.SandboxSpec{}, nil) + + // Create "alpha" — should be received + _, _ = sc.Create(ctx, "alpha", &types.SandboxSpec{}, nil) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for filtered event") + } +} + +func TestSandbox_Watch_MultipleWatchers(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w1, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w1.Stop() + + w2, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w2.Stop() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + for _, w := range []types.WatchInterface[*types.Sandbox]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on watcher") + } + } +} + +func TestSandbox_Watch_StopClosesChannel(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + + w.Stop() + + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestSandbox_Watch_DeletedEventContainsFullObject(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _ = sc.Delete(ctx, "test-sb") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + // Verify the DELETED event contains the full last-known object + assert.Equal(t, "debug", ev.Object.Spec.LogLevel) + assert.Equal(t, "test", ev.Object.Labels["env"]) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +// --- T019: Concurrent sandbox access tests --- + +func TestSandbox_ConcurrentCreateGetDeleteWatch(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + // Start a watcher to exercise broadcast under concurrency + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + // Drain watcher events in a background goroutine + done := make(chan struct{}) + go func() { + defer close(done) + for range w.ResultChan() { //nolint:revive // intentionally draining channel + } + }() + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("sb-%d-%d", id, j) + _, _ = sc.Create(ctx, name, &types.SandboxSpec{LogLevel: "info"}, nil) + _, _ = sc.Get(ctx, name) + _, _ = sc.List(ctx) + _, _ = sc.WaitReady(ctx, name) + _ = sc.Delete(ctx, name) + } + }(i) + } + wg.Wait() + + // Stop watcher and wait for drain goroutine + w.Stop() + <-done +} + +// --- T026: AttachProvider / DetachProvider / ListProviders tests --- + +func TestSandbox_AttachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + assert.Equal(t, "test-sb", result.Sandbox.Name) + assert.Contains(t, result.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_AttachProvider_AlreadyAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + + // Attach again — should return Attached=false (idempotent, already attached) + result2, err := sc.AttachProvider(ctx, "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.False(t, result2.Attached) +} + +func TestSandbox_AttachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.AttachProvider(ctx, "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_DetachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + detach, err := sc.DetachProvider(ctx, "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.True(t, detach.Detached) + assert.NotContains(t, detach.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_DetachProvider_NotAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.DetachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.False(t, result.Detached) +} + +func TestSandbox_DetachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.DetachProvider(ctx, "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_ListProviders(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // No providers yet + providers, err := sc.ListProviders(ctx, "test-sb") + require.NoError(t, err) + assert.Empty(t, providers) + + // Attach two providers + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + _, err = sc.AttachProvider(ctx, "test-sb", "anthropic", result.Sandbox.ResourceVersion) + require.NoError(t, err) + + providers, err = sc.ListProviders(ctx, "test-sb") + require.NoError(t, err) + assert.Len(t, providers, 2) + + names := make([]string, len(providers)) + for i, p := range providers { + names[i] = p.Name + } + assert.Contains(t, names, "openai") + assert.Contains(t, names, "anthropic") +} + +func TestSandbox_ListProviders_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.ListProviders(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_AttachProvider_BroadcastsModified(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Contains(t, ev.Object.Spec.Providers, "openai") + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event from AttachProvider") + } +} + +// --- T033: StopOnTerminal tests for fake Watch --- + +func TestSandbox_Watch_StopOnTerminal_Ready(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Transition to Ready — this broadcasts a MODIFIED event with SandboxReady phase + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // Should receive the Ready event + var gotReady bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxReady { + gotReady = true + } + } + // Channel should be closed after the terminal event + assert.True(t, gotReady, "expected to receive a Ready event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_Error(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Manually transition to Error phase via store update + broadcast + sb.Status.Phase = types.SandboxError + sb.ResourceVersion++ + updated, err := sc.store.Update(sb) + require.NoError(t, err) + sc.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, "test-sb") + + // Should receive the Error event and then the channel closes + var gotError bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxError { + gotError = true + } + } + assert.True(t, gotError, "expected to receive an Error event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_False_DoesNotClose(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // Watch WITHOUT StopOnTerminal + w, err := sc.Watch(ctx, "test-sb") + require.NoError(t, err) + defer w.Stop() + + // Transition to Ready + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // Receive the Ready event + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for Ready event") + } + + // Channel should still be open — verify by checking no close + select { + case _, ok := <-w.ResultChan(): + if !ok { + t.Fatal("channel closed unexpectedly when StopOnTerminal was not set") + } + // Got another event, that's fine + case <-time.After(100 * time.Millisecond): + // No event and not closed — correct behavior + } +} + +// --- T016: Sandbox Create with Policy --- + +func TestFakeSandboxCreateWithPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + spec := &types.SandboxSpec{ + LogLevel: "debug", + Policy: &types.SandboxPolicy{ + Version: 3, + Filesystem: &types.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp"}, + }, + Landlock: &types.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &types.ProcessPolicy{ + RunAsUser: "sandbox", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]types.NetworkPolicyRule{ + "web": { + Name: "web", + Endpoints: []types.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + }, + }, + } + + created, err := sc.Create(ctx, "policy-sb", spec, nil) + require.NoError(t, err) + + // Verify created sandbox has policy + require.NotNil(t, created.Spec.Policy) + assert.Equal(t, uint32(3), created.Spec.Policy.Version) + + // Get it back and verify all fields + got, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + require.NotNil(t, got.Spec.Policy) + + p := got.Spec.Policy + assert.Equal(t, uint32(3), p.Version) + + require.NotNil(t, p.Filesystem) + assert.True(t, p.Filesystem.IncludeWorkdir) + assert.Equal(t, []string{"/etc", "/usr/share"}, p.Filesystem.ReadOnly) + assert.Equal(t, []string{"/tmp"}, p.Filesystem.ReadWrite) + + require.NotNil(t, p.Landlock) + assert.Equal(t, "best_effort", p.Landlock.Compatibility) + + require.NotNil(t, p.Process) + assert.Equal(t, "sandbox", p.Process.RunAsUser) + assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + + require.Len(t, p.NetworkPolicies, 1) + webRule, ok := p.NetworkPolicies["web"] + require.True(t, ok) + assert.Equal(t, "web", webRule.Name) + require.Len(t, webRule.Endpoints, 1) + assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host) + assert.Equal(t, uint32(443), webRule.Endpoints[0].Port) + + // Deep-copy isolation: mutate input spec, verify stored copy unchanged + spec.Policy.Version = 99 + spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} + + got2, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + assert.Equal(t, uint32(3), got2.Spec.Policy.Version) + assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) + + // Deep-copy isolation: mutate returned object, verify store unchanged + got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got3, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) +} + +func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "no-policy-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + assert.Nil(t, created.Spec.Policy) + + got, err := sc.Get(ctx, "no-policy-sb") + require.NoError(t, err) + assert.Nil(t, got.Spec.Policy) +} + +// --- T032: GetLogs stub tests --- + +func TestSandbox_GetLogs_ReturnsUnimplemented(t *testing.T) { + sc := newTestSandboxClient() + _, err := sc.GetLogs(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestSandbox_GetLogs_ClosedReturnsUnavailable(t *testing.T) { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + sc := newFakeSandboxClient(store, broadcaster, func() bool { return true }) + _, err := sc.GetLogs(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/service.go b/sdk/go/openshell/v1/fake/service.go new file mode 100644 index 0000000000..3df23520cc --- /dev/null +++ b/sdk/go/openshell/v1/fake/service.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeServiceClient implements v1.ServiceInterface. All methods return +// Unimplemented because service exposure requires a real sandbox runtime. +type fakeServiceClient struct { + closedFunc func() bool +} + +// newFakeServiceClient creates a new fakeServiceClient. +func newFakeServiceClient(closedFunc func() bool) *fakeServiceClient { + return &fakeServiceClient{closedFunc: closedFunc} +} + +// Expose returns Unimplemented. +func (c *fakeServiceClient) Expose(_ context.Context, _, _ string, _ uint32, _ bool) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Expose is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeServiceClient) Get(_ context.Context, _, _ string) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// List returns Unimplemented. +func (c *fakeServiceClient) List(_ context.Context, _ string, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeServiceClient) Delete(_ context.Context, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeServiceClient implements v1.ServiceInterface. +var _ v1.ServiceInterface = (*fakeServiceClient)(nil) diff --git a/sdk/go/openshell/v1/fake/service_test.go b/sdk/go/openshell/v1/fake/service_test.go new file mode 100644 index 0000000000..9744ce5cb1 --- /dev/null +++ b/sdk/go/openshell/v1/fake/service_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T026: fakeServiceClient stub tests --- + +func TestFakeService_Expose_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Expose(context.Background(), "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Get(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.List(context.Background(), "sb1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + err := c.Delete(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Expose_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Expose(context.Background(), "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Get(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.List(context.Background(), "sb1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + err := c.Delete(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/ssh.go b/sdk/go/openshell/v1/fake/ssh.go new file mode 100644 index 0000000000..654a5ab102 --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeSSHClient implements v1.SSHInterface. All methods return +// Unimplemented because SSH session management requires a real gateway. +type fakeSSHClient struct { + closedFunc func() bool +} + +// newFakeSSHClient creates a new fakeSSHClient. +func newFakeSSHClient(closedFunc func() bool) *fakeSSHClient { + return &fakeSSHClient{closedFunc: closedFunc} +} + +// CreateSession returns Unimplemented. +func (c *fakeSSHClient) CreateSession(_ context.Context, _ string) (*types.SSHSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "CreateSession is not supported by the fake client"} +} + +// RevokeSession returns Unimplemented. +func (c *fakeSSHClient) RevokeSession(_ context.Context, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "RevokeSession is not supported by the fake client"} +} + +// Tunnel returns Unimplemented. Ports outside 1-65535 and empty sandbox names +// are rejected with InvalidArgument to match the real client's behavior. +func (c *fakeSSHClient) Tunnel(_ context.Context, sandboxName string, port uint32, _ ...v1.TunnelOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Tunnel is not supported by the fake client"} +} + +// Compile-time check that fakeSSHClient implements v1.SSHInterface. +var _ v1.SSHInterface = (*fakeSSHClient)(nil) diff --git a/sdk/go/openshell/v1/fake/ssh_test.go b/sdk/go/openshell/v1/fake/ssh_test.go new file mode 100644 index 0000000000..fd18b84ffe --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T018: fakeSSHClient stub tests --- + +func TestFakeSSH_CreateSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.CreateSession(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_RevokeSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.RevokeSession(context.Background(), "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_CreateSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.CreateSession(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeSSH_RevokeSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.RevokeSession(context.Background(), "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T014: Fake Tunnel tests --- + +func TestFakeSSH_Tunnel_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_WithTunnelOption(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22, v1.WithTunnelServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_InvalidPort(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + + _, err := c.Tunnel(context.Background(), "my-sandbox", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + + _, err = c.Tunnel(context.Background(), "my-sandbox", 65536) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_EmptySandboxName(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "", 22) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/store.go b/sdk/go/openshell/v1/fake/store.go new file mode 100644 index 0000000000..e5c64c6441 --- /dev/null +++ b/sdk/go/openshell/v1/fake/store.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "fmt" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// objectStore is a generic, thread-safe, in-memory store for named objects. +// It deep-copies objects at all boundaries (insert and retrieval) to prevent +// callers from mutating internal state. +type objectStore[T any] struct { + mu sync.RWMutex + items map[string]T + nameFunc func(T) string + copyFunc func(T) T +} + +// newobjectStore creates a new objectStore with the given name-extraction +// and deep-copy functions. +func newobjectStore[T any](nameFunc func(T) string, copyFunc func(T) T) *objectStore[T] { + return &objectStore[T]{ + items: make(map[string]T), + nameFunc: nameFunc, + copyFunc: copyFunc, + } +} + +// Create adds a new object to the store. Returns AlreadyExists if an object +// with the same name already exists. The object is deep-copied on insert and +// a deep copy is returned. +func (s *objectStore[T]) Create(obj T) (T, error) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[name]; exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorAlreadyExists, + Message: fmt.Sprintf("%s already exists", name), + } + } + + stored := s.copyFunc(obj) + s.items[name] = stored + return s.copyFunc(stored), nil +} + +// Get retrieves an object by name. Returns NotFound if the object does not exist. +// The returned object is a deep copy. +func (s *objectStore[T]) Get(name string) (T, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + obj, exists := s.items[name] + if !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + return s.copyFunc(obj), nil +} + +// List returns deep copies of all objects in the store. The order is not +// guaranteed. +func (s *objectStore[T]) List() []T { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]T, 0, len(s.items)) + for _, obj := range s.items { + result = append(result, s.copyFunc(obj)) + } + return result +} + +// Update replaces an existing object in the store. Returns NotFound if the +// object does not exist. The object is deep-copied on insert and a deep copy +// is returned. +func (s *objectStore[T]) Update(obj T) (T, error) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[name]; !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + + stored := s.copyFunc(obj) + s.items[name] = stored + return s.copyFunc(stored), nil +} + +// Delete removes an object from the store by name. The operation is +// idempotent — deleting a non-existent object is a no-op. +func (s *objectStore[T]) Delete(name string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, name) +} + +// DeleteAndGet atomically removes an object from the store and returns a +// deep copy of the removed object. Returns the zero value and false if the +// object did not exist. This avoids the race between a separate Get+Delete. +func (s *objectStore[T]) DeleteAndGet(name string) (T, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + obj, exists := s.items[name] + if !exists { + var zero T + return zero, false + } + delete(s.items, name) + return s.copyFunc(obj), true +} + +// Insert directly places an object into the store without checking for +// duplicates. This is intended for pre-seeding test fixtures. The object +// is deep-copied on insert. +func (s *objectStore[T]) Insert(obj T) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + s.items[name] = s.copyFunc(obj) +} diff --git a/sdk/go/openshell/v1/fake/store_test.go b/sdk/go/openshell/v1/fake/store_test.go new file mode 100644 index 0000000000..1eb738451d --- /dev/null +++ b/sdk/go/openshell/v1/fake/store_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// testItem is a simple struct used for objectStore tests. +type testItem struct { + Name string + Value string + Tags map[string]string +} + +func testItemName(t *testItem) string { return t.Name } + +func copyTestItem(t *testItem) *testItem { + if t == nil { + return nil + } + c := *t + if t.Tags != nil { + c.Tags = make(map[string]string, len(t.Tags)) + for k, v := range t.Tags { + c.Tags[k] = v + } + } + return &c +} + +func newTestStore() *objectStore[*testItem] { + return newobjectStore(testItemName, copyTestItem) +} + +// --- T004: objectStore CRUD tests --- + +func TestObjectStore_Create(t *testing.T) { + s := newTestStore() + + item := &testItem{Name: "alpha", Value: "v1"} + created, err := s.Create(item) + require.NoError(t, err) + assert.Equal(t, "alpha", created.Name) + assert.Equal(t, "v1", created.Value) +} + +func TestObjectStore_Create_AlreadyExists(t *testing.T) { + s := newTestStore() + + _, err := s.Create(&testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + _, err = s.Create(&testItem{Name: "alpha", Value: "v2"}) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err), "expected AlreadyExists error, got: %v", err) +} + +func TestObjectStore_Get(t *testing.T) { + s := newTestStore() + + _, err := s.Create(&testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "alpha", got.Name) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Get_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Get("nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_List_Empty(t *testing.T) { + s := newTestStore() + items := s.List() + assert.Empty(t, items) +} + +func TestObjectStore_List(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + _, _ = s.Create(&testItem{Name: "beta", Value: "v2"}) + + items := s.List() + assert.Len(t, items, 2) + + // Sort for deterministic comparison + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + assert.Equal(t, "alpha", items[0].Name) + assert.Equal(t, "beta", items[1].Name) +} + +func TestObjectStore_Update(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + + updated, err := s.Update(&testItem{Name: "alpha", Value: "v2"}) + require.NoError(t, err) + assert.Equal(t, "v2", updated.Value) + + // Verify the stored value is updated + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_Update_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Update(&testItem{Name: "nonexistent", Value: "v1"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_Delete(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + s.Delete("alpha") + + _, err := s.Get("alpha") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestObjectStore_Delete_Idempotent(_ *testing.T) { + s := newTestStore() + + // Deleting a non-existent item should not panic or error + s.Delete("nonexistent") + + // Create and delete twice + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + s.Delete("alpha") + s.Delete("alpha") // second delete should be idempotent +} + +func TestObjectStore_Insert(t *testing.T) { + s := newTestStore() + + // Insert bypasses duplicate checks (for pre-seeding) + s.Insert(&testItem{Name: "alpha", Value: "v1"}) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Insert_Overwrites(t *testing.T) { + s := newTestStore() + + s.Insert(&testItem{Name: "alpha", Value: "v1"}) + s.Insert(&testItem{Name: "alpha", Value: "v2"}) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_DeepCopy_OnCreate(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + created, err := s.Create(original) + require.NoError(t, err) + + // Mutating original should not affect stored object + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) + + // Mutating returned object should not affect stored object + created.Value = "mutated-created" + got2, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) +} + +func TestObjectStore_DeepCopy_OnGet(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + got, err := s.Get("alpha") + require.NoError(t, err) + + // Mutating retrieved object should not affect stored object + got.Value = "mutated" + got.Tags["env"] = "mutated" + + got2, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) + assert.Equal(t, "test", got2.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnList(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + items := s.List() + require.Len(t, items, 1) + + // Mutating listed object should not affect stored object + items[0].Value = "mutated" + items[0].Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnInsert(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + s.Insert(original) + + // Mutating original should not affect stored object + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} diff --git a/sdk/go/openshell/v1/fake/tcp.go b/sdk/go/openshell/v1/fake/tcp.go new file mode 100644 index 0000000000..b39699c369 --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + "net" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeTCPClient implements v1.TCPInterface. All methods return +// Unimplemented because TCP port forwarding requires a real sandbox runtime. +type fakeTCPClient struct { + closedFunc func() bool +} + +// newFakeTCPClient creates a new fakeTCPClient. +func newFakeTCPClient(closedFunc func() bool) *fakeTCPClient { + return &fakeTCPClient{closedFunc: closedFunc} +} + +// Forward returns Unimplemented. Ports outside 1-65535 are rejected with +// InvalidArgument to match the real client's behavior. +func (c *fakeTCPClient) Forward(_ context.Context, _ string, port uint32, _ ...v1.ForwardOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Forward is not supported by the fake client"} +} + +// Listen validates inputs then returns Unimplemented. The fake does not bind +// any local port; it checks that sandboxName is non-empty, remotePort is in +// the range 1-65535, and localPort is in the range 0-65535. +func (c *fakeTCPClient) Listen(_ context.Context, sandboxName string, remotePort uint32, localPort uint32, _ ...v1.ListenOption) (net.Listener, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePort == 0 || remotePort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", remotePort)} + } + if localPort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("local port must be in range 0-65535, got %d", localPort)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Listen is not supported by the fake client"} +} + +// Compile-time check that fakeTCPClient implements v1.TCPInterface. +var _ v1.TCPInterface = (*fakeTCPClient)(nil) diff --git a/sdk/go/openshell/v1/fake/tcp_test.go b/sdk/go/openshell/v1/fake/tcp_test.go new file mode 100644 index 0000000000..2a8d1a2a70 --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T019: fakeTCPClient stub tests --- + +func TestFakeTCP_Forward_ReturnsUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Forward_WithForwardOption(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080, v1.WithForwardServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_InvalidPort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +// --- T020: fakeTCPClient.Listen tests --- + +func TestFakeTCP_Listen_EmptySandboxName(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeTCP_Listen_InvalidRemotePort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := c.Listen(context.Background(), "my-sandbox", tt.port, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + }) + } +} + +func TestFakeTCP_Listen_ValidInputsReturnUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Listen_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Listen_WithOptions(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0, + v1.WithBindAddress("0.0.0.0"), + v1.WithSSHTunnel(), + v1.WithListenServiceID("svc-1"), + ) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} diff --git a/sdk/go/openshell/v1/file.go b/sdk/go/openshell/v1/file.go new file mode 100644 index 0000000000..e7e8a7ecd3 --- /dev/null +++ b/sdk/go/openshell/v1/file.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import "context" + +// FileInterface defines file transfer operations on sandboxes. +// Methods accept a sandbox name and resolve it to an ID internally. +type FileInterface interface { + Upload(ctx context.Context, sandboxName string, localPath string, remotePath string) error + Download(ctx context.Context, sandboxName string, remotePath string, localPath string) error +} diff --git a/sdk/go/openshell/v1/file_client.go b/sdk/go/openshell/v1/file_client.go new file mode 100644 index 0000000000..8fb51a72c5 --- /dev/null +++ b/sdk/go/openshell/v1/file_client.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type fileClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface + transport sshTransport +} + +type sshTransport interface { + upload(ctx context.Context, session *pb.CreateSshSessionResponse, localPath, remotePath string) error + download(ctx context.Context, session *pb.CreateSshSessionResponse, remotePath, localPath string) error +} + +func newFileClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *fileClient { + return &fileClient{ + client: pb.NewOpenShellClient(conn), + sandboxes: sandboxes, + transport: &defaultSSHTransport{}, + } +} + +func (f *fileClient) Upload(ctx context.Context, sandboxName string, localPath string, remotePath string) error { + if sandboxName == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} + } + + info, err := os.Stat(localPath) + if err != nil { + return fmt.Errorf("local file error: %w", err) + } + if info.IsDir() { + return fmt.Errorf("local path is a directory, not a file: %s", localPath) + } + + sb, err := f.sandboxes.Get(ctx, sandboxName) + if err != nil { + return err + } + + session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return converter.FromGRPCError(err) + } + + defer func() { + revokeCtx, revokeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer revokeCancel() + _, _ = f.client.RevokeSshSession(revokeCtx, &pb.RevokeSshSessionRequest{ + Token: session.GetToken(), + }) + }() + + return f.transport.upload(ctx, session, localPath, remotePath) +} + +func (f *fileClient) Download(ctx context.Context, sandboxName string, remotePath string, localPath string) error { + if sandboxName == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} + } + + sb, err := f.sandboxes.Get(ctx, sandboxName) + if err != nil { + return err + } + + session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return converter.FromGRPCError(err) + } + + defer func() { + revokeCtx, revokeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer revokeCancel() + _, _ = f.client.RevokeSshSession(revokeCtx, &pb.RevokeSshSessionRequest{ + Token: session.GetToken(), + }) + }() + + return f.transport.download(ctx, session, remotePath, localPath) +} + +type defaultSSHTransport struct{} + +func (t *defaultSSHTransport) upload(_ context.Context, session *pb.CreateSshSessionResponse, localPath, remotePath string) error { + return fmt.Errorf("SSH transport to %s:%d not implemented (local: %s -> remote: %s)", + session.GetGatewayHost(), session.GetGatewayPort(), localPath, remotePath) +} + +func (t *defaultSSHTransport) download(_ context.Context, session *pb.CreateSshSessionResponse, remotePath, localPath string) error { + return fmt.Errorf("SSH transport to %s:%d not implemented (remote: %s -> local: %s)", + session.GetGatewayHost(), session.GetGatewayPort(), remotePath, localPath) +} diff --git a/sdk/go/openshell/v1/file_client_test.go b/sdk/go/openshell/v1/file_client_test.go new file mode 100644 index 0000000000..afc78672a6 --- /dev/null +++ b/sdk/go/openshell/v1/file_client_test.go @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type mockFileServer struct { + pb.UnimplementedOpenShellServer + createResp *pb.CreateSshSessionResponse + createErr error + revokeResp *pb.RevokeSshSessionResponse + revokeErr error + lastCreateReq *pb.CreateSshSessionRequest + lastRevokeReq *pb.RevokeSshSessionRequest + createCallCount int + revokeCallCount int +} + +func newMockFileServer() *mockFileServer { + return &mockFileServer{} +} + +func (s *mockFileServer) CreateSshSession(_ context.Context, req *pb.CreateSshSessionRequest) (*pb.CreateSshSessionResponse, error) { //nolint:revive // method name matches proto interface + s.lastCreateReq = req + s.createCallCount++ + if s.createErr != nil { + return nil, s.createErr + } + return s.createResp, nil +} + +func (s *mockFileServer) RevokeSshSession(_ context.Context, req *pb.RevokeSshSessionRequest) (*pb.RevokeSshSessionResponse, error) { //nolint:revive // method name matches proto interface + s.lastRevokeReq = req + s.revokeCallCount++ + if s.revokeErr != nil { + return nil, s.revokeErr + } + return s.revokeResp, nil +} + +func setupFileTest(t *testing.T, mock *mockFileServer) (*fileClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newFileClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T051: Upload and Download tests --- + +func TestFileUpload(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-test-sandbox", + Token: "session-token-123", + GatewayHost: "gateway.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("file content"), 0644)) + + err := client.Upload(context.Background(), "test-sandbox", localPath, "/remote/upload.txt") + + // Upload will fail because there's no real SSH server, but it should + // at least call CreateSshSession with the resolved sandbox ID + // and attempt the transfer. The error is from the SSH connection, not the RPC. + assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, 1, mock.createCallCount) + + // We accept an error here since there's no real SSH server to connect to. + // Integration tests will verify end-to-end transfer. + _ = err +} + +func TestFileUpload_CreateSessionError(t *testing.T) { + mock := newMockFileServer() + mock.createErr = status.Error(codes.NotFound, "sandbox not found") + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err := client.Upload(context.Background(), "test-sandbox", localPath, "/remote/file.txt") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestFileDownload(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-test-sandbox", + Token: "session-token-456", + GatewayHost: "gateway.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "test-sandbox", "/remote/file.txt", localPath) + + // Download will fail at SSH connection (no real server), but should + // call CreateSshSession with the resolved sandbox ID. + assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, 1, mock.createCallCount) + + _ = err +} + +func TestFileDownload_CreateSessionError(t *testing.T) { + mock := newMockFileServer() + mock.createErr = status.Error(codes.PermissionDenied, "access denied") + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "test-sandbox", "/remote/file.txt", localPath) + + require.Error(t, err) + assert.True(t, IsPermissionDenied(err)) +} + +// --- T052: Upload error cases --- + +func TestFileUpload_NonExistentLocalFile(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + err := client.Upload(context.Background(), "test-sandbox", "/nonexistent/file.txt", "/remote/file.txt") + + require.Error(t, err) + // Should fail before contacting gateway + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileUpload_LocalPathIsDirectory(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + + err := client.Upload(context.Background(), "test-sandbox", tmpDir, "/remote/file.txt") + + require.Error(t, err) + // Should fail before contacting gateway + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileUpload_EmptySandboxName(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err := client.Upload(context.Background(), "", localPath, "/remote/file.txt") + + require.Error(t, err) + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileDownload_EmptyRemotePath(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "test-sandbox", "", localPath) + + require.Error(t, err) + assert.Equal(t, 0, mock.createCallCount) +} + +// --- Name-to-ID resolution tests --- + +func TestFileUpload_ResolvesNameToID(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-my-sandbox", + Token: "token", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + _ = client.Upload(context.Background(), "my-sandbox", localPath, "/remote/file.txt") + + // Verify the proto request contains the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) +} + +func TestFileUpload_ResolutionError(t *testing.T) { + mock := newMockFileServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newFileClient(conn, resolver) + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err = client.Upload(context.Background(), "nonexistent", localPath, "/remote/file.txt") + require.Error(t, err) + assert.True(t, IsNotFound(err)) + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileDownload_ResolvesNameToID(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + localPath := filepath.Join(t.TempDir(), "downloaded.txt") + _ = client.Download(context.Background(), "my-sandbox", "/remote/file.txt", localPath) + + assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) +} + +func TestFileDownload_ResolutionError(t *testing.T) { + mock := newMockFileServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newFileClient(conn, resolver) + + localPath := filepath.Join(t.TempDir(), "downloaded.txt") + err = client.Download(context.Background(), "nonexistent", "/remote/file.txt", localPath) + require.Error(t, err) + assert.True(t, IsNotFound(err)) + assert.Equal(t, 0, mock.createCallCount) +} diff --git a/sdk/go/openshell/v1/gateway/config.go b/sdk/go/openshell/v1/gateway/config.go new file mode 100644 index 0000000000..c0813986e9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// AuthMode represents the authentication mode configured for a gateway. +type AuthMode string + +// Known auth mode values matching the Rust CLI's gateway configuration. +const ( + // AuthModeNone indicates no authentication (default when auth_mode is + // unset or explicitly "none"). + AuthModeNone AuthMode = "" + + // AuthModePlaintext indicates an insecure plaintext connection with + // no TLS and no authentication. + AuthModePlaintext AuthMode = "plaintext" + + // AuthModeCloudflareJWT indicates Cloudflare Access JWT authentication + // using an edge token loaded from disk. + AuthModeCloudflareJWT AuthMode = "cloudflare_jwt" + + // AuthModeOIDC indicates OpenID Connect authentication using a + // refreshable token bundle loaded from disk. + AuthModeOIDC AuthMode = "oidc" + + // AuthModeMTLS indicates mutual TLS authentication. Currently + // unsupported; returns [ErrUnsupportedAuthMode] with guidance. + AuthModeMTLS AuthMode = "mtls" +) + +// ConfigSource identifies where a gateway configuration was found. +type ConfigSource string + +const ( + // SourceUser indicates the gateway was found in the user config + // directory ($XDG_CONFIG_HOME/openshell/gateways/). + SourceUser ConfigSource = "user" + + // SourceSystem indicates the gateway was found in the system config + // directory (/etc/openshell/gateways/). + SourceSystem ConfigSource = "system" +) + +// Config is a parsed representation of a gateway's on-disk metadata.json. +// It is an immutable snapshot captured at load time; subsequent changes to +// the on-disk files are not reflected. +type Config struct { + // Name is the validated gateway name. + Name string + + // Endpoint is the host:port address of the gateway. + Endpoint string + + // AuthMode is the resolved authentication mode. + AuthMode AuthMode + + // Source indicates whether the config came from the user or system + // directory. + Source ConfigSource + + // Dir is the absolute path to the gateway config directory. + Dir string + + // OIDCIssuer is the OIDC provider's issuer URL read from + // metadata.json. Empty when the gateway does not use OIDC auth. + OIDCIssuer string + + // OIDCClientID is the OAuth2 client ID read from metadata.json. + // Empty when the gateway does not use OIDC auth. + OIDCClientID string +} + +// Info is a lightweight summary of a gateway for listing purposes. +// It does not load tokens or validate config completeness. +type Info struct { + // Name is the gateway name derived from the directory listing. + Name string + + // Active indicates whether this is the currently active gateway. + Active bool + + // Source indicates whether the gateway is from the user or system + // directory. + Source ConfigSource +} + +// metadataJSON is the on-disk representation of metadata.json. +// Unknown fields are silently ignored for forward compatibility. +type metadataJSON struct { + Endpoint string `json:"gateway_endpoint"` + AuthMode string `json:"auth_mode"` + Name string `json:"name"` + OIDCIssuer string `json:"oidc_issuer"` + OIDCClientID string `json:"oidc_client_id"` +} + +// parseAuthMode converts a raw auth_mode string to the typed AuthMode. +// Empty string and "none" both map to AuthModeNone. +func parseAuthMode(raw string) (AuthMode, error) { + switch raw { + case "", "none": + return AuthModeNone, nil + case "plaintext": + return AuthModePlaintext, nil + case "cloudflare_jwt": + return AuthModeCloudflareJWT, nil + case "oidc": + return AuthModeOIDC, nil + case "mtls": + return AuthModeMTLS, nil + default: + return "", fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, raw) + } +} + +// parseMetadata reads and parses metadata.json from the given gateway +// directory. Unknown fields are silently ignored for forward compatibility +// with newer Rust CLI versions. +func parseMetadata(dir string) (*Config, error) { + path := filepath.Join(dir, "metadata.json") + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrConfigParse, err) + } + + var meta metadataJSON + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrConfigParse, path, err) + } + + if meta.Endpoint == "" { + return nil, fmt.Errorf("%w: missing gateway_endpoint in %s", ErrConfigParse, path) + } + + mode, err := parseAuthMode(meta.AuthMode) + if err != nil { + return nil, err + } + + return &Config{ + Name: meta.Name, + Endpoint: meta.Endpoint, + AuthMode: mode, + Dir: dir, + OIDCIssuer: meta.OIDCIssuer, + OIDCClientID: meta.OIDCClientID, + }, nil +} diff --git a/sdk/go/openshell/v1/gateway/config_test.go b/sdk/go/openshell/v1/gateway/config_test.go new file mode 100644 index 0000000000..8411d2eef9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config_test.go @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T010: metadata.json parsing tests --- + +func TestParseMetadata_ValidConfig(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"localhost:8080","auth_mode":"none","name":"prod"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "localhost:8080", cfg.Endpoint) + assert.Equal(t, AuthModeNone, cfg.AuthMode) + assert.Equal(t, dir, cfg.Dir) +} + +func TestParseMetadata_EmptyAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, AuthModeNone, cfg.AuthMode) +} + +func TestParseMetadata_AllAuthModes(t *testing.T) { + cases := []struct { + mode string + expected AuthMode + }{ + {"", AuthModeNone}, + {"none", AuthModeNone}, + {"plaintext", AuthModePlaintext}, + {"cloudflare_jwt", AuthModeCloudflareJWT}, + {"oidc", AuthModeOIDC}, + {"mtls", AuthModeMTLS}, + } + + for _, tc := range cases { + t.Run("mode_"+tc.mode, func(t *testing.T) { + dir := t.TempDir() + if tc.mode == "" { + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + } else { + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"`+tc.mode+`"}`) + } + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, tc.expected, cfg.AuthMode) + }) + } +} + +func TestParseMetadata_MissingEndpoint(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"auth_mode":"none","name":"prod"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "missing gateway_endpoint") +} + +func TestParseMetadata_MissingFile(t *testing.T) { + dir := t.TempDir() + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) +} + +func TestParseMetadata_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{invalid json}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseMetadata_UnknownFieldsIgnored(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"none", + "name":"prod", + "future_field":"some_value", + "another_new_thing": 42 + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +func TestParseMetadata_UnsupportedAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"kerberos"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "kerberos") +} + +func TestParseAuthMode(t *testing.T) { + cases := []struct { + input string + expected AuthMode + wantErr bool + }{ + {"", AuthModeNone, false}, + {"none", AuthModeNone, false}, + {"plaintext", AuthModePlaintext, false}, + {"cloudflare_jwt", AuthModeCloudflareJWT, false}, + {"oidc", AuthModeOIDC, false}, + {"mtls", AuthModeMTLS, false}, + {"unknown", "", true}, + {"NONE", "", true}, // case-sensitive + } + + for _, tc := range cases { + t.Run("input_"+tc.input, func(t *testing.T) { + mode, err := parseAuthMode(tc.input) + if tc.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, mode) + } + }) + } +} + +// --- T010: OIDC config field tests --- + +func TestParseMetadata_OIDCFields(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "name":"oidc-gw", + "oidc_issuer":"https://auth.example.com", + "oidc_client_id":"my-client-id" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "oidc-gw", cfg.Name) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, "https://auth.example.com", cfg.OIDCIssuer) + assert.Equal(t, "my-client-id", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsMissing(t *testing.T) { + // When OIDC fields are absent (older gateway or non-OIDC mode), + // the Config should have empty strings for OIDCIssuer/OIDCClientID. + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"cloudflare_jwt", + "name":"legacy-gw" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsEmpty(t *testing.T) { + // Explicit empty strings for OIDC fields should be handled + // gracefully (backward compatibility). + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "oidc_issuer":"", + "oidc_client_id":"" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +// writeJSON is a test helper that writes a metadata.json file. +func writeJSON(t *testing.T, dir, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/gateway/doc.go b/sdk/go/openshell/v1/gateway/doc.go new file mode 100644 index 0000000000..ef060530d7 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/doc.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package gateway reads on-disk gateway configurations created by the +// OpenShell Rust CLI and constructs fully wired SDK clients. +// +// The package resolves XDG config paths, validates gateway names, loads +// tokens lazily, maps auth modes to existing auth providers, and provides +// one-call convenience constructors. This eliminates 20+ lines of +// boilerplate for Go programs connecting to gateways managed by the CLI. +// +// # Quick Start +// +// Connect to a named gateway: +// +// client, err := gateway.NewClient("prod") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Connect to the active gateway (set via `openshell gateway use`): +// +// client, err := gateway.NewClient("") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Inspect configuration without creating a client: +// +// cfg, err := gateway.LoadConfig("staging") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Endpoint: %s, Auth: %s\n", cfg.Endpoint, cfg.AuthMode) +// +// List all configured gateways: +// +// gateways, err := gateway.ListGateways() +// if err != nil { +// log.Fatal(err) +// } +// for _, gw := range gateways { +// fmt.Printf("%s (active=%v, source=%s)\n", gw.Name, gw.Active, gw.Source) +// } +// +// # On-Disk Layout +// +// The package reads gateway metadata from the following locations: +// +// $XDG_CONFIG_HOME/openshell/gateways//metadata.json (user) +// /etc/openshell/gateways//metadata.json (system) +// +// Token files (edge_token, cf_token, oidc_token.json) sit alongside +// metadata.json and are loaded lazily on first authentication attempt. +// +// # Error Handling +// +// The package provides typed errors for precise failure classification: +// +// - [ErrGatewayNotFound]: no gateway directory found +// - [ErrConfigParse]: metadata.json missing or malformed +// - [ErrTokenLoad]: token file missing or unreadable +// - [ErrUnsupportedAuthMode]: unrecognized auth_mode value +// - [ErrInvalidGatewayName]: name fails validation +// - [ErrNoActiveGateway]: no active gateway configured +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. +package gateway diff --git a/sdk/go/openshell/v1/gateway/errors.go b/sdk/go/openshell/v1/gateway/errors.go new file mode 100644 index 0000000000..ef26774617 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import "errors" + +// Sentinel errors for gateway configuration failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrGatewayNotFound is returned when no gateway directory exists + // in either the user or system config paths. + ErrGatewayNotFound = errors.New("gateway: not found") + + // ErrConfigParse is returned when metadata.json is missing, + // unreadable, or contains invalid JSON. + ErrConfigParse = errors.New("gateway: config parse error") + + // ErrTokenLoad is returned when a token file (edge_token, + // oidc_token.json) is missing, unreadable, or malformed. + ErrTokenLoad = errors.New("gateway: token load error") + + // ErrUnsupportedAuthMode is returned when the auth_mode value in + // metadata.json is not recognized (not none, plaintext, + // cloudflare_jwt, oidc, or mtls). + ErrUnsupportedAuthMode = errors.New("gateway: unsupported auth mode") + + // ErrInvalidGatewayName is returned when a gateway name fails + // validation (empty, contains path separators, dots, or + // non-ASCII-alnum-dash-underscore characters). + ErrInvalidGatewayName = errors.New("gateway: invalid gateway name") + + // ErrNoActiveGateway is returned when no active gateway is + // configured (active_gateway file missing or empty). + ErrNoActiveGateway = errors.New("gateway: no active gateway") +) diff --git a/sdk/go/openshell/v1/gateway/errors_test.go b/sdk/go/openshell/v1/gateway/errors_test.go new file mode 100644 index 0000000000..09d8b40d6d --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +// --- T011: Error type tests --- + +func TestSentinelErrors_ErrorsIs(t *testing.T) { + sentinels := []struct { + name string + err error + }{ + {"ErrGatewayNotFound", ErrGatewayNotFound}, + {"ErrConfigParse", ErrConfigParse}, + {"ErrTokenLoad", ErrTokenLoad}, + {"ErrUnsupportedAuthMode", ErrUnsupportedAuthMode}, + {"ErrInvalidGatewayName", ErrInvalidGatewayName}, + {"ErrNoActiveGateway", ErrNoActiveGateway}, + } + + for _, tc := range sentinels { + t.Run(tc.name+"_direct", func(t *testing.T) { + assert.True(t, errors.Is(tc.err, tc.err), + "errors.Is should match sentinel directly") + }) + + t.Run(tc.name+"_wrapped", func(t *testing.T) { + wrapped := fmt.Errorf("context: %w", tc.err) + assert.True(t, errors.Is(wrapped, tc.err), + "errors.Is should match through fmt.Errorf wrapping") + }) + + t.Run(tc.name+"_double_wrapped", func(t *testing.T) { + inner := fmt.Errorf("inner: %w", tc.err) + outer := fmt.Errorf("outer: %w", inner) + assert.True(t, errors.Is(outer, tc.err), + "errors.Is should match through double wrapping") + }) + } +} + +func TestSentinelErrors_NotConfused(t *testing.T) { + // Verify that different sentinel errors are not equal. + pairs := []struct { + a, b error + }{ + {ErrGatewayNotFound, ErrConfigParse}, + {ErrConfigParse, ErrTokenLoad}, + {ErrTokenLoad, ErrUnsupportedAuthMode}, + {ErrUnsupportedAuthMode, ErrInvalidGatewayName}, + {ErrInvalidGatewayName, ErrNoActiveGateway}, + {ErrNoActiveGateway, ErrGatewayNotFound}, + } + + for _, tc := range pairs { + t.Run(tc.a.Error()+"_vs_"+tc.b.Error(), func(t *testing.T) { + assert.False(t, errors.Is(tc.a, tc.b), + "different sentinels must not match") + }) + } +} + +func TestSentinelErrors_HaveMessages(t *testing.T) { + sentinels := []error{ + ErrGatewayNotFound, + ErrConfigParse, + ErrTokenLoad, + ErrUnsupportedAuthMode, + ErrInvalidGatewayName, + ErrNoActiveGateway, + } + + for _, err := range sentinels { + t.Run(err.Error(), func(t *testing.T) { + msg := err.Error() + assert.NotEmpty(t, msg) + assert.Contains(t, msg, "gateway:") + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go new file mode 100644 index 0000000000..8d1bd9d87b --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// 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. +// +// The function resolves the gateway directory, parses metadata.json, +// loads tokens lazily, maps the auth mode to an SDK auth provider, and +// applies any ClientOptions before delegating to [v1.NewClient]. +// +// NewClient is safe for concurrent use from multiple goroutines. +func NewClient(name string, opts ...ClientOption) (*v1.Client, error) { + cfg, err := loadConfigInternal(name) + if err != nil { + return nil, err + } + + // Apply caller options. + cc := &clientConfig{} + for _, o := range opts { + o(cc) + } + + // Resolve auth provider: caller override takes precedence. + auth := cc.auth + if auth == nil { + auth, err = resolveAuthProvider(cfg) + if err != nil { + return nil, err + } + } + + // Build the SDK Config. + sdkCfg := types.Config{ + Address: cfg.Endpoint, + Auth: auth, + } + + // Apply TLS: caller override or auth-mode defaults. + if cc.tls != nil { + sdkCfg.TLS = cc.tls + } else if cfg.AuthMode == AuthModePlaintext { + sdkCfg.TLS = &types.TLSConfig{Insecure: true} + } + + if cc.timeout > 0 { + sdkCfg.Timeout = cc.timeout + } + if cc.retryPolicy != nil { + sdkCfg.RetryPolicy = cc.retryPolicy + } + if cc.logger != nil { + sdkCfg.Logger = cc.logger + } + + return v1.NewClient(sdkCfg) +} + +// LoadConfig reads and parses a gateway's on-disk configuration without +// creating a client connection. If name is empty, the active gateway is +// used. +// +// The returned [Config] is an immutable snapshot; changes to the on-disk +// files after this call are not reflected. +// +// LoadConfig is safe for concurrent use from multiple goroutines. +func LoadConfig(name string) (*Config, error) { + return loadConfigInternal(name) +} + +// loadConfigInternal resolves the gateway name (including active gateway +// fallback), finds the config directory, and parses metadata.json. This +// shared implementation is used by both NewClient and LoadConfig. +func loadConfigInternal(name string) (*Config, error) { + // If name is empty, resolve the active gateway. + if name == "" { + activeName, err := resolveActiveGateway() + if err != nil { + return nil, err + } + name = activeName + } + + dir, source, err := resolveGatewayDir(name) + if err != nil { + return nil, err + } + + cfg, err := parseMetadata(dir) + if err != nil { + return nil, err + } + + // Override name from directory (validated) rather than metadata.json. + cfg.Name = name + cfg.Source = source + + return cfg, nil +} + +// ListGateways enumerates all available gateways from user and system +// 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. +// +// ListGateways is safe for concurrent use from multiple goroutines. +func ListGateways() ([]Info, error) { + seen := make(map[string]bool) + var result []Info + + activeName, _ := resolveActiveGateway() + + userBase, err := userConfigDir() + if err == nil { + names, listErr := listGatewayDirs(userBase) + if listErr != nil { + return nil, listErr + } + for _, name := range names { + seen[name] = true + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceUser, + }) + } + } + + sysNames, listErr := listGatewayDirs(systemConfigBase) + if listErr != nil { + return nil, listErr + } + for _, name := range sysNames { + if !seen[name] { + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceSystem, + }) + } + } + + return result, nil +} + +// resolveAuthProvider maps a Config's AuthMode to an SDK AuthProvider. +// Tokens are loaded lazily where possible. +func resolveAuthProvider(cfg *Config) (types.AuthProvider, error) { + switch cfg.AuthMode { + case AuthModeNone: + return v1.NoAuth(), nil + + case AuthModePlaintext: + return v1.NoAuth(), nil + + case AuthModeCloudflareJWT: + // Token loading is deferred to GetRequestMetadata so that + // NewClient succeeds even when the token file is missing. + // The error surfaces on first authentication attempt (FR-007). + return &lazyEdgeAuth{loader: &edgeTokenLoader{dir: cfg.Dir}}, nil + + case AuthModeOIDC: + src := newDiskTokenSource(cfg.Dir) + return v1.RefreshableToken(src) + + case AuthModeMTLS: + return nil, fmt.Errorf("%w: mtls is not yet supported; use WithAuth() to provide a custom auth provider", ErrUnsupportedAuthMode) + + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, cfg.AuthMode) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go new file mode 100644 index 0000000000..1462175f24 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -0,0 +1,477 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- Test helpers --- + +// setupGateway creates a gateway directory with the given metadata.json +// content under a temp XDG config dir. Returns the XDG root path. +func setupGateway(t *testing.T, name, metadataJSON string) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", metadataJSON) + + return tmp +} + +// setupGatewayWithTokens creates a gateway directory with metadata and +// token files. +func setupGatewayWithTokens(t *testing.T, name, metadataJSON string, tokens map[string]string) string { + t.Helper() + tmp := setupGateway(t, name, metadataJSON) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + for filename, content := range tokens { + writeFile(t, gwDir, filename, content) + } + + return tmp +} + +// --- T018: NewClient tests --- + +func TestNewClient_AuthModeNone(t *testing.T) { + setupGateway(t, "test-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("test-gw", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModePlaintext(t *testing.T) { + setupGateway(t, "plain-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"plaintext"}`) + + // Plaintext mode should auto-set insecure TLS. + client, err := NewClient("plain-gw") + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeCloudflareJWT(t *testing.T) { + setupGatewayWithTokens(t, "cf-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: "test-edge-token"}, + ) + + // StaticToken requires transport security, so use WithAuth override + // to bypass gRPC TLS requirement. Auth resolution is verified by + // TestResolveAuthProvider_CloudflareJWT. + client, err := NewClient("cf-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeOIDC(t *testing.T) { + setupGatewayWithTokens(t, "oidc-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`, + map[string]string{oidcTokenFile: `{"access_token":"test-oidc-token"}`}, + ) + + // RefreshableToken requires transport security, so use WithAuth + // override. Auth resolution verified by TestResolveAuthProvider_OIDC. + client, err := NewClient("oidc-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := NewClient("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestNewClient_InvalidName(t *testing.T) { + _, err := NewClient("../escape") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestNewClient_MissingEdgeToken(t *testing.T) { + setupGateway(t, "no-token-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Edge token loading is lazy (FR-007): NewClient succeeds even when + // the token file is missing. The error surfaces on first use via + // GetRequestMetadata, not at construction time. + _, err := NewClient("no-token-gw") + require.NoError(t, err) +} + +func TestNewClient_MissingOIDCToken(t *testing.T) { + setupGateway(t, "no-oidc-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`) + + _, err := NewClient("no-oidc-gw") + // OIDC uses RefreshableToken which defers the disk read to Token(), + // so NewClient should succeed. The error would come on first use. + // Let's verify it doesn't fail at construction time. + require.NoError(t, err) + assert.NoError(t, err) +} + +func TestNewClient_MTLSUnsupported(t *testing.T) { + setupGateway(t, "mtls-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"mtls"}`) + + _, err := NewClient("mtls-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") +} + +func TestNewClient_WithAuthOverride(t *testing.T) { + setupGateway(t, "override-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Even though auth_mode is cloudflare_jwt and there's no edge_token, + // the WithAuth override should bypass token loading entirely. + customAuth := &mockAuth{} + client, err := NewClient("override-gw", + WithAuth(customAuth), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_WithLogger(t *testing.T) { + setupGateway(t, "logger-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("logger-gw", + WithLogger(nil), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_WithOptions(t *testing.T) { + setupGateway(t, "opts-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("opts-gw", + WithTimeout(5*time.Second), + WithRetryPolicy(&types.RetryPolicy{MaxRetries: 3}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +// --- T019: Credential leak test --- + +func TestNewClient_NoCredentialLeaks(t *testing.T) { + // Test 1: Invalid gateway with path traversal attempt. + _, err := NewClient("../../../etc/passwd") + require.Error(t, err) + assert.NotContains(t, err.Error(), "passwd") + + // Test 2: Verify error from missing edge token does not reveal + // file system details beyond the generic message. + setupGateway(t, "no-edge-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + cfg := &Config{ + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "no-edge-gw"), + } + auth, authErr := resolveAuthProvider(cfg) + require.NoError(t, authErr) + _, err = auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + + // Test 3: Verify credential values never appear in error strings. + secretToken := "SUPER_SECRET_TOKEN_12345" + setupGatewayWithTokens(t, "leak-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: secretToken}, + ) + // Use resolveAuthProvider directly to test token loading. + cfg = &Config{ + Name: "leak-gw", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "leak-gw"), + } + auth, err = resolveAuthProvider(cfg) + require.NoError(t, err) + + // The provider should not expose the token in its string form. + if stringer, ok := auth.(interface{ String() string }); ok { + assert.NotContains(t, stringer.String(), secretToken) + } +} + +func TestResolveAuthProvider_NoTokenLeaks(t *testing.T) { + secretToken := "CREDENTIAL_THAT_MUST_NOT_LEAK" + + // Create a gateway with a bad OIDC token. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + gwDir := filepath.Join(tmp, "openshell", "gateways", "leak-test") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + writeFile(t, gwDir, edgeTokenFile, secretToken) + + cfg := &Config{ + Name: "leak-test", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: gwDir, + } + + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + + // The auth provider should work but the token value should not + // appear in the provider's string representation (if any). + providerStr := "" + if stringer, ok := auth.(interface{ String() string }); ok { + providerStr = stringer.String() + assert.NotContains(t, providerStr, secretToken) + } + + // Verify the token IS used correctly via GetRequestMetadata. + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Contains(t, md["authorization"], secretToken) +} + +// --- Test helpers --- + +// mockAuth is a minimal AuthProvider for testing WithAuth overrides. +type mockAuth struct{} + +func (m *mockAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer mock-token"}, nil +} + +func (m *mockAuth) RequireTransportSecurity() bool { + return false +} + +// --- LoadConfig tests (T025 placeholder, implemented here for Phase 3 coverage) --- + +func TestLoadConfig_ValidConfig(t *testing.T) { + setupGateway(t, "cfg-gw", `{"gateway_endpoint":"host:443","auth_mode":"oidc","name":"ignored"}`) + + cfg, err := LoadConfig("cfg-gw") + require.NoError(t, err) + // Name comes from directory, not metadata.json "name" field. + assert.Equal(t, "cfg-gw", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, SourceUser, cfg.Source) + assert.NotEmpty(t, cfg.Dir) +} + +func TestLoadConfig_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := LoadConfig("missing-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestLoadConfig_FrozenSnapshot(t *testing.T) { + xdg := setupGateway(t, "snap-gw", `{"gateway_endpoint":"original:443","auth_mode":"none"}`) + + cfg, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "original:443", cfg.Endpoint) + + // Modify the on-disk file. + gwDir := filepath.Join(xdg, "openshell", "gateways", "snap-gw") + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"modified:443","auth_mode":"none"}`) + + // The previously loaded config should be unchanged. + assert.Equal(t, "original:443", cfg.Endpoint) + + // A new load should see the change. + cfg2, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "modified:443", cfg2.Endpoint) +} + +// --- resolveAuthProvider tests --- + +func TestResolveAuthProvider_None(t *testing.T) { + cfg := &Config{AuthMode: AuthModeNone} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_Plaintext(t *testing.T) { + cfg := &Config{AuthMode: AuthModePlaintext} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_CloudflareJWT(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "cf-jwt-token") + + cfg := &Config{AuthMode: AuthModeCloudflareJWT, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer cf-jwt-token", md["authorization"]) +} + +func TestResolveAuthProvider_OIDC(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token":"oidc-access-token"}`) + + cfg := &Config{AuthMode: AuthModeOIDC, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer oidc-access-token", md["authorization"]) +} + +func TestResolveAuthProvider_MTLS(t *testing.T) { + cfg := &Config{AuthMode: AuthModeMTLS} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") + assert.Contains(t, err.Error(), "WithAuth") +} + +func TestResolveAuthProvider_UnknownMode(t *testing.T) { + cfg := &Config{AuthMode: "alien_auth"} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) +} + +// --- T021/T023: Active gateway tests --- + +func TestNewClient_ActiveGateway(t *testing.T) { + tmp := setupGateway(t, "active-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + activeFile := filepath.Join(tmp, "openshell", "active_gateway") + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "active-gw") + _ = activeFile + + client, err := NewClient("", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NoActiveGateway(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := NewClient("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +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") + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "my-active", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +// --- T027: ListGateways tests --- + +func TestListGateways_MultipleGateways(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"prod", "staging", "dev"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 3) + + names := make(map[string]bool) + for _, gw := range gateways { + names[gw.Name] = true + assert.Equal(t, SourceUser, gw.Source) + } + assert.True(t, names["prod"]) + assert.True(t, names["staging"]) + assert.True(t, names["dev"]) +} + +func TestListGateways_EmptyDirs(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Empty(t, gateways) +} + +func TestListGateways_ActiveStatus(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"alpha", "beta"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "beta") + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 2) + + for _, gw := range gateways { + if gw.Name == "beta" { + assert.True(t, gw.Active) + } else { + assert.False(t, gw.Active) + } + } +} diff --git a/sdk/go/openshell/v1/gateway/options.go b/sdk/go/openshell/v1/gateway/options.go new file mode 100644 index 0000000000..dfd16e89ca --- /dev/null +++ b/sdk/go/openshell/v1/gateway/options.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// clientConfig holds resolved options applied after gateway config +// resolution but before the v1.Client is created. +type clientConfig struct { + logger types.Logger + timeout time.Duration + tls *types.TLSConfig + auth types.AuthProvider + retryPolicy *types.RetryPolicy +} + +// ClientOption configures the behavior of [NewClient]. Options are +// applied after gateway configuration is resolved but before the +// underlying SDK client is created. +type ClientOption func(*clientConfig) + +// WithLogger sets the logger on the SDK client configuration. +func WithLogger(l types.Logger) ClientOption { + return func(c *clientConfig) { + c.logger = l + } +} + +// WithTimeout sets the connection timeout for the SDK client. +func WithTimeout(d time.Duration) ClientOption { + return func(c *clientConfig) { + c.timeout = d + } +} + +// WithTLS overrides the TLS settings derived from the gateway's auth mode. +// Use this to provide custom certificates or force insecure connections. +func WithTLS(cfg *types.TLSConfig) ClientOption { + return func(c *clientConfig) { + c.tls = cfg + } +} + +// WithAuth overrides the auth provider that would normally be resolved +// from the gateway's auth_mode. When set, the gateway package skips +// its own auth resolution and uses the provided provider directly. +func WithAuth(provider types.AuthProvider) ClientOption { + return func(c *clientConfig) { + c.auth = provider + } +} + +// WithRetryPolicy sets the retry policy on the SDK client configuration. +func WithRetryPolicy(p *types.RetryPolicy) ClientOption { + return func(c *clientConfig) { + c.retryPolicy = p + } +} diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go new file mode 100644 index 0000000000..fa0e1373c2 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +const ( + // appName is the application directory name used in XDG paths. + appName = "openshell" + + // gatewaySubdir is the subdirectory within the app config holding + // per-gateway directories. + gatewaySubdir = "gateways" + + // activeGatewayFile is the filename that stores the active gateway name. + activeGatewayFile = "active_gateway" + + // systemConfigBase is the system-wide config directory. + systemConfigBase = "/etc/openshell" +) + +// userConfigDir returns the user-specific configuration directory for +// OpenShell, following XDG Base Directory specification: +// +// $XDG_CONFIG_HOME/openshell (if XDG_CONFIG_HOME is set) +// ~/.config/openshell (fallback) +func userConfigDir() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + if !filepath.IsAbs(xdg) { + return "", fmt.Errorf("XDG_CONFIG_HOME must be an absolute path, got %q", xdg) + } + return filepath.Join(xdg, appName), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + + return filepath.Join(home, ".config", appName), nil +} + +// systemGatewayDir returns the system-wide gateway config directory. +func systemGatewayDir() string { + return filepath.Join(systemConfigBase, gatewaySubdir) +} + +// resolveGatewayDir searches for a gateway directory by name, checking the +// user directory first, then the system directory. Returns the absolute +// directory path, the config source, or ErrGatewayNotFound. +func resolveGatewayDir(name string) (string, ConfigSource, error) { + if err := validateGatewayName(name); err != nil { + return "", "", err + } + + // Check user config dir first. + userBase, err := userConfigDir() + if err == nil { + userDir := filepath.Join(userBase, gatewaySubdir, name) + if info, statErr := os.Stat(userDir); statErr == nil && info.IsDir() { + return userDir, SourceUser, nil + } + } + + // Check system config dir. + sysDir := filepath.Join(systemGatewayDir(), name) + if info, statErr := os.Stat(sysDir); statErr == nil && info.IsDir() { + return sysDir, SourceSystem, nil + } + + return "", "", fmt.Errorf("%w: %q", ErrGatewayNotFound, name) +} + +// validateGatewayName checks that a gateway name is safe for use as a +// directory component. It rejects: +// - empty names +// - names containing path separators (/ or \) +// - names that are "." or ".." (directory traversal) +// - names containing "." (prevents hidden files and extension confusion) +// - names with characters outside ASCII alphanumerics, dashes, underscores +func validateGatewayName(name string) error { + if name == "" { + return fmt.Errorf("%w: name must not be empty", ErrInvalidGatewayName) + } + + if strings.ContainsAny(name, "/\\") { + return fmt.Errorf("%w: name must not contain path separators", ErrInvalidGatewayName) + } + + if strings.Contains(name, ".") { + return fmt.Errorf("%w: name must not contain dots", ErrInvalidGatewayName) + } + + for _, r := range name { + if !isValidNameRune(r) { + return fmt.Errorf("%w: name contains invalid character %q", ErrInvalidGatewayName, string(r)) + } + } + + 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. +func resolveActiveGateway() (string, error) { + userBase, err := userConfigDir() + if err != nil { + return "", fmt.Errorf("%w: cannot determine config directory: %v", ErrNoActiveGateway, err) + } + + path := filepath.Join(userBase, activeGatewayFile) + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("%w", ErrNoActiveGateway) + } + + name := strings.TrimSpace(string(data)) + if name == "" { + return "", fmt.Errorf("%w: active_gateway file is empty", ErrNoActiveGateway) + } + + if err := validateGatewayName(name); err != nil { + return "", fmt.Errorf("%w: active_gateway contains invalid name %q: %v", ErrNoActiveGateway, name, err) + } + + return name, nil +} + +// listGatewayDirs returns a list of gateway directories found under the +// given base path. Each entry is just the directory name (gateway name). +func listGatewayDirs(base string) ([]string, error) { + gatewaysDir := filepath.Join(base, gatewaySubdir) + entries, err := os.ReadDir(gatewaysDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var names []string + for _, e := range entries { + if e.IsDir() && validateGatewayName(e.Name()) == nil { + names = append(names, e.Name()) + } + } + return names, nil +} + +// isValidNameRune returns true if the rune is an ASCII letter, digit, +// dash, or underscore. +func isValidNameRune(r rune) bool { + if r > unicode.MaxASCII { + return false + } + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' +} diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go new file mode 100644 index 0000000000..547bd3fc3c --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T007: XDG resolution tests --- + +func TestUserConfigDir_XDGSet(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + dir, err := userConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(tmp, "openshell"), dir) +} + +func TestUserConfigDir_XDGUnset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + + dir, err := userConfigDir() + require.NoError(t, err) + + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", "openshell"), dir) +} + +func TestSystemGatewayDir(t *testing.T) { + dir := systemGatewayDir() + assert.Equal(t, "/etc/openshell/gateways", dir) +} + +func TestResolveGatewayDir_UserDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + // Create a gateway directory in user config. + gwDir := filepath.Join(tmp, "openshell", "gateways", "prod") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("prod") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +func TestResolveGatewayDir_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, _, err := resolveGatewayDir("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestResolveGatewayDir_InvalidName(t *testing.T) { + _, _, err := resolveGatewayDir("../etc") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestResolveGatewayDir_UserPrecedenceOverSystem(t *testing.T) { + // This test verifies the search order: user dir is checked before + // system dir. We can only test the user dir path since we cannot + // write to /etc in tests. The logic is verified by the successful + // user dir resolution above. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", "shared") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("shared") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +// --- T008: Name validation tests --- + +func TestValidateGatewayName_ValidNames(t *testing.T) { + validNames := []string{ + "prod", + "staging", + "my-gateway", + "gateway_1", + "PROD", + "a", + "test-gateway-01", + "A_B_C", + } + + for _, name := range validNames { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + assert.NoError(t, err, "expected %q to be valid", name) + }) + } +} + +func TestValidateGatewayName_Empty(t *testing.T) { + err := validateGatewayName("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + assert.Contains(t, err.Error(), "empty") +} + +func TestValidateGatewayName_PathSeparators(t *testing.T) { + cases := []string{ + "../etc", + "foo/bar", + "foo\\bar", + "/absolute", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +func TestValidateGatewayName_Dots(t *testing.T) { + cases := []string{ + ".", + "..", + ".hidden", + "foo.bar", + "config.json", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +// --- T021: Active gateway resolution tests --- + +func TestResolveActiveGateway_ValidName(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte("my-gateway"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_WhitespaceHandling(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" my-gateway \n"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_FileMissing(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestResolveActiveGateway_EmptyFile(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" \n"), 0o644)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestValidateGatewayName_SpecialCharacters(t *testing.T) { + cases := []struct { + name string + desc string + }{ + {"hello world", "space"}, + {"foo@bar", "at sign"}, + {"foo#bar", "hash"}, + {"café", "non-ASCII"}, + {"日本語", "unicode"}, + {"foo bar", "tab (space)"}, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + err := validateGatewayName(tc.name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/token.go b/sdk/go/openshell/v1/gateway/token.go new file mode 100644 index 0000000000..b4e13c359e --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token.go @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // edgeTokenFile is the primary edge token filename. + edgeTokenFile = "edge_token" + + // cfTokenFile is the legacy Cloudflare token filename, used as + // fallback when edge_token does not exist. + cfTokenFile = "cf_token" + + // oidcTokenFile is the OIDC token bundle filename. + oidcTokenFile = "oidc_token.json" +) + +// edgeTokenLoader provides lazy, thread-safe loading of the edge token +// from disk. The token is read on the first call to load() and cached +// for subsequent calls via sync.Once. +type edgeTokenLoader struct { + dir string + once sync.Once + token string + err error +} + +// load returns the edge token string, reading it from disk on the first +// call. It tries edge_token first, falling back to cf_token for legacy +// compatibility. The result (success or failure) is cached. +func (l *edgeTokenLoader) load() (string, error) { + l.once.Do(func() { + l.token, l.err = readEdgeToken(l.dir) + }) + return l.token, l.err +} + +// readEdgeToken reads the edge token from the given directory. It tries +// edge_token first, then cf_token as a legacy fallback. The file content +// is trimmed of surrounding whitespace. +func readEdgeToken(dir string) (string, error) { + // Try primary edge_token file. + primary := filepath.Join(dir, edgeTokenFile) + data, err := os.ReadFile(primary) + if err == nil { + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: edge_token file is empty", ErrTokenLoad) + } + return token, nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, edgeTokenFile, err) + } + + // Fallback to legacy cf_token file (only when edge_token is absent). + legacy := filepath.Join(dir, cfTokenFile) + data, err = os.ReadFile(legacy) + if err != nil { + return "", fmt.Errorf("%w: neither edge_token nor cf_token found in gateway directory", ErrTokenLoad) + } + + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: cf_token file is empty", ErrTokenLoad) + } + + return token, nil +} + +// oidcBundle is the on-disk representation of oidc_token.json. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// diskTokenSource implements oauth2.TokenSource by reading oidc_token.json +// from disk on every Token() call. This allows the source to pick up +// tokens refreshed by the Rust CLI without process restart. +type diskTokenSource struct { + dir string +} + +// newDiskTokenSource returns an oauth2.TokenSource that reads +// oidc_token.json from the given gateway directory on each Token() call. +func newDiskTokenSource(dir string) oauth2.TokenSource { + return &diskTokenSource{dir: dir} +} + +// Token reads and parses oidc_token.json, returning an oauth2.Token. +// The file is read on every call to pick up CLI-refreshed tokens. +func (d *diskTokenSource) Token() (*oauth2.Token, error) { + path := filepath.Join(d.dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, oidcTokenFile, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s", ErrTokenLoad, oidcTokenFile) + } + + if bundle.AccessToken == "" { + return nil, fmt.Errorf("%w: missing access_token in %s", ErrTokenLoad, oidcTokenFile) + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry: prefer explicit "expiry" field, fall back to + // "expires_in" (seconds from now). + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr == nil { + tok.Expiry = expiry + } + // If expiry can't be parsed, leave it zero (token treated as + // non-expiring by oauth2). + } else if bundle.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(bundle.ExpiresIn) * time.Second) + } + + return tok, nil +} + +// lazyEdgeAuth implements types.AuthProvider for the cloudflare_jwt auth +// mode. Token loading is deferred to GetRequestMetadata so that +// NewClient succeeds even when the token file is missing on disk (FR-007). +// The error surfaces on the first authentication attempt. +type lazyEdgeAuth struct { + loader *edgeTokenLoader +} + +// GetRequestMetadata loads the edge token lazily and returns it as a +// Bearer authorization header. The first load is cached by the +// underlying edgeTokenLoader. +func (a *lazyEdgeAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + token, err := a.loader.load() + if err != nil { + return nil, err + } + return map[string]string{ + "authorization": "Bearer " + token, + }, nil +} + +// RequireTransportSecurity returns true because Bearer tokens must not +// be sent over plaintext connections. +func (a *lazyEdgeAuth) RequireTransportSecurity() bool { + return true +} diff --git a/sdk/go/openshell/v1/gateway/token_test.go b/sdk/go/openshell/v1/gateway/token_test.go new file mode 100644 index 0000000000..952c1e7d44 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token_test.go @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T014: Edge token loading tests --- + +func TestReadEdgeToken_PrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "my-edge-token-123") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "my-edge-token-123", token) +} + +func TestReadEdgeToken_PrimaryFileWithWhitespace(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " token-with-whitespace \n") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "token-with-whitespace", token) +} + +func TestReadEdgeToken_CfTokenFallback(t *testing.T) { + dir := t.TempDir() + // No edge_token file, only cf_token. + writeFile(t, dir, cfTokenFile, "legacy-cf-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "legacy-cf-token", token) +} + +func TestReadEdgeToken_PrimaryTakesPrecedence(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "primary-token") + writeFile(t, dir, cfTokenFile, "legacy-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "primary-token", token) +} + +func TestReadEdgeToken_MissingBothFiles(t *testing.T) { + dir := t.TempDir() + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "neither edge_token nor cf_token") +} + +func TestReadEdgeToken_EmptyPrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " \n") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestReadEdgeToken_EmptyFallbackFile(t *testing.T) { + dir := t.TempDir() + // No edge_token, only empty cf_token. + writeFile(t, dir, cfTokenFile, "") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestEdgeTokenLoader_Lazy(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "lazy-token") + + loader := &edgeTokenLoader{dir: dir} + + // First call reads from disk. + token1, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token1) + + // Remove the file. Second call should return cached value. + require.NoError(t, os.Remove(filepath.Join(dir, edgeTokenFile))) + + token2, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token2, "should return cached token") +} + +func TestEdgeTokenLoader_LazyError(t *testing.T) { + dir := t.TempDir() + // No token files exist. + + loader := &edgeTokenLoader{dir: dir} + + // First call fails. + _, err1 := loader.load() + require.Error(t, err1) + + // Write the file now. Second call should return the cached error. + writeFile(t, dir, edgeTokenFile, "late-token") + + _, err2 := loader.load() + require.Error(t, err2, "should return cached error from first attempt") +} + +// --- T015: diskTokenSource tests --- + +func TestDiskTokenSource_ValidBundle(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "`+expiry+`" + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestDiskTokenSource_ExpiresIn(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-789", + "expires_in": 3600 + }`) + + before := time.Now() + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-789", tok.AccessToken) + // Expiry should be approximately 1 hour from now. + assert.WithinDuration(t, before.Add(time.Hour), tok.Expiry, 5*time.Second) +} + +func TestDiskTokenSource_ExpiryPrecedence(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-abc", + "expiry": "`+expiry+`", + "expires_in": 60 + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + // "expiry" field takes precedence over "expires_in". + assert.WithinDuration(t, time.Now().Add(2*time.Hour), tok.Expiry, 5*time.Second) +} + +func TestDiskTokenSource_NoExpiry(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "no-expiry-token"}`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "no-expiry-token", tok.AccessToken) + assert.True(t, tok.Expiry.IsZero(), "should have zero expiry when not set") +} + +func TestDiskTokenSource_MissingFile(t *testing.T) { + dir := t.TempDir() + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) +} + +func TestDiskTokenSource_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{not valid json}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestDiskTokenSource_MissingAccessToken(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"refresh_token": "only-refresh"}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "missing access_token") +} + +func TestDiskTokenSource_ReReadsOnEachCall(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v1"}`) + + src := newDiskTokenSource(dir) + tok1, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v1", tok1.AccessToken) + + // Update the file. Next call should read the new value. + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v2"}`) + + tok2, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v2", tok2.AccessToken) +} + +func TestDiskTokenSource_InvalidExpiryFormat(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "token-xyz", + "expiry": "not-a-date" + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-xyz", tok.AccessToken) + assert.True(t, tok.Expiry.IsZero(), "unparseable expiry should result in zero time") +} + +// writeFile is a test helper that writes a file with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/grpc_errors.go b/sdk/go/openshell/v1/grpc_errors.go new file mode 100644 index 0000000000..660ff8ef2d --- /dev/null +++ b/sdk/go/openshell/v1/grpc_errors.go @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package v1 provides the OpenShell SDK client. +// gRPC error conversion is handled by the internal/converter package. +package v1 diff --git a/sdk/go/openshell/v1/health.go b/sdk/go/openshell/v1/health.go new file mode 100644 index 0000000000..c5e62eaa32 --- /dev/null +++ b/sdk/go/openshell/v1/health.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// HealthResult holds the result of a health check. +type HealthResult = types.HealthResult + +// HealthInterface defines health check operations. +type HealthInterface interface { + Check(ctx context.Context) (*HealthResult, error) +} diff --git a/sdk/go/openshell/v1/health_client.go b/sdk/go/openshell/v1/health_client.go new file mode 100644 index 0000000000..1722f8520c --- /dev/null +++ b/sdk/go/openshell/v1/health_client.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type healthClient struct { + client pb.OpenShellClient +} + +func newHealthClient(conn grpc.ClientConnInterface) *healthClient { + return &healthClient{client: pb.NewOpenShellClient(conn)} +} + +func (h *healthClient) Check(ctx context.Context) (*HealthResult, error) { + resp, err := h.client.Health(ctx, &pb.HealthRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + return &HealthResult{ + Healthy: resp.GetStatus() == pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + Version: resp.GetVersion(), + }, nil +} diff --git a/sdk/go/openshell/v1/health_client_test.go b/sdk/go/openshell/v1/health_client_test.go new file mode 100644 index 0000000000..34cf1d6ece --- /dev/null +++ b/sdk/go/openshell/v1/health_client_test.go @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +const bufSize = 1024 * 1024 + +type mockHealthServer struct { + pb.UnimplementedOpenShellServer + status pb.ServiceStatus + version string + err error +} + +func (s *mockHealthServer) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { + if s.err != nil { + return nil, s.err + } + return &pb.HealthResponse{ + Status: s.status, + Version: s.version, + }, nil +} + +func newMockHealthServer(s pb.ServiceStatus, version string, err error) (*grpc.ClientConn, func()) { + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, &mockHealthServer{status: s, version: version, err: err}) + + go func() { _ = srv.Serve(lis) }() + + conn, err2 := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + + if err2 != nil { + srv.Stop() + panic("grpc.NewClient failed: " + err2.Error()) + } + + return conn, func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestHealthCheck_Success(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_HEALTHY, "1.2.3", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.True(t, result.Healthy) + assert.Equal(t, "1.2.3", result.Version) +} + +func TestHealthCheck_Degraded(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_DEGRADED, "2.0.0", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "2.0.0", result.Version) +} + +func TestHealthCheck_Unhealthy(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_UNHEALTHY, "3.0.0", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "3.0.0", result.Version) +} + +func TestHealthCheck_Unavailable(t *testing.T) { + conn, cleanup := newMockHealthServer(0, "", status.Error(codes.Unavailable, "service down")) + defer cleanup() + + h := newHealthClient(conn) + _, err := h.Check(context.Background()) + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/integration_test.go b/sdk/go/openshell/v1/integration_test.go new file mode 100644 index 0000000000..314f1aee91 --- /dev/null +++ b/sdk/go/openshell/v1/integration_test.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package v1 + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func gatewayAddress(t *testing.T) string { + t.Helper() + addr := os.Getenv("OPENSHELL_GATEWAY_ADDRESS") + if addr == "" { + t.Skip("OPENSHELL_GATEWAY_ADDRESS not set") + } + return addr +} + +func TestIntegration_HealthCheck(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + _, err = client.Health().Check(context.Background()) + require.NoError(t, err) +} + +func TestIntegration_ProviderLifecycle(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement provider create/get/list/delete integration test") +} + +func TestIntegration_SandboxLifecycle(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement sandbox create/wait-ready/delete integration test") +} + +func TestIntegration_ExecRun(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement exec run integration test") +} + +func TestIntegration_FileTransfer(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement file upload/download integration test") +} diff --git a/sdk/go/openshell/v1/internal/converter/copy.go b/sdk/go/openshell/v1/internal/converter/copy.go new file mode 100644 index 0000000000..e2523a61c3 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/copy.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +// CopyStringMap returns a shallow copy of a string-to-string map. +// Returns nil for nil input. +func CopyStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + c := make(map[string]string, len(m)) + for k, v := range m { + c[k] = v + } + return c +} + +// CopyBoolPtr returns a copy of a *bool pointer. +// Returns nil for nil input. +func CopyBoolPtr(p *bool) *bool { + if p == nil { + return nil + } + v := *p + return &v +} + +// CopyStringSlice returns a copy of a string slice. +// Returns nil for nil input. +func CopyStringSlice(s []string) []string { + if s == nil { + return nil + } + c := make([]string, len(s)) + copy(c, s) + return c +} + +// CopyByteSlice returns a copy of a byte slice. +// Returns nil for nil input. +func CopyByteSlice(b []byte) []byte { + if b == nil { + return nil + } + c := make([]byte, len(b)) + copy(c, b) + return c +} + +func boolCount(flags ...bool) int { + n := 0 + for _, f := range flags { + if f { + n++ + } + } + return n +} diff --git a/sdk/go/openshell/v1/internal/converter/errors.go b/sdk/go/openshell/v1/internal/converter/errors.go new file mode 100644 index 0000000000..349001ad15 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/errors.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package converter maps between gRPC/proto types and SDK domain types. +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var grpcToSDK = map[codes.Code]types.ErrorCode{ + codes.NotFound: types.ErrorNotFound, + codes.AlreadyExists: types.ErrorAlreadyExists, + codes.Unavailable: types.ErrorUnavailable, + codes.PermissionDenied: types.ErrorPermissionDenied, + codes.InvalidArgument: types.ErrorInvalidArgument, + codes.DeadlineExceeded: types.ErrorDeadlineExceeded, + codes.Canceled: types.ErrorCancelled, + codes.Internal: types.ErrorInternal, + codes.Unimplemented: types.ErrorUnimplemented, + codes.Aborted: types.ErrorConflict, + codes.FailedPrecondition: types.ErrorConflict, +} + +// FromGRPCError converts a gRPC error to a typed StatusError. +// Returns nil for nil errors and OK status. Non-gRPC errors pass through unchanged. +func FromGRPCError(err error) error { + if err == nil { + return nil + } + + st, ok := status.FromError(err) + if !ok { + return err + } + + if st.Code() == codes.OK { + return nil + } + + code, mapped := grpcToSDK[st.Code()] + if !mapped { + code = types.ErrorInternal + } + + return &types.StatusError{ + Code: code, + Message: st.Message(), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/errors_test.go b/sdk/go/openshell/v1/internal/converter/errors_test.go new file mode 100644 index 0000000000..c7238eaae5 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/errors_test.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestFromGRPCError_NotFound(t *testing.T) { + grpcErr := status.Error(codes.NotFound, "sandbox not found") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsNotFound(err)) +} + +func TestFromGRPCError_AlreadyExists(t *testing.T) { + grpcErr := status.Error(codes.AlreadyExists, "already exists") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsAlreadyExists(err)) +} + +func TestFromGRPCError_Unavailable(t *testing.T) { + grpcErr := status.Error(codes.Unavailable, "service down") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsUnavailable(err)) +} + +func TestFromGRPCError_PermissionDenied(t *testing.T) { + grpcErr := status.Error(codes.PermissionDenied, "denied") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsPermissionDenied(err)) +} + +func TestFromGRPCError_InvalidArgument(t *testing.T) { + grpcErr := status.Error(codes.InvalidArgument, "bad arg") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsInvalidArgument(err)) +} + +func TestFromGRPCError_DeadlineExceeded(t *testing.T) { + grpcErr := status.Error(codes.DeadlineExceeded, "timeout") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsDeadlineExceeded(err)) +} + +func TestFromGRPCError_Cancelled(t *testing.T) { + grpcErr := status.Error(codes.Canceled, "cancelled") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsCancelled(err)) +} + +func TestFromGRPCError_Internal(t *testing.T) { + grpcErr := status.Error(codes.Internal, "internal error") + err := FromGRPCError(grpcErr) + require.Error(t, err) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorInternal, se.Code) +} + +func TestFromGRPCError_Unimplemented(t *testing.T) { + grpcErr := status.Error(codes.Unimplemented, "not implemented") + err := FromGRPCError(grpcErr) + require.Error(t, err) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorUnimplemented, se.Code) +} + +func TestFromGRPCError_Aborted(t *testing.T) { + grpcErr := status.Error(codes.Aborted, "version conflict") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsConflict(err)) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorConflict, se.Code) + assert.Equal(t, "version conflict", se.Message) +} + +func TestFromGRPCError_UnmappedCode(t *testing.T) { + grpcErr := status.Error(codes.DataLoss, "data loss") + err := FromGRPCError(grpcErr) + require.Error(t, err) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorInternal, se.Code) +} + +func TestFromGRPCError_NilError(t *testing.T) { + err := FromGRPCError(nil) + assert.NoError(t, err) +} + +func TestFromGRPCError_NonGRPCError(t *testing.T) { + err := FromGRPCError(assert.AnError) + require.Error(t, err) + assert.Equal(t, assert.AnError, err) +} + +func TestFromGRPCError_OKStatus(t *testing.T) { + grpcErr := status.Error(codes.OK, "") + err := FromGRPCError(grpcErr) + assert.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/internal/converter/exec.go b/sdk/go/openshell/v1/internal/converter/exec.go new file mode 100644 index 0000000000..bb509cd613 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/exec.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "fmt" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// ExecChunkFromEvent converts a proto ExecSandboxEvent to an ExecChunk and/or exit code. +// For stdout/stderr events, returns the chunk with exitCode -1. +// For exit events, returns nil chunk with the exit code. +func ExecChunkFromEvent(event *pb.ExecSandboxEvent) (*types.ExecChunk, int, error) { + if event == nil { + return nil, -1, fmt.Errorf("nil exec event") + } + + switch p := event.Payload.(type) { + case *pb.ExecSandboxEvent_Stdout: + return &types.ExecChunk{ + Stream: types.StreamStdout, + Data: append([]byte(nil), p.Stdout.GetData()...), + }, -1, nil + case *pb.ExecSandboxEvent_Stderr: + return &types.ExecChunk{ + Stream: types.StreamStderr, + Data: append([]byte(nil), p.Stderr.GetData()...), + }, -1, nil + case *pb.ExecSandboxEvent_Exit: + return nil, int(p.Exit.GetExitCode()), nil + default: + return nil, -1, fmt.Errorf("unknown exec event payload type: %T", p) + } +} + +// ExecRequestToProto builds a proto ExecSandboxRequest for Run/Stream modes. +func ExecRequestToProto(sandboxID string, command []string, opts *types.ExecOptions) *pb.ExecSandboxRequest { + req := &pb.ExecSandboxRequest{ + SandboxId: sandboxID, + Command: command, + } + if opts != nil { + req.Workdir = opts.WorkDir + req.Environment = opts.Env + } + return req +} + +// ExecInteractiveRequestToProto builds a proto ExecSandboxRequest for Interactive mode. +func ExecInteractiveRequestToProto(sandboxID string, command []string, cols, rows uint32, opts *types.ExecOptions) *pb.ExecSandboxRequest { + req := ExecRequestToProto(sandboxID, command, opts) + req.Tty = true + req.Cols = cols + req.Rows = rows + return req +} + +// ExecResultFromEvents collects a sequence of ExecSandboxEvents into an ExecResult. +func ExecResultFromEvents(events []*pb.ExecSandboxEvent) (*types.ExecResult, error) { + if len(events) == 0 { + return nil, fmt.Errorf("no exec events received") + } + + var stdout, stderr []byte + exitCode := -1 + + for _, event := range events { + chunk, code, err := ExecChunkFromEvent(event) + if err != nil { + return nil, err + } + if chunk != nil { + switch chunk.Stream { + case types.StreamStdout: + stdout = append(stdout, chunk.Data...) + case types.StreamStderr: + stderr = append(stderr, chunk.Data...) + } + } else { + exitCode = code + } + } + + if exitCode == -1 { + return nil, fmt.Errorf("no exit event received") + } + + return &types.ExecResult{ + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + }, nil +} diff --git a/sdk/go/openshell/v1/internal/converter/exec_test.go b/sdk/go/openshell/v1/internal/converter/exec_test.go new file mode 100644 index 0000000000..078a72d4d1 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/exec_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecChunkFromEvent_Stdout(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stdout{ + Stdout: &pb.ExecSandboxStdout{ + Data: []byte("hello world"), + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStdout, chunk.Stream) + assert.Equal(t, []byte("hello world"), chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecChunkFromEvent_Stderr(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stderr{ + Stderr: &pb.ExecSandboxStderr{ + Data: []byte("error output"), + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStderr, chunk.Stream) + assert.Equal(t, []byte("error output"), chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecChunkFromEvent_Exit(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Exit{ + Exit: &pb.ExecSandboxExit{ + ExitCode: 42, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + assert.Nil(t, chunk) + assert.Equal(t, 42, exitCode) +} + +func TestExecChunkFromEvent_ExitZero(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Exit{ + Exit: &pb.ExecSandboxExit{ + ExitCode: 0, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + assert.Nil(t, chunk) + assert.Equal(t, 0, exitCode) +} + +func TestExecChunkFromEvent_NilEvent(t *testing.T) { + _, _, err := ExecChunkFromEvent(nil) + assert.Error(t, err) +} + +func TestExecChunkFromEvent_NilPayload(t *testing.T) { + event := &pb.ExecSandboxEvent{} + + _, _, err := ExecChunkFromEvent(event) + assert.Error(t, err) +} + +func TestExecChunkFromEvent_EmptyStdout(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stdout{ + Stdout: &pb.ExecSandboxStdout{ + Data: []byte{}, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStdout, chunk.Stream) + assert.Empty(t, chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecRequestToProto(t *testing.T) { + req := ExecRequestToProto("sb-1", []string{"ls", "-la"}, &v1.ExecOptions{ + Env: map[string]string{"FOO": "bar"}, + WorkDir: "/home/user", + }) + + require.NotNil(t, req) + assert.Equal(t, "sb-1", req.SandboxId) + assert.Equal(t, []string{"ls", "-la"}, req.Command) + assert.Equal(t, "/home/user", req.Workdir) + assert.Equal(t, map[string]string{"FOO": "bar"}, req.Environment) + assert.False(t, req.Tty) +} + +func TestExecRequestToProto_NilOptions(t *testing.T) { + req := ExecRequestToProto("sb-2", []string{"echo", "hi"}, nil) + + require.NotNil(t, req) + assert.Equal(t, "sb-2", req.SandboxId) + assert.Equal(t, []string{"echo", "hi"}, req.Command) + assert.Empty(t, req.Workdir) + assert.Nil(t, req.Environment) +} + +func TestExecRequestToProto_Interactive(t *testing.T) { + req := ExecInteractiveRequestToProto("sb-3", []string{"/bin/bash"}, 80, 24, &v1.ExecOptions{ + Env: map[string]string{"TERM": "xterm"}, + WorkDir: "/root", + }) + + require.NotNil(t, req) + assert.Equal(t, "sb-3", req.SandboxId) + assert.Equal(t, []string{"/bin/bash"}, req.Command) + assert.Equal(t, "/root", req.Workdir) + assert.Equal(t, map[string]string{"TERM": "xterm"}, req.Environment) + assert.True(t, req.Tty) + assert.Equal(t, uint32(80), req.Cols) + assert.Equal(t, uint32(24), req.Rows) +} + +func TestExecResultFromEvents(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("warn\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + + result, err := ExecResultFromEvents(events) + + require.NoError(t, err) + assert.Equal(t, 0, result.ExitCode) + assert.Equal(t, []byte("line1\nline2\n"), result.Stdout) + assert.Equal(t, []byte("warn\n"), result.Stderr) +} + +func TestExecResultFromEvents_NoExit(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("data")}}}, + } + + _, err := ExecResultFromEvents(events) + assert.Error(t, err) +} + +func TestExecResultFromEvents_Empty(t *testing.T) { + _, err := ExecResultFromEvents(nil) + assert.Error(t, err) +} + +func TestExecResultFromEvents_OnlyExit(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}}, + } + + result, err := ExecResultFromEvents(events) + + require.NoError(t, err) + assert.Equal(t, 1, result.ExitCode) + assert.Empty(t, result.Stdout) + assert.Empty(t, result.Stderr) +} diff --git a/sdk/go/openshell/v1/internal/converter/log.go b/sdk/go/openshell/v1/internal/converter/log.go new file mode 100644 index 0000000000..42f530fb1c --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/log.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// --- LogLine --- + +// LogLineFromProto converts a proto SandboxLogLine to an SDK LogLine. +func LogLineFromProto(l *pb.SandboxLogLine) *types.LogLine { + if l == nil { + return nil + } + return &types.LogLine{ + Timestamp: TimeFromMillis(l.GetTimestampMs()), + Level: l.GetLevel(), + Target: l.GetTarget(), + Message: l.GetMessage(), + Source: l.GetSource(), + Fields: CopyStringMap(l.GetFields()), + } +} + +// --- LogResult --- + +// LogResultFromProto converts a proto GetSandboxLogsResponse to an SDK LogResult. +func LogResultFromProto(r *pb.GetSandboxLogsResponse) *types.LogResult { + if r == nil { + return nil + } + result := &types.LogResult{ + BufferTotal: r.GetBufferTotal(), + } + if logs := r.GetLogs(); len(logs) > 0 { + result.Lines = make([]types.LogLine, 0, len(logs)) + for _, l := range logs { + if converted := LogLineFromProto(l); converted != nil { + result.Lines = append(result.Lines, *converted) + } + } + } + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/log_test.go b/sdk/go/openshell/v1/internal/converter/log_test.go new file mode 100644 index 0000000000..7462396262 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/log_test.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- LogLine --- + +func TestLogLineFromProto(t *testing.T) { + proto := &pb.SandboxLogLine{ + SandboxId: "sbx-1", + TimestampMs: 1700000000000, + Level: "INFO", + Target: "network", + Message: "Connection established", + Source: "sandbox-agent", + Fields: map[string]string{ + "host": "api.example.com", + "port": "443", + }, + } + + line := LogLineFromProto(proto) + + require.NotNil(t, line) + assert.False(t, line.Timestamp.IsZero()) + assert.Equal(t, "INFO", line.Level) + assert.Equal(t, "network", line.Target) + assert.Equal(t, "Connection established", line.Message) + assert.Equal(t, "sandbox-agent", line.Source) + assert.Equal(t, "api.example.com", line.Fields["host"]) + assert.Equal(t, "443", line.Fields["port"]) +} + +func TestLogLineFromProto_Nil(t *testing.T) { + assert.Nil(t, LogLineFromProto(nil)) +} + +func TestLogLineDeepCopy(t *testing.T) { + proto := &pb.SandboxLogLine{ + TimestampMs: 1700000000000, + Level: "WARN", + Message: "test", + Fields: map[string]string{ + "key": "value", + }, + } + + line := LogLineFromProto(proto) + proto.Fields["key"] = "changed" + + assert.Equal(t, "value", line.Fields["key"]) +} + +// --- LogResult --- + +func TestLogResultFromProto(t *testing.T) { + proto := &pb.GetSandboxLogsResponse{ + Logs: []*pb.SandboxLogLine{ + {TimestampMs: 1700000000000, Level: "INFO", Message: "first"}, + {TimestampMs: 1700000001000, Level: "DEBUG", Message: "second"}, + }, + BufferTotal: 100, + } + + result := LogResultFromProto(proto) + + require.NotNil(t, result) + assert.Len(t, result.Lines, 2) + assert.Equal(t, "INFO", result.Lines[0].Level) + assert.Equal(t, "first", result.Lines[0].Message) + assert.Equal(t, "DEBUG", result.Lines[1].Level) + assert.Equal(t, "second", result.Lines[1].Message) + assert.Equal(t, uint32(100), result.BufferTotal) +} + +func TestLogResultFromProto_Nil(t *testing.T) { + assert.Nil(t, LogResultFromProto(nil)) +} + +func TestLogResultFromProto_EmptyLogs(t *testing.T) { + proto := &pb.GetSandboxLogsResponse{ + BufferTotal: 0, + } + + result := LogResultFromProto(proto) + require.NotNil(t, result) + assert.Empty(t, result.Lines) + assert.Equal(t, uint32(0), result.BufferTotal) +} diff --git a/sdk/go/openshell/v1/internal/converter/network_policy.go b/sdk/go/openshell/v1/internal/converter/network_policy.go new file mode 100644 index 0000000000..1bf7d30d7e --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/network_policy.go @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- NetworkPolicyRule --- + +// NetworkPolicyRuleFromProto converts a proto NetworkPolicyRule to an SDK NetworkPolicyRule. +func NetworkPolicyRuleFromProto(r *sbv1.NetworkPolicyRule) *types.NetworkPolicyRule { + if r == nil { + return nil + } + result := &types.NetworkPolicyRule{ + Name: r.GetName(), + } + if eps := r.GetEndpoints(); len(eps) > 0 { + result.Endpoints = make([]types.PolicyNetworkEndpoint, len(eps)) + for i, ep := range eps { + if ep != nil { + result.Endpoints[i] = policyNetworkEndpointFromProto(ep) + } + } + } + if bins := r.GetBinaries(); len(bins) > 0 { + result.Binaries = make([]types.PolicyNetworkBinary, len(bins)) + for i, b := range bins { + if b != nil { + result.Binaries[i] = types.PolicyNetworkBinary{Path: b.GetPath()} + } + } + } + return result +} + +// NetworkPolicyRuleToProto converts an SDK NetworkPolicyRule to a proto NetworkPolicyRule. +func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *sbv1.NetworkPolicyRule { + if r == nil { + return nil + } + result := &sbv1.NetworkPolicyRule{ + Name: r.Name, + } + if len(r.Endpoints) > 0 { + result.Endpoints = make([]*sbv1.NetworkEndpoint, len(r.Endpoints)) + for i := range r.Endpoints { + result.Endpoints[i] = policyNetworkEndpointToProto(&r.Endpoints[i]) + } + } + if len(r.Binaries) > 0 { + result.Binaries = make([]*sbv1.NetworkBinary, len(r.Binaries)) + for i := range r.Binaries { + result.Binaries[i] = &sbv1.NetworkBinary{Path: r.Binaries[i].Path} + } + } + return result +} + +// --- PolicyNetworkEndpoint --- + +func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetworkEndpoint { + result := types.PolicyNetworkEndpoint{ + Host: ep.GetHost(), + Port: ep.GetPort(), + Protocol: ep.GetProtocol(), + TLS: ep.GetTls(), + Enforcement: ep.GetEnforcement(), + Access: ep.GetAccess(), + AllowEncodedSlash: ep.GetAllowEncodedSlash(), + PersistedQueries: ep.GetPersistedQueries(), + GraphqlMaxBodyBytes: ep.GetGraphqlMaxBodyBytes(), + Path: ep.GetPath(), + WebsocketCredentialRewrite: ep.GetWebsocketCredentialRewrite(), + RequestBodyCredentialRewrite: ep.GetRequestBodyCredentialRewrite(), + AdvisorProposed: ep.GetAdvisorProposed(), + } + if ports := ep.GetPorts(); len(ports) > 0 { + result.Ports = make([]uint32, len(ports)) + copy(result.Ports, ports) + } + if ips := ep.GetAllowedIps(); len(ips) > 0 { + result.AllowedIPs = CopyStringSlice(ips) + } + if rules := ep.GetRules(); len(rules) > 0 { + result.Rules = make([]types.L7Rule, len(rules)) + for i, r := range rules { + if r != nil { + result.Rules[i] = l7RuleFromProto(r) + } + } + } + if deny := ep.GetDenyRules(); len(deny) > 0 { + result.DenyRules = make([]types.L7DenyRule, len(deny)) + for i, r := range deny { + if r != nil { + result.DenyRules[i] = l7DenyRuleFromProto(r) + } + } + } + if gql := ep.GetGraphqlPersistedQueries(); len(gql) > 0 { + result.GraphqlPersistedQueries = make(map[string]types.GraphqlOperation, len(gql)) + for k, v := range gql { + if v != nil { + result.GraphqlPersistedQueries[k] = graphqlOperationFromProto(v) + } + } + } + return result +} + +func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.NetworkEndpoint { + result := &sbv1.NetworkEndpoint{ + Host: ep.Host, + Port: ep.Port, + Protocol: ep.Protocol, + Tls: ep.TLS, + Enforcement: ep.Enforcement, + Access: ep.Access, + AllowEncodedSlash: ep.AllowEncodedSlash, + PersistedQueries: ep.PersistedQueries, + GraphqlMaxBodyBytes: ep.GraphqlMaxBodyBytes, + Path: ep.Path, + WebsocketCredentialRewrite: ep.WebsocketCredentialRewrite, + RequestBodyCredentialRewrite: ep.RequestBodyCredentialRewrite, + AdvisorProposed: ep.AdvisorProposed, + } + if len(ep.Ports) > 0 { + result.Ports = make([]uint32, len(ep.Ports)) + copy(result.Ports, ep.Ports) + } + if len(ep.AllowedIPs) > 0 { + result.AllowedIps = CopyStringSlice(ep.AllowedIPs) + } + if len(ep.Rules) > 0 { + result.Rules = make([]*sbv1.L7Rule, len(ep.Rules)) + for i := range ep.Rules { + result.Rules[i] = l7RuleToProto(&ep.Rules[i]) + } + } + if len(ep.DenyRules) > 0 { + result.DenyRules = make([]*sbv1.L7DenyRule, len(ep.DenyRules)) + for i := range ep.DenyRules { + result.DenyRules[i] = l7DenyRuleToProto(&ep.DenyRules[i]) + } + } + if len(ep.GraphqlPersistedQueries) > 0 { + result.GraphqlPersistedQueries = make(map[string]*sbv1.GraphqlOperation, len(ep.GraphqlPersistedQueries)) + for k, v := range ep.GraphqlPersistedQueries { + result.GraphqlPersistedQueries[k] = graphqlOperationToProto(&v) + } + } + return result +} + +// --- L7Rule --- + +func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule { + result := types.L7Rule{} + if a := r.GetAllow(); a != nil { + result.Allow = &types.L7Allow{ + Method: a.GetMethod(), + Path: a.GetPath(), + Command: a.GetCommand(), + OperationType: a.GetOperationType(), + OperationName: a.GetOperationName(), + Fields: CopyStringSlice(a.GetFields()), + } + if q := a.GetQuery(); len(q) > 0 { + result.Allow.Query = l7QueryMapFromProto(q) + } + } + return result +} + +func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule { + result := &sbv1.L7Rule{} + if r.Allow != nil { + result.Allow = &sbv1.L7Allow{ + Method: r.Allow.Method, + Path: r.Allow.Path, + Command: r.Allow.Command, + OperationType: r.Allow.OperationType, + OperationName: r.Allow.OperationName, + Fields: CopyStringSlice(r.Allow.Fields), + } + if len(r.Allow.Query) > 0 { + result.Allow.Query = l7QueryMapToProto(r.Allow.Query) + } + } + return result +} + +// --- L7DenyRule --- + +func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule { + return types.L7DenyRule{ + Method: r.GetMethod(), + Path: r.GetPath(), + Command: r.GetCommand(), + OperationType: r.GetOperationType(), + OperationName: r.GetOperationName(), + Fields: CopyStringSlice(r.GetFields()), + Query: l7QueryMapFromProtoDeny(r.GetQuery()), + } +} + +func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule { + result := &sbv1.L7DenyRule{ + Method: r.Method, + Path: r.Path, + Command: r.Command, + OperationType: r.OperationType, + OperationName: r.OperationName, + Fields: CopyStringSlice(r.Fields), + } + if len(r.Query) > 0 { + result.Query = l7QueryMapToProtoDeny(r.Query) + } + return result +} + +// --- L7QueryMatcher helpers --- + +func l7QueryMapFromProto(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher { + if len(m) == 0 { + return nil + } + result := make(map[string]types.L7QueryMatcher, len(m)) + for k, v := range m { + if v != nil { + result[k] = types.L7QueryMatcher{ + Glob: v.GetGlob(), + Any: CopyStringSlice(v.GetAny()), + } + } + } + return result +} + +func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher { + if len(m) == 0 { + return nil + } + result := make(map[string]*sbv1.L7QueryMatcher, len(m)) + for k, v := range m { + result[k] = &sbv1.L7QueryMatcher{ + Glob: v.Glob, + Any: CopyStringSlice(v.Any), + } + } + return result +} + +// L7DenyRule uses the same L7QueryMatcher proto type but on a different message. +func l7QueryMapFromProtoDeny(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher { + return l7QueryMapFromProto(m) +} + +func l7QueryMapToProtoDeny(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher { + return l7QueryMapToProto(m) +} + +// --- GraphqlOperation --- + +func graphqlOperationFromProto(op *sbv1.GraphqlOperation) types.GraphqlOperation { + return types.GraphqlOperation{ + OperationType: op.GetOperationType(), + OperationName: op.GetOperationName(), + Fields: CopyStringSlice(op.GetFields()), + } +} + +func graphqlOperationToProto(op *types.GraphqlOperation) *sbv1.GraphqlOperation { + return &sbv1.GraphqlOperation{ + OperationType: op.OperationType, + OperationName: op.OperationName, + Fields: CopyStringSlice(op.Fields), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/network_policy_test.go b/sdk/go/openshell/v1/internal/converter/network_policy_test.go new file mode 100644 index 0000000000..8916bccb9b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/network_policy_test.go @@ -0,0 +1,272 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- NetworkPolicyRule round-trip --- + +func TestNetworkPolicyRuleFromProto(t *testing.T) { + proto := &sbv1.NetworkPolicyRule{ + Name: "web-api", + Endpoints: []*sbv1.NetworkEndpoint{ + { + Host: "api.example.com", + Port: 443, + Protocol: "rest", + Tls: "strict", + Enforcement: "enforce", + Access: "allow", + Ports: []uint32{80, 443}, + AllowedIps: []string{"10.0.0.1", "10.0.0.2"}, + AllowEncodedSlash: true, + PersistedQueries: "allow", + GraphqlMaxBodyBytes: 1024, + Path: "/api/v1", + WebsocketCredentialRewrite: true, + RequestBodyCredentialRewrite: false, + AdvisorProposed: true, + Rules: []*sbv1.L7Rule{ + { + Allow: &sbv1.L7Allow{ + Method: "GET", + Path: "/users", + Command: "list", + Query: map[string]*sbv1.L7QueryMatcher{ + "page": {Glob: "[0-9]*", Any: []string{"1", "2"}}, + }, + OperationType: "query", + OperationName: "GetUsers", + Fields: []string{"id", "name"}, + }, + }, + }, + DenyRules: []*sbv1.L7DenyRule{ + { + Method: "DELETE", + Path: "/admin", + Command: "rm", + OperationType: "mutation", + OperationName: "DeleteAll", + Fields: []string{"*"}, + Query: map[string]*sbv1.L7QueryMatcher{ + "force": {Glob: "true"}, + }, + }, + }, + GraphqlPersistedQueries: map[string]*sbv1.GraphqlOperation{ + "abc123": { + OperationType: "query", + OperationName: "GetUser", + Fields: []string{"id", "email"}, + }, + }, + }, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/curl"}, + }, + } + + rule := NetworkPolicyRuleFromProto(proto) + + require.NotNil(t, rule) + assert.Equal(t, "web-api", rule.Name) + require.Len(t, rule.Endpoints, 1) + ep := rule.Endpoints[0] + assert.Equal(t, "api.example.com", ep.Host) + assert.Equal(t, uint32(443), ep.Port) + assert.Equal(t, "rest", ep.Protocol) + assert.Equal(t, "strict", ep.TLS) + assert.Equal(t, "enforce", ep.Enforcement) + assert.Equal(t, "allow", ep.Access) + assert.Equal(t, []uint32{80, 443}, ep.Ports) + assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, ep.AllowedIPs) + assert.True(t, ep.AllowEncodedSlash) + assert.Equal(t, "allow", ep.PersistedQueries) + assert.Equal(t, uint32(1024), ep.GraphqlMaxBodyBytes) + assert.Equal(t, "/api/v1", ep.Path) + assert.True(t, ep.WebsocketCredentialRewrite) + assert.False(t, ep.RequestBodyCredentialRewrite) + assert.True(t, ep.AdvisorProposed) + + // L7 rules + require.Len(t, ep.Rules, 1) + allow := ep.Rules[0].Allow + require.NotNil(t, allow) + assert.Equal(t, "GET", allow.Method) + assert.Equal(t, "/users", allow.Path) + assert.Equal(t, "list", allow.Command) + assert.Equal(t, "query", allow.OperationType) + assert.Equal(t, "GetUsers", allow.OperationName) + assert.Equal(t, []string{"id", "name"}, allow.Fields) + require.Contains(t, allow.Query, "page") + assert.Equal(t, "[0-9]*", allow.Query["page"].Glob) + assert.Equal(t, []string{"1", "2"}, allow.Query["page"].Any) + + // Deny rules + require.Len(t, ep.DenyRules, 1) + deny := ep.DenyRules[0] + assert.Equal(t, "DELETE", deny.Method) + assert.Equal(t, "/admin", deny.Path) + assert.Equal(t, "rm", deny.Command) + assert.Equal(t, "mutation", deny.OperationType) + assert.Equal(t, "DeleteAll", deny.OperationName) + assert.Equal(t, []string{"*"}, deny.Fields) + require.Contains(t, deny.Query, "force") + assert.Equal(t, "true", deny.Query["force"].Glob) + + // GraphQL persisted queries + require.Contains(t, ep.GraphqlPersistedQueries, "abc123") + gql := ep.GraphqlPersistedQueries["abc123"] + assert.Equal(t, "query", gql.OperationType) + assert.Equal(t, "GetUser", gql.OperationName) + assert.Equal(t, []string{"id", "email"}, gql.Fields) + + // Binaries + require.Len(t, rule.Binaries, 1) + assert.Equal(t, "/usr/bin/curl", rule.Binaries[0].Path) +} + +func TestNetworkPolicyRuleFromProto_Nil(t *testing.T) { + assert.Nil(t, NetworkPolicyRuleFromProto(nil)) +} + +func TestNetworkPolicyRuleRoundTrip(t *testing.T) { + original := &v1.NetworkPolicyRule{ + Name: "graphql-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + { + Host: "gql.example.com", + Port: 8080, + Protocol: "graphql", + TLS: "permissive", + Enforcement: "audit", + Access: "allow", + Ports: []uint32{8080, 8443}, + AllowedIPs: []string{"192.168.1.0/24"}, + AllowEncodedSlash: false, + PersistedQueries: "enforce", + GraphqlMaxBodyBytes: 2048, + Path: "/graphql", + WebsocketCredentialRewrite: false, + RequestBodyCredentialRewrite: true, + AdvisorProposed: false, + Rules: []v1.L7Rule{ + { + Allow: &v1.L7Allow{ + Method: "POST", + Path: "/graphql", + OperationType: "query", + OperationName: "ListItems", + Fields: []string{"id"}, + Query: map[string]v1.L7QueryMatcher{ + "limit": {Glob: "[0-9]+"}, + }, + }, + }, + }, + DenyRules: []v1.L7DenyRule{ + { + Method: "POST", + Path: "/graphql", + OperationType: "mutation", + OperationName: "DropDB", + }, + }, + GraphqlPersistedQueries: map[string]v1.GraphqlOperation{ + "hash1": { + OperationType: "query", + OperationName: "Safe", + Fields: []string{"f1"}, + }, + }, + }, + }, + Binaries: []v1.PolicyNetworkBinary{ + {Path: "/usr/bin/wget"}, + }, + } + + proto := NetworkPolicyRuleToProto(original) + require.NotNil(t, proto) + + roundTrip := NetworkPolicyRuleFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Name, roundTrip.Name) + require.Len(t, roundTrip.Endpoints, 1) + assert.Equal(t, original.Endpoints[0].Host, roundTrip.Endpoints[0].Host) + assert.Equal(t, original.Endpoints[0].Port, roundTrip.Endpoints[0].Port) + assert.Equal(t, original.Endpoints[0].Protocol, roundTrip.Endpoints[0].Protocol) + assert.Equal(t, original.Endpoints[0].TLS, roundTrip.Endpoints[0].TLS) + assert.Equal(t, original.Endpoints[0].Enforcement, roundTrip.Endpoints[0].Enforcement) + assert.Equal(t, original.Endpoints[0].Access, roundTrip.Endpoints[0].Access) + assert.Equal(t, original.Endpoints[0].Ports, roundTrip.Endpoints[0].Ports) + assert.Equal(t, original.Endpoints[0].AllowedIPs, roundTrip.Endpoints[0].AllowedIPs) + assert.Equal(t, original.Endpoints[0].AllowEncodedSlash, roundTrip.Endpoints[0].AllowEncodedSlash) + assert.Equal(t, original.Endpoints[0].GraphqlMaxBodyBytes, roundTrip.Endpoints[0].GraphqlMaxBodyBytes) + assert.Equal(t, original.Endpoints[0].AdvisorProposed, roundTrip.Endpoints[0].AdvisorProposed) + + // L7 rules round-trip + require.Len(t, roundTrip.Endpoints[0].Rules, 1) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Method, roundTrip.Endpoints[0].Rules[0].Allow.Method) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.OperationName, roundTrip.Endpoints[0].Rules[0].Allow.OperationName) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Query["limit"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Query["limit"].Glob) + + // Deny rules round-trip + require.Len(t, roundTrip.Endpoints[0].DenyRules, 1) + assert.Equal(t, original.Endpoints[0].DenyRules[0].OperationName, roundTrip.Endpoints[0].DenyRules[0].OperationName) + + // GraphQL persisted queries round-trip + require.Contains(t, roundTrip.Endpoints[0].GraphqlPersistedQueries, "hash1") + + // Binaries round-trip + require.Len(t, roundTrip.Binaries, 1) + assert.Equal(t, original.Binaries[0].Path, roundTrip.Binaries[0].Path) +} + +func TestNetworkPolicyRuleToProto_Nil(t *testing.T) { + assert.Nil(t, NetworkPolicyRuleToProto(nil)) +} + +func TestNetworkPolicyRuleDeepCopy(t *testing.T) { + proto := &sbv1.NetworkPolicyRule{ + Name: "test", + Endpoints: []*sbv1.NetworkEndpoint{ + { + AllowedIps: []string{"1.2.3.4"}, + Ports: []uint32{80}, + Rules: []*sbv1.L7Rule{ + {Allow: &sbv1.L7Allow{Fields: []string{"f1"}}}, + }, + }, + }, + } + + rule := NetworkPolicyRuleFromProto(proto) + + // Mutate proto source + proto.Endpoints[0].AllowedIps[0] = "changed" + proto.Endpoints[0].Ports[0] = 9999 + proto.Endpoints[0].Rules[0].Allow.Fields[0] = "changed" + + // SDK type should be unaffected + assert.Equal(t, "1.2.3.4", rule.Endpoints[0].AllowedIPs[0]) + assert.Equal(t, uint32(80), rule.Endpoints[0].Ports[0]) + assert.Equal(t, "f1", rule.Endpoints[0].Rules[0].Allow.Fields[0]) +} + +func TestL7RuleFromProto_NilAllow(t *testing.T) { + proto := &sbv1.L7Rule{Allow: nil} + result := l7RuleFromProto(proto) + assert.Nil(t, result.Allow) +} diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go new file mode 100644 index 0000000000..780fadb56c --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- PolicyLoadStatus enum mapping --- + +// PolicyLoadStatusFromProto converts a proto PolicyStatus to an SDK PolicyLoadStatus. +func PolicyLoadStatusFromProto(s pb.PolicyStatus) types.PolicyLoadStatus { + switch s { + case pb.PolicyStatus_POLICY_STATUS_PENDING: + return types.PolicyLoadStatusPending + case pb.PolicyStatus_POLICY_STATUS_LOADED: + return types.PolicyLoadStatusLoaded + case pb.PolicyStatus_POLICY_STATUS_FAILED: + return types.PolicyLoadStatusFailed + case pb.PolicyStatus_POLICY_STATUS_SUPERSEDED: + return types.PolicyLoadStatusSuperseded + default: + return types.PolicyLoadStatusUnspecified + } +} + +// PolicyLoadStatusToProto converts an SDK PolicyLoadStatus to a proto PolicyStatus. +func PolicyLoadStatusToProto(s types.PolicyLoadStatus) pb.PolicyStatus { + switch s { + case types.PolicyLoadStatusPending: + return pb.PolicyStatus_POLICY_STATUS_PENDING + case types.PolicyLoadStatusLoaded: + return pb.PolicyStatus_POLICY_STATUS_LOADED + case types.PolicyLoadStatusFailed: + return pb.PolicyStatus_POLICY_STATUS_FAILED + case types.PolicyLoadStatusSuperseded: + return pb.PolicyStatus_POLICY_STATUS_SUPERSEDED + default: + return pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED + } +} + +// --- PolicyChunk --- + +// PolicyChunkFromProto converts a proto PolicyChunk to an SDK PolicyChunk. +func PolicyChunkFromProto(c *pb.PolicyChunk) *types.PolicyChunk { + if c == nil { + return nil + } + return &types.PolicyChunk{ + ID: c.GetId(), + Status: c.GetStatus(), + RuleName: c.GetRuleName(), + ProposedRule: NetworkPolicyRuleFromProto(c.GetProposedRule()), + Rationale: c.GetRationale(), + SecurityNotes: c.GetSecurityNotes(), + Confidence: c.GetConfidence(), + DenialSummaryIDs: CopyStringSlice(c.GetDenialSummaryIds()), + CreatedAt: TimeFromMillis(c.GetCreatedAtMs()), + DecidedAt: TimeFromMillis(c.GetDecidedAtMs()), + Stage: c.GetStage(), + SupersedesChunkID: c.GetSupersedesChunkId(), + HitCount: c.GetHitCount(), + FirstSeen: TimeFromMillis(c.GetFirstSeenMs()), + LastSeen: TimeFromMillis(c.GetLastSeenMs()), + Binary: c.GetBinary(), + ValidationResult: c.GetValidationResult(), + RejectionReason: c.GetRejectionReason(), + } +} + +// --- DraftPolicy --- + +// DraftPolicyFromProto converts a proto GetDraftPolicyResponse to an SDK DraftPolicy. +func DraftPolicyFromProto(r *pb.GetDraftPolicyResponse) *types.DraftPolicy { + if r == nil { + return nil + } + result := &types.DraftPolicy{ + RollingSummary: r.GetRollingSummary(), + DraftVersion: r.GetDraftVersion(), + LastAnalyzedAt: TimeFromMillis(r.GetLastAnalyzedAtMs()), + } + if chunks := r.GetChunks(); len(chunks) > 0 { + result.Chunks = make([]types.PolicyChunk, 0, len(chunks)) + for _, c := range chunks { + if converted := PolicyChunkFromProto(c); converted != nil { + result.Chunks = append(result.Chunks, *converted) + } + } + } + return result +} + +// --- SandboxPolicy --- + +// SandboxPolicyFromProto converts a proto SandboxPolicy to an SDK SandboxPolicy. +// Returns nil for nil input. All slice and map fields are deep-copied. +func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { + if p == nil { + return nil + } + result := &types.SandboxPolicy{ + Version: p.GetVersion(), + Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), + Landlock: landlockPolicyFromProto(p.GetLandlock()), + Process: processPolicyFromProto(p.GetProcess()), + } + if np := p.GetNetworkPolicies(); np != nil { + result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np)) + for k, v := range np { + if converted := NetworkPolicyRuleFromProto(v); converted != nil { + result.NetworkPolicies[k] = *converted + } + } + } + return result +} + +// SandboxPolicyToProto converts an SDK SandboxPolicy to a proto SandboxPolicy. +// Returns nil for nil input. All slice and map fields are deep-copied. +func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { + if p == nil { + return nil + } + result := &sbv1.SandboxPolicy{ + Version: p.Version, + Filesystem: filesystemPolicyToProto(p.Filesystem), + Landlock: landlockPolicyToProto(p.Landlock), + Process: processPolicyToProto(p.Process), + } + if p.NetworkPolicies != nil { + result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) + for k, v := range p.NetworkPolicies { + result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v) + } + } + return result +} + +func filesystemPolicyFromProto(f *sbv1.FilesystemPolicy) *types.FilesystemPolicy { + if f == nil { + return nil + } + return &types.FilesystemPolicy{ + IncludeWorkdir: f.GetIncludeWorkdir(), + ReadOnly: CopyStringSlice(f.GetReadOnly()), + ReadWrite: CopyStringSlice(f.GetReadWrite()), + } +} + +func filesystemPolicyToProto(f *types.FilesystemPolicy) *sbv1.FilesystemPolicy { + if f == nil { + return nil + } + return &sbv1.FilesystemPolicy{ + IncludeWorkdir: f.IncludeWorkdir, + ReadOnly: CopyStringSlice(f.ReadOnly), + ReadWrite: CopyStringSlice(f.ReadWrite), + } +} + +func landlockPolicyFromProto(l *sbv1.LandlockPolicy) *types.LandlockPolicy { + if l == nil { + return nil + } + return &types.LandlockPolicy{ + Compatibility: l.GetCompatibility(), + } +} + +func landlockPolicyToProto(l *types.LandlockPolicy) *sbv1.LandlockPolicy { + if l == nil { + return nil + } + return &sbv1.LandlockPolicy{ + Compatibility: l.Compatibility, + } +} + +func processPolicyFromProto(p *sbv1.ProcessPolicy) *types.ProcessPolicy { + if p == nil { + return nil + } + return &types.ProcessPolicy{ + RunAsUser: p.GetRunAsUser(), + RunAsGroup: p.GetRunAsGroup(), + } +} + +func processPolicyToProto(p *types.ProcessPolicy) *sbv1.ProcessPolicy { + if p == nil { + return nil + } + return &sbv1.ProcessPolicy{ + RunAsUser: p.RunAsUser, + RunAsGroup: p.RunAsGroup, + } +} + +// --- SandboxPolicyRevision --- + +// SandboxPolicyRevisionFromProto converts a proto SandboxPolicyRevision to an SDK SandboxPolicyRevision. +func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxPolicyRevision { + if r == nil { + return nil + } + return &types.SandboxPolicyRevision{ + Version: r.GetVersion(), + PolicyHash: r.GetPolicyHash(), + Status: PolicyLoadStatusFromProto(r.GetStatus()), + LoadError: r.GetLoadError(), + CreatedAt: TimeFromMillis(r.GetCreatedAtMs()), + LoadedAt: TimeFromMillis(r.GetLoadedAtMs()), + Policy: SandboxPolicyFromProto(r.GetPolicy()), + } +} + +// --- PolicyStatusResult --- + +// PolicyStatusResultFromProto converts a proto GetSandboxPolicyStatusResponse to an SDK PolicyStatusResult. +func PolicyStatusResultFromProto(r *pb.GetSandboxPolicyStatusResponse) *types.PolicyStatusResult { + if r == nil { + return nil + } + result := &types.PolicyStatusResult{ + ActiveVersion: r.GetActiveVersion(), + } + if rev := SandboxPolicyRevisionFromProto(r.GetRevision()); rev != nil { + result.Revision = *rev + } + return result +} + +// --- ApproveResult --- + +// ApproveResultFromProto converts a proto ApproveDraftChunkResponse to an SDK ApproveResult. +func ApproveResultFromProto(r *pb.ApproveDraftChunkResponse) *types.ApproveResult { + if r == nil { + return nil + } + return &types.ApproveResult{ + PolicyVersion: r.GetPolicyVersion(), + PolicyHash: r.GetPolicyHash(), + } +} + +// --- ApproveAllResult --- + +// ApproveAllResultFromProto converts a proto ApproveAllDraftChunksResponse to an SDK ApproveAllResult. +func ApproveAllResultFromProto(r *pb.ApproveAllDraftChunksResponse) *types.ApproveAllResult { + if r == nil { + return nil + } + return &types.ApproveAllResult{ + PolicyVersion: r.GetPolicyVersion(), + PolicyHash: r.GetPolicyHash(), + ChunksApproved: r.GetChunksApproved(), + ChunksSkipped: r.GetChunksSkipped(), + } +} + +// --- UndoResult --- + +// UndoResultFromProto converts a proto UndoDraftChunkResponse to an SDK UndoResult. +func UndoResultFromProto(r *pb.UndoDraftChunkResponse) *types.UndoResult { + if r == nil { + return nil + } + return &types.UndoResult{ + PolicyVersion: r.GetPolicyVersion(), + PolicyHash: r.GetPolicyHash(), + } +} + +// --- ClearResult --- + +// ClearResultFromProto converts a proto ClearDraftChunksResponse to an SDK ClearResult. +func ClearResultFromProto(r *pb.ClearDraftChunksResponse) *types.ClearResult { + if r == nil { + return nil + } + return &types.ClearResult{ + ChunksCleared: r.GetChunksCleared(), + } +} + +// --- DraftHistoryEntry --- + +// DraftHistoryEntryFromProto converts a proto DraftHistoryEntry to an SDK DraftHistoryEntry. +func DraftHistoryEntryFromProto(e *pb.DraftHistoryEntry) *types.DraftHistoryEntry { + if e == nil { + return nil + } + return &types.DraftHistoryEntry{ + Timestamp: TimeFromMillis(e.GetTimestampMs()), + EventType: e.GetEventType(), + Description: e.GetDescription(), + ChunkID: e.GetChunkId(), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go new file mode 100644 index 0000000000..f83dac59c5 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -0,0 +1,608 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- PolicyLoadStatus --- + +func TestPolicyLoadStatusFromProto(t *testing.T) { + tests := []struct { + proto pb.PolicyStatus + want v1.PolicyLoadStatus + }{ + {pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED, v1.PolicyLoadStatusUnspecified}, + {pb.PolicyStatus_POLICY_STATUS_PENDING, v1.PolicyLoadStatusPending}, + {pb.PolicyStatus_POLICY_STATUS_LOADED, v1.PolicyLoadStatusLoaded}, + {pb.PolicyStatus_POLICY_STATUS_FAILED, v1.PolicyLoadStatusFailed}, + {pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, v1.PolicyLoadStatusSuperseded}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicyLoadStatusFromProto(tt.proto)) + }) + } +} + +func TestPolicyLoadStatusToProto(t *testing.T) { + tests := []struct { + sdk v1.PolicyLoadStatus + want pb.PolicyStatus + }{ + {v1.PolicyLoadStatusUnspecified, pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED}, + {v1.PolicyLoadStatusPending, pb.PolicyStatus_POLICY_STATUS_PENDING}, + {v1.PolicyLoadStatusLoaded, pb.PolicyStatus_POLICY_STATUS_LOADED}, + {v1.PolicyLoadStatusFailed, pb.PolicyStatus_POLICY_STATUS_FAILED}, + {v1.PolicyLoadStatusSuperseded, pb.PolicyStatus_POLICY_STATUS_SUPERSEDED}, + } + for _, tt := range tests { + t.Run(tt.sdk.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicyLoadStatusToProto(tt.sdk)) + }) + } +} + +func TestPolicyLoadStatusRoundTrip(t *testing.T) { + for _, s := range []v1.PolicyLoadStatus{ + v1.PolicyLoadStatusUnspecified, + v1.PolicyLoadStatusPending, + v1.PolicyLoadStatusLoaded, + v1.PolicyLoadStatusFailed, + v1.PolicyLoadStatusSuperseded, + } { + assert.Equal(t, s, PolicyLoadStatusFromProto(PolicyLoadStatusToProto(s))) + } +} + +// --- PolicyChunk --- + +func TestPolicyChunkFromProto(t *testing.T) { + proto := &pb.PolicyChunk{ + Id: "chunk-1", + Status: "pending", + RuleName: "web-api", + Rationale: "Observed DNS resolution", + SecurityNotes: "No concerns", + Confidence: 0.95, + DenialSummaryIds: []string{"d1", "d2"}, + CreatedAtMs: 1700000000000, + DecidedAtMs: 1700000001000, + Stage: "initial", + SupersedesChunkId: "chunk-0", + HitCount: 5, + FirstSeenMs: 1699999999000, + LastSeenMs: 1700000000500, + Binary: "/usr/bin/curl", + ValidationResult: "valid", + RejectionReason: "", + ProposedRule: &sbv1.NetworkPolicyRule{ + Name: "web-api", + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + } + + chunk := PolicyChunkFromProto(proto) + + require.NotNil(t, chunk) + assert.Equal(t, "chunk-1", chunk.ID) + assert.Equal(t, "pending", chunk.Status) + assert.Equal(t, "web-api", chunk.RuleName) + assert.Equal(t, "Observed DNS resolution", chunk.Rationale) + assert.Equal(t, "No concerns", chunk.SecurityNotes) + assert.InDelta(t, float32(0.95), chunk.Confidence, 0.001) + assert.Equal(t, []string{"d1", "d2"}, chunk.DenialSummaryIDs) + assert.False(t, chunk.CreatedAt.IsZero()) + assert.False(t, chunk.DecidedAt.IsZero()) + assert.Equal(t, "initial", chunk.Stage) + assert.Equal(t, "chunk-0", chunk.SupersedesChunkID) + assert.Equal(t, int32(5), chunk.HitCount) + assert.False(t, chunk.FirstSeen.IsZero()) + assert.False(t, chunk.LastSeen.IsZero()) + assert.Equal(t, "/usr/bin/curl", chunk.Binary) + assert.Equal(t, "valid", chunk.ValidationResult) + assert.Empty(t, chunk.RejectionReason) + + require.NotNil(t, chunk.ProposedRule) + assert.Equal(t, "web-api", chunk.ProposedRule.Name) + require.Len(t, chunk.ProposedRule.Endpoints, 1) + assert.Equal(t, "api.example.com", chunk.ProposedRule.Endpoints[0].Host) +} + +func TestPolicyChunkFromProto_Nil(t *testing.T) { + assert.Nil(t, PolicyChunkFromProto(nil)) +} + +func TestPolicyChunkDeepCopy(t *testing.T) { + proto := &pb.PolicyChunk{ + Id: "c1", + DenialSummaryIds: []string{"d1"}, + } + + chunk := PolicyChunkFromProto(proto) + proto.DenialSummaryIds[0] = "changed" + + assert.Equal(t, "d1", chunk.DenialSummaryIDs[0]) +} + +// --- DraftPolicy --- + +func TestDraftPolicyFromProto(t *testing.T) { + proto := &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + {Id: "c1", Status: "pending", RuleName: "rule1"}, + {Id: "c2", Status: "approved", RuleName: "rule2"}, + }, + RollingSummary: "Analysis summary", + DraftVersion: 42, + LastAnalyzedAtMs: 1700000000000, + } + + draft := DraftPolicyFromProto(proto) + + require.NotNil(t, draft) + assert.Len(t, draft.Chunks, 2) + assert.Equal(t, "c1", draft.Chunks[0].ID) + assert.Equal(t, "c2", draft.Chunks[1].ID) + assert.Equal(t, "Analysis summary", draft.RollingSummary) + assert.Equal(t, uint64(42), draft.DraftVersion) + assert.False(t, draft.LastAnalyzedAt.IsZero()) +} + +func TestDraftPolicyFromProto_Nil(t *testing.T) { + assert.Nil(t, DraftPolicyFromProto(nil)) +} + +func TestDraftPolicyFromProto_EmptyChunks(t *testing.T) { + proto := &pb.GetDraftPolicyResponse{ + RollingSummary: "empty", + DraftVersion: 1, + } + + draft := DraftPolicyFromProto(proto) + require.NotNil(t, draft) + assert.Empty(t, draft.Chunks) +} + +// --- SandboxPolicy --- + +func TestSandboxPolicyFromProtoNil(t *testing.T) { + assert.Nil(t, SandboxPolicyFromProto(nil)) +} + +func TestSandboxPolicyToProtoNil(t *testing.T) { + assert.Nil(t, SandboxPolicyToProto(nil)) +} + +func TestSandboxPolicyRoundTrip(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 5, + Filesystem: &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp", "/workspace"}, + }, + Landlock: &v1.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &v1.ProcessPolicy{ + RunAsUser: "sandbox-user", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "web-api": { + Name: "web-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + "db": { + Name: "db", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "db.internal", Port: 5432, Protocol: "tcp"}, + }, + }, + }, + } + + proto := SandboxPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := SandboxPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Version, roundTrip.Version) + + // Filesystem + require.NotNil(t, roundTrip.Filesystem) + assert.Equal(t, original.Filesystem.IncludeWorkdir, roundTrip.Filesystem.IncludeWorkdir) + assert.Equal(t, original.Filesystem.ReadOnly, roundTrip.Filesystem.ReadOnly) + assert.Equal(t, original.Filesystem.ReadWrite, roundTrip.Filesystem.ReadWrite) + + // Landlock + require.NotNil(t, roundTrip.Landlock) + assert.Equal(t, original.Landlock.Compatibility, roundTrip.Landlock.Compatibility) + + // Process + require.NotNil(t, roundTrip.Process) + assert.Equal(t, original.Process.RunAsUser, roundTrip.Process.RunAsUser) + assert.Equal(t, original.Process.RunAsGroup, roundTrip.Process.RunAsGroup) + + // NetworkPolicies + require.Len(t, roundTrip.NetworkPolicies, 2) + webAPI, ok := roundTrip.NetworkPolicies["web-api"] + require.True(t, ok) + assert.Equal(t, "web-api", webAPI.Name) + require.Len(t, webAPI.Endpoints, 1) + assert.Equal(t, "api.example.com", webAPI.Endpoints[0].Host) + + db, ok := roundTrip.NetworkPolicies["db"] + require.True(t, ok) + assert.Equal(t, "db", db.Name) +} + +func TestSandboxPolicyDeepCopy(t *testing.T) { + // Build a proto, convert to SDK, mutate proto, verify SDK is isolated. + proto := &sbv1.SandboxPolicy{ + Version: 1, + Filesystem: &sbv1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/original"}, + ReadWrite: []string{"/tmp"}, + }, + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{ + "rule1": { + Name: "rule1", + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "original.host", Port: 80}, + }, + }, + }, + } + + sdk := SandboxPolicyFromProto(proto) + require.NotNil(t, sdk) + + // Mutate proto source after conversion. + proto.Version = 99 + proto.Filesystem.ReadOnly[0] = "mutated" + proto.Filesystem.ReadWrite[0] = "mutated" + proto.NetworkPolicies["rule1"].Name = "mutated" + proto.NetworkPolicies["rule1"].Endpoints[0].Host = "mutated.host" + + // SDK values must be unaffected. + assert.Equal(t, uint32(1), sdk.Version) + assert.Equal(t, "/original", sdk.Filesystem.ReadOnly[0]) + assert.Equal(t, "/tmp", sdk.Filesystem.ReadWrite[0]) + assert.Equal(t, "rule1", sdk.NetworkPolicies["rule1"].Name) + assert.Equal(t, "original.host", sdk.NetworkPolicies["rule1"].Endpoints[0].Host) + + // Also test ToProto deep-copy isolation. + protoOut := SandboxPolicyToProto(sdk) + require.NotNil(t, protoOut) + + // Mutate SDK after ToProto conversion. + sdk.Filesystem.ReadOnly[0] = "sdk-mutated" + + // Proto output must be unaffected. + assert.Equal(t, "/original", protoOut.Filesystem.ReadOnly[0]) +} + +func TestSandboxPolicyPartialSubPolicies(t *testing.T) { + t.Run("only filesystem", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 1, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + assert.Nil(t, roundTrip.Process) + assert.Nil(t, roundTrip.NetworkPolicies) + }) + + t.Run("only landlock", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 2, + Landlock: &v1.LandlockPolicy{ + Compatibility: "hard_requirement", + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + require.NotNil(t, roundTrip.Landlock) + assert.Equal(t, "hard_requirement", roundTrip.Landlock.Compatibility) + assert.Nil(t, roundTrip.Process) + assert.Nil(t, roundTrip.NetworkPolicies) + }) + + t.Run("only process", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Process: &v1.ProcessPolicy{ + RunAsUser: "nobody", + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + require.NotNil(t, roundTrip.Process) + assert.Equal(t, "nobody", roundTrip.Process.RunAsUser) + }) + + t.Run("only network policies", func(t *testing.T) { + original := &v1.SandboxPolicy{ + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "r1": {Name: "r1"}, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + assert.Nil(t, roundTrip.Process) + require.Len(t, roundTrip.NetworkPolicies, 1) + }) + + t.Run("empty network policies map preserved", func(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{}, + } + // Proto empty map is non-nil, so converter creates an empty SDK map. + sdk := SandboxPolicyFromProto(proto) + require.NotNil(t, sdk) + require.NotNil(t, sdk.NetworkPolicies) + assert.Empty(t, sdk.NetworkPolicies) + }) +} + +func TestFilesystemPolicyRoundTrip(t *testing.T) { + original := &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/lib"}, + ReadWrite: []string{"/tmp", "/var/run"}, + } + + proto := filesystemPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := filesystemPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.IncludeWorkdir, roundTrip.IncludeWorkdir) + assert.Equal(t, original.ReadOnly, roundTrip.ReadOnly) + assert.Equal(t, original.ReadWrite, roundTrip.ReadWrite) +} + +func TestFilesystemPolicyNil(t *testing.T) { + assert.Nil(t, filesystemPolicyFromProto(nil)) + assert.Nil(t, filesystemPolicyToProto(nil)) +} + +func TestLandlockPolicyRoundTrip(t *testing.T) { + original := &v1.LandlockPolicy{ + Compatibility: "best_effort", + } + + proto := landlockPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := landlockPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Compatibility, roundTrip.Compatibility) +} + +func TestLandlockPolicyNil(t *testing.T) { + assert.Nil(t, landlockPolicyFromProto(nil)) + assert.Nil(t, landlockPolicyToProto(nil)) +} + +func TestProcessPolicyRoundTrip(t *testing.T) { + original := &v1.ProcessPolicy{ + RunAsUser: "app-user", + RunAsGroup: "app-group", + } + + proto := processPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := processPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.RunAsUser, roundTrip.RunAsUser) + assert.Equal(t, original.RunAsGroup, roundTrip.RunAsGroup) +} + +func TestProcessPolicyNil(t *testing.T) { + assert.Nil(t, processPolicyFromProto(nil)) + assert.Nil(t, processPolicyToProto(nil)) +} + +// --- SandboxPolicyRevision --- + +func TestSandboxPolicyRevisionFromProto(t *testing.T) { + proto := &pb.SandboxPolicyRevision{ + Version: 3, + PolicyHash: "sha256:abc123", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + LoadError: "", + CreatedAtMs: 1700000000000, + LoadedAtMs: 1700000001000, + } + + rev := SandboxPolicyRevisionFromProto(proto) + + require.NotNil(t, rev) + assert.Equal(t, uint32(3), rev.Version) + assert.Equal(t, "sha256:abc123", rev.PolicyHash) + assert.Equal(t, v1.PolicyLoadStatusLoaded, rev.Status) + assert.Empty(t, rev.LoadError) + assert.False(t, rev.CreatedAt.IsZero()) + assert.False(t, rev.LoadedAt.IsZero()) +} + +func TestSandboxPolicyRevisionFromProto_Nil(t *testing.T) { + assert.Nil(t, SandboxPolicyRevisionFromProto(nil)) +} + +func TestSandboxPolicyRevisionFromProto_WithPolicy(t *testing.T) { + proto := &pb.SandboxPolicyRevision{ + Version: 1, + PolicyHash: "sha256:def", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + Policy: &sbv1.SandboxPolicy{ + Version: 2, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + rev := SandboxPolicyRevisionFromProto(proto) + require.NotNil(t, rev) + require.NotNil(t, rev.Policy, "typed SandboxPolicy should be populated when proto policy is set") + assert.Equal(t, uint32(2), rev.Policy.Version) + require.NotNil(t, rev.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, rev.Policy.Filesystem.ReadOnly) +} + +// --- PolicyStatusResult --- + +func TestPolicyStatusResultFromProto(t *testing.T) { + proto := &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 5, + PolicyHash: "sha256:xyz", + Status: pb.PolicyStatus_POLICY_STATUS_PENDING, + }, + ActiveVersion: 4, + } + + result := PolicyStatusResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(5), result.Revision.Version) + assert.Equal(t, "sha256:xyz", result.Revision.PolicyHash) + assert.Equal(t, v1.PolicyLoadStatusPending, result.Revision.Status) + assert.Equal(t, uint32(4), result.ActiveVersion) +} + +func TestPolicyStatusResultFromProto_Nil(t *testing.T) { + assert.Nil(t, PolicyStatusResultFromProto(nil)) +} + +// --- ApproveResult --- + +func TestApproveResultFromProto(t *testing.T) { + proto := &pb.ApproveDraftChunkResponse{ + PolicyVersion: 7, + PolicyHash: "sha256:merged", + } + + result := ApproveResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(7), result.PolicyVersion) + assert.Equal(t, "sha256:merged", result.PolicyHash) +} + +func TestApproveResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ApproveResultFromProto(nil)) +} + +// --- ApproveAllResult --- + +func TestApproveAllResultFromProto(t *testing.T) { + proto := &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 8, + PolicyHash: "sha256:all", + ChunksApproved: 10, + ChunksSkipped: 2, + } + + result := ApproveAllResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(8), result.PolicyVersion) + assert.Equal(t, "sha256:all", result.PolicyHash) + assert.Equal(t, uint32(10), result.ChunksApproved) + assert.Equal(t, uint32(2), result.ChunksSkipped) +} + +func TestApproveAllResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ApproveAllResultFromProto(nil)) +} + +// --- UndoResult --- + +func TestUndoResultFromProto(t *testing.T) { + proto := &pb.UndoDraftChunkResponse{ + PolicyVersion: 6, + PolicyHash: "sha256:reverted", + } + + result := UndoResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(6), result.PolicyVersion) + assert.Equal(t, "sha256:reverted", result.PolicyHash) +} + +func TestUndoResultFromProto_Nil(t *testing.T) { + assert.Nil(t, UndoResultFromProto(nil)) +} + +// --- ClearResult --- + +func TestClearResultFromProto(t *testing.T) { + proto := &pb.ClearDraftChunksResponse{ + ChunksCleared: 15, + } + + result := ClearResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(15), result.ChunksCleared) +} + +func TestClearResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ClearResultFromProto(nil)) +} + +// --- DraftHistoryEntry --- + +func TestDraftHistoryEntryFromProto(t *testing.T) { + proto := &pb.DraftHistoryEntry{ + TimestampMs: 1700000000000, + EventType: "approved", + Description: "Chunk c1 approved", + ChunkId: "c1", + } + + entry := DraftHistoryEntryFromProto(proto) + + require.NotNil(t, entry) + assert.False(t, entry.Timestamp.IsZero()) + assert.Equal(t, "approved", entry.EventType) + assert.Equal(t, "Chunk c1 approved", entry.Description) + assert.Equal(t, "c1", entry.ChunkID) +} + +func TestDraftHistoryEntryFromProto_Nil(t *testing.T) { + assert.Nil(t, DraftHistoryEntryFromProto(nil)) +} diff --git a/sdk/go/openshell/v1/internal/converter/profile.go b/sdk/go/openshell/v1/internal/converter/profile.go new file mode 100644 index 0000000000..21fb83306b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- ProfileCategory enum mapping --- + +// ProfileCategoryFromProto converts a proto ProviderProfileCategory to an SDK ProfileCategory. +func ProfileCategoryFromProto(c pb.ProviderProfileCategory) types.ProfileCategory { + switch c { + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER: + return types.ProfileCategoryOther + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE: + return types.ProfileCategoryInference + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT: + return types.ProfileCategoryAgent + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL: + return types.ProfileCategorySourceControl + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING: + return types.ProfileCategoryMessaging + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA: + return types.ProfileCategoryData + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE: + return types.ProfileCategoryKnowledge + default: + return types.ProfileCategory("") + } +} + +// ProfileCategoryToProto converts an SDK ProfileCategory to a proto ProviderProfileCategory. +func ProfileCategoryToProto(c types.ProfileCategory) pb.ProviderProfileCategory { + switch c { + case types.ProfileCategoryOther: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER + case types.ProfileCategoryInference: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE + case types.ProfileCategoryAgent: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT + case types.ProfileCategorySourceControl: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL + case types.ProfileCategoryMessaging: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING + case types.ProfileCategoryData: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA + case types.ProfileCategoryKnowledge: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE + default: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED + } +} + +// --- NetworkEndpoint --- + +// NetworkEndpointFromProto converts a proto NetworkEndpoint to an SDK NetworkEndpoint. +// Only Host, Port, and Protocol are mapped; additional proto fields are ignored. +func NetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) *types.NetworkEndpoint { + if ep == nil { + return nil + } + return &types.NetworkEndpoint{ + Host: ep.GetHost(), + Port: ep.GetPort(), + Protocol: ep.GetProtocol(), + } +} + +// NetworkEndpointToProto converts an SDK NetworkEndpoint to a proto NetworkEndpoint. +func NetworkEndpointToProto(ep *types.NetworkEndpoint) *sbv1.NetworkEndpoint { + if ep == nil { + return nil + } + return &sbv1.NetworkEndpoint{ + Host: ep.Host, + Port: ep.Port, + Protocol: ep.Protocol, + } +} + +// --- NetworkBinary --- + +// NetworkBinaryFromProto converts a proto NetworkBinary to an SDK NetworkBinary. +func NetworkBinaryFromProto(b *sbv1.NetworkBinary) *types.NetworkBinary { + if b == nil { + return nil + } + return &types.NetworkBinary{ + Path: b.GetPath(), + } +} + +// NetworkBinaryToProto converts an SDK NetworkBinary to a proto NetworkBinary. +func NetworkBinaryToProto(b *types.NetworkBinary) *sbv1.NetworkBinary { + if b == nil { + return nil + } + return &sbv1.NetworkBinary{ + Path: b.Path, + } +} + +// --- ProfileCredential --- + +// ProfileCredentialFromProto converts a proto ProviderProfileCredential to an SDK ProfileCredential. +// The SDK maps 4 fields from the 10-field proto: Name, Description, Required, and Secret. +// Secret is derived from whether the proto has a Refresh configuration. +func ProfileCredentialFromProto(c *pb.ProviderProfileCredential) *types.ProfileCredential { + if c == nil { + return nil + } + return &types.ProfileCredential{ + Name: c.GetName(), + Description: c.GetDescription(), + Required: c.GetRequired(), + Secret: c.GetRefresh() != nil, + } +} + +// ProfileCredentialToProto converts an SDK ProfileCredential to a proto ProviderProfileCredential. +// Only Name, Description, and Required are mapped. Secret is not round-trippable +// because it is derived from the Refresh field in the proto. +func ProfileCredentialToProto(c *types.ProfileCredential) *pb.ProviderProfileCredential { + if c == nil { + return nil + } + return &pb.ProviderProfileCredential{ + Name: c.Name, + Description: c.Description, + Required: c.Required, + } +} + +// --- ProfileDiagnostic --- + +// ProfileDiagnosticFromProto converts a proto ProviderProfileDiagnostic to an SDK ProfileDiagnostic. +func ProfileDiagnosticFromProto(d *pb.ProviderProfileDiagnostic) *types.ProfileDiagnostic { + if d == nil { + return nil + } + return &types.ProfileDiagnostic{ + Source: d.GetSource(), + ProfileID: d.GetProfileId(), + Field: d.GetField(), + Message: d.GetMessage(), + Severity: d.GetSeverity(), + } +} + +// --- ProviderProfile --- + +// ProviderProfileFromProto converts a proto ProviderProfile to an SDK ProviderProfile. +func ProviderProfileFromProto(p *pb.ProviderProfile) *types.ProviderProfile { + if p == nil { + return nil + } + + result := &types.ProviderProfile{ + ID: p.GetId(), + DisplayName: p.GetDisplayName(), + Description: p.GetDescription(), + Category: ProfileCategoryFromProto(p.GetCategory()), + InferenceCapable: p.GetInferenceCapable(), + ResourceVersion: p.GetResourceVersion(), + } + + // Credentials + if creds := p.GetCredentials(); len(creds) > 0 { + result.Credentials = make([]types.ProfileCredential, len(creds)) + for i, c := range creds { + if converted := ProfileCredentialFromProto(c); converted != nil { + result.Credentials[i] = *converted + } + } + } + + // Endpoints + if eps := p.GetEndpoints(); len(eps) > 0 { + result.Endpoints = make([]types.NetworkEndpoint, len(eps)) + for i, ep := range eps { + if converted := NetworkEndpointFromProto(ep); converted != nil { + result.Endpoints[i] = *converted + } + } + } + + // Binaries + if bins := p.GetBinaries(); len(bins) > 0 { + result.Binaries = make([]types.NetworkBinary, len(bins)) + for i, b := range bins { + if converted := NetworkBinaryFromProto(b); converted != nil { + result.Binaries[i] = *converted + } + } + } + + // Discovery + if d := p.GetDiscovery(); d != nil { + result.Discovery = types.ProfileDiscovery{ + Credentials: CopyStringSlice(d.GetCredentials()), + } + } + + return result +} + +// ProviderProfileToProto converts an SDK ProviderProfile to a proto ProviderProfile. +func ProviderProfileToProto(p *types.ProviderProfile) *pb.ProviderProfile { + if p == nil { + return nil + } + + result := &pb.ProviderProfile{ + Id: p.ID, + DisplayName: p.DisplayName, + Description: p.Description, + Category: ProfileCategoryToProto(p.Category), + InferenceCapable: p.InferenceCapable, + ResourceVersion: p.ResourceVersion, + } + + // Credentials + if len(p.Credentials) > 0 { + result.Credentials = make([]*pb.ProviderProfileCredential, len(p.Credentials)) + for i := range p.Credentials { + result.Credentials[i] = ProfileCredentialToProto(&p.Credentials[i]) + } + } + + // Endpoints + if len(p.Endpoints) > 0 { + result.Endpoints = make([]*sbv1.NetworkEndpoint, len(p.Endpoints)) + for i := range p.Endpoints { + result.Endpoints[i] = NetworkEndpointToProto(&p.Endpoints[i]) + } + } + + // Binaries + if len(p.Binaries) > 0 { + result.Binaries = make([]*sbv1.NetworkBinary, len(p.Binaries)) + for i := range p.Binaries { + result.Binaries[i] = NetworkBinaryToProto(&p.Binaries[i]) + } + } + + // Discovery + if len(p.Discovery.Credentials) > 0 { + result.Discovery = &pb.ProviderProfileDiscovery{ + Credentials: CopyStringSlice(p.Discovery.Credentials), + } + } + + return result +} + +// --- ProfileImportItem --- + +// ProfileImportItemToProto converts an SDK ProfileImportItem to a proto ProviderProfileImportItem. +func ProfileImportItemToProto(item *types.ProfileImportItem) *pb.ProviderProfileImportItem { + if item == nil { + return nil + } + return &pb.ProviderProfileImportItem{ + Profile: ProviderProfileToProto(&item.Profile), + Source: item.Source, + } +} + +// ProfileImportItemFromProto converts a proto ProviderProfileImportItem to an SDK ProfileImportItem. +func ProfileImportItemFromProto(item *pb.ProviderProfileImportItem) *types.ProfileImportItem { + if item == nil { + return nil + } + + result := &types.ProfileImportItem{ + Source: item.GetSource(), + } + + if p := ProviderProfileFromProto(item.GetProfile()); p != nil { + result.Profile = *p + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go new file mode 100644 index 0000000000..b3bf266995 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- ProfileCategory --- + +func TestProfileCategoryFromProto(t *testing.T) { + tests := []struct { + proto pb.ProviderProfileCategory + want v1.ProfileCategory + }{ + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER, v1.ProfileCategoryOther}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, v1.ProfileCategoryInference}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT, v1.ProfileCategoryAgent}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL, v1.ProfileCategorySourceControl}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING, v1.ProfileCategoryMessaging}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA, v1.ProfileCategoryData}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE, v1.ProfileCategoryKnowledge}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED, v1.ProfileCategory("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, ProfileCategoryFromProto(tt.proto)) + }) + } +} + +func TestProfileCategoryToProto(t *testing.T) { + tests := []struct { + sdk v1.ProfileCategory + want pb.ProviderProfileCategory + }{ + {v1.ProfileCategoryOther, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER}, + {v1.ProfileCategoryInference, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE}, + {v1.ProfileCategoryAgent, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT}, + {v1.ProfileCategorySourceControl, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL}, + {v1.ProfileCategoryMessaging, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING}, + {v1.ProfileCategoryData, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA}, + {v1.ProfileCategoryKnowledge, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE}, + {v1.ProfileCategory(""), pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED}, + {v1.ProfileCategory("Unknown"), pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, ProfileCategoryToProto(tt.sdk)) + }) + } +} + +// --- NetworkEndpoint --- + +func TestNetworkEndpointFromProto(t *testing.T) { + proto := &sbv1.NetworkEndpoint{ + Host: "api.example.com", + Port: 443, + Protocol: "rest", + } + + ep := NetworkEndpointFromProto(proto) + + require.NotNil(t, ep) + assert.Equal(t, "api.example.com", ep.Host) + assert.Equal(t, uint32(443), ep.Port) + assert.Equal(t, "rest", ep.Protocol) +} + +func TestNetworkEndpointFromProto_Nil(t *testing.T) { + ep := NetworkEndpointFromProto(nil) + assert.Nil(t, ep) +} + +func TestNetworkEndpointToProto(t *testing.T) { + ep := &v1.NetworkEndpoint{ + Host: "api.example.com", + Port: 443, + Protocol: "rest", + } + + proto := NetworkEndpointToProto(ep) + + require.NotNil(t, proto) + assert.Equal(t, "api.example.com", proto.Host) + assert.Equal(t, uint32(443), proto.Port) + assert.Equal(t, "rest", proto.Protocol) +} + +func TestNetworkEndpointToProto_Nil(t *testing.T) { + proto := NetworkEndpointToProto(nil) + assert.Nil(t, proto) +} + +// --- NetworkBinary --- + +func TestNetworkBinaryFromProto(t *testing.T) { + proto := &sbv1.NetworkBinary{ + Path: "/usr/local/bin/tool", + } + + bin := NetworkBinaryFromProto(proto) + + require.NotNil(t, bin) + assert.Equal(t, "/usr/local/bin/tool", bin.Path) +} + +func TestNetworkBinaryFromProto_Nil(t *testing.T) { + bin := NetworkBinaryFromProto(nil) + assert.Nil(t, bin) +} + +func TestNetworkBinaryToProto(t *testing.T) { + bin := &v1.NetworkBinary{ + Path: "/usr/local/bin/tool", + } + + proto := NetworkBinaryToProto(bin) + + require.NotNil(t, proto) + assert.Equal(t, "/usr/local/bin/tool", proto.Path) +} + +func TestNetworkBinaryToProto_Nil(t *testing.T) { + proto := NetworkBinaryToProto(nil) + assert.Nil(t, proto) +} + +// --- ProfileCredential --- + +func TestProfileCredentialFromProto(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "API_KEY", + Description: "API key for auth", + Required: true, + Refresh: &pb.ProviderCredentialRefresh{ + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, + }, + } + + cred := ProfileCredentialFromProto(proto) + + require.NotNil(t, cred) + assert.Equal(t, "API_KEY", cred.Name) + assert.Equal(t, "API key for auth", cred.Description) + assert.True(t, cred.Required) + assert.True(t, cred.Secret, "credential with refresh config is secret") +} + +func TestProfileCredentialFromProto_NotSecret(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "ENDPOINT_URL", + Required: false, + } + + cred := ProfileCredentialFromProto(proto) + + require.NotNil(t, cred) + assert.Equal(t, "ENDPOINT_URL", cred.Name) + assert.False(t, cred.Required) + assert.False(t, cred.Secret, "credential without refresh config is not secret") +} + +func TestProfileCredentialFromProto_Nil(t *testing.T) { + cred := ProfileCredentialFromProto(nil) + assert.Nil(t, cred) +} + +// --- ProfileDiagnostic --- + +func TestProfileDiagnosticFromProto(t *testing.T) { + proto := &pb.ProviderProfileDiagnostic{ + Source: "import", + ProfileId: "prof-1", + Field: "credentials", + Message: "missing required field", + Severity: "error", + } + + diag := ProfileDiagnosticFromProto(proto) + + require.NotNil(t, diag) + assert.Equal(t, "import", diag.Source) + assert.Equal(t, "prof-1", diag.ProfileID) + assert.Equal(t, "credentials", diag.Field) + assert.Equal(t, "missing required field", diag.Message) + assert.Equal(t, "error", diag.Severity) +} + +func TestProfileDiagnosticFromProto_Nil(t *testing.T) { + diag := ProfileDiagnosticFromProto(nil) + assert.Nil(t, diag) +} + +// --- ProviderProfile --- + +func TestProviderProfileFromProto(t *testing.T) { + proto := &pb.ProviderProfile{ + Id: "prof-1", + DisplayName: "Claude Provider", + Description: "Anthropic Claude", + Category: pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, + Credentials: []*pb.ProviderProfileCredential{ + {Name: "API_KEY", Description: "key", Required: true}, + }, + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/claude"}, + }, + InferenceCapable: true, + Discovery: &pb.ProviderProfileDiscovery{ + Credentials: []string{"API_KEY"}, + }, + ResourceVersion: 7, + } + + profile := ProviderProfileFromProto(proto) + + require.NotNil(t, profile) + assert.Equal(t, "prof-1", profile.ID) + assert.Equal(t, "Claude Provider", profile.DisplayName) + assert.Equal(t, "Anthropic Claude", profile.Description) + assert.Equal(t, v1.ProfileCategoryInference, profile.Category) + assert.True(t, profile.InferenceCapable) + assert.Equal(t, uint64(7), profile.ResourceVersion) + + require.Len(t, profile.Credentials, 1) + assert.Equal(t, "API_KEY", profile.Credentials[0].Name) + assert.True(t, profile.Credentials[0].Required) + + require.Len(t, profile.Endpoints, 1) + assert.Equal(t, "api.anthropic.com", profile.Endpoints[0].Host) + assert.Equal(t, uint32(443), profile.Endpoints[0].Port) + + require.Len(t, profile.Binaries, 1) + assert.Equal(t, "/usr/bin/claude", profile.Binaries[0].Path) + + assert.Equal(t, []string{"API_KEY"}, profile.Discovery.Credentials) +} + +func TestProviderProfileFromProto_NilDiscovery(t *testing.T) { + proto := &pb.ProviderProfile{ + Id: "prof-2", + } + + profile := ProviderProfileFromProto(proto) + + require.NotNil(t, profile) + assert.Nil(t, profile.Discovery.Credentials) +} + +func TestProviderProfileFromProto_Nil(t *testing.T) { + profile := ProviderProfileFromProto(nil) + assert.Nil(t, profile) +} + +func TestProviderProfileToProto(t *testing.T) { + profile := &v1.ProviderProfile{ + ID: "prof-1", + DisplayName: "Claude Provider", + Description: "Anthropic Claude", + Category: v1.ProfileCategoryInference, + Credentials: []v1.ProfileCredential{ + {Name: "API_KEY", Description: "key", Required: true, Secret: true}, + }, + Endpoints: []v1.NetworkEndpoint{ + {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + }, + Binaries: []v1.NetworkBinary{ + {Path: "/usr/bin/claude"}, + }, + InferenceCapable: true, + Discovery: v1.ProfileDiscovery{ + Credentials: []string{"API_KEY"}, + }, + ResourceVersion: 7, + } + + proto := ProviderProfileToProto(profile) + + require.NotNil(t, proto) + assert.Equal(t, "prof-1", proto.Id) + assert.Equal(t, "Claude Provider", proto.DisplayName) + assert.Equal(t, "Anthropic Claude", proto.Description) + assert.Equal(t, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, proto.Category) + assert.True(t, proto.InferenceCapable) + assert.Equal(t, uint64(7), proto.ResourceVersion) + + require.Len(t, proto.Credentials, 1) + assert.Equal(t, "API_KEY", proto.Credentials[0].Name) + + require.Len(t, proto.Endpoints, 1) + assert.Equal(t, "api.anthropic.com", proto.Endpoints[0].Host) + + require.Len(t, proto.Binaries, 1) + assert.Equal(t, "/usr/bin/claude", proto.Binaries[0].Path) + + require.NotNil(t, proto.Discovery) + assert.Equal(t, []string{"API_KEY"}, proto.Discovery.Credentials) +} + +func TestProviderProfileToProto_Nil(t *testing.T) { + proto := ProviderProfileToProto(nil) + assert.Nil(t, proto) +} + +// --- ProfileImportItem --- + +func TestProfileImportItemToProto(t *testing.T) { + item := &v1.ProfileImportItem{ + Profile: v1.ProviderProfile{ + ID: "prof-1", + DisplayName: "Test", + Category: v1.ProfileCategoryOther, + }, + Source: "file:///profiles/test.yaml", + } + + proto := ProfileImportItemToProto(item) + + require.NotNil(t, proto) + assert.Equal(t, "file:///profiles/test.yaml", proto.Source) + require.NotNil(t, proto.Profile) + assert.Equal(t, "prof-1", proto.Profile.Id) + assert.Equal(t, "Test", proto.Profile.DisplayName) +} + +func TestProfileImportItemToProto_Nil(t *testing.T) { + proto := ProfileImportItemToProto(nil) + assert.Nil(t, proto) +} + +func TestProfileImportItemFromProto(t *testing.T) { + proto := &pb.ProviderProfileImportItem{ + Profile: &pb.ProviderProfile{ + Id: "prof-1", + DisplayName: "Test", + }, + Source: "file:///profiles/test.yaml", + } + + item := ProfileImportItemFromProto(proto) + + require.NotNil(t, item) + assert.Equal(t, "file:///profiles/test.yaml", item.Source) + assert.Equal(t, "prof-1", item.Profile.ID) +} + +func TestProfileImportItemFromProto_Nil(t *testing.T) { + item := ProfileImportItemFromProto(nil) + assert.Nil(t, item) +} + +// --- ProviderProfile round-trip --- + +func TestProviderProfileRoundTrip(t *testing.T) { + original := &v1.ProviderProfile{ + ID: "prof-rt", + DisplayName: "Round Trip", + Description: "Testing round trip", + Category: v1.ProfileCategoryAgent, + Credentials: []v1.ProfileCredential{ + {Name: "TOKEN", Description: "auth token", Required: true, Secret: false}, + }, + Endpoints: []v1.NetworkEndpoint{ + {Host: "agent.example.com", Port: 8080, Protocol: "websocket"}, + }, + Binaries: []v1.NetworkBinary{ + {Path: "/bin/agent"}, + }, + InferenceCapable: false, + Discovery: v1.ProfileDiscovery{ + Credentials: []string{"TOKEN"}, + }, + ResourceVersion: 42, + } + + proto := ProviderProfileToProto(original) + back := ProviderProfileFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.DisplayName, back.DisplayName) + assert.Equal(t, original.Description, back.Description) + assert.Equal(t, original.Category, back.Category) + assert.Equal(t, original.InferenceCapable, back.InferenceCapable) + assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + + require.Len(t, back.Credentials, 1) + assert.Equal(t, original.Credentials[0].Name, back.Credentials[0].Name) + assert.Equal(t, original.Credentials[0].Required, back.Credentials[0].Required) + + require.Len(t, back.Endpoints, 1) + assert.Equal(t, original.Endpoints[0].Host, back.Endpoints[0].Host) + assert.Equal(t, original.Endpoints[0].Port, back.Endpoints[0].Port) + + require.Len(t, back.Binaries, 1) + assert.Equal(t, original.Binaries[0].Path, back.Binaries[0].Path) + + assert.Equal(t, original.Discovery.Credentials, back.Discovery.Credentials) +} diff --git a/sdk/go/openshell/v1/internal/converter/provider.go b/sdk/go/openshell/v1/internal/converter/provider.go new file mode 100644 index 0000000000..52fca5433d --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/provider.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" +) + +// ProviderFromProto converts a proto Provider to an SDK Provider. +func ProviderFromProto(p *dm.Provider) *types.Provider { + if p == nil { + return nil + } + + result := &types.Provider{ + Type: p.GetType(), + Spec: types.ProviderSpec{ + Config: CopyStringMap(p.GetConfig()), + }, + } + + if m := p.GetMetadata(); m != nil { + result.ID = m.GetId() + result.Name = m.GetName() + result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.Labels = CopyStringMap(m.GetLabels()) + result.ResourceVersion = m.GetResourceVersion() + } + + if expires := p.GetCredentialExpiresAtMs(); len(expires) > 0 { + result.Spec.CredentialExpiresAt = make(map[string]time.Time, len(expires)) + for k, ms := range expires { + result.Spec.CredentialExpiresAt[k] = TimeFromMillis(ms) + } + } + + return result +} + +// ProviderToProto converts an SDK Provider to a proto Provider. +func ProviderToProto(p *types.Provider) *dm.Provider { + if p == nil { + return nil + } + + result := &dm.Provider{ + Metadata: &dm.ObjectMeta{ + Id: p.ID, + Name: p.Name, + CreatedAtMs: MillisFromTime(p.CreatedAt), + Labels: CopyStringMap(p.Labels), + ResourceVersion: p.ResourceVersion, + }, + Type: p.Type, + Credentials: CopyStringMap(p.Spec.Credentials), + Config: CopyStringMap(p.Spec.Config), + } + + if len(p.Spec.CredentialExpiresAt) > 0 { + result.CredentialExpiresAtMs = make(map[string]int64, len(p.Spec.CredentialExpiresAt)) + for k, t := range p.Spec.CredentialExpiresAt { + result.CredentialExpiresAtMs[k] = MillisFromTime(t) + } + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/provider_test.go b/sdk/go/openshell/v1/internal/converter/provider_test.go new file mode 100644 index 0000000000..f8f0c940e9 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/provider_test.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProviderFromProto(t *testing.T) { + proto := &dm.Provider{ + Metadata: &dm.ObjectMeta{ + Id: "prov-1", + Name: "my-claude", + CreatedAtMs: 1700000000000, + Labels: map[string]string{"env": "prod"}, + ResourceVersion: 5, + }, + Type: "claude", + Credentials: map[string]string{"API_KEY": "secret"}, + Config: map[string]string{"region": "us-east-1"}, + CredentialExpiresAtMs: map[string]int64{ + "API_KEY": 1700100000000, + }, + } + + p := ProviderFromProto(proto) + + require.NotNil(t, p) + assert.Equal(t, "prov-1", p.ID) + assert.Equal(t, "my-claude", p.Name) + assert.Equal(t, "claude", p.Type) + assert.Equal(t, time.UnixMilli(1700000000000).UTC(), p.CreatedAt) + assert.Equal(t, map[string]string{"env": "prod"}, p.Labels) + assert.Equal(t, uint64(5), p.ResourceVersion) + assert.Nil(t, p.Spec.Credentials, "credentials are write-only") + assert.Equal(t, map[string]string{"region": "us-east-1"}, p.Spec.Config) + assert.Equal(t, time.UnixMilli(1700100000000).UTC(), p.Spec.CredentialExpiresAt["API_KEY"]) +} + +func TestProviderFromProto_NilMetadata(t *testing.T) { + proto := &dm.Provider{ + Type: "gitlab", + } + + p := ProviderFromProto(proto) + + require.NotNil(t, p) + assert.Empty(t, p.ID) + assert.Empty(t, p.Name) + assert.Equal(t, "gitlab", p.Type) + assert.True(t, p.CreatedAt.IsZero()) +} + +func TestProviderFromProto_Nil(t *testing.T) { + p := ProviderFromProto(nil) + assert.Nil(t, p) +} + +func TestProviderToProto(t *testing.T) { + p := &v1.Provider{ + ID: "prov-1", + Name: "my-claude", + Type: "claude", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"env": "prod"}, + ResourceVersion: 5, + Spec: v1.ProviderSpec{ + Credentials: map[string]string{"API_KEY": "secret"}, + Config: map[string]string{"region": "us-east-1"}, + CredentialExpiresAt: map[string]time.Time{ + "API_KEY": time.UnixMilli(1700100000000).UTC(), + }, + }, + } + + proto := ProviderToProto(p) + + require.NotNil(t, proto) + require.NotNil(t, proto.Metadata) + assert.Equal(t, "prov-1", proto.Metadata.Id) + assert.Equal(t, "my-claude", proto.Metadata.Name) + assert.Equal(t, int64(1700000000000), proto.Metadata.CreatedAtMs) + assert.Equal(t, map[string]string{"env": "prod"}, proto.Metadata.Labels) + assert.Equal(t, uint64(5), proto.Metadata.ResourceVersion) + assert.Equal(t, "claude", proto.Type) + assert.Equal(t, map[string]string{"API_KEY": "secret"}, proto.Credentials) + assert.Equal(t, map[string]string{"region": "us-east-1"}, proto.Config) + assert.Equal(t, int64(1700100000000), proto.CredentialExpiresAtMs["API_KEY"]) +} + +func TestProviderToProto_Nil(t *testing.T) { + proto := ProviderToProto(nil) + assert.Nil(t, proto) +} + +func TestProviderRoundTrip(t *testing.T) { + original := &v1.Provider{ + ID: "prov-rt", + Name: "round-trip", + Type: "github", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"team": "infra"}, + ResourceVersion: 42, + Spec: v1.ProviderSpec{ + Credentials: map[string]string{"TOKEN": "abc123"}, + Config: map[string]string{"org": "myorg"}, + CredentialExpiresAt: map[string]time.Time{ + "TOKEN": time.UnixMilli(1700200000000).UTC(), + }, + }, + } + + proto := ProviderToProto(original) + back := ProviderFromProto(proto) + + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.Name, back.Name) + assert.Equal(t, original.Type, back.Type) + assert.Equal(t, original.CreatedAt, back.CreatedAt) + assert.Equal(t, original.Labels, back.Labels) + assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + assert.Nil(t, back.Spec.Credentials, "credentials are write-only and should not be returned") + assert.Equal(t, original.Spec.Config, back.Spec.Config) + assert.Equal(t, original.Spec.CredentialExpiresAt, back.Spec.CredentialExpiresAt) +} diff --git a/sdk/go/openshell/v1/internal/converter/refresh.go b/sdk/go/openshell/v1/internal/converter/refresh.go new file mode 100644 index 0000000000..cd6aced7bc --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/refresh.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// --- RefreshStrategy enum mapping --- + +// RefreshStrategyFromProto converts a proto ProviderCredentialRefreshStrategy to an SDK RefreshStrategy. +func RefreshStrategyFromProto(s pb.ProviderCredentialRefreshStrategy) types.RefreshStrategy { + switch s { + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC: + return types.RefreshStrategyStatic + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL: + return types.RefreshStrategyExternal + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN: + return types.RefreshStrategyOAuth2RefreshToken + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS: + return types.RefreshStrategyOAuth2ClientCredentials + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT: + return types.RefreshStrategyGoogleServiceAccountJWT + default: + return types.RefreshStrategy("") + } +} + +// RefreshStrategyToProto converts an SDK RefreshStrategy to a proto ProviderCredentialRefreshStrategy. +func RefreshStrategyToProto(s types.RefreshStrategy) pb.ProviderCredentialRefreshStrategy { + switch s { + case types.RefreshStrategyStatic: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC + case types.RefreshStrategyExternal: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL + case types.RefreshStrategyOAuth2RefreshToken: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN + case types.RefreshStrategyOAuth2ClientCredentials: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS + case types.RefreshStrategyGoogleServiceAccountJWT: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT + default: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED + } +} + +// --- RefreshStatus --- + +// RefreshStatusFromProto converts a proto ProviderCredentialRefreshStatus to an SDK RefreshStatus. +func RefreshStatusFromProto(s *pb.ProviderCredentialRefreshStatus) *types.RefreshStatus { + if s == nil { + return nil + } + return &types.RefreshStatus{ + ProviderName: s.GetProviderName(), + ProviderID: s.GetProviderId(), + CredentialKey: s.GetCredentialKey(), + Strategy: RefreshStrategyFromProto(s.GetStrategy()), + Status: s.GetStatus(), + ExpiresAt: TimeFromMillis(s.GetExpiresAtMs()), + NextRefreshAt: TimeFromMillis(s.GetNextRefreshAtMs()), + LastRefreshAt: TimeFromMillis(s.GetLastRefreshAtMs()), + LastError: s.GetLastError(), + } +} + +// --- RefreshConfig --- + +// RefreshConfigToProto converts an SDK RefreshConfig to a proto ConfigureProviderRefreshRequest. +// Material and SecretMaterialKeys are deep-copied. +func RefreshConfigToProto(c *types.RefreshConfig) *pb.ConfigureProviderRefreshRequest { + if c == nil { + return nil + } + + result := &pb.ConfigureProviderRefreshRequest{ + Provider: c.Provider, + CredentialKey: c.CredentialKey, + Strategy: RefreshStrategyToProto(c.Strategy), + Material: CopyStringMap(c.Material), + SecretMaterialKeys: CopyStringSlice(c.SecretMaterialKeys), + } + + if c.ExpiresAt != nil { + ms := MillisFromTime(*c.ExpiresAt) + result.ExpiresAtMs = &ms + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/refresh_test.go b/sdk/go/openshell/v1/internal/converter/refresh_test.go new file mode 100644 index 0000000000..ab7e3b018b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/refresh_test.go @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- RefreshStrategy --- + +func TestRefreshStrategyFromProto(t *testing.T) { + tests := []struct { + proto pb.ProviderCredentialRefreshStrategy + want v1.RefreshStrategy + }{ + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC, v1.RefreshStrategyStatic}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL, v1.RefreshStrategyExternal}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, v1.RefreshStrategyOAuth2RefreshToken}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS, v1.RefreshStrategyOAuth2ClientCredentials}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT, v1.RefreshStrategyGoogleServiceAccountJWT}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED, v1.RefreshStrategy("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, RefreshStrategyFromProto(tt.proto)) + }) + } +} + +func TestRefreshStrategyToProto(t *testing.T) { + tests := []struct { + sdk v1.RefreshStrategy + want pb.ProviderCredentialRefreshStrategy + }{ + {v1.RefreshStrategyStatic, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC}, + {v1.RefreshStrategyExternal, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL}, + {v1.RefreshStrategyOAuth2RefreshToken, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN}, + {v1.RefreshStrategyOAuth2ClientCredentials, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS}, + {v1.RefreshStrategyGoogleServiceAccountJWT, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT}, + {v1.RefreshStrategy(""), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, + {v1.RefreshStrategy("Unknown"), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, RefreshStrategyToProto(tt.sdk)) + }) + } +} + +// --- RefreshStatus --- + +func TestRefreshStatusFromProto(t *testing.T) { + proto := &pb.ProviderCredentialRefreshStatus{ + ProviderName: "anthropic", + ProviderId: "prov-1", + CredentialKey: "API_KEY", + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, + Status: "active", + ExpiresAtMs: 1700000000000, + NextRefreshAtMs: 1699999000000, + LastRefreshAtMs: 1699998000000, + LastError: "none", + } + + status := RefreshStatusFromProto(proto) + + require.NotNil(t, status) + assert.Equal(t, "anthropic", status.ProviderName) + assert.Equal(t, "prov-1", status.ProviderID) + assert.Equal(t, "API_KEY", status.CredentialKey) + assert.Equal(t, v1.RefreshStrategyOAuth2RefreshToken, status.Strategy) + assert.Equal(t, "active", status.Status) + assert.Equal(t, TimeFromMillis(1700000000000), status.ExpiresAt) + assert.Equal(t, TimeFromMillis(1699999000000), status.NextRefreshAt) + assert.Equal(t, TimeFromMillis(1699998000000), status.LastRefreshAt) + assert.Equal(t, "none", status.LastError) +} + +func TestRefreshStatusFromProto_Nil(t *testing.T) { + status := RefreshStatusFromProto(nil) + assert.Nil(t, status) +} + +func TestRefreshStatusFromProto_ZeroTimestamps(t *testing.T) { + proto := &pb.ProviderCredentialRefreshStatus{ + ProviderName: "test", + CredentialKey: "KEY", + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC, + } + + status := RefreshStatusFromProto(proto) + + require.NotNil(t, status) + assert.Equal(t, v1.RefreshStrategyStatic, status.Strategy) + assert.True(t, status.ExpiresAt.IsZero()) + assert.True(t, status.NextRefreshAt.IsZero()) + assert.True(t, status.LastRefreshAt.IsZero()) +} + +// --- RefreshConfig --- + +func TestRefreshConfigToProto(t *testing.T) { + expiresAt := time.Unix(1700000000, 0) + config := &v1.RefreshConfig{ + Provider: "anthropic", + CredentialKey: "API_KEY", + Strategy: v1.RefreshStrategyOAuth2ClientCredentials, + Material: map[string]string{ + "client_id": "my-id", + "client_secret": "my-secret", + }, + SecretMaterialKeys: []string{"client_secret"}, + ExpiresAt: &expiresAt, + } + + proto := RefreshConfigToProto(config) + + require.NotNil(t, proto) + assert.Equal(t, "anthropic", proto.Provider) + assert.Equal(t, "API_KEY", proto.CredentialKey) + assert.Equal(t, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS, proto.Strategy) + + // Material is deep-copied + require.Len(t, proto.Material, 2) + assert.Equal(t, "my-id", proto.Material["client_id"]) + assert.Equal(t, "my-secret", proto.Material["client_secret"]) + + // Verify deep copy by mutating original + config.Material["client_id"] = "mutated" + assert.Equal(t, "my-id", proto.Material["client_id"], "material must be deep copied") + + assert.Equal(t, []string{"client_secret"}, proto.SecretMaterialKeys) + + // Verify SecretMaterialKeys deep copy + config.SecretMaterialKeys[0] = "mutated" + assert.Equal(t, "client_secret", proto.SecretMaterialKeys[0], "secret keys must be deep copied") + + // ExpiresAt conversion + require.NotNil(t, proto.ExpiresAtMs) + assert.Equal(t, MillisFromTime(expiresAt), *proto.ExpiresAtMs) +} + +func TestRefreshConfigToProto_NilExpiresAt(t *testing.T) { + config := &v1.RefreshConfig{ + Provider: "test", + CredentialKey: "KEY", + Strategy: v1.RefreshStrategyStatic, + } + + proto := RefreshConfigToProto(config) + + require.NotNil(t, proto) + assert.Nil(t, proto.ExpiresAtMs) +} + +func TestRefreshConfigToProto_Nil(t *testing.T) { + proto := RefreshConfigToProto(nil) + assert.Nil(t, proto) +} diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go new file mode 100644 index 0000000000..3ba031365d --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// SandboxFromProto converts a proto Sandbox to an SDK Sandbox. +func SandboxFromProto(s *pb.Sandbox) *types.Sandbox { + if s == nil { + return nil + } + + result := &types.Sandbox{} + + if m := s.GetMetadata(); m != nil { + result.ID = m.GetId() + result.Name = m.GetName() + result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.Labels = CopyStringMap(m.GetLabels()) + result.ResourceVersion = m.GetResourceVersion() + } + + if spec := s.GetSpec(); spec != nil { + result.Spec = sandboxSpecFromProto(spec) + } + + if status := s.GetStatus(); status != nil { + result.Status = sandboxStatusFromProto(status) + } else { + result.Status.Phase = types.SandboxUnknown + } + + return result +} + +func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { + result := types.SandboxSpec{ + LogLevel: spec.GetLogLevel(), + Environment: CopyStringMap(spec.GetEnvironment()), + Providers: CopyStringSlice(spec.GetProviders()), + Policy: SandboxPolicyFromProto(spec.GetPolicy()), + } + + if tmpl := spec.GetTemplate(); tmpl != nil { + result.Template = &types.SandboxTemplate{ + Image: tmpl.GetImage(), + RuntimeClassName: tmpl.GetRuntimeClassName(), + AgentSocket: tmpl.GetAgentSocket(), + Labels: CopyStringMap(tmpl.GetLabels()), + Annotations: CopyStringMap(tmpl.GetAnnotations()), + Environment: CopyStringMap(tmpl.GetEnvironment()), + UserNamespaces: CopyBoolPtr(tmpl.UserNamespaces), + } + } + + if rr := spec.GetResourceRequirements(); rr != nil { + if gpu := rr.GetGpu(); gpu != nil && gpu.Count != nil { + result.GPUCount = gpu.Count + } + } + + return result +} + +func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus { + result := types.SandboxStatus{ + SandboxName: status.GetSandboxName(), + AgentPod: status.GetAgentPod(), + AgentFd: status.GetAgentFd(), + SandboxFd: status.GetSandboxFd(), + Phase: SandboxPhaseFromProto(status.GetPhase()), + CurrentPolicyVersion: status.GetCurrentPolicyVersion(), + } + + for _, c := range status.GetConditions() { + result.Conditions = append(result.Conditions, types.SandboxCondition{ + Type: c.GetType(), + Status: c.GetStatus(), + Reason: c.GetReason(), + Message: c.GetMessage(), + LastTransitionTime: c.GetLastTransitionTime(), + }) + } + + return result +} + +// SandboxPhaseFromProto converts a proto SandboxPhase to an SDK SandboxPhase. +func SandboxPhaseFromProto(phase pb.SandboxPhase) types.SandboxPhase { + switch phase { + case pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING: + return types.SandboxProvisioning + case pb.SandboxPhase_SANDBOX_PHASE_READY: + return types.SandboxReady + case pb.SandboxPhase_SANDBOX_PHASE_ERROR: + return types.SandboxError + case pb.SandboxPhase_SANDBOX_PHASE_DELETING: + return types.SandboxDeleting + case pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN: + return types.SandboxUnknown + default: + return types.SandboxUnknown + } +} + +// SandboxPhaseToProto converts an SDK SandboxPhase to a proto SandboxPhase. +func SandboxPhaseToProto(phase types.SandboxPhase) pb.SandboxPhase { + switch phase { + case types.SandboxProvisioning: + return pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING + case types.SandboxReady: + return pb.SandboxPhase_SANDBOX_PHASE_READY + case types.SandboxError: + return pb.SandboxPhase_SANDBOX_PHASE_ERROR + case types.SandboxDeleting: + return pb.SandboxPhase_SANDBOX_PHASE_DELETING + case types.SandboxUnknown: + return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN + default: + return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN + } +} + +// SandboxToProto converts an SDK Sandbox to a proto Sandbox. +func SandboxToProto(s *types.Sandbox) *pb.Sandbox { + if s == nil { + return nil + } + + return &pb.Sandbox{ + Metadata: &dm.ObjectMeta{ + Id: s.ID, + Name: s.Name, + CreatedAtMs: MillisFromTime(s.CreatedAt), + Labels: CopyStringMap(s.Labels), + ResourceVersion: s.ResourceVersion, + }, + Spec: SandboxSpecToProto(&s.Spec), + } +} + +// SandboxSpecToProto converts an SDK SandboxSpec to a proto SandboxSpec. +func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { + if spec == nil { + return nil + } + + result := &pb.SandboxSpec{ + LogLevel: spec.LogLevel, + Environment: CopyStringMap(spec.Environment), + Providers: CopyStringSlice(spec.Providers), + Policy: SandboxPolicyToProto(spec.Policy), + } + + if spec.Template != nil { + result.Template = &pb.SandboxTemplate{ + Image: spec.Template.Image, + RuntimeClassName: spec.Template.RuntimeClassName, + AgentSocket: spec.Template.AgentSocket, + Labels: CopyStringMap(spec.Template.Labels), + Annotations: CopyStringMap(spec.Template.Annotations), + Environment: CopyStringMap(spec.Template.Environment), + UserNamespaces: CopyBoolPtr(spec.Template.UserNamespaces), + } + } + + if spec.GPUCount != nil { + result.ResourceRequirements = &pb.ResourceRequirements{ + Gpu: &pb.GpuResourceRequirements{ + Count: spec.GPUCount, + }, + } + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go new file mode 100644 index 0000000000..06a7075245 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestSandboxFromProto(t *testing.T) { + userNS := true + gpuCount := uint32(2) + proto := &pb.Sandbox{ + Metadata: &dm.ObjectMeta{ + Id: "sb-1", + Name: "my-sandbox", + CreatedAtMs: 1700000000000, + Labels: map[string]string{"env": "dev"}, + ResourceVersion: 3, + }, + Spec: &pb.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"FOO": "bar"}, + Template: &pb.SandboxTemplate{ + Image: "nvidia/sandbox:latest", + RuntimeClassName: "kata", + AgentSocket: "/var/run/agent.sock", + Labels: map[string]string{"app": "test"}, + Annotations: map[string]string{"note": "hello"}, + Environment: map[string]string{"TMPL_VAR": "val"}, + UserNamespaces: &userNS, + }, + Providers: []string{"claude", "github"}, + ResourceRequirements: &pb.ResourceRequirements{ + Gpu: &pb.GpuResourceRequirements{ + Count: &gpuCount, + }, + }, + }, + Status: &pb.SandboxStatus{ + SandboxName: "sb-compute-1", + AgentPod: "agent-pod-xyz", + AgentFd: "fd-agent", + SandboxFd: "fd-sandbox", + Phase: pb.SandboxPhase_SANDBOX_PHASE_READY, + CurrentPolicyVersion: 7, + Conditions: []*pb.SandboxCondition{ + { + Type: "Ready", + Status: "True", + Reason: "AllGood", + Message: "Sandbox is ready", + LastTransitionTime: "2024-01-01T00:00:00Z", + }, + }, + }, + } + + s := SandboxFromProto(proto) + + require.NotNil(t, s) + assert.Equal(t, "sb-1", s.ID) + assert.Equal(t, "my-sandbox", s.Name) + assert.Equal(t, time.UnixMilli(1700000000000).UTC(), s.CreatedAt) + assert.Equal(t, map[string]string{"env": "dev"}, s.Labels) + assert.Equal(t, uint64(3), s.ResourceVersion) + + // Spec + assert.Equal(t, "debug", s.Spec.LogLevel) + assert.Equal(t, map[string]string{"FOO": "bar"}, s.Spec.Environment) + assert.Equal(t, []string{"claude", "github"}, s.Spec.Providers) + require.NotNil(t, s.Spec.GPUCount) + assert.Equal(t, uint32(2), *s.Spec.GPUCount) + + // Template + require.NotNil(t, s.Spec.Template) + assert.Equal(t, "nvidia/sandbox:latest", s.Spec.Template.Image) + assert.Equal(t, "kata", s.Spec.Template.RuntimeClassName) + assert.Equal(t, "/var/run/agent.sock", s.Spec.Template.AgentSocket) + assert.Equal(t, map[string]string{"app": "test"}, s.Spec.Template.Labels) + assert.Equal(t, map[string]string{"note": "hello"}, s.Spec.Template.Annotations) + assert.Equal(t, map[string]string{"TMPL_VAR": "val"}, s.Spec.Template.Environment) + require.NotNil(t, s.Spec.Template.UserNamespaces) + assert.True(t, *s.Spec.Template.UserNamespaces) + + // Status + assert.Equal(t, "sb-compute-1", s.Status.SandboxName) + assert.Equal(t, "agent-pod-xyz", s.Status.AgentPod) + assert.Equal(t, "fd-agent", s.Status.AgentFd) + assert.Equal(t, "fd-sandbox", s.Status.SandboxFd) + assert.Equal(t, v1.SandboxReady, s.Status.Phase) + assert.Equal(t, uint32(7), s.Status.CurrentPolicyVersion) + require.Len(t, s.Status.Conditions, 1) + assert.Equal(t, "Ready", s.Status.Conditions[0].Type) + assert.Equal(t, "True", s.Status.Conditions[0].Status) + assert.Equal(t, "AllGood", s.Status.Conditions[0].Reason) + assert.Equal(t, "Sandbox is ready", s.Status.Conditions[0].Message) + assert.Equal(t, "2024-01-01T00:00:00Z", s.Status.Conditions[0].LastTransitionTime) +} + +func TestSandboxFromProto_NilFields(t *testing.T) { + proto := &pb.Sandbox{} + + s := SandboxFromProto(proto) + + require.NotNil(t, s) + assert.Empty(t, s.ID) + assert.Empty(t, s.Name) + assert.True(t, s.CreatedAt.IsZero()) + assert.Nil(t, s.Spec.Template) + assert.Nil(t, s.Spec.GPUCount) + assert.Equal(t, v1.SandboxUnknown, s.Status.Phase) +} + +func TestSandboxFromProto_Nil(t *testing.T) { + s := SandboxFromProto(nil) + assert.Nil(t, s) +} + +func TestSandboxPhaseFromProto(t *testing.T) { + tests := []struct { + proto pb.SandboxPhase + expected v1.SandboxPhase + }{ + {pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING, v1.SandboxProvisioning}, + {pb.SandboxPhase_SANDBOX_PHASE_READY, v1.SandboxReady}, + {pb.SandboxPhase_SANDBOX_PHASE_ERROR, v1.SandboxError}, + {pb.SandboxPhase_SANDBOX_PHASE_DELETING, v1.SandboxDeleting}, + {pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN, v1.SandboxUnknown}, + {pb.SandboxPhase_SANDBOX_PHASE_UNSPECIFIED, v1.SandboxUnknown}, + {pb.SandboxPhase(999), v1.SandboxUnknown}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, SandboxPhaseFromProto(tt.proto), "phase %v", tt.proto) + } +} + +func TestSandboxPhaseToProto(t *testing.T) { + tests := []struct { + sdk v1.SandboxPhase + expected pb.SandboxPhase + }{ + {v1.SandboxProvisioning, pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + {v1.SandboxReady, pb.SandboxPhase_SANDBOX_PHASE_READY}, + {v1.SandboxError, pb.SandboxPhase_SANDBOX_PHASE_ERROR}, + {v1.SandboxDeleting, pb.SandboxPhase_SANDBOX_PHASE_DELETING}, + {v1.SandboxUnknown, pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN}, + {v1.SandboxPhase("bogus"), pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, SandboxPhaseToProto(tt.sdk), "phase %v", tt.sdk) + } +} + +func TestSandboxToProto(t *testing.T) { + userNS := true + gpuCount := uint32(4) + s := &v1.Sandbox{ + ID: "sb-1", + Name: "my-sandbox", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"env": "dev"}, + ResourceVersion: 3, + Spec: v1.SandboxSpec{ + LogLevel: "info", + Environment: map[string]string{"KEY": "val"}, + Template: &v1.SandboxTemplate{ + Image: "img:v1", + RuntimeClassName: "runc", + AgentSocket: "/sock", + Labels: map[string]string{"l": "v"}, + Annotations: map[string]string{"a": "v"}, + Environment: map[string]string{"E": "V"}, + UserNamespaces: &userNS, + }, + Providers: []string{"prov-a"}, + GPUCount: &gpuCount, + }, + } + + p := SandboxToProto(s) + + require.NotNil(t, p) + require.NotNil(t, p.Metadata) + assert.Equal(t, "sb-1", p.Metadata.Id) + assert.Equal(t, "my-sandbox", p.Metadata.Name) + assert.Equal(t, int64(1700000000000), p.Metadata.CreatedAtMs) + assert.Equal(t, map[string]string{"env": "dev"}, p.Metadata.Labels) + assert.Equal(t, uint64(3), p.Metadata.ResourceVersion) + + require.NotNil(t, p.Spec) + assert.Equal(t, "info", p.Spec.LogLevel) + assert.Equal(t, map[string]string{"KEY": "val"}, p.Spec.Environment) + assert.Equal(t, []string{"prov-a"}, p.Spec.Providers) + + require.NotNil(t, p.Spec.ResourceRequirements) + require.NotNil(t, p.Spec.ResourceRequirements.Gpu) + assert.Equal(t, uint32(4), p.Spec.ResourceRequirements.Gpu.GetCount()) + + require.NotNil(t, p.Spec.Template) + assert.Equal(t, "img:v1", p.Spec.Template.Image) + assert.Equal(t, "runc", p.Spec.Template.RuntimeClassName) + assert.Equal(t, "/sock", p.Spec.Template.AgentSocket) + assert.Equal(t, map[string]string{"l": "v"}, p.Spec.Template.Labels) + assert.Equal(t, map[string]string{"a": "v"}, p.Spec.Template.Annotations) + assert.Equal(t, map[string]string{"E": "V"}, p.Spec.Template.Environment) + require.NotNil(t, p.Spec.Template.UserNamespaces) + assert.True(t, *p.Spec.Template.UserNamespaces) +} + +func TestSandboxToProto_Nil(t *testing.T) { + p := SandboxToProto(nil) + assert.Nil(t, p) +} + +func TestSandboxToProto_NilTemplate(t *testing.T) { + s := &v1.Sandbox{ + Spec: v1.SandboxSpec{ + LogLevel: "warn", + }, + } + + p := SandboxToProto(s) + + require.NotNil(t, p) + require.NotNil(t, p.Spec) + assert.Nil(t, p.Spec.Template) + assert.Nil(t, p.Spec.ResourceRequirements) +} + +func TestSandboxRoundTrip(t *testing.T) { + userNS := false + gpuCount := uint32(1) + original := &v1.Sandbox{ + ID: "sb-rt", + Name: "round-trip", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"team": "platform"}, + ResourceVersion: 10, + Spec: v1.SandboxSpec{ + LogLevel: "trace", + Environment: map[string]string{"A": "B"}, + Template: &v1.SandboxTemplate{ + Image: "img:rt", + UserNamespaces: &userNS, + }, + Providers: []string{"p1", "p2"}, + GPUCount: &gpuCount, + Policy: &v1.SandboxPolicy{ + Version: 3, + Filesystem: &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp"}, + }, + Landlock: &v1.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &v1.ProcessPolicy{ + RunAsUser: "sandbox", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "web": { + Name: "web", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + }, + }, + }, + } + + p := SandboxToProto(original) + back := SandboxFromProto(p) + + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.Name, back.Name) + assert.Equal(t, original.CreatedAt, back.CreatedAt) + assert.Equal(t, original.Labels, back.Labels) + assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + assert.Equal(t, original.Spec.LogLevel, back.Spec.LogLevel) + assert.Equal(t, original.Spec.Environment, back.Spec.Environment) + assert.Equal(t, original.Spec.Providers, back.Spec.Providers) + require.NotNil(t, back.Spec.GPUCount) + assert.Equal(t, *original.Spec.GPUCount, *back.Spec.GPUCount) + require.NotNil(t, back.Spec.Template) + assert.Equal(t, original.Spec.Template.Image, back.Spec.Template.Image) + require.NotNil(t, back.Spec.Template.UserNamespaces) + assert.Equal(t, *original.Spec.Template.UserNamespaces, *back.Spec.Template.UserNamespaces) + + // Policy round-trip + require.NotNil(t, back.Spec.Policy) + assert.Equal(t, uint32(3), back.Spec.Policy.Version) + require.NotNil(t, back.Spec.Policy.Filesystem) + assert.True(t, back.Spec.Policy.Filesystem.IncludeWorkdir) + assert.Equal(t, []string{"/etc", "/usr/share"}, back.Spec.Policy.Filesystem.ReadOnly) + assert.Equal(t, []string{"/tmp"}, back.Spec.Policy.Filesystem.ReadWrite) + require.NotNil(t, back.Spec.Policy.Landlock) + assert.Equal(t, "best_effort", back.Spec.Policy.Landlock.Compatibility) + require.NotNil(t, back.Spec.Policy.Process) + assert.Equal(t, "sandbox", back.Spec.Policy.Process.RunAsUser) + assert.Equal(t, "sandbox-group", back.Spec.Policy.Process.RunAsGroup) + require.Len(t, back.Spec.Policy.NetworkPolicies, 1) + webRule, ok := back.Spec.Policy.NetworkPolicies["web"] + require.True(t, ok) + assert.Equal(t, "web", webRule.Name) + require.Len(t, webRule.Endpoints, 1) + assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host) +} + +func TestSandboxSpecToProto(t *testing.T) { + gpuCount := uint32(3) + spec := &v1.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"X": "Y"}, + Template: &v1.SandboxTemplate{ + Image: "img:spec", + }, + Providers: []string{"prov"}, + GPUCount: &gpuCount, + Policy: &v1.SandboxPolicy{ + Version: 2, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + p := SandboxSpecToProto(spec) + + require.NotNil(t, p) + assert.Equal(t, "debug", p.LogLevel) + assert.Equal(t, map[string]string{"X": "Y"}, p.Environment) + assert.Equal(t, []string{"prov"}, p.Providers) + require.NotNil(t, p.ResourceRequirements) + assert.Equal(t, uint32(3), p.ResourceRequirements.Gpu.GetCount()) + require.NotNil(t, p.Template) + assert.Equal(t, "img:spec", p.Template.Image) + + // Policy conversion + require.NotNil(t, p.Policy) + assert.Equal(t, uint32(2), p.Policy.Version) + require.NotNil(t, p.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, p.Policy.Filesystem.ReadOnly) +} + +func TestSandboxSpecToProto_Nil(t *testing.T) { + p := SandboxSpecToProto(nil) + assert.Nil(t, p) +} + +// Verify proto import is used (suppress unused import warning). +var _ = proto.Marshal diff --git a/sdk/go/openshell/v1/internal/converter/service.go b/sdk/go/openshell/v1/internal/converter/service.go new file mode 100644 index 0000000000..20949016b7 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// ServiceEndpointFromProto converts a proto ServiceEndpointResponse to an SDK ServiceEndpoint. +// The response flattens the nested Endpoint and top-level URL into a single SDK type. +func ServiceEndpointFromProto(resp *pb.ServiceEndpointResponse) *types.ServiceEndpoint { + if resp == nil { + return nil + } + + result := &types.ServiceEndpoint{ + URL: resp.GetUrl(), + } + + if ep := resp.GetEndpoint(); ep != nil { + result.SandboxID = ep.GetSandboxId() + result.SandboxName = ep.GetSandboxName() + result.ServiceName = ep.GetServiceName() + result.TargetPort = ep.GetTargetPort() + result.Domain = ep.GetDomain() + + if m := ep.GetMetadata(); m != nil { + result.ID = m.GetId() + } + } + + return result +} + +// ServiceEndpointToProto converts an SDK ServiceEndpoint to a proto ServiceEndpointResponse. +func ServiceEndpointToProto(se *types.ServiceEndpoint) *pb.ServiceEndpointResponse { + if se == nil { + return nil + } + + return &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: se.ID, + }, + SandboxId: se.SandboxID, + SandboxName: se.SandboxName, + ServiceName: se.ServiceName, + TargetPort: se.TargetPort, + Domain: se.Domain, + }, + Url: se.URL, + } +} diff --git a/sdk/go/openshell/v1/internal/converter/service_test.go b/sdk/go/openshell/v1/internal/converter/service_test.go new file mode 100644 index 0000000000..c04ddcd552 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service_test.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceEndpointFromProto(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: "svc-1", + }, + SandboxId: "sb-1", + SandboxName: "my-sandbox", + ServiceName: "http-server", + TargetPort: 8080, + Domain: true, + }, + Url: "https://svc-1.example.com", + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Equal(t, "svc-1", se.ID) + assert.Equal(t, "sb-1", se.SandboxID) + assert.Equal(t, "my-sandbox", se.SandboxName) + assert.Equal(t, "http-server", se.ServiceName) + assert.Equal(t, uint32(8080), se.TargetPort) + assert.True(t, se.Domain) + assert.Equal(t, "https://svc-1.example.com", se.URL) +} + +func TestServiceEndpointFromProto_NilEndpoint(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Url: "https://orphan.example.com", + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Empty(t, se.ID) + assert.Empty(t, se.SandboxID) + assert.Equal(t, "https://orphan.example.com", se.URL) +} + +func TestServiceEndpointFromProto_NilMetadata(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + SandboxId: "sb-2", + ServiceName: "api", + TargetPort: 3000, + }, + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Empty(t, se.ID) + assert.Equal(t, "sb-2", se.SandboxID) + assert.Equal(t, "api", se.ServiceName) + assert.Equal(t, uint32(3000), se.TargetPort) +} + +func TestServiceEndpointFromProto_Nil(t *testing.T) { + se := ServiceEndpointFromProto(nil) + assert.Nil(t, se) +} + +func TestServiceEndpointToProto(t *testing.T) { + se := &v1.ServiceEndpoint{ + ID: "svc-1", + SandboxID: "sb-1", + SandboxName: "my-sandbox", + ServiceName: "http-server", + TargetPort: 8080, + Domain: true, + URL: "https://svc-1.example.com", + } + + resp := ServiceEndpointToProto(se) + + require.NotNil(t, resp) + require.NotNil(t, resp.Endpoint) + require.NotNil(t, resp.Endpoint.Metadata) + assert.Equal(t, "svc-1", resp.Endpoint.Metadata.Id) + assert.Equal(t, "sb-1", resp.Endpoint.SandboxId) + assert.Equal(t, "my-sandbox", resp.Endpoint.SandboxName) + assert.Equal(t, "http-server", resp.Endpoint.ServiceName) + assert.Equal(t, uint32(8080), resp.Endpoint.TargetPort) + assert.True(t, resp.Endpoint.Domain) + assert.Equal(t, "https://svc-1.example.com", resp.Url) +} + +func TestServiceEndpointToProto_Nil(t *testing.T) { + resp := ServiceEndpointToProto(nil) + assert.Nil(t, resp) +} + +func TestServiceEndpointRoundTrip(t *testing.T) { + original := &v1.ServiceEndpoint{ + ID: "svc-rt", + SandboxID: "sb-rt", + SandboxName: "round-trip", + ServiceName: "web", + TargetPort: 9090, + Domain: false, + URL: "http://localhost:9090", + } + + proto := ServiceEndpointToProto(original) + back := ServiceEndpointFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.SandboxID, back.SandboxID) + assert.Equal(t, original.SandboxName, back.SandboxName) + assert.Equal(t, original.ServiceName, back.ServiceName) + assert.Equal(t, original.TargetPort, back.TargetPort) + assert.Equal(t, original.Domain, back.Domain) + assert.Equal(t, original.URL, back.URL) +} diff --git a/sdk/go/openshell/v1/internal/converter/setting.go b/sdk/go/openshell/v1/internal/converter/setting.go new file mode 100644 index 0000000000..2c31185e59 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/setting.go @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- SettingValue oneof conversion --- + +// SettingValueFromProto converts a proto SettingValue (oneof) to an SDK SettingValue. +func SettingValueFromProto(pv *sbv1.SettingValue) *v1.SettingValue { + if pv == nil { + return nil + } + sv := &v1.SettingValue{} + switch v := pv.GetValue().(type) { + case *sbv1.SettingValue_StringValue: + sv.Type = v1.SettingValueString + sv.StringVal = v.StringValue + case *sbv1.SettingValue_BoolValue: + sv.Type = v1.SettingValueBool + sv.BoolVal = v.BoolValue + case *sbv1.SettingValue_IntValue: + sv.Type = v1.SettingValueInt + sv.IntVal = v.IntValue + case *sbv1.SettingValue_BytesValue: + sv.Type = v1.SettingValueBytes + sv.BytesVal = CopyByteSlice(v.BytesValue) + } + return sv +} + +// SettingValueToProto converts an SDK SettingValue to a proto SettingValue (oneof). +func SettingValueToProto(sv *v1.SettingValue) *sbv1.SettingValue { + if sv == nil { + return nil + } + pv := &sbv1.SettingValue{} + switch sv.Type { + case v1.SettingValueString: + pv.Value = &sbv1.SettingValue_StringValue{StringValue: sv.StringVal} + case v1.SettingValueBool: + pv.Value = &sbv1.SettingValue_BoolValue{BoolValue: sv.BoolVal} + case v1.SettingValueInt: + pv.Value = &sbv1.SettingValue_IntValue{IntValue: sv.IntVal} + case v1.SettingValueBytes: + pv.Value = &sbv1.SettingValue_BytesValue{BytesValue: CopyByteSlice(sv.BytesVal)} + } + return pv +} + +// --- Enum conversions --- + +// SettingScopeFromProto converts a proto SettingScope enum to an SDK SettingScope. +func SettingScopeFromProto(ps sbv1.SettingScope) v1.SettingScope { + switch ps { + case sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED: + return v1.SettingScopeUnspecified + case sbv1.SettingScope_SETTING_SCOPE_SANDBOX: + return v1.SettingScopeSandbox + case sbv1.SettingScope_SETTING_SCOPE_GLOBAL: + return v1.SettingScopeGlobal + default: + return v1.SettingScope("") + } +} + +// SettingScopeToProto converts an SDK SettingScope to a proto SettingScope enum. +func SettingScopeToProto(s v1.SettingScope) sbv1.SettingScope { + switch s { + case v1.SettingScopeUnspecified: + return sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED + case v1.SettingScopeSandbox: + return sbv1.SettingScope_SETTING_SCOPE_SANDBOX + case v1.SettingScopeGlobal: + return sbv1.SettingScope_SETTING_SCOPE_GLOBAL + default: + return sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED + } +} + +// PolicySourceFromProto converts a proto PolicySource enum to an SDK PolicySource. +func PolicySourceFromProto(ps sbv1.PolicySource) v1.PolicySource { + switch ps { + case sbv1.PolicySource_POLICY_SOURCE_UNSPECIFIED: + return v1.PolicySourceUnspecified + case sbv1.PolicySource_POLICY_SOURCE_SANDBOX: + return v1.PolicySourceSandbox + case sbv1.PolicySource_POLICY_SOURCE_GLOBAL: + return v1.PolicySourceGlobal + default: + return v1.PolicySource("") + } +} + +// --- EffectiveSetting --- + +// EffectiveSettingFromProto converts a proto EffectiveSetting to an SDK EffectiveSetting. +func EffectiveSettingFromProto(pv *sbv1.EffectiveSetting) *v1.EffectiveSetting { + if pv == nil { + return nil + } + es := &v1.EffectiveSetting{ + Scope: SettingScopeFromProto(pv.GetScope()), + } + if sv := SettingValueFromProto(pv.GetValue()); sv != nil { + es.Value = *sv + } + return es +} + +// --- SandboxConfig --- + +// SandboxConfigFromProto converts a GetSandboxConfigResponse to an SDK SandboxConfig. +func SandboxConfigFromProto(resp *sbv1.GetSandboxConfigResponse) *v1.SandboxConfig { + if resp == nil { + return nil + } + sc := &v1.SandboxConfig{ + PolicyVersion: resp.GetVersion(), + PolicyHash: resp.GetPolicyHash(), + ConfigRevision: resp.GetConfigRevision(), + PolicySource: PolicySourceFromProto(resp.GetPolicySource()), + GlobalPolicyVersion: resp.GetGlobalPolicyVersion(), + ProviderEnvRevision: resp.GetProviderEnvRevision(), + } + + // Convert proto SandboxPolicy to typed SDK SandboxPolicy. + sc.Policy = SandboxPolicyFromProto(resp.GetPolicy()) + + // Deep-copy settings map. + if m := resp.GetSettings(); len(m) > 0 { + sc.Settings = make(map[string]v1.EffectiveSetting, len(m)) + for k, v := range m { + if es := EffectiveSettingFromProto(v); es != nil { + sc.Settings[k] = *es + } + } + } + + return sc +} + +// --- GatewayConfig --- + +// GatewayConfigFromProto converts a GetGatewayConfigResponse to an SDK GatewayConfig. +func GatewayConfigFromProto(resp *sbv1.GetGatewayConfigResponse) *v1.GatewayConfig { + if resp == nil { + return nil + } + gc := &v1.GatewayConfig{ + SettingsRevision: resp.GetSettingsRevision(), + } + + // Deep-copy settings map. + if m := resp.GetSettings(); len(m) > 0 { + gc.Settings = make(map[string]v1.SettingValue, len(m)) + for k, v := range m { + if sv := SettingValueFromProto(v); sv != nil { + gc.Settings[k] = *sv + } + } + } + + return gc +} + +// --- ConfigUpdate --- + +// ConfigUpdateToProto converts an SDK ConfigUpdate to an UpdateConfigRequest. +func ConfigUpdateToProto(cu *v1.ConfigUpdate) (*pb.UpdateConfigRequest, error) { + if cu == nil { + return nil, nil + } + req := &pb.UpdateConfigRequest{ + Name: cu.Name, + SettingKey: cu.SettingKey, + SettingValue: SettingValueToProto(cu.SettingValue), + DeleteSetting: cu.DeleteSetting, + Global: cu.Global, + ExpectedResourceVersion: cu.ExpectedResourceVersion, + } + + // Convert typed SDK SandboxPolicy to proto SandboxPolicy. + req.Policy = SandboxPolicyToProto(cu.Policy) + + // Convert typed merge operations with validation. + if len(cu.MergeOperations) > 0 { + req.MergeOperations = make([]*pb.PolicyMergeOperation, len(cu.MergeOperations)) + for i := range cu.MergeOperations { + converted, err := PolicyMergeOperationToProto(&cu.MergeOperations[i]) + if err != nil { + return nil, fmt.Errorf("merge operation [%d]: %w", i, err) + } + req.MergeOperations[i] = converted + } + } + + return req, nil +} + +// --- PolicyMergeOperation --- + +// PolicyMergeOperationToProto converts an SDK PolicyMergeOperation to a proto PolicyMergeOperation. +// Exactly one of the pointer fields must be non-nil. Returns an error if zero or multiple are set. +func PolicyMergeOperationToProto(op *v1.PolicyMergeOperation) (*pb.PolicyMergeOperation, error) { + if op == nil { + return nil, nil + } + set := boolCount(op.AddRule != nil, op.RemoveEndpoint != nil, op.RemoveRule != nil, + op.AddDenyRules != nil, op.AddAllowRules != nil, op.RemoveBinary != nil) + if set != 1 { + return nil, fmt.Errorf("PolicyMergeOperation: exactly one variant must be set, got %d", set) + } + pmo := &pb.PolicyMergeOperation{} + switch { + case op.AddRule != nil: + rule := NetworkPolicyRuleToProto(&op.AddRule.Rule) + pmo.Operation = &pb.PolicyMergeOperation_AddRule{ + AddRule: &pb.AddNetworkRule{ + RuleName: op.AddRule.RuleName, + Rule: rule, + }, + } + case op.RemoveEndpoint != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveEndpoint{ + RemoveEndpoint: &pb.RemoveNetworkEndpoint{ + RuleName: op.RemoveEndpoint.RuleName, + Host: op.RemoveEndpoint.Host, + Port: op.RemoveEndpoint.Port, + }, + } + case op.RemoveRule != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveRule{ + RemoveRule: &pb.RemoveNetworkRule{ + RuleName: op.RemoveRule.RuleName, + }, + } + case op.AddDenyRules != nil: + var denyRules []*sbv1.L7DenyRule + if len(op.AddDenyRules.DenyRules) > 0 { + denyRules = make([]*sbv1.L7DenyRule, len(op.AddDenyRules.DenyRules)) + for i := range op.AddDenyRules.DenyRules { + denyRules[i] = l7DenyRuleToProto(&op.AddDenyRules.DenyRules[i]) + } + } + pmo.Operation = &pb.PolicyMergeOperation_AddDenyRules{ + AddDenyRules: &pb.AddDenyRules{ + Host: op.AddDenyRules.Host, + Port: op.AddDenyRules.Port, + DenyRules: denyRules, + }, + } + case op.AddAllowRules != nil: + var rules []*sbv1.L7Rule + if len(op.AddAllowRules.Rules) > 0 { + rules = make([]*sbv1.L7Rule, len(op.AddAllowRules.Rules)) + for i := range op.AddAllowRules.Rules { + rules[i] = l7RuleToProto(&op.AddAllowRules.Rules[i]) + } + } + pmo.Operation = &pb.PolicyMergeOperation_AddAllowRules{ + AddAllowRules: &pb.AddAllowRules{ + Host: op.AddAllowRules.Host, + Port: op.AddAllowRules.Port, + Rules: rules, + }, + } + case op.RemoveBinary != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveBinary{ + RemoveBinary: &pb.RemoveNetworkBinary{ + RuleName: op.RemoveBinary.RuleName, + BinaryPath: op.RemoveBinary.BinaryPath, + }, + } + } + return pmo, nil +} + +// --- ConfigUpdateResult --- + +// ConfigUpdateResultFromProto converts an UpdateConfigResponse to an SDK ConfigUpdateResult. +func ConfigUpdateResultFromProto(resp *pb.UpdateConfigResponse) *v1.ConfigUpdateResult { + if resp == nil { + return nil + } + return &v1.ConfigUpdateResult{ + Version: resp.GetVersion(), + PolicyHash: resp.GetPolicyHash(), + SettingsRevision: resp.GetSettingsRevision(), + Deleted: resp.GetDeleted(), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/setting_test.go b/sdk/go/openshell/v1/internal/converter/setting_test.go new file mode 100644 index 0000000000..122e0f514b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/setting_test.go @@ -0,0 +1,825 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- SettingValue oneof mapping --- + +func TestSettingValueFromProto_StringValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "hello"}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueString, sv.Type) + assert.Equal(t, "hello", sv.StringVal) + assert.False(t, sv.BoolVal) + assert.Zero(t, sv.IntVal) + assert.Nil(t, sv.BytesVal) +} + +func TestSettingValueFromProto_BoolValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueBool, sv.Type) + assert.True(t, sv.BoolVal) + assert.Empty(t, sv.StringVal) +} + +func TestSettingValueFromProto_IntValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 42}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueInt, sv.Type) + assert.Equal(t, int64(42), sv.IntVal) +} + +func TestSettingValueFromProto_BytesValue(t *testing.T) { + data := []byte{0xDE, 0xAD, 0xBE, 0xEF} + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: data}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueBytes, sv.Type) + assert.Equal(t, data, sv.BytesVal) +} + +func TestSettingValueFromProto_BytesDeepCopy(t *testing.T) { + data := []byte{0x01, 0x02, 0x03} + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: data}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + // Mutate original data — SDK copy must not be affected. + data[0] = 0xFF + assert.Equal(t, byte(0x01), sv.BytesVal[0], "deep copy must isolate SDK from proto") +} + +func TestSettingValueFromProto_NilOneof(t *testing.T) { + pv := &sbv1.SettingValue{} + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueType(""), sv.Type) +} + +func TestSettingValueFromProto_Nil(t *testing.T) { + sv := SettingValueFromProto(nil) + assert.Nil(t, sv) +} + +func TestSettingValueToProto_StringValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueString, + StringVal: "world", + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, "world", pv.GetStringValue()) +} + +func TestSettingValueToProto_BoolValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueBool, + BoolVal: true, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.True(t, pv.GetBoolValue()) +} + +func TestSettingValueToProto_IntValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueInt, + IntVal: 99, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, int64(99), pv.GetIntValue()) +} + +func TestSettingValueToProto_BytesValue(t *testing.T) { + data := []byte{0xCA, 0xFE} + sv := &v1.SettingValue{ + Type: v1.SettingValueBytes, + BytesVal: data, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, data, pv.GetBytesValue()) +} + +func TestSettingValueToProto_Nil(t *testing.T) { + pv := SettingValueToProto(nil) + assert.Nil(t, pv) +} + +// --- SettingScope enum mapping --- + +func TestSettingScopeFromProto(t *testing.T) { + tests := []struct { + proto sbv1.SettingScope + want v1.SettingScope + }{ + {sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED, v1.SettingScopeUnspecified}, + {sbv1.SettingScope_SETTING_SCOPE_SANDBOX, v1.SettingScopeSandbox}, + {sbv1.SettingScope_SETTING_SCOPE_GLOBAL, v1.SettingScopeGlobal}, + {sbv1.SettingScope(999), v1.SettingScope("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, SettingScopeFromProto(tt.proto)) + }) + } +} + +func TestSettingScopeToProto(t *testing.T) { + tests := []struct { + sdk v1.SettingScope + want sbv1.SettingScope + }{ + {v1.SettingScopeUnspecified, sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED}, + {v1.SettingScopeSandbox, sbv1.SettingScope_SETTING_SCOPE_SANDBOX}, + {v1.SettingScopeGlobal, sbv1.SettingScope_SETTING_SCOPE_GLOBAL}, + {v1.SettingScope("unknown"), sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, SettingScopeToProto(tt.sdk)) + }) + } +} + +// --- PolicySource enum mapping --- + +func TestPolicySourceFromProto(t *testing.T) { + tests := []struct { + proto sbv1.PolicySource + want v1.PolicySource + }{ + {sbv1.PolicySource_POLICY_SOURCE_UNSPECIFIED, v1.PolicySourceUnspecified}, + {sbv1.PolicySource_POLICY_SOURCE_SANDBOX, v1.PolicySourceSandbox}, + {sbv1.PolicySource_POLICY_SOURCE_GLOBAL, v1.PolicySourceGlobal}, + {sbv1.PolicySource(999), v1.PolicySource("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicySourceFromProto(tt.proto)) + }) + } +} + +// --- EffectiveSetting --- + +func TestEffectiveSettingFromProto(t *testing.T) { + pv := &sbv1.EffectiveSetting{ + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "val"}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + } + + es := EffectiveSettingFromProto(pv) + + require.NotNil(t, es) + assert.Equal(t, v1.SettingValueString, es.Value.Type) + assert.Equal(t, "val", es.Value.StringVal) + assert.Equal(t, v1.SettingScopeSandbox, es.Scope) +} + +func TestEffectiveSettingFromProto_NilValue(t *testing.T) { + pv := &sbv1.EffectiveSetting{ + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + } + + es := EffectiveSettingFromProto(pv) + + require.NotNil(t, es) + assert.Equal(t, v1.SettingValueType(""), es.Value.Type) + assert.Equal(t, v1.SettingScopeGlobal, es.Scope) +} + +func TestEffectiveSettingFromProto_Nil(t *testing.T) { + es := EffectiveSettingFromProto(nil) + assert.Nil(t, es) +} + +// --- SandboxConfig (GetSandboxConfigResponse → SandboxConfig) --- + +func TestSandboxConfigFromProto(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Policy: &sbv1.SandboxPolicy{ + Version: 7, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + Version: 3, + PolicyHash: "sha256:abc", + Settings: map[string]*sbv1.EffectiveSetting{ + "timeout": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 30}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + "debug": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + }, + }, + ConfigRevision: 100, + PolicySource: sbv1.PolicySource_POLICY_SOURCE_SANDBOX, + GlobalPolicyVersion: 5, + ProviderEnvRevision: 200, + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + require.NotNil(t, sc.Policy, "typed SandboxPolicy must be populated") + assert.Equal(t, uint32(7), sc.Policy.Version) + require.NotNil(t, sc.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, sc.Policy.Filesystem.ReadOnly) + assert.Equal(t, uint32(3), sc.PolicyVersion) + assert.Equal(t, "sha256:abc", sc.PolicyHash) + assert.Equal(t, uint64(100), sc.ConfigRevision) + assert.Equal(t, v1.PolicySourceSandbox, sc.PolicySource) + assert.Equal(t, uint32(5), sc.GlobalPolicyVersion) + assert.Equal(t, uint64(200), sc.ProviderEnvRevision) + + require.Len(t, sc.Settings, 2) + + timeout := sc.Settings["timeout"] + assert.Equal(t, v1.SettingValueInt, timeout.Value.Type) + assert.Equal(t, int64(30), timeout.Value.IntVal) + assert.Equal(t, v1.SettingScopeSandbox, timeout.Scope) + + debug := sc.Settings["debug"] + assert.Equal(t, v1.SettingValueBool, debug.Value.Type) + assert.True(t, debug.Value.BoolVal) + assert.Equal(t, v1.SettingScopeGlobal, debug.Scope) +} + +func TestSandboxConfigFromProto_NilPolicy(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Version: 1, + PolicyHash: "sha256:empty", + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + assert.Nil(t, sc.Policy) + assert.Equal(t, uint32(1), sc.PolicyVersion) + assert.Empty(t, sc.Settings) +} + +func TestSandboxConfigFromProto_Nil(t *testing.T) { + sc := SandboxConfigFromProto(nil) + assert.Nil(t, sc) +} + +func TestSandboxConfigFromProto_SettingsDeepCopy(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Settings: map[string]*sbv1.EffectiveSetting{ + "key1": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "original"}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + }, + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + // Mutate the proto map — SDK map must not be affected. + resp.Settings["key1"].Value.Value = &sbv1.SettingValue_StringValue{StringValue: "mutated"} + assert.Equal(t, "original", sc.Settings["key1"].Value.StringVal, + "deep copy must isolate SDK settings from proto") +} + +// --- GatewayConfig (GetGatewayConfigResponse → GatewayConfig) --- + +func TestGatewayConfigFromProto(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + Settings: map[string]*sbv1.SettingValue{ + "region": { + Value: &sbv1.SettingValue_StringValue{StringValue: "us-west-2"}, + }, + "max_sandboxes": { + Value: &sbv1.SettingValue_IntValue{IntValue: 100}, + }, + }, + SettingsRevision: 42, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + assert.Equal(t, uint64(42), gc.SettingsRevision) + require.Len(t, gc.Settings, 2) + + region := gc.Settings["region"] + assert.Equal(t, v1.SettingValueString, region.Type) + assert.Equal(t, "us-west-2", region.StringVal) + + maxSb := gc.Settings["max_sandboxes"] + assert.Equal(t, v1.SettingValueInt, maxSb.Type) + assert.Equal(t, int64(100), maxSb.IntVal) +} + +func TestGatewayConfigFromProto_EmptySettings(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + SettingsRevision: 1, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + assert.Equal(t, uint64(1), gc.SettingsRevision) + assert.Empty(t, gc.Settings) +} + +func TestGatewayConfigFromProto_Nil(t *testing.T) { + gc := GatewayConfigFromProto(nil) + assert.Nil(t, gc) +} + +func TestGatewayConfigFromProto_SettingsDeepCopy(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + Settings: map[string]*sbv1.SettingValue{ + "key": { + Value: &sbv1.SettingValue_StringValue{StringValue: "original"}, + }, + }, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + // Mutate the proto map — SDK map must not be affected. + resp.Settings["key"].Value = &sbv1.SettingValue_StringValue{StringValue: "mutated"} + assert.Equal(t, "original", gc.Settings["key"].StringVal, + "deep copy must isolate SDK settings from proto") +} + +// --- ConfigUpdate (ConfigUpdate → UpdateConfigRequest) --- + +func TestConfigUpdateToProto(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "timeout", + SettingValue: &v1.SettingValue{ + Type: v1.SettingValueInt, + IntVal: 60, + }, + DeleteSetting: false, + Global: false, + ExpectedResourceVersion: 7, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.Name) + assert.Equal(t, "timeout", req.SettingKey) + require.NotNil(t, req.SettingValue) + assert.Equal(t, int64(60), req.SettingValue.GetIntValue()) + assert.False(t, req.DeleteSetting) + assert.False(t, req.Global) + assert.Equal(t, uint64(7), req.ExpectedResourceVersion) + assert.Nil(t, req.Policy) + assert.Empty(t, req.MergeOperations) +} + +func TestConfigUpdateToProto_WithPolicy(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-policy", + Policy: &v1.SandboxPolicy{ + Version: 3, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + require.NotNil(t, req.Policy, "typed SandboxPolicy must be converted to proto") + assert.Equal(t, uint32(3), req.Policy.GetVersion()) + require.NotNil(t, req.Policy.GetFilesystem()) + assert.Equal(t, []string{"/etc"}, req.Policy.GetFilesystem().GetReadOnly()) +} + +func TestConfigUpdateToProto_WithDeleteSetting(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-del", + SettingKey: "obsolete-key", + DeleteSetting: true, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "obsolete-key", req.SettingKey) + assert.True(t, req.DeleteSetting) +} + +func TestConfigUpdateToProto_GlobalScope(t *testing.T) { + cu := &v1.ConfigUpdate{ + SettingKey: "global-setting", + SettingValue: &v1.SettingValue{ + Type: v1.SettingValueString, + StringVal: "global-val", + }, + Global: true, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.True(t, req.Global) + assert.Empty(t, req.Name) +} + +func TestConfigUpdateToProto_NilSettingValue(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-nil", + SettingKey: "key", + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Nil(t, req.SettingValue) +} + +func TestConfigUpdateToProto_Nil(t *testing.T) { + req, err := ConfigUpdateToProto(nil) + require.NoError(t, err) + assert.Nil(t, req) +} + +func TestConfigUpdateToProto_NilPolicy(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-nil-policy", + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Nil(t, req.Policy, "nil SDK policy must produce nil proto policy") +} + +// --- ConfigUpdateResult (UpdateConfigResponse → ConfigUpdateResult) --- + +func TestConfigUpdateResultFromProto(t *testing.T) { + resp := &pb.UpdateConfigResponse{ + Version: 10, + PolicyHash: "sha256:updated", + SettingsRevision: 55, + Deleted: true, + } + + result := ConfigUpdateResultFromProto(resp) + + require.NotNil(t, result) + assert.Equal(t, uint32(10), result.Version) + assert.Equal(t, "sha256:updated", result.PolicyHash) + assert.Equal(t, uint64(55), result.SettingsRevision) + assert.True(t, result.Deleted) +} + +func TestConfigUpdateResultFromProto_DefaultValues(t *testing.T) { + resp := &pb.UpdateConfigResponse{} + + result := ConfigUpdateResultFromProto(resp) + + require.NotNil(t, result) + assert.Zero(t, result.Version) + assert.Empty(t, result.PolicyHash) + assert.Zero(t, result.SettingsRevision) + assert.False(t, result.Deleted) +} + +func TestConfigUpdateResultFromProto_Nil(t *testing.T) { + result := ConfigUpdateResultFromProto(nil) + assert.Nil(t, result) +} + +// --- PolicyMergeOperationToProto --- + +func TestPolicyMergeOperationToProto_Nil(t *testing.T) { + pmo, err := PolicyMergeOperationToProto(nil) + assert.NoError(t, err) + assert.Nil(t, pmo) +} + +func TestPolicyMergeOperationToProto_Empty(t *testing.T) { + op := &v1.PolicyMergeOperation{} + _, err := PolicyMergeOperationToProto(op) + + require.Error(t, err) + assert.Contains(t, err.Error(), "got 0") +} + +func TestPolicyMergeOperationToProto_MultipleSet(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddRule: &v1.AddNetworkRule{RuleName: "r1"}, + RemoveRule: &v1.RemoveNetworkRule{RuleName: "r2"}, + } + _, err := PolicyMergeOperationToProto(op) + + require.Error(t, err) + assert.Contains(t, err.Error(), "got 2") +} + +func TestPolicyMergeOperationToProto_AddRule(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddRule: &v1.AddNetworkRule{ + RuleName: "allow-api", + Rule: v1.NetworkPolicyRule{ + Name: "allow-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "tcp"}, + }, + Binaries: []v1.PolicyNetworkBinary{ + {Path: "/usr/bin/curl"}, + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + ar := pmo.GetAddRule() + require.NotNil(t, ar, "expected AddRule variant") + assert.Equal(t, "allow-api", ar.GetRuleName()) + require.NotNil(t, ar.GetRule()) + assert.Equal(t, "allow-api", ar.GetRule().GetName()) + require.Len(t, ar.GetRule().GetEndpoints(), 1) + assert.Equal(t, "api.example.com", ar.GetRule().GetEndpoints()[0].GetHost()) + assert.Equal(t, uint32(443), ar.GetRule().GetEndpoints()[0].GetPort()) + require.Len(t, ar.GetRule().GetBinaries(), 1) + assert.Equal(t, "/usr/bin/curl", ar.GetRule().GetBinaries()[0].GetPath()) +} + +func TestPolicyMergeOperationToProto_RemoveEndpoint(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveEndpoint: &v1.RemoveNetworkEndpoint{ + RuleName: "allow-api", + Host: "old.example.com", + Port: 8080, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + re := pmo.GetRemoveEndpoint() + require.NotNil(t, re, "expected RemoveEndpoint variant") + assert.Equal(t, "allow-api", re.GetRuleName()) + assert.Equal(t, "old.example.com", re.GetHost()) + assert.Equal(t, uint32(8080), re.GetPort()) +} + +func TestPolicyMergeOperationToProto_RemoveRule(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveRule: &v1.RemoveNetworkRule{ + RuleName: "obsolete-rule", + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + rr := pmo.GetRemoveRule() + require.NotNil(t, rr, "expected RemoveRule variant") + assert.Equal(t, "obsolete-rule", rr.GetRuleName()) +} + +func TestPolicyMergeOperationToProto_AddDenyRules(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddDenyRules: &v1.AddDenyRules{ + Host: "blocked.example.com", + Port: 443, + DenyRules: []v1.L7DenyRule{ + { + Method: "POST", + Path: "/admin", + }, + { + Method: "GET", + OperationType: "query", + OperationName: "InternalData", + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + adr := pmo.GetAddDenyRules() + require.NotNil(t, adr, "expected AddDenyRules variant") + assert.Equal(t, "blocked.example.com", adr.GetHost()) + assert.Equal(t, uint32(443), adr.GetPort()) + require.Len(t, adr.GetDenyRules(), 2) + assert.Equal(t, "POST", adr.GetDenyRules()[0].GetMethod()) + assert.Equal(t, "/admin", adr.GetDenyRules()[0].GetPath()) + assert.Equal(t, "InternalData", adr.GetDenyRules()[1].GetOperationName()) +} + +func TestPolicyMergeOperationToProto_AddAllowRules(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddAllowRules: &v1.AddAllowRules{ + Host: "api.example.com", + Port: 443, + Rules: []v1.L7Rule{ + { + Allow: &v1.L7Allow{ + Method: "GET", + Path: "/health", + }, + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + aar := pmo.GetAddAllowRules() + require.NotNil(t, aar, "expected AddAllowRules variant") + assert.Equal(t, "api.example.com", aar.GetHost()) + assert.Equal(t, uint32(443), aar.GetPort()) + require.Len(t, aar.GetRules(), 1) + require.NotNil(t, aar.GetRules()[0].GetAllow()) + assert.Equal(t, "GET", aar.GetRules()[0].GetAllow().GetMethod()) + assert.Equal(t, "/health", aar.GetRules()[0].GetAllow().GetPath()) +} + +func TestPolicyMergeOperationToProto_RemoveBinary(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveBinary: &v1.RemoveNetworkBinary{ + RuleName: "allow-api", + BinaryPath: "/usr/bin/wget", + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + rb := pmo.GetRemoveBinary() + require.NotNil(t, rb, "expected RemoveBinary variant") + assert.Equal(t, "allow-api", rb.GetRuleName()) + assert.Equal(t, "/usr/bin/wget", rb.GetBinaryPath()) +} + +// --- ConfigUpdateToProto with MergeOperations --- + +func TestConfigUpdateToProto_WithMergeOperations(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "my-sandbox", + MergeOperations: []v1.PolicyMergeOperation{ + { + RemoveRule: &v1.RemoveNetworkRule{RuleName: "old-rule"}, + }, + { + AddRule: &v1.AddNetworkRule{ + RuleName: "new-rule", + Rule: v1.NetworkPolicyRule{ + Name: "new-rule", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "svc.local", Port: 8080}, + }, + }, + }, + }, + { + RemoveBinary: &v1.RemoveNetworkBinary{ + RuleName: "new-rule", + BinaryPath: "/tmp/bad", + }, + }, + }, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.GetName()) + require.Len(t, req.GetMergeOperations(), 3) + + // First: RemoveRule + rr := req.GetMergeOperations()[0].GetRemoveRule() + require.NotNil(t, rr) + assert.Equal(t, "old-rule", rr.GetRuleName()) + + // Second: AddRule + ar := req.GetMergeOperations()[1].GetAddRule() + require.NotNil(t, ar) + assert.Equal(t, "new-rule", ar.GetRuleName()) + require.NotNil(t, ar.GetRule()) + require.Len(t, ar.GetRule().GetEndpoints(), 1) + assert.Equal(t, "svc.local", ar.GetRule().GetEndpoints()[0].GetHost()) + + // Third: RemoveBinary + rb := req.GetMergeOperations()[2].GetRemoveBinary() + require.NotNil(t, rb) + assert.Equal(t, "/tmp/bad", rb.GetBinaryPath()) +} + +func TestConfigUpdateToProto_EmptyMergeOperations(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb", + MergeOperations: []v1.PolicyMergeOperation{}, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Empty(t, req.GetMergeOperations()) +} + +// --- CopyByteSlice helper --- + +func TestCopyByteSlice(t *testing.T) { + original := []byte{0x01, 0x02, 0x03} + copied := CopyByteSlice(original) + + assert.Equal(t, original, copied) + + // Mutate original — copy must not be affected. + original[0] = 0xFF + assert.Equal(t, byte(0x01), copied[0], "copy must be independent of original") +} + +func TestCopyByteSlice_Nil(t *testing.T) { + copied := CopyByteSlice(nil) + assert.Nil(t, copied) +} + +func TestCopyByteSlice_Empty(t *testing.T) { + original := []byte{} + copied := CopyByteSlice(original) + assert.NotNil(t, copied) + assert.Empty(t, copied) +} diff --git a/sdk/go/openshell/v1/internal/converter/ssh.go b/sdk/go/openshell/v1/internal/converter/ssh.go new file mode 100644 index 0000000000..538c1d17f5 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/ssh.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// SSHSessionFromProto converts a CreateSshSessionResponse to an SSHSession. +func SSHSessionFromProto(resp *pb.CreateSshSessionResponse) *v1.SSHSession { + if resp == nil { + return nil + } + return &v1.SSHSession{ + SandboxID: resp.GetSandboxId(), + Token: resp.GetToken(), + GatewayHost: resp.GetGatewayHost(), + GatewayPort: resp.GetGatewayPort(), + GatewayScheme: resp.GetGatewayScheme(), + HostKeyFingerprint: resp.GetHostKeyFingerprint(), + ExpiresAtMs: resp.GetExpiresAtMs(), + } +} + +// SSHSessionToProto converts an SSHSession to a CreateSshSessionResponse. +// This is primarily used for round-trip testing and fake implementations. +func SSHSessionToProto(session *v1.SSHSession) *pb.CreateSshSessionResponse { + if session == nil { + return nil + } + return &pb.CreateSshSessionResponse{ + SandboxId: session.SandboxID, + Token: session.Token, + GatewayHost: session.GatewayHost, + GatewayPort: session.GatewayPort, + GatewayScheme: session.GatewayScheme, + HostKeyFingerprint: session.HostKeyFingerprint, + ExpiresAtMs: session.ExpiresAtMs, + } +} diff --git a/sdk/go/openshell/v1/internal/converter/ssh_test.go b/sdk/go/openshell/v1/internal/converter/ssh_test.go new file mode 100644 index 0000000000..11ce395d75 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/ssh_test.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSHSessionFromProto(t *testing.T) { + resp := &pb.CreateSshSessionResponse{ + SandboxId: "sb-123", + Token: "tok-secret", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + + session := SSHSessionFromProto(resp) + + require.NotNil(t, session) + assert.Equal(t, "sb-123", session.SandboxID) + assert.Equal(t, "tok-secret", session.Token) + assert.Equal(t, "gw.example.com", session.GatewayHost) + assert.Equal(t, uint32(2222), session.GatewayPort) + assert.Equal(t, "https", session.GatewayScheme) + assert.Equal(t, "SHA256:abc123", session.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), session.ExpiresAtMs) +} + +func TestSSHSessionFromProto_MinimalFields(t *testing.T) { + resp := &pb.CreateSshSessionResponse{ + SandboxId: "sb-min", + Token: "tok-min", + GatewayHost: "localhost", + GatewayPort: 22, + } + + session := SSHSessionFromProto(resp) + + require.NotNil(t, session) + assert.Equal(t, "sb-min", session.SandboxID) + assert.Equal(t, "tok-min", session.Token) + assert.Equal(t, "localhost", session.GatewayHost) + assert.Equal(t, uint32(22), session.GatewayPort) + assert.Empty(t, session.GatewayScheme) + assert.Empty(t, session.HostKeyFingerprint) + assert.Zero(t, session.ExpiresAtMs) +} + +func TestSSHSessionFromProto_Nil(t *testing.T) { + session := SSHSessionFromProto(nil) + assert.Nil(t, session) +} + +func TestSSHSessionToProto(t *testing.T) { + session := &v1.SSHSession{ + SandboxID: "sb-123", + Token: "tok-secret", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + + resp := SSHSessionToProto(session) + + require.NotNil(t, resp) + assert.Equal(t, "sb-123", resp.SandboxId) + assert.Equal(t, "tok-secret", resp.Token) + assert.Equal(t, "gw.example.com", resp.GatewayHost) + assert.Equal(t, uint32(2222), resp.GatewayPort) + assert.Equal(t, "https", resp.GatewayScheme) + assert.Equal(t, "SHA256:abc123", resp.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), resp.ExpiresAtMs) +} + +func TestSSHSessionToProto_Nil(t *testing.T) { + resp := SSHSessionToProto(nil) + assert.Nil(t, resp) +} + +func TestSSHSessionRoundTrip(t *testing.T) { + original := &v1.SSHSession{ + SandboxID: "sb-rt", + Token: "tok-rt", + GatewayHost: "rt.example.com", + GatewayPort: 443, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:roundtrip", + ExpiresAtMs: 1800000000000, + } + + proto := SSHSessionToProto(original) + back := SSHSessionFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.SandboxID, back.SandboxID) + assert.Equal(t, original.Token, back.Token) + assert.Equal(t, original.GatewayHost, back.GatewayHost) + assert.Equal(t, original.GatewayPort, back.GatewayPort) + assert.Equal(t, original.GatewayScheme, back.GatewayScheme) + assert.Equal(t, original.HostKeyFingerprint, back.HostKeyFingerprint) + assert.Equal(t, original.ExpiresAtMs, back.ExpiresAtMs) +} diff --git a/sdk/go/openshell/v1/internal/converter/time.go b/sdk/go/openshell/v1/internal/converter/time.go new file mode 100644 index 0000000000..c1bba576aa --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/time.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import "time" + +// TimeFromMillis converts a millisecond epoch timestamp to time.Time. +// A zero value returns the zero time. +func TimeFromMillis(ms int64) time.Time { + if ms == 0 { + return time.Time{} + } + return time.UnixMilli(ms).UTC() +} + +// MillisFromTime converts a time.Time to a millisecond epoch timestamp. +// A zero time returns 0. +func MillisFromTime(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} diff --git a/sdk/go/openshell/v1/internal/converter/time_test.go b/sdk/go/openshell/v1/internal/converter/time_test.go new file mode 100644 index 0000000000..26ecdc83c8 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/time_test.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTimeFromMillis(t *testing.T) { + ms := int64(1719475200000) // 2024-06-27T12:00:00Z + tm := TimeFromMillis(ms) + assert.Equal(t, 2024, tm.Year()) + assert.Equal(t, time.June, tm.Month()) + assert.Equal(t, 27, tm.Day()) +} + +func TestTimeFromMillis_Zero(t *testing.T) { + tm := TimeFromMillis(0) + assert.True(t, tm.IsZero()) +} + +func TestMillisFromTime(t *testing.T) { + tm := time.Date(2024, time.June, 27, 12, 0, 0, 0, time.UTC) + ms := MillisFromTime(tm) + assert.Equal(t, int64(1719489600000), ms) +} + +func TestMillisFromTime_Zero(t *testing.T) { + ms := MillisFromTime(time.Time{}) + assert.Equal(t, int64(0), ms) +} + +func TestRoundTrip(t *testing.T) { + original := time.Date(2025, time.March, 15, 10, 30, 0, 0, time.UTC) + ms := MillisFromTime(original) + restored := TimeFromMillis(ms) + assert.Equal(t, original.Unix(), restored.Unix()) +} diff --git a/sdk/go/openshell/v1/internal/grpc/conn.go b/sdk/go/openshell/v1/internal/grpc/conn.go new file mode 100644 index 0000000000..4e2a1e6c63 --- /dev/null +++ b/sdk/go/openshell/v1/internal/grpc/conn.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package grpc provides gRPC connection setup utilities. +package grpc + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +// TLSParams holds TLS settings without importing the v1 package. +type TLSParams struct { + CertFile string + KeyFile string + CAFile string + Insecure bool +} + +// NewConnection creates a gRPC client connection. +// The address may include an http:// or https:// scheme (as written by the +// upstream gateway), which is stripped since gRPC expects host:port. +func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCredentials) (*grpc.ClientConn, error) { + address = strings.TrimPrefix(address, "https://") + address = strings.TrimPrefix(address, "http://") + opts := []grpc.DialOption{} + + if tlsCfg != nil && tlsCfg.Insecure { + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else if tlsCfg != nil { + creds, err := buildTLSCredentials(tlsCfg) + if err != nil { + return nil, fmt.Errorf("tls config: %w", err) + } + opts = append(opts, grpc.WithTransportCredentials(creds)) + } else { + opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}))) + } + + if auth != nil { + opts = append(opts, grpc.WithPerRPCCredentials(auth)) + } + + conn, err := grpc.NewClient(address, opts...) + if err != nil { + return nil, fmt.Errorf("grpc connect: %w", err) + } + return conn, nil +} + +func buildTLSCredentials(cfg *TLSParams) (credentials.TransportCredentials, error) { + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + + if cfg.CAFile != "" { + caCert, err := os.ReadFile(cfg.CAFile) + if err != nil { + return nil, fmt.Errorf("read CA file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("invalid CA certificate") + } + tlsConfig.RootCAs = pool + } + + if cfg.CertFile != "" && cfg.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("load client cert: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + return credentials.NewTLS(tlsConfig), nil +} diff --git a/sdk/go/openshell/v1/logger.go b/sdk/go/openshell/v1/logger.go new file mode 100644 index 0000000000..e8274012ae --- /dev/null +++ b/sdk/go/openshell/v1/logger.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Logger defines structured logging for the SDK. Compatible with logr.Logger +// and slog.Logger adapters. +type Logger = types.Logger diff --git a/sdk/go/openshell/v1/oidc/authcode.go b/sdk/go/openshell/v1/oidc/authcode.go new file mode 100644 index 0000000000..d47f57b8a7 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode.go @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// generateCodeVerifier creates a PKCE code verifier per RFC 7636. +// It generates 32 random bytes and encodes them as base64url without +// padding, producing a 43-character string. +func generateCodeVerifier() (string, error) { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate code verifier: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// codeChallengeS256 computes the S256 PKCE code challenge for the +// given verifier. It returns BASE64URL(SHA256(verifier)) without +// padding, as specified in RFC 7636 Section 4.2. +func codeChallengeS256(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// generateState creates a cryptographic state parameter for the +// authorization request. It generates 16 random bytes encoded as +// base64url without padding. +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// buildAuthURL constructs the authorization endpoint URL with query +// parameters for the authorization code flow. If challenge is empty, +// PKCE parameters are omitted (for providers that do not support it). +func buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge string, scopes []string) string { + u, err := url.Parse(authEndpoint) + if err != nil || u.Scheme == "" { + u = &url.URL{Path: authEndpoint} + } + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("state", state) + q.Set("scope", strings.Join(scopes, " ")) + + if challenge != "" { + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + } + + u.RawQuery = q.Encode() + return u.String() +} + +// callbackResult carries the authorization code (or error) from the +// callback server to the auth code flow orchestrator. +type callbackResult struct { + code string + err error +} + +// startCallbackServer starts a localhost HTTP server to receive the +// OIDC provider's authorization callback. It listens on the specified +// port (use 0 for OS-assigned port). The server handles a single +// callback request and sends the result on the returned channel. +// +// The caller is responsible for calling srv.Close() when done. +func startCallbackServer(ctx context.Context, port int, expectedState string) (*http.Server, <-chan callbackResult, error) { + resultCh := make(chan callbackResult, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + // Check for provider error response. + if errCode := q.Get("error"); errCode != "" { + desc := q.Get("error_description") + msg := fmt.Sprintf("provider error: %s", errCode) + if desc != "" { + msg += ": " + desc + } + http.Error(w, msg, http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: %s", ErrAuthCode, msg)} + return + } + + // Validate state parameter. + state := q.Get("state") + if state != expectedState { + http.Error(w, "state mismatch", http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: state mismatch", ErrAuthCode)} + return + } + + // Extract authorization code. + code := q.Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: missing authorization code in callback", ErrAuthCode)} + return + } + + w.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprint(w, "

Login successful

You can close this window.

") + resultCh <- callbackResult{code: code} + }) + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, nil, fmt.Errorf("%w: failed to start callback server on port %d: %v", ErrCallbackServer, port, err) + } + + srv := &http.Server{ + Addr: listener.Addr().String(), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + go func() { + _ = srv.Serve(listener) + }() + + // Shut down the server when the context is cancelled. + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + return srv, resultCh, nil +} + +// tokenResponse is the JSON structure returned by the token endpoint. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// exchangeCode exchanges an authorization code for tokens at the +// token endpoint. If codeVerifier is empty, the PKCE code_verifier +// parameter is omitted from the request. +// +// Secrets (code, verifier) are never included in error messages. +func exchangeCode(ctx context.Context, tokenEndpoint, clientID, code, redirectURI, codeVerifier string) (*oauth2.Token, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + } + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request: %v", ErrAuthCode, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed: %v", ErrAuthCode, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response: %v", ErrAuthCode, err) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON: %v", ErrAuthCode, err) + } + + if resp.StatusCode != http.StatusOK || tokResp.Error != "" { + msg := "token exchange failed" + if tokResp.Error != "" { + msg = fmt.Sprintf("token exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + } + return nil, fmt.Errorf("%w: %s", ErrAuthCode, msg) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/authcode_test.go b/sdk/go/openshell/v1/oidc/authcode_test.go new file mode 100644 index 0000000000..dc241a3249 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode_test.go @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T012: PKCE verifier/challenge generation tests --- + +func TestGenerateCodeVerifier_Length(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // RFC 7636 requires 43-128 characters. Our implementation uses 32 + // random bytes -> 43 base64url characters (no padding). + assert.GreaterOrEqual(t, len(verifier), 43) + assert.LessOrEqual(t, len(verifier), 128) +} + +func TestGenerateCodeVerifier_Base64URLSafe(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // Must contain only base64url characters (A-Z, a-z, 0-9, -, _). + // No padding (=) allowed per RFC 7636. + for _, c := range verifier { + valid := (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' + assert.True(t, valid, "invalid character in verifier: %c", c) + } +} + +func TestGenerateCodeVerifier_Unique(t *testing.T) { + v1, err := generateCodeVerifier() + require.NoError(t, err) + + v2, err := generateCodeVerifier() + require.NoError(t, err) + + assert.NotEqual(t, v1, v2, "two verifiers should differ (random)") +} + +func TestCodeChallengeS256(t *testing.T) { + // RFC 7636 Appendix B test vector: + // verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge) +} + +func TestCodeChallengeS256_NoPadding(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + challenge := codeChallengeS256(verifier) + + // S256 challenge must be base64url without padding. + assert.NotContains(t, challenge, "=") + assert.NotContains(t, challenge, "+") + assert.NotContains(t, challenge, "/") +} + +// --- T013: Auth code flow tests --- + +func TestGenerateState_Length(t *testing.T) { + state, err := generateState() + require.NoError(t, err) + + // 16 random bytes -> 22 base64url chars (no padding). + assert.GreaterOrEqual(t, len(state), 16) +} + +func TestGenerateState_Unique(t *testing.T) { + s1, err := generateState() + require.NoError(t, err) + + s2, err := generateState() + require.NoError(t, err) + + assert.NotEqual(t, s1, s2) +} + +func TestBuildAuthURL(t *testing.T) { + authEndpoint := "https://auth.example.com/authorize" + clientID := "test-client" + redirectURI := "http://localhost:8000/callback" + state := "random-state" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + scopes := []string{"openid", "profile"} + + authURL := buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge, scopes) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Equal(t, clientID, q.Get("client_id")) + assert.Equal(t, redirectURI, q.Get("redirect_uri")) + assert.Equal(t, state, q.Get("state")) + assert.Equal(t, challenge, q.Get("code_challenge")) + assert.Equal(t, "S256", q.Get("code_challenge_method")) + assert.Equal(t, "openid profile", q.Get("scope")) +} + +func TestBuildAuthURL_NoPKCE(t *testing.T) { + authURL := buildAuthURL( + "https://auth.example.com/authorize", + "test-client", + "http://localhost:8000/callback", + "state", + "", // empty challenge = no PKCE + []string{"openid"}, + ) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Empty(t, q.Get("code_challenge"), "no PKCE when challenge is empty") + assert.Empty(t, q.Get("code_challenge_method")) +} + +func TestStartCallbackServer_ReceivesCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state-123" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + // Extract the port from the server's listener address. + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=auth-code-xyz&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + result := <-resultCh + require.NoError(t, result.err) + assert.Equal(t, "auth-code-xyz", result.code) +} + +func TestStartCallbackServer_StateMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "expected-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=some-code&state=wrong-state", addr, ) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "state") +} + +func TestStartCallbackServer_MissingCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) +} + +func TestStartCallbackServer_ProviderError(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?error=access_denied&error_description=user+denied&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "access_denied") +} + +func TestStartCallbackServer_SpecificPort(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Port 0 tells OS to pick a free port. We just verify it works. + srv, _, err := startCallbackServer(ctx, 0, "state") + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + assert.NotEmpty(t, srv.Addr) +} + +func TestExchangeCode_Success(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + + assert.Equal(t, "authorization_code", r.Form.Get("grant_type")) + assert.Equal(t, "test-code", r.Form.Get("code")) + assert.Equal(t, "test-client", r.Form.Get("client_id")) + assert.Equal(t, "http://localhost:8000/callback", r.Form.Get("redirect_uri")) + assert.NotEmpty(t, r.Form.Get("code_verifier"), "PKCE verifier should be sent") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-123", + "refresh_token": "rt-456", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "pkce-verifier", + ) + require.NoError(t, err) + assert.Equal(t, "at-123", tok.AccessToken) + assert.Equal(t, "rt-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestExchangeCode_NoPKCE(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Empty(t, r.Form.Get("code_verifier"), "no PKCE verifier when empty") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-no-pkce", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "", // empty verifier = no PKCE + ) + require.NoError(t, err) + assert.Equal(t, "at-no-pkce", tok.AccessToken) +} + +func TestExchangeCode_ErrorResponse(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant", "error_description": "code expired"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "bad-code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestExchangeCode_SecretsNotInError(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "secret-code-value", + "http://localhost/callback", + "secret-verifier-value", + ) + require.Error(t, err) + // The error message must not contain the auth code or verifier. + assert.NotContains(t, err.Error(), "secret-code-value") + assert.NotContains(t, err.Error(), "secret-verifier-value") +} + +func TestExchangeCode_InvalidJSON(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not json`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +// tokenResponseJSON is a helper for creating token endpoint responses. +func tokenResponseJSON(accessToken, refreshToken string, expiresIn int) string { + return fmt.Sprintf(`{ + "access_token": %q, + "refresh_token": %q, + "token_type": "Bearer", + "expires_in": %d + }`, accessToken, refreshToken, expiresIn) +} + diff --git a/sdk/go/openshell/v1/oidc/browser.go b/sdk/go/openshell/v1/oidc/browser.go new file mode 100644 index 0000000000..fd55c2ef36 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "fmt" + "os/exec" + "runtime" +) + +// browserCommand returns the platform-specific command name and +// arguments for opening a URL in the user's default browser. +func browserCommand(url string) (string, []string) { + switch runtime.GOOS { + case "darwin": + return "open", []string{url} + case "linux": + return "xdg-open", []string{url} + case "windows": + return "cmd", []string{"/c", "start", "", url} + default: + // Fallback: try xdg-open (common on Unix-like systems). + return "xdg-open", []string{url} + } +} + +// openBrowser attempts to open the given URL in the user's default +// browser using the platform-appropriate command. Returns an error if +// the browser could not be launched. +func openBrowser(url string) error { + name, args := browserCommand(url) + return openBrowserWith(name, args...) +} + +// openBrowserWith runs the given command with the provided arguments. +// This is separated from openBrowser to allow testing with arbitrary +// command names. +func openBrowserWith(name string, args ...string) error { + cmd := exec.Command(name, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open browser with %s: %w", name, err) + } + // We don't wait for the browser process to exit. It runs + // independently, and we only care that it launched. + return nil +} diff --git a/sdk/go/openshell/v1/oidc/browser_test.go b/sdk/go/openshell/v1/oidc/browser_test.go new file mode 100644 index 0000000000..49035238d9 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser_test.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "runtime" + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +// T015: Browser opener tests + +func TestBrowserCommand_Platform(t *testing.T) { + name, args := browserCommand("https://example.com/auth") + + switch runtime.GOOS { + case "darwin": + assert.Equal(t, "open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "linux": + assert.Equal(t, "xdg-open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "windows": + assert.Equal(t, "cmd", name) + assert.Contains(t, args, "/c") + assert.Contains(t, args, "start") + default: + // Unknown platform should still return something (even if it fails). + assert.NotEmpty(t, name) + } +} + +func TestBrowserCommand_URLPassedAsArg(t *testing.T) { + testURL := "https://auth.example.com/authorize?client_id=test&state=abc" + _, args := browserCommand(testURL) + + assert.True(t, slices.Contains(args, testURL), "URL should be passed as an argument to the browser command") +} + +func TestOpenBrowser_InvalidCommand(t *testing.T) { + // Attempting to open a browser with a non-existent command should + // return an error rather than panic. + err := openBrowserWith("nonexistent-browser-cmd-that-does-not-exist", "https://example.com") + assert.Error(t, err, "should fail when the browser command does not exist") +} diff --git a/sdk/go/openshell/v1/oidc/credentials.go b/sdk/go/openshell/v1/oidc/credentials.go new file mode 100644 index 0000000000..d49f19cbeb --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// ClientCredentials performs a non-interactive OAuth2 client credentials +// grant (RFC 6749 Section 4.4). It requires [WithIssuer], [WithClientID], +// and [WithClientSecret] (or [WithGateway] combined with [WithClientSecret]). +// +// This flow is intended for service accounts and machine-to-machine +// authentication. No user interaction occurs. The returned token +// typically contains only an access token (no refresh token). +// +// The client secret is never included in error messages (FR-014). +func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Client credentials should not send interactive scopes by default. + // Only send scopes if the caller explicitly set them via WithScopes. + if !cfg.scopesSet { + cfg.scopes = nil + } + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + if err != nil { + return nil, fmt.Errorf("failed to load gateway %q: %w", cfg.gateway, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return nil, fmt.Errorf( + "%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", + ErrOIDCConfig, cfg.gateway, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + if cfg.clientSecret == "" { + return nil, fmt.Errorf( + "%w: client secret is required (use WithClientSecret)", + ErrClientCredentials, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Build the token request with client credentials grant type. + data := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {cfg.clientID}, + "client_secret": {cfg.clientSecret}, + } + if len(cfg.scopes) > 0 { + data.Set("scope", strings.Join(cfg.scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.TokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request", ErrClientCredentials) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed", ErrClientCredentials) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response", ErrClientCredentials) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON", ErrClientCredentials) + } + + if resp.StatusCode != http.StatusOK || tokResp.Error != "" { + // FR-014: Never include the client secret in error messages. + // Only include the provider's error code and description. + msg := "client credentials exchange failed" + if tokResp.Error != "" { + msg = fmt.Sprintf("provider error: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + } + return nil, fmt.Errorf("%w: %s", ErrClientCredentials, msg) + } + + if tokResp.AccessToken == "" { + return nil, fmt.Errorf("%w: token response missing access_token", ErrClientCredentials) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/credentials_test.go b/sdk/go/openshell/v1/oidc/credentials_test.go new file mode 100644 index 0000000000..f87c6b31fb --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials_test.go @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T023: Client credentials tests --- + +// setupCredentialsMockProvider creates a mock OIDC provider for client +// credentials testing. The token endpoint validates Basic Auth and +// returns a token response. Returns the server and the expected +// client ID / client secret pair. +func setupCredentialsMockProvider(t *testing.T, expectedClientID, expectedSecret string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Validate grant type. + if r.Form.Get("grant_type") != "client_credentials" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type","error_description":"expected client_credentials"}`)) + return + } + + // Check credentials from form body (client_id + client_secret) + // or Basic Auth header. + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + if clientID == "" || clientSecret == "" { + // Try Basic Auth. + var ok bool + clientID, clientSecret, ok = r.BasicAuth() + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"missing credentials"}`)) + return + } + } + + if clientID != expectedClientID || clientSecret != expectedSecret { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"invalid credentials"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("cc-access-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestClientCredentials_Success verifies the happy path: valid client +// ID, secret, and issuer produce a valid access token. +func TestClientCredentials_Success(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) + assert.Empty(t, tok.RefreshToken, "client credentials should not return a refresh token") +} + +// TestClientCredentials_MissingIssuer verifies that ClientCredentials +// returns ErrOIDCConfig when the issuer is not set. +func TestClientCredentials_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientID verifies that ClientCredentials +// returns ErrOIDCConfig when the client ID is not set. +func TestClientCredentials_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientSecret verifies that +// ClientCredentials returns ErrClientCredentials when the secret is +// missing. +func TestClientCredentials_MissingClientSecret(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientID("my-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) +} + +// TestClientCredentials_InvalidCredentials verifies that +// ClientCredentials returns ErrClientCredentials when the provider +// rejects the credentials, and that the secret is not leaked in the +// error message. +func TestClientCredentials_InvalidCredentials(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "good-client", "good-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("good-client"), + WithClientSecret("wrong-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) + + // FR-014: The secret must NEVER appear in error messages. + assert.NotContains(t, err.Error(), "wrong-secret", "secret must not leak in error message") + assert.NotContains(t, err.Error(), "good-secret", "secret must not leak in error message") +} + +// TestClientCredentials_DiscoveryFailure verifies that +// ClientCredentials returns ErrDiscovery when the provider is +// unreachable. +func TestClientCredentials_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestClientCredentials_WithGateway verifies that ClientCredentials +// resolves OIDC config from gateway metadata when WithGateway is set. +func TestClientCredentials_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "gw-client", "gw-secret") + + fakeConfig := &gateway.Config{ + Name: "cc-gateway", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithGateway("cc-gateway"), + WithClientSecret("gw-secret"), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "cc-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) +} + +// TestClientCredentials_CustomScopes verifies that WithScopes overrides +// default scopes in the client credentials request. +func TestClientCredentials_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + var receivedScope string + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedScope = r.Form.Get("scope") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-cc-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(srv.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + WithScopes("api:read", "api:write"), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-cc-token", tok.AccessToken) + assert.Equal(t, "api:read api:write", receivedScope) +} + +// TestClientCredentials_ContextCancellation verifies that +// ClientCredentials respects context cancellation. +func TestClientCredentials_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/device.go b/sdk/go/openshell/v1/oidc/device.go new file mode 100644 index 0000000000..c6701ff80e --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device.go @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// deviceAuthResponse holds the parsed response from the device +// authorization endpoint (RFC 8628 Section 3.2). +type deviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// DeviceLogin performs an OAuth2 device authorization grant (RFC 8628). +// +// The flow requests a device code and user code from the provider's +// device authorization endpoint, displays them to the user (via +// [WithDisplayFunc] or stdout), and polls the token endpoint until the +// user completes authorization. +// +// Required options: [WithIssuer] and [WithClientID], or [WithGateway]. +// +// The polling loop respects the provider's interval and handles the +// following token endpoint error codes: +// - "authorization_pending": continue polling at the current interval +// - "slow_down": increase the polling interval by 5 seconds (RFC 8628 Section 3.5) +// - "expired_token": the device code has expired, return [ErrDeviceCode] +// - any other error: return [ErrDeviceCode] +func DeviceLogin(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + if err != nil { + return nil, fmt.Errorf("failed to load gateway %q: %w", cfg.gateway, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return nil, fmt.Errorf( + "%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", + ErrOIDCConfig, cfg.gateway, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Verify the provider supports device authorization. + if provider.DeviceAuthorizationEndpoint == "" { + return nil, fmt.Errorf( + "%w: provider does not support device authorization (no device_authorization_endpoint in discovery)", + ErrDeviceCode, + ) + } + + // Request a device code from the provider. + deviceResp, err := requestDeviceCode(ctx, provider.DeviceAuthorizationEndpoint, cfg.clientID, cfg.scopes) + if err != nil { + return nil, err + } + + // Display the verification URL and user code to the user. + if cfg.displayFunc != nil { + cfg.displayFunc(deviceResp.VerificationURI, deviceResp.UserCode) + } else { + fmt.Printf("To sign in, visit: %s\n", deviceResp.VerificationURI) + fmt.Printf("Enter code: %s\n", deviceResp.UserCode) + } + + // Enforce device code lifetime from the provider's expires_in field. + // If the caller's context already has a shorter deadline, that takes + // precedence. This prevents indefinite polling against non-compliant + // providers that never return expired_token. + if deviceResp.ExpiresIn > 0 { + expiry := time.Duration(deviceResp.ExpiresIn) * time.Second + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, expiry) + defer cancel() + } + + // Poll the token endpoint until authorization completes, expires, + // or the context is cancelled. + interval := deviceResp.Interval + if interval < 1 { + interval = 5 // default polling interval per RFC 8628 + } + + return pollDeviceToken(ctx, provider.TokenEndpoint, cfg.clientID, deviceResp.DeviceCode, interval) +} + +// requestDeviceCode sends a POST to the device authorization endpoint +// and returns the parsed response containing the device code, user +// code, and verification URI. +func requestDeviceCode(ctx context.Context, endpoint, clientID string, scopes []string) (*deviceAuthResponse, error) { + data := url.Values{ + "client_id": {clientID}, + } + if len(scopes) > 0 { + data.Set("scope", strings.Join(scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create device authorization request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: device authorization request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read device authorization response", ErrDeviceCode) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: device authorization endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + var deviceResp deviceAuthResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return nil, fmt.Errorf("%w: invalid device authorization response JSON", ErrDeviceCode) + } + + if deviceResp.DeviceCode == "" || deviceResp.UserCode == "" { + return nil, fmt.Errorf("%w: device authorization response missing device_code or user_code", ErrDeviceCode) + } + + return &deviceResp, nil +} + +// pollDeviceToken polls the token endpoint at the given interval until +// the user completes authorization. It handles RFC 8628 error codes: +// - "authorization_pending": keep polling +// - "slow_down": increase interval by 5 seconds +// - "expired_token": return ErrDeviceCode +func pollDeviceToken(ctx context.Context, tokenEndpoint, clientID, deviceCode string, interval int64) (*oauth2.Token, error) { + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case <-ticker.C: + tok, done, slowDown, err := tryDeviceTokenExchange(ctx, tokenEndpoint, clientID, deviceCode) + if done { + if err != nil { + return nil, err + } + return tok, nil + } + // Adjust interval if the provider requested slow_down + // (+5 seconds per RFC 8628 Section 3.5). + if slowDown < 0 { + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + } + } + } +} + +// tryDeviceTokenExchange makes a single token request for the device +// code grant. Returns: +// - (token, true, 0, nil): success +// - (nil, true, 0, err): terminal error (expired, access_denied, etc.) +// - (nil, false, interval, nil): continue polling (authorization_pending or slow_down) +func tryDeviceTokenExchange(ctx context.Context, tokenEndpoint, clientID, deviceCode string) (*oauth2.Token, bool, int64, error) { + data := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {clientID}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to create token request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + // If the context was cancelled or timed out, surface that as + // ErrTimeout so callers can distinguish "user/caller cancelled" + // from a genuine device-code error. + if ctx.Err() != nil { + return nil, true, 0, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + } + // Other network errors during polling are terminal. + return nil, true, 0, fmt.Errorf("%w: token request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to read token response", ErrDeviceCode) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, true, 0, fmt.Errorf("%w: invalid token response JSON", ErrDeviceCode) + } + + // Handle error responses per RFC 8628 Section 3.5. + if tokResp.Error != "" { + switch tokResp.Error { + case "authorization_pending": + // User has not yet completed authorization. Keep polling. + return nil, false, 0, nil + case "slow_down": + // Provider requests increased interval (+5 seconds per RFC 8628). + // Return a sentinel value; the caller adds 5 to current interval. + return nil, false, -1, nil + case "expired_token": + return nil, true, 0, fmt.Errorf("%w: device code expired", ErrDeviceCode) + case "access_denied": + return nil, true, 0, fmt.Errorf("%w: access denied by user", ErrDeviceCode) + default: + msg := fmt.Sprintf("device code exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + return nil, true, 0, fmt.Errorf("%w: %s", ErrDeviceCode, msg) + } + } + + // Success: parse the token. + if resp.StatusCode != http.StatusOK { + return nil, true, 0, fmt.Errorf("%w: token endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, true, 0, nil +} diff --git a/sdk/go/openshell/v1/oidc/device_test.go b/sdk/go/openshell/v1/oidc/device_test.go new file mode 100644 index 0000000000..43b2bae82b --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device_test.go @@ -0,0 +1,682 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T025: Device code flow tests --- + +// setupDeviceMockProvider creates a mock OIDC provider for device code +// flow testing. The device authorization endpoint returns a device code +// and verification URL. The token endpoint simulates polling behavior: +// it returns "authorization_pending" for the first N polls, then returns +// a valid token response. +func setupDeviceMockProvider(t *testing.T, pendingPolls int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=ABCD-1234", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Only handle device code grants here. + if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type"}`)) + return + } + + count := pollCount.Add(1) + if int(count) <= pendingPolls { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("device-access-token", "device-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestDeviceLogin_Success verifies the happy path: the device code flow +// requests a device code, displays it, polls until authorized, and +// returns a valid token. +func TestDeviceLogin_Success(t *testing.T) { + resetDiscoveryCache() + + // Provider returns "authorization_pending" for the first 2 polls, + // then returns a token on the 3rd poll. + provider := setupDeviceMockProvider(t, 2) + + var displayedURL, displayedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + displayedURL = verificationURL + displayedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.Equal(t, "device-refresh-token", tok.RefreshToken) + + // Verify the display callback was invoked with correct values. + assert.Equal(t, "https://example.com/activate", displayedURL) + assert.Equal(t, "ABCD-1234", displayedCode) +} + +// TestDeviceLogin_MissingIssuer verifies that DeviceLogin returns +// ErrOIDCConfig when the issuer is not provided. +func TestDeviceLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_MissingClientID verifies that DeviceLogin returns +// ErrOIDCConfig when the client ID is not provided. +func TestDeviceLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithIssuer("https://example.com"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_DiscoveryFailure verifies that DeviceLogin returns +// ErrDiscovery when the OIDC provider is unreachable. +func TestDeviceLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestDeviceLogin_SlowDown verifies that the polling loop respects the +// "slow_down" response by increasing the polling interval. +func TestDeviceLogin_SlowDown(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "slow-device-code", + "user_code": "SLOW-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + count := pollCount.Add(1) + w.Header().Set("Content-Type", "application/json") + + switch count { + case 1: + // First poll: slow_down + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"slow_down"}`)) + case 2: + // Second poll: still pending + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + default: + // Third poll: success + _, _ = w.Write([]byte(tokenResponseJSON("slow-token", "", 3600))) + } + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.NoError(t, err) + assert.Equal(t, "slow-token", tok.AccessToken) +} + +// TestDeviceLogin_ExpiredDeviceCode verifies that DeviceLogin returns +// ErrDeviceCode when the device code expires before authorization. +func TestDeviceLogin_ExpiredDeviceCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "expiring-device-code", + "user_code": "EXPR-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"expired_token","error_description":"device code expired"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_CustomDisplayFunc verifies that WithDisplayFunc is +// invoked with the verification URL and user code. +func TestDeviceLogin_CustomDisplayFunc(t *testing.T) { + resetDiscoveryCache() + + // Provider that immediately returns a token (0 pending polls). + provider := setupDeviceMockProvider(t, 0) + + var called bool + var capturedURL, capturedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + called = true + capturedURL = verificationURL + capturedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.True(t, called, "display function should have been called") + assert.Equal(t, "https://example.com/activate", capturedURL) + assert.Equal(t, "ABCD-1234", capturedCode) +} + +// TestDeviceLogin_WithGateway verifies that DeviceLogin resolves OIDC +// config from gateway metadata when WithGateway is set. +func TestDeviceLogin_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupDeviceMockProvider(t, 0) + + fakeConfig := &gateway.Config{ + Name: "device-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-device-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithGateway("device-gw"), + WithDisplayFunc(func(_, _ string) {}), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "device-gw", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) +} + +// TestDeviceLogin_ContextCancellation verifies that DeviceLogin +// respects context cancellation during polling. +func TestDeviceLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + // Provider that always returns "authorization_pending" so + // the polling loop never succeeds on its own. + provider := setupDeviceMockProvider(t, 1000) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + // Should be ErrTimeout or context.DeadlineExceeded wrapped. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, context.DeadlineExceeded), + "expected timeout error, got: %v", err, + ) +} + +// TestDeviceLogin_AccessDenied verifies that DeviceLogin returns +// ErrDeviceCode when the user denies authorization. +func TestDeviceLogin_AccessDenied(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "denied-device-code", + "user_code": "DENY-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"user denied"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "access denied") +} + +// TestDeviceLogin_UnknownError verifies that DeviceLogin returns +// ErrDeviceCode with the error description for unknown error codes. +func TestDeviceLogin_UnknownError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "unknown-err-code", + "user_code": "UNKN-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"server_error","error_description":"internal failure"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "server_error") + assert.Contains(t, err.Error(), "internal failure") +} + +// TestDeviceLogin_DeviceEndpointHTTPError verifies that DeviceLogin +// returns ErrDeviceCode when the device authorization endpoint returns +// a non-200 HTTP status. +func TestDeviceLogin_DeviceEndpointHTTPError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_DeviceEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the device endpoint returns invalid JSON. +func TestDeviceLogin_DeviceEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_MissingUserCode verifies that DeviceLogin returns +// ErrDeviceCode when the device endpoint returns an empty user_code. +func TestDeviceLogin_MissingUserCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "code-but-no-user", + "user_code": "", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_TokenEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the token endpoint returns invalid JSON +// during polling. +func TestDeviceLogin_TokenEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "json-err-code", + "user_code": "JSON-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{invalid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_NoDeviceEndpoint verifies that DeviceLogin returns +// ErrDeviceCode when the provider does not advertise a device +// authorization endpoint. +func TestDeviceLogin_NoDeviceEndpoint(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + // No device_authorization_endpoint. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} diff --git a/sdk/go/openshell/v1/oidc/discovery.go b/sdk/go/openshell/v1/oidc/discovery.go new file mode 100644 index 0000000000..14ad5f47e4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" +) + +// providerConfig holds parsed fields from an OIDC discovery document +// (.well-known/openid-configuration). +type providerConfig struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// discoveryCache stores successfully fetched provider configurations +// keyed by normalized issuer URL. Only successful results are cached; +// errors are not cached so that transient failures (network issues, +// context cancellation) do not permanently poison the cache. +var ( + discoveryCacheMu sync.Mutex + discoveryCache = make(map[string]*providerConfig) +) + +// resetDiscoveryCache clears the in-memory discovery cache. This is +// only used by tests to avoid interference between test cases. +func resetDiscoveryCache() { + discoveryCacheMu.Lock() + defer discoveryCacheMu.Unlock() + discoveryCache = make(map[string]*providerConfig) +} + +// normalizeIssuer strips a trailing slash from the issuer URL so that +// "https://auth.example.com" and "https://auth.example.com/" resolve +// to the same cache key. +func normalizeIssuer(issuer string) string { + return strings.TrimRight(issuer, "/") +} + +// discover fetches and caches the OIDC discovery document for the +// given issuer URL. Only successful results are cached; failed +// fetches are retried on the next call. +func discover(ctx context.Context, issuer string) (*providerConfig, error) { + key := normalizeIssuer(issuer) + + discoveryCacheMu.Lock() + if cached, ok := discoveryCache[key]; ok { + discoveryCacheMu.Unlock() + return cached, nil + } + discoveryCacheMu.Unlock() + + cfg, err := fetchDiscovery(ctx, key) + if err != nil { + return nil, err + } + + discoveryCacheMu.Lock() + // Check again in case another goroutine cached it while we fetched. + if existing, ok := discoveryCache[key]; ok { + discoveryCacheMu.Unlock() + return existing, nil + } + discoveryCache[key] = cfg + discoveryCacheMu.Unlock() + + return cfg, nil +} + +// fetchDiscovery performs the actual HTTP GET to the OIDC discovery +// endpoint and parses the response. +func fetchDiscovery(ctx context.Context, issuer string) (*providerConfig, error) { + url := issuer + "/.well-known/openid-configuration" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: discovery endpoint returned HTTP %d", ErrDiscovery, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read discovery response: %v", ErrDiscovery, err) + } + + var cfg providerConfig + if err := json.Unmarshal(body, &cfg); err != nil { + return nil, fmt.Errorf("%w: invalid discovery JSON: %v", ErrDiscovery, err) + } + + if cfg.TokenEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing token_endpoint", ErrDiscovery) + } + if cfg.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing authorization_endpoint", ErrDiscovery) + } + + return &cfg, nil +} diff --git a/sdk/go/openshell/v1/oidc/discovery_test.go b/sdk/go/openshell/v1/oidc/discovery_test.go new file mode 100644 index 0000000000..14af9ed6aa --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery_test.go @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// wellKnownJSON returns a valid OIDC discovery document JSON string +// with the given issuer URL as the base. +func wellKnownJSON(issuer string) string { + return `{ + "issuer": "` + issuer + `", + "authorization_endpoint": "` + issuer + `/authorize", + "token_endpoint": "` + issuer + `/token", + "device_authorization_endpoint": "` + issuer + `/device", + "scopes_supported": ["openid", "profile", "email"], + "code_challenge_methods_supported": ["S256"] + }` +} + +func TestDiscover_ValidDocument(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + // Clear cache to avoid interference from other tests. + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + assert.Equal(t, srv.URL, cfg.Issuer) + assert.Equal(t, srv.URL+"/authorize", cfg.AuthorizationEndpoint) + assert.Equal(t, srv.URL+"/token", cfg.TokenEndpoint) + assert.Equal(t, srv.URL+"/device", cfg.DeviceAuthorizationEndpoint) + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.ScopesSupported) + assert.Equal(t, []string{"S256"}, cfg.CodeChallengeMethodsSupported) +} + +func TestDiscover_CachesResult(t *testing.T) { + callCount := 0 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + // Same pointer should be returned from cache. + assert.Same(t, cfg1, cfg2) + assert.Equal(t, 1, callCount, "discovery should be fetched only once") +} + +func TestDiscover_DifferentIssuersNotCached(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + }) + + srv1 := httptest.NewServer(handler) + defer srv1.Close() + srv2 := httptest.NewServer(handler) + defer srv2.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv1.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv2.URL) + require.NoError(t, err) + + // Different issuers should yield different cached entries. + assert.NotSame(t, cfg1, cfg2) +} + +func TestDiscover_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json}`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_MissingTokenEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) + assert.Contains(t, err.Error(), "token_endpoint") +} + +func TestDiscover_ContextCancelled(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + _, err := discover(ctx, srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_ConcurrentAccess(t *testing.T) { + callCount := 0 + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + callCount++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON("http://example.com"))) + })) + defer srv.Close() + + resetDiscoveryCache() + + var wg sync.WaitGroup + results := make([]*providerConfig, 10) + errs := make([]error, 10) + + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errs[idx] = discover(context.Background(), srv.URL) + }(i) + } + wg.Wait() + + for i := range 10 { + require.NoError(t, errs[i]) + assert.NotNil(t, results[i]) + } + + // Without sync.Once, concurrent goroutines may each fetch before + // the first result is cached. All calls should succeed, and the + // server should be called at most once per concurrent racer (not + // 10 times if caching works at all). In practice, most calls + // should hit the cache after the first fetch completes. + mu.Lock() + defer mu.Unlock() + assert.LessOrEqual(t, callCount, 10, "caching should reduce total fetches") +} + +func TestDiscover_NoDeviceEndpointIsOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + assert.Empty(t, cfg.DeviceAuthorizationEndpoint) +} + +func TestDiscover_TrailingSlashNormalized(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + // Call with trailing slash and without; should be same cache entry. + _, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + _, err = discover(context.Background(), srv.URL+"/") + require.NoError(t, err) + + assert.Equal(t, 1, callCount, "trailing slash should be normalized for caching") +} diff --git a/sdk/go/openshell/v1/oidc/doc.go b/sdk/go/openshell/v1/oidc/doc.go new file mode 100644 index 0000000000..2575a09127 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/doc.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package oidc provides OIDC authentication flows for the OpenShell SDK. +// +// The package supports four authentication flows: +// +// - Authorization Code with PKCE (interactive browser-based login) +// - Keyboard flow (manual URL copy and code paste for headless environments) +// - Device Code flow (RFC 8628, for input-constrained devices) +// - Client Credentials grant (non-interactive service account authentication) +// +// # Gateway-Aware Login +// +// The primary use case is gateway-aware login, where OIDC provider +// configuration is read from a gateway's metadata.json file: +// +// token, err := oidc.Login(ctx, "my-gateway") +// if err != nil { +// log.Fatal(err) +// } +// +// After successful authentication, tokens are persisted to disk in the +// gateway directory as oidc_token.json, compatible with +// [gateway.NewClient] and the existing [gateway.diskTokenSource]. +// +// # Standalone Login +// +// For OIDC providers not tied to an OpenShell gateway, use explicit +// configuration: +// +// token, err := oidc.Login(ctx, "", +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// oidc.WithInMemory(), +// ) +// +// # Device Code Flow +// +// For environments without a browser: +// +// token, err := oidc.DeviceLogin(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// ) +// +// # Client Credentials +// +// For non-interactive service accounts: +// +// token, err := oidc.ClientCredentials(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-service"), +// oidc.WithClientSecret("secret"), +// ) +// +// # Error Handling +// +// The package provides typed sentinel errors for precise failure +// classification: +// +// - [ErrDiscovery]: OIDC discovery fetch or parse failed +// - [ErrAuthCode]: Authorization code exchange failed +// - [ErrDeviceCode]: Device code flow failed +// - [ErrClientCredentials]: Client credentials exchange failed +// - [ErrTimeout]: Interactive flow timed out +// - [ErrCallbackServer]: Localhost callback server failed to start +// - [ErrTokenPersist]: Token disk write failed +// - [ErrOIDCConfig]: Gateway metadata missing OIDC fields +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. OIDC discovery documents are cached in memory per issuer +// URL for the lifetime of the process. +package oidc diff --git a/sdk/go/openshell/v1/oidc/errors.go b/sdk/go/openshell/v1/oidc/errors.go new file mode 100644 index 0000000000..20c9105c03 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import "errors" + +// Sentinel errors for OIDC authentication failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrDiscovery is returned when the OIDC discovery document + // (.well-known/openid-configuration) cannot be fetched or parsed. + ErrDiscovery = errors.New("oidc: discovery failed") + + // ErrAuthCode is returned when the authorization code exchange + // fails (invalid code, expired code, provider error). + ErrAuthCode = errors.New("oidc: auth code exchange failed") + + // ErrDeviceCode is returned when the device code flow fails + // (request error, expired device code, provider error). + ErrDeviceCode = errors.New("oidc: device code flow failed") + + // ErrClientCredentials is returned when the client credentials + // grant fails (invalid credentials, provider error). The error + // message never contains the client secret. + ErrClientCredentials = errors.New("oidc: client credentials exchange failed") + + // ErrTimeout is returned when an interactive login flow + // (browser, keyboard, or device code) exceeds its deadline. + ErrTimeout = errors.New("oidc: login timed out") + + // ErrCallbackServer is returned when the localhost HTTP server + // for the authorization code redirect cannot bind to any port. + ErrCallbackServer = errors.New("oidc: callback server failed") + + // ErrTokenPersist is returned when the token cannot be written + // to disk (permission error, invalid path). + ErrTokenPersist = errors.New("oidc: token persistence failed") + + // ErrOIDCConfig is returned when gateway metadata is missing + // the required oidc_issuer or oidc_client_id fields. + ErrOIDCConfig = errors.New("oidc: gateway OIDC config missing") +) diff --git a/sdk/go/openshell/v1/oidc/errors_test.go b/sdk/go/openshell/v1/oidc/errors_test.go new file mode 100644 index 0000000000..ea9dfb5a10 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors_test.go @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSentinelErrors_AreDistinct(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for i, a := range sentinels { + for j, b := range sentinels { + if i == j { + continue + } + assert.False(t, errors.Is(a, b), + "expected %v and %v to be distinct", a, b) + } + } +} + +func TestSentinelErrors_MatchSelf(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for _, sentinel := range sentinels { + assert.True(t, errors.Is(sentinel, sentinel), + "expected %v to match itself", sentinel) + } +} + +func TestSentinelErrors_WrappedMatchViIs(t *testing.T) { + cases := []struct { + name string + sentinel error + }{ + {"ErrDiscovery", ErrDiscovery}, + {"ErrAuthCode", ErrAuthCode}, + {"ErrDeviceCode", ErrDeviceCode}, + {"ErrClientCredentials", ErrClientCredentials}, + {"ErrTimeout", ErrTimeout}, + {"ErrCallbackServer", ErrCallbackServer}, + {"ErrTokenPersist", ErrTokenPersist}, + {"ErrOIDCConfig", ErrOIDCConfig}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapped := fmt.Errorf("operation failed: %w", tc.sentinel) + assert.True(t, errors.Is(wrapped, tc.sentinel), + "wrapped error should match sentinel via errors.Is") + }) + } +} + +func TestSentinelErrors_HaveDescriptiveMessages(t *testing.T) { + cases := []struct { + sentinel error + contains string + }{ + {ErrDiscovery, "discovery"}, + {ErrAuthCode, "auth code"}, + {ErrDeviceCode, "device code"}, + {ErrClientCredentials, "client credentials"}, + {ErrTimeout, "timed out"}, + {ErrCallbackServer, "callback server"}, + {ErrTokenPersist, "token persistence"}, + {ErrOIDCConfig, "OIDC config"}, + } + + for _, tc := range cases { + t.Run(tc.sentinel.Error(), func(t *testing.T) { + assert.Contains(t, tc.sentinel.Error(), tc.contains) + }) + } +} + +func TestSentinelErrors_DoubleWrapped(t *testing.T) { + inner := fmt.Errorf("http timeout: %w", ErrDiscovery) + outer := fmt.Errorf("login failed: %w", inner) + + assert.True(t, errors.Is(outer, ErrDiscovery), + "double-wrapped error should still match sentinel") +} diff --git a/sdk/go/openshell/v1/oidc/example_test.go b/sdk/go/openshell/v1/oidc/example_test.go new file mode 100644 index 0000000000..c304ef0bfa --- /dev/null +++ b/sdk/go/openshell/v1/oidc/example_test.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// These examples demonstrate OIDC package usage but are guarded from +// execution during `go test` because they require real network access +// and user interaction. The guard `if false` keeps the code type-checked +// by the compiler without executing during tests. + +package oidc_test + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc" +) + +func ExampleLogin_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_standalone() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "", + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-app"), + oidc.WithInMemory(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Access token: %s...\n", token.AccessToken[:10]) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_keyboard() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway", + oidc.WithKeyboardFlow(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated via keyboard flow. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-device-app"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Device authorized. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin_customDisplay() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-tui-app"), + oidc.WithDisplayFunc(func(verificationURL, userCode string) { + fmt.Printf("Please visit: %s\n", verificationURL) + fmt.Printf("Enter code: %s\n", userCode) + }), + ) + if err != nil { + log.Fatal(err) + } + + _ = token + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-service"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithGateway("my-gateway"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated via gateway. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} diff --git a/sdk/go/openshell/v1/oidc/keyboard.go b/sdk/go/openshell/v1/oidc/keyboard.go new file mode 100644 index 0000000000..d7367b8186 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" +) + +// keyboardFlow implements the keyboard fallback for the authorization +// code flow. It displays the authorization URL to the user and reads +// the pasted authorization code from the provided reader. +// +// Parameters: +// - ctx: context for cancellation/timeout +// - authURL: the full authorization URL to display +// - input: reader for user input (typically os.Stdin) +// - output: writer for prompts/instructions (typically os.Stderr) +// +// Returns the authorization code or an error. +func keyboardFlow(ctx context.Context, authURL string, input io.Reader, output io.Writer) (string, error) { + // Display instructions and URL. + _, _ = fmt.Fprintf(output, "\nOpen the following URL in your browser to authenticate:\n\n %s\n\n", authURL) + _, _ = fmt.Fprint(output, "Paste the authorization code here and press Enter: ") + + // Read code with context cancellation support. + type readResult struct { + code string + err error + } + ch := make(chan readResult, 1) + + go func() { + scanner := bufio.NewScanner(input) + if scanner.Scan() { + ch <- readResult{code: strings.TrimSpace(scanner.Text())} + } else { + err := scanner.Err() + if err == nil { + // EOF without reading a line. + ch <- readResult{err: fmt.Errorf("%w: no authorization code received (EOF)", ErrAuthCode)} + } else { + ch <- readResult{err: fmt.Errorf("%w: failed to read authorization code: %v", ErrAuthCode, err)} + } + } + }() + + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case result := <-ch: + if result.err != nil { + return "", result.err + } + if result.code == "" { + return "", fmt.Errorf("%w: empty authorization code", ErrAuthCode) + } + return result.code, nil + } +} diff --git a/sdk/go/openshell/v1/oidc/keyboard_test.go b/sdk/go/openshell/v1/oidc/keyboard_test.go new file mode 100644 index 0000000000..fa0c4ab17f --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard_test.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// T014: Keyboard fallback flow tests + +func TestKeyboardFlow_ReadsCode(t *testing.T) { + // Simulate user pasting a code via stdin. + input := strings.NewReader("my-auth-code\n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?client_id=test", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "my-auth-code", code) + + // Verify that the URL was displayed to the user. + assert.Contains(t, output.String(), "https://auth.example.com/authorize?client_id=test") +} + +func TestKeyboardFlow_TrimsWhitespace(t *testing.T) { + input := strings.NewReader(" some-code-with-spaces \n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "some-code-with-spaces", code) +} + +func TestKeyboardFlow_EmptyInput(t *testing.T) { + input := strings.NewReader("\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_EOFBeforeInput(t *testing.T) { + // Reader that returns EOF immediately (e.g., piped /dev/null). + input := strings.NewReader("") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + // Should be ErrAuthCode since no code was received. + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + // Use a reader that blocks forever (until context cancel). + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://auth.example.com/authorize", input, output) + require.Error(t, err) +} + +func TestKeyboardFlow_DisplaysInstructions(t *testing.T) { + input := strings.NewReader("test-code\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?response_type=code", + input, + output, + ) + require.NoError(t, err) + + displayed := output.String() + // Must show the URL and some instruction text. + assert.Contains(t, displayed, "https://auth.example.com/authorize?response_type=code") + // Should prompt user to paste the code. + lower := strings.ToLower(displayed) + assert.True(t, + strings.Contains(lower, "paste") || strings.Contains(lower, "code") || strings.Contains(lower, "enter"), + "output should instruct the user to paste or enter the code", + ) +} + +func TestKeyboardFlow_Timeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // Reader that never returns data. + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://example.com/auth", input, output) + require.Error(t, err) +} + +// blockingReader is an io.Reader that blocks until the context is cancelled. +// It is used to simulate a user who never types anything. +type blockingReader struct{} + +func (r *blockingReader) Read(_ []byte) (int, error) { + // Block for a long time to simulate waiting for input. + time.Sleep(10 * time.Second) + return 0, io.EOF +} diff --git a/sdk/go/openshell/v1/oidc/oidc.go b/sdk/go/openshell/v1/oidc/oidc.go new file mode 100644 index 0000000000..9b612cc280 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc.go @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "fmt" + "io" + "os" + "slices" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// Login performs an interactive OIDC authorization code login. +// +// When gatewayName is non-empty, Login resolves OIDC configuration +// (issuer URL and client ID) from the gateway's metadata.json file and +// persists tokens to the gateway directory. +// +// When gatewayName is empty, the caller must provide [WithIssuer] and +// [WithClientID] options explicitly. +// +// Before starting an interactive flow, Login checks for an existing +// valid token on disk (FR-019). If a valid, non-expired token is found, +// it is returned immediately without user interaction. +// +// The flow attempts to open a browser for authorization. If the browser +// cannot be opened, or if [WithKeyboardFlow] is set, the keyboard +// fallback flow is used instead. +func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Apply configured timeout if the caller's context has no deadline. + if _, hasDeadline := ctx.Deadline(); !hasDeadline && cfg.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.timeout) + defer cancel() + } + + // Resolve OIDC configuration from gateway or explicit options. + tokenDir, err := resolveOIDCConfig(cfg, gatewayName) + if err != nil { + return nil, err + } + + // FR-019: Check for existing valid token on disk before starting + // an interactive flow. + if tokenDir != "" { + tok, readErr := readToken(tokenDir) + if readErr == nil && tok != nil && tok.Valid() { + return tok, nil + } + // Stale/expired tokens or missing files are not errors; we + // simply proceed to the interactive flow. + } + + // Run OIDC discovery to get provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Generate PKCE verifier and challenge if the provider supports S256. + var codeVerifier, codeChallenge string + if supportsS256(provider) { + codeVerifier, err = generateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + codeChallenge = codeChallengeS256(codeVerifier) + } + + // Generate cryptographic state for CSRF protection. + state, err := generateState() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + + // Determine the authorization code acquisition method. + // The redirectURI must match exactly between the auth request and + // the token exchange (OIDC/OAuth2 requirement). + var code, redirectURI string + if cfg.keyboardFlow { + redirectURI = "urn:ietf:wg:oauth:2.0:oob" + code, err = loginKeyboard(ctx, cfg, provider, state, codeChallenge) + } else { + code, redirectURI, err = loginBrowser(ctx, cfg, provider, state, codeChallenge) + } + if err != nil { + return nil, err + } + + // Exchange the authorization code for tokens. + tok, err := exchangeCode(ctx, provider.TokenEndpoint, cfg.clientID, code, redirectURI, codeVerifier) + if err != nil { + return nil, err + } + + // Persist token to disk unless in-memory mode is requested. + if !cfg.inMemory && tokenDir != "" { + if writeErr := writeToken(tokenDir, tok); writeErr != nil { + return nil, writeErr + } + } + + return tok, nil +} + +// resolveOIDCConfig resolves the OIDC issuer and client ID either from +// the gateway metadata or from explicit options. Returns the token +// directory path (empty if in-memory or no directory available). +func resolveOIDCConfig(cfg *loginConfig, gatewayName string) (string, error) { + tokenDir := cfg.tokenDir + + if gatewayName != "" { + // Resolve from gateway. + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(gatewayName) + if err != nil { + return "", fmt.Errorf("failed to load gateway %q: %w", gatewayName, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return "", fmt.Errorf("%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", ErrOIDCConfig, gatewayName) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + if tokenDir == "" { + tokenDir = gwCfg.Dir + } + } + + // Validate that we have the minimum required config. + if cfg.issuer == "" || cfg.clientID == "" { + return "", fmt.Errorf("%w: issuer and client ID are required (provide a gateway name or use WithIssuer and WithClientID)", ErrOIDCConfig) + } + + return tokenDir, nil +} + +// supportsS256 checks if the OIDC provider advertises S256 PKCE support. +func supportsS256(provider *providerConfig) bool { + return slices.Contains(provider.CodeChallengeMethodsSupported, "S256") +} + +// loginKeyboard performs the keyboard flow: builds the auth URL, shows +// it to the user, and reads the pasted authorization code. +func loginKeyboard(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, error) { + redirectURI := "urn:ietf:wg:oauth:2.0:oob" + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + input := cfg.input + if input == nil { + input = os.Stdin + } + var output io.Writer = os.Stderr + if cfg.output != nil { + output = cfg.output + } + + return keyboardFlow(ctx, authURL, input, output) +} + +// loginBrowser performs the browser-based flow: starts a callback server, +// opens the browser, and waits for the callback. Falls back to keyboard +// if the browser cannot be opened. +// +// Returns (code, redirectURI, error). The redirectURI must be passed to +// exchangeCode so that it exactly matches the URI used in the auth +// request. When the function falls back to keyboard flow, the returned +// redirectURI is the keyboard placeholder ("urn:ietf:wg:oauth:2.0:oob"). +func loginBrowser(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, string, error) { + port := cfg.callbackPort + if port == 0 { + port = 8000 + } + + srv, resultCh, err := startCallbackServer(ctx, port, state) + if err != nil { + // Try fallback port if the primary port failed and no custom + // port was specified. + if cfg.callbackPort == 0 { + port = 18000 + srv, resultCh, err = startCallbackServer(ctx, port, state) + } + if err != nil { + // Cannot start callback server, fall back to keyboard. + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + } + defer func() { + _ = srv.Close() + }() + + redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + // Try to open the browser. + if browserErr := openBrowser(authURL); browserErr != nil { + // Browser failed, fall back to keyboard flow. + _ = srv.Close() + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + + // Wait for the callback result or context cancellation. + select { + case <-ctx.Done(): + return "", "", fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case result := <-resultCh: + if result.err != nil { + return "", "", result.err + } + return result.code, redirectURI, nil + } +} diff --git a/sdk/go/openshell/v1/oidc/oidc_test.go b/sdk/go/openshell/v1/oidc/oidc_test.go new file mode 100644 index 0000000000..691ff67c68 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc_test.go @@ -0,0 +1,496 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T021: Login entry point tests --- + +// setupMockProvider creates a mock OIDC provider that serves discovery, +// authorize, and token endpoints. The token endpoint returns a valid +// token response. Returns the server (auto-cleaned up) and its URL. +func setupMockProvider(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "scopes_supported": []string{"openid", "profile", "email"}, + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("login-access-token", "login-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestLogin_MissingOIDCConfig verifies that Login returns ErrOIDCConfig +// when called without a gateway name and without WithIssuer/WithClientID. +func TestLogin_MissingOIDCConfig(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingIssuer verifies that Login returns ErrOIDCConfig +// when only WithClientID is provided (missing issuer). +func TestLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithClientID("test-client")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingClientID verifies that Login returns ErrOIDCConfig +// when only WithIssuer is provided (missing client ID). +func TestLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithIssuer("https://example.com")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_ReusesValidToken verifies FR-019: when a valid token exists +// on disk in the token directory, Login returns it without starting an +// interactive flow. +func TestLogin_ReusesValidToken(t *testing.T) { + resetDiscoveryCache() + + // Create a temp dir with a valid, non-expired token file. + tokenDir := t.TempDir() + existingToken := &oauth2.Token{ + AccessToken: "existing-access-token", + RefreshToken: "existing-refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(1 * time.Hour), + } + err := writeToken(tokenDir, existingToken) + require.NoError(t, err) + + // Login with explicit issuer/clientID and WithTokenDir (internal) + // pointing to the directory with the existing token. No OIDC + // provider is needed because the existing token is returned. + tok, err := Login(context.Background(), "", + WithIssuer("https://issuer-should-not-be-called.example.com"), + WithClientID("test-client"), + withTokenDir(tokenDir), + ) + require.NoError(t, err) + assert.Equal(t, "existing-access-token", tok.AccessToken) + assert.Equal(t, "existing-refresh-token", tok.RefreshToken) +} + +// TestLogin_ExpiredTokenTriggersFlow verifies that an expired token on +// disk does not short-circuit: Login proceeds to the interactive flow. +// Since we use keyboard flow (no browser), we feed it a code and verify +// a new token is returned. +func TestLogin_ExpiredTokenTriggersFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + // Write an expired token. + expiredToken := &oauth2.Token{ + AccessToken: "expired-token", + RefreshToken: "old-refresh", + TokenType: "Bearer", + Expiry: time.Now().Add(-1 * time.Hour), // expired + } + err := writeToken(tokenDir, expiredToken) + require.NoError(t, err) + + // Start a callback server ourselves to simulate the auth code callback. + // We'll use keyboard flow to avoid browser dependency. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Use keyboard flow with a reader that provides a fake auth code. + // The mock provider's /token endpoint accepts any code. + codeReader := strings.NewReader("fake-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + assert.Equal(t, "login-refresh-token", tok.RefreshToken) +} + +// TestLogin_KeyboardFlow verifies that Login completes using the +// keyboard flow when WithKeyboardFlow() is set. The test provides a +// mock OIDC provider and feeds an auth code through a reader. +func TestLogin_KeyboardFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted to disk. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_InMemorySkipsPersistence verifies that WithInMemory() +// returns a token without writing to disk. +func TestLogin_InMemorySkipsPersistence(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + WithInMemory(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify NO token file on disk. + _, err = os.Stat(filepath.Join(tokenDir, oidcTokenFile)) + assert.True(t, os.IsNotExist(err), "token file should not exist in in-memory mode") +} + +// TestLogin_DiscoveryFailure verifies that Login returns ErrDiscovery +// when the OIDC provider is unreachable. +func TestLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + // Point to a server that doesn't exist. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := Login(ctx, "", + WithIssuer("http://127.0.0.1:1"), // port 1 should refuse connections + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestLogin_GatewayResolution verifies that Login resolves OIDC config +// from gateway metadata when a gateway name is provided. We use the +// withGatewayResolver option to inject a fake gateway loader. +func TestLogin_GatewayResolution(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("gw-auth-code\n") + + fakeConfig := &gateway.Config{ + Name: "test-gateway", + Endpoint: "gateway.example.com:443", + Dir: tokenDir, + OIDCIssuer: provider.URL, + OIDCClientID: "gateway-client-id", + } + + tok, err := Login(ctx, "test-gateway", + WithKeyboardFlow(), + withInput(codeReader), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "test-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted in the gateway dir. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_GatewayMissingOIDCFields verifies that Login returns +// ErrOIDCConfig when the gateway config has empty OIDC fields. +func TestLogin_GatewayMissingOIDCFields(t *testing.T) { + resetDiscoveryCache() + + fakeConfig := &gateway.Config{ + Name: "no-oidc-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + // OIDCIssuer and OIDCClientID are empty. + } + + _, err := Login(context.Background(), "no-oidc-gw", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return fakeConfig, nil + }), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_GatewayResolutionError verifies that Login propagates +// errors from gateway resolution. +func TestLogin_GatewayResolutionError(t *testing.T) { + resetDiscoveryCache() + + gwErr := fmt.Errorf("gateway not found: no-such-gateway") + + _, err := Login(context.Background(), "no-such-gateway", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return nil, gwErr + }), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway not found") +} + +// TestLogin_NoPKCESupport verifies that Login proceeds without PKCE +// when the OIDC provider does not advertise S256 support. +func TestLogin_NoPKCESupport(t *testing.T) { + resetDiscoveryCache() + + // Create a provider that does NOT list S256 in supported methods. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "scopes_supported": []string{"openid"}, + // No code_challenge_methods_supported field. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + var receivedVerifier string + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedVerifier = r.Form.Get("code_verifier") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("no-pkce-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "no-pkce-token", tok.AccessToken) + + // Verify no PKCE verifier was sent to the token endpoint. + assert.Empty(t, receivedVerifier, "code_verifier should not be sent when PKCE is not supported") +} + +// TestLogin_ContextCancellation verifies that Login respects context +// cancellation during the interactive flow. +func TestLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that is already cancelled. + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + // Should get a context error or timeout error. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, ErrDiscovery) || errors.Is(err, context.Canceled), + "expected timeout/discovery/cancelled error, got: %v", err, + ) +} + +// TestLogin_CustomScopes verifies that WithScopes overrides the +// default scopes sent in the authorization request. +func TestLogin_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + // Provider that captures the auth URL scope parameter. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + WithScopes("openid", "custom-scope"), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-token", tok.AccessToken) +} + +// TestLoginBrowser_PortBusy_FallbackToKeyboard verifies that +// loginBrowser falls back to keyboard flow when the callback server +// port is already occupied and no custom port is set. +func TestLoginBrowser_PortBusy_FallbackToKeyboard(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Occupy port 8000 so startCallbackServer fails on the primary port. + // Then occupy port 18000 so the fallback port also fails. + // This forces loginBrowser into the keyboard fallback path. + ln1, err1 := net.Listen("tcp", "127.0.0.1:8000") + ln2, err2 := net.Listen("tcp", "127.0.0.1:18000") + if err1 != nil || err2 != nil { + if ln1 != nil { + _ = ln1.Close() + } + if ln2 != nil { + _ = ln2.Close() + } + t.Skip("Cannot bind test ports 8000 and 18000") + } + defer func() { _ = ln1.Close() }() + defer func() { _ = ln2.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-fallback-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) +} + +// TestLogin_ContextTimeout verifies that a Login with a very short +// timeout returns a timeout-related error. +func TestLogin_ContextTimeout(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that times out immediately. + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + time.Sleep(1 * time.Millisecond) // ensure timeout fires + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/options.go b/sdk/go/openshell/v1/oidc/options.go new file mode 100644 index 0000000000..0c83be1c4a --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "io" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// defaultScopes are the OIDC scopes requested when no custom scopes +// are specified via [WithScopes]. +var defaultScopes = []string{"openid", "profile", "email"} + +// defaultTimeout is the maximum duration for interactive login flows +// (browser, keyboard, device code) when no custom timeout is set. +const defaultTimeout = 2 * time.Minute + +// loginConfig holds the resolved configuration for a single login +// attempt. It is built by applying [LoginOption] functions to a +// zero-value struct and then filling in defaults. +type loginConfig struct { + issuer string + clientID string + clientSecret string + scopes []string + scopesSet bool + callbackPort int + timeout time.Duration + keyboardFlow bool + inMemory bool + displayFunc func(verificationURL, userCode string) + gateway string + + // Internal fields for testing. Not exposed via public API. + tokenDir string // override token directory + input io.Reader // override stdin for keyboard flow + output io.Writer // override stderr for keyboard flow + gatewayResolver func(name string) (*gateway.Config, error) // override gateway.LoadConfig +} + +// applyDefaults fills in default values for fields that were not set +// by any option function. +func (c *loginConfig) applyDefaults() { + if len(c.scopes) == 0 { + // Deep copy to avoid callers mutating the package-level slice. + c.scopes = make([]string, len(defaultScopes)) + copy(c.scopes, defaultScopes) + } + if c.timeout == 0 { + c.timeout = defaultTimeout + } +} + +// LoginOption configures a login attempt. Use the With* functions to +// create option values. +type LoginOption func(*loginConfig) + +// WithIssuer sets the OIDC issuer URL. Required for standalone flows +// (when no gateway name is provided to [Login]). +func WithIssuer(url string) LoginOption { + return func(c *loginConfig) { + c.issuer = url + } +} + +// WithClientID sets the OAuth2 client ID. Required for standalone +// flows (when no gateway name is provided to [Login]). +func WithClientID(id string) LoginOption { + return func(c *loginConfig) { + c.clientID = id + } +} + +// WithClientSecret sets the client secret for the client credentials +// grant. Required for [ClientCredentials]. +func WithClientSecret(secret string) LoginOption { + return func(c *loginConfig) { + c.clientSecret = secret + } +} + +// WithScopes overrides the default scopes (openid, profile, email). +// The provided scopes replace the defaults entirely. +func WithScopes(scopes ...string) LoginOption { + return func(c *loginConfig) { + c.scopes = make([]string, len(scopes)) + copy(c.scopes, scopes) + c.scopesSet = true + } +} + +// WithCallbackPort sets a fixed port for the localhost callback server. +// By default the server tries port 8000, then 18000. +func WithCallbackPort(port int) LoginOption { + return func(c *loginConfig) { + c.callbackPort = port + } +} + +// WithTimeout sets the maximum duration for interactive login flows. +// The default is 2 minutes. +func WithTimeout(d time.Duration) LoginOption { + return func(c *loginConfig) { + c.timeout = d + } +} + +// WithKeyboardFlow forces the keyboard flow (manual URL copy and code +// paste) instead of attempting to open a browser. +func WithKeyboardFlow() LoginOption { + return func(c *loginConfig) { + c.keyboardFlow = true + } +} + +// WithInMemory skips persisting the token to disk. The returned token +// is only available in memory for the lifetime of the process. +func WithInMemory() LoginOption { + return func(c *loginConfig) { + c.inMemory = true + } +} + +// WithDisplayFunc sets a custom display function for the device code +// flow. The function receives the verification URL and user code that +// the user must enter to authorize the device. If not set, the default +// behavior prints to stdout. +func WithDisplayFunc(fn func(verificationURL, userCode string)) LoginOption { + return func(c *loginConfig) { + c.displayFunc = fn + } +} + +// WithGateway sets the gateway name for [DeviceLogin] and +// [ClientCredentials]. When set, OIDC config is read from the +// gateway's metadata.json and tokens are persisted to the gateway +// directory. +func WithGateway(name string) LoginOption { + return func(c *loginConfig) { + c.gateway = name + } +} + +// --- Internal options for testing (unexported) --- + +// withTokenDir overrides the token directory for testing. +func withTokenDir(dir string) LoginOption { + return func(c *loginConfig) { + c.tokenDir = dir + } +} + +// withInput overrides the input reader for keyboard flow testing. +func withInput(r io.Reader) LoginOption { + return func(c *loginConfig) { + c.input = r + } +} + +// withGatewayResolver overrides the gateway.LoadConfig function for +// testing. This allows tests to inject a fake gateway resolver +// without filesystem setup. +func withGatewayResolver(fn func(name string) (*gateway.Config, error)) LoginOption { + return func(c *loginConfig) { + c.gatewayResolver = fn + } +} diff --git a/sdk/go/openshell/v1/oidc/options_test.go b/sdk/go/openshell/v1/oidc/options_test.go new file mode 100644 index 0000000000..28d17c06b4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLoginConfig_Defaults(t *testing.T) { + var cfg loginConfig + cfg.applyDefaults() + + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.scopes) + assert.Equal(t, 2*time.Minute, cfg.timeout) + assert.Empty(t, cfg.issuer) + assert.Empty(t, cfg.clientID) + assert.Empty(t, cfg.clientSecret) + assert.Zero(t, cfg.callbackPort) + assert.False(t, cfg.keyboardFlow) + assert.False(t, cfg.inMemory) + assert.Nil(t, cfg.displayFunc) + assert.Empty(t, cfg.gateway) +} + +func TestWithIssuer(t *testing.T) { + var cfg loginConfig + WithIssuer("https://auth.example.com")(&cfg) + + assert.Equal(t, "https://auth.example.com", cfg.issuer) +} + +func TestWithClientID(t *testing.T) { + var cfg loginConfig + WithClientID("my-app")(&cfg) + + assert.Equal(t, "my-app", cfg.clientID) +} + +func TestWithClientSecret(t *testing.T) { + var cfg loginConfig + WithClientSecret("s3cret")(&cfg) + + assert.Equal(t, "s3cret", cfg.clientSecret) +} + +func TestWithScopes(t *testing.T) { + var cfg loginConfig + WithScopes("openid", "custom")(&cfg) + cfg.applyDefaults() + + // Custom scopes should not be overwritten by defaults. + assert.Equal(t, []string{"openid", "custom"}, cfg.scopes) +} + +func TestWithScopes_DeepCopy(t *testing.T) { + original := []string{"openid", "custom"} + var cfg loginConfig + WithScopes(original...)(&cfg) + + // Mutating the original slice should not affect the config. + original[0] = "mutated" + assert.Equal(t, "openid", cfg.scopes[0]) +} + +func TestWithCallbackPort(t *testing.T) { + var cfg loginConfig + WithCallbackPort(9090)(&cfg) + + assert.Equal(t, 9090, cfg.callbackPort) +} + +func TestWithTimeout(t *testing.T) { + var cfg loginConfig + WithTimeout(5 * time.Minute)(&cfg) + cfg.applyDefaults() + + // Custom timeout should not be overwritten by defaults. + assert.Equal(t, 5*time.Minute, cfg.timeout) +} + +func TestWithKeyboardFlow(t *testing.T) { + var cfg loginConfig + WithKeyboardFlow()(&cfg) + + assert.True(t, cfg.keyboardFlow) +} + +func TestWithInMemory(t *testing.T) { + var cfg loginConfig + WithInMemory()(&cfg) + + assert.True(t, cfg.inMemory) +} + +func TestWithDisplayFunc(t *testing.T) { + called := false + fn := func(_, _ string) { called = true } + + var cfg loginConfig + WithDisplayFunc(fn)(&cfg) + + assert.NotNil(t, cfg.displayFunc) + cfg.displayFunc("http://example.com", "ABCD-1234") + assert.True(t, called) +} + +func TestWithGateway(t *testing.T) { + var cfg loginConfig + WithGateway("prod-gw")(&cfg) + + assert.Equal(t, "prod-gw", cfg.gateway) +} + +func TestMultipleOptions(t *testing.T) { + opts := []LoginOption{ + WithIssuer("https://auth.example.com"), + WithClientID("app-id"), + WithScopes("openid"), + WithTimeout(30 * time.Second), + WithKeyboardFlow(), + } + + var cfg loginConfig + for _, opt := range opts { + opt(&cfg) + } + cfg.applyDefaults() + + assert.Equal(t, "https://auth.example.com", cfg.issuer) + assert.Equal(t, "app-id", cfg.clientID) + assert.Equal(t, []string{"openid"}, cfg.scopes) + assert.Equal(t, 30*time.Second, cfg.timeout) + assert.True(t, cfg.keyboardFlow) +} + +func TestDefaultScopes_NotMutatedByConfig(t *testing.T) { + // Verify the package-level defaultScopes slice is not shared. + var cfg loginConfig + cfg.applyDefaults() + cfg.scopes[0] = "mutated" + + assert.Equal(t, "openid", defaultScopes[0]) +} + +func TestLastOptionWins(t *testing.T) { + var cfg loginConfig + WithIssuer("first")(&cfg) + WithIssuer("second")(&cfg) + + assert.Equal(t, "second", cfg.issuer) +} diff --git a/sdk/go/openshell/v1/oidc/token.go b/sdk/go/openshell/v1/oidc/token.go new file mode 100644 index 0000000000..e551dafc39 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/oauth2" +) + +// oidcTokenFile is the filename for persisted OIDC tokens. This must +// match the constant in gateway/token.go for interop. +const oidcTokenFile = "oidc_token.json" + +// tokenExpiryLeeway is the grace period subtracted from the token +// expiry when checking validity. Tokens expiring within this window +// are treated as expired to avoid using a token that expires during +// an in-flight request. +const tokenExpiryLeeway = 10 * time.Second + +// oidcBundle is the on-disk JSON representation of an OIDC token. +// The format is shared with the Rust CLI and the gateway package's +// diskTokenSource for interop. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// writeToken persists an oauth2.Token to disk as oidc_token.json in +// the given directory. The file is written with 0600 permissions +// (owner-only) to protect credentials. +func writeToken(dir string, tok *oauth2.Token) error { + bundle := oidcBundle{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + } + + if !tok.Expiry.IsZero() { + bundle.Expiry = tok.Expiry.UTC().Format(time.RFC3339) + remaining := time.Until(tok.Expiry) + if remaining > 0 { + bundle.ExpiresIn = int64(remaining.Seconds()) + } + } + + data, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("%w: failed to marshal token: %v", ErrTokenPersist, err) + } + + path := filepath.Join(dir, oidcTokenFile) + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("%w: failed to write %s: %v", ErrTokenPersist, path, err) + } + + return nil +} + +// readToken reads an existing oidc_token.json from the given +// directory. It returns: +// - (token, nil) if the file exists, is valid, and the token has not +// expired (with leeway) +// - (nil, nil) if the file does not exist, the token is expired, or +// the access token is empty (not an error, just no reusable token) +// - (nil, error) if the file exists but cannot be parsed +func readToken(dir string) (*oauth2.Token, error) { + path := filepath.Join(dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenPersist, path, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrTokenPersist, oidcTokenFile, err) + } + + if bundle.AccessToken == "" { + return nil, nil + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry from the "expiry" field (RFC 3339). Without an + // explicit expiry, the token is treated as non-expiring (always + // valid); "expires_in" alone cannot reconstruct an absolute time + // without a write timestamp. + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr != nil { + return nil, fmt.Errorf("%w: invalid expiry format in %s: %v", ErrTokenPersist, oidcTokenFile, parseErr) + } + tok.Expiry = expiry + } + + // Check if the token has expired (with leeway). + if !tok.Expiry.IsZero() && time.Now().After(tok.Expiry.Add(-tokenExpiryLeeway)) { + return nil, nil // Expired; caller should re-authenticate. + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/token_test.go b/sdk/go/openshell/v1/oidc/token_test.go new file mode 100644 index 0000000000..3590916b2f --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token_test.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestWriteToken_Success(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "access-123", + RefreshToken: "refresh-456", + Expiry: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + // Verify the file was written. + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + assert.Contains(t, string(data), `"access_token":"access-123"`) + assert.Contains(t, string(data), `"refresh_token":"refresh-456"`) + assert.Contains(t, string(data), `"expiry":"2026-07-03T12:00:00Z"`) +} + +func TestWriteToken_ExpiresInCalculated(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(3600 * time.Second) + tok := &oauth2.Token{ + AccessToken: "access-123", + Expiry: expiry, + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // expires_in should be roughly 3600 (within a few seconds). + assert.Contains(t, string(data), `"expires_in":`) +} + +func TestWriteToken_InvalidDirectory(t *testing.T) { + err := writeToken("/nonexistent/path/that/does/not/exist", &oauth2.Token{ + AccessToken: "test", + Expiry: time.Now().Add(time.Hour), + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_Success(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "2099-07-03T12:00:00Z", + "expires_in": 3600 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestReadToken_MissingFile(t *testing.T) { + dir := t.TempDir() + + tok, err := readToken(dir) + assert.Nil(t, tok) + assert.NoError(t, err, "missing file should return nil token, no error") +} + +func TestReadToken_InvalidJSON(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(`{invalid`), 0o600) + require.NoError(t, err) + + _, err = readToken(dir) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_ExpiredToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "expired-access", + "expiry": "2020-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "expired token should return nil") + assert.NoError(t, err, "expired token is not an error, just nil") +} + +func TestReadToken_ValidWithExpiresInFallback(t *testing.T) { + dir := t.TempDir() + // No expiry field, only expires_in. Since we wrote it "now", + // a large expires_in should make the token valid. + content := `{ + "access_token": "access-via-expires-in", + "expires_in": 99999 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + // Token with only expires_in cannot reconstruct a valid Expiry + // without knowing when the file was written. readToken should + // treat it as potentially valid and return it. + assert.NotNil(t, tok) + assert.Equal(t, "access-via-expires-in", tok.AccessToken) +} + +func TestReadToken_EmptyAccessToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "", + "expiry": "2099-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "empty access token should return nil") + assert.NoError(t, err) +} + +func TestWriteAndReadToken_Roundtrip(t *testing.T) { + dir := t.TempDir() + original := &oauth2.Token{ + AccessToken: "roundtrip-access", + RefreshToken: "roundtrip-refresh", + Expiry: time.Now().Add(time.Hour).Truncate(time.Second), + } + + err := writeToken(dir, original) + require.NoError(t, err) + + loaded, err := readToken(dir) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, original.AccessToken, loaded.AccessToken) + assert.Equal(t, original.RefreshToken, loaded.RefreshToken) + // Expiry should be close (within a second due to serialization). + assert.WithinDuration(t, original.Expiry, loaded.Expiry, time.Second) +} + +func TestWriteToken_FilePermissions(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "perm-check", + Expiry: time.Now().Add(time.Hour), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // File should be owner-only readable (0600). + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} diff --git a/sdk/go/openshell/v1/options.go b/sdk/go/openshell/v1/options.go new file mode 100644 index 0000000000..cb165b23a4 --- /dev/null +++ b/sdk/go/openshell/v1/options.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// CreateOptions configures resource creation. +type CreateOptions = types.CreateOptions + +// GetOptions configures resource retrieval. +type GetOptions = types.GetOptions + +// ListOptions configures resource listing with pagination and filtering. +type ListOptions = types.ListOptions + +// DeleteOptions configures resource deletion. +type DeleteOptions = types.DeleteOptions + +// UpdateOptions configures resource updates. +type UpdateOptions = types.UpdateOptions + +// WatchOptions configures watch behavior. +type WatchOptions = types.WatchOptions + +// WaitOptions configures wait behavior. Use context for timeout control. +type WaitOptions = types.WaitOptions + +// ExecOptions configures command execution. +type ExecOptions = types.ExecOptions diff --git a/sdk/go/openshell/v1/policy.go b/sdk/go/openshell/v1/policy.go new file mode 100644 index 0000000000..5678157da1 --- /dev/null +++ b/sdk/go/openshell/v1/policy.go @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// SandboxPolicy is the top-level security policy configuration for a sandbox. +type SandboxPolicy = types.SandboxPolicy + +// FilesystemPolicy controls which directories the sandbox can access. +type FilesystemPolicy = types.FilesystemPolicy + +// LandlockPolicy configures the Linux Landlock LSM. +type LandlockPolicy = types.LandlockPolicy + +// ProcessPolicy controls the user and group identity for sandboxed processes. +type ProcessPolicy = types.ProcessPolicy + +// PolicyChunk represents a single proposed policy change in the draft inbox. +type PolicyChunk = types.PolicyChunk + +// DraftPolicy contains the full draft policy state returned by GetDraft. +type DraftPolicy = types.DraftPolicy + +// PolicyStatusResult contains the status of a sandbox's policy. +type PolicyStatusResult = types.PolicyStatusResult + +// SandboxPolicyRevision represents a versioned policy revision for a sandbox. +type SandboxPolicyRevision = types.SandboxPolicyRevision + +// PolicyLoadStatus represents the load state of a policy revision. +type PolicyLoadStatus = types.PolicyLoadStatus + +// PolicyLoadStatus constants re-exported from types package. +const ( + PolicyLoadStatusUnspecified = types.PolicyLoadStatusUnspecified + PolicyLoadStatusPending = types.PolicyLoadStatusPending + PolicyLoadStatusLoaded = types.PolicyLoadStatusLoaded + PolicyLoadStatusFailed = types.PolicyLoadStatusFailed + PolicyLoadStatusSuperseded = types.PolicyLoadStatusSuperseded +) + +// ApproveResult contains the result of approving a single draft chunk. +type ApproveResult = types.ApproveResult + +// ApproveAllResult contains the result of approving all draft chunks. +type ApproveAllResult = types.ApproveAllResult + +// UndoResult contains the result of undoing a draft chunk approval. +type UndoResult = types.UndoResult + +// ClearResult contains the result of clearing all draft chunks. +type ClearResult = types.ClearResult + +// DraftHistoryEntry represents a single event in the draft policy history. +type DraftHistoryEntry = types.DraftHistoryEntry + +// GetDraftOption configures a GetDraft call. +type GetDraftOption = types.GetDraftOption + +// WithStatusFilter filters draft chunks by approval status. +var WithStatusFilter = types.WithStatusFilter + +// ApproveAllOption configures an ApproveAllDraftChunks call. +type ApproveAllOption = types.ApproveAllOption + +// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval. +var WithIncludeSecurityFlagged = types.WithIncludeSecurityFlagged + +// GetStatusOption configures a GetStatus call. +type GetStatusOption = types.GetStatusOption + +// WithVersion queries a specific policy version instead of the latest. +var WithVersion = types.WithVersion + +// ListPolicyOption configures a List call. +type ListPolicyOption = types.ListPolicyOption + +// WithLimit sets the maximum number of revisions to return. +var WithLimit = types.WithLimit + +// WithOffset sets the pagination offset. +var WithOffset = types.WithOffset + +// PolicyInterface defines operations for managing sandbox policy drafts, +// approvals, and revision history. +type PolicyInterface interface { + // GetDraft retrieves the current draft policy for a sandbox, including + // all pending, approved, and rejected chunks. Use WithStatusFilter to + // return only chunks matching a specific status. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if the + // sandbox name is empty; Unimplemented by the fake client. + GetDraft(ctx context.Context, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error) + + // ApproveDraftChunk approves a single pending draft chunk, merging + // its proposed rule into the active policy. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name or chunk ID is empty; + // Conflict if the chunk has already been approved or rejected; + // Unimplemented by the fake client. + ApproveDraftChunk(ctx context.Context, sandboxName, chunkID string) (*ApproveResult, error) + + // RejectDraftChunk rejects a single pending draft chunk with an + // optional reason that is fed to future LLM analysis context. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name or chunk ID is empty; + // Conflict if the chunk has already been approved or rejected; + // Unimplemented by the fake client. + RejectDraftChunk(ctx context.Context, sandboxName, chunkID, reason string) error + + // ApproveAllDraftChunks approves all pending draft chunks at once. + // By default, security-flagged chunks are skipped. Use + // WithIncludeSecurityFlagged to include them. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + ApproveAllDraftChunks(ctx context.Context, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error) + + // ClearDraftChunks removes all pending draft chunks for a sandbox. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + ClearDraftChunks(ctx context.Context, sandboxName string) (*ClearResult, error) + + // GetDraftHistory returns the chronological decision history for a + // sandbox's draft policy (approvals, rejections, edits, undos, clears). + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + GetDraftHistory(ctx context.Context, sandboxName string) ([]DraftHistoryEntry, error) + + // GetStatus retrieves the policy status for a sandbox, including the + // queried revision and the active version. Use WithVersion to query a + // specific version instead of the latest. + // + // Errors: NotFound if the sandbox or requested version does not exist; + // InvalidArgument if the sandbox name is empty; + // Unimplemented by the fake client. + GetStatus(ctx context.Context, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) + + // List returns policy revisions for a sandbox, ordered by version. + // Use WithLimit and WithOffset for pagination. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + List(ctx context.Context, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) + + // EditDraftChunk replaces the proposed rule of a pending draft chunk + // with the given network policy rule. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name, chunk ID, or proposed rule is + // empty/nil; Conflict if the chunk is not in a pending state; + // Unimplemented by the fake client. + EditDraftChunk(ctx context.Context, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error + + // UndoDraftChunk reverses a previously approved chunk, removing its + // merged rule from the active policy. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name or chunk ID is empty; + // Conflict if the chunk has not been approved; + // Unimplemented by the fake client. + UndoDraftChunk(ctx context.Context, sandboxName, chunkID string) (*UndoResult, error) +} diff --git a/sdk/go/openshell/v1/policy_client.go b/sdk/go/openshell/v1/policy_client.go new file mode 100644 index 0000000000..0844242fc1 --- /dev/null +++ b/sdk/go/openshell/v1/policy_client.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type policyClient struct { + client pb.OpenShellClient +} + +func newPolicyClient(conn grpc.ClientConnInterface) *policyClient { + return &policyClient{client: pb.NewOpenShellClient(conn)} +} + +func (p *policyClient) GetDraft(ctx context.Context, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error) { + cfg := types.ApplyGetDraftOptions(opts) + resp, err := p.client.GetDraftPolicy(ctx, &pb.GetDraftPolicyRequest{ + Name: sandboxName, + StatusFilter: cfg.StatusFilter(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.DraftPolicyFromProto(resp), nil +} + +func (p *policyClient) ApproveDraftChunk(ctx context.Context, sandboxName, chunkID string) (*ApproveResult, error) { + resp, err := p.client.ApproveDraftChunk(ctx, &pb.ApproveDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ApproveResultFromProto(resp), nil +} + +func (p *policyClient) RejectDraftChunk(ctx context.Context, sandboxName, chunkID, reason string) error { + _, err := p.client.RejectDraftChunk(ctx, &pb.RejectDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + Reason: reason, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *policyClient) ApproveAllDraftChunks(ctx context.Context, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error) { + cfg := types.ApplyApproveAllOptions(opts) + resp, err := p.client.ApproveAllDraftChunks(ctx, &pb.ApproveAllDraftChunksRequest{ + Name: sandboxName, + IncludeSecurityFlagged: cfg.IncludeSecurityFlagged(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ApproveAllResultFromProto(resp), nil +} + +func (p *policyClient) ClearDraftChunks(ctx context.Context, sandboxName string) (*ClearResult, error) { + resp, err := p.client.ClearDraftChunks(ctx, &pb.ClearDraftChunksRequest{ + Name: sandboxName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ClearResultFromProto(resp), nil +} + +func (p *policyClient) GetDraftHistory(ctx context.Context, sandboxName string) ([]DraftHistoryEntry, error) { + resp, err := p.client.GetDraftHistory(ctx, &pb.GetDraftHistoryRequest{ + Name: sandboxName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + entries := resp.GetEntries() + if len(entries) == 0 { + return nil, nil + } + result := make([]DraftHistoryEntry, 0, len(entries)) + for _, e := range entries { + if converted := converter.DraftHistoryEntryFromProto(e); converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (p *policyClient) GetStatus(ctx context.Context, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) { + cfg := types.ApplyGetStatusOptions(opts) + resp, err := p.client.GetSandboxPolicyStatus(ctx, &pb.GetSandboxPolicyStatusRequest{ + Name: sandboxName, + Version: cfg.Version(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.PolicyStatusResultFromProto(resp), nil +} + +func (p *policyClient) List(ctx context.Context, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) { + cfg := types.ApplyListPolicyOptions(opts) + resp, err := p.client.ListSandboxPolicies(ctx, &pb.ListSandboxPoliciesRequest{ + Name: sandboxName, + Limit: cfg.Limit(), + Offset: cfg.Offset(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + revisions := resp.GetRevisions() + if len(revisions) == 0 { + return nil, nil + } + result := make([]SandboxPolicyRevision, 0, len(revisions)) + for _, r := range revisions { + if converted := converter.SandboxPolicyRevisionFromProto(r); converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (p *policyClient) EditDraftChunk(ctx context.Context, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error { + _, err := p.client.EditDraftChunk(ctx, &pb.EditDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + ProposedRule: converter.NetworkPolicyRuleToProto(proposedRule), + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *policyClient) UndoDraftChunk(ctx context.Context, sandboxName, chunkID string) (*UndoResult, error) { + resp, err := p.client.UndoDraftChunk(ctx, &pb.UndoDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.UndoResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/policy_client_test.go b/sdk/go/openshell/v1/policy_client_test.go new file mode 100644 index 0000000000..1383af6251 --- /dev/null +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -0,0 +1,816 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --- Mock server for Policy RPCs --- + +type mockPolicyServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + + // Canned responses. + getDraftResp *pb.GetDraftPolicyResponse + approveResp *pb.ApproveDraftChunkResponse + rejectResp *pb.RejectDraftChunkResponse + approveAllResp *pb.ApproveAllDraftChunksResponse + clearResp *pb.ClearDraftChunksResponse + historyResp *pb.GetDraftHistoryResponse + statusResp *pb.GetSandboxPolicyStatusResponse + listResp *pb.ListSandboxPoliciesResponse + editResp *pb.EditDraftChunkResponse + undoResp *pb.UndoDraftChunkResponse + + // Recorded requests. + lastGetDraftReq *pb.GetDraftPolicyRequest + lastApproveReq *pb.ApproveDraftChunkRequest + lastRejectReq *pb.RejectDraftChunkRequest + lastApproveAllReq *pb.ApproveAllDraftChunksRequest + lastClearReq *pb.ClearDraftChunksRequest + lastHistoryReq *pb.GetDraftHistoryRequest + lastStatusReq *pb.GetSandboxPolicyStatusRequest + lastListReq *pb.ListSandboxPoliciesRequest + lastEditReq *pb.EditDraftChunkRequest + lastUndoReq *pb.UndoDraftChunkRequest + + // Inject errors. + getDraftErr error + approveErr error + rejectErr error + approveAllErr error + clearErr error + historyErr error + statusErr error + listErr error + editErr error + undoErr error +} + +func newMockPolicyServer() *mockPolicyServer { + return &mockPolicyServer{} +} + +func (s *mockPolicyServer) GetDraftPolicy(_ context.Context, req *pb.GetDraftPolicyRequest) (*pb.GetDraftPolicyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGetDraftReq = req + if s.getDraftErr != nil { + return nil, s.getDraftErr + } + return s.getDraftResp, nil +} + +func (s *mockPolicyServer) ApproveDraftChunk(_ context.Context, req *pb.ApproveDraftChunkRequest) (*pb.ApproveDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastApproveReq = req + if s.approveErr != nil { + return nil, s.approveErr + } + return s.approveResp, nil +} + +func (s *mockPolicyServer) RejectDraftChunk(_ context.Context, req *pb.RejectDraftChunkRequest) (*pb.RejectDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastRejectReq = req + if s.rejectErr != nil { + return nil, s.rejectErr + } + return s.rejectResp, nil +} + +func (s *mockPolicyServer) ApproveAllDraftChunks(_ context.Context, req *pb.ApproveAllDraftChunksRequest) (*pb.ApproveAllDraftChunksResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastApproveAllReq = req + if s.approveAllErr != nil { + return nil, s.approveAllErr + } + return s.approveAllResp, nil +} + +func (s *mockPolicyServer) ClearDraftChunks(_ context.Context, req *pb.ClearDraftChunksRequest) (*pb.ClearDraftChunksResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastClearReq = req + if s.clearErr != nil { + return nil, s.clearErr + } + return s.clearResp, nil +} + +func (s *mockPolicyServer) GetDraftHistory(_ context.Context, req *pb.GetDraftHistoryRequest) (*pb.GetDraftHistoryResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastHistoryReq = req + if s.historyErr != nil { + return nil, s.historyErr + } + return s.historyResp, nil +} + +func (s *mockPolicyServer) GetSandboxPolicyStatus(_ context.Context, req *pb.GetSandboxPolicyStatusRequest) (*pb.GetSandboxPolicyStatusResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastStatusReq = req + if s.statusErr != nil { + return nil, s.statusErr + } + return s.statusResp, nil +} + +func (s *mockPolicyServer) ListSandboxPolicies(_ context.Context, req *pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastListReq = req + if s.listErr != nil { + return nil, s.listErr + } + return s.listResp, nil +} + +func (s *mockPolicyServer) EditDraftChunk(_ context.Context, req *pb.EditDraftChunkRequest) (*pb.EditDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastEditReq = req + if s.editErr != nil { + return nil, s.editErr + } + return s.editResp, nil +} + +func (s *mockPolicyServer) UndoDraftChunk(_ context.Context, req *pb.UndoDraftChunkRequest) (*pb.UndoDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastUndoReq = req + if s.undoErr != nil { + return nil, s.undoErr + } + return s.undoResp, nil +} + +// --- Test setup --- + +func setupPolicyTest(t *testing.T, mock *mockPolicyServer) (*policyClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newPolicyClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// =========================================================================== +// Phase 2 (T020): GetDraft, ApproveDraftChunk, RejectDraftChunk +// =========================================================================== + +func TestPolicyGetDraft(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftResp = &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + { + Id: "chunk-1", + Status: "pending", + RuleName: "allow-dns", + Rationale: "DNS access needed", + Confidence: 0.95, + DenialSummaryIds: []string{"ds-1", "ds-2"}, + CreatedAtMs: 1700000000000, + Stage: "initial", + HitCount: 3, + Binary: "/usr/bin/curl", + ProposedRule: &sbv1.NetworkPolicyRule{ + Name: "allow-dns-rule", + }, + }, + { + Id: "chunk-2", + Status: "approved", + RuleName: "allow-https", + }, + }, + RollingSummary: "Two rules proposed", + DraftVersion: 5, + LastAnalyzedAtMs: 1700000001000, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, draft) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastGetDraftReq.GetName()) + assert.Empty(t, mock.lastGetDraftReq.GetStatusFilter()) + mock.mu.Unlock() + + // Verify response mapping. + assert.Equal(t, "Two rules proposed", draft.RollingSummary) + assert.Equal(t, uint64(5), draft.DraftVersion) + assert.False(t, draft.LastAnalyzedAt.IsZero()) + + require.Len(t, draft.Chunks, 2) + + c1 := draft.Chunks[0] + assert.Equal(t, "chunk-1", c1.ID) + assert.Equal(t, "pending", c1.Status) + assert.Equal(t, "allow-dns", c1.RuleName) + assert.Equal(t, "DNS access needed", c1.Rationale) + assert.InDelta(t, float32(0.95), c1.Confidence, 0.001) + assert.Equal(t, []string{"ds-1", "ds-2"}, c1.DenialSummaryIDs) + assert.Equal(t, "initial", c1.Stage) + assert.Equal(t, int32(3), c1.HitCount) + assert.Equal(t, "/usr/bin/curl", c1.Binary) + require.NotNil(t, c1.ProposedRule) + assert.Equal(t, "allow-dns-rule", c1.ProposedRule.Name) + + c2 := draft.Chunks[1] + assert.Equal(t, "chunk-2", c2.ID) + assert.Equal(t, "approved", c2.Status) +} + +func TestPolicyGetDraft_WithStatusFilter(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftResp = &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + {Id: "chunk-1", Status: "pending"}, + }, + DraftVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "sb1", types.WithStatusFilter("pending")) + + require.NoError(t, err) + require.NotNil(t, draft) + + // Verify status filter was forwarded. + mock.mu.Lock() + assert.Equal(t, "pending", mock.lastGetDraftReq.GetStatusFilter()) + mock.mu.Unlock() + + require.Len(t, draft.Chunks, 1) + assert.Equal(t, "pending", draft.Chunks[0].Status) +} + +func TestPolicyGetDraft_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "missing") + + assert.Nil(t, draft) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyApproveDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.approveResp = &pb.ApproveDraftChunkResponse{ + PolicyVersion: 7, + PolicyHash: "sha256:abc123", + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveDraftChunk(context.Background(), "my-sandbox", "chunk-1") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastApproveReq.GetName()) + assert.Equal(t, "chunk-1", mock.lastApproveReq.GetChunkId()) + mock.mu.Unlock() + + // Verify response mapping. + assert.Equal(t, uint32(7), result.PolicyVersion) + assert.Equal(t, "sha256:abc123", result.PolicyHash) +} + +func TestPolicyApproveDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.approveErr = status.Errorf(codes.NotFound, "chunk not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveDraftChunk(context.Background(), "sb1", "bad-chunk") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyRejectDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.rejectResp = &pb.RejectDraftChunkResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.RejectDraftChunk(context.Background(), "my-sandbox", "chunk-2", "too broad") + + require.NoError(t, err) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastRejectReq.GetName()) + assert.Equal(t, "chunk-2", mock.lastRejectReq.GetChunkId()) + assert.Equal(t, "too broad", mock.lastRejectReq.GetReason()) + mock.mu.Unlock() +} + +func TestPolicyRejectDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.rejectErr = status.Errorf(codes.InvalidArgument, "invalid chunk") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.RejectDraftChunk(context.Background(), "sb1", "bad", "reason") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// =========================================================================== +// Phase 3 (T022): ApproveAllDraftChunks, ClearDraftChunks, GetDraftHistory +// =========================================================================== + +func TestPolicyApproveAllDraftChunks(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllResp = &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 8, + PolicyHash: "sha256:bulk", + ChunksApproved: 5, + ChunksSkipped: 2, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify default: security-flagged NOT included. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastApproveAllReq.GetName()) + assert.False(t, mock.lastApproveAllReq.GetIncludeSecurityFlagged()) + mock.mu.Unlock() + + assert.Equal(t, uint32(8), result.PolicyVersion) + assert.Equal(t, "sha256:bulk", result.PolicyHash) + assert.Equal(t, uint32(5), result.ChunksApproved) + assert.Equal(t, uint32(2), result.ChunksSkipped) +} + +func TestPolicyApproveAllDraftChunks_WithSecurityFlagged(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllResp = &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 9, + PolicyHash: "sha256:all", + ChunksApproved: 7, + ChunksSkipped: 0, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "sb1", types.WithIncludeSecurityFlagged()) + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify security-flagged flag was sent. + mock.mu.Lock() + assert.True(t, mock.lastApproveAllReq.GetIncludeSecurityFlagged()) + mock.mu.Unlock() + + assert.Equal(t, uint32(7), result.ChunksApproved) + assert.Equal(t, uint32(0), result.ChunksSkipped) +} + +func TestPolicyApproveAllDraftChunks_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "missing") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyClearDraftChunks(t *testing.T) { + mock := newMockPolicyServer() + mock.clearResp = &pb.ClearDraftChunksResponse{ + ChunksCleared: 4, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ClearDraftChunks(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastClearReq.GetName()) + mock.mu.Unlock() + + assert.Equal(t, uint32(4), result.ChunksCleared) +} + +func TestPolicyClearDraftChunks_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.clearErr = status.Errorf(codes.Internal, "internal error") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ClearDraftChunks(context.Background(), "sb1") + + assert.Nil(t, result) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +func TestPolicyGetDraftHistory(t *testing.T) { + mock := newMockPolicyServer() + mock.historyResp = &pb.GetDraftHistoryResponse{ + Entries: []*pb.DraftHistoryEntry{ + { + TimestampMs: 1700000000000, + EventType: "approved", + Description: "Chunk chunk-1 approved", + ChunkId: "chunk-1", + }, + { + TimestampMs: 1700000001000, + EventType: "rejected", + Description: "Chunk chunk-2 rejected: too broad", + ChunkId: "chunk-2", + }, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.Len(t, entries, 2) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastHistoryReq.GetName()) + mock.mu.Unlock() + + assert.Equal(t, "approved", entries[0].EventType) + assert.Equal(t, "Chunk chunk-1 approved", entries[0].Description) + assert.Equal(t, "chunk-1", entries[0].ChunkID) + assert.False(t, entries[0].Timestamp.IsZero()) + + assert.Equal(t, "rejected", entries[1].EventType) + assert.Equal(t, "chunk-2", entries[1].ChunkID) +} + +func TestPolicyGetDraftHistory_Empty(t *testing.T) { + mock := newMockPolicyServer() + mock.historyResp = &pb.GetDraftHistoryResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "sb1") + + require.NoError(t, err) + assert.Nil(t, entries) +} + +func TestPolicyGetDraftHistory_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.historyErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "missing") + + assert.Nil(t, entries) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// =========================================================================== +// Phase 4 (T024): GetStatus, List, EditDraftChunk, UndoDraftChunk +// =========================================================================== + +func TestPolicyGetStatus(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 3, + PolicyHash: "sha256:rev3", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + CreatedAtMs: 1700000000000, + LoadedAtMs: 1700000001000, + }, + ActiveVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded (no version = latest). + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetName()) + assert.Equal(t, uint32(0), mock.lastStatusReq.GetVersion()) + mock.mu.Unlock() + + assert.Equal(t, uint32(3), result.ActiveVersion) + assert.Equal(t, uint32(3), result.Revision.Version) + assert.Equal(t, "sha256:rev3", result.Revision.PolicyHash) + assert.Equal(t, PolicyLoadStatusLoaded, result.Revision.Status) + assert.False(t, result.Revision.CreatedAt.IsZero()) + assert.False(t, result.Revision.LoadedAt.IsZero()) +} + +func TestPolicyGetStatus_WithVersion(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 2, + PolicyHash: "sha256:rev2", + Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, + }, + ActiveVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "sb1", types.WithVersion(2)) + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify version was forwarded. + mock.mu.Lock() + assert.Equal(t, uint32(2), mock.lastStatusReq.GetVersion()) + mock.mu.Unlock() + + assert.Equal(t, uint32(2), result.Revision.Version) + assert.Equal(t, PolicyLoadStatusSuperseded, result.Revision.Status) + assert.Equal(t, uint32(3), result.ActiveVersion) +} + +func TestPolicyGetStatus_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.statusErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "missing") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyList(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + { + Version: 1, + PolicyHash: "sha256:v1", + Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, + CreatedAtMs: 1700000000000, + }, + { + Version: 2, + PolicyHash: "sha256:v2", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + CreatedAtMs: 1700000001000, + LoadedAtMs: 1700000002000, + }, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.Len(t, revisions, 2) + + // Verify request was forwarded (no pagination options). + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastListReq.GetName()) + assert.Equal(t, uint32(0), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(0), mock.lastListReq.GetOffset()) + mock.mu.Unlock() + + assert.Equal(t, uint32(1), revisions[0].Version) + assert.Equal(t, "sha256:v1", revisions[0].PolicyHash) + assert.Equal(t, PolicyLoadStatusSuperseded, revisions[0].Status) + + assert.Equal(t, uint32(2), revisions[1].Version) + assert.Equal(t, "sha256:v2", revisions[1].PolicyHash) + assert.Equal(t, PolicyLoadStatusLoaded, revisions[1].Status) +} + +func TestPolicyList_WithPagination(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + {Version: 3, PolicyHash: "sha256:v3"}, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "sb1", + types.WithLimit(10), + types.WithOffset(20), + ) + + require.NoError(t, err) + require.Len(t, revisions, 1) + + // Verify pagination options were forwarded. + mock.mu.Lock() + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(20), mock.lastListReq.GetOffset()) + mock.mu.Unlock() +} + +func TestPolicyList_Empty(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "sb1") + + require.NoError(t, err) + assert.Nil(t, revisions) +} + +func TestPolicyList_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.listErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "missing") + + assert.Nil(t, revisions) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyEditDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.editResp = &pb.EditDraftChunkResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + rule := &NetworkPolicyRule{ + Name: "allow-https", + Endpoints: []PolicyNetworkEndpoint{ + {Host: "example.com", Port: 443, Protocol: "tcp"}, + }, + } + + err := client.EditDraftChunk(context.Background(), "my-sandbox", "chunk-1", rule) + + require.NoError(t, err) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastEditReq.GetName()) + assert.Equal(t, "chunk-1", mock.lastEditReq.GetChunkId()) + require.NotNil(t, mock.lastEditReq.GetProposedRule()) + assert.Equal(t, "allow-https", mock.lastEditReq.GetProposedRule().GetName()) + require.Len(t, mock.lastEditReq.GetProposedRule().GetEndpoints(), 1) + assert.Equal(t, "example.com", mock.lastEditReq.GetProposedRule().GetEndpoints()[0].GetHost()) + mock.mu.Unlock() +} + +func TestPolicyEditDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.editErr = status.Errorf(codes.InvalidArgument, "invalid rule") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.EditDraftChunk(context.Background(), "sb1", "chunk-1", &NetworkPolicyRule{}) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestPolicyUndoDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.undoResp = &pb.UndoDraftChunkResponse{ + PolicyVersion: 10, + PolicyHash: "sha256:undo", + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.UndoDraftChunk(context.Background(), "my-sandbox", "chunk-3") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastUndoReq.GetName()) + assert.Equal(t, "chunk-3", mock.lastUndoReq.GetChunkId()) + mock.mu.Unlock() + + assert.Equal(t, uint32(10), result.PolicyVersion) + assert.Equal(t, "sha256:undo", result.PolicyHash) +} + +func TestPolicyUndoDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.undoErr = status.Errorf(codes.NotFound, "chunk not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.UndoDraftChunk(context.Background(), "sb1", "bad-chunk") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} diff --git a/sdk/go/openshell/v1/profile.go b/sdk/go/openshell/v1/profile.go new file mode 100644 index 0000000000..8f289be53e --- /dev/null +++ b/sdk/go/openshell/v1/profile.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ProviderProfile represents a provider type template. +type ProviderProfile = types.ProviderProfile + +// ProfileCredential defines a single credential required by a provider profile. +type ProfileCredential = types.ProfileCredential + +// ProfileCategory classifies a provider profile. +type ProfileCategory = types.ProfileCategory + +// NetworkEndpoint describes a network endpoint provided by a profile. +type NetworkEndpoint = types.NetworkEndpoint + +// NetworkBinary describes a binary artifact provided by a profile. +type NetworkBinary = types.NetworkBinary + +// ProfileDiscovery holds local discovery configuration for a profile. +type ProfileDiscovery = types.ProfileDiscovery + +// ProfileImportItem is an item submitted for profile import or lint validation. +type ProfileImportItem = types.ProfileImportItem + +// ProfileDiagnostic is a validation finding from Import, Update, or Lint. +type ProfileDiagnostic = types.ProfileDiagnostic + +// ImportResult holds the result of a profile import operation. +type ImportResult = types.ImportResult + +// UpdateResult holds the result of a profile update operation. +type UpdateResult = types.UpdateResult + +// LintResult holds the result of a profile lint operation. +type LintResult = types.LintResult + +// ProfileCategory values. +const ( + ProfileCategoryOther = types.ProfileCategoryOther + ProfileCategoryInference = types.ProfileCategoryInference + ProfileCategoryAgent = types.ProfileCategoryAgent + ProfileCategorySourceControl = types.ProfileCategorySourceControl + ProfileCategoryMessaging = types.ProfileCategoryMessaging + ProfileCategoryData = types.ProfileCategoryData + ProfileCategoryKnowledge = types.ProfileCategoryKnowledge +) + +// ProfileInterface defines operations for managing provider profiles. +type ProfileInterface interface { + // List returns all provider profiles. + List(ctx context.Context, opts ...ListOptions) ([]*ProviderProfile, error) + // Get retrieves a provider profile by ID. + Get(ctx context.Context, id string) (*ProviderProfile, error) + // Import submits profiles for import and returns the result with diagnostics. + Import(ctx context.Context, items []ProfileImportItem) (*ImportResult, error) + // Update replaces an existing profile identified by ID and expected resource version. + Update(ctx context.Context, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) + // Lint validates profiles without persisting them and returns diagnostics. + Lint(ctx context.Context, items []ProfileImportItem) (*LintResult, error) + // Delete removes a provider profile by ID. Returns true if deleted. + Delete(ctx context.Context, id string) (bool, error) +} diff --git a/sdk/go/openshell/v1/profile_client.go b/sdk/go/openshell/v1/profile_client.go new file mode 100644 index 0000000000..f53a50f841 --- /dev/null +++ b/sdk/go/openshell/v1/profile_client.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type profileClient struct { + client pb.OpenShellClient +} + +func newProfileClient(conn grpc.ClientConnInterface) *profileClient { + return &profileClient{client: pb.NewOpenShellClient(conn)} +} + +func (p *profileClient) List(ctx context.Context, opts ...ListOptions) ([]*ProviderProfile, error) { + req := &pb.ListProviderProfilesRequest{} + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + } + + resp, err := p.client.ListProviderProfiles(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + profiles := make([]*ProviderProfile, 0, len(resp.GetProfiles())) + for _, pp := range resp.GetProfiles() { + profiles = append(profiles, converter.ProviderProfileFromProto(pp)) + } + return profiles, nil +} + +func (p *profileClient) Get(ctx context.Context, id string) (*ProviderProfile, error) { + resp, err := p.client.GetProviderProfile(ctx, &pb.GetProviderProfileRequest{ + Id: id, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderProfileFromProto(resp.GetProfile()), nil +} + +func (p *profileClient) Import(ctx context.Context, items []ProfileImportItem) (*ImportResult, error) { + pbItems := make([]*pb.ProviderProfileImportItem, len(items)) + for i := range items { + pbItems[i] = converter.ProfileImportItemToProto(&items[i]) + } + + resp, err := p.client.ImportProviderProfiles(ctx, &pb.ImportProviderProfilesRequest{ + Profiles: pbItems, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &ImportResult{ + Imported: resp.GetImported(), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + for _, pp := range resp.GetProfiles() { + if profile := converter.ProviderProfileFromProto(pp); profile != nil { + result.Profiles = append(result.Profiles, *profile) + } + } + + return result, nil +} + +func (p *profileClient) Update(ctx context.Context, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) { + resp, err := p.client.UpdateProviderProfiles(ctx, &pb.UpdateProviderProfilesRequest{ + Id: id, + Profile: converter.ProfileImportItemToProto(&item), + ExpectedResourceVersion: expectedResourceVersion, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &UpdateResult{ + Updated: resp.GetUpdated(), + Profile: converter.ProviderProfileFromProto(resp.GetProfile()), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + return result, nil +} + +func (p *profileClient) Lint(ctx context.Context, items []ProfileImportItem) (*LintResult, error) { + pbItems := make([]*pb.ProviderProfileImportItem, len(items)) + for i := range items { + pbItems[i] = converter.ProfileImportItemToProto(&items[i]) + } + + resp, err := p.client.LintProviderProfiles(ctx, &pb.LintProviderProfilesRequest{ + Profiles: pbItems, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &LintResult{ + Valid: resp.GetValid(), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + return result, nil +} + +func (p *profileClient) Delete(ctx context.Context, id string) (bool, error) { + resp, err := p.client.DeleteProviderProfile(ctx, &pb.DeleteProviderProfileRequest{ + Id: id, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetDeleted(), nil +} diff --git a/sdk/go/openshell/v1/profile_client_test.go b/sdk/go/openshell/v1/profile_client_test.go new file mode 100644 index 0000000000..efb5109b9b --- /dev/null +++ b/sdk/go/openshell/v1/profile_client_test.go @@ -0,0 +1,570 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --- Mock server for provider profiles --- + +type mockProfileServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + profiles map[string]*pb.ProviderProfile // key: profile ID + + listErr error + getErr error + importErr error + updateErr error + lintErr error + deleteErr error + + lastListReq *pb.ListProviderProfilesRequest +} + +func newMockProfileServer() *mockProfileServer { + return &mockProfileServer{ + profiles: make(map[string]*pb.ProviderProfile), + } +} + +func (s *mockProfileServer) ListProviderProfiles(_ context.Context, req *pb.ListProviderProfilesRequest) (*pb.ListProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastListReq = req + if s.listErr != nil { + return nil, s.listErr + } + + var profiles []*pb.ProviderProfile + for _, p := range s.profiles { + profiles = append(profiles, p) + } + return &pb.ListProviderProfilesResponse{Profiles: profiles}, nil +} + +func (s *mockProfileServer) GetProviderProfile(_ context.Context, req *pb.GetProviderProfileRequest) (*pb.ProviderProfileResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + + p, ok := s.profiles[req.GetId()] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", req.GetId()) + } + return &pb.ProviderProfileResponse{Profile: p}, nil +} + +func (s *mockProfileServer) ImportProviderProfiles(_ context.Context, req *pb.ImportProviderProfilesRequest) (*pb.ImportProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.importErr != nil { + return nil, s.importErr + } + + var imported []*pb.ProviderProfile + for _, item := range req.GetProfiles() { + p := item.GetProfile() + if p != nil { + s.profiles[p.GetId()] = p + imported = append(imported, p) + } + } + return &pb.ImportProviderProfilesResponse{ + Profiles: imported, + Imported: len(imported) > 0, + }, nil +} + +func (s *mockProfileServer) UpdateProviderProfiles(_ context.Context, req *pb.UpdateProviderProfilesRequest) (*pb.UpdateProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.updateErr != nil { + return nil, s.updateErr + } + + id := req.GetId() + existing, ok := s.profiles[id] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", id) + } + + if req.GetExpectedResourceVersion() != existing.GetResourceVersion() { + return nil, status.Errorf(codes.FailedPrecondition, "resource version mismatch") + } + + p := req.GetProfile().GetProfile() + if p != nil { + p.ResourceVersion = existing.GetResourceVersion() + 1 + s.profiles[id] = p + } + + return &pb.UpdateProviderProfilesResponse{ + Profile: p, + Updated: true, + }, nil +} + +func (s *mockProfileServer) LintProviderProfiles(_ context.Context, req *pb.LintProviderProfilesRequest) (*pb.LintProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.lintErr != nil { + return nil, s.lintErr + } + + // Simple lint: valid if all profiles have an ID + var diagnostics []*pb.ProviderProfileDiagnostic + valid := true + for _, item := range req.GetProfiles() { + p := item.GetProfile() + if p != nil && p.GetId() == "" { + valid = false + diagnostics = append(diagnostics, &pb.ProviderProfileDiagnostic{ + Source: item.GetSource(), + Field: "id", + Message: "profile ID is required", + Severity: "error", + }) + } + } + return &pb.LintProviderProfilesResponse{ + Diagnostics: diagnostics, + Valid: valid, + }, nil +} + +func (s *mockProfileServer) DeleteProviderProfile(_ context.Context, req *pb.DeleteProviderProfileRequest) (*pb.DeleteProviderProfileResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + _, ok := s.profiles[req.GetId()] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", req.GetId()) + } + delete(s.profiles, req.GetId()) + return &pb.DeleteProviderProfileResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupProfileTest(t *testing.T, mock *mockProfileServer) (*profileClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newProfileClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// seedProfile adds a profile to the mock server store for testing. +func seedProfile(mock *mockProfileServer, id, displayName string, category pb.ProviderProfileCategory) { + mock.mu.Lock() + defer mock.mu.Unlock() + mock.profiles[id] = &pb.ProviderProfile{ + Id: id, + DisplayName: displayName, + Description: "Test profile " + id, + Category: category, + ResourceVersion: 1, + Credentials: []*pb.ProviderProfileCredential{ + {Name: "api-key", Description: "API Key", Required: true, Refresh: &pb.ProviderCredentialRefresh{}}, + }, + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "localhost", Port: 8080, Protocol: "http"}, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/provider"}, + }, + } +} + +// --- List tests --- + +func TestProfileList(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + seedProfile(mock, "p2", "Profile Two", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Len(t, profiles, 2) +} + +func TestProfileList_Empty(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Empty(t, profiles) +} + +func TestProfileList_WithOptions(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background(), ListOptions{Limit: 10, Offset: 5}) + + require.NoError(t, err) + assert.Len(t, profiles, 1) + require.NotNil(t, mock.lastListReq) + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(5), mock.lastListReq.GetOffset()) +} + +func TestProfileList_Error(t *testing.T) { + mock := newMockProfileServer() + mock.listErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background()) + + assert.Nil(t, profiles) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Get tests --- + +func TestProfileGet(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "p1") + + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, "p1", profile.ID) + assert.Equal(t, "Profile One", profile.DisplayName) + assert.Equal(t, ProfileCategoryInference, profile.Category) + assert.Equal(t, uint64(1), profile.ResourceVersion) + // Verify credential deep copy + require.Len(t, profile.Credentials, 1) + assert.Equal(t, "api-key", profile.Credentials[0].Name) + assert.True(t, profile.Credentials[0].Required) + assert.True(t, profile.Credentials[0].Secret) // derived from Refresh != nil + // Verify endpoint deep copy + require.Len(t, profile.Endpoints, 1) + assert.Equal(t, "localhost", profile.Endpoints[0].Host) + assert.Equal(t, uint32(8080), profile.Endpoints[0].Port) + // Verify binary deep copy + require.Len(t, profile.Binaries, 1) + assert.Equal(t, "/usr/bin/provider", profile.Binaries[0].Path) +} + +func TestProfileGet_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "nonexistent") + + assert.Nil(t, profile) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileGet_Error(t *testing.T) { + mock := newMockProfileServer() + mock.getErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "p1") + + assert.Nil(t, profile) + require.Error(t, err) +} + +// --- Import tests --- + +func TestProfileImport(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ + ID: "p1", + DisplayName: "New Profile", + Category: ProfileCategoryInference, + }, + Source: "test.yaml", + }, + } + + result, err := client.Import(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Imported) + assert.Len(t, result.Profiles, 1) + assert.Equal(t, "p1", result.Profiles[0].ID) +} + +func TestProfileImport_MultipleItems(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "p1", DisplayName: "Profile 1"}, + Source: "a.yaml", + }, + { + Profile: ProviderProfile{ID: "p2", DisplayName: "Profile 2"}, + Source: "b.yaml", + }, + } + + result, err := client.Import(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Imported) + assert.Len(t, result.Profiles, 2) +} + +func TestProfileImport_Error(t *testing.T) { + mock := newMockProfileServer() + mock.importErr = status.Errorf(codes.InvalidArgument, "bad request") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + {Profile: ProviderProfile{ID: "p1"}, Source: "test.yaml"}, + } + + result, err := client.Import(context.Background(), items) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Update tests --- + +func TestProfileUpdate(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Original", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ + ID: "p1", + DisplayName: "Updated", + Category: ProfileCategoryAgent, + }, + Source: "update.yaml", + } + + result, err := client.Update(context.Background(), "p1", 1, item) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Updated) + require.NotNil(t, result.Profile) + assert.Equal(t, uint64(2), result.Profile.ResourceVersion) // bumped by mock +} + +func TestProfileUpdate_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "missing"}, + Source: "test.yaml", + } + + result, err := client.Update(context.Background(), "missing", 1, item) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileUpdate_VersionMismatch(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Original", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "p1"}, + Source: "test.yaml", + } + + // Use wrong version (99 instead of 1) + result, err := client.Update(context.Background(), "p1", 99, item) + + assert.Nil(t, result) + require.Error(t, err) +} + +func TestProfileUpdate_Error(t *testing.T) { + mock := newMockProfileServer() + mock.updateErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "p1"}, + Source: "test.yaml", + } + + result, err := client.Update(context.Background(), "p1", 1, item) + + assert.Nil(t, result) + require.Error(t, err) +} + +// --- Lint tests --- + +func TestProfileLint_Valid(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "p1", DisplayName: "Good Profile"}, + Source: "test.yaml", + }, + } + + result, err := client.Lint(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Valid) + assert.Empty(t, result.Diagnostics) +} + +func TestProfileLint_Invalid(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "", DisplayName: "Bad Profile"}, // empty ID triggers lint error + Source: "bad.yaml", + }, + } + + result, err := client.Lint(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.Valid) + require.Len(t, result.Diagnostics, 1) + assert.Equal(t, "id", result.Diagnostics[0].Field) + assert.Equal(t, "error", result.Diagnostics[0].Severity) +} + +func TestProfileLint_Error(t *testing.T) { + mock := newMockProfileServer() + mock.lintErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + {Profile: ProviderProfile{ID: "p1"}, Source: "test.yaml"}, + } + + result, err := client.Lint(context.Background(), items) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Delete tests --- + +func TestProfileDelete(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "p1") + + require.NoError(t, err) + assert.True(t, deleted) + + // Verify subsequent Get returns NotFound + profile, err := client.Get(context.Background(), "p1") + assert.Nil(t, profile) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileDelete_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "nonexistent") + + assert.False(t, deleted) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileDelete_Error(t *testing.T) { + mock := newMockProfileServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "p1") + + assert.False(t, deleted) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/provider.go b/sdk/go/openshell/v1/provider.go new file mode 100644 index 0000000000..49fd6ea22f --- /dev/null +++ b/sdk/go/openshell/v1/provider.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Provider represents an AI provider registration. +type Provider = types.Provider + +// ProviderSpec holds provider-specific configuration and credentials. +type ProviderSpec = types.ProviderSpec + +// ProviderInterface defines CRUD and Ensure operations on providers, +// plus sub-client accessors for profiles and credential refresh. +type ProviderInterface interface { + Create(ctx context.Context, provider *Provider) (*Provider, error) + Get(ctx context.Context, name string) (*Provider, error) + List(ctx context.Context, opts ...ListOptions) ([]*Provider, error) + Update(ctx context.Context, provider *Provider) (*Provider, error) + Delete(ctx context.Context, name string) error + Ensure(ctx context.Context, provider *Provider) (*Provider, error) + Profiles() ProfileInterface + Refresh() RefreshInterface +} diff --git a/sdk/go/openshell/v1/provider_client.go b/sdk/go/openshell/v1/provider_client.go new file mode 100644 index 0000000000..442ab86f34 --- /dev/null +++ b/sdk/go/openshell/v1/provider_client.go @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type providerClient struct { + client pb.OpenShellClient + profiles *profileClient + refresh *refreshClient +} + +func newProviderClient(conn grpc.ClientConnInterface) *providerClient { + return &providerClient{ + client: pb.NewOpenShellClient(conn), + profiles: newProfileClient(conn), + refresh: newRefreshClient(conn), + } +} + +// Profiles returns a sub-client for provider profile operations. +func (p *providerClient) Profiles() ProfileInterface { + return p.profiles +} + +// Refresh returns a sub-client for credential refresh operations. +func (p *providerClient) Refresh() RefreshInterface { + return p.refresh +} + +func (p *providerClient) Create(ctx context.Context, provider *Provider) (*Provider, error) { + resp, err := p.client.CreateProvider(ctx, &pb.CreateProviderRequest{ + Provider: converter.ProviderToProto(provider), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) Get(ctx context.Context, name string) (*Provider, error) { + resp, err := p.client.GetProvider(ctx, &pb.GetProviderRequest{ + Name: name, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) List(ctx context.Context, opts ...ListOptions) ([]*Provider, error) { + req := &pb.ListProvidersRequest{} + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + } + + resp, err := p.client.ListProviders(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + providers := make([]*Provider, 0, len(resp.GetProviders())) + for _, proto := range resp.GetProviders() { + providers = append(providers, converter.ProviderFromProto(proto)) + } + return providers, nil +} + +func (p *providerClient) Update(ctx context.Context, provider *Provider) (*Provider, error) { + proto := converter.ProviderToProto(provider) + req := &pb.UpdateProviderRequest{ + Provider: proto, + } + if proto != nil { + req.CredentialExpiresAtMs = proto.CredentialExpiresAtMs + } + + resp, err := p.client.UpdateProvider(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) Delete(ctx context.Context, name string) error { + _, err := p.client.DeleteProvider(ctx, &pb.DeleteProviderRequest{ + Name: name, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *providerClient) Ensure(ctx context.Context, provider *Provider) (*Provider, error) { + if provider == nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "provider must not be nil"} + } + existing, err := p.Get(ctx, provider.Name) + if err != nil { + if !IsNotFound(err) { + return nil, err + } + return p.Create(ctx, provider) + } + + updated := *provider + updated.ID = existing.ID + updated.ResourceVersion = existing.ResourceVersion + return p.Update(ctx, &updated) +} diff --git a/sdk/go/openshell/v1/provider_client_test.go b/sdk/go/openshell/v1/provider_client_test.go new file mode 100644 index 0000000000..ea793673ff --- /dev/null +++ b/sdk/go/openshell/v1/provider_client_test.go @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type mockProviderServer struct { + pb.UnimplementedOpenShellServer + providers map[string]*dm.Provider + createErr error + getErr error + listErr error + updateErr error + deleteErr error +} + +func newMockProviderServer() *mockProviderServer { + return &mockProviderServer{ + providers: make(map[string]*dm.Provider), + } +} + +func (s *mockProviderServer) CreateProvider(_ context.Context, req *pb.CreateProviderRequest) (*pb.ProviderResponse, error) { + if s.createErr != nil { + return nil, s.createErr + } + p := req.GetProvider() + if p.GetMetadata() != nil { + s.providers[p.GetMetadata().GetName()] = p + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) GetProvider(_ context.Context, req *pb.GetProviderRequest) (*pb.ProviderResponse, error) { + if s.getErr != nil { + return nil, s.getErr + } + p, ok := s.providers[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "provider %q not found", req.GetName()) + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) ListProviders(_ context.Context, _ *pb.ListProvidersRequest) (*pb.ListProvidersResponse, error) { + if s.listErr != nil { + return nil, s.listErr + } + var list []*dm.Provider + for _, p := range s.providers { + list = append(list, p) + } + return &pb.ListProvidersResponse{Providers: list}, nil +} + +func (s *mockProviderServer) UpdateProvider(_ context.Context, req *pb.UpdateProviderRequest) (*pb.ProviderResponse, error) { + if s.updateErr != nil { + return nil, s.updateErr + } + p := req.GetProvider() + if p.GetMetadata() != nil { + name := p.GetMetadata().GetName() + if _, ok := s.providers[name]; !ok { + return nil, status.Errorf(codes.NotFound, "provider %q not found", name) + } + s.providers[name] = p + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) DeleteProvider(_ context.Context, req *pb.DeleteProviderRequest) (*pb.DeleteProviderResponse, error) { + if s.deleteErr != nil { + return nil, s.deleteErr + } + delete(s.providers, req.GetName()) + return &pb.DeleteProviderResponse{}, nil +} + +func setupProviderTest(t *testing.T, mock *mockProviderServer) (*providerClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newProviderClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestProviderCreate(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "my-claude", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"API_KEY": "secret"}, + Config: map[string]string{"region": "us-east-1"}, + }, + } + + result, err := client.Create(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "my-claude", result.Name) + assert.Equal(t, "claude", result.Type) + assert.Nil(t, result.Spec.Credentials, "credentials are write-only and should not be returned") +} + +func TestProviderCreate_AlreadyExists(t *testing.T) { + mock := newMockProviderServer() + mock.createErr = status.Error(codes.AlreadyExists, "provider already exists") + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), &Provider{Name: "dup"}) + + require.Error(t, err) + assert.True(t, IsAlreadyExists(err)) +} + +func TestProviderGet(t *testing.T) { + mock := newMockProviderServer() + mock.providers["existing"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Id: "p1", Name: "existing"}, + Type: "gitlab", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.Get(context.Background(), "existing") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) + assert.Equal(t, "gitlab", result.Type) +} + +func TestProviderGet_NotFound(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Get(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderList(t *testing.T) { + mock := newMockProviderServer() + mock.providers["p1"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "p1"}, + Type: "claude", + } + mock.providers["p2"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "p2"}, + Type: "gitlab", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestProviderList_Empty(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestProviderUpdate(t *testing.T) { + mock := newMockProviderServer() + mock.providers["updatable"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "updatable"}, + Type: "claude", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "updatable", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"API_KEY": "new-secret"}, + }, + } + + result, err := client.Update(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "updatable", result.Name) +} + +func TestProviderUpdate_NotFound(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Update(context.Background(), &Provider{Name: "missing"}) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderDelete(t *testing.T) { + mock := newMockProviderServer() + mock.providers["deleteme"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "deleteme"}, + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "deleteme") + + require.NoError(t, err) + assert.Empty(t, mock.providers["deleteme"]) +} + +func TestProviderDelete_NotFound(t *testing.T) { + mock := newMockProviderServer() + mock.deleteErr = status.Error(codes.NotFound, "not found") + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderEnsure_Creates(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "new-provider", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"KEY": "val"}, + }, + } + + result, err := client.Ensure(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "new-provider", result.Name) +} + +func TestProviderEnsure_Updates(t *testing.T) { + mock := newMockProviderServer() + mock.providers["existing"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "existing"}, + Type: "claude", + Config: map[string]string{"old": "config"}, + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "existing", + Type: "claude", + Spec: ProviderSpec{ + Config: map[string]string{"new": "config"}, + }, + } + + result, err := client.Ensure(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) +} diff --git a/sdk/go/openshell/v1/refresh.go b/sdk/go/openshell/v1/refresh.go new file mode 100644 index 0000000000..ff2da766c0 --- /dev/null +++ b/sdk/go/openshell/v1/refresh.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// RefreshStrategy describes how credentials are refreshed. +type RefreshStrategy = types.RefreshStrategy + +// RefreshStatus reports the current state of credential refresh for a provider credential. +type RefreshStatus = types.RefreshStatus + +// RefreshConfig holds configuration parameters for credential refresh. +type RefreshConfig = types.RefreshConfig + +// RefreshStrategy values. +const ( + RefreshStrategyStatic = types.RefreshStrategyStatic + RefreshStrategyExternal = types.RefreshStrategyExternal + RefreshStrategyOAuth2RefreshToken = types.RefreshStrategyOAuth2RefreshToken + RefreshStrategyOAuth2ClientCredentials = types.RefreshStrategyOAuth2ClientCredentials + RefreshStrategyGoogleServiceAccountJWT = types.RefreshStrategyGoogleServiceAccountJWT +) + +// RefreshInterface defines operations for managing provider credential refresh. +type RefreshInterface interface { + // GetStatus returns the refresh status for a provider's credential. + // If credentialKey is empty, statuses for all credentials are returned. + GetStatus(ctx context.Context, provider, credentialKey string) ([]*RefreshStatus, error) + // Configure sets up credential refresh for a provider credential. + Configure(ctx context.Context, config *RefreshConfig) (*RefreshStatus, error) + // Rotate triggers an immediate credential rotation. + Rotate(ctx context.Context, provider, credentialKey string) (*RefreshStatus, error) + // Delete removes credential refresh configuration. Returns true if deleted. + Delete(ctx context.Context, provider, credentialKey string) (bool, error) +} diff --git a/sdk/go/openshell/v1/refresh_client.go b/sdk/go/openshell/v1/refresh_client.go new file mode 100644 index 0000000000..89a5ffdd46 --- /dev/null +++ b/sdk/go/openshell/v1/refresh_client.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type refreshClient struct { + client pb.OpenShellClient +} + +func newRefreshClient(conn grpc.ClientConnInterface) *refreshClient { + return &refreshClient{client: pb.NewOpenShellClient(conn)} +} + +func (r *refreshClient) GetStatus(ctx context.Context, provider, credentialKey string) ([]*RefreshStatus, error) { + resp, err := r.client.GetProviderRefreshStatus(ctx, &pb.GetProviderRefreshStatusRequest{ + Provider: provider, + CredentialKey: credentialKey, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + statuses := make([]*RefreshStatus, 0, len(resp.GetCredentials())) + for _, s := range resp.GetCredentials() { + statuses = append(statuses, converter.RefreshStatusFromProto(s)) + } + return statuses, nil +} + +func (r *refreshClient) Configure(ctx context.Context, config *RefreshConfig) (*RefreshStatus, error) { + req := converter.RefreshConfigToProto(config) + resp, err := r.client.ConfigureProviderRefresh(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.RefreshStatusFromProto(resp.GetStatus()), nil +} + +func (r *refreshClient) Rotate(ctx context.Context, provider, credentialKey string) (*RefreshStatus, error) { + resp, err := r.client.RotateProviderCredential(ctx, &pb.RotateProviderCredentialRequest{ + Provider: provider, + CredentialKey: credentialKey, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.RefreshStatusFromProto(resp.GetStatus()), nil +} + +func (r *refreshClient) Delete(ctx context.Context, provider, credentialKey string) (bool, error) { + resp, err := r.client.DeleteProviderRefresh(ctx, &pb.DeleteProviderRefreshRequest{ + Provider: provider, + CredentialKey: credentialKey, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetDeleted(), nil +} diff --git a/sdk/go/openshell/v1/refresh_client_test.go b/sdk/go/openshell/v1/refresh_client_test.go new file mode 100644 index 0000000000..a08759bf65 --- /dev/null +++ b/sdk/go/openshell/v1/refresh_client_test.go @@ -0,0 +1,426 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --- Mock server for credential refresh --- + +type mockRefreshServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + statuses map[string]*pb.ProviderCredentialRefreshStatus // key: "provider/credentialKey" + getStatusErr error + configureErr error + rotateErr error + deleteErr error +} + +func newMockRefreshServer() *mockRefreshServer { + return &mockRefreshServer{ + statuses: make(map[string]*pb.ProviderCredentialRefreshStatus), + } +} + +func refreshKey(provider, credentialKey string) string { + return provider + "/" + credentialKey +} + +func (s *mockRefreshServer) GetProviderRefreshStatus(_ context.Context, req *pb.GetProviderRefreshStatusRequest) (*pb.GetProviderRefreshStatusResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getStatusErr != nil { + return nil, s.getStatusErr + } + + var creds []*pb.ProviderCredentialRefreshStatus + if req.GetCredentialKey() != "" { + // Return specific credential + st, ok := s.statuses[refreshKey(req.GetProvider(), req.GetCredentialKey())] + if ok { + creds = append(creds, st) + } + } else { + // Return all credentials for provider + for key, st := range s.statuses { + if len(key) > len(req.GetProvider()) && key[:len(req.GetProvider())+1] == req.GetProvider()+"/" { + creds = append(creds, st) + } + } + } + return &pb.GetProviderRefreshStatusResponse{Credentials: creds}, nil +} + +func (s *mockRefreshServer) ConfigureProviderRefresh(_ context.Context, req *pb.ConfigureProviderRefreshRequest) (*pb.ConfigureProviderRefreshResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.configureErr != nil { + return nil, s.configureErr + } + + st := &pb.ProviderCredentialRefreshStatus{ + ProviderName: req.GetProvider(), + ProviderId: "prov-id-" + req.GetProvider(), + CredentialKey: req.GetCredentialKey(), + Strategy: req.GetStrategy(), + Status: "active", + ExpiresAtMs: req.GetExpiresAtMs(), + } + s.statuses[refreshKey(req.GetProvider(), req.GetCredentialKey())] = st + return &pb.ConfigureProviderRefreshResponse{Status: st}, nil +} + +func (s *mockRefreshServer) RotateProviderCredential(_ context.Context, req *pb.RotateProviderCredentialRequest) (*pb.RotateProviderCredentialResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.rotateErr != nil { + return nil, s.rotateErr + } + + key := refreshKey(req.GetProvider(), req.GetCredentialKey()) + st, ok := s.statuses[key] + if !ok { + return nil, status.Errorf(codes.NotFound, "refresh config %q not found", key) + } + st.Status = "rotated" + st.LastRefreshAtMs = time.Now().UnixMilli() + return &pb.RotateProviderCredentialResponse{Status: st}, nil +} + +func (s *mockRefreshServer) DeleteProviderRefresh(_ context.Context, req *pb.DeleteProviderRefreshRequest) (*pb.DeleteProviderRefreshResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + key := refreshKey(req.GetProvider(), req.GetCredentialKey()) + _, ok := s.statuses[key] + if !ok { + return &pb.DeleteProviderRefreshResponse{Deleted: false}, nil + } + delete(s.statuses, key) + return &pb.DeleteProviderRefreshResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupRefreshTest(t *testing.T, mock *mockRefreshServer) (*refreshClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newRefreshClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- GetStatus tests --- + +func TestRefreshGetStatus(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure a credential first + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + Material: map[string]string{"refresh_token": "tok-123"}, + } + _, err := client.Configure(context.Background(), cfg) + require.NoError(t, err) + + // Get status for specific credential + statuses, err := client.GetStatus(context.Background(), "openai", "api-key") + + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.Equal(t, "openai", statuses[0].ProviderName) + assert.Equal(t, "api-key", statuses[0].CredentialKey) + assert.Equal(t, RefreshStrategyOAuth2RefreshToken, statuses[0].Strategy) + assert.Equal(t, "active", statuses[0].Status) +} + +func TestRefreshGetStatus_AllCredentials(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure two credentials + _, err := client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "key-1", + Strategy: RefreshStrategyStatic, + }) + require.NoError(t, err) + _, err = client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "key-2", + Strategy: RefreshStrategyExternal, + }) + require.NoError(t, err) + + // Get all statuses (empty credentialKey) + statuses, err := client.GetStatus(context.Background(), "openai", "") + + require.NoError(t, err) + assert.Len(t, statuses, 2) +} + +func TestRefreshGetStatus_Empty(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + statuses, err := client.GetStatus(context.Background(), "openai", "nonexistent") + + require.NoError(t, err) + assert.Empty(t, statuses) +} + +func TestRefreshGetStatus_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.getStatusErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + statuses, err := client.GetStatus(context.Background(), "openai", "key") + + assert.Nil(t, statuses) + require.Error(t, err) +} + +// --- Configure tests --- + +func TestRefreshConfigure(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + expires := time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC) + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2ClientCredentials, + Material: map[string]string{"client_id": "id-1", "client_secret": "sec-1"}, + SecretMaterialKeys: []string{"client_secret"}, + ExpiresAt: &expires, + } + + result, err := client.Configure(context.Background(), cfg) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "openai", result.ProviderName) + assert.Equal(t, "prov-id-openai", result.ProviderID) + assert.Equal(t, "api-key", result.CredentialKey) + assert.Equal(t, RefreshStrategyOAuth2ClientCredentials, result.Strategy) + assert.Equal(t, "active", result.Status) +} + +func TestRefreshConfigure_MinimalConfig(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + cfg := &RefreshConfig{ + Provider: "anthropic", + CredentialKey: "key", + Strategy: RefreshStrategyStatic, + } + + result, err := client.Configure(context.Background(), cfg) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "anthropic", result.ProviderName) + assert.Equal(t, RefreshStrategyStatic, result.Strategy) +} + +func TestRefreshConfigure_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.configureErr = status.Errorf(codes.InvalidArgument, "invalid config") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "key", + Strategy: RefreshStrategyStatic, + } + + result, err := client.Configure(context.Background(), cfg) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Rotate tests --- + +func TestRefreshRotate(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure first + _, err := client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + }) + require.NoError(t, err) + + // Rotate + result, err := client.Rotate(context.Background(), "openai", "api-key") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "rotated", result.Status) + assert.False(t, result.LastRefreshAt.IsZero()) +} + +func TestRefreshRotate_NotFound(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + result, err := client.Rotate(context.Background(), "openai", "nonexistent") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestRefreshRotate_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.rotateErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + result, err := client.Rotate(context.Background(), "openai", "key") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Delete tests --- + +func TestRefreshDelete(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure first + _, err := client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyStatic, + }) + require.NoError(t, err) + + // Delete + deleted, err := client.Delete(context.Background(), "openai", "api-key") + + require.NoError(t, err) + assert.True(t, deleted) + + // Verify it's gone + statuses, err := client.GetStatus(context.Background(), "openai", "api-key") + require.NoError(t, err) + assert.Empty(t, statuses) +} + +func TestRefreshDelete_NotConfigured(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "openai", "nonexistent") + + require.NoError(t, err) + assert.False(t, deleted) +} + +func TestRefreshDelete_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "openai", "key") + + assert.False(t, deleted) + require.Error(t, err) +} + +// --- Integration test: full lifecycle --- + +func TestRefreshLifecycle(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + ctx := context.Background() + + // 1. Configure + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + Material: map[string]string{"refresh_token": "tok-123"}, + } + st, err := client.Configure(ctx, cfg) + require.NoError(t, err) + assert.Equal(t, "active", st.Status) + + // 2. GetStatus + statuses, err := client.GetStatus(ctx, "openai", "api-key") + require.NoError(t, err) + require.Len(t, statuses, 1) + + // 3. Rotate + rotated, err := client.Rotate(ctx, "openai", "api-key") + require.NoError(t, err) + assert.Equal(t, "rotated", rotated.Status) + + // 4. Delete + deleted, err := client.Delete(ctx, "openai", "api-key") + require.NoError(t, err) + assert.True(t, deleted) + + // 5. Verify removed + statuses, err = client.GetStatus(ctx, "openai", "api-key") + require.NoError(t, err) + assert.Empty(t, statuses) +} diff --git a/sdk/go/openshell/v1/sandbox.go b/sdk/go/openshell/v1/sandbox.go new file mode 100644 index 0000000000..060b3c72c3 --- /dev/null +++ b/sdk/go/openshell/v1/sandbox.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Sandbox represents a sandbox instance. +type Sandbox = types.Sandbox + +// SandboxSpec holds the desired state of a sandbox. +type SandboxSpec = types.SandboxSpec + +// SandboxTemplate defines the container template for a sandbox. +type SandboxTemplate = types.SandboxTemplate + +// SandboxStatus holds the observed state of a sandbox. +type SandboxStatus = types.SandboxStatus + +// SandboxCondition describes an observed condition of a sandbox. +type SandboxCondition = types.SandboxCondition + +// AttachProviderResult holds the result of attaching a provider to a sandbox. +type AttachProviderResult = types.AttachProviderResult + +// DetachProviderResult holds the result of detaching a provider from a sandbox. +type DetachProviderResult = types.DetachProviderResult + +// LogLine represents a single log entry from a sandbox. +type LogLine = types.LogLine + +// LogResult contains the result of a GetLogs call. +type LogResult = types.LogResult + +// LogOption configures a GetLogs call. +type LogOption = types.LogOption + +// WithLogLines sets the maximum number of log lines to return. +var WithLogLines = types.WithLogLines + +// WithLogSince filters logs to entries at or after the given time. +var WithLogSince = types.WithLogSince + +// WithLogSources filters logs by source (e.g., "gateway", "sandbox"). +var WithLogSources = types.WithLogSources + +// WithLogMinLevel sets the minimum log level to include. +var WithLogMinLevel = types.WithLogMinLevel + +// SandboxInterface defines lifecycle operations on sandboxes. +type SandboxInterface interface { + Create(ctx context.Context, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) + Get(ctx context.Context, name string) (*Sandbox, error) + List(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) + Delete(ctx context.Context, name string) error + AttachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error) + DetachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error) + ListProviders(ctx context.Context, sandboxName string) ([]*Provider, error) + WaitReady(ctx context.Context, name string, opts ...WaitOptions) (*Sandbox, error) + Watch(ctx context.Context, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) + // GetLogs retrieves log entries for a sandbox. The sandbox is resolved + // by name (an internal Get call translates name to ID). Use + // WithLogLines, WithLogSince, WithLogSources, and WithLogMinLevel to + // filter the results. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + GetLogs(ctx context.Context, sandboxName string, opts ...LogOption) (*LogResult, error) +} diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go new file mode 100644 index 0000000000..66f02b27fa --- /dev/null +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +const defaultPollInterval = 500 * time.Millisecond + +type sandboxClient struct { + client pb.OpenShellClient +} + +func newSandboxClient(conn grpc.ClientConnInterface) *sandboxClient { + return &sandboxClient{client: pb.NewOpenShellClient(conn)} +} + +func (s *sandboxClient) Create(ctx context.Context, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) { + resp, err := s.client.CreateSandbox(ctx, &pb.CreateSandboxRequest{ + Name: name, + Spec: converter.SandboxSpecToProto(spec), + Labels: labels, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxFromProto(resp.GetSandbox()), nil +} + +func (s *sandboxClient) Get(ctx context.Context, name string) (*Sandbox, error) { + resp, err := s.client.GetSandbox(ctx, &pb.GetSandboxRequest{ + Name: name, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxFromProto(resp.GetSandbox()), nil +} + +func (s *sandboxClient) List(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) { + req := &pb.ListSandboxesRequest{} + if len(opts) > 0 { + if opts[0].Limit > 0 { + req.Limit = uint32(opts[0].Limit) + } + if opts[0].Offset > 0 { + req.Offset = uint32(opts[0].Offset) + } + req.LabelSelector = opts[0].LabelSelector + } + + resp, err := s.client.ListSandboxes(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + sandboxes := make([]*Sandbox, 0, len(resp.GetSandboxes())) + for _, proto := range resp.GetSandboxes() { + sandboxes = append(sandboxes, converter.SandboxFromProto(proto)) + } + return sandboxes, nil +} + +func (s *sandboxClient) Delete(ctx context.Context, name string) error { + _, err := s.client.DeleteSandbox(ctx, &pb.DeleteSandboxRequest{ + Name: name, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (s *sandboxClient) AttachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error) { + resp, err := s.client.AttachSandboxProvider(ctx, &pb.AttachSandboxProviderRequest{ + SandboxName: sandboxName, + ProviderName: providerName, + ExpectedResourceVersion: expectedResourceVersion, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return &AttachProviderResult{ + Sandbox: converter.SandboxFromProto(resp.GetSandbox()), + Attached: resp.GetAttached(), + }, nil +} + +func (s *sandboxClient) DetachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error) { + resp, err := s.client.DetachSandboxProvider(ctx, &pb.DetachSandboxProviderRequest{ + SandboxName: sandboxName, + ProviderName: providerName, + ExpectedResourceVersion: expectedResourceVersion, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return &DetachProviderResult{ + Sandbox: converter.SandboxFromProto(resp.GetSandbox()), + Detached: resp.GetDetached(), + }, nil +} + +func (s *sandboxClient) ListProviders(ctx context.Context, sandboxName string) ([]*Provider, error) { + resp, err := s.client.ListSandboxProviders(ctx, &pb.ListSandboxProvidersRequest{ + SandboxName: sandboxName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + providers := make([]*Provider, 0, len(resp.GetProviders())) + for _, proto := range resp.GetProviders() { + providers = append(providers, converter.ProviderFromProto(proto)) + } + return providers, nil +} + +func (s *sandboxClient) WaitReady(ctx context.Context, name string, opts ...WaitOptions) (*Sandbox, error) { + interval := defaultPollInterval + if len(opts) > 0 && opts[0].PollInterval > 0 { + interval = opts[0].PollInterval + } + + sb, err := s.Get(ctx, name) + if err != nil { + return nil, err + } + + if sb.Status.Phase == SandboxReady { + return sb, nil + } + if sb.Status.Phase == SandboxError { + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + sb, err = s.Get(ctx, name) + if err != nil { + return nil, err + } + if sb.Status.Phase == SandboxReady { + return sb, nil + } + if sb.Status.Phase == SandboxError { + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + } + } + } +} + +func (s *sandboxClient) Watch(ctx context.Context, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) { + if name == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + + var watchOpts WatchOptions + if len(opts) > 0 { + watchOpts = opts[0] + } + + // Resolve sandbox name to ID — the proto RPC takes Id, not name. + sb, err := s.Get(ctx, name) + if err != nil { + return nil, err + } + + streamCtx, streamCancel := context.WithCancel(ctx) + stream, err := s.client.WatchSandbox(streamCtx, &pb.WatchSandboxRequest{ + Id: sb.ID, + FollowStatus: true, + StopOnTerminal: watchOpts.StopOnTerminal, + }) + if err != nil { + streamCancel() + return nil, converter.FromGRPCError(err) + } + + first, err := stream.Recv() + if err != nil { + streamCancel() + return nil, converter.FromGRPCError(err) + } + + ch := make(chan Event[*Sandbox], 64) + w := newWatcher(ch, streamCancel) + + go func() { + defer close(ch) + ev := first + for { + if sbPayload, ok := ev.Payload.(*pb.SandboxStreamEvent_Sandbox); ok && sbPayload.Sandbox != nil { + sandbox := converter.SandboxFromProto(sbPayload.Sandbox) + eventType := EventModified + if sandbox.Status.Phase == SandboxDeleting { + eventType = EventDeleted + } + select { + case ch <- Event[*Sandbox]{Type: eventType, Object: sandbox}: + case <-w.done: + return + } + // StopOnTerminal: close watcher after delivering a terminal phase event + if watchOpts.StopOnTerminal && (sandbox.Status.Phase == SandboxReady || sandbox.Status.Phase == SandboxError) { + w.Stop() + return + } + } + var recvErr error + ev, recvErr = stream.Recv() + if recvErr != nil { + select { + case <-w.done: + default: + if recvErr != io.EOF { + select { + case ch <- Event[*Sandbox]{Type: EventError, Object: nil}: + default: + } + } + } + return + } + } + }() + + return w, nil +} + +func (s *sandboxClient) GetLogs(ctx context.Context, sandboxName string, opts ...LogOption) (*LogResult, error) { + // Resolve sandbox name to ID — the proto RPC takes SandboxId, not name. + sb, err := s.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + cfg := types.ApplyLogOptions(opts) + req := &pb.GetSandboxLogsRequest{ + SandboxId: sb.ID, + Lines: cfg.Lines(), + Sources: cfg.Sources(), + MinLevel: cfg.MinLevel(), + } + if !cfg.Since().IsZero() { + req.SinceMs = converter.MillisFromTime(cfg.Since()) + } + + resp, err := s.client.GetSandboxLogs(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.LogResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go new file mode 100644 index 0000000000..130e28d86b --- /dev/null +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -0,0 +1,971 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + "time" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" +) + +type mockSandboxServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + sandboxes map[string]*pb.Sandbox + providers map[string][]*dm.Provider + createErr error + getErr error + listErr error + deleteErr error + attachErr error + detachErr error + listProvErr error + watchEvents []*pb.SandboxStreamEvent + watchErr error + watchKeepOpen chan struct{} // if non-nil, WatchSandbox blocks after sending events until closed + watchRequest *pb.WatchSandboxRequest // recorded request + + // GetLogs fields + getLogsResp *pb.GetSandboxLogsResponse + getLogsErr error + getLogsRequest *pb.GetSandboxLogsRequest // recorded request +} + +func newMockSandboxServer() *mockSandboxServer { + return &mockSandboxServer{ + sandboxes: make(map[string]*pb.Sandbox), + providers: make(map[string][]*dm.Provider), + } +} + +func (s *mockSandboxServer) CreateSandbox(_ context.Context, req *pb.CreateSandboxRequest) (*pb.SandboxResponse, error) { + if s.createErr != nil { + return nil, s.createErr + } + sb := &pb.Sandbox{ + Metadata: &dm.ObjectMeta{ + Id: "sb-" + req.GetName(), + Name: req.GetName(), + CreatedAtMs: 1700000000000, + Labels: req.GetLabels(), + ResourceVersion: 1, + }, + Spec: req.GetSpec(), + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + s.sandboxes[req.GetName()] = sb + return &pb.SandboxResponse{Sandbox: sb}, nil +} + +func (s *mockSandboxServer) GetSandbox(_ context.Context, req *pb.GetSandboxRequest) (*pb.SandboxResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + sb, ok := s.sandboxes[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + } + cloned := proto.Clone(sb).(*pb.Sandbox) + return &pb.SandboxResponse{Sandbox: cloned}, nil +} + +func (s *mockSandboxServer) setPhase(name string, phase pb.SandboxPhase) { + s.mu.Lock() + defer s.mu.Unlock() + if sb, ok := s.sandboxes[name]; ok { + sb.Status.Phase = phase + } +} + +func (s *mockSandboxServer) ListSandboxes(_ context.Context, _ *pb.ListSandboxesRequest) (*pb.ListSandboxesResponse, error) { + if s.listErr != nil { + return nil, s.listErr + } + var list []*pb.Sandbox + for _, sb := range s.sandboxes { + list = append(list, sb) + } + return &pb.ListSandboxesResponse{Sandboxes: list}, nil +} + +func (s *mockSandboxServer) DeleteSandbox(_ context.Context, req *pb.DeleteSandboxRequest) (*pb.DeleteSandboxResponse, error) { + if s.deleteErr != nil { + return nil, s.deleteErr + } + _, ok := s.sandboxes[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + } + delete(s.sandboxes, req.GetName()) + return &pb.DeleteSandboxResponse{Deleted: true}, nil +} + +func (s *mockSandboxServer) AttachSandboxProvider(_ context.Context, req *pb.AttachSandboxProviderRequest) (*pb.AttachSandboxProviderResponse, error) { + if s.attachErr != nil { + return nil, s.attachErr + } + sb, ok := s.sandboxes[req.GetSandboxName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetSandboxName()) + } + sb.Spec.Providers = append(sb.Spec.Providers, req.GetProviderName()) + return &pb.AttachSandboxProviderResponse{Sandbox: sb, Attached: true}, nil +} + +func (s *mockSandboxServer) DetachSandboxProvider(_ context.Context, req *pb.DetachSandboxProviderRequest) (*pb.DetachSandboxProviderResponse, error) { + if s.detachErr != nil { + return nil, s.detachErr + } + sb, ok := s.sandboxes[req.GetSandboxName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetSandboxName()) + } + return &pb.DetachSandboxProviderResponse{Sandbox: sb, Detached: true}, nil +} + +func (s *mockSandboxServer) ListSandboxProviders(_ context.Context, req *pb.ListSandboxProvidersRequest) (*pb.ListSandboxProvidersResponse, error) { + if s.listProvErr != nil { + return nil, s.listProvErr + } + provs := s.providers[req.GetSandboxName()] + return &pb.ListSandboxProvidersResponse{Providers: provs}, nil +} + +func (s *mockSandboxServer) WatchSandbox(req *pb.WatchSandboxRequest, stream grpc.ServerStreamingServer[pb.SandboxStreamEvent]) error { + s.mu.Lock() + s.watchRequest = req + s.mu.Unlock() + if s.watchErr != nil { + return s.watchErr + } + s.mu.Lock() + events := make([]*pb.SandboxStreamEvent, len(s.watchEvents)) + copy(events, s.watchEvents) + keepOpen := s.watchKeepOpen + s.mu.Unlock() + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + // If watchKeepOpen is set, block until it is closed (simulates long-running stream) + if keepOpen != nil { + <-keepOpen + } + return nil +} + +func (s *mockSandboxServer) GetSandboxLogs(_ context.Context, req *pb.GetSandboxLogsRequest) (*pb.GetSandboxLogsResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.getLogsRequest = req + if s.getLogsErr != nil { + return nil, s.getLogsErr + } + if s.getLogsResp != nil { + return s.getLogsResp, nil + } + return &pb.GetSandboxLogsResponse{}, nil +} + +func setupSandboxTest(t *testing.T, mock *mockSandboxServer) (*sandboxClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newSandboxClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T029: Sandbox CRUD tests --- + +func TestSandboxCreate(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + spec := &SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"FOO": "bar"}, + Providers: []string{"claude"}, + } + labels := map[string]string{"env": "dev"} + + result, err := client.Create(context.Background(), "my-sandbox", spec, labels) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "my-sandbox", result.Name) + assert.Equal(t, "sb-my-sandbox", result.ID) + assert.Equal(t, map[string]string{"env": "dev"}, result.Labels) + assert.Equal(t, SandboxProvisioning, result.Status.Phase) +} + +func TestSandboxCreate_AlreadyExists(t *testing.T) { + mock := newMockSandboxServer() + mock.createErr = status.Error(codes.AlreadyExists, "sandbox already exists") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), "dup", &SandboxSpec{}, nil) + + require.Error(t, err) + assert.True(t, IsAlreadyExists(err)) +} + +func TestSandboxGet(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["existing"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-1", Name: "existing", ResourceVersion: 5}, + Spec: &pb.SandboxSpec{LogLevel: "info"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.Get(context.Background(), "existing") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) + assert.Equal(t, "sb-1", result.ID) + assert.Equal(t, uint64(5), result.ResourceVersion) + assert.Equal(t, SandboxReady, result.Status.Phase) +} + +func TestSandboxGet_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Get(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxList(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.sandboxes["sb2"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb2"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestSandboxList_Empty(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestSandboxList_WithOptions(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background(), ListOptions{Limit: 10, Offset: 0}) + + require.NoError(t, err) + assert.Len(t, result, 1) +} + +func TestSandboxDelete(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["deleteme"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "deleteme"}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "deleteme") + + require.NoError(t, err) + assert.Empty(t, mock.sandboxes["deleteme"]) +} + +func TestSandboxDelete_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- T030: AttachProvider, DetachProvider, ListProviders tests --- + +func TestSandboxAttachProvider(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["my-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "my-sb", ResourceVersion: 2}, + Spec: &pb.SandboxSpec{Providers: []string{"existing-prov"}}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.AttachProvider(context.Background(), "my-sb", "new-prov", 2) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Attached) + require.NotNil(t, result.Sandbox) + assert.Equal(t, "my-sb", result.Sandbox.Name) +} + +func TestSandboxAttachProvider_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.AttachProvider(context.Background(), "missing", "prov", 1) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxAttachProvider_Error(t *testing.T) { + mock := newMockSandboxServer() + mock.attachErr = status.Error(codes.InvalidArgument, "bad version") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.AttachProvider(context.Background(), "sb", "prov", 99) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSandboxDetachProvider(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["my-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "my-sb", ResourceVersion: 3}, + Spec: &pb.SandboxSpec{Providers: []string{"prov-a", "prov-b"}}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.DetachProvider(context.Background(), "my-sb", "prov-a", 3) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Detached) + require.NotNil(t, result.Sandbox) + assert.Equal(t, "my-sb", result.Sandbox.Name) +} + +func TestSandboxDetachProvider_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.DetachProvider(context.Background(), "missing", "prov", 1) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxListProviders(t *testing.T) { + mock := newMockSandboxServer() + mock.providers["my-sb"] = []*dm.Provider{ + {Metadata: &dm.ObjectMeta{Name: "claude-prov"}, Type: "claude"}, + {Metadata: &dm.ObjectMeta{Name: "github-prov"}, Type: "github"}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.ListProviders(context.Background(), "my-sb") + + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestSandboxListProviders_Empty(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.ListProviders(context.Background(), "empty-sb") + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestSandboxListProviders_Error(t *testing.T) { + mock := newMockSandboxServer() + mock.listProvErr = status.Error(codes.Unavailable, "service down") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.ListProviders(context.Background(), "sb") + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- T031: WaitReady tests --- + +func TestSandboxWaitReady_AlreadyReady(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["ready-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "ready-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.WaitReady(context.Background(), "ready-sb") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "ready-sb", result.Name) + assert.Equal(t, SandboxReady, result.Status.Phase) +} + +func TestSandboxWaitReady_BecomesReady(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["pending-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "pending-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + go func() { + time.Sleep(50 * time.Millisecond) + mock.setPhase("pending-sb", pb.SandboxPhase_SANDBOX_PHASE_READY) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result, err := client.WaitReady(ctx, "pending-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, SandboxReady, result.Status.Phase) +} + +func TestSandboxWaitReady_ContextTimeout(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["stuck-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "stuck-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := client.WaitReady(ctx, "stuck-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) + + require.Error(t, err) +} + +func TestSandboxWaitReady_SandboxFailed(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["fail-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "fail-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_ERROR}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.WaitReady(context.Background(), "fail-sb") + + require.Error(t, err) +} + +func TestSandboxWaitReady_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.WaitReady(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- T039: Watch integration tests --- + +func TestSandboxWatch_ReceivesEvents(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev1 := <-w.ResultChan() + assert.Equal(t, EventModified, ev1.Type) + require.NotNil(t, ev1.Object) + assert.Equal(t, "sb-1", ev1.Object.Name) + assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) + + ev2 := <-w.ResultChan() + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, SandboxReady, ev2.Object.Status.Phase) +} + +func TestSandboxWatch_FiltersSandboxEventsOnly(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Log{Log: &pb.SandboxLogLine{Message: "some log"}}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + {Payload: &pb.SandboxStreamEvent_Warning{Warning: &pb.SandboxStreamWarning{Message: "warn"}}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev := <-w.ResultChan() + assert.Equal(t, EventModified, ev.Type) + assert.Equal(t, "sb-1", ev.Object.Name) + + // Stream ends after server sends all events; channel should close + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after stream ends") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } +} + +func TestSandboxWatch_StopCancelsStream(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + + <-w.ResultChan() + w.Stop() + + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should be closed after Stop") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close after Stop") + } +} + +func TestSandboxWatch_RPCError(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["watch-err"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-watch-err", Name: "watch-err"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchErr = status.Error(codes.Unavailable, "stream unavailable") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Watch(context.Background(), "watch-err") + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- T016: Watch name-to-ID resolution verification tests --- + +func TestSandboxWatch_ResolvesNameToID(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["my-sandbox"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "resolved-id-123", Name: "my-sandbox"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchKeepOpen = make(chan struct{}) + defer close(mock.watchKeepOpen) + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "my-sandbox", Id: "resolved-id-123"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "my-sandbox") + require.NoError(t, err) + defer w.Stop() + + // Verify the WatchSandboxRequest.Id contains the resolved ID, not the name + mock.mu.Lock() + req := mock.watchRequest + mock.mu.Unlock() + require.NotNil(t, req) + assert.Equal(t, "resolved-id-123", req.GetId(), "Watch should send resolved sandbox ID, not the name") +} + +func TestSandboxWatch_ResolutionError(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Watch(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err), "Watch should return NotFound when sandbox name cannot be resolved") +} + +func TestSandboxWatch_EmptySandboxName(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Watch(context.Background(), "") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "Watch should reject empty sandbox name") +} + +// --- T023/T024: StopOnTerminal watch tests --- + +func TestSandboxWatch_StopOnTerminal_Ready(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchKeepOpen = make(chan struct{}) + defer close(mock.watchKeepOpen) + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1", WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + defer w.Stop() + + // Should receive the Provisioning event + ev1 := <-w.ResultChan() + assert.Equal(t, EventModified, ev1.Type) + assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) + + // Should receive the Ready event (terminal) + ev2 := <-w.ResultChan() + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, SandboxReady, ev2.Object.Status.Phase) + + // Channel should close automatically after terminal event (stream is still open) + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after terminal Ready event") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close after terminal Ready event") + } +} + +func TestSandboxWatch_StopOnTerminal_Error(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchKeepOpen = make(chan struct{}) + defer close(mock.watchKeepOpen) + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_ERROR}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1", WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + defer w.Stop() + + // Should receive the Provisioning event + ev1 := <-w.ResultChan() + assert.Equal(t, EventModified, ev1.Type) + assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) + + // Should receive the Error event (terminal) + ev2 := <-w.ResultChan() + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, SandboxError, ev2.Object.Status.Phase) + + // Channel should close automatically after terminal event (stream is still open) + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after terminal Error event") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close after terminal Error event") + } +} + +func TestSandboxWatch_StopOnTerminal_False_DoesNotClose(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + // Default: StopOnTerminal=false — watcher should NOT auto-close on Ready + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev := <-w.ResultChan() + assert.Equal(t, EventModified, ev.Type) + assert.Equal(t, SandboxReady, ev.Object.Status.Phase) + + // Channel closes because mock stream ends (not because of StopOnTerminal) + // This test verifies the existing behavior is preserved + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after stream ends") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } +} + +// --- T027: GetLogs tests --- + +func TestSandboxGetLogs(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["log-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-123", Name: "log-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsResp = &pb.GetSandboxLogsResponse{ + Logs: []*pb.SandboxLogLine{ + {TimestampMs: 1700000000000, Level: "INFO", Target: "gateway", Message: "connected", Source: "gateway"}, + {TimestampMs: 1700000001000, Level: "DEBUG", Target: "sandbox", Message: "init done", Source: "sandbox"}, + }, + BufferTotal: 42, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.GetLogs(context.Background(), "log-sb") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Lines, 2) + assert.Equal(t, uint32(42), result.BufferTotal) + assert.Equal(t, "INFO", result.Lines[0].Level) + assert.Equal(t, "connected", result.Lines[0].Message) + assert.Equal(t, "gateway", result.Lines[0].Source) + assert.Equal(t, "DEBUG", result.Lines[1].Level) + assert.Equal(t, "init done", result.Lines[1].Message) + + // Verify name→id resolution: the proto request should contain the sandbox ID + mock.mu.Lock() + assert.Equal(t, "sb-id-123", mock.getLogsRequest.GetSandboxId()) + mock.mu.Unlock() +} + +func TestSandboxGetLogs_WithOptions(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["opts-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-opts", Name: "opts-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsResp = &pb.GetSandboxLogsResponse{ + Logs: []*pb.SandboxLogLine{{TimestampMs: 1700000000000, Level: "WARN", Message: "high cpu"}}, + BufferTotal: 100, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + since := time.Date(2023, 11, 14, 0, 0, 0, 0, time.UTC) + result, err := client.GetLogs(context.Background(), "opts-sb", + WithLogLines(50), + WithLogSince(since), + WithLogSources("gateway", "sandbox"), + WithLogMinLevel("WARN"), + ) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Lines, 1) + assert.Equal(t, "WARN", result.Lines[0].Level) + + // Verify all options were passed to the proto request + mock.mu.Lock() + req := mock.getLogsRequest + mock.mu.Unlock() + assert.Equal(t, "sb-id-opts", req.GetSandboxId()) + assert.Equal(t, uint32(50), req.GetLines()) + assert.Equal(t, since.UnixMilli(), req.GetSinceMs()) + assert.Equal(t, []string{"gateway", "sandbox"}, req.GetSources()) + assert.Equal(t, "WARN", req.GetMinLevel()) +} + +func TestSandboxGetLogs_SandboxNotFound(t *testing.T) { + mock := newMockSandboxServer() + // No sandbox registered — Get will return NotFound + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.GetLogs(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxGetLogs_RPCError(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["rpc-err-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-rpc", Name: "rpc-err-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsErr = status.Error(codes.Unavailable, "log service down") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.GetLogs(context.Background(), "rpc-err-sb") + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +func TestSandboxGetLogs_EmptyResult(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["empty-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-empty", Name: "empty-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsResp = &pb.GetSandboxLogsResponse{ + BufferTotal: 0, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.GetLogs(context.Background(), "empty-sb") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Empty(t, result.Lines) + assert.Equal(t, uint32(0), result.BufferTotal) +} + +func TestSandboxGetLogs_SinceZeroNotSent(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["zero-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-zero", Name: "zero-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + // Call without WithLogSince — SinceMs should be 0 (not set) + _, err := client.GetLogs(context.Background(), "zero-sb") + + require.NoError(t, err) + mock.mu.Lock() + assert.Equal(t, int64(0), mock.getLogsRequest.GetSinceMs()) + mock.mu.Unlock() +} diff --git a/sdk/go/openshell/v1/service.go b/sdk/go/openshell/v1/service.go new file mode 100644 index 0000000000..e31ce8497a --- /dev/null +++ b/sdk/go/openshell/v1/service.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ServiceEndpoint represents an exposed HTTP service endpoint within a sandbox. +type ServiceEndpoint = types.ServiceEndpoint + +// ServiceInterface defines operations for managing sandbox service endpoints. +type ServiceInterface interface { + // Expose creates a new service endpoint in the given sandbox. + Expose(ctx context.Context, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) + // Get retrieves a service endpoint by sandbox and service name. + Get(ctx context.Context, sandboxName, serviceName string) (*ServiceEndpoint, error) + // List returns all service endpoints for a sandbox. An empty sandboxName returns endpoints across all sandboxes. + List(ctx context.Context, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) + // Delete removes a service endpoint by sandbox and service name. + Delete(ctx context.Context, sandboxName, serviceName string) error +} diff --git a/sdk/go/openshell/v1/service_client.go b/sdk/go/openshell/v1/service_client.go new file mode 100644 index 0000000000..ed203cd931 --- /dev/null +++ b/sdk/go/openshell/v1/service_client.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type serviceClient struct { + client pb.OpenShellClient +} + +func newServiceClient(conn grpc.ClientConnInterface) *serviceClient { + return &serviceClient{client: pb.NewOpenShellClient(conn)} +} + +func (s *serviceClient) Expose(ctx context.Context, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) { + resp, err := s.client.ExposeService(ctx, &pb.ExposeServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + TargetPort: targetPort, + Domain: domain, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ServiceEndpointFromProto(resp), nil +} + +func (s *serviceClient) Get(ctx context.Context, sandboxName, serviceName string) (*ServiceEndpoint, error) { + resp, err := s.client.GetService(ctx, &pb.GetServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ServiceEndpointFromProto(resp), nil +} + +func (s *serviceClient) List(ctx context.Context, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) { + req := &pb.ListServicesRequest{ + Sandbox: sandboxName, + } + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + } + + resp, err := s.client.ListServices(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + endpoints := make([]*ServiceEndpoint, 0, len(resp.GetServices())) + for _, svc := range resp.GetServices() { + endpoints = append(endpoints, converter.ServiceEndpointFromProto(svc)) + } + return endpoints, nil +} + +func (s *serviceClient) Delete(ctx context.Context, sandboxName, serviceName string) error { + _, err := s.client.DeleteService(ctx, &pb.DeleteServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} diff --git a/sdk/go/openshell/v1/service_client_test.go b/sdk/go/openshell/v1/service_client_test.go new file mode 100644 index 0000000000..4732880649 --- /dev/null +++ b/sdk/go/openshell/v1/service_client_test.go @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --- Mock server for service endpoints --- + +type mockServiceServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + endpoints map[string]*pb.ServiceEndpointResponse // key: "sandbox/service" + exposeErr error + getErr error + listErr error + deleteErr error +} + +func newMockServiceServer() *mockServiceServer { + return &mockServiceServer{ + endpoints: make(map[string]*pb.ServiceEndpointResponse), + } +} + +func serviceKey(sandbox, service string) string { + return sandbox + "/" + service +} + +func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServiceRequest) (*pb.ServiceEndpointResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.exposeErr != nil { + return nil, s.exposeErr + } + + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: "ep-" + req.GetService(), + }, + SandboxName: req.GetSandbox(), + ServiceName: req.GetService(), + TargetPort: req.GetTargetPort(), + Domain: req.GetDomain(), + }, + } + if req.GetDomain() { + resp.Url = "https://" + req.GetService() + ".example.com" + } + + s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] = resp + return resp, nil +} + +func (s *mockServiceServer) GetService(_ context.Context, req *pb.GetServiceRequest) (*pb.ServiceEndpointResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + + ep, ok := s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] + if !ok { + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + } + return ep, nil +} + +func (s *mockServiceServer) ListServices(_ context.Context, req *pb.ListServicesRequest) (*pb.ListServicesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.listErr != nil { + return nil, s.listErr + } + + var services []*pb.ServiceEndpointResponse + for key, ep := range s.endpoints { + prefix := req.GetSandbox() + "/" + if req.GetSandbox() == "" || (len(key) >= len(prefix) && key[:len(prefix)] == prefix) { + services = append(services, ep) + } + } + return &pb.ListServicesResponse{Services: services}, nil +} + +func (s *mockServiceServer) DeleteService(_ context.Context, req *pb.DeleteServiceRequest) (*pb.DeleteServiceResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + key := serviceKey(req.GetSandbox(), req.GetService()) + _, ok := s.endpoints[key] + if !ok { + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + } + delete(s.endpoints, key) + return &pb.DeleteServiceResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupServiceTest(t *testing.T, mock *mockServiceServer) (*serviceClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newServiceClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestServiceExpose(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + + require.NoError(t, err) + require.NotNil(t, ep) + assert.Equal(t, "ep-api", ep.ID) + assert.Equal(t, "web-app", ep.SandboxName) + assert.Equal(t, "api", ep.ServiceName) + assert.Equal(t, uint32(8080), ep.TargetPort) + assert.True(t, ep.Domain) + assert.Equal(t, "https://api.example.com", ep.URL) +} + +func TestServiceExpose_NoDomain(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "web-app", "api", 8080, false) + + require.NoError(t, err) + require.NotNil(t, ep) + assert.False(t, ep.Domain) + assert.Empty(t, ep.URL) +} + +func TestServiceExpose_Error(t *testing.T) { + mock := newMockServiceServer() + mock.exposeErr = status.Errorf(codes.NotFound, "sandbox not found") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "missing", "api", 8080, true) + + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceGet(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // First expose, then get + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + + ep, err := client.Get(context.Background(), "web-app", "api") + + require.NoError(t, err) + require.NotNil(t, ep) + assert.Equal(t, "api", ep.ServiceName) + assert.Equal(t, "web-app", ep.SandboxName) +} + +func TestServiceGet_NotFound(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Get(context.Background(), "web-app", "nonexistent") + + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceGet_Error(t *testing.T) { + mock := newMockServiceServer() + mock.getErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Get(context.Background(), "web-app", "api") + + assert.Nil(t, ep) + require.Error(t, err) +} + +func TestServiceList(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // Expose two services + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + _, err = client.Expose(context.Background(), "web-app", "web", 3000, false) + require.NoError(t, err) + + endpoints, err := client.List(context.Background(), "web-app") + + require.NoError(t, err) + assert.Len(t, endpoints, 2) +} + +func TestServiceList_Empty(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + endpoints, err := client.List(context.Background(), "web-app") + + require.NoError(t, err) + assert.Empty(t, endpoints) +} + +func TestServiceList_WithOptions(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + + endpoints, err := client.List(context.Background(), "web-app", ListOptions{Limit: 10, Offset: 0}) + + require.NoError(t, err) + assert.Len(t, endpoints, 1) +} + +func TestServiceList_Error(t *testing.T) { + mock := newMockServiceServer() + mock.listErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + endpoints, err := client.List(context.Background(), "web-app") + + assert.Nil(t, endpoints) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +func TestServiceDelete(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // Expose then delete + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + + err = client.Delete(context.Background(), "web-app", "api") + + require.NoError(t, err) + + // Verify subsequent Get returns NotFound + ep, err := client.Get(context.Background(), "web-app", "api") + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceDelete_NotFound(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "web-app", "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceDelete_Error(t *testing.T) { + mock := newMockServiceServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "web-app", "api") + + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/ssh.go b/sdk/go/openshell/v1/ssh.go new file mode 100644 index 0000000000..282068e57d --- /dev/null +++ b/sdk/go/openshell/v1/ssh.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// SSHSession represents an SSH session created for a sandbox. +type SSHSession = types.SSHSession + +// tunnelConfig accumulates options for the Tunnel method. +type tunnelConfig struct { + serviceID string +} + +// TunnelOption configures an SSH tunnel opened via [SSHInterface.Tunnel]. +type TunnelOption func(*tunnelConfig) + +// WithTunnelServiceID sets an optional service identifier on the tunnel's +// init frame for audit and correlation purposes. +func WithTunnelServiceID(id string) TunnelOption { + return func(c *tunnelConfig) { + c.serviceID = id + } +} + +// SSHInterface defines operations for managing SSH sessions. +type SSHInterface interface { + // CreateSession creates a new SSH session for the given sandbox. + // The returned SSHSession contains connection details including the + // sensitive Token field that must not be logged. + // + // Note: CreateSession accepts a raw sandbox ID, not a name. + // For name-based access with automatic session lifecycle management, + // prefer [SSHInterface.Tunnel] which resolves sandbox names internally + // and revokes the session on Close. + CreateSession(ctx context.Context, sandboxID string) (*SSHSession, error) + // RevokeSession revokes an existing SSH session by its token. + // Returns true if the session was actively revoked, false if it was + // already expired or not found. + RevokeSession(ctx context.Context, token string) (bool, error) + // Tunnel opens a bidirectional SSH tunnel to the given port inside a + // sandbox. It combines CreateSession and ForwardTcp(SshRelayTarget) + // into a single call with automatic session cleanup on Close. + // + // The sandboxName is resolved to a sandbox ID internally. Port must + // be in the range 1-65535. + // + // Errors: InvalidArgument if port is out of range or sandboxName is + // empty; NotFound if the sandbox does not exist; Unimplemented by + // the fake client; Unavailable if the client is closed. + Tunnel(ctx context.Context, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) +} diff --git a/sdk/go/openshell/v1/ssh_client.go b/sdk/go/openshell/v1/ssh_client.go new file mode 100644 index 0000000000..5d52e02d3c --- /dev/null +++ b/sdk/go/openshell/v1/ssh_client.go @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type sshClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newSSHClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *sshClient { + return &sshClient{ + client: pb.NewOpenShellClient(conn), + sandboxes: sandboxes, + } +} + +func (s *sshClient) CreateSession(ctx context.Context, sandboxID string) (*SSHSession, error) { + resp, err := s.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sandboxID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SSHSessionFromProto(resp), nil +} + +func (s *sshClient) RevokeSession(ctx context.Context, token string) (bool, error) { + resp, err := s.client.RevokeSshSession(ctx, &pb.RevokeSshSessionRequest{ + Token: token, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetRevoked(), nil +} + +func (s *sshClient) Tunnel(ctx context.Context, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) { + if sandboxName == "" { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "sandbox name must not be empty", + } + } + if port == 0 || port > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("port must be in range 1-65535, got %d", port), + } + } + + var cfg tunnelConfig + for _, o := range opts { + o(&cfg) + } + + sandbox, err := s.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + session, err := s.CreateSession(ctx, sandbox.ID) + if err != nil { + return nil, err + } + + revokeSession := true + defer func() { + if revokeSession { + _, _ = s.RevokeSession(context.Background(), session.Token) + } + }() + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := s.client.ForwardTcp(streamCtx) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + initFrame := &pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Init{ + Init: &pb.TcpForwardInit{ + SandboxId: sandbox.ID, + ServiceId: cfg.serviceID, + AuthorizationToken: session.Token, + Target: &pb.TcpForwardInit_Ssh{ + Ssh: &pb.SshRelayTarget{}, + }, + }, + }, + } + + if err := stream.Send(initFrame); err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + conn := &tcpForwardConn{ + stream: stream, + streamCtx: streamCtx, + cancel: cancel, + dataCh: make(chan []byte, 64), + done: make(chan struct{}), + } + go conn.readLoop() + + revokeSession = false + + t := &sshTunnel{ + tcpForwardConn: conn, + revokeFunc: func() { + _, _ = s.RevokeSession(context.Background(), session.Token) + }, + } + + // Auto-revoke the SSH session when the parent context is cancelled. + // The done channel closes after readLoop exits (stream fully drained), + // so Close() won't race with an active stream. + go func() { + <-conn.done + _ = t.Close() + }() + + return t, nil +} + +type sshTunnel struct { + *tcpForwardConn + revokeFunc func() + closeOnce sync.Once + closeErr error +} + +func (t *sshTunnel) Close() error { + t.closeOnce.Do(func() { + t.closeErr = t.tcpForwardConn.Close() + t.revokeFunc() + }) + return t.closeErr +} diff --git a/sdk/go/openshell/v1/ssh_client_test.go b/sdk/go/openshell/v1/ssh_client_test.go new file mode 100644 index 0000000000..e0219567c1 --- /dev/null +++ b/sdk/go/openshell/v1/ssh_client_test.go @@ -0,0 +1,609 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --- Mock server for SSH sessions --- + +type mockSSHServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + sessions map[string]*pb.CreateSshSessionResponse // key: sandbox ID + tokens map[string]bool // track active tokens + revokeCount int // total revocation attempts + createErr error + revokeErr error + forwardErr error + nextToken string // override token for testing + lastInit *pb.TcpForwardInit +} + +func newMockSSHServer() *mockSSHServer { + return &mockSSHServer{ + sessions: make(map[string]*pb.CreateSshSessionResponse), + tokens: make(map[string]bool), + } +} + +func (s *mockSSHServer) CreateSshSession(_ context.Context, req *pb.CreateSshSessionRequest) (*pb.CreateSshSessionResponse, error) { //nolint:revive // proto-generated method name + s.mu.Lock() + defer s.mu.Unlock() + if s.createErr != nil { + return nil, s.createErr + } + + token := "tok-" + req.GetSandboxId() + if s.nextToken != "" { + token = s.nextToken + } + + resp := &pb.CreateSshSessionResponse{ + SandboxId: req.GetSandboxId(), + Token: token, + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + s.sessions[req.GetSandboxId()] = resp + s.tokens[token] = true + return resp, nil +} + +func (s *mockSSHServer) RevokeSshSession(_ context.Context, req *pb.RevokeSshSessionRequest) (*pb.RevokeSshSessionResponse, error) { //nolint:revive // proto-generated method name + s.mu.Lock() + defer s.mu.Unlock() + s.revokeCount++ + if s.revokeErr != nil { + return nil, s.revokeErr + } + + token := req.GetToken() + active, exists := s.tokens[token] + if exists && active { + s.tokens[token] = false + return &pb.RevokeSshSessionResponse{Revoked: true}, nil + } + // Already revoked or not found — not an error, just revoked=false. + return &pb.RevokeSshSessionResponse{Revoked: false}, nil +} + +func (s *mockSSHServer) ForwardTcp(stream grpc.BidiStreamingServer[pb.TcpForwardFrame, pb.TcpForwardFrame]) error { //nolint:revive // proto-generated method name + s.mu.Lock() + earlyErr := s.forwardErr + s.mu.Unlock() + if earlyErr != nil { + return earlyErr + } + + frame, err := stream.Recv() + if err != nil { + return err + } + init := frame.GetInit() + if init == nil { + return status.Errorf(codes.InvalidArgument, "first frame must be init") + } + + s.mu.Lock() + s.lastInit = init + s.mu.Unlock() + + for { + frame, err = stream.Recv() + if err != nil { + return err + } + data := frame.GetData() + if data == nil { + continue + } + if err := stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: data}, + }); err != nil { + return err + } + } +} + +// --- Mock sandbox resolver --- + +type mockSandboxResolver struct { + sandboxes map[string]*Sandbox + err error +} + +func (m *mockSandboxResolver) Create(_ context.Context, _ string, _ *SandboxSpec, _ map[string]string) (*Sandbox, error) { + return nil, nil +} + +func (m *mockSandboxResolver) Get(_ context.Context, name string) (*Sandbox, error) { + if m.err != nil { + return nil, m.err + } + sb, ok := m.sandboxes[name] + if !ok { + return nil, &StatusError{Code: ErrorNotFound, Message: "sandbox not found: " + name} + } + return sb, nil +} + +func (m *mockSandboxResolver) List(_ context.Context, _ ...ListOptions) ([]*Sandbox, error) { + return nil, nil +} +func (m *mockSandboxResolver) Delete(_ context.Context, _ string) error { return nil } +func (m *mockSandboxResolver) AttachProvider(_ context.Context, _, _ string, _ uint64) (*AttachProviderResult, error) { + return nil, nil +} +func (m *mockSandboxResolver) DetachProvider(_ context.Context, _, _ string, _ uint64) (*DetachProviderResult, error) { + return nil, nil +} +func (m *mockSandboxResolver) ListProviders(_ context.Context, _ string) ([]*Provider, error) { + return nil, nil +} +func (m *mockSandboxResolver) WaitReady(_ context.Context, _ string, _ ...WaitOptions) (*Sandbox, error) { + return nil, nil +} +func (m *mockSandboxResolver) Watch(_ context.Context, _ string, _ ...WatchOptions) (WatchInterface[*Sandbox], error) { + return nil, nil +} +func (m *mockSandboxResolver) GetLogs(_ context.Context, _ string, _ ...LogOption) (*LogResult, error) { + return nil, nil +} + +// --- Test setup --- + +func setupSSHTest(t *testing.T, mock *mockSSHServer) (*sshClient, func()) { + t.Helper() + return setupSSHTestWithSandboxes(t, mock, nil) +} + +func setupSSHTestWithSandboxes(t *testing.T, mock *mockSSHServer, sandboxes SandboxInterface) (*sshClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newSSHClient(conn, sandboxes), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestSSHCreateSession(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + session, err := client.CreateSession(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "my-sandbox", session.SandboxID) + assert.Equal(t, "tok-my-sandbox", session.Token) + assert.Equal(t, "gw.example.com", session.GatewayHost) + assert.Equal(t, uint32(2222), session.GatewayPort) + assert.Equal(t, "https", session.GatewayScheme) + assert.Equal(t, "SHA256:abc123", session.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), session.ExpiresAtMs) +} + +func TestSSHCreateSession_Error(t *testing.T) { + mock := newMockSSHServer() + mock.createErr = status.Errorf(codes.NotFound, "sandbox not found") + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + session, err := client.CreateSession(context.Background(), "missing") + + assert.Nil(t, session) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSSHRevokeSession(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + // Create a session first. + session, err := client.CreateSession(context.Background(), "my-sandbox") + require.NoError(t, err) + + // Revoke it — should return true. + revoked, err := client.RevokeSession(context.Background(), session.Token) + + require.NoError(t, err) + assert.True(t, revoked) +} + +func TestSSHRevokeSession_AlreadyRevoked(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + // Create and revoke. + session, err := client.CreateSession(context.Background(), "my-sandbox") + require.NoError(t, err) + _, err = client.RevokeSession(context.Background(), session.Token) + require.NoError(t, err) + + // Revoke again — should return false (already revoked). + revoked, err := client.RevokeSession(context.Background(), session.Token) + + require.NoError(t, err) + assert.False(t, revoked) +} + +func TestSSHRevokeSession_Error(t *testing.T) { + mock := newMockSSHServer() + mock.revokeErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + revoked, err := client.RevokeSession(context.Background(), "some-token") + + assert.False(t, revoked) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +// --- Tunnel tests (T012) --- + +func defaultSandboxResolver() *mockSandboxResolver { + return &mockSandboxResolver{ + sandboxes: map[string]*Sandbox{ + "my-sandbox": {ID: "sb-123", Name: "my-sandbox"}, + }, + } +} + +func TestSSHTunnel_Success(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Round-trip to verify the stream works. + _, err = rwc.Write([]byte("hello")) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + + // Verify init frame sent to server. + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "sb-123", init.GetSandboxId()) + assert.NotEmpty(t, init.GetAuthorizationToken()) + assert.NotNil(t, init.GetSsh(), "target should be SshRelayTarget") +} + +func TestSSHTunnel_WithServiceID(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22, WithTunnelServiceID("audit-svc")) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "audit-svc", init.GetServiceId()) +} + +func TestSSHTunnel_InvalidPort(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwc, err := client.Tunnel(context.Background(), "my-sandbox", tt.port) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + }) + } +} + +func TestSSHTunnel_EmptySandboxName(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "", 22) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSSHTunnel_SandboxNotFound(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "nonexistent", 22) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSSHTunnel_SessionRevokedOnForwardFailure(t *testing.T) { + mock := newMockSSHServer() + mock.forwardErr = status.Errorf(codes.Internal, "forward failed") + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + + if err != nil { + assert.Nil(t, rwc) + } else { + require.NotNil(t, rwc) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + _ = rwc.Close() + } + + // Session should have been revoked since the forward failed. + mock.mu.Lock() + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + assert.True(t, tokenRevoked, "session token should be revoked after forward failure") +} + +func TestSSHTunnel_SessionRevokedOnClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + + // Close the tunnel, which should revoke the session. + err = rwc.Close() + require.NoError(t, err) + + mock.mu.Lock() + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + assert.True(t, tokenRevoked, "session token should be revoked after tunnel close") +} + +func TestSSHTunnel_DoubleClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + + err = rwc.Close() + require.NoError(t, err) + + // Second close should not panic or return a different error. + err = rwc.Close() + assert.NoError(t, err) +} + +func TestSSHTunnel_ContextCancellation(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) + + _ = rwc.Close() +} + +func TestSSHTunnel_TokenNotExposed(t *testing.T) { + mock := newMockSSHServer() + mock.nextToken = "secret-tunnel-token-xyz" + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + repr := fmt.Sprintf("%v", rwc) + assert.NotContains(t, repr, "secret-tunnel-token-xyz", + "token must not leak through the returned value's string representation") +} + +func TestSSHTunnel_ContextCancelRevokesSession(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + require.Eventually(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + for _, active := range mock.tokens { + if !active { + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond, "session token should be revoked after context cancel") +} + +func TestSSHTunnel_ContextCancelThenClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + // Wait for the cleanup goroutine to complete its revocation. + require.Eventually(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + for _, active := range mock.tokens { + if !active { + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond) + + // Explicit Close() after the cleanup goroutine already ran. + err = rwc.Close() + assert.NoError(t, err) + + mock.mu.Lock() + count := mock.revokeCount + mock.mu.Unlock() + assert.Equal(t, 1, count, "exactly one revocation should occur (closeOnce idempotency)") +} + +func TestSSHTunnel_CloseBeforeContextCancel(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + // Explicit Close() first (revokes the session). + err = rwc.Close() + require.NoError(t, err) + + // Cancel context after Close() already completed. + cancel() + + // Verify the cleanup goroutine does not trigger a second revocation. + require.Never(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + return mock.revokeCount > 1 + }, 200*time.Millisecond, 10*time.Millisecond, "cleanup goroutine should not revoke again") + + mock.mu.Lock() + count := mock.revokeCount + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + + assert.True(t, tokenRevoked, "session should be revoked") + assert.Equal(t, 1, count, "exactly one revocation should occur") +} diff --git a/sdk/go/openshell/v1/tcp.go b/sdk/go/openshell/v1/tcp.go new file mode 100644 index 0000000000..94a3c108e2 --- /dev/null +++ b/sdk/go/openshell/v1/tcp.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "net" +) + +// forwardConfig accumulates options for the Forward method. +type forwardConfig struct { + serviceID string +} + +// ForwardOption configures a TCP forward opened via [TCPInterface.Forward]. +type ForwardOption func(*forwardConfig) + +// WithForwardServiceID sets an optional service identifier on the forward's +// init frame for audit and correlation purposes. +func WithForwardServiceID(id string) ForwardOption { + return func(c *forwardConfig) { + c.serviceID = id + } +} + +// listenConfig accumulates options for the Listen method. +type listenConfig struct { + bindAddress string + useSSHTunnel bool + serviceID string +} + +// ListenOption configures a local listener opened via [TCPInterface.Listen]. +type ListenOption func(*listenConfig) + +// WithBindAddress overrides the default local bind address ("127.0.0.1"). +// Pass "0.0.0.0" to accept connections from any interface. +func WithBindAddress(addr string) ListenOption { + return func(c *listenConfig) { + c.bindAddress = addr + } +} + +// WithSSHTunnel routes each accepted connection through an SSH tunnel +// ([SSHInterface.Tunnel]) instead of the default TCP forward +// ([TCPInterface.Forward]). +func WithSSHTunnel() ListenOption { + return func(c *listenConfig) { + c.useSSHTunnel = true + } +} + +// WithListenServiceID sets an optional service identifier on each tunneled +// connection's init frame for audit and correlation purposes. +func WithListenServiceID(id string) ListenOption { + return func(c *listenConfig) { + c.serviceID = id + } +} + +// TCPInterface defines operations for TCP port forwarding to sandboxes. +// Methods accept a sandbox name and resolve it to an ID internally. +type TCPInterface interface { + // Forward opens a bidirectional TCP connection to the given port inside a + // sandbox. The sandbox is identified by name; the SDK resolves it to an + // ID internally. The returned io.ReadWriteCloser wraps the underlying + // gRPC stream; closing it terminates the stream. Port must be in the + // range 1-65535; out-of-range values are rejected client-side with an + // InvalidArgument error before opening the gRPC stream. + // + // The connection respects context cancellation: if ctx is cancelled, + // the stream is closed and pending Read/Write calls return a context error. + Forward(ctx context.Context, sandboxName string, port uint32, opts ...ForwardOption) (io.ReadWriteCloser, error) + + // Listen binds a local TCP port and tunnels every accepted connection to + // the given port inside a sandbox, returning a standard [net.Listener]. + // Each call to Accept on the returned listener establishes a new tunnel + // to the sandbox port, bridging data bidirectionally. + // + // The sandbox is identified by name; the SDK resolves it to an ID + // internally. remotePort must be in the range 1-65535; localPort must be + // in the range 0-65535, where 0 lets the OS assign an ephemeral port + // (discoverable via Addr). + // + // Closing the listener stops accepting new connections, tears down all + // active tunnels, and blocks until all bridge goroutines finish. + // Cancelling ctx triggers the same shutdown behavior. + // + // Errors: + // - InvalidArgument: sandboxName is empty, remotePort is 0 or > 65535, + // or localPort is > 65535 + // - Unimplemented: returned by the fake client + // - Unavailable: client is closed + Listen(ctx context.Context, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (net.Listener, error) +} diff --git a/sdk/go/openshell/v1/tcp_client.go b/sdk/go/openshell/v1/tcp_client.go new file mode 100644 index 0000000000..4d47a08681 --- /dev/null +++ b/sdk/go/openshell/v1/tcp_client.go @@ -0,0 +1,376 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "net" + "strconv" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type tcpClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface + ssh SSHInterface +} + +func newTCPClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface, ssh SSHInterface) *tcpClient { + return &tcpClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes, ssh: ssh} +} + +func (t *tcpClient) Forward(ctx context.Context, sandboxName string, port uint32, opts ...ForwardOption) (io.ReadWriteCloser, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if port == 0 || port > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("port must be in range 1-65535, got %d", port), + } + } + + // Resolve sandbox name to ID — the proto RPC takes SandboxId, not name. + sb, err := t.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var cfg forwardConfig + for _, o := range opts { + o(&cfg) + } + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := t.client.ForwardTcp(streamCtx) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + initFrame := &pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Init{ + Init: &pb.TcpForwardInit{ + SandboxId: sb.ID, + ServiceId: cfg.serviceID, + Target: &pb.TcpForwardInit_Tcp{ + Tcp: &pb.TcpRelayTarget{ + Host: "127.0.0.1", + Port: port, + }, + }, + }, + }, + } + + if err := stream.Send(initFrame); err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + conn := &tcpForwardConn{ + stream: stream, + streamCtx: streamCtx, + cancel: cancel, + dataCh: make(chan []byte, 64), + done: make(chan struct{}), + } + go conn.readLoop() + return conn, nil +} + +func (t *tcpClient) Listen(ctx context.Context, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (net.Listener, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePort == 0 || remotePort > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("port must be in range 1-65535, got %d", remotePort), + } + } + if localPort > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("local port must be in range 0-65535, got %d", localPort), + } + } + + cfg := listenConfig{bindAddress: "127.0.0.1"} + for _, o := range opts { + o(&cfg) + } + + if cfg.useSSHTunnel && t.ssh == nil { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "WithSSHTunnel requires an SSH client, but none is available", + } + } + + addr := net.JoinHostPort(cfg.bindAddress, strconv.FormatUint(uint64(localPort), 10)) + inner, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", addr, err) + } + + listenCtx, cancel := context.WithCancel(ctx) + tl := &tunnelListener{ + inner: inner, + ctx: listenCtx, + cancel: cancel, + tcp: t, + ssh: t.ssh, + sandboxName: sandboxName, + remotePort: remotePort, + cfg: cfg, + } + + // Context-watcher: if the parent context is cancelled, close the listener. + go func() { + <-listenCtx.Done() + _ = tl.Close() + }() + + return tl, nil +} + +// tunnelListener implements net.Listener. It accepts local TCP connections +// and bridges each one to a sandbox port via Forward (or Tunnel in SSH mode). +type tunnelListener struct { + inner net.Listener + ctx context.Context + cancel context.CancelFunc + tcp *tcpClient + ssh SSHInterface + sandboxName string + remotePort uint32 + cfg listenConfig + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeOnce sync.Once + closeErr error +} + +// Accept waits for and returns the next tunneled connection. Each accepted +// local connection triggers a Forward (or SSH Tunnel) to the sandbox port. +// If tunnel setup fails, the local connection is closed and Accept retries. +// Accept returns an error when the listener is closed. +func (tl *tunnelListener) Accept() (net.Conn, error) { + for { + conn, err := tl.inner.Accept() + if err != nil { + return nil, err + } + + // Establish the tunnel to the sandbox. + var tunnel io.ReadWriteCloser + if tl.cfg.useSSHTunnel && tl.ssh != nil { + var tunnelOpts []TunnelOption + if tl.cfg.serviceID != "" { + tunnelOpts = append(tunnelOpts, WithTunnelServiceID(tl.cfg.serviceID)) + } + tunnel, err = tl.ssh.Tunnel(tl.ctx, tl.sandboxName, tl.remotePort, tunnelOpts...) + } else { + var fwdOpts []ForwardOption + if tl.cfg.serviceID != "" { + fwdOpts = append(fwdOpts, WithForwardServiceID(tl.cfg.serviceID)) + } + tunnel, err = tl.tcp.Forward(tl.ctx, tl.sandboxName, tl.remotePort, fwdOpts...) + } + + if err != nil { + _ = conn.Close() + select { + case <-tl.ctx.Done(): + return nil, tl.ctx.Err() + default: + continue + } + } + + // Wrap the local conn + tunnel into a bridged connection and + // spawn goroutines that copy data bidirectionally. + bc := &bridgedConn{ + Conn: conn, + tunnel: tunnel, + } + + tl.mu.Lock() + if tl.closing { + tl.mu.Unlock() + _ = bc.Close() + return nil, net.ErrClosed + } + tl.wg.Add(1) + tl.mu.Unlock() + go tl.bridge(bc) + + return bc, nil + } +} + +// bridge copies data bidirectionally between the local connection and the +// tunnel. It runs in its own goroutine and decrements the WaitGroup on exit. +func (tl *tunnelListener) bridge(bc *bridgedConn) { + defer tl.wg.Done() + + done := make(chan struct{}, 2) + + // Local → tunnel + go func() { + _, _ = io.Copy(bc.tunnel, bc.Conn) + done <- struct{}{} + }() + + // Tunnel → local + go func() { + _, _ = io.Copy(bc.Conn, bc.tunnel) + done <- struct{}{} + }() + + <-done + _ = bc.Close() + <-done +} + +// Close stops the listener from accepting new connections, cancels all +// active tunnels, and blocks until all bridge goroutines finish. +func (tl *tunnelListener) Close() error { + tl.closeOnce.Do(func() { + tl.mu.Lock() + tl.closing = true + tl.mu.Unlock() + tl.closeErr = tl.inner.Close() + tl.cancel() + tl.wg.Wait() + }) + return tl.closeErr +} + +// Addr returns the listener's network address (the bound local address). +func (tl *tunnelListener) Addr() net.Addr { + return tl.inner.Addr() +} + +// bridgedConn wraps a local net.Conn and its associated tunnel. Closing +// the bridgedConn closes both the local connection and the tunnel. +type bridgedConn struct { + net.Conn + tunnel io.ReadWriteCloser + closeOnce sync.Once + closeErr error +} + +// Close closes both the tunnel and the underlying local connection. +// Safe to call concurrently from bridge teardown and consumer code. +func (bc *bridgedConn) Close() error { + bc.closeOnce.Do(func() { + tErr := bc.tunnel.Close() + cErr := bc.Conn.Close() + if tErr != nil { + bc.closeErr = tErr + } else { + bc.closeErr = cErr + } + }) + return bc.closeErr +} + +// tcpForwardConn wraps a bidirectional TcpForwardFrame stream into an +// io.ReadWriteCloser. A background goroutine owns the Recv loop and routes +// data frames to dataCh. Read and Write may be called from different +// goroutines, but multiple concurrent Read callers are not supported. +type tcpForwardConn struct { + stream grpc.BidiStreamingClient[pb.TcpForwardFrame, pb.TcpForwardFrame] + streamCtx context.Context + cancel context.CancelFunc + sendMu sync.Mutex + dataCh chan []byte + done chan struct{} + errOnce sync.Once + err error + buf []byte +} + +func (c *tcpForwardConn) setErr(err error) { + c.errOnce.Do(func() { c.err = err }) +} + +func (c *tcpForwardConn) readLoop() { + defer close(c.dataCh) + defer close(c.done) + for { + frame, err := c.stream.Recv() + if err != nil { + if err != io.EOF { + c.setErr(converter.FromGRPCError(err)) + } + return + } + data := frame.GetData() + if data == nil { + continue + } + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + select { + case c.dataCh <- dataCopy: + case <-c.streamCtx.Done(): + return + } + } +} + +func (c *tcpForwardConn) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if len(c.buf) > 0 { + n := copy(p, c.buf) + c.buf = c.buf[n:] + return n, nil + } + + data, ok := <-c.dataCh + if !ok { + if c.err != nil { + return 0, c.err + } + return 0, io.EOF + } + n := copy(p, data) + if n < len(data) { + c.buf = append(c.buf, data[n:]...) + } + return n, nil +} + +func (c *tcpForwardConn) Write(p []byte) (int, error) { + c.sendMu.Lock() + defer c.sendMu.Unlock() + err := c.stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: p}, + }) + if err != nil { + return 0, converter.FromGRPCError(err) + } + return len(p), nil +} + +func (c *tcpForwardConn) Close() error { + c.sendMu.Lock() + err := c.stream.CloseSend() + c.sendMu.Unlock() + c.cancel() + <-c.done + return err +} diff --git a/sdk/go/openshell/v1/tcp_client_test.go b/sdk/go/openshell/v1/tcp_client_test.go new file mode 100644 index 0000000000..30d5248574 --- /dev/null +++ b/sdk/go/openshell/v1/tcp_client_test.go @@ -0,0 +1,1286 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "net" + "sync" + "testing" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// --- Mock server for TCP forwarding --- + +// mockTCPServer implements the ForwardTcp bidi stream. It records the init +// frame and echoes every data frame back to the client. +type mockTCPServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + lastInit *pb.TcpForwardInit + err error // if non-nil, return this error immediately on stream open +} + +func newMockTCPServer() *mockTCPServer { + return &mockTCPServer{} +} + +func (s *mockTCPServer) ForwardTcp(stream grpc.BidiStreamingServer[pb.TcpForwardFrame, pb.TcpForwardFrame]) error { //nolint:revive // proto-generated method name + s.mu.Lock() + earlyErr := s.err + s.mu.Unlock() + if earlyErr != nil { + return earlyErr + } + + // First frame must be init. + frame, err := stream.Recv() + if err != nil { + return err + } + init := frame.GetInit() + if init == nil { + return status.Errorf(codes.InvalidArgument, "first frame must be init") + } + + s.mu.Lock() + s.lastInit = init + s.mu.Unlock() + + // Echo loop: every data frame is sent back verbatim. + for { + frame, err = stream.Recv() + if err != nil { + return err + } + data := frame.GetData() + if data == nil { + continue + } + if err := stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: data}, + }); err != nil { + return err + } + } +} + +// --- Test setup --- + +func setupTCPTest(t *testing.T, mock *mockTCPServer) (*tcpClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newTCPClient(conn, &stubSandboxResolver{}, nil), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestTCPForward_InitFrame(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Write something to trigger the init frame to be sent (init is sent + // on Forward, before any Write — but we need a brief moment for the + // server to process it). + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + + // Read back the echo. + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "ping", string(buf[:n])) + + // Verify the init frame the server received. + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) + assert.Empty(t, init.GetServiceId(), "service_id should be empty per FR-007a") + assert.Empty(t, init.GetAuthorizationToken()) + + tcp := init.GetTcp() + require.NotNil(t, tcp, "target should be TcpRelayTarget") + assert.Equal(t, "127.0.0.1", tcp.GetHost()) + assert.Equal(t, uint32(8080), tcp.GetPort()) +} + +func TestTCPForward_ReadWrite(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "test-sandbox", 3000) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + // Write data and read the echo back. + payload := []byte("hello, sandbox!") + _, err = rwc.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + // Second round-trip. + _, err = rwc.Write([]byte("round2")) + require.NoError(t, err) + + n, err = rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "round2", string(buf[:n])) +} + +func TestTCPForward_Close(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 5432) + require.NoError(t, err) + + err = rwc.Close() + require.NoError(t, err) + + // Subsequent writes should fail. + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) + + // Subsequent reads should also fail. + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) +} + +func TestTCPForward_PartialRead(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + // Write a payload larger than the read buffer. + payload := []byte("abcdefghijklmnopqrstuvwxyz") + _, err = rwc.Write(payload) + require.NoError(t, err) + + // Read with a small buffer — should get partial data and buffer the rest. + var collected []byte + buf := make([]byte, 10) + for len(collected) < len(payload) { + n, readErr := rwc.Read(buf) + require.NoError(t, readErr) + collected = append(collected, buf[:n]...) + } + assert.Equal(t, payload, collected) +} + +func TestTCPForward_ConcurrentReadWrite(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + const iterations = 50 + var wg sync.WaitGroup + errCh := make(chan error, 2) + wg.Add(2) + + go func() { + defer wg.Done() + for range iterations { + _, writeErr := rwc.Write([]byte("ping")) + if writeErr != nil { + errCh <- writeErr + return + } + } + }() + + go func() { + defer wg.Done() + buf := make([]byte, 64) + for range iterations { + _, readErr := rwc.Read(buf) + if readErr != nil { + errCh <- readErr + return + } + } + }() + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent goroutine failed: %v", err) + } +} + +func TestTCPForward_PortValidation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + {"port way too high", 100000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwc, err := client.Forward(context.Background(), "my-sandbox", tt.port) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "expected InvalidArgument, got: %v", err) + }) + } + + // Valid boundary ports should not get client-side rejection. + for _, port := range []uint32{1, 65535} { + rwc, err := client.Forward(context.Background(), "my-sandbox", port) + require.NoError(t, err, "port %d should be valid", port) + require.NotNil(t, rwc) + _ = rwc.Close() + } +} + +func TestTCPForward_ContextCancellation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Forward(ctx, "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + + // Cancel the context. + cancel() + + // Reads should return an error (context cancelled propagates through the gRPC stream). + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + + // Writes should also fail after context cancellation. + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) +} + +func TestTCPForward_WithServiceID(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080, WithForwardServiceID("audit-svc")) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Trigger a round-trip so the server has processed the init frame. + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "audit-svc", init.GetServiceId()) + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) +} + +func TestTCPForward_WithoutOptions_BackwardCompat(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Empty(t, init.GetServiceId(), "service_id should be empty when no option provided") +} + +func TestTCPForward_ServerError(t *testing.T) { + mock := newMockTCPServer() + mock.err = status.Errorf(codes.Unavailable, "server unavailable") + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + + // The stream opens successfully (gRPC bidi streams don't fail on open), + // but the first write or read should surface the server error. + if err != nil { + // When Send(initFrame) races with the server returning the error, + // the client may get the server status or a transport-level error. + assert.Nil(t, rwc) + require.Error(t, err) + return + } + + // If stream opened, the error surfaces on Read (the server returns it + // immediately, which closes the recv side). + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) +} + +// --- Name-to-ID resolution tests --- + +func TestTCPForward_ResolvesNameToID(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Trigger a round-trip so the server has processed the init frame. + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId(), "Forward should send resolved sandbox ID, not the name") +} + +func TestTCPForward_ResolutionError(t *testing.T) { + mock := newMockTCPServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newTCPClient(conn, resolver, nil) + + rwc, err := client.Forward(context.Background(), "nonexistent", 8080) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestTCPForward_EmptySandboxName(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "", 8080) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Listen tests --- + +func TestTCPListen_ReturnsValidListener(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Addr should return a non-nil TCP address with a non-zero port. + addr := ln.Addr() + require.NotNil(t, addr) + tcpAddr, ok := addr.(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr, got %T", addr) + assert.NotZero(t, tcpAddr.Port, "OS-assigned port should be non-zero") +} + +func TestTCPListen_ConcurrentConnections(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + const numConns = 10 + var wg sync.WaitGroup + errCh := make(chan error, numConns*2) // space for accept + dial errors + + // Accept goroutine: accepts all connections. + wg.Add(1) + go func() { + defer wg.Done() + for range numConns { + _, acceptErr := ln.Accept() + if acceptErr != nil { + errCh <- fmt.Errorf("accept: %w", acceptErr) + return + } + } + }() + + // Dial numConns goroutines, each independently writes and reads. + for i := range numConns { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, dialErr := net.Dial("tcp", ln.Addr().String()) + if dialErr != nil { + errCh <- fmt.Errorf("dial %d: %w", idx, dialErr) + return + } + defer func() { _ = conn.Close() }() + + payload := []byte(fmt.Sprintf("msg-%d", idx)) + _, writeErr := conn.Write(payload) + if writeErr != nil { + errCh <- fmt.Errorf("write %d: %w", idx, writeErr) + return + } + + buf := make([]byte, 256) + n, readErr := conn.Read(buf) + if readErr != nil { + errCh <- fmt.Errorf("read %d: %w", idx, readErr) + return + } + + if string(buf[:n]) != string(payload) { + errCh <- fmt.Errorf("conn %d: expected %q, got %q", idx, payload, buf[:n]) + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Errorf("concurrent connection error: %v", err) + } +} + +func TestTCPListen_EphemeralPort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + // localPort=0 → OS assigns an ephemeral port. + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Addr() should expose the assigned port. + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok) + assert.NotZero(t, tcpAddr.Port, "OS-assigned port should be non-zero") + + // Verify a connection through the ephemeral port actually works. + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + _, _ = ln.Accept() + }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + payload := []byte("ephemeral-test") + _, err = conn.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 256) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) +} + +func TestTCPListen_EmptySandboxName(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestTCPListen_BidirectionalDataFlow(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Accept in a goroutine (Accept blocks until a connection arrives). + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + // The bridge goroutines handle data flow; we just need to keep + // the accepted connection alive until the test completes. + _ = conn + }() + + // Connect to the listener's local address. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Write data through the local connection → tunnel → mock echo → back. + payload := []byte("hello through the tunnel") + _, err = conn.Write(payload) + require.NoError(t, err) + + // Read the echoed data back. + buf := make([]byte, 256) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + // Second round-trip to confirm bidirectionality. + payload2 := []byte("round two") + _, err = conn.Write(payload2) + require.NoError(t, err) + + n, err = conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload2, buf[:n]) +} + +func TestTCPListen_InvalidRemotePort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := client.Listen(context.Background(), "my-sandbox", tt.port, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "expected InvalidArgument, got: %v", err) + }) + } +} + +// --- Graceful shutdown tests --- + +func TestTCPListen_CloseTerminatesConnections(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + + const numConns = 3 + conns := make([]net.Conn, numConns) + + // Accept goroutine. + go func() { + for range numConns { + _, _ = ln.Accept() + } + }() + + // Establish 3 connections. + for i := range numConns { + conns[i], err = net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + // Verify data flows before shutdown. + _, err = conns[i].Write([]byte("pre-close")) + require.NoError(t, err) + buf := make([]byte, 256) + _, err = conns[i].Read(buf) + require.NoError(t, err) + } + + // Close the listener. Per SC-003, this should complete within 5 seconds. + closeDone := make(chan error, 1) + go func() { + closeDone <- ln.Close() + }() + + select { + case closeErr := <-closeDone: + assert.NoError(t, closeErr) + case <-time.After(5 * time.Second): + t.Fatal("Close did not complete within 5 seconds") + } + + // All connections should now return errors on read. + for i, conn := range conns { + buf := make([]byte, 64) + _, readErr := conn.Read(buf) + assert.Error(t, readErr, "connection %d should be closed after listener.Close()", i) + _ = conn.Close() + } +} + +func TestTCPListen_ContextCancellation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + ln, err := client.Listen(ctx, "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + + // Accept a connection so there's an active bridge. + go func() { _, _ = ln.Accept() }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + // Verify data flows before cancellation. + _, err = conn.Write([]byte("before-cancel")) + require.NoError(t, err) + buf := make([]byte, 256) + _, err = conn.Read(buf) + require.NoError(t, err) + + // Cancel the context — should trigger listener close. + cancel() + + // The connection should eventually fail. + // Give the context-watcher goroutine a moment to close the listener. + time.Sleep(50 * time.Millisecond) + + _, err = conn.Write([]byte("after-cancel")) + if err == nil { + // Write may succeed if buffered, but Read should fail. + buf = make([]byte, 64) + _, err = conn.Read(buf) + } + assert.Error(t, err, "connection should fail after context cancellation") + _ = conn.Close() +} + +func TestTCPListen_AcceptOnClosedListener(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + + // Close the listener immediately. + err = ln.Close() + require.NoError(t, err) + + // Accept should return an error. + conn, err := ln.Accept() + assert.Nil(t, conn) + assert.Error(t, err) +} + +// --- Custom bind address tests --- + +func TestTCPListen_WithBindAddress(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + // Verify WithBindAddress is accepted and the listener binds to the + // specified address. We use 127.0.0.1 explicitly since it is the only + // loopback address guaranteed on all platforms (macOS does not enable + // 127.0.0.2+ by default). The default-case assertion below confirms + // that omitting the option also produces 127.0.0.1. + ln, err := client.Listen( + context.Background(), "my-sandbox", 8080, 0, + WithBindAddress("127.0.0.1"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr") + assert.Equal(t, "127.0.0.1", tcpAddr.IP.String(), + "listener should bind to the address specified by WithBindAddress") + + // Also verify that without WithBindAddress the default is 127.0.0.1. + lnDefault, err := client.Listen( + context.Background(), "my-sandbox", 8080, 0, + ) + require.NoError(t, err) + defer func() { _ = lnDefault.Close() }() + + defaultAddr, ok := lnDefault.Addr().(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr") + assert.Equal(t, "127.0.0.1", defaultAddr.IP.String(), + "default bind address should be 127.0.0.1") +} + +// --- SSH tunnel transport tests --- + +// mockSSHClient implements SSHInterface for testing the SSH tunnel path. +type mockSSHClient struct { + mu sync.Mutex + tunnelCalls int +} + +func (m *mockSSHClient) CreateSession(_ context.Context, _ string) (*SSHSession, error) { + return nil, fmt.Errorf("not implemented in mock") +} + +func (m *mockSSHClient) RevokeSession(_ context.Context, _ string) (bool, error) { + return false, fmt.Errorf("not implemented in mock") +} + +// Tunnel returns a pipe that echoes data back, and increments the call counter. +func (m *mockSSHClient) Tunnel(_ context.Context, _ string, _ uint32, _ ...TunnelOption) (io.ReadWriteCloser, error) { + m.mu.Lock() + m.tunnelCalls++ + m.mu.Unlock() + + // Create a pipe-based echo tunnel: read from one end, write back to the other. + clientReader, serverWriter := io.Pipe() + serverReader, clientWriter := io.Pipe() + + // Echo goroutine: copy everything from server reader to server writer. + go func() { + buf := make([]byte, 4096) + for { + n, err := serverReader.Read(buf) + if err != nil { + _ = serverWriter.Close() + return + } + if _, wErr := serverWriter.Write(buf[:n]); wErr != nil { + return + } + } + }() + + return &pipeRWC{Reader: clientReader, Writer: clientWriter, closers: []io.Closer{clientReader, clientWriter, serverReader, serverWriter}}, nil +} + +// pipeRWC wraps a Reader and Writer into an io.ReadWriteCloser. +type pipeRWC struct { + io.Reader + io.Writer + closers []io.Closer +} + +func (p *pipeRWC) Close() error { + for _, c := range p.closers { + _ = c.Close() + } + return nil +} + +func TestTCPListen_WithSSHTunnel(t *testing.T) { + mock := newMockTCPServer() + + // Set up the gRPC connection (needed for tcpClient even though SSH path + // won't use Forward). + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + sshMock := &mockSSHClient{} + client := newTCPClient(conn, &stubSandboxResolver{}, sshMock) + + ln, err := client.Listen( + context.Background(), "my-sandbox", 8080, 0, + WithSSHTunnel(), + WithListenServiceID("ssh-svc"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + // Accept in background. + go func() { _, _ = ln.Accept() }() + + // Connect and send data through the SSH tunnel path. + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + payload := []byte("ssh-tunnel-test") + _, err = c.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 256) + n, err := c.Read(buf) + require.NoError(t, err) + assert.Equal(t, string(payload), string(buf[:n]), + "data should echo through SSH tunnel") + + // Verify that Tunnel was called (not Forward). + sshMock.mu.Lock() + calls := sshMock.tunnelCalls + sshMock.mu.Unlock() + assert.Equal(t, 1, calls, "SSH Tunnel should have been called exactly once") + + // Verify no Forward calls happened on the mock TCP server. + mock.mu.Lock() + initFrame := mock.lastInit + mock.mu.Unlock() + assert.Nil(t, initFrame, "TCP Forward should not have been called when using SSH tunnel") + + _ = c.Close() +} + +func TestTCPListen_CallerSpecifiedPort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + const wantPort = 19876 + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, wantPort) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok) + assert.Equal(t, wantPort, tcpAddr.Port, "listener should bind to the exact port requested") + + go func() { _, _ = ln.Accept() }() + + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = c.Close() }() + + _, err = c.Write([]byte("fixed-port")) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := c.Read(buf) + require.NoError(t, err) + assert.Equal(t, "fixed-port", string(buf[:n])) +} + +func TestTCPListen_ServiceIDPropagated(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0, + WithListenServiceID("test-svc-id"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + go func() { _, _ = ln.Accept() }() + + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + _, err = c.Write([]byte("svc-id-test")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = c.Read(buf) + require.NoError(t, err) + _ = c.Close() + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init, "mock should have received the init frame") + assert.Equal(t, "test-svc-id", init.GetServiceId(), + "Listen should propagate service ID to the Forward init frame") +} + +func TestTCPListen_ConcurrentAccept(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + const numAcceptors = 3 + const numConns = 6 + accepted := make(chan net.Conn, numConns) + var wg sync.WaitGroup + + for range numAcceptors { + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + accepted <- conn + } + }() + } + + for range numConns { + c, dialErr := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, dialErr) + _, err = c.Write([]byte("hello")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = c.Read(buf) + require.NoError(t, err) + _ = c.Close() + } + + _ = ln.Close() + wg.Wait() + close(accepted) + + count := 0 + for conn := range accepted { + _ = conn.Close() + count++ + } + assert.Equal(t, numConns, count, "all connections should be accepted across concurrent acceptors") +} + +func TestTCPListen_WithSSHTunnel_NilSSH(t *testing.T) { + mock := newMockTCPServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + client := newTCPClient(conn, &stubSandboxResolver{}, nil) + _, err = client.Listen(context.Background(), "my-sandbox", 8080, 0, WithSSHTunnel()) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "WithSSHTunnel with nil SSH client should return InvalidArgument") +} + +// --- Failure injection helpers --- + +// flippableResolver extends stubSandboxResolver with a mutex-guarded error +// that can be toggled at runtime (set to nil to stop failing). +type flippableResolver struct { + mu sync.Mutex + failErr error +} + +func (r *flippableResolver) Get(_ context.Context, name string) (*Sandbox, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failErr != nil { + return nil, r.failErr + } + return &Sandbox{ID: "sb-" + name, Name: name}, nil +} + +func (r *flippableResolver) Create(context.Context, string, *SandboxSpec, map[string]string) (*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) List(context.Context, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) Delete(context.Context, string) error { panic("not implemented") } +func (r *flippableResolver) AttachProvider(context.Context, string, string, uint64) (*AttachProviderResult, error) { + panic("not implemented") +} +func (r *flippableResolver) DetachProvider(context.Context, string, string, uint64) (*DetachProviderResult, error) { + panic("not implemented") +} +func (r *flippableResolver) ListProviders(context.Context, string) ([]*Provider, error) { + panic("not implemented") +} +func (r *flippableResolver) WaitReady(context.Context, string, ...WaitOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) Watch(context.Context, string, ...WatchOptions) (WatchInterface[*Sandbox], error) { + panic("not implemented") +} +func (r *flippableResolver) GetLogs(context.Context, string, ...LogOption) (*LogResult, error) { + panic("not implemented") +} + +// --- Failure injection tests --- + +func TestTCPListen_TunnelSetupRetry(t *testing.T) { + mock := newMockTCPServer() + + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + // Use a resolver that fails initially, then succeeds. + resolver := &flippableResolver{ + failErr: &StatusError{Code: ErrorUnavailable, Message: "sandbox unreachable"}, + } + client := newTCPClient(conn, resolver, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inner, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + tl := &tunnelListener{ + inner: inner, + ctx: ctx, + cancel: cancel, + tcp: client, + sandboxName: "my-sandbox", + remotePort: 8080, + cfg: listenConfig{bindAddress: "127.0.0.1"}, + } + + accepted := make(chan net.Conn, 1) + acceptErr := make(chan error, 1) + + go func() { + c, e := tl.Accept() + if e != nil { + acceptErr <- e + return + } + accepted <- c + }() + + // First connection triggers Forward which fails (resolver returns error). + c1, err := net.Dial("tcp", inner.Addr().String()) + require.NoError(t, err) + defer func() { _ = c1.Close() }() + + time.Sleep(50 * time.Millisecond) + + // Clear the error so the next Forward succeeds. + resolver.mu.Lock() + resolver.failErr = nil + resolver.mu.Unlock() + + // Second connection should succeed through the retry loop. + c2, err := net.Dial("tcp", inner.Addr().String()) + require.NoError(t, err) + defer func() { _ = c2.Close() }() + + select { + case c := <-accepted: + require.NotNil(t, c) + _ = c.Close() + case e := <-acceptErr: + t.Fatalf("Accept returned error instead of retrying: %v", e) + case <-time.After(5 * time.Second): + t.Fatal("Accept did not return after retry") + } + + _ = tl.Close() +} + +func TestTCPListen_TunnelFailureWithContextCancel(t *testing.T) { + mock := newMockTCPServer() + + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + // Resolver always fails: Forward will error on every attempt. + resolver := &flippableResolver{ + failErr: &StatusError{Code: ErrorUnavailable, Message: "permanent failure"}, + } + client := newTCPClient(conn, resolver, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + ln, err := client.Listen(ctx, "my-sandbox", 8080, 0) + require.NoError(t, err) + + acceptErr := make(chan error, 1) + go func() { + _, e := ln.Accept() + acceptErr <- e + }() + + // Trigger a connection that will fail tunnel setup. + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + _ = c.Close() + + // Give Accept time to enter the retry loop. + time.Sleep(50 * time.Millisecond) + + // Cancel context: the context-watcher goroutine in Listen() calls + // Close(), which closes the inner listener and unblocks Accept. + cancel() + + select { + case e := <-acceptErr: + require.Error(t, e) + case <-time.After(5 * time.Second): + t.Fatal("Accept did not return after context cancellation") + } +} + +func TestTCPListen_BridgedConnCloseIdempotent(t *testing.T) { + r1, w1 := io.Pipe() + r2, w2 := io.Pipe() + tunnel := &pipeRWC{Reader: r1, Writer: w2, closers: []io.Closer{r1, w1, r2, w2}} + + server, client := net.Pipe() + defer func() { _ = server.Close() }() + + bc := &bridgedConn{ + Conn: client, + tunnel: tunnel, + } + + err1 := bc.Close() + err2 := bc.Close() + + // Second close must not panic and must return the same error. + assert.Equal(t, err1, err2, "idempotent Close should return the same error") +} diff --git a/sdk/go/openshell/v1/types.go b/sdk/go/openshell/v1/types.go new file mode 100644 index 0000000000..012811cabb --- /dev/null +++ b/sdk/go/openshell/v1/types.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// SandboxPhase represents the lifecycle phase of a sandbox. +type SandboxPhase = types.SandboxPhase + +// SandboxPhase values for sandbox lifecycle. +const ( + SandboxProvisioning = types.SandboxProvisioning + SandboxReady = types.SandboxReady + SandboxError = types.SandboxError + SandboxDeleting = types.SandboxDeleting + SandboxUnknown = types.SandboxUnknown +) + +// EventType classifies watch events. +type EventType = types.EventType + +// EventType values for watch events. +const ( + EventAdded = types.EventAdded + EventModified = types.EventModified + EventDeleted = types.EventDeleted + EventError = types.EventError +) + +// StreamType identifies which output stream a chunk belongs to. +type StreamType = types.StreamType + +// StreamType values for exec output. +const ( + StreamStdout = types.StreamStdout + StreamStderr = types.StreamStderr +) + +// TLSConfig holds TLS connection settings. +type TLSConfig = types.TLSConfig + +// RetryPolicy configures automatic retry behavior for failed RPCs. +type RetryPolicy = types.RetryPolicy diff --git a/sdk/go/openshell/v1/types/auth.go b/sdk/go/openshell/v1/types/auth.go new file mode 100644 index 0000000000..90da224739 --- /dev/null +++ b/sdk/go/openshell/v1/types/auth.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "context" + +// AuthProvider supplies per-RPC credentials. It implements the +// grpc credentials.PerRPCCredentials interface. +type AuthProvider interface { + GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) + RequireTransportSecurity() bool +} diff --git a/sdk/go/openshell/v1/types/config.go b/sdk/go/openshell/v1/types/config.go new file mode 100644 index 0000000000..a0edf5b275 --- /dev/null +++ b/sdk/go/openshell/v1/types/config.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// Config holds all settings needed to create a Client. +type Config struct { + Address string + TLS *TLSConfig + Auth AuthProvider + Timeout time.Duration + RetryPolicy *RetryPolicy + Logger Logger +} diff --git a/sdk/go/openshell/v1/types/doc.go b/sdk/go/openshell/v1/types/doc.go new file mode 100644 index 0000000000..c6577baf45 --- /dev/null +++ b/sdk/go/openshell/v1/types/doc.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package types defines all domain data types for the OpenShell SDK v1 API. +// +// These types are the canonical definitions used by both the client layer +// (openshell/v1) and the converter layer (openshell/v1/internal/converter). +// The v1 package re-exports all types via type aliases for backward +// compatibility. +package types diff --git a/sdk/go/openshell/v1/types/errors.go b/sdk/go/openshell/v1/types/errors.go new file mode 100644 index 0000000000..426ff330c8 --- /dev/null +++ b/sdk/go/openshell/v1/types/errors.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "errors" + "fmt" +) + +// ErrorCode classifies SDK errors by their gRPC origin. +type ErrorCode int + +// ErrorCode values for classifying gRPC errors. +const ( + ErrorNotFound ErrorCode = iota + 1 + ErrorAlreadyExists + ErrorUnavailable + ErrorPermissionDenied + ErrorInvalidArgument + ErrorDeadlineExceeded + ErrorCancelled + ErrorInternal + ErrorUnimplemented + ErrorConflict +) + +// String returns the human-readable name of the error code. +func (c ErrorCode) String() string { + switch c { + case ErrorNotFound: + return "NotFound" + case ErrorAlreadyExists: + return "AlreadyExists" + case ErrorUnavailable: + return "Unavailable" + case ErrorPermissionDenied: + return "PermissionDenied" + case ErrorInvalidArgument: + return "InvalidArgument" + case ErrorDeadlineExceeded: + return "DeadlineExceeded" + case ErrorCancelled: + return "Cancelled" + case ErrorInternal: + return "Internal" + case ErrorUnimplemented: + return "Unimplemented" + case ErrorConflict: + return "Conflict" + default: + return fmt.Sprintf("Unknown(%d)", int(c)) + } +} + +// StatusError is the typed error returned by all SDK operations. +type StatusError struct { + Code ErrorCode + Message string + Details map[string]string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// IsNotFound returns true if the error indicates a resource was not found. +func IsNotFound(err error) bool { + return hasCode(err, ErrorNotFound) +} + +// IsAlreadyExists returns true if the error indicates a resource already exists. +func IsAlreadyExists(err error) bool { + return hasCode(err, ErrorAlreadyExists) +} + +// IsUnavailable returns true if the error indicates the service is unavailable. +func IsUnavailable(err error) bool { + return hasCode(err, ErrorUnavailable) +} + +// IsPermissionDenied returns true if the error indicates insufficient permissions. +func IsPermissionDenied(err error) bool { + return hasCode(err, ErrorPermissionDenied) +} + +// IsInvalidArgument returns true if the error indicates an invalid argument. +func IsInvalidArgument(err error) bool { + return hasCode(err, ErrorInvalidArgument) +} + +// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded. +func IsDeadlineExceeded(err error) bool { + return hasCode(err, ErrorDeadlineExceeded) +} + +// IsCancelled returns true if the error indicates the operation was cancelled. +func IsCancelled(err error) bool { + return hasCode(err, ErrorCancelled) +} + +// IsUnimplemented returns true if the error indicates the operation is not implemented. +func IsUnimplemented(err error) bool { + return hasCode(err, ErrorUnimplemented) +} + +// IsConflict returns true if the error indicates a conflict, such as +// optimistic concurrency or an invalid state transition. +func IsConflict(err error) bool { + return hasCode(err, ErrorConflict) +} + +func hasCode(err error, code ErrorCode) bool { + if err == nil { + return false + } + var se *StatusError + if errors.As(err, &se) { + return se.Code == code + } + return false +} diff --git a/sdk/go/openshell/v1/types/exec.go b/sdk/go/openshell/v1/types/exec.go new file mode 100644 index 0000000000..ecc322a2df --- /dev/null +++ b/sdk/go/openshell/v1/types/exec.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// ExecResult holds the collected output of a completed command execution. +type ExecResult struct { + ExitCode int + Stdout []byte + Stderr []byte +} + +// ExecChunk represents a single chunk of output from a streaming command execution. +type ExecChunk struct { + Stream StreamType + Data []byte +} diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go new file mode 100644 index 0000000000..0036183180 --- /dev/null +++ b/sdk/go/openshell/v1/types/health.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// HealthResult holds the result of a health check. +type HealthResult struct { + Healthy bool + Version string +} diff --git a/sdk/go/openshell/v1/types/log.go b/sdk/go/openshell/v1/types/log.go new file mode 100644 index 0000000000..85a62c29db --- /dev/null +++ b/sdk/go/openshell/v1/types/log.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// LogLine represents a single log entry from a sandbox. +type LogLine struct { + // Timestamp is when the log entry was recorded. + Timestamp time.Time + // Level is the log severity level (e.g., "INFO", "WARN", "ERROR"). + Level string + // Target is the log target/module. + Target string + // Message is the log message text. + Message string + // Source is the log source: "gateway" or "sandbox". + Source string + // Fields contains structured key-value fields from the tracing event. + Fields map[string]string +} + +// LogResult contains the result of a GetLogs call. +type LogResult struct { + // Lines contains the log entries in chronological order. + Lines []LogLine + // BufferTotal is the total number of lines in the server's buffer. + BufferTotal uint32 +} + +// logConfig holds configuration for GetLogs calls. +type logConfig struct { + lines uint32 + since time.Time + sources []string + minLevel string +} + +// LogOption configures a GetLogs call. +type LogOption func(*logConfig) + +// WithLogLines sets the maximum number of log lines to return. +func WithLogLines(n uint32) LogOption { + return func(c *logConfig) { + c.lines = n + } +} + +// WithLogSince filters logs to entries at or after the given time. +func WithLogSince(t time.Time) LogOption { + return func(c *logConfig) { + c.since = t + } +} + +// WithLogSources filters logs by source (e.g., "gateway", "sandbox"). +func WithLogSources(sources ...string) LogOption { + return func(c *logConfig) { + c.sources = sources + } +} + +// WithLogMinLevel sets the minimum log level to include. +func WithLogMinLevel(level string) LogOption { + return func(c *logConfig) { + c.minLevel = level + } +} + +// ApplyLogOptions applies options and returns the config. +func ApplyLogOptions(opts []LogOption) logConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg logConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// Lines returns the configured max lines (0 means server default). +func (c *logConfig) Lines() uint32 { + return c.lines +} + +// Since returns the configured since timestamp (zero means no filter). +func (c *logConfig) Since() time.Time { + return c.since +} + +// Sources returns the configured source filters. +func (c *logConfig) Sources() []string { + return c.sources +} + +// MinLevel returns the configured minimum log level. +func (c *logConfig) MinLevel() string { + return c.minLevel +} diff --git a/sdk/go/openshell/v1/types/logger.go b/sdk/go/openshell/v1/types/logger.go new file mode 100644 index 0000000000..351630cb0b --- /dev/null +++ b/sdk/go/openshell/v1/types/logger.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// Logger defines structured logging for the SDK. Compatible with logr.Logger +// and slog.Logger adapters. +type Logger interface { + Debug(msg string, keysAndValues ...any) + Info(msg string, keysAndValues ...any) + Error(err error, msg string, keysAndValues ...any) +} diff --git a/sdk/go/openshell/v1/types/network_policy.go b/sdk/go/openshell/v1/types/network_policy.go new file mode 100644 index 0000000000..99084a8499 --- /dev/null +++ b/sdk/go/openshell/v1/types/network_policy.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// NetworkPolicyRule defines a named network policy rule containing endpoints and binaries. +type NetworkPolicyRule struct { + // Name is the map key for this rule in the sandbox policy. + Name string + // Endpoints lists the network endpoints governed by this rule. + Endpoints []PolicyNetworkEndpoint + // Binaries lists the binaries governed by this rule. + Binaries []PolicyNetworkBinary +} + +// PolicyNetworkEndpoint describes a full network endpoint with its access controls +// as used in sandbox network policy rules. This is distinct from [NetworkEndpoint] +// which is the simplified profile-level endpoint (Host, Port, Protocol only). +type PolicyNetworkEndpoint struct { + Host string + Port uint32 + Ports []uint32 + Protocol string + TLS string + Enforcement string + Access string + Rules []L7Rule + AllowedIPs []string + DenyRules []L7DenyRule + AllowEncodedSlash bool + PersistedQueries string + GraphqlPersistedQueries map[string]GraphqlOperation + GraphqlMaxBodyBytes uint32 + Path string + WebsocketCredentialRewrite bool + RequestBodyCredentialRewrite bool + AdvisorProposed bool +} + +// PolicyNetworkBinary identifies a binary subject to network policy enforcement. +// This is distinct from [NetworkBinary] which is the simplified profile-level binary. +type PolicyNetworkBinary struct { + // Path is the filesystem path to the binary. + Path string +} + +// L7Rule wraps an L7 allow rule. +type L7Rule struct { + // Allow holds the layer-7 allow criteria. + Allow *L7Allow +} + +// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL traffic. +type L7Allow struct { + Method string + Path string + Command string + Query map[string]L7QueryMatcher + OperationType string + OperationName string + Fields []string +} + +// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL traffic. +type L7DenyRule struct { + Method string + Path string + Command string + Query map[string]L7QueryMatcher + OperationType string + OperationName string + Fields []string +} + +// L7QueryMatcher matches query parameters by glob pattern or exact values. +type L7QueryMatcher struct { + Glob string + Any []string +} + +// GraphqlOperation describes a GraphQL operation for persisted-query validation. +type GraphqlOperation struct { + OperationType string + OperationName string + Fields []string +} + +// --- MergeOperation types --- + +// PolicyMergeOperation represents a single atomic policy mutation. +// Exactly one of the pointer fields must be non-nil, modelling the proto oneof. +type PolicyMergeOperation struct { + // AddRule adds a new named network policy rule. + AddRule *AddNetworkRule + // RemoveEndpoint removes a single endpoint from a rule. + RemoveEndpoint *RemoveNetworkEndpoint + // RemoveRule removes an entire named rule. + RemoveRule *RemoveNetworkRule + // AddDenyRules appends deny rules to an endpoint. + AddDenyRules *AddDenyRules + // AddAllowRules appends allow rules to an endpoint. + AddAllowRules *AddAllowRules + // RemoveBinary removes a binary from a rule. + RemoveBinary *RemoveNetworkBinary +} + +// AddNetworkRule adds a named network policy rule with a full rule definition. +type AddNetworkRule struct { + // RuleName is the name key for the rule. + RuleName string + // Rule is the full network policy rule to add. + Rule NetworkPolicyRule +} + +// RemoveNetworkEndpoint removes a specific endpoint from a named rule. +type RemoveNetworkEndpoint struct { + // RuleName is the name of the rule containing the endpoint. + RuleName string + // Host is the endpoint host to remove. + Host string + // Port is the endpoint port to remove. + Port uint32 +} + +// RemoveNetworkRule removes an entire named rule from the policy. +type RemoveNetworkRule struct { + // RuleName is the name of the rule to remove. + RuleName string +} + +// AddDenyRules appends layer-7 deny rules to a specific endpoint. +type AddDenyRules struct { + // Host identifies the target endpoint host. + Host string + // Port identifies the target endpoint port. + Port uint32 + // DenyRules are the deny rules to append. + DenyRules []L7DenyRule +} + +// AddAllowRules appends layer-7 allow rules to a specific endpoint. +type AddAllowRules struct { + // Host identifies the target endpoint host. + Host string + // Port identifies the target endpoint port. + Port uint32 + // Rules are the allow rules to append. + Rules []L7Rule +} + +// RemoveNetworkBinary removes a binary from a named rule. +type RemoveNetworkBinary struct { + // RuleName is the name of the rule containing the binary. + RuleName string + // BinaryPath is the filesystem path of the binary to remove. + BinaryPath string +} diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go new file mode 100644 index 0000000000..c4be36b088 --- /dev/null +++ b/sdk/go/openshell/v1/types/options.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// CreateOptions configures resource creation. +type CreateOptions struct{} + +// GetOptions configures resource retrieval. +type GetOptions struct{} + +// ListOptions configures resource listing with pagination and filtering. +type ListOptions struct { + Limit int + Offset int + LabelSelector string +} + +// DeleteOptions configures resource deletion. +type DeleteOptions struct{} + +// UpdateOptions configures resource updates. +type UpdateOptions struct{} + +// WatchOptions configures watch behavior. +type WatchOptions struct { + TimeoutSeconds int64 + LabelSelector string + // StopOnTerminal causes the watch to close automatically when the sandbox + // reaches a terminal phase (Ready or Error). + StopOnTerminal bool +} + +// WaitOptions configures wait behavior. Use context for timeout control. +type WaitOptions struct { + PollInterval time.Duration +} + +// ExecOptions configures command execution. +type ExecOptions struct { + Env map[string]string + WorkDir string +} diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go new file mode 100644 index 0000000000..f71aeca1dc --- /dev/null +++ b/sdk/go/openshell/v1/types/policy.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// PolicyLoadStatus represents the load state of a policy revision. +type PolicyLoadStatus int + +const ( + // PolicyLoadStatusUnspecified is the default zero value. + PolicyLoadStatusUnspecified PolicyLoadStatus = iota + // PolicyLoadStatusPending means the policy is queued for loading. + PolicyLoadStatusPending + // PolicyLoadStatusLoaded means the policy was successfully loaded. + PolicyLoadStatusLoaded + // PolicyLoadStatusFailed means the policy failed to load. + PolicyLoadStatusFailed + // PolicyLoadStatusSuperseded means a newer revision replaced this one. + PolicyLoadStatusSuperseded +) + +// String returns the human-readable name of the load status. +func (s PolicyLoadStatus) String() string { + switch s { + case PolicyLoadStatusUnspecified: + return "Unspecified" + case PolicyLoadStatusPending: + return "Pending" + case PolicyLoadStatusLoaded: + return "Loaded" + case PolicyLoadStatusFailed: + return "Failed" + case PolicyLoadStatusSuperseded: + return "Superseded" + default: + return "Unknown" + } +} + +// PolicyChunk represents a single proposed policy change in the draft inbox. +type PolicyChunk struct { + // ID is the unique chunk identifier. + ID string + // Status is the approval status: "pending", "approved", "rejected". + Status string + // RuleName is the proposed network_policies map key. + RuleName string + // ProposedRule is the proposed network policy rule. + ProposedRule *NetworkPolicyRule + + // Rationale is a human-readable explanation of why this rule is proposed. + Rationale string + // SecurityNotes contains security concerns flagged by analysis (empty if none). + SecurityNotes string + // Confidence is the analysis confidence score (0.0-1.0). + Confidence float32 + // DenialSummaryIDs lists the IDs of denial summaries that led to this chunk. + DenialSummaryIDs []string + // CreatedAt is when the chunk was created. + CreatedAt time.Time + // DecidedAt is when the user approved/rejected (zero if undecided). + DecidedAt time.Time + // Stage is the recommendation stage: "initial" or "refined". + Stage string + // SupersedesChunkID is the initial chunk ID this refined chunk replaces. + SupersedesChunkID string + // HitCount is how many times this endpoint was seen across denial flush cycles. + HitCount int32 + // FirstSeen is the first time this endpoint was proposed. + FirstSeen time.Time + // LastSeen is the most recent time this endpoint was re-proposed. + LastSeen time.Time + // Binary is the binary path that triggered the denial. + Binary string + // ValidationResult is the prover output from gateway-side static checks. + ValidationResult string + // RejectionReason is the operator-supplied text accompanying a rejection. + RejectionReason string +} + +// DraftPolicy contains the full draft policy state returned by GetDraft. +type DraftPolicy struct { + // Chunks contains the draft policy chunks. + Chunks []PolicyChunk + // RollingSummary is an LLM-generated summary of all analysis. + RollingSummary string + // DraftVersion is the current draft version number. + DraftVersion uint64 + // LastAnalyzedAt is when the last analysis completed. + LastAnalyzedAt time.Time +} + +// SandboxPolicy is the top-level security policy configuration for a sandbox. +// It contains filesystem access rules, Landlock LSM configuration, process +// identity rules, and named network access policies. +type SandboxPolicy struct { + // Version is the policy version number. The server may override this on write. + Version uint32 + // Filesystem controls which directories the sandbox can access. + // Nil means no filesystem policy is specified. + Filesystem *FilesystemPolicy + // Landlock configures the Linux Landlock LSM. + // Nil means no landlock policy is specified. + Landlock *LandlockPolicy + // Process controls the user and group identity for sandboxed processes. + // Nil means no process policy is specified. + Process *ProcessPolicy + // NetworkPolicies contains named network access rules. + // Nil means no network policies are specified; an empty map is distinct from nil. + NetworkPolicies map[string]NetworkPolicyRule +} + +// FilesystemPolicy controls which directories the sandbox can access +// in read-only or read-write mode. +type FilesystemPolicy struct { + // IncludeWorkdir auto-includes the working directory as read-write. + IncludeWorkdir bool + // ReadOnly is the list of read-only directory paths. + // Nil means no read-only directories; an empty slice is distinct from nil. + ReadOnly []string + // ReadWrite is the list of read-write directory paths. + // Nil means no read-write directories; an empty slice is distinct from nil. + ReadWrite []string +} + +// LandlockPolicy configures the Linux Landlock LSM for filesystem restriction enforcement. +type LandlockPolicy struct { + // Compatibility is the compatibility mode (e.g., "best_effort", "hard_requirement"). + Compatibility string +} + +// ProcessPolicy controls the user and group identity under which sandboxed processes execute. +type ProcessPolicy struct { + // RunAsUser is the user name for sandboxed processes. + RunAsUser string + // RunAsGroup is the group name for sandboxed processes. + RunAsGroup string +} + +// SandboxPolicyRevision represents a versioned policy revision for a sandbox. +type SandboxPolicyRevision struct { + // Version is the policy version (monotonically increasing per sandbox). + Version uint32 + // PolicyHash is the SHA-256 hash of the serialized policy payload. + PolicyHash string + // Status is the load status of this revision. + Status PolicyLoadStatus + // LoadError is the error message if status is Failed. + LoadError string + // CreatedAt is when this revision was created. + CreatedAt time.Time + // LoadedAt is when this revision was loaded by the sandbox. + LoadedAt time.Time + // Policy is the typed security policy for this revision. Nil when not requested or absent. + Policy *SandboxPolicy +} + +// PolicyStatusResult contains the status of a sandbox's policy. +type PolicyStatusResult struct { + // Revision is the queried policy revision. + Revision SandboxPolicyRevision + // ActiveVersion is the currently active (loaded) policy version. + ActiveVersion uint32 +} + +// ApproveResult contains the result of approving a single draft chunk. +type ApproveResult struct { + // PolicyVersion is the new policy version after merge. + PolicyVersion uint32 + // PolicyHash is the SHA-256 hash of the new policy. + PolicyHash string +} + +// ApproveAllResult contains the result of approving all draft chunks. +type ApproveAllResult struct { + // PolicyVersion is the new policy version after merge. + PolicyVersion uint32 + // PolicyHash is the SHA-256 hash of the new policy. + PolicyHash string + // ChunksApproved is the number of chunks approved. + ChunksApproved uint32 + // ChunksSkipped is the number of chunks skipped (security-flagged). + ChunksSkipped uint32 +} + +// UndoResult contains the result of undoing a draft chunk approval. +type UndoResult struct { + // PolicyVersion is the new policy version after removal. + PolicyVersion uint32 + // PolicyHash is the SHA-256 hash of the updated policy. + PolicyHash string +} + +// ClearResult contains the result of clearing all draft chunks. +type ClearResult struct { + // ChunksCleared is the number of chunks cleared. + ChunksCleared uint32 +} + +// DraftHistoryEntry represents a single event in the draft policy history. +type DraftHistoryEntry struct { + // Timestamp is when the event occurred. + Timestamp time.Time + // EventType is the event type (e.g., "approved", "rejected", "cleared"). + EventType string + // Description is a human-readable description. + Description string + // ChunkID is the associated chunk ID (if applicable). + ChunkID string +} + +// getDraftConfig holds configuration for GetDraft calls. +type getDraftConfig struct { + statusFilter string +} + +// GetDraftOption configures a GetDraft call. +type GetDraftOption func(*getDraftConfig) + +// WithStatusFilter filters draft chunks by approval status. +func WithStatusFilter(status string) GetDraftOption { + return func(c *getDraftConfig) { + c.statusFilter = status + } +} + +// ApplyGetDraftOptions applies options and returns the config. +func ApplyGetDraftOptions(opts []GetDraftOption) getDraftConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg getDraftConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// StatusFilter returns the configured status filter. +func (c *getDraftConfig) StatusFilter() string { + return c.statusFilter +} + +// approveAllConfig holds configuration for ApproveAllDraftChunks calls. +type approveAllConfig struct { + includeSecurityFlagged bool +} + +// ApproveAllOption configures an ApproveAllDraftChunks call. +type ApproveAllOption func(*approveAllConfig) + +// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval. +func WithIncludeSecurityFlagged() ApproveAllOption { + return func(c *approveAllConfig) { + c.includeSecurityFlagged = true + } +} + +// ApplyApproveAllOptions applies options and returns the config. +func ApplyApproveAllOptions(opts []ApproveAllOption) approveAllConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg approveAllConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// IncludeSecurityFlagged returns whether security-flagged chunks are included. +func (c *approveAllConfig) IncludeSecurityFlagged() bool { + return c.includeSecurityFlagged +} + +// getStatusConfig holds configuration for GetStatus calls. +type getStatusConfig struct { + version uint32 +} + +// GetStatusOption configures a GetStatus call. +type GetStatusOption func(*getStatusConfig) + +// WithVersion queries a specific policy version instead of the latest. +func WithVersion(version uint32) GetStatusOption { + return func(c *getStatusConfig) { + c.version = version + } +} + +// ApplyGetStatusOptions applies options and returns the config. +func ApplyGetStatusOptions(opts []GetStatusOption) getStatusConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg getStatusConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// Version returns the configured version (0 means latest). +func (c *getStatusConfig) Version() uint32 { + return c.version +} + +// listPolicyConfig holds configuration for List calls. +type listPolicyConfig struct { + limit uint32 + offset uint32 +} + +// ListPolicyOption configures a List call. +type ListPolicyOption func(*listPolicyConfig) + +// WithLimit sets the maximum number of revisions to return. +func WithLimit(limit uint32) ListPolicyOption { + return func(c *listPolicyConfig) { + c.limit = limit + } +} + +// WithOffset sets the pagination offset. +func WithOffset(offset uint32) ListPolicyOption { + return func(c *listPolicyConfig) { + c.offset = offset + } +} + +// ApplyListPolicyOptions applies options and returns the config. +func ApplyListPolicyOptions(opts []ListPolicyOption) listPolicyConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg listPolicyConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// Limit returns the configured limit (0 means server default). +func (c *listPolicyConfig) Limit() uint32 { + return c.limit +} + +// Offset returns the configured offset. +func (c *listPolicyConfig) Offset() uint32 { + return c.offset +} diff --git a/sdk/go/openshell/v1/types/profile.go b/sdk/go/openshell/v1/types/profile.go new file mode 100644 index 0000000000..9d57c6d920 --- /dev/null +++ b/sdk/go/openshell/v1/types/profile.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// ProfileCategory classifies a provider profile. +type ProfileCategory string + +// ProfileCategory values. +const ( + ProfileCategoryOther ProfileCategory = "Other" + ProfileCategoryInference ProfileCategory = "Inference" + ProfileCategoryAgent ProfileCategory = "Agent" + ProfileCategorySourceControl ProfileCategory = "SourceControl" + ProfileCategoryMessaging ProfileCategory = "Messaging" + ProfileCategoryData ProfileCategory = "Data" + ProfileCategoryKnowledge ProfileCategory = "Knowledge" +) + +// ProviderProfile defines a provider type template with credentials schema, +// endpoints, binaries, and discovery configuration. +type ProviderProfile struct { + ID string + DisplayName string + Description string + Category ProfileCategory + Credentials []ProfileCredential + Endpoints []NetworkEndpoint + Binaries []NetworkBinary + InferenceCapable bool + Discovery ProfileDiscovery + ResourceVersion uint64 +} + +// ProfileCredential defines a single credential required by a provider profile. +type ProfileCredential struct { + Name string + Description string + Required bool + Secret bool +} + +// NetworkEndpoint describes a network endpoint provided by a profile. +type NetworkEndpoint struct { + Host string + Port uint32 + Protocol string +} + +// NetworkBinary describes a binary artifact provided by a profile. +type NetworkBinary struct { + Path string +} + +// ProfileDiscovery holds local discovery configuration for a profile. +type ProfileDiscovery struct { + Credentials []string +} + +// ProfileImportItem is an item submitted for profile import or lint validation. +type ProfileImportItem struct { + Profile ProviderProfile + Source string +} + +// ProfileDiagnostic is a validation finding from Import, Update, or Lint. +type ProfileDiagnostic struct { + Source string + ProfileID string + Field string + Message string + Severity string +} + +// ImportResult holds the result of a profile import operation. +type ImportResult struct { + Diagnostics []ProfileDiagnostic + Profiles []ProviderProfile + Imported bool +} + +// UpdateResult holds the result of a profile update operation. +type UpdateResult struct { + Diagnostics []ProfileDiagnostic + Profile *ProviderProfile + Updated bool +} + +// LintResult holds the result of a profile lint operation. +type LintResult struct { + Diagnostics []ProfileDiagnostic + Valid bool +} diff --git a/sdk/go/openshell/v1/types/provider.go b/sdk/go/openshell/v1/types/provider.go new file mode 100644 index 0000000000..f7ea51dc7b --- /dev/null +++ b/sdk/go/openshell/v1/types/provider.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// Provider represents an AI provider registration. +type Provider struct { + ID string + Name string + Type string + CreatedAt time.Time + Labels map[string]string + ResourceVersion uint64 + Spec ProviderSpec +} + +// ProviderSpec holds provider-specific configuration and credentials. +type ProviderSpec struct { + Credentials map[string]string + Config map[string]string + CredentialExpiresAt map[string]time.Time +} diff --git a/sdk/go/openshell/v1/types/refresh.go b/sdk/go/openshell/v1/types/refresh.go new file mode 100644 index 0000000000..fc73315a01 --- /dev/null +++ b/sdk/go/openshell/v1/types/refresh.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// RefreshStrategy describes how credentials are refreshed. +type RefreshStrategy string + +// RefreshStrategy values. +const ( + RefreshStrategyStatic RefreshStrategy = "Static" + RefreshStrategyExternal RefreshStrategy = "External" + RefreshStrategyOAuth2RefreshToken RefreshStrategy = "OAuth2RefreshToken" + RefreshStrategyOAuth2ClientCredentials RefreshStrategy = "OAuth2ClientCredentials" + RefreshStrategyGoogleServiceAccountJWT RefreshStrategy = "GoogleServiceAccountJWT" +) + +// RefreshStatus reports the current state of credential refresh for a specific +// provider credential. +type RefreshStatus struct { + ProviderName string + ProviderID string + CredentialKey string + Strategy RefreshStrategy + Status string + ExpiresAt time.Time + NextRefreshAt time.Time + LastRefreshAt time.Time + LastError string +} + +// RefreshConfig holds configuration parameters for gateway-owned credential +// refresh on a provider credential. +type RefreshConfig struct { + Provider string + CredentialKey string + Strategy RefreshStrategy + Material map[string]string + SecretMaterialKeys []string + ExpiresAt *time.Time +} diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go new file mode 100644 index 0000000000..4404b8716e --- /dev/null +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// Sandbox represents a sandbox instance. +type Sandbox struct { + ID string + Name string + CreatedAt time.Time + Labels map[string]string + ResourceVersion uint64 + Spec SandboxSpec + Status SandboxStatus +} + +// SandboxSpec holds the desired state of a sandbox. +type SandboxSpec struct { + LogLevel string + Environment map[string]string + Template *SandboxTemplate + Providers []string + GPUCount *uint32 + // Policy is the security policy for the sandbox. Nil means no policy specified. + Policy *SandboxPolicy +} + +// SandboxTemplate defines the container template for a sandbox. +type SandboxTemplate struct { + Image string + RuntimeClassName string + AgentSocket string + Labels map[string]string + Annotations map[string]string + Environment map[string]string + UserNamespaces *bool +} + +// SandboxStatus holds the observed state of a sandbox. +type SandboxStatus struct { + SandboxName string + AgentPod string + AgentFd string + SandboxFd string + Phase SandboxPhase + Conditions []SandboxCondition + CurrentPolicyVersion uint32 +} + +// SandboxCondition describes an observed condition of a sandbox. +type SandboxCondition struct { + Type string + Status string + Reason string + Message string + LastTransitionTime string +} + +// AttachProviderResult holds the result of attaching a provider to a sandbox. +type AttachProviderResult struct { + Sandbox *Sandbox + Attached bool +} + +// DetachProviderResult holds the result of detaching a provider from a sandbox. +type DetachProviderResult struct { + Sandbox *Sandbox + Detached bool +} diff --git a/sdk/go/openshell/v1/types/service.go b/sdk/go/openshell/v1/types/service.go new file mode 100644 index 0000000000..c25cb9b63d --- /dev/null +++ b/sdk/go/openshell/v1/types/service.go @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// ServiceEndpoint represents an exposed HTTP service on a sandbox. +type ServiceEndpoint struct { + ID string + SandboxID string + SandboxName string + ServiceName string + TargetPort uint32 + Domain bool + URL string +} diff --git a/sdk/go/openshell/v1/types/setting.go b/sdk/go/openshell/v1/types/setting.go new file mode 100644 index 0000000000..341f245b64 --- /dev/null +++ b/sdk/go/openshell/v1/types/setting.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// SettingValueType identifies which typed field of a SettingValue is active. +type SettingValueType string + +// SettingValueType constants. +const ( + SettingValueString SettingValueType = "string" + SettingValueBool SettingValueType = "bool" + SettingValueInt SettingValueType = "int" + SettingValueBytes SettingValueType = "bytes" +) + +// SettingValue is a typed setting value supporting string, bool, int64, and bytes variants. +// The Type field indicates which value field is populated. +type SettingValue struct { + Type SettingValueType + StringVal string + BoolVal bool + IntVal int64 + BytesVal []byte +} + +// SettingScope indicates whether a setting is controlled at sandbox or global level. +type SettingScope string + +// SettingScope constants. +const ( + SettingScopeUnspecified SettingScope = "" + SettingScopeSandbox SettingScope = "sandbox" + SettingScopeGlobal SettingScope = "global" +) + +// PolicySource indicates the source of the policy payload in a SandboxConfig response. +type PolicySource string + +// PolicySource constants. +const ( + PolicySourceUnspecified PolicySource = "" + PolicySourceSandbox PolicySource = "sandbox" + PolicySourceGlobal PolicySource = "global" +) + +// EffectiveSetting is a setting value paired with the scope it was resolved from. +type EffectiveSetting struct { + Value SettingValue + Scope SettingScope +} + +// SandboxConfig represents the full configuration state of a sandbox, +// including policy, effective settings, and revision metadata. +type SandboxConfig struct { + // Policy is the typed security policy for this sandbox. Nil means no policy in the response. + Policy *SandboxPolicy + // PolicyVersion is monotonically increasing per sandbox. + PolicyVersion uint32 + // PolicyHash is the SHA-256 of the serialized policy payload. + PolicyHash string + // Settings is the effective settings resolved for this sandbox. + Settings map[string]EffectiveSetting + // ConfigRevision is the fingerprint for effective config (policy + settings). + ConfigRevision uint64 + // PolicySource indicates where the policy came from (sandbox or global). + PolicySource PolicySource + // GlobalPolicyVersion is the global policy version (0 if not applicable). + GlobalPolicyVersion uint32 + // ProviderEnvRevision is the fingerprint for provider credential inputs. + ProviderEnvRevision uint64 +} + +// GatewayConfig represents gateway-global settings. +type GatewayConfig struct { + // Settings is the global settings map. + Settings map[string]SettingValue + // SettingsRevision is a monotonically increasing revision for gateway-global settings. + SettingsRevision uint64 +} + +// ConfigUpdate represents a configuration mutation request. +// For sandbox-scoped updates, set Name to the sandbox name. +// For global-scoped updates, set Global to true. +type ConfigUpdate struct { + // Name is the sandbox name (required for sandbox-scoped updates). + Name string + // Policy is the typed security policy for a full policy replacement. Nil means no policy change. + Policy *SandboxPolicy + // SettingKey is a single setting key to mutate. + SettingKey string + // SettingValue is the setting value for upsert. Nil means no value change. + SettingValue *SettingValue + // DeleteSetting deletes the setting key when true. + DeleteSetting bool + // Global applies the update at gateway-global scope when true. + Global bool + // MergeOperations is a list of typed policy merge operations. + MergeOperations []PolicyMergeOperation + // ExpectedResourceVersion is for optimistic concurrency (0 = skip check). + ExpectedResourceVersion uint64 +} + +// ConfigUpdateResult holds the result of a configuration update operation. +// Named ConfigUpdateResult to avoid collision with profile.UpdateResult. +type ConfigUpdateResult struct { + // Version is the assigned policy version. + Version uint32 + // PolicyHash is the SHA-256 of the serialized policy. + PolicyHash string + // SettingsRevision is the settings revision for the modified scope. + SettingsRevision uint64 + // Deleted is true when a setting delete removed an existing key. + Deleted bool +} diff --git a/sdk/go/openshell/v1/types/ssh.go b/sdk/go/openshell/v1/types/ssh.go new file mode 100644 index 0000000000..ec5e58e518 --- /dev/null +++ b/sdk/go/openshell/v1/types/ssh.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "fmt" + +// SSHSession represents an SSH session created for a sandbox. +// The Token field is sensitive and MUST NOT be logged or included in error messages. +// The String() method redacts the token to prevent accidental exposure via fmt or logging. +type SSHSession struct { + // SandboxID is the sandbox this session connects to. + SandboxID string + // Token is the session token for gateway tunnel authentication. + // This is a sensitive credential — treat it like an API key. + Token string + // GatewayHost is the host for SSH proxy connection. + GatewayHost string + // GatewayPort is the gateway port (1-65535). + GatewayPort uint32 + // GatewayScheme is the gateway protocol scheme ("http" or "https"). + GatewayScheme string + // HostKeyFingerprint is the optional host key fingerprint. + HostKeyFingerprint string + // ExpiresAtMs is the session expiry in milliseconds since epoch. + // Zero means no expiry. + ExpiresAtMs int64 +} + +// String returns a human-readable representation with the Token redacted. +func (s SSHSession) String() string { + return fmt.Sprintf("SSHSession{SandboxID:%s, GatewayHost:%s, GatewayPort:%d, GatewayScheme:%s, Token:[REDACTED]}", + s.SandboxID, s.GatewayHost, s.GatewayPort, s.GatewayScheme) +} diff --git a/sdk/go/openshell/v1/types/types.go b/sdk/go/openshell/v1/types/types.go new file mode 100644 index 0000000000..cc59f86117 --- /dev/null +++ b/sdk/go/openshell/v1/types/types.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// SandboxPhase represents the lifecycle phase of a sandbox. +type SandboxPhase string + +// SandboxPhase values for sandbox lifecycle. +const ( + SandboxProvisioning SandboxPhase = "Provisioning" + SandboxReady SandboxPhase = "Ready" + SandboxError SandboxPhase = "Error" + SandboxDeleting SandboxPhase = "Deleting" + SandboxUnknown SandboxPhase = "Unknown" +) + +// EventType classifies watch events. +type EventType string + +// EventType values for watch events. +const ( + EventAdded EventType = "ADDED" + EventModified EventType = "MODIFIED" + EventDeleted EventType = "DELETED" + EventError EventType = "ERROR" +) + +// StreamType identifies which output stream a chunk belongs to. +type StreamType string + +// StreamType values for exec output. +const ( + StreamStdout StreamType = "stdout" + StreamStderr StreamType = "stderr" +) + +// TLSConfig holds TLS connection settings. +type TLSConfig struct { + CertFile string + KeyFile string + CAFile string + Insecure bool +} + +// RetryPolicy configures automatic retry behavior for failed RPCs. +type RetryPolicy struct { + MaxRetries int + InitialWait time.Duration + MaxWait time.Duration +} diff --git a/sdk/go/openshell/v1/types/watch.go b/sdk/go/openshell/v1/types/watch.go new file mode 100644 index 0000000000..d97e07e3d7 --- /dev/null +++ b/sdk/go/openshell/v1/types/watch.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// Event represents a watch event carrying a resource that changed. +type Event[T any] struct { + Type EventType + Object T +} + +// WatchInterface delivers a stream of typed events. Modeled after +// k8s.io/apimachinery/pkg/watch.Interface. +type WatchInterface[T any] interface { + ResultChan() <-chan Event[T] + Stop() +} diff --git a/sdk/go/openshell/v1/types_reexport.go b/sdk/go/openshell/v1/types_reexport.go new file mode 100644 index 0000000000..b92aa80bc8 --- /dev/null +++ b/sdk/go/openshell/v1/types_reexport.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- Network Policy types --- + +// NetworkPolicyRule defines a named network policy rule containing endpoints and binaries. +type NetworkPolicyRule = types.NetworkPolicyRule + +// PolicyNetworkEndpoint describes a full network endpoint in a sandbox network policy rule. +type PolicyNetworkEndpoint = types.PolicyNetworkEndpoint + +// PolicyNetworkBinary identifies a binary subject to network policy enforcement. +type PolicyNetworkBinary = types.PolicyNetworkBinary + +// L7Rule wraps an L7 allow rule. +type L7Rule = types.L7Rule + +// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL traffic. +type L7Allow = types.L7Allow + +// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL traffic. +type L7DenyRule = types.L7DenyRule + +// L7QueryMatcher matches query parameters by glob pattern or exact values. +type L7QueryMatcher = types.L7QueryMatcher + +// GraphqlOperation describes a GraphQL operation for persisted-query validation. +type GraphqlOperation = types.GraphqlOperation + +// --- MergeOperation types --- + +// PolicyMergeOperation represents a single atomic policy mutation. +type PolicyMergeOperation = types.PolicyMergeOperation + +// AddNetworkRule adds a named network policy rule with a full rule definition. +type AddNetworkRule = types.AddNetworkRule + +// RemoveNetworkEndpoint removes a specific endpoint from a named rule. +type RemoveNetworkEndpoint = types.RemoveNetworkEndpoint + +// RemoveNetworkRule removes an entire named rule from the policy. +type RemoveNetworkRule = types.RemoveNetworkRule + +// AddDenyRules appends layer-7 deny rules to a specific endpoint. +type AddDenyRules = types.AddDenyRules + +// AddAllowRules appends layer-7 allow rules to a specific endpoint. +type AddAllowRules = types.AddAllowRules + +// RemoveNetworkBinary removes a binary from a named rule. +type RemoveNetworkBinary = types.RemoveNetworkBinary diff --git a/sdk/go/openshell/v1/watch.go b/sdk/go/openshell/v1/watch.go new file mode 100644 index 0000000000..696d8a9bd1 --- /dev/null +++ b/sdk/go/openshell/v1/watch.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Event represents a watch event carrying a resource that changed. +type Event[T any] = types.Event[T] + +// WatchInterface delivers a stream of typed events. Modeled after +// k8s.io/apimachinery/pkg/watch.Interface. +type WatchInterface[T any] = types.WatchInterface[T] + +type watcher[T any] struct { + result chan Event[T] + done chan struct{} + cancel context.CancelFunc + stopOnce sync.Once +} + +func newWatcher[T any](ch chan Event[T], cancel context.CancelFunc) *watcher[T] { + return &watcher[T]{ + result: ch, + done: make(chan struct{}), + cancel: cancel, + } +} + +func (w *watcher[T]) ResultChan() <-chan Event[T] { + return w.result +} + +func (w *watcher[T]) Stop() { + w.stopOnce.Do(func() { + close(w.done) + if w.cancel != nil { + w.cancel() + } + }) +} diff --git a/sdk/go/openshell/v1/watch_test.go b/sdk/go/openshell/v1/watch_test.go new file mode 100644 index 0000000000..1d6d5dc07a --- /dev/null +++ b/sdk/go/openshell/v1/watch_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestWatcher creates a watcher with a simulated producer goroutine that +// forwards events from src to the watcher's channel and closes it when the +// producer finishes or Stop is called. +func newTestWatcher(src <-chan Event[string]) *watcher[string] { + ch := make(chan Event[string], 10) + w := newWatcher(ch, nil) + go func() { + defer close(ch) + for { + select { + case ev, ok := <-src: + if !ok { + return + } + select { + case ch <- ev: + case <-w.done: + return + } + case <-w.done: + return + } + } + }() + return w +} + +// --- T038: WatchInterface event delivery, Stop, and error handling --- + +func TestWatcher_DeliversEvents(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventAdded, Object: "sandbox-1"} + src <- Event[string]{Type: EventModified, Object: "sandbox-1"} + + resultCh := w.ResultChan() + + ev1 := <-resultCh + assert.Equal(t, EventAdded, ev1.Type) + assert.Equal(t, "sandbox-1", ev1.Object) + + ev2 := <-resultCh + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, "sandbox-1", ev2.Object) +} + +func TestWatcher_StopClosesChannel(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + w.Stop() + + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should be closed after Stop") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } +} + +func TestWatcher_StopIsIdempotent(_ *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + w.Stop() + w.Stop() // must not panic +} + +func TestWatcher_ErrorEvent(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventError, Object: "error details"} + + ev := <-w.ResultChan() + assert.Equal(t, EventError, ev.Type) + assert.Equal(t, "error details", ev.Object) +} + +func TestWatcher_ChannelClosesWhenSourceEnds(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventAdded, Object: "sb-1"} + close(src) + + ev := <-w.ResultChan() + require.Equal(t, "sb-1", ev.Object) + + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close when source ends") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel to close") + } +} + +func TestWatcher_DrainAfterStop(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventAdded, Object: "sb-1"} + + ev := <-w.ResultChan() + require.Equal(t, "sb-1", ev.Object) + + w.Stop() + + timeout := time.After(time.Second) + for { + select { + case _, ok := <-w.ResultChan(): + if !ok { + return // success: channel closed + } + case <-timeout: + t.Fatal("timed out waiting for channel to close after Stop") + } + } +} diff --git a/sdk/go/proto/UPSTREAM_VERSION b/sdk/go/proto/UPSTREAM_VERSION new file mode 100644 index 0000000000..0ab4d79066 --- /dev/null +++ b/sdk/go/proto/UPSTREAM_VERSION @@ -0,0 +1 @@ +29ce6a704cba222c29b5e0d73b90280cf5ed3b9f diff --git a/sdk/go/proto/datamodel.proto b/sdk/go/proto/datamodel.proto new file mode 100644 index 0000000000..f92d7b7a36 --- /dev/null +++ b/sdk/go/proto/datamodel.proto @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.datamodel.v1; + +// Kubernetes-style metadata shared by all top-level OpenShell domain objects. +// +// This structure provides consistent metadata (identity, labels, timestamps, +// resource versioning) across Sandbox, Provider, SshSession, and other resources. +message ObjectMeta { + // Stable object ID generated by the gateway. + string id = 1; + + // Human-readable object name (unique per object type). + string name = 2; + + // Milliseconds since Unix epoch when the object was created. + int64 created_at_ms = 3; + + // Key-value labels for filtering and organization. + // Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment. + map labels = 4; + + // Optimistic concurrency control version. + // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. + uint64 resource_version = 5; +} + +// Provider model stored by OpenShell. +message Provider { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + ObjectMeta metadata = 1; + // Canonical provider type slug (for example: "claude", "gitlab"). + string type = 2; + // Secret values used for authentication. + map credentials = 3; + // Non-secret provider configuration. + map config = 4; + // Expiration timestamps for credential values, keyed by credential/env var + // name. A zero or missing value means the credential does not expire. + map credential_expires_at_ms = 5; +} diff --git a/sdk/go/proto/datamodelv1/datamodel.pb.go b/sdk/go/proto/datamodelv1/datamodel.pb.go new file mode 100644 index 0000000000..04e6848414 --- /dev/null +++ b/sdk/go/proto/datamodelv1/datamodel.pb.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.6 +// source: datamodel.proto + +package datamodelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Kubernetes-style metadata shared by all top-level OpenShell domain objects. +// +// This structure provides consistent metadata (identity, labels, timestamps, +// resource versioning) across Sandbox, Provider, SshSession, and other resources. +type ObjectMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable object ID generated by the gateway. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Human-readable object name (unique per object type). + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Milliseconds since Unix epoch when the object was created. + CreatedAtMs int64 `protobuf:"varint,3,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // Key-value labels for filtering and organization. + // Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment. + Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optimistic concurrency control version. + // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. + ResourceVersion uint64 `protobuf:"varint,5,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ObjectMeta) Reset() { + *x = ObjectMeta{} + mi := &file_datamodel_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ObjectMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ObjectMeta) ProtoMessage() {} + +func (x *ObjectMeta) ProtoReflect() protoreflect.Message { + mi := &file_datamodel_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ObjectMeta.ProtoReflect.Descriptor instead. +func (*ObjectMeta) Descriptor() ([]byte, []int) { + return file_datamodel_proto_rawDescGZIP(), []int{0} +} + +func (x *ObjectMeta) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ObjectMeta) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ObjectMeta) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *ObjectMeta) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *ObjectMeta) GetResourceVersion() uint64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +// Provider model stored by OpenShell. +type Provider struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + Metadata *ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Canonical provider type slug (for example: "claude", "gitlab"). + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + // Secret values used for authentication. + Credentials map[string]string `protobuf:"bytes,3,rep,name=credentials,proto3" json:"credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Non-secret provider configuration. + Config map[string]string `protobuf:"bytes,4,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Expiration timestamps for credential values, keyed by credential/env var + // name. A zero or missing value means the credential does not expire. + CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,5,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Provider) Reset() { + *x = Provider{} + mi := &file_datamodel_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Provider) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Provider) ProtoMessage() {} + +func (x *Provider) ProtoReflect() protoreflect.Message { + mi := &file_datamodel_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Provider.ProtoReflect.Descriptor instead. +func (*Provider) Descriptor() ([]byte, []int) { + return file_datamodel_proto_rawDescGZIP(), []int{1} +} + +func (x *Provider) GetMetadata() *ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Provider) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Provider) GetCredentials() map[string]string { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *Provider) GetConfig() map[string]string { + if x != nil { + return x.Config + } + return nil +} + +func (x *Provider) GetCredentialExpiresAtMs() map[string]int64 { + if x != nil { + return x.CredentialExpiresAtMs + } + return nil +} + +var File_datamodel_proto protoreflect.FileDescriptor + +const file_datamodel_proto_rawDesc = "" + + "\n" + + "\x0fdatamodel.proto\x12\x16openshell.datamodel.v1\"\x82\x02\n" + + "\n" + + "ObjectMeta\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\"\n" + + "\rcreated_at_ms\x18\x03 \x01(\x03R\vcreatedAtMs\x12F\n" + + "\x06labels\x18\x04 \x03(\v2..openshell.datamodel.v1.ObjectMeta.LabelsEntryR\x06labels\x12)\n" + + "\x10resource_version\x18\x05 \x01(\x04R\x0fresourceVersion\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb4\x04\n" + + "\bProvider\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12S\n" + + "\vcredentials\x18\x03 \x03(\v21.openshell.datamodel.v1.Provider.CredentialsEntryR\vcredentials\x12D\n" + + "\x06config\x18\x04 \x03(\v2,.openshell.datamodel.v1.Provider.ConfigEntryR\x06config\x12t\n" + + "\x18credential_expires_at_ms\x18\x05 \x03(\v2;.openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x1a>\n" + + "\x10CredentialsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a9\n" + + "\vConfigEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aH\n" + + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01b\x06proto3" + +var ( + file_datamodel_proto_rawDescOnce sync.Once + file_datamodel_proto_rawDescData []byte +) + +func file_datamodel_proto_rawDescGZIP() []byte { + file_datamodel_proto_rawDescOnce.Do(func() { + file_datamodel_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_datamodel_proto_rawDesc), len(file_datamodel_proto_rawDesc))) + }) + return file_datamodel_proto_rawDescData +} + +var file_datamodel_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_datamodel_proto_goTypes = []any{ + (*ObjectMeta)(nil), // 0: openshell.datamodel.v1.ObjectMeta + (*Provider)(nil), // 1: openshell.datamodel.v1.Provider + nil, // 2: openshell.datamodel.v1.ObjectMeta.LabelsEntry + nil, // 3: openshell.datamodel.v1.Provider.CredentialsEntry + nil, // 4: openshell.datamodel.v1.Provider.ConfigEntry + nil, // 5: openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry +} +var file_datamodel_proto_depIdxs = []int32{ + 2, // 0: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry + 0, // 1: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 3, // 2: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry + 4, // 3: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry + 5, // 4: openshell.datamodel.v1.Provider.credential_expires_at_ms:type_name -> openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_datamodel_proto_init() } +func file_datamodel_proto_init() { + if File_datamodel_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_datamodel_proto_rawDesc), len(file_datamodel_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_datamodel_proto_goTypes, + DependencyIndexes: file_datamodel_proto_depIdxs, + MessageInfos: file_datamodel_proto_msgTypes, + }.Build() + File_datamodel_proto = out.File + file_datamodel_proto_goTypes = nil + file_datamodel_proto_depIdxs = nil +} diff --git a/sdk/go/proto/openshell.proto b/sdk/go/proto/openshell.proto new file mode 100644 index 0000000000..bf803e864a --- /dev/null +++ b/sdk/go/proto/openshell.proto @@ -0,0 +1,1899 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.v1; + +import "datamodel.proto"; +import "google/protobuf/struct.proto"; +import "sandbox.proto"; + +// OpenShell service provides sandbox, provider, and runtime management capabilities. +// +// Conventions: +// - This file owns the public API resource model exposed to OpenShell clients. +// - `Sandbox`, `SandboxSpec`, `SandboxStatus`, and `SandboxPhase` are gateway-owned +// public types. Internal compute drivers must not import or return them directly. +// - The gateway translates internal compute-driver observations into these public +// resource messages before persisting or returning them to clients. +service OpenShell { + // Check the health of the service. + rpc Health(HealthRequest) returns (HealthResponse); + + // Create a new sandbox. + rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse); + + // Fetch a sandbox by name. + rpc GetSandbox(GetSandboxRequest) returns (SandboxResponse); + + // List sandboxes. + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + + // List provider records attached to a sandbox. + rpc ListSandboxProviders(ListSandboxProvidersRequest) + returns (ListSandboxProvidersResponse); + + // Attach a provider record to an existing sandbox. + rpc AttachSandboxProvider(AttachSandboxProviderRequest) + returns (AttachSandboxProviderResponse); + + // Detach a provider record from an existing sandbox. + rpc DetachSandboxProvider(DetachSandboxProviderRequest) + returns (DetachSandboxProviderResponse); + + // Delete a sandbox by name. + rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse); + + // Create a short-lived SSH session for a sandbox. + rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse); + + // Create or update a sandbox HTTP service endpoint for local routing. + rpc ExposeService(ExposeServiceRequest) returns (ServiceEndpointResponse); + + // Fetch one sandbox HTTP service endpoint. + rpc GetService(GetServiceRequest) returns (ServiceEndpointResponse); + + // List sandbox HTTP service endpoints. + rpc ListServices(ListServicesRequest) returns (ListServicesResponse); + + // Delete one sandbox HTTP service endpoint. + rpc DeleteService(DeleteServiceRequest) returns (DeleteServiceResponse); + + // Revoke a previously issued SSH session. + rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse); + + // Execute a command in a ready sandbox and stream output. + rpc ExecSandbox(ExecSandboxRequest) returns (stream ExecSandboxEvent); + + // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. + rpc ForwardTcp(stream TcpForwardFrame) returns (stream TcpForwardFrame); + + // Execute an interactive command with bidirectional stdin/stdout streaming. + // The first client message MUST carry an ExecSandboxInput with the start + // variant. Subsequent messages carry stdin bytes or window resize events. + rpc ExecSandboxInteractive(stream ExecSandboxInput) returns (stream ExecSandboxEvent); + + // Create a provider. + rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse); + + // Fetch a provider by name. + rpc GetProvider(GetProviderRequest) returns (ProviderResponse); + + // List providers. + rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + + // List available provider type profiles. + rpc ListProviderProfiles(ListProviderProfilesRequest) + returns (ListProviderProfilesResponse); + + // Fetch one provider type profile by id. + rpc GetProviderProfile(GetProviderProfileRequest) + returns (ProviderProfileResponse); + + // Import custom provider type profiles. + rpc ImportProviderProfiles(ImportProviderProfilesRequest) + returns (ImportProviderProfilesResponse); + + // Update an existing custom provider type profile. + rpc UpdateProviderProfiles(UpdateProviderProfilesRequest) + returns (UpdateProviderProfilesResponse); + + // Validate provider type profiles without registering them. + rpc LintProviderProfiles(LintProviderProfilesRequest) + returns (LintProviderProfilesResponse); + + // Update an existing provider by name. + rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); + + // Fetch refresh status for one provider or provider credential. + rpc GetProviderRefreshStatus(GetProviderRefreshStatusRequest) + returns (GetProviderRefreshStatusResponse); + + // Configure gateway-owned refresh material for one provider credential. + rpc ConfigureProviderRefresh(ConfigureProviderRefreshRequest) + returns (ConfigureProviderRefreshResponse); + + // Record a gateway-owned refresh request for one provider credential. + rpc RotateProviderCredential(RotateProviderCredentialRequest) + returns (RotateProviderCredentialResponse); + + // Delete gateway-owned refresh configuration for one provider credential. + rpc DeleteProviderRefresh(DeleteProviderRefreshRequest) + returns (DeleteProviderRefreshResponse); + + // Delete a provider by name. + rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); + + // Delete a custom provider type profile by id. + rpc DeleteProviderProfile(DeleteProviderProfileRequest) + returns (DeleteProviderProfileResponse); + + // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + rpc GetSandboxConfig(openshell.sandbox.v1.GetSandboxConfigRequest) + returns (openshell.sandbox.v1.GetSandboxConfigResponse); + + // Get gateway-global settings. + rpc GetGatewayConfig(openshell.sandbox.v1.GetGatewayConfigRequest) + returns (openshell.sandbox.v1.GetGatewayConfigResponse); + + // Update settings or policy at sandbox or global scope. + rpc UpdateConfig(UpdateConfigRequest) + returns (UpdateConfigResponse); + + // Get the load status of a specific policy version. + rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) + returns (GetSandboxPolicyStatusResponse); + + // List policy history for a sandbox. + rpc ListSandboxPolicies(ListSandboxPoliciesRequest) + returns (ListSandboxPoliciesResponse); + + // Report policy load result (called by sandbox after reload attempt). + rpc ReportPolicyStatus(ReportPolicyStatusRequest) + returns (ReportPolicyStatusResponse); + + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) + returns (GetSandboxProviderEnvironmentResponse); + + // Fetch recent sandbox logs (one-shot). + rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse); + + // Push sandbox supervisor logs to the server (client-streaming). + rpc PushSandboxLogs(stream PushSandboxLogsRequest) returns (PushSandboxLogsResponse); + + // Persistent supervisor-to-gateway session (bidirectional streaming). + // + // The supervisor opens this stream at startup and keeps it alive for the + // sandbox lifetime. The gateway uses it to coordinate relay channels for + // SSH connect, ExecSandbox, and targetable sandbox services. Raw service + // bytes flow over RelayStream calls (separate HTTP/2 streams on the same + // connection), not over this stream. + rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage); + + // Raw byte relay between supervisor and gateway. + // + // The supervisor initiates this call after receiving a RelayOpen message + // on its ConnectSupervisor stream. The first RelayFrame carries a + // RelayInit with the channel_id to associate the new HTTP/2 stream with + // the pending relay slot on the gateway. Subsequent frames carry raw bytes in either + // direction between the gateway-side waiter (ForwardTcp / exec handler) + // and the supervisor-side target bridge. + // + // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — + // no new TLS handshake, no reverse HTTP CONNECT. + rpc RelayStream(stream RelayFrame) returns (stream RelayFrame); + + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - OpenShell server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + rpc WatchSandbox(WatchSandboxRequest) returns (stream SandboxStreamEvent); + + // --------------------------------------------------------------------------- + // Draft policy recommendation RPCs + // --------------------------------------------------------------------------- + + // Submit denial analysis results from sandbox (summaries + proposed chunks). + rpc SubmitPolicyAnalysis(SubmitPolicyAnalysisRequest) + returns (SubmitPolicyAnalysisResponse); + + // Get draft policy recommendations for a sandbox. + rpc GetDraftPolicy(GetDraftPolicyRequest) returns (GetDraftPolicyResponse); + + // Approve a single draft policy chunk (merges into active policy). + rpc ApproveDraftChunk(ApproveDraftChunkRequest) + returns (ApproveDraftChunkResponse); + + // Reject a single draft policy chunk. + rpc RejectDraftChunk(RejectDraftChunkRequest) + returns (RejectDraftChunkResponse); + + // Approve all pending draft chunks (skips security-flagged unless forced). + rpc ApproveAllDraftChunks(ApproveAllDraftChunksRequest) + returns (ApproveAllDraftChunksResponse); + + // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). + rpc EditDraftChunk(EditDraftChunkRequest) returns (EditDraftChunkResponse); + + // Reverse an approval (remove merged rule from active policy). + rpc UndoDraftChunk(UndoDraftChunkRequest) returns (UndoDraftChunkResponse); + + // Clear all pending draft chunks for a sandbox. + rpc ClearDraftChunks(ClearDraftChunksRequest) + returns (ClearDraftChunksResponse); + + // Get decision history for a sandbox's draft policy. + rpc GetDraftHistory(GetDraftHistoryRequest) returns (GetDraftHistoryResponse); + + // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected + // ServiceAccount token) for a gateway-minted JWT bound to the calling + // sandbox's UUID. Used by the Kubernetes driver path; singleplayer + // drivers receive the gateway JWT directly from the create-sandbox flow + // and never call this RPC. + rpc IssueSandboxToken(IssueSandboxTokenRequest) returns (IssueSandboxTokenResponse); + + // Renew the calling sandbox's gateway JWT. Older tokens remain valid + // until their own expiry; deployments should keep token TTLs short to + // bound replay exposure. The supervisor calls this from a background + // task at ~80% of the token's lifetime; the new token is cached in + // memory only — the on-disk bootstrap file is intentionally not + // rewritten. + rpc RefreshSandboxToken(RefreshSandboxTokenRequest) + returns (RefreshSandboxTokenResponse); +} + +// IssueSandboxToken request. Empty body; identity is established by the +// authentication credentials carried in the request headers (a projected +// Kubernetes ServiceAccount JWT in the K8s driver path). +message IssueSandboxTokenRequest {} + +// IssueSandboxToken response. The supervisor caches the returned token in +// memory and presents it as `Authorization: Bearer` on every subsequent +// gateway RPC. +message IssueSandboxTokenResponse { + // Gateway-minted JWT bound to the calling sandbox's UUID. + string token = 1; + // Absolute expiry of the issued token, milliseconds since the epoch. 0 means + // the token is non-expiring. + int64 expires_at_ms = 2; +} + +// RefreshSandboxToken request. Empty body; the calling principal must +// already be a sandbox principal (i.e. the request carries a still-valid +// gateway-minted JWT in its Authorization header). +message RefreshSandboxTokenRequest {} + +// RefreshSandboxToken response. The new token replaces the supervisor's +// in-memory bearer credential. +message RefreshSandboxTokenResponse { + // Fresh gateway-minted JWT bound to the same sandbox UUID. + string token = 1; + // Absolute expiry of the new token, milliseconds since the epoch. 0 means + // the token is non-expiring. + int64 expires_at_ms = 2; +} + +// Health check request. +message HealthRequest {} + +// Health check response. +message HealthResponse { + // Service status. + ServiceStatus status = 1; + + // Service version. + string version = 2; +} + +// Public sandbox resource exposed by the OpenShell API. +// +// This is the canonical gateway-owned view of a sandbox. It merges user intent +// (`spec`) with gateway-managed metadata and status derived from internal +// compute-driver observations. +// +// Note: The `namespace` field has been removed from the public API. It remains +// in the internal `DriverSandbox` message as a compute-driver implementation detail. +message Sandbox { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Desired sandbox configuration submitted through the API. + SandboxSpec spec = 2; + // Latest user-facing observed status derived by the gateway. + SandboxStatus status = 3; + + reserved 4, 5; + reserved "phase", "current_policy_version"; +} + +// Desired sandbox configuration provided through the public API. +message SandboxSpec { + // Log level exposed to processes running inside the sandbox. + string log_level = 1; + // Environment variables injected into the sandbox runtime. + map environment = 5; + // Container or VM template used to provision the sandbox. + SandboxTemplate template = 6; + // Required sandbox policy configuration. + openshell.sandbox.v1.SandboxPolicy policy = 7; + // Provider names to attach to this sandbox. + repeated string providers = 8; + // Portable resource requirements used by the gateway for driver selection + // and by drivers for provisioning. + ResourceRequirements resource_requirements = 9; + reserved 10; + reserved "gpu_device"; + // Field 11 was `proposal_approval_mode`. The approval mode is now a + // runtime setting (gateway or sandbox scope) read via UpdateConfig / + // GetSandboxConfig, so it can be flipped on a running sandbox and + // managed fleet-wide. + reserved 11; + reserved "proposal_approval_mode"; +} + +message ResourceRequirements { + // GPU requirements for the sandbox. Presence indicates a GPU request. + GpuResourceRequirements gpu = 1; +} + +// Public GPU resource requirements. +message GpuResourceRequirements { + // Optional number of GPUs requested. When omitted, the request is for one + // GPU using the selected driver's default assignment behavior. + optional uint32 count = 1; +} + +// Public sandbox template mapped onto compute-driver template inputs. +message SandboxTemplate { + // Fully-qualified OCI image reference used to boot the sandbox. + string image = 1; + // Optional runtime class name requested from the compute platform. + string runtime_class_name = 2; + // Optional agent socket path exposed to the workload. + string agent_socket = 3; + // Labels applied to compute-platform resources for this sandbox. + map labels = 4; + // Annotations applied to compute-platform resources for this sandbox. + map annotations = 5; + // Additional environment variables injected by the template. + map environment = 6; + // Platform-specific compute resource requirements and limits. + google.protobuf.Struct resources = 7; + // Optional platform-specific volume claim templates. + google.protobuf.Struct volume_claim_templates = 9; + // Enable Kubernetes user namespace isolation (hostUsers: false). + // When true, container UID 0 maps to a non-root host UID and capabilities + // become namespaced. Requires Kubernetes 1.33+ with user namespace support + // available (beta through 1.35, GA in 1.36+) and a supporting runtime. + // When unset, the cluster-wide default is used. + optional bool user_namespaces = 10; + // Driver-keyed opaque config envelope supplied by the caller. + // The gateway selects the block matching the active compute driver and + // forwards only that inner Struct to DriverSandboxTemplate.driver_config. + // The selected driver owns nested schema validation. + google.protobuf.Struct driver_config = 11; +} + +// User-facing sandbox status derived by the gateway from compute-driver observations. +// +// Public status does not embed driver-only flags such as `deleting`. +message SandboxStatus { + // Compute-platform sandbox object name. + string sandbox_name = 1; + // Name of the agent pod or equivalent runtime instance. + string agent_pod = 2; + // File descriptor or endpoint for reaching the agent service, when available. + string agent_fd = 3; + // File descriptor or endpoint for reaching the sandbox service, when available. + string sandbox_fd = 4; + // Latest user-facing readiness and lifecycle conditions. + repeated SandboxCondition conditions = 5; + // Gateway-derived lifecycle summary. + SandboxPhase phase = 6; + // Currently active policy version (updated when sandbox reports loaded). + uint32 current_policy_version = 7; +} + +// User-facing sandbox condition derived from driver-native conditions. +message SandboxCondition { + // Condition class, typically mirroring the underlying platform condition type. + string type = 1; + // Condition status value such as `True`, `False`, or `Unknown`. + string status = 2; + // Short machine-readable reason associated with the condition. + string reason = 3; + // Human-readable condition message. + string message = 4; + // Timestamp reported by the underlying platform for the last transition. + string last_transition_time = 5; +} + +// High-level sandbox lifecycle phase derived by the gateway. +// +// Clients should rely on this normalized lifecycle summary for readiness and +// deletion decisions instead of interpreting raw conditions. +enum SandboxPhase { + SANDBOX_PHASE_UNSPECIFIED = 0; + SANDBOX_PHASE_PROVISIONING = 1; + SANDBOX_PHASE_READY = 2; + SANDBOX_PHASE_ERROR = 3; + SANDBOX_PHASE_DELETING = 4; + SANDBOX_PHASE_UNKNOWN = 5; +} + +// Public platform event exposed on the sandbox watch stream. +message PlatformEvent { + // Event timestamp in milliseconds since epoch. + int64 timestamp_ms = 1; + // Event source (e.g. "kubernetes", "docker", "process"). + string source = 2; + // Event type/severity (e.g. "Normal", "Warning"). + string type = 3; + // Short reason code (e.g. "Started", "Pulled", "Failed"). + string reason = 4; + // Human-readable event message. + string message = 5; + // Optional metadata as key-value pairs. + map metadata = 6; +} + +// Create sandbox request. +message CreateSandboxRequest { + SandboxSpec spec = 1; + // Optional user-supplied sandbox name. When empty the server generates one. + string name = 2; + // Optional labels for the sandbox (key-value metadata). + map labels = 3; +} + +// Get sandbox request. +message GetSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// List sandboxes request. +message ListSandboxesRequest { + uint32 limit = 1; + uint32 offset = 2; + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + string label_selector = 3; +} + +// List providers attached to a sandbox request. +message ListSandboxProvidersRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; +} + +// Attach provider to sandbox request. +message AttachSandboxProviderRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; + // Provider name to attach. + string provider_name = 2; + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + uint64 expected_resource_version = 3; +} + +// Detach provider from sandbox request. +message DetachSandboxProviderRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; + // Provider name to detach. + string provider_name = 2; + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + uint64 expected_resource_version = 3; +} + +// Delete sandbox request. +message DeleteSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// Sandbox response. +message SandboxResponse { + Sandbox sandbox = 1; +} + +// List sandboxes response. +message ListSandboxesResponse { + repeated Sandbox sandboxes = 1; +} + +// List providers attached to a sandbox response. +message ListSandboxProvidersResponse { + repeated openshell.datamodel.v1.Provider providers = 1; +} + +// Attach provider to sandbox response. +message AttachSandboxProviderResponse { + Sandbox sandbox = 1; + // True when the provider was newly attached. False means it was already attached. + bool attached = 2; +} + +// Detach provider from sandbox response. +message DetachSandboxProviderResponse { + Sandbox sandbox = 1; + // True when the provider was removed. False means it was not attached. + bool detached = 2; +} + +// Delete sandbox response. +message DeleteSandboxResponse { + bool deleted = 1; +} + +// Create SSH session request. +message CreateSshSessionRequest { + // Sandbox id. + string sandbox_id = 1; +} + +// Create SSH session response. +// +// Fields are interpolated into an SSH `ProxyCommand` string that OpenSSH +// executes through `/bin/sh -c` on the caller's workstation. Servers MUST +// uphold the charset contract below; clients MUST reject responses that +// violate it. The client's own escaping provides defense-in-depth, but +// narrow charsets close injection vectors at the trust boundary. +message CreateSshSessionResponse { + // Sandbox id. [A-Za-z0-9._-]{1,128}. + string sandbox_id = 1; + + // Session token for the gateway tunnel. URL-safe ASCII + // ([A-Za-z0-9._~+/=-]) up to 4096 bytes. No shell metacharacters or + // whitespace. + string token = 2; + + // Gateway host for SSH proxy connection. IPv4 address, bracketed IPv6 + // address, or DNS hostname (Punycode-encoded for IDN). Alphanumeric plus + // `.-:[]` only, up to 253 bytes. + string gateway_host = 3; + + // Gateway port for SSH proxy connection. Must be in range 1..=65535. + uint32 gateway_port = 4; + + // Gateway scheme. Must be exactly "http" or "https". + string gateway_scheme = 5; + + // Optional host key fingerprint. If non-empty, [A-Za-z0-9:+/=-] only. + string host_key_fingerprint = 7; + + // Expiry timestamp in milliseconds since epoch. 0 means no expiry. + int64 expires_at_ms = 8; +} + +// Request to expose an HTTP service running inside a sandbox. +message ExposeServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. + string service = 2; + // Loopback TCP port inside the sandbox. + uint32 target_port = 3; + // Whether to print/use the browser-facing service URL. + bool domain = 4; +} + +// Request to fetch an exposed sandbox service endpoint. +message GetServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. Empty selects the unnamed endpoint. + string service = 2; +} + +// Request to list exposed sandbox service endpoints. +message ListServicesRequest { + // Optional sandbox name. Empty lists endpoints for all sandboxes. + string sandbox = 1; + // Page size. Zero uses the server default. + uint32 limit = 2; + // Page offset. + uint32 offset = 3; +} + +// Response containing exposed sandbox service endpoints. +message ListServicesResponse { + repeated ServiceEndpointResponse services = 1; +} + +// Request to delete an exposed sandbox service endpoint. +message DeleteServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. Empty selects the unnamed endpoint. + string service = 2; +} + +// Response for deleting an exposed sandbox service endpoint. +message DeleteServiceResponse { + // True when an endpoint existed and was deleted. + bool deleted = 1; +} + +// Persisted sandbox service endpoint. +message ServiceEndpoint { + // Kubernetes-style metadata. + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Sandbox object ID. + string sandbox_id = 2; + // Sandbox name. + string sandbox_name = 3; + // Service name within the sandbox. + string service_name = 4; + // Loopback TCP port inside the sandbox. + uint32 target_port = 5; + // Whether browser-facing service routing is enabled for this endpoint. + bool domain = 6; +} + +// Response containing a service endpoint and, when available, its local URL. +message ServiceEndpointResponse { + ServiceEndpoint endpoint = 1; + string url = 2; +} + +// Revoke SSH session request. +message RevokeSshSessionRequest { + // Session token to revoke. + string token = 1; +} + +// Revoke SSH session response. +message RevokeSshSessionResponse { + // True when a session was revoked. + bool revoked = 1; +} + +// Execute command request. +message ExecSandboxRequest { + // Sandbox id. + string sandbox_id = 1; + + // Command and arguments. + repeated string command = 2; + + // Optional working directory. + string workdir = 3; + + // Optional environment overrides. + map environment = 4; + + // Optional timeout in seconds. 0 means no timeout. + uint32 timeout_seconds = 5; + + // Optional stdin payload passed to the command. + bytes stdin = 6; + + // Request a pseudo-terminal for the remote command. + bool tty = 7; + + // Initial terminal columns (used when tty=true, 0 = use default). + uint32 cols = 8; + + // Initial terminal rows (used when tty=true, 0 = use default). + uint32 rows = 9; +} + +// One stdout chunk from a sandbox exec. +message ExecSandboxStdout { + bytes data = 1; +} + +// One stderr chunk from a sandbox exec. +message ExecSandboxStderr { + bytes data = 1; +} + +// Final exit status for a sandbox exec. +message ExecSandboxExit { + int32 exit_code = 1; +} + +// One event in a sandbox exec stream. +message ExecSandboxEvent { + oneof payload { + ExecSandboxStdout stdout = 1; + ExecSandboxStderr stderr = 2; + ExecSandboxExit exit = 3; + } +} + +// Initial frame for one TCP forward stream. +message TcpForwardInit { + // Sandbox id. + string sandbox_id = 1; + // Optional service identifier for audit/correlation. + string service_id = 4; + // Target the gateway should request from the supervisor. + oneof target { + SshRelayTarget ssh = 5; + TcpRelayTarget tcp = 6; + } + // Optional target-specific authorization token. SSH targets use this as the + // short-lived SSH session token issued by CreateSshSession. + string authorization_token = 7; +} + +// A single frame on the CLI-to-gateway TCP forward stream. +message TcpForwardFrame { + oneof payload { + TcpForwardInit init = 1; + bytes data = 2; + } +} + +// Client-to-server message for interactive exec. +message ExecSandboxInput { + oneof payload { + // First message: exec request metadata. + ExecSandboxRequest start = 1; + // Subsequent messages: raw stdin bytes. + bytes stdin = 2; + // Terminal window size change. + ExecSandboxWindowResize resize = 3; + } +} + +// Terminal window resize event for interactive exec. +message ExecSandboxWindowResize { + uint32 cols = 1; + uint32 rows = 2; +} + + +// SSH session record stored in persistence. +message SshSession { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + openshell.datamodel.v1.ObjectMeta metadata = 1; + + // Sandbox id. + string sandbox_id = 2; + + // Session token. + string token = 3; + + // Expiry timestamp in milliseconds since epoch. 0 means no expiry + // (backward-compatible default for sessions created before this field existed). + int64 expires_at_ms = 4; + + // Revoked flag. + bool revoked = 5; +} + +// Watch sandbox request. +message WatchSandboxRequest { + // Sandbox id. + string id = 1; + + // Stream sandbox status snapshots. + bool follow_status = 2; + + // Stream openshell-server process logs correlated to this sandbox. + bool follow_logs = 3; + + // Stream platform events correlated to this sandbox. + bool follow_events = 4; + + // Replay the last N log lines (best-effort) before following. + uint32 log_tail_lines = 5; + + // Replay the last N platform events (best-effort) before following. + uint32 event_tail = 6; + + // Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). + bool stop_on_terminal = 7; + + // Only include log lines with timestamp >= this value (milliseconds since epoch). + // 0 means no time filter. Applies to both tail replay and live streaming. + int64 log_since_ms = 8; + + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string log_sources = 9; + + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string log_min_level = 10; +} + +// One event in a sandbox watch stream. +message SandboxStreamEvent { + oneof payload { + // Latest sandbox snapshot. + Sandbox sandbox = 1; + // One server log line/event. + SandboxLogLine log = 2; + // One platform event. + PlatformEvent event = 3; + // Warning from the server (e.g. missed messages due to lag). + SandboxStreamWarning warning = 4; + // Draft policy update notification. + DraftPolicyUpdate draft_policy_update = 5; + } +} + +// Log line correlated to a sandbox. +message SandboxLogLine { + string sandbox_id = 1; + int64 timestamp_ms = 2; + string level = 3; + string target = 4; + string message = 5; + // Log source: "gateway" (server-side) or "sandbox" (supervisor). + // Empty is treated as "gateway" for backward compatibility. + string source = 6; + // Structured key-value fields from the tracing event (e.g. dst_host, action). + map fields = 7; +} + +message SandboxStreamWarning { + string message = 1; +} + +// Create provider request. +message CreateProviderRequest { + openshell.datamodel.v1.Provider provider = 1; +} + +// Get provider request. +message GetProviderRequest { + string name = 1; +} + +// List providers request. +message ListProvidersRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Update provider request. +message UpdateProviderRequest { + openshell.datamodel.v1.Provider provider = 1; + // Optional per-credential expiry timestamps to merge into the provider. + // A zero value removes the expiry for that credential. + map credential_expires_at_ms = 2; +} + +// Delete provider request. +message DeleteProviderRequest { + string name = 1; +} + +// Provider response. +message ProviderResponse { + openshell.datamodel.v1.Provider provider = 1; +} + +// List providers response. +message ListProvidersResponse { + repeated openshell.datamodel.v1.Provider providers = 1; +} + +// List provider type profiles request. +message ListProviderProfilesRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Fetch provider type profile request. +message GetProviderProfileRequest { + string id = 1; +} + +// Provider profile payload with optional source metadata for diagnostics. +message ProviderProfileImportItem { + ProviderProfile profile = 1; + string source = 2; +} + +// Provider profile validation diagnostic. +message ProviderProfileDiagnostic { + string source = 1; + string profile_id = 2; + string field = 3; + string message = 4; + string severity = 5; +} + +// Endpoint selector for token grant audience overrides. +message ProviderCredentialTokenGrantAudienceOverride { + // Optional: endpoint host selector. If omitted, inherits the profile endpoint host. + string host = 1; + + // Optional: endpoint port selector. If omitted, matches the expanded profile endpoint port. + uint32 port = 2; + + // Optional: endpoint path selector. If omitted, inherits the profile endpoint path. + string path = 3; + + // Resource audience to request for matching endpoints. + string audience = 4; + + // Optional: OAuth2 scopes to request. If omitted, inherits the token grant scopes. + repeated string scopes = 5; +} + +// Provider credential token grant configuration. +// When present, the credential is obtained dynamically via OAuth2 grant when needed. +message ProviderCredentialTokenGrant { + // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) + string token_endpoint = 1; + + // Optional: default resource audience to request from the token service + string audience = 2; + + // Optional: audience to request when fetching the JWT-SVID from SPIRE. + // If omitted, the sandbox derives this from token_endpoint. + string jwt_svid_audience = 6; + + // Optional: OAuth2 scopes to request + repeated string scopes = 3; + + // Optional: override token cache TTL (seconds) + // If 0 or omitted, use expires_in from token response + int64 cache_ttl_seconds = 4; + + // Optional: endpoint-specific resource audience overrides. + repeated ProviderCredentialTokenGrantAudienceOverride audience_overrides = 5; + + // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses + // urn:ietf:params:oauth:client-assertion-type:jwt-bearer. + string client_assertion_type = 7; +} + +// Provider credential declaration. +message ProviderProfileCredential { + string name = 1; + string description = 2; + repeated string env_vars = 3; + bool required = 4; + string auth_style = 5; + string header_name = 6; + string query_param = 7; + ProviderCredentialRefresh refresh = 8; + string path_template = 9; + ProviderCredentialTokenGrant token_grant = 10; +} + +enum ProviderCredentialRefreshStrategy { + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED = 0; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC = 1; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL = 2; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN = 3; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS = 4; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT = 5; +} + +message ProviderCredentialRefreshMaterial { + string name = 1; + string description = 2; + bool required = 3; + bool secret = 4; +} + +message ProviderCredentialRefresh { + ProviderCredentialRefreshStrategy strategy = 1; + string token_url = 2; + repeated string scopes = 3; + int64 refresh_before_seconds = 4; + int64 max_lifetime_seconds = 5; + repeated ProviderCredentialRefreshMaterial material = 6; +} + +message ProviderCredentialRefreshStatus { + string provider_name = 1; + string provider_id = 2; + string credential_key = 3; + ProviderCredentialRefreshStrategy strategy = 4; + string status = 5; + int64 expires_at_ms = 6; + int64 next_refresh_at_ms = 7; + int64 last_refresh_at_ms = 8; + string last_error = 9; +} + +// Provider profile local discovery declaration. +message ProviderProfileDiscovery { + // Credential names from ProviderProfile.credentials eligible for local discovery. + repeated string credentials = 1; +} + +message StoredProviderCredentialRefreshState { + openshell.datamodel.v1.ObjectMeta metadata = 1; + string provider_id = 2; + string provider_name = 3; + string credential_key = 4; + ProviderCredentialRefreshStrategy strategy = 5; + map material = 6; + repeated string secret_material_keys = 7; + int64 expires_at_ms = 8; + int64 next_refresh_at_ms = 9; + int64 last_refresh_at_ms = 10; + string status = 11; + string last_error = 12; + string token_url = 13; + repeated string scopes = 14; + int64 refresh_before_seconds = 15; + int64 max_lifetime_seconds = 16; +} + +message GetProviderRefreshStatusRequest { + string provider = 1; + string credential_key = 2; +} + +message GetProviderRefreshStatusResponse { + repeated ProviderCredentialRefreshStatus credentials = 1; +} + +message ConfigureProviderRefreshRequest { + string provider = 1; + string credential_key = 2; + ProviderCredentialRefreshStrategy strategy = 3; + map material = 4; + repeated string secret_material_keys = 5; + optional int64 expires_at_ms = 6; +} + +message ConfigureProviderRefreshResponse { + ProviderCredentialRefreshStatus status = 1; +} + +message RotateProviderCredentialRequest { + string provider = 1; + string credential_key = 2; +} + +message RotateProviderCredentialResponse { + ProviderCredentialRefreshStatus status = 1; +} + +message DeleteProviderRefreshRequest { + string provider = 1; + string credential_key = 2; +} + +message DeleteProviderRefreshResponse { + bool deleted = 1; +} + +// Stable provider profile categories used by clients for grouping and filtering. +enum ProviderProfileCategory { + PROVIDER_PROFILE_CATEGORY_UNSPECIFIED = 0; + PROVIDER_PROFILE_CATEGORY_OTHER = 1; + PROVIDER_PROFILE_CATEGORY_INFERENCE = 2; + PROVIDER_PROFILE_CATEGORY_AGENT = 3; + PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL = 4; + PROVIDER_PROFILE_CATEGORY_MESSAGING = 5; + PROVIDER_PROFILE_CATEGORY_DATA = 6; + PROVIDER_PROFILE_CATEGORY_KNOWLEDGE = 7; +} + +// Provider type profile metadata exposed to clients. +message ProviderProfile { + string id = 1; + string display_name = 2; + string description = 3; + ProviderProfileCategory category = 4; + repeated ProviderProfileCredential credentials = 5; + repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; + repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + bool inference_capable = 8; + ProviderProfileDiscovery discovery = 9; + // Storage resource version for custom profiles. Built-in profiles and new + // profile files use 0. Gateway responses set this for stored custom profiles. + // Update calls use this for optimistic concurrency. + uint64 resource_version = 10; +} + +// Stored custom provider profile object. +message StoredProviderProfile { + openshell.datamodel.v1.ObjectMeta metadata = 1; + ProviderProfile profile = 2; +} + +// Provider profile response. +message ProviderProfileResponse { + ProviderProfile profile = 1; +} + +// List provider profiles response. +message ListProviderProfilesResponse { + repeated ProviderProfile profiles = 1; +} + +// Import custom provider profiles request. +message ImportProviderProfilesRequest { + repeated ProviderProfileImportItem profiles = 1; +} + +// Import custom provider profiles response. +message ImportProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + repeated ProviderProfile profiles = 2; + bool imported = 3; +} + +// Update one custom provider profile request. +message UpdateProviderProfilesRequest { + ProviderProfileImportItem profile = 1; + // Expected storage resource version for optimistic concurrency control. + // If 0, the server uses the resource_version embedded in profile.profile. + // Updates without a non-zero version are rejected to prevent stale files from + // silently overwriting newer profile definitions. + uint64 expected_resource_version = 2; + // Existing custom provider profile ID to update. The payload ID must match. + string id = 3; +} + +// Update one custom provider profile response. +message UpdateProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + ProviderProfile profile = 2; + bool updated = 3; +} + +// Lint provider profiles request. +message LintProviderProfilesRequest { + repeated ProviderProfileImportItem profiles = 1; +} + +// Lint provider profiles response. +message LintProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + bool valid = 2; +} + +// Delete provider response. +message DeleteProviderResponse { + bool deleted = 1; +} + +// Delete custom provider profile request. +message DeleteProviderProfileRequest { + string id = 1; +} + +// Delete custom provider profile response. +message DeleteProviderProfileResponse { + bool deleted = 1; +} + +// Get sandbox provider environment request. +message GetSandboxProviderEnvironmentRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Get sandbox provider environment response. +message GetSandboxProviderEnvironmentResponse { + // Provider credential environment variables. + map environment = 1; + // Fingerprint for the provider credential inputs that produced environment. + uint64 provider_env_revision = 2; + // Expiration timestamps for returned environment variables. + map credential_expires_at_ms = 3; + // Dynamic credentials that require token grants or other runtime injection. + // Maps endpoint-bound provider metadata to credential metadata. + // Supervisor uses this to inject Authorization headers for token grant credentials. + map dynamic_credentials = 4; +} + +// --------------------------------------------------------------------------- +// Policy update messages +// --------------------------------------------------------------------------- + +// Update sandbox policy request. +message UpdateConfigRequest { + // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. + // Not required when `global=true`. + string name = 1; + // The new policy to apply. + // + // Sandbox scope (`global=false`): + // - only network_policies and inference fields may differ from create-time + // policy; static fields must match version 1. + // + // Global scope (`global=true`): + // - applies to all sandboxes in full (no merge). + openshell.sandbox.v1.SandboxPolicy policy = 2; + // Optional single setting key to mutate. + string setting_key = 3; + // Setting value for upsert operations. + openshell.sandbox.v1.SettingValue setting_value = 4; + // Delete the setting key from scope. + // Sandbox-scoped deletes are rejected; only global delete is supported. + bool delete_setting = 5; + // Apply mutation at gateway-global scope. + bool global = 6; + // Batched incremental policy merge operations. Sandbox-scoped only. + repeated PolicyMergeOperation merge_operations = 7; + // Expected resource version for optimistic concurrency control (sandbox-scoped only). + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + // Ignored for global-scoped updates. + uint64 expected_resource_version = 8; +} + +message PolicyMergeOperation { + oneof operation { + AddNetworkRule add_rule = 1; + RemoveNetworkEndpoint remove_endpoint = 2; + RemoveNetworkRule remove_rule = 3; + AddDenyRules add_deny_rules = 4; + AddAllowRules add_allow_rules = 5; + RemoveNetworkBinary remove_binary = 6; + } +} + +message AddNetworkRule { + string rule_name = 1; + openshell.sandbox.v1.NetworkPolicyRule rule = 2; +} + +message RemoveNetworkEndpoint { + string rule_name = 1; + string host = 2; + uint32 port = 3; +} + +message RemoveNetworkRule { + string rule_name = 1; +} + +message AddDenyRules { + string host = 1; + uint32 port = 2; + repeated openshell.sandbox.v1.L7DenyRule deny_rules = 3; +} + +message AddAllowRules { + string host = 1; + uint32 port = 2; + repeated openshell.sandbox.v1.L7Rule rules = 3; +} + +message RemoveNetworkBinary { + string rule_name = 1; + string binary_path = 2; +} + +// Update sandbox policy response. +message UpdateConfigResponse { + // Assigned policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Settings revision for the scope that was modified. + uint64 settings_revision = 3; + // True when a setting delete operation removed an existing key. + bool deleted = 4; +} + +// Get sandbox policy status request. +message GetSandboxPolicyStatusRequest { + // Sandbox name (canonical lookup key). Ignored when global is true. + string name = 1; + // The specific policy version to query. 0 means latest. + uint32 version = 2; + // Query global policy revisions instead of a sandbox-scoped one. + bool global = 3; +} + +// Get sandbox policy status response. +message GetSandboxPolicyStatusResponse { + // The queried policy revision. + SandboxPolicyRevision revision = 1; + // The currently active (loaded) policy version for this sandbox. + uint32 active_version = 2; +} + +// List sandbox policies request. +message ListSandboxPoliciesRequest { + // Sandbox name (canonical lookup key). Ignored when global is true. + string name = 1; + uint32 limit = 2; + uint32 offset = 3; + // List global policy revisions instead of sandbox-scoped ones. + bool global = 4; +} + +// List sandbox policies response. +message ListSandboxPoliciesResponse { + repeated SandboxPolicyRevision revisions = 1; +} + +// Report policy load status (called by sandbox runtime after reload attempt). +message ReportPolicyStatusRequest { + // Sandbox id. + string sandbox_id = 1; + // The policy version that was attempted. + uint32 version = 2; + // Load result status. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; +} + +// Report policy status response. +message ReportPolicyStatusResponse {} + +// A versioned policy revision with metadata. +message SandboxPolicyRevision { + // Policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Load status of this revision. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; + // Milliseconds since epoch when this revision was created. + int64 created_at_ms = 5; + // Milliseconds since epoch when this revision was loaded by the sandbox. + int64 loaded_at_ms = 6; + // The full policy (only populated when explicitly requested). + openshell.sandbox.v1.SandboxPolicy policy = 7; +} + +// Policy load status. +enum PolicyStatus { + POLICY_STATUS_UNSPECIFIED = 0; + // Server received the update; sandbox has not yet loaded it. + POLICY_STATUS_PENDING = 1; + // Sandbox successfully applied this policy version. + POLICY_STATUS_LOADED = 2; + // Sandbox attempted to apply but failed; LKG policy remains active. + POLICY_STATUS_FAILED = 3; + // A newer version was persisted before the sandbox loaded this one. + POLICY_STATUS_SUPERSEDED = 4; +} + +// --------------------------------------------------------------------------- +// Sandbox logs messages +// --------------------------------------------------------------------------- + +// Get sandbox logs request (one-shot fetch). +message GetSandboxLogsRequest { + // Sandbox id. + string sandbox_id = 1; + // Maximum number of log lines to return. 0 means use default (2000). + uint32 lines = 2; + // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. + int64 since_ms = 3; + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string sources = 4; + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string min_level = 5; +} + +// Batch of log lines pushed from sandbox to server. +message PushSandboxLogsRequest { + // The sandbox ID. + string sandbox_id = 1; + // Log lines to ingest. + repeated SandboxLogLine logs = 2; +} + +// Push sandbox logs response. +message PushSandboxLogsResponse {} + +// Get sandbox logs response. +message GetSandboxLogsResponse { + // Log lines in chronological order. + repeated SandboxLogLine logs = 1; + // Total number of lines in the server's buffer for this sandbox. + uint32 buffer_total = 2; +} + +// --------------------------------------------------------------------------- +// Supervisor session messages +// --------------------------------------------------------------------------- + +// Envelope for supervisor-to-gateway messages on the ConnectSupervisor stream. +message SupervisorMessage { + oneof payload { + SupervisorHello hello = 1; + SupervisorHeartbeat heartbeat = 2; + RelayOpenResult relay_open_result = 3; + RelayClose relay_close = 4; + } +} + +// Envelope for gateway-to-supervisor messages on the ConnectSupervisor stream. +message GatewayMessage { + oneof payload { + SessionAccepted session_accepted = 1; + SessionRejected session_rejected = 2; + GatewayHeartbeat heartbeat = 3; + RelayOpen relay_open = 4; + RelayClose relay_close = 5; + } +} + +// Supervisor identifies itself and the sandbox it manages. +message SupervisorHello { + // Sandbox ID this supervisor manages. + string sandbox_id = 1; + // Supervisor instance ID (e.g. boot id or process epoch). + string instance_id = 2; +} + +// Gateway accepts the supervisor session. +message SessionAccepted { + // Gateway-assigned session ID for this connection. + string session_id = 1; + // Recommended heartbeat interval in seconds. + uint32 heartbeat_interval_secs = 2; +} + +// Gateway rejects the supervisor session. +message SessionRejected { + // Human-readable rejection reason. + string reason = 1; +} + +// Supervisor heartbeat. +message SupervisorHeartbeat {} + +// Gateway heartbeat. +message GatewayHeartbeat {} + +// Gateway requests the supervisor to open a relay channel. +// +// On receiving this, the supervisor should initiate a RelayStream RPC to +// the gateway, sending a RelayInit in the first RelayFrame to associate +// the new HTTP/2 stream with the pending relay slot. The supervisor +// bridges that stream to the requested local target. +message RelayOpen { + // Gateway-allocated channel identifier (UUID). + string channel_id = 1; + // Target the supervisor should dial inside the sandbox. + // If absent, supervisors treat the relay as SSH for compatibility. + oneof target { + SshRelayTarget ssh = 2; + TcpRelayTarget tcp = 3; + } + // Optional service identifier for audit/correlation. + string service_id = 5; +} + +// Built-in SSH relay target. +message SshRelayTarget {} + +// TCP target dialed by the supervisor from inside the sandbox. +message TcpRelayTarget { + // Phase 1 accepts loopback only: 127.0.0.1, ::1, or localhost. + string host = 1; + // Target port. Must fit in u16 and be non-zero. + uint32 port = 2; +} + +// Initial RelayStream frame sent by the supervisor to claim a pending relay. +message RelayInit { + // Gateway-allocated channel identifier (UUID). + string channel_id = 1; +} + +// A single frame on the RelayStream RPC. +// +// The supervisor MUST send `init` as the first frame. All subsequent frames +// in either direction carry raw bytes in `data`. +message RelayFrame { + oneof payload { + RelayInit init = 1; + bytes data = 2; + } +} + +// Supervisor reports the result of a relay open request. +message RelayOpenResult { + // Channel identifier from the RelayOpen request. + string channel_id = 1; + // True if the relay was successfully established. + bool success = 2; + // Error message if success is false. + string error = 3; +} + +// Either side requests closure of a relay channel. +message RelayClose { + // Channel identifier to close. + string channel_id = 1; + // Optional reason for closure. + string reason = 2; +} + +// --------------------------------------------------------------------------- +// Service status +// --------------------------------------------------------------------------- + +// Service status enum. +enum ServiceStatus { + SERVICE_STATUS_UNSPECIFIED = 0; + SERVICE_STATUS_HEALTHY = 1; + SERVICE_STATUS_DEGRADED = 2; + SERVICE_STATUS_UNHEALTHY = 3; +} + +// --------------------------------------------------------------------------- +// Draft policy recommendation messages +// --------------------------------------------------------------------------- + +// Observed HTTP method+path pattern from L7 inspection. +message L7RequestSample { + // HTTP method: GET, POST, PUT, DELETE, etc. + string method = 1; + // HTTP path: /v1/models, /repos/myorg/issues + string path = 2; + // L7 decision: "audit" or "deny" (allowed requests not collected). + string decision = 3; + // Number of times this (method, path) was observed. + uint32 count = 4; +} + +// Structured denial summary from sandbox aggregator. +message DenialSummary { + // Sandbox ID that produced this summary. + string sandbox_id = 1; + // Denied destination host. + string host = 2; + // Denied destination port. + uint32 port = 3; + // Binary that attempted the connection. + string binary = 4; + // Process ancestor chain. + repeated string ancestors = 5; + // Denial reason from OPA evaluation. + string deny_reason = 6; + // First denial timestamp (ms since epoch). + int64 first_seen_ms = 7; + // Most recent denial timestamp (ms since epoch). + int64 last_seen_ms = 8; + // Number of denials in the current window. + uint32 count = 9; + // Events dropped during aggregator cooldown. + uint32 suppressed_count = 10; + // Cumulative lifetime count (never resets). + uint32 total_count = 11; + // Distinct cmdline strings observed (sanitized of credentials). + repeated string sample_cmdlines = 12; + // SHA-256 of the binary for audit trail. + string binary_sha256 = 13; + // True if emitted by stale-flush rather than threshold. + bool persistent = 14; + // Denial category: "l4_deny", "l7_deny", "l7_audit", "ssrf". + string denial_stage = 15; + // Observed HTTP request patterns (from L7 inspection). + repeated L7RequestSample l7_request_samples = 16; + // True if L7 inspection was active during observation window. + bool l7_inspection_active = 17; +} + +// Count of denied actions grouped only by sanitized telemetry category. +message DenialGroupCount { + // Sanitized denial category, e.g. "connect_policy", "l7_policy", "ssrf". + string deny_group = 1; + // Number of denied actions in this category. + uint32 denied_count = 2; +} + +// Anonymous sandbox network activity counters. This intentionally excludes +// hosts, paths, binaries, raw deny reasons, sandbox IDs, and user content. +message NetworkActivitySummary { + // Total observed network activities in the current window. + uint32 network_activity_count = 1; + // Total denied actions in the current window. + uint32 denied_action_count = 2; + // Denied action counts grouped by sanitized category. + repeated DenialGroupCount denials_by_group = 3; +} + +// A proposed policy rule with rationale and approval status. +message PolicyChunk { + // Unique chunk identifier. + string id = 1; + // Approval status: "pending", "approved", "rejected". + string status = 2; + // Proposed network_policies map key. + string rule_name = 3; + // The proposed network policy rule. + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 4; + // Human-readable explanation of why this rule is proposed. + string rationale = 5; + // Security concerns flagged by analysis (empty if none). + string security_notes = 6; + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + float confidence = 7; + // IDs of denial summaries that led to this chunk. + repeated string denial_summary_ids = 8; + // Creation timestamp (ms since epoch). + int64 created_at_ms = 9; + // When the user approved/rejected (ms since epoch). 0 if undecided. + int64 decided_at_ms = 10; + // Recommendation stage: "initial" or "refined" (progressive L7 visibility). + string stage = 11; + // For stage="refined": the initial chunk this replaces. + string supersedes_chunk_id = 12; + // How many times this endpoint has been seen across denial flush cycles. + int32 hit_count = 13; + // First time this endpoint was proposed (ms since epoch). + int64 first_seen_ms = 14; + // Most recent time this endpoint was re-proposed (ms since epoch). + int64 last_seen_ms = 15; + // Binary path that triggered the denial (denormalized for display convenience). + string binary = 16; + // Validation verdict from gateway-side static checks (prover output). + // Free-form summary string for human consumption in the inbox card. + // Empty until the prover has run for this chunk. + string validation_result = 17; + // Operator-supplied free-form text accompanying a rejection. Populated + // when the reviewer rejects via `RejectDraftChunkRequest.reason`; surfaced + // back to the in-sandbox agent so it can revise the proposal. + // Empty for non-rejected chunks. + string rejection_reason = 18; +} + +// Notification that the draft policy was updated. +message DraftPolicyUpdate { + // Current draft version. + uint64 draft_version = 1; + // Number of new chunks added in this update. + uint32 new_chunks = 2; + // Total pending chunks awaiting approval. + uint32 total_pending = 3; + // Brief description of what changed. + string summary = 4; +} + +// Submit analysis results from sandbox to gateway. +message SubmitPolicyAnalysisRequest { + // Aggregated denial summaries. + repeated DenialSummary summaries = 1; + // Proposed policy chunks (validated by sandbox OPA engine). + repeated PolicyChunk proposed_chunks = 2; + // Analysis mode. `mechanistic` is the observation-driven path from the + // denial aggregator — chunks targeting the same host|port|binary fold + // into one row with hit_count incremented. `agent_authored` is an + // intentional proposal from an in-sandbox agent — each submission lands + // as its own chunk so the redraft-after-rejection loop has a stable id + // to watch. Other values are treated as agent-style (no dedup) so a new + // mode does not silently collapse proposals. + string analysis_mode = 3; + // Sandbox name. + string name = 4; + // Anonymous network activity counters. + repeated NetworkActivitySummary network_activity_summaries = 5; +} + +message SubmitPolicyAnalysisResponse { + // Number of chunks accepted by the gateway. + uint32 accepted_chunks = 1; + // Number of chunks rejected by gateway validation. + uint32 rejected_chunks = 2; + // Reasons for each rejected chunk. + repeated string rejection_reasons = 3; + // Server-assigned chunk IDs for the accepted chunks, in submission order. + // Agents use these to watch proposal state via policy.local's + // GET /v1/proposals/{id} and /wait endpoints. + repeated string accepted_chunk_ids = 4; +} + +// Get draft policy for a sandbox. +message GetDraftPolicyRequest { + // Sandbox name. + string name = 1; + // Optional status filter: "pending", "approved", "rejected", or "" for all. + string status_filter = 2; +} + +message GetDraftPolicyResponse { + // Draft policy chunks. + repeated PolicyChunk chunks = 1; + // LLM-generated summary of all analysis (empty in mechanistic mode). + string rolling_summary = 2; + // Current draft version. + uint64 draft_version = 3; + // When the last analysis completed (ms since epoch). + int64 last_analyzed_at_ms = 4; +} + +// Approve a single draft chunk. +message ApproveDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to approve. + string chunk_id = 2; +} + +message ApproveDraftChunkResponse { + // New policy version after merge. + uint32 policy_version = 1; + // SHA-256 hash of the new policy. + string policy_hash = 2; +} + +// Reject a single draft chunk. +message RejectDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to reject. + string chunk_id = 2; + // Optional reason for rejection (fed to LLM context in future analysis). + string reason = 3; +} + +message RejectDraftChunkResponse {} + +// Approve all pending chunks. +message ApproveAllDraftChunksRequest { + // Sandbox name. + string name = 1; + // Include chunks with security_notes (default false: skips them). + bool include_security_flagged = 2; +} + +message ApproveAllDraftChunksResponse { + // New policy version after merge. + uint32 policy_version = 1; + // SHA-256 hash of the new policy. + string policy_hash = 2; + // Number of chunks approved. + uint32 chunks_approved = 3; + // Number of chunks skipped (security-flagged). + uint32 chunks_skipped = 4; +} + +// Edit a pending chunk in-place. +message EditDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to edit. + string chunk_id = 2; + // The modified rule (replaces existing proposed_rule). + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; +} + +message EditDraftChunkResponse {} + +// Reverse an approval (remove merged rule from active policy). +message UndoDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to undo. + string chunk_id = 2; +} + +message UndoDraftChunkResponse { + // New policy version after removal. + uint32 policy_version = 1; + // SHA-256 hash of the updated policy. + string policy_hash = 2; +} + +// Clear all pending draft chunks for a sandbox. +message ClearDraftChunksRequest { + // Sandbox name. + string name = 1; +} + +message ClearDraftChunksResponse { + // Number of chunks cleared. + uint32 chunks_cleared = 1; +} + +// Get decision history for a sandbox's draft policy. +message GetDraftHistoryRequest { + // Sandbox name. + string name = 1; +} + +message DraftHistoryEntry { + // Event timestamp (ms since epoch). + int64 timestamp_ms = 1; + // Event type: "denial_detected", "analysis_cycle", "approved", + // "rejected", "edited", "undone", "cleared". + string event_type = 2; + // Human-readable description. + string description = 3; + // Associated chunk ID (if applicable). + string chunk_id = 4; +} + +message GetDraftHistoryResponse { + // Chronological decision history. + repeated DraftHistoryEntry entries = 1; +} + +// Stored payload for a policy revision row in the generic objects table. +message PolicyRevisionPayload { + // Serialized policy contents. + openshell.sandbox.v1.SandboxPolicy policy = 1; + // Deterministic hash of the policy payload. + string hash = 2; + // Load error reported by the sandbox, if any. + string load_error = 3; + // When the policy version was reported as loaded (ms since epoch). 0 if unset. + int64 loaded_at_ms = 4; +} + +// Stored payload for a draft policy chunk row in the generic objects table. +message DraftChunkPayload { + // Proposed network_policies map key. + string rule_name = 1; + // Proposed network policy rule. + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 2; + // Human-readable explanation of why this rule is proposed. + string rationale = 3; + // Security concerns flagged by analysis (empty if none). + string security_notes = 4; + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + float confidence = 5; + // When the user approved/rejected (ms since epoch). 0 if undecided. + int64 decided_at_ms = 6; + // Denormalized endpoint host for dedup and display. + string host = 7; + // Denormalized endpoint port for dedup and display. + int32 port = 8; + // Binary path that triggered the denial. + string binary = 9; + // Current draft version for the owning sandbox. + int64 draft_version = 10; + // Gateway prover verdict for this chunk; empty until prover runs. + // Mirrors PolicyChunk.validation_result. + string validation_result = 11; + // Operator-supplied free-form rejection text; empty for non-rejected + // chunks. Mirrors PolicyChunk.rejection_reason. + string rejection_reason = 12; +} + +// Internal stored policy revision row materialized from the generic objects table. +message StoredPolicyRevision { + string id = 1; + string sandbox_id = 2; + int64 version = 3; + bytes policy_payload = 4; + string policy_hash = 5; + string status = 6; + optional string load_error = 7; + int64 created_at_ms = 8; + optional int64 loaded_at_ms = 9; +} + +// Internal stored draft chunk row materialized from the generic objects table. +message StoredDraftChunk { + string id = 1; + string sandbox_id = 2; + int64 draft_version = 3; + string status = 4; + string rule_name = 5; + bytes proposed_rule = 6; + string rationale = 7; + string security_notes = 8; + double confidence = 9; + int64 created_at_ms = 10; + optional int64 decided_at_ms = 11; + string host = 12; + int32 port = 13; + string binary = 14; + int32 hit_count = 15; + int64 first_seen_ms = 16; + int64 last_seen_ms = 17; + // Gateway prover verdict; empty until the prover runs. See PolicyChunk. + string validation_result = 18; + // Operator-supplied free-form rejection text. See PolicyChunk. + string rejection_reason = 19; +} diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go new file mode 100644 index 0000000000..46eb5d0d17 --- /dev/null +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -0,0 +1,12469 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.6 +// source: openshell.proto + +package openshellv1 + +import ( + datamodelv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + sandboxv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// High-level sandbox lifecycle phase derived by the gateway. +// +// Clients should rely on this normalized lifecycle summary for readiness and +// deletion decisions instead of interpreting raw conditions. +type SandboxPhase int32 + +const ( + SandboxPhase_SANDBOX_PHASE_UNSPECIFIED SandboxPhase = 0 + SandboxPhase_SANDBOX_PHASE_PROVISIONING SandboxPhase = 1 + SandboxPhase_SANDBOX_PHASE_READY SandboxPhase = 2 + SandboxPhase_SANDBOX_PHASE_ERROR SandboxPhase = 3 + SandboxPhase_SANDBOX_PHASE_DELETING SandboxPhase = 4 + SandboxPhase_SANDBOX_PHASE_UNKNOWN SandboxPhase = 5 +) + +// Enum value maps for SandboxPhase. +var ( + SandboxPhase_name = map[int32]string{ + 0: "SANDBOX_PHASE_UNSPECIFIED", + 1: "SANDBOX_PHASE_PROVISIONING", + 2: "SANDBOX_PHASE_READY", + 3: "SANDBOX_PHASE_ERROR", + 4: "SANDBOX_PHASE_DELETING", + 5: "SANDBOX_PHASE_UNKNOWN", + } + SandboxPhase_value = map[string]int32{ + "SANDBOX_PHASE_UNSPECIFIED": 0, + "SANDBOX_PHASE_PROVISIONING": 1, + "SANDBOX_PHASE_READY": 2, + "SANDBOX_PHASE_ERROR": 3, + "SANDBOX_PHASE_DELETING": 4, + "SANDBOX_PHASE_UNKNOWN": 5, + } +) + +func (x SandboxPhase) Enum() *SandboxPhase { + p := new(SandboxPhase) + *p = x + return p +} + +func (x SandboxPhase) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SandboxPhase) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[0].Descriptor() +} + +func (SandboxPhase) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[0] +} + +func (x SandboxPhase) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SandboxPhase.Descriptor instead. +func (SandboxPhase) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{0} +} + +type ProviderCredentialRefreshStrategy int32 + +const ( + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED ProviderCredentialRefreshStrategy = 0 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC ProviderCredentialRefreshStrategy = 1 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL ProviderCredentialRefreshStrategy = 2 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN ProviderCredentialRefreshStrategy = 3 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS ProviderCredentialRefreshStrategy = 4 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT ProviderCredentialRefreshStrategy = 5 +) + +// Enum value maps for ProviderCredentialRefreshStrategy. +var ( + ProviderCredentialRefreshStrategy_name = map[int32]string{ + 0: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED", + 1: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC", + 2: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL", + 3: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN", + 4: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS", + 5: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT", + } + ProviderCredentialRefreshStrategy_value = map[string]int32{ + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED": 0, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC": 1, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL": 2, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN": 3, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS": 4, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT": 5, + } +) + +func (x ProviderCredentialRefreshStrategy) Enum() *ProviderCredentialRefreshStrategy { + p := new(ProviderCredentialRefreshStrategy) + *p = x + return p +} + +func (x ProviderCredentialRefreshStrategy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderCredentialRefreshStrategy) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[1].Descriptor() +} + +func (ProviderCredentialRefreshStrategy) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[1] +} + +func (x ProviderCredentialRefreshStrategy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderCredentialRefreshStrategy.Descriptor instead. +func (ProviderCredentialRefreshStrategy) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{1} +} + +// Stable provider profile categories used by clients for grouping and filtering. +type ProviderProfileCategory int32 + +const ( + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED ProviderProfileCategory = 0 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER ProviderProfileCategory = 1 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE ProviderProfileCategory = 2 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT ProviderProfileCategory = 3 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL ProviderProfileCategory = 4 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING ProviderProfileCategory = 5 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA ProviderProfileCategory = 6 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE ProviderProfileCategory = 7 +) + +// Enum value maps for ProviderProfileCategory. +var ( + ProviderProfileCategory_name = map[int32]string{ + 0: "PROVIDER_PROFILE_CATEGORY_UNSPECIFIED", + 1: "PROVIDER_PROFILE_CATEGORY_OTHER", + 2: "PROVIDER_PROFILE_CATEGORY_INFERENCE", + 3: "PROVIDER_PROFILE_CATEGORY_AGENT", + 4: "PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL", + 5: "PROVIDER_PROFILE_CATEGORY_MESSAGING", + 6: "PROVIDER_PROFILE_CATEGORY_DATA", + 7: "PROVIDER_PROFILE_CATEGORY_KNOWLEDGE", + } + ProviderProfileCategory_value = map[string]int32{ + "PROVIDER_PROFILE_CATEGORY_UNSPECIFIED": 0, + "PROVIDER_PROFILE_CATEGORY_OTHER": 1, + "PROVIDER_PROFILE_CATEGORY_INFERENCE": 2, + "PROVIDER_PROFILE_CATEGORY_AGENT": 3, + "PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL": 4, + "PROVIDER_PROFILE_CATEGORY_MESSAGING": 5, + "PROVIDER_PROFILE_CATEGORY_DATA": 6, + "PROVIDER_PROFILE_CATEGORY_KNOWLEDGE": 7, + } +) + +func (x ProviderProfileCategory) Enum() *ProviderProfileCategory { + p := new(ProviderProfileCategory) + *p = x + return p +} + +func (x ProviderProfileCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderProfileCategory) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[2].Descriptor() +} + +func (ProviderProfileCategory) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[2] +} + +func (x ProviderProfileCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderProfileCategory.Descriptor instead. +func (ProviderProfileCategory) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{2} +} + +// Policy load status. +type PolicyStatus int32 + +const ( + PolicyStatus_POLICY_STATUS_UNSPECIFIED PolicyStatus = 0 + // Server received the update; sandbox has not yet loaded it. + PolicyStatus_POLICY_STATUS_PENDING PolicyStatus = 1 + // Sandbox successfully applied this policy version. + PolicyStatus_POLICY_STATUS_LOADED PolicyStatus = 2 + // Sandbox attempted to apply but failed; LKG policy remains active. + PolicyStatus_POLICY_STATUS_FAILED PolicyStatus = 3 + // A newer version was persisted before the sandbox loaded this one. + PolicyStatus_POLICY_STATUS_SUPERSEDED PolicyStatus = 4 +) + +// Enum value maps for PolicyStatus. +var ( + PolicyStatus_name = map[int32]string{ + 0: "POLICY_STATUS_UNSPECIFIED", + 1: "POLICY_STATUS_PENDING", + 2: "POLICY_STATUS_LOADED", + 3: "POLICY_STATUS_FAILED", + 4: "POLICY_STATUS_SUPERSEDED", + } + PolicyStatus_value = map[string]int32{ + "POLICY_STATUS_UNSPECIFIED": 0, + "POLICY_STATUS_PENDING": 1, + "POLICY_STATUS_LOADED": 2, + "POLICY_STATUS_FAILED": 3, + "POLICY_STATUS_SUPERSEDED": 4, + } +) + +func (x PolicyStatus) Enum() *PolicyStatus { + p := new(PolicyStatus) + *p = x + return p +} + +func (x PolicyStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PolicyStatus) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[3].Descriptor() +} + +func (PolicyStatus) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[3] +} + +func (x PolicyStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PolicyStatus.Descriptor instead. +func (PolicyStatus) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{3} +} + +// Service status enum. +type ServiceStatus int32 + +const ( + ServiceStatus_SERVICE_STATUS_UNSPECIFIED ServiceStatus = 0 + ServiceStatus_SERVICE_STATUS_HEALTHY ServiceStatus = 1 + ServiceStatus_SERVICE_STATUS_DEGRADED ServiceStatus = 2 + ServiceStatus_SERVICE_STATUS_UNHEALTHY ServiceStatus = 3 +) + +// Enum value maps for ServiceStatus. +var ( + ServiceStatus_name = map[int32]string{ + 0: "SERVICE_STATUS_UNSPECIFIED", + 1: "SERVICE_STATUS_HEALTHY", + 2: "SERVICE_STATUS_DEGRADED", + 3: "SERVICE_STATUS_UNHEALTHY", + } + ServiceStatus_value = map[string]int32{ + "SERVICE_STATUS_UNSPECIFIED": 0, + "SERVICE_STATUS_HEALTHY": 1, + "SERVICE_STATUS_DEGRADED": 2, + "SERVICE_STATUS_UNHEALTHY": 3, + } +) + +func (x ServiceStatus) Enum() *ServiceStatus { + p := new(ServiceStatus) + *p = x + return p +} + +func (x ServiceStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ServiceStatus) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[4].Descriptor() +} + +func (ServiceStatus) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[4] +} + +func (x ServiceStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ServiceStatus.Descriptor instead. +func (ServiceStatus) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{4} +} + +// IssueSandboxToken request. Empty body; identity is established by the +// authentication credentials carried in the request headers (a projected +// Kubernetes ServiceAccount JWT in the K8s driver path). +type IssueSandboxTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IssueSandboxTokenRequest) Reset() { + *x = IssueSandboxTokenRequest{} + mi := &file_openshell_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IssueSandboxTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueSandboxTokenRequest) ProtoMessage() {} + +func (x *IssueSandboxTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssueSandboxTokenRequest.ProtoReflect.Descriptor instead. +func (*IssueSandboxTokenRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{0} +} + +// IssueSandboxToken response. The supervisor caches the returned token in +// memory and presents it as `Authorization: Bearer` on every subsequent +// gateway RPC. +type IssueSandboxTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-minted JWT bound to the calling sandbox's UUID. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // Absolute expiry of the issued token, milliseconds since the epoch. 0 means + // the token is non-expiring. + ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IssueSandboxTokenResponse) Reset() { + *x = IssueSandboxTokenResponse{} + mi := &file_openshell_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IssueSandboxTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueSandboxTokenResponse) ProtoMessage() {} + +func (x *IssueSandboxTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssueSandboxTokenResponse.ProtoReflect.Descriptor instead. +func (*IssueSandboxTokenResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{1} +} + +func (x *IssueSandboxTokenResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *IssueSandboxTokenResponse) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +// RefreshSandboxToken request. Empty body; the calling principal must +// already be a sandbox principal (i.e. the request carries a still-valid +// gateway-minted JWT in its Authorization header). +type RefreshSandboxTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RefreshSandboxTokenRequest) Reset() { + *x = RefreshSandboxTokenRequest{} + mi := &file_openshell_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RefreshSandboxTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RefreshSandboxTokenRequest) ProtoMessage() {} + +func (x *RefreshSandboxTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RefreshSandboxTokenRequest.ProtoReflect.Descriptor instead. +func (*RefreshSandboxTokenRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{2} +} + +// RefreshSandboxToken response. The new token replaces the supervisor's +// in-memory bearer credential. +type RefreshSandboxTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Fresh gateway-minted JWT bound to the same sandbox UUID. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // Absolute expiry of the new token, milliseconds since the epoch. 0 means + // the token is non-expiring. + ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RefreshSandboxTokenResponse) Reset() { + *x = RefreshSandboxTokenResponse{} + mi := &file_openshell_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RefreshSandboxTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RefreshSandboxTokenResponse) ProtoMessage() {} + +func (x *RefreshSandboxTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RefreshSandboxTokenResponse.ProtoReflect.Descriptor instead. +func (*RefreshSandboxTokenResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{3} +} + +func (x *RefreshSandboxTokenResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *RefreshSandboxTokenResponse) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +// Health check request. +type HealthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + mi := &file_openshell_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{4} +} + +// Health check response. +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Service status. + Status ServiceStatus `protobuf:"varint,1,opt,name=status,proto3,enum=openshell.v1.ServiceStatus" json:"status,omitempty"` + // Service version. + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_openshell_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{5} +} + +func (x *HealthResponse) GetStatus() ServiceStatus { + if x != nil { + return x.Status + } + return ServiceStatus_SERVICE_STATUS_UNSPECIFIED +} + +func (x *HealthResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +// Public sandbox resource exposed by the OpenShell API. +// +// This is the canonical gateway-owned view of a sandbox. It merges user intent +// (`spec`) with gateway-managed metadata and status derived from internal +// compute-driver observations. +// +// Note: The `namespace` field has been removed from the public API. It remains +// in the internal `DriverSandbox` message as a compute-driver implementation detail. +type Sandbox struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Desired sandbox configuration submitted through the API. + Spec *SandboxSpec `protobuf:"bytes,2,opt,name=spec,proto3" json:"spec,omitempty"` + // Latest user-facing observed status derived by the gateway. + Status *SandboxStatus `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Sandbox) Reset() { + *x = Sandbox{} + mi := &file_openshell_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Sandbox) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Sandbox) ProtoMessage() {} + +func (x *Sandbox) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Sandbox.ProtoReflect.Descriptor instead. +func (*Sandbox) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{6} +} + +func (x *Sandbox) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Sandbox) GetSpec() *SandboxSpec { + if x != nil { + return x.Spec + } + return nil +} + +func (x *Sandbox) GetStatus() *SandboxStatus { + if x != nil { + return x.Status + } + return nil +} + +// Desired sandbox configuration provided through the public API. +type SandboxSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Log level exposed to processes running inside the sandbox. + LogLevel string `protobuf:"bytes,1,opt,name=log_level,json=logLevel,proto3" json:"log_level,omitempty"` + // Environment variables injected into the sandbox runtime. + Environment map[string]string `protobuf:"bytes,5,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Container or VM template used to provision the sandbox. + Template *SandboxTemplate `protobuf:"bytes,6,opt,name=template,proto3" json:"template,omitempty"` + // Required sandbox policy configuration. + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` + // Provider names to attach to this sandbox. + Providers []string `protobuf:"bytes,8,rep,name=providers,proto3" json:"providers,omitempty"` + // Portable resource requirements used by the gateway for driver selection + // and by drivers for provisioning. + ResourceRequirements *ResourceRequirements `protobuf:"bytes,9,opt,name=resource_requirements,json=resourceRequirements,proto3" json:"resource_requirements,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxSpec) Reset() { + *x = SandboxSpec{} + mi := &file_openshell_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxSpec) ProtoMessage() {} + +func (x *SandboxSpec) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxSpec.ProtoReflect.Descriptor instead. +func (*SandboxSpec) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{7} +} + +func (x *SandboxSpec) GetLogLevel() string { + if x != nil { + return x.LogLevel + } + return "" +} + +func (x *SandboxSpec) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *SandboxSpec) GetTemplate() *SandboxTemplate { + if x != nil { + return x.Template + } + return nil +} + +func (x *SandboxSpec) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *SandboxSpec) GetProviders() []string { + if x != nil { + return x.Providers + } + return nil +} + +func (x *SandboxSpec) GetResourceRequirements() *ResourceRequirements { + if x != nil { + return x.ResourceRequirements + } + return nil +} + +type ResourceRequirements struct { + state protoimpl.MessageState `protogen:"open.v1"` + // GPU requirements for the sandbox. Presence indicates a GPU request. + Gpu *GpuResourceRequirements `protobuf:"bytes,1,opt,name=gpu,proto3" json:"gpu,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceRequirements) Reset() { + *x = ResourceRequirements{} + mi := &file_openshell_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceRequirements) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceRequirements) ProtoMessage() {} + +func (x *ResourceRequirements) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceRequirements.ProtoReflect.Descriptor instead. +func (*ResourceRequirements) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{8} +} + +func (x *ResourceRequirements) GetGpu() *GpuResourceRequirements { + if x != nil { + return x.Gpu + } + return nil +} + +// Public GPU resource requirements. +type GpuResourceRequirements struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional number of GPUs requested. When omitted, the request is for one + // GPU using the selected driver's default assignment behavior. + Count *uint32 `protobuf:"varint,1,opt,name=count,proto3,oneof" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GpuResourceRequirements) Reset() { + *x = GpuResourceRequirements{} + mi := &file_openshell_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GpuResourceRequirements) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GpuResourceRequirements) ProtoMessage() {} + +func (x *GpuResourceRequirements) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GpuResourceRequirements.ProtoReflect.Descriptor instead. +func (*GpuResourceRequirements) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{9} +} + +func (x *GpuResourceRequirements) GetCount() uint32 { + if x != nil && x.Count != nil { + return *x.Count + } + return 0 +} + +// Public sandbox template mapped onto compute-driver template inputs. +type SandboxTemplate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Fully-qualified OCI image reference used to boot the sandbox. + Image string `protobuf:"bytes,1,opt,name=image,proto3" json:"image,omitempty"` + // Optional runtime class name requested from the compute platform. + RuntimeClassName string `protobuf:"bytes,2,opt,name=runtime_class_name,json=runtimeClassName,proto3" json:"runtime_class_name,omitempty"` + // Optional agent socket path exposed to the workload. + AgentSocket string `protobuf:"bytes,3,opt,name=agent_socket,json=agentSocket,proto3" json:"agent_socket,omitempty"` + // Labels applied to compute-platform resources for this sandbox. + Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Annotations applied to compute-platform resources for this sandbox. + Annotations map[string]string `protobuf:"bytes,5,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Additional environment variables injected by the template. + Environment map[string]string `protobuf:"bytes,6,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Platform-specific compute resource requirements and limits. + Resources *structpb.Struct `protobuf:"bytes,7,opt,name=resources,proto3" json:"resources,omitempty"` + // Optional platform-specific volume claim templates. + VolumeClaimTemplates *structpb.Struct `protobuf:"bytes,9,opt,name=volume_claim_templates,json=volumeClaimTemplates,proto3" json:"volume_claim_templates,omitempty"` + // Enable Kubernetes user namespace isolation (hostUsers: false). + // When true, container UID 0 maps to a non-root host UID and capabilities + // become namespaced. Requires Kubernetes 1.33+ with user namespace support + // available (beta through 1.35, GA in 1.36+) and a supporting runtime. + // When unset, the cluster-wide default is used. + UserNamespaces *bool `protobuf:"varint,10,opt,name=user_namespaces,json=userNamespaces,proto3,oneof" json:"user_namespaces,omitempty"` + // Driver-keyed opaque config envelope supplied by the caller. + // The gateway selects the block matching the active compute driver and + // forwards only that inner Struct to DriverSandboxTemplate.driver_config. + // The selected driver owns nested schema validation. + DriverConfig *structpb.Struct `protobuf:"bytes,11,opt,name=driver_config,json=driverConfig,proto3" json:"driver_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxTemplate) Reset() { + *x = SandboxTemplate{} + mi := &file_openshell_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxTemplate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxTemplate) ProtoMessage() {} + +func (x *SandboxTemplate) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxTemplate.ProtoReflect.Descriptor instead. +func (*SandboxTemplate) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{10} +} + +func (x *SandboxTemplate) GetImage() string { + if x != nil { + return x.Image + } + return "" +} + +func (x *SandboxTemplate) GetRuntimeClassName() string { + if x != nil { + return x.RuntimeClassName + } + return "" +} + +func (x *SandboxTemplate) GetAgentSocket() string { + if x != nil { + return x.AgentSocket + } + return "" +} + +func (x *SandboxTemplate) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *SandboxTemplate) GetAnnotations() map[string]string { + if x != nil { + return x.Annotations + } + return nil +} + +func (x *SandboxTemplate) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *SandboxTemplate) GetResources() *structpb.Struct { + if x != nil { + return x.Resources + } + return nil +} + +func (x *SandboxTemplate) GetVolumeClaimTemplates() *structpb.Struct { + if x != nil { + return x.VolumeClaimTemplates + } + return nil +} + +func (x *SandboxTemplate) GetUserNamespaces() bool { + if x != nil && x.UserNamespaces != nil { + return *x.UserNamespaces + } + return false +} + +func (x *SandboxTemplate) GetDriverConfig() *structpb.Struct { + if x != nil { + return x.DriverConfig + } + return nil +} + +// User-facing sandbox status derived by the gateway from compute-driver observations. +// +// Public status does not embed driver-only flags such as `deleting`. +type SandboxStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Compute-platform sandbox object name. + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Name of the agent pod or equivalent runtime instance. + AgentPod string `protobuf:"bytes,2,opt,name=agent_pod,json=agentPod,proto3" json:"agent_pod,omitempty"` + // File descriptor or endpoint for reaching the agent service, when available. + AgentFd string `protobuf:"bytes,3,opt,name=agent_fd,json=agentFd,proto3" json:"agent_fd,omitempty"` + // File descriptor or endpoint for reaching the sandbox service, when available. + SandboxFd string `protobuf:"bytes,4,opt,name=sandbox_fd,json=sandboxFd,proto3" json:"sandbox_fd,omitempty"` + // Latest user-facing readiness and lifecycle conditions. + Conditions []*SandboxCondition `protobuf:"bytes,5,rep,name=conditions,proto3" json:"conditions,omitempty"` + // Gateway-derived lifecycle summary. + Phase SandboxPhase `protobuf:"varint,6,opt,name=phase,proto3,enum=openshell.v1.SandboxPhase" json:"phase,omitempty"` + // Currently active policy version (updated when sandbox reports loaded). + CurrentPolicyVersion uint32 `protobuf:"varint,7,opt,name=current_policy_version,json=currentPolicyVersion,proto3" json:"current_policy_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxStatus) Reset() { + *x = SandboxStatus{} + mi := &file_openshell_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxStatus) ProtoMessage() {} + +func (x *SandboxStatus) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxStatus.ProtoReflect.Descriptor instead. +func (*SandboxStatus) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{11} +} + +func (x *SandboxStatus) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *SandboxStatus) GetAgentPod() string { + if x != nil { + return x.AgentPod + } + return "" +} + +func (x *SandboxStatus) GetAgentFd() string { + if x != nil { + return x.AgentFd + } + return "" +} + +func (x *SandboxStatus) GetSandboxFd() string { + if x != nil { + return x.SandboxFd + } + return "" +} + +func (x *SandboxStatus) GetConditions() []*SandboxCondition { + if x != nil { + return x.Conditions + } + return nil +} + +func (x *SandboxStatus) GetPhase() SandboxPhase { + if x != nil { + return x.Phase + } + return SandboxPhase_SANDBOX_PHASE_UNSPECIFIED +} + +func (x *SandboxStatus) GetCurrentPolicyVersion() uint32 { + if x != nil { + return x.CurrentPolicyVersion + } + return 0 +} + +// User-facing sandbox condition derived from driver-native conditions. +type SandboxCondition struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Condition class, typically mirroring the underlying platform condition type. + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + // Condition status value such as `True`, `False`, or `Unknown`. + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + // Short machine-readable reason associated with the condition. + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + // Human-readable condition message. + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + // Timestamp reported by the underlying platform for the last transition. + LastTransitionTime string `protobuf:"bytes,5,opt,name=last_transition_time,json=lastTransitionTime,proto3" json:"last_transition_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxCondition) Reset() { + *x = SandboxCondition{} + mi := &file_openshell_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxCondition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxCondition) ProtoMessage() {} + +func (x *SandboxCondition) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxCondition.ProtoReflect.Descriptor instead. +func (*SandboxCondition) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{12} +} + +func (x *SandboxCondition) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *SandboxCondition) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *SandboxCondition) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *SandboxCondition) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *SandboxCondition) GetLastTransitionTime() string { + if x != nil { + return x.LastTransitionTime + } + return "" +} + +// Public platform event exposed on the sandbox watch stream. +type PlatformEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Event timestamp in milliseconds since epoch. + TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + // Event source (e.g. "kubernetes", "docker", "process"). + Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` + // Event type/severity (e.g. "Normal", "Warning"). + Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` + // Short reason code (e.g. "Started", "Pulled", "Failed"). + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` + // Human-readable event message. + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + // Optional metadata as key-value pairs. + Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlatformEvent) Reset() { + *x = PlatformEvent{} + mi := &file_openshell_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlatformEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlatformEvent) ProtoMessage() {} + +func (x *PlatformEvent) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlatformEvent.ProtoReflect.Descriptor instead. +func (*PlatformEvent) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{13} +} + +func (x *PlatformEvent) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *PlatformEvent) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *PlatformEvent) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *PlatformEvent) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *PlatformEvent) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *PlatformEvent) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +// Create sandbox request. +type CreateSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Spec *SandboxSpec `protobuf:"bytes,1,opt,name=spec,proto3" json:"spec,omitempty"` + // Optional user-supplied sandbox name. When empty the server generates one. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Optional labels for the sandbox (key-value metadata). + Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSandboxRequest) Reset() { + *x = CreateSandboxRequest{} + mi := &file_openshell_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSandboxRequest) ProtoMessage() {} + +func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSandboxRequest.ProtoReflect.Descriptor instead. +func (*CreateSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateSandboxRequest) GetSpec() *SandboxSpec { + if x != nil { + return x.Spec + } + return nil +} + +func (x *CreateSandboxRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateSandboxRequest) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +// Get sandbox request. +type GetSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxRequest) Reset() { + *x = GetSandboxRequest{} + mi := &file_openshell_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxRequest) ProtoMessage() {} + +func (x *GetSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{15} +} + +func (x *GetSandboxRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// List sandboxes request. +type ListSandboxesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + Offset uint32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + LabelSelector string `protobuf:"bytes,3,opt,name=label_selector,json=labelSelector,proto3" json:"label_selector,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxesRequest) Reset() { + *x = ListSandboxesRequest{} + mi := &file_openshell_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxesRequest) ProtoMessage() {} + +func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxesRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{16} +} + +func (x *ListSandboxesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSandboxesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *ListSandboxesRequest) GetLabelSelector() string { + if x != nil { + return x.LabelSelector + } + return "" +} + +// List providers attached to a sandbox request. +type ListSandboxProvidersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxProvidersRequest) Reset() { + *x = ListSandboxProvidersRequest{} + mi := &file_openshell_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxProvidersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxProvidersRequest) ProtoMessage() {} + +func (x *ListSandboxProvidersRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxProvidersRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxProvidersRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{17} +} + +func (x *ListSandboxProvidersRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +// Attach provider to sandbox request. +type AttachSandboxProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Provider name to attach. + ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachSandboxProviderRequest) Reset() { + *x = AttachSandboxProviderRequest{} + mi := &file_openshell_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachSandboxProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachSandboxProviderRequest) ProtoMessage() {} + +func (x *AttachSandboxProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttachSandboxProviderRequest.ProtoReflect.Descriptor instead. +func (*AttachSandboxProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{18} +} + +func (x *AttachSandboxProviderRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *AttachSandboxProviderRequest) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *AttachSandboxProviderRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +// Detach provider from sandbox request. +type DetachSandboxProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Provider name to detach. + ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachSandboxProviderRequest) Reset() { + *x = DetachSandboxProviderRequest{} + mi := &file_openshell_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachSandboxProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachSandboxProviderRequest) ProtoMessage() {} + +func (x *DetachSandboxProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DetachSandboxProviderRequest.ProtoReflect.Descriptor instead. +func (*DetachSandboxProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{19} +} + +func (x *DetachSandboxProviderRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *DetachSandboxProviderRequest) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *DetachSandboxProviderRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +// Delete sandbox request. +type DeleteSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSandboxRequest) Reset() { + *x = DeleteSandboxRequest{} + mi := &file_openshell_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSandboxRequest) ProtoMessage() {} + +func (x *DeleteSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSandboxRequest.ProtoReflect.Descriptor instead. +func (*DeleteSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{20} +} + +func (x *DeleteSandboxRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// Sandbox response. +type SandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxResponse) Reset() { + *x = SandboxResponse{} + mi := &file_openshell_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxResponse) ProtoMessage() {} + +func (x *SandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxResponse.ProtoReflect.Descriptor instead. +func (*SandboxResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{21} +} + +func (x *SandboxResponse) GetSandbox() *Sandbox { + if x != nil { + return x.Sandbox + } + return nil +} + +// List sandboxes response. +type ListSandboxesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandboxes []*Sandbox `protobuf:"bytes,1,rep,name=sandboxes,proto3" json:"sandboxes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxesResponse) Reset() { + *x = ListSandboxesResponse{} + mi := &file_openshell_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxesResponse) ProtoMessage() {} + +func (x *ListSandboxesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxesResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{22} +} + +func (x *ListSandboxesResponse) GetSandboxes() []*Sandbox { + if x != nil { + return x.Sandboxes + } + return nil +} + +// List providers attached to a sandbox response. +type ListSandboxProvidersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Providers []*datamodelv1.Provider `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxProvidersResponse) Reset() { + *x = ListSandboxProvidersResponse{} + mi := &file_openshell_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxProvidersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxProvidersResponse) ProtoMessage() {} + +func (x *ListSandboxProvidersResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxProvidersResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxProvidersResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{23} +} + +func (x *ListSandboxProvidersResponse) GetProviders() []*datamodelv1.Provider { + if x != nil { + return x.Providers + } + return nil +} + +// Attach provider to sandbox response. +type AttachSandboxProviderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // True when the provider was newly attached. False means it was already attached. + Attached bool `protobuf:"varint,2,opt,name=attached,proto3" json:"attached,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachSandboxProviderResponse) Reset() { + *x = AttachSandboxProviderResponse{} + mi := &file_openshell_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachSandboxProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachSandboxProviderResponse) ProtoMessage() {} + +func (x *AttachSandboxProviderResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttachSandboxProviderResponse.ProtoReflect.Descriptor instead. +func (*AttachSandboxProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{24} +} + +func (x *AttachSandboxProviderResponse) GetSandbox() *Sandbox { + if x != nil { + return x.Sandbox + } + return nil +} + +func (x *AttachSandboxProviderResponse) GetAttached() bool { + if x != nil { + return x.Attached + } + return false +} + +// Detach provider from sandbox response. +type DetachSandboxProviderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // True when the provider was removed. False means it was not attached. + Detached bool `protobuf:"varint,2,opt,name=detached,proto3" json:"detached,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachSandboxProviderResponse) Reset() { + *x = DetachSandboxProviderResponse{} + mi := &file_openshell_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachSandboxProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachSandboxProviderResponse) ProtoMessage() {} + +func (x *DetachSandboxProviderResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DetachSandboxProviderResponse.ProtoReflect.Descriptor instead. +func (*DetachSandboxProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{25} +} + +func (x *DetachSandboxProviderResponse) GetSandbox() *Sandbox { + if x != nil { + return x.Sandbox + } + return nil +} + +func (x *DetachSandboxProviderResponse) GetDetached() bool { + if x != nil { + return x.Detached + } + return false +} + +// Delete sandbox response. +type DeleteSandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSandboxResponse) Reset() { + *x = DeleteSandboxResponse{} + mi := &file_openshell_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSandboxResponse) ProtoMessage() {} + +func (x *DeleteSandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSandboxResponse.ProtoReflect.Descriptor instead. +func (*DeleteSandboxResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{26} +} + +func (x *DeleteSandboxResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Create SSH session request. +type CreateSshSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSshSessionRequest) Reset() { + *x = CreateSshSessionRequest{} + mi := &file_openshell_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSshSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSshSessionRequest) ProtoMessage() {} + +func (x *CreateSshSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSshSessionRequest.ProtoReflect.Descriptor instead. +func (*CreateSshSessionRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{27} +} + +func (x *CreateSshSessionRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +// Create SSH session response. +// +// Fields are interpolated into an SSH `ProxyCommand` string that OpenSSH +// executes through `/bin/sh -c` on the caller's workstation. Servers MUST +// uphold the charset contract below; clients MUST reject responses that +// violate it. The client's own escaping provides defense-in-depth, but +// narrow charsets close injection vectors at the trust boundary. +type CreateSshSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. [A-Za-z0-9._-]{1,128}. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Session token for the gateway tunnel. URL-safe ASCII + // ([A-Za-z0-9._~+/=-]) up to 4096 bytes. No shell metacharacters or + // whitespace. + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + // Gateway host for SSH proxy connection. IPv4 address, bracketed IPv6 + // address, or DNS hostname (Punycode-encoded for IDN). Alphanumeric plus + // `.-:[]` only, up to 253 bytes. + GatewayHost string `protobuf:"bytes,3,opt,name=gateway_host,json=gatewayHost,proto3" json:"gateway_host,omitempty"` + // Gateway port for SSH proxy connection. Must be in range 1..=65535. + GatewayPort uint32 `protobuf:"varint,4,opt,name=gateway_port,json=gatewayPort,proto3" json:"gateway_port,omitempty"` + // Gateway scheme. Must be exactly "http" or "https". + GatewayScheme string `protobuf:"bytes,5,opt,name=gateway_scheme,json=gatewayScheme,proto3" json:"gateway_scheme,omitempty"` + // Optional host key fingerprint. If non-empty, [A-Za-z0-9:+/=-] only. + HostKeyFingerprint string `protobuf:"bytes,7,opt,name=host_key_fingerprint,json=hostKeyFingerprint,proto3" json:"host_key_fingerprint,omitempty"` + // Expiry timestamp in milliseconds since epoch. 0 means no expiry. + ExpiresAtMs int64 `protobuf:"varint,8,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSshSessionResponse) Reset() { + *x = CreateSshSessionResponse{} + mi := &file_openshell_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSshSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSshSessionResponse) ProtoMessage() {} + +func (x *CreateSshSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSshSessionResponse.ProtoReflect.Descriptor instead. +func (*CreateSshSessionResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{28} +} + +func (x *CreateSshSessionResponse) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *CreateSshSessionResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *CreateSshSessionResponse) GetGatewayHost() string { + if x != nil { + return x.GatewayHost + } + return "" +} + +func (x *CreateSshSessionResponse) GetGatewayPort() uint32 { + if x != nil { + return x.GatewayPort + } + return 0 +} + +func (x *CreateSshSessionResponse) GetGatewayScheme() string { + if x != nil { + return x.GatewayScheme + } + return "" +} + +func (x *CreateSshSessionResponse) GetHostKeyFingerprint() string { + if x != nil { + return x.HostKeyFingerprint + } + return "" +} + +func (x *CreateSshSessionResponse) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +// Request to expose an HTTP service running inside a sandbox. +type ExposeServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Service name within the sandbox. + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + // Loopback TCP port inside the sandbox. + TargetPort uint32 `protobuf:"varint,3,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` + // Whether to print/use the browser-facing service URL. + Domain bool `protobuf:"varint,4,opt,name=domain,proto3" json:"domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceRequest) Reset() { + *x = ExposeServiceRequest{} + mi := &file_openshell_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceRequest) ProtoMessage() {} + +func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. +func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{29} +} + +func (x *ExposeServiceRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *ExposeServiceRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *ExposeServiceRequest) GetTargetPort() uint32 { + if x != nil { + return x.TargetPort + } + return 0 +} + +func (x *ExposeServiceRequest) GetDomain() bool { + if x != nil { + return x.Domain + } + return false +} + +// Request to fetch an exposed sandbox service endpoint. +type GetServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Service name within the sandbox. Empty selects the unnamed endpoint. + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServiceRequest) Reset() { + *x = GetServiceRequest{} + mi := &file_openshell_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServiceRequest) ProtoMessage() {} + +func (x *GetServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServiceRequest.ProtoReflect.Descriptor instead. +func (*GetServiceRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{30} +} + +func (x *GetServiceRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *GetServiceRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +// Request to list exposed sandbox service endpoints. +type ListServicesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional sandbox name. Empty lists endpoints for all sandboxes. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Page size. Zero uses the server default. + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + // Page offset. + Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServicesRequest) Reset() { + *x = ListServicesRequest{} + mi := &file_openshell_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServicesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServicesRequest) ProtoMessage() {} + +func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListServicesRequest.ProtoReflect.Descriptor instead. +func (*ListServicesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{31} +} + +func (x *ListServicesRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *ListServicesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListServicesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +// Response containing exposed sandbox service endpoints. +type ListServicesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Services []*ServiceEndpointResponse `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServicesResponse) Reset() { + *x = ListServicesResponse{} + mi := &file_openshell_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServicesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServicesResponse) ProtoMessage() {} + +func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListServicesResponse.ProtoReflect.Descriptor instead. +func (*ListServicesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{32} +} + +func (x *ListServicesResponse) GetServices() []*ServiceEndpointResponse { + if x != nil { + return x.Services + } + return nil +} + +// Request to delete an exposed sandbox service endpoint. +type DeleteServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Service name within the sandbox. Empty selects the unnamed endpoint. + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteServiceRequest) Reset() { + *x = DeleteServiceRequest{} + mi := &file_openshell_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteServiceRequest) ProtoMessage() {} + +func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteServiceRequest.ProtoReflect.Descriptor instead. +func (*DeleteServiceRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{33} +} + +func (x *DeleteServiceRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *DeleteServiceRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +// Response for deleting an exposed sandbox service endpoint. +type DeleteServiceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True when an endpoint existed and was deleted. + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteServiceResponse) Reset() { + *x = DeleteServiceResponse{} + mi := &file_openshell_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteServiceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteServiceResponse) ProtoMessage() {} + +func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteServiceResponse.ProtoReflect.Descriptor instead. +func (*DeleteServiceResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{34} +} + +func (x *DeleteServiceResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Persisted sandbox service endpoint. +type ServiceEndpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata. + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Sandbox object ID. + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Sandbox name. + SandboxName string `protobuf:"bytes,3,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Service name within the sandbox. + ServiceName string `protobuf:"bytes,4,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + // Loopback TCP port inside the sandbox. + TargetPort uint32 `protobuf:"varint,5,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` + // Whether browser-facing service routing is enabled for this endpoint. + Domain bool `protobuf:"varint,6,opt,name=domain,proto3" json:"domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceEndpoint) Reset() { + *x = ServiceEndpoint{} + mi := &file_openshell_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceEndpoint) ProtoMessage() {} + +func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceEndpoint.ProtoReflect.Descriptor instead. +func (*ServiceEndpoint) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{35} +} + +func (x *ServiceEndpoint) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *ServiceEndpoint) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ServiceEndpoint) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *ServiceEndpoint) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *ServiceEndpoint) GetTargetPort() uint32 { + if x != nil { + return x.TargetPort + } + return 0 +} + +func (x *ServiceEndpoint) GetDomain() bool { + if x != nil { + return x.Domain + } + return false +} + +// Response containing a service endpoint and, when available, its local URL. +type ServiceEndpointResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Endpoint *ServiceEndpoint `protobuf:"bytes,1,opt,name=endpoint,proto3" json:"endpoint,omitempty"` + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceEndpointResponse) Reset() { + *x = ServiceEndpointResponse{} + mi := &file_openshell_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceEndpointResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceEndpointResponse) ProtoMessage() {} + +func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceEndpointResponse.ProtoReflect.Descriptor instead. +func (*ServiceEndpointResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{36} +} + +func (x *ServiceEndpointResponse) GetEndpoint() *ServiceEndpoint { + if x != nil { + return x.Endpoint + } + return nil +} + +func (x *ServiceEndpointResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +// Revoke SSH session request. +type RevokeSshSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Session token to revoke. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeSshSessionRequest) Reset() { + *x = RevokeSshSessionRequest{} + mi := &file_openshell_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeSshSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeSshSessionRequest) ProtoMessage() {} + +func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeSshSessionRequest.ProtoReflect.Descriptor instead. +func (*RevokeSshSessionRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{37} +} + +func (x *RevokeSshSessionRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +// Revoke SSH session response. +type RevokeSshSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True when a session was revoked. + Revoked bool `protobuf:"varint,1,opt,name=revoked,proto3" json:"revoked,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeSshSessionResponse) Reset() { + *x = RevokeSshSessionResponse{} + mi := &file_openshell_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeSshSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeSshSessionResponse) ProtoMessage() {} + +func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeSshSessionResponse.ProtoReflect.Descriptor instead. +func (*RevokeSshSessionResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{38} +} + +func (x *RevokeSshSessionResponse) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +// Execute command request. +type ExecSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Command and arguments. + Command []string `protobuf:"bytes,2,rep,name=command,proto3" json:"command,omitempty"` + // Optional working directory. + Workdir string `protobuf:"bytes,3,opt,name=workdir,proto3" json:"workdir,omitempty"` + // Optional environment overrides. + Environment map[string]string `protobuf:"bytes,4,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional timeout in seconds. 0 means no timeout. + TimeoutSeconds uint32 `protobuf:"varint,5,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + // Optional stdin payload passed to the command. + Stdin []byte `protobuf:"bytes,6,opt,name=stdin,proto3" json:"stdin,omitempty"` + // Request a pseudo-terminal for the remote command. + Tty bool `protobuf:"varint,7,opt,name=tty,proto3" json:"tty,omitempty"` + // Initial terminal columns (used when tty=true, 0 = use default). + Cols uint32 `protobuf:"varint,8,opt,name=cols,proto3" json:"cols,omitempty"` + // Initial terminal rows (used when tty=true, 0 = use default). + Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxRequest) Reset() { + *x = ExecSandboxRequest{} + mi := &file_openshell_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxRequest) ProtoMessage() {} + +func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecSandboxRequest.ProtoReflect.Descriptor instead. +func (*ExecSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{39} +} + +func (x *ExecSandboxRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ExecSandboxRequest) GetCommand() []string { + if x != nil { + return x.Command + } + return nil +} + +func (x *ExecSandboxRequest) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +func (x *ExecSandboxRequest) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *ExecSandboxRequest) GetTimeoutSeconds() uint32 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + +func (x *ExecSandboxRequest) GetStdin() []byte { + if x != nil { + return x.Stdin + } + return nil +} + +func (x *ExecSandboxRequest) GetTty() bool { + if x != nil { + return x.Tty + } + return false +} + +func (x *ExecSandboxRequest) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *ExecSandboxRequest) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +// One stdout chunk from a sandbox exec. +type ExecSandboxStdout struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxStdout) Reset() { + *x = ExecSandboxStdout{} + mi := &file_openshell_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxStdout) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxStdout) ProtoMessage() {} + +func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecSandboxStdout.ProtoReflect.Descriptor instead. +func (*ExecSandboxStdout) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{40} +} + +func (x *ExecSandboxStdout) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// One stderr chunk from a sandbox exec. +type ExecSandboxStderr struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxStderr) Reset() { + *x = ExecSandboxStderr{} + mi := &file_openshell_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxStderr) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxStderr) ProtoMessage() {} + +func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecSandboxStderr.ProtoReflect.Descriptor instead. +func (*ExecSandboxStderr) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{41} +} + +func (x *ExecSandboxStderr) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// Final exit status for a sandbox exec. +type ExecSandboxExit struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxExit) Reset() { + *x = ExecSandboxExit{} + mi := &file_openshell_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxExit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxExit) ProtoMessage() {} + +func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecSandboxExit.ProtoReflect.Descriptor instead. +func (*ExecSandboxExit) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{42} +} + +func (x *ExecSandboxExit) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +// One event in a sandbox exec stream. +type ExecSandboxEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ExecSandboxEvent_Stdout + // *ExecSandboxEvent_Stderr + // *ExecSandboxEvent_Exit + Payload isExecSandboxEvent_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxEvent) Reset() { + *x = ExecSandboxEvent{} + mi := &file_openshell_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxEvent) ProtoMessage() {} + +func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecSandboxEvent.ProtoReflect.Descriptor instead. +func (*ExecSandboxEvent) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{43} +} + +func (x *ExecSandboxEvent) GetPayload() isExecSandboxEvent_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ExecSandboxEvent) GetStdout() *ExecSandboxStdout { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ExecSandboxEvent) GetStderr() *ExecSandboxStderr { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ExecSandboxEvent) GetExit() *ExecSandboxExit { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxEvent_Exit); ok { + return x.Exit + } + } + return nil +} + +type isExecSandboxEvent_Payload interface { + isExecSandboxEvent_Payload() +} + +type ExecSandboxEvent_Stdout struct { + Stdout *ExecSandboxStdout `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ExecSandboxEvent_Stderr struct { + Stderr *ExecSandboxStderr `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ExecSandboxEvent_Exit struct { + Exit *ExecSandboxExit `protobuf:"bytes,3,opt,name=exit,proto3,oneof"` +} + +func (*ExecSandboxEvent_Stdout) isExecSandboxEvent_Payload() {} + +func (*ExecSandboxEvent_Stderr) isExecSandboxEvent_Payload() {} + +func (*ExecSandboxEvent_Exit) isExecSandboxEvent_Payload() {} + +// Initial frame for one TCP forward stream. +type TcpForwardInit struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Optional service identifier for audit/correlation. + ServiceId string `protobuf:"bytes,4,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` + // Target the gateway should request from the supervisor. + // + // Types that are valid to be assigned to Target: + // + // *TcpForwardInit_Ssh + // *TcpForwardInit_Tcp + Target isTcpForwardInit_Target `protobuf_oneof:"target"` + // Optional target-specific authorization token. SSH targets use this as the + // short-lived SSH session token issued by CreateSshSession. + AuthorizationToken string `protobuf:"bytes,7,opt,name=authorization_token,json=authorizationToken,proto3" json:"authorization_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TcpForwardInit) Reset() { + *x = TcpForwardInit{} + mi := &file_openshell_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TcpForwardInit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TcpForwardInit) ProtoMessage() {} + +func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TcpForwardInit.ProtoReflect.Descriptor instead. +func (*TcpForwardInit) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{44} +} + +func (x *TcpForwardInit) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *TcpForwardInit) GetServiceId() string { + if x != nil { + return x.ServiceId + } + return "" +} + +func (x *TcpForwardInit) GetTarget() isTcpForwardInit_Target { + if x != nil { + return x.Target + } + return nil +} + +func (x *TcpForwardInit) GetSsh() *SshRelayTarget { + if x != nil { + if x, ok := x.Target.(*TcpForwardInit_Ssh); ok { + return x.Ssh + } + } + return nil +} + +func (x *TcpForwardInit) GetTcp() *TcpRelayTarget { + if x != nil { + if x, ok := x.Target.(*TcpForwardInit_Tcp); ok { + return x.Tcp + } + } + return nil +} + +func (x *TcpForwardInit) GetAuthorizationToken() string { + if x != nil { + return x.AuthorizationToken + } + return "" +} + +type isTcpForwardInit_Target interface { + isTcpForwardInit_Target() +} + +type TcpForwardInit_Ssh struct { + Ssh *SshRelayTarget `protobuf:"bytes,5,opt,name=ssh,proto3,oneof"` +} + +type TcpForwardInit_Tcp struct { + Tcp *TcpRelayTarget `protobuf:"bytes,6,opt,name=tcp,proto3,oneof"` +} + +func (*TcpForwardInit_Ssh) isTcpForwardInit_Target() {} + +func (*TcpForwardInit_Tcp) isTcpForwardInit_Target() {} + +// A single frame on the CLI-to-gateway TCP forward stream. +type TcpForwardFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *TcpForwardFrame_Init + // *TcpForwardFrame_Data + Payload isTcpForwardFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TcpForwardFrame) Reset() { + *x = TcpForwardFrame{} + mi := &file_openshell_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TcpForwardFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TcpForwardFrame) ProtoMessage() {} + +func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TcpForwardFrame.ProtoReflect.Descriptor instead. +func (*TcpForwardFrame) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{45} +} + +func (x *TcpForwardFrame) GetPayload() isTcpForwardFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *TcpForwardFrame) GetInit() *TcpForwardInit { + if x != nil { + if x, ok := x.Payload.(*TcpForwardFrame_Init); ok { + return x.Init + } + } + return nil +} + +func (x *TcpForwardFrame) GetData() []byte { + if x != nil { + if x, ok := x.Payload.(*TcpForwardFrame_Data); ok { + return x.Data + } + } + return nil +} + +type isTcpForwardFrame_Payload interface { + isTcpForwardFrame_Payload() +} + +type TcpForwardFrame_Init struct { + Init *TcpForwardInit `protobuf:"bytes,1,opt,name=init,proto3,oneof"` +} + +type TcpForwardFrame_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +func (*TcpForwardFrame_Init) isTcpForwardFrame_Payload() {} + +func (*TcpForwardFrame_Data) isTcpForwardFrame_Payload() {} + +// Client-to-server message for interactive exec. +type ExecSandboxInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ExecSandboxInput_Start + // *ExecSandboxInput_Stdin + // *ExecSandboxInput_Resize + Payload isExecSandboxInput_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxInput) Reset() { + *x = ExecSandboxInput{} + mi := &file_openshell_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxInput) ProtoMessage() {} + +func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecSandboxInput.ProtoReflect.Descriptor instead. +func (*ExecSandboxInput) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{46} +} + +func (x *ExecSandboxInput) GetPayload() isExecSandboxInput_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ExecSandboxInput) GetStart() *ExecSandboxRequest { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxInput_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ExecSandboxInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ExecSandboxInput) GetResize() *ExecSandboxWindowResize { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxInput_Resize); ok { + return x.Resize + } + } + return nil +} + +type isExecSandboxInput_Payload interface { + isExecSandboxInput_Payload() +} + +type ExecSandboxInput_Start struct { + // First message: exec request metadata. + Start *ExecSandboxRequest `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ExecSandboxInput_Stdin struct { + // Subsequent messages: raw stdin bytes. + Stdin []byte `protobuf:"bytes,2,opt,name=stdin,proto3,oneof"` +} + +type ExecSandboxInput_Resize struct { + // Terminal window size change. + Resize *ExecSandboxWindowResize `protobuf:"bytes,3,opt,name=resize,proto3,oneof"` +} + +func (*ExecSandboxInput_Start) isExecSandboxInput_Payload() {} + +func (*ExecSandboxInput_Stdin) isExecSandboxInput_Payload() {} + +func (*ExecSandboxInput_Resize) isExecSandboxInput_Payload() {} + +// Terminal window resize event for interactive exec. +type ExecSandboxWindowResize struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxWindowResize) Reset() { + *x = ExecSandboxWindowResize{} + mi := &file_openshell_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxWindowResize) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxWindowResize) ProtoMessage() {} + +func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecSandboxWindowResize.ProtoReflect.Descriptor instead. +func (*ExecSandboxWindowResize) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{47} +} + +func (x *ExecSandboxWindowResize) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *ExecSandboxWindowResize) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +// SSH session record stored in persistence. +type SshSession struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Sandbox id. + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Session token. + Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` + // Expiry timestamp in milliseconds since epoch. 0 means no expiry + // (backward-compatible default for sessions created before this field existed). + ExpiresAtMs int64 `protobuf:"varint,4,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + // Revoked flag. + Revoked bool `protobuf:"varint,5,opt,name=revoked,proto3" json:"revoked,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SshSession) Reset() { + *x = SshSession{} + mi := &file_openshell_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SshSession) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SshSession) ProtoMessage() {} + +func (x *SshSession) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SshSession.ProtoReflect.Descriptor instead. +func (*SshSession) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{48} +} + +func (x *SshSession) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *SshSession) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SshSession) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *SshSession) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +func (x *SshSession) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +// Watch sandbox request. +type WatchSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Stream sandbox status snapshots. + FollowStatus bool `protobuf:"varint,2,opt,name=follow_status,json=followStatus,proto3" json:"follow_status,omitempty"` + // Stream openshell-server process logs correlated to this sandbox. + FollowLogs bool `protobuf:"varint,3,opt,name=follow_logs,json=followLogs,proto3" json:"follow_logs,omitempty"` + // Stream platform events correlated to this sandbox. + FollowEvents bool `protobuf:"varint,4,opt,name=follow_events,json=followEvents,proto3" json:"follow_events,omitempty"` + // Replay the last N log lines (best-effort) before following. + LogTailLines uint32 `protobuf:"varint,5,opt,name=log_tail_lines,json=logTailLines,proto3" json:"log_tail_lines,omitempty"` + // Replay the last N platform events (best-effort) before following. + EventTail uint32 `protobuf:"varint,6,opt,name=event_tail,json=eventTail,proto3" json:"event_tail,omitempty"` + // Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). + StopOnTerminal bool `protobuf:"varint,7,opt,name=stop_on_terminal,json=stopOnTerminal,proto3" json:"stop_on_terminal,omitempty"` + // Only include log lines with timestamp >= this value (milliseconds since epoch). + // 0 means no time filter. Applies to both tail replay and live streaming. + LogSinceMs int64 `protobuf:"varint,8,opt,name=log_since_ms,json=logSinceMs,proto3" json:"log_since_ms,omitempty"` + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + LogSources []string `protobuf:"bytes,9,rep,name=log_sources,json=logSources,proto3" json:"log_sources,omitempty"` + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + LogMinLevel string `protobuf:"bytes,10,opt,name=log_min_level,json=logMinLevel,proto3" json:"log_min_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchSandboxRequest) Reset() { + *x = WatchSandboxRequest{} + mi := &file_openshell_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchSandboxRequest) ProtoMessage() {} + +func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchSandboxRequest.ProtoReflect.Descriptor instead. +func (*WatchSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{49} +} + +func (x *WatchSandboxRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *WatchSandboxRequest) GetFollowStatus() bool { + if x != nil { + return x.FollowStatus + } + return false +} + +func (x *WatchSandboxRequest) GetFollowLogs() bool { + if x != nil { + return x.FollowLogs + } + return false +} + +func (x *WatchSandboxRequest) GetFollowEvents() bool { + if x != nil { + return x.FollowEvents + } + return false +} + +func (x *WatchSandboxRequest) GetLogTailLines() uint32 { + if x != nil { + return x.LogTailLines + } + return 0 +} + +func (x *WatchSandboxRequest) GetEventTail() uint32 { + if x != nil { + return x.EventTail + } + return 0 +} + +func (x *WatchSandboxRequest) GetStopOnTerminal() bool { + if x != nil { + return x.StopOnTerminal + } + return false +} + +func (x *WatchSandboxRequest) GetLogSinceMs() int64 { + if x != nil { + return x.LogSinceMs + } + return 0 +} + +func (x *WatchSandboxRequest) GetLogSources() []string { + if x != nil { + return x.LogSources + } + return nil +} + +func (x *WatchSandboxRequest) GetLogMinLevel() string { + if x != nil { + return x.LogMinLevel + } + return "" +} + +// One event in a sandbox watch stream. +type SandboxStreamEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *SandboxStreamEvent_Sandbox + // *SandboxStreamEvent_Log + // *SandboxStreamEvent_Event + // *SandboxStreamEvent_Warning + // *SandboxStreamEvent_DraftPolicyUpdate + Payload isSandboxStreamEvent_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxStreamEvent) Reset() { + *x = SandboxStreamEvent{} + mi := &file_openshell_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxStreamEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxStreamEvent) ProtoMessage() {} + +func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxStreamEvent.ProtoReflect.Descriptor instead. +func (*SandboxStreamEvent) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{50} +} + +func (x *SandboxStreamEvent) GetPayload() isSandboxStreamEvent_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *SandboxStreamEvent) GetSandbox() *Sandbox { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Sandbox); ok { + return x.Sandbox + } + } + return nil +} + +func (x *SandboxStreamEvent) GetLog() *SandboxLogLine { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Log); ok { + return x.Log + } + } + return nil +} + +func (x *SandboxStreamEvent) GetEvent() *PlatformEvent { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Event); ok { + return x.Event + } + } + return nil +} + +func (x *SandboxStreamEvent) GetWarning() *SandboxStreamWarning { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Warning); ok { + return x.Warning + } + } + return nil +} + +func (x *SandboxStreamEvent) GetDraftPolicyUpdate() *DraftPolicyUpdate { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_DraftPolicyUpdate); ok { + return x.DraftPolicyUpdate + } + } + return nil +} + +type isSandboxStreamEvent_Payload interface { + isSandboxStreamEvent_Payload() +} + +type SandboxStreamEvent_Sandbox struct { + // Latest sandbox snapshot. + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3,oneof"` +} + +type SandboxStreamEvent_Log struct { + // One server log line/event. + Log *SandboxLogLine `protobuf:"bytes,2,opt,name=log,proto3,oneof"` +} + +type SandboxStreamEvent_Event struct { + // One platform event. + Event *PlatformEvent `protobuf:"bytes,3,opt,name=event,proto3,oneof"` +} + +type SandboxStreamEvent_Warning struct { + // Warning from the server (e.g. missed messages due to lag). + Warning *SandboxStreamWarning `protobuf:"bytes,4,opt,name=warning,proto3,oneof"` +} + +type SandboxStreamEvent_DraftPolicyUpdate struct { + // Draft policy update notification. + DraftPolicyUpdate *DraftPolicyUpdate `protobuf:"bytes,5,opt,name=draft_policy_update,json=draftPolicyUpdate,proto3,oneof"` +} + +func (*SandboxStreamEvent_Sandbox) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_Log) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_Event) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_Warning) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_DraftPolicyUpdate) isSandboxStreamEvent_Payload() {} + +// Log line correlated to a sandbox. +type SandboxLogLine struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + TimestampMs int64 `protobuf:"varint,2,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + Level string `protobuf:"bytes,3,opt,name=level,proto3" json:"level,omitempty"` + Target string `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + // Log source: "gateway" (server-side) or "sandbox" (supervisor). + // Empty is treated as "gateway" for backward compatibility. + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` + // Structured key-value fields from the tracing event (e.g. dst_host, action). + Fields map[string]string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxLogLine) Reset() { + *x = SandboxLogLine{} + mi := &file_openshell_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxLogLine) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxLogLine) ProtoMessage() {} + +func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxLogLine.ProtoReflect.Descriptor instead. +func (*SandboxLogLine) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{51} +} + +func (x *SandboxLogLine) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SandboxLogLine) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *SandboxLogLine) GetLevel() string { + if x != nil { + return x.Level + } + return "" +} + +func (x *SandboxLogLine) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *SandboxLogLine) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *SandboxLogLine) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *SandboxLogLine) GetFields() map[string]string { + if x != nil { + return x.Fields + } + return nil +} + +type SandboxStreamWarning struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxStreamWarning) Reset() { + *x = SandboxStreamWarning{} + mi := &file_openshell_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxStreamWarning) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxStreamWarning) ProtoMessage() {} + +func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxStreamWarning.ProtoReflect.Descriptor instead. +func (*SandboxStreamWarning) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{52} +} + +func (x *SandboxStreamWarning) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// Create provider request. +type CreateProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateProviderRequest) Reset() { + *x = CreateProviderRequest{} + mi := &file_openshell_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateProviderRequest) ProtoMessage() {} + +func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateProviderRequest.ProtoReflect.Descriptor instead. +func (*CreateProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{53} +} + +func (x *CreateProviderRequest) GetProvider() *datamodelv1.Provider { + if x != nil { + return x.Provider + } + return nil +} + +// Get provider request. +type GetProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderRequest) Reset() { + *x = GetProviderRequest{} + mi := &file_openshell_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderRequest) ProtoMessage() {} + +func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[54] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProviderRequest.ProtoReflect.Descriptor instead. +func (*GetProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{54} +} + +func (x *GetProviderRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// List providers request. +type ListProvidersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + Offset uint32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProvidersRequest) Reset() { + *x = ListProvidersRequest{} + mi := &file_openshell_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProvidersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProvidersRequest) ProtoMessage() {} + +func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListProvidersRequest.ProtoReflect.Descriptor instead. +func (*ListProvidersRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{55} +} + +func (x *ListProvidersRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListProvidersRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +// Update provider request. +type UpdateProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + // Optional per-credential expiry timestamps to merge into the provider. + // A zero value removes the expiry for that credential. + CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,2,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateProviderRequest) Reset() { + *x = UpdateProviderRequest{} + mi := &file_openshell_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateProviderRequest) ProtoMessage() {} + +func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[56] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateProviderRequest.ProtoReflect.Descriptor instead. +func (*UpdateProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{56} +} + +func (x *UpdateProviderRequest) GetProvider() *datamodelv1.Provider { + if x != nil { + return x.Provider + } + return nil +} + +func (x *UpdateProviderRequest) GetCredentialExpiresAtMs() map[string]int64 { + if x != nil { + return x.CredentialExpiresAtMs + } + return nil +} + +// Delete provider request. +type DeleteProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderRequest) Reset() { + *x = DeleteProviderRequest{} + mi := &file_openshell_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderRequest) ProtoMessage() {} + +func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[57] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteProviderRequest.ProtoReflect.Descriptor instead. +func (*DeleteProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{57} +} + +func (x *DeleteProviderRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// Provider response. +type ProviderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderResponse) Reset() { + *x = ProviderResponse{} + mi := &file_openshell_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderResponse) ProtoMessage() {} + +func (x *ProviderResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[58] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderResponse.ProtoReflect.Descriptor instead. +func (*ProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{58} +} + +func (x *ProviderResponse) GetProvider() *datamodelv1.Provider { + if x != nil { + return x.Provider + } + return nil +} + +// List providers response. +type ListProvidersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Providers []*datamodelv1.Provider `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProvidersResponse) Reset() { + *x = ListProvidersResponse{} + mi := &file_openshell_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProvidersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProvidersResponse) ProtoMessage() {} + +func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[59] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListProvidersResponse.ProtoReflect.Descriptor instead. +func (*ListProvidersResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{59} +} + +func (x *ListProvidersResponse) GetProviders() []*datamodelv1.Provider { + if x != nil { + return x.Providers + } + return nil +} + +// List provider type profiles request. +type ListProviderProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + Offset uint32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProviderProfilesRequest) Reset() { + *x = ListProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProviderProfilesRequest) ProtoMessage() {} + +func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[60] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*ListProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{60} +} + +func (x *ListProviderProfilesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListProviderProfilesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +// Fetch provider type profile request. +type GetProviderProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderProfileRequest) Reset() { + *x = GetProviderProfileRequest{} + mi := &file_openshell_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderProfileRequest) ProtoMessage() {} + +func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[61] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProviderProfileRequest.ProtoReflect.Descriptor instead. +func (*GetProviderProfileRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{61} +} + +func (x *GetProviderProfileRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Provider profile payload with optional source metadata for diagnostics. +type ProviderProfileImportItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile *ProviderProfile `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileImportItem) Reset() { + *x = ProviderProfileImportItem{} + mi := &file_openshell_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileImportItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileImportItem) ProtoMessage() {} + +func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[62] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderProfileImportItem.ProtoReflect.Descriptor instead. +func (*ProviderProfileImportItem) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{62} +} + +func (x *ProviderProfileImportItem) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +func (x *ProviderProfileImportItem) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +// Provider profile validation diagnostic. +type ProviderProfileDiagnostic struct { + state protoimpl.MessageState `protogen:"open.v1"` + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + ProfileId string `protobuf:"bytes,2,opt,name=profile_id,json=profileId,proto3" json:"profile_id,omitempty"` + Field string `protobuf:"bytes,3,opt,name=field,proto3" json:"field,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + Severity string `protobuf:"bytes,5,opt,name=severity,proto3" json:"severity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileDiagnostic) Reset() { + *x = ProviderProfileDiagnostic{} + mi := &file_openshell_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileDiagnostic) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileDiagnostic) ProtoMessage() {} + +func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[63] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderProfileDiagnostic.ProtoReflect.Descriptor instead. +func (*ProviderProfileDiagnostic) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{63} +} + +func (x *ProviderProfileDiagnostic) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetProfileId() string { + if x != nil { + return x.ProfileId + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetField() string { + if x != nil { + return x.Field + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetSeverity() string { + if x != nil { + return x.Severity + } + return "" +} + +// Endpoint selector for token grant audience overrides. +type ProviderCredentialTokenGrantAudienceOverride struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional: endpoint host selector. If omitted, inherits the profile endpoint host. + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + // Optional: endpoint port selector. If omitted, matches the expanded profile endpoint port. + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + // Optional: endpoint path selector. If omitted, inherits the profile endpoint path. + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + // Resource audience to request for matching endpoints. + Audience string `protobuf:"bytes,4,opt,name=audience,proto3" json:"audience,omitempty"` + // Optional: OAuth2 scopes to request. If omitted, inherits the token grant scopes. + Scopes []string `protobuf:"bytes,5,rep,name=scopes,proto3" json:"scopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) Reset() { + *x = ProviderCredentialTokenGrantAudienceOverride{} + mi := &file_openshell_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialTokenGrantAudienceOverride) ProtoMessage() {} + +func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[64] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderCredentialTokenGrantAudienceOverride.ProtoReflect.Descriptor instead. +func (*ProviderCredentialTokenGrantAudienceOverride) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{64} +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +// Provider credential token grant configuration. +// When present, the credential is obtained dynamically via OAuth2 grant when needed. +type ProviderCredentialTokenGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) + TokenEndpoint string `protobuf:"bytes,1,opt,name=token_endpoint,json=tokenEndpoint,proto3" json:"token_endpoint,omitempty"` + // Optional: default resource audience to request from the token service + Audience string `protobuf:"bytes,2,opt,name=audience,proto3" json:"audience,omitempty"` + // Optional: audience to request when fetching the JWT-SVID from SPIRE. + // If omitted, the sandbox derives this from token_endpoint. + JwtSvidAudience string `protobuf:"bytes,6,opt,name=jwt_svid_audience,json=jwtSvidAudience,proto3" json:"jwt_svid_audience,omitempty"` + // Optional: OAuth2 scopes to request + Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` + // Optional: override token cache TTL (seconds) + // If 0 or omitted, use expires_in from token response + CacheTtlSeconds int64 `protobuf:"varint,4,opt,name=cache_ttl_seconds,json=cacheTtlSeconds,proto3" json:"cache_ttl_seconds,omitempty"` + // Optional: endpoint-specific resource audience overrides. + AudienceOverrides []*ProviderCredentialTokenGrantAudienceOverride `protobuf:"bytes,5,rep,name=audience_overrides,json=audienceOverrides,proto3" json:"audience_overrides,omitempty"` + // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses + // urn:ietf:params:oauth:client-assertion-type:jwt-bearer. + ClientAssertionType string `protobuf:"bytes,7,opt,name=client_assertion_type,json=clientAssertionType,proto3" json:"client_assertion_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialTokenGrant) Reset() { + *x = ProviderCredentialTokenGrant{} + mi := &file_openshell_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialTokenGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialTokenGrant) ProtoMessage() {} + +func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[65] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderCredentialTokenGrant.ProtoReflect.Descriptor instead. +func (*ProviderCredentialTokenGrant) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{65} +} + +func (x *ProviderCredentialTokenGrant) GetTokenEndpoint() string { + if x != nil { + return x.TokenEndpoint + } + return "" +} + +func (x *ProviderCredentialTokenGrant) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + +func (x *ProviderCredentialTokenGrant) GetJwtSvidAudience() string { + if x != nil { + return x.JwtSvidAudience + } + return "" +} + +func (x *ProviderCredentialTokenGrant) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *ProviderCredentialTokenGrant) GetCacheTtlSeconds() int64 { + if x != nil { + return x.CacheTtlSeconds + } + return 0 +} + +func (x *ProviderCredentialTokenGrant) GetAudienceOverrides() []*ProviderCredentialTokenGrantAudienceOverride { + if x != nil { + return x.AudienceOverrides + } + return nil +} + +func (x *ProviderCredentialTokenGrant) GetClientAssertionType() string { + if x != nil { + return x.ClientAssertionType + } + return "" +} + +// Provider credential declaration. +type ProviderProfileCredential struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + EnvVars []string `protobuf:"bytes,3,rep,name=env_vars,json=envVars,proto3" json:"env_vars,omitempty"` + Required bool `protobuf:"varint,4,opt,name=required,proto3" json:"required,omitempty"` + AuthStyle string `protobuf:"bytes,5,opt,name=auth_style,json=authStyle,proto3" json:"auth_style,omitempty"` + HeaderName string `protobuf:"bytes,6,opt,name=header_name,json=headerName,proto3" json:"header_name,omitempty"` + QueryParam string `protobuf:"bytes,7,opt,name=query_param,json=queryParam,proto3" json:"query_param,omitempty"` + Refresh *ProviderCredentialRefresh `protobuf:"bytes,8,opt,name=refresh,proto3" json:"refresh,omitempty"` + PathTemplate string `protobuf:"bytes,9,opt,name=path_template,json=pathTemplate,proto3" json:"path_template,omitempty"` + TokenGrant *ProviderCredentialTokenGrant `protobuf:"bytes,10,opt,name=token_grant,json=tokenGrant,proto3" json:"token_grant,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileCredential) Reset() { + *x = ProviderProfileCredential{} + mi := &file_openshell_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileCredential) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileCredential) ProtoMessage() {} + +func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[66] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderProfileCredential.ProtoReflect.Descriptor instead. +func (*ProviderProfileCredential) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{66} +} + +func (x *ProviderProfileCredential) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProviderProfileCredential) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProviderProfileCredential) GetEnvVars() []string { + if x != nil { + return x.EnvVars + } + return nil +} + +func (x *ProviderProfileCredential) GetRequired() bool { + if x != nil { + return x.Required + } + return false +} + +func (x *ProviderProfileCredential) GetAuthStyle() string { + if x != nil { + return x.AuthStyle + } + return "" +} + +func (x *ProviderProfileCredential) GetHeaderName() string { + if x != nil { + return x.HeaderName + } + return "" +} + +func (x *ProviderProfileCredential) GetQueryParam() string { + if x != nil { + return x.QueryParam + } + return "" +} + +func (x *ProviderProfileCredential) GetRefresh() *ProviderCredentialRefresh { + if x != nil { + return x.Refresh + } + return nil +} + +func (x *ProviderProfileCredential) GetPathTemplate() string { + if x != nil { + return x.PathTemplate + } + return "" +} + +func (x *ProviderProfileCredential) GetTokenGrant() *ProviderCredentialTokenGrant { + if x != nil { + return x.TokenGrant + } + return nil +} + +type ProviderCredentialRefreshMaterial struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Required bool `protobuf:"varint,3,opt,name=required,proto3" json:"required,omitempty"` + Secret bool `protobuf:"varint,4,opt,name=secret,proto3" json:"secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialRefreshMaterial) Reset() { + *x = ProviderCredentialRefreshMaterial{} + mi := &file_openshell_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialRefreshMaterial) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialRefreshMaterial) ProtoMessage() {} + +func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[67] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderCredentialRefreshMaterial.ProtoReflect.Descriptor instead. +func (*ProviderCredentialRefreshMaterial) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{67} +} + +func (x *ProviderCredentialRefreshMaterial) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProviderCredentialRefreshMaterial) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProviderCredentialRefreshMaterial) GetRequired() bool { + if x != nil { + return x.Required + } + return false +} + +func (x *ProviderCredentialRefreshMaterial) GetSecret() bool { + if x != nil { + return x.Secret + } + return false +} + +type ProviderCredentialRefresh struct { + state protoimpl.MessageState `protogen:"open.v1"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,1,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + TokenUrl string `protobuf:"bytes,2,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` + RefreshBeforeSeconds int64 `protobuf:"varint,4,opt,name=refresh_before_seconds,json=refreshBeforeSeconds,proto3" json:"refresh_before_seconds,omitempty"` + MaxLifetimeSeconds int64 `protobuf:"varint,5,opt,name=max_lifetime_seconds,json=maxLifetimeSeconds,proto3" json:"max_lifetime_seconds,omitempty"` + Material []*ProviderCredentialRefreshMaterial `protobuf:"bytes,6,rep,name=material,proto3" json:"material,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialRefresh) Reset() { + *x = ProviderCredentialRefresh{} + mi := &file_openshell_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialRefresh) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialRefresh) ProtoMessage() {} + +func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[68] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderCredentialRefresh.ProtoReflect.Descriptor instead. +func (*ProviderCredentialRefresh) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{68} +} + +func (x *ProviderCredentialRefresh) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *ProviderCredentialRefresh) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *ProviderCredentialRefresh) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *ProviderCredentialRefresh) GetRefreshBeforeSeconds() int64 { + if x != nil { + return x.RefreshBeforeSeconds + } + return 0 +} + +func (x *ProviderCredentialRefresh) GetMaxLifetimeSeconds() int64 { + if x != nil { + return x.MaxLifetimeSeconds + } + return 0 +} + +func (x *ProviderCredentialRefresh) GetMaterial() []*ProviderCredentialRefreshMaterial { + if x != nil { + return x.Material + } + return nil +} + +type ProviderCredentialRefreshStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + CredentialKey string `protobuf:"bytes,3,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,4,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` + ExpiresAtMs int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + NextRefreshAtMs int64 `protobuf:"varint,7,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"` + LastRefreshAtMs int64 `protobuf:"varint,8,opt,name=last_refresh_at_ms,json=lastRefreshAtMs,proto3" json:"last_refresh_at_ms,omitempty"` + LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialRefreshStatus) Reset() { + *x = ProviderCredentialRefreshStatus{} + mi := &file_openshell_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialRefreshStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialRefreshStatus) ProtoMessage() {} + +func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[69] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderCredentialRefreshStatus.ProtoReflect.Descriptor instead. +func (*ProviderCredentialRefreshStatus) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{69} +} + +func (x *ProviderCredentialRefreshStatus) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *ProviderCredentialRefreshStatus) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +func (x *ProviderCredentialRefreshStatus) GetNextRefreshAtMs() int64 { + if x != nil { + return x.NextRefreshAtMs + } + return 0 +} + +func (x *ProviderCredentialRefreshStatus) GetLastRefreshAtMs() int64 { + if x != nil { + return x.LastRefreshAtMs + } + return 0 +} + +func (x *ProviderCredentialRefreshStatus) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +// Provider profile local discovery declaration. +type ProviderProfileDiscovery struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Credential names from ProviderProfile.credentials eligible for local discovery. + Credentials []string `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileDiscovery) Reset() { + *x = ProviderProfileDiscovery{} + mi := &file_openshell_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileDiscovery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileDiscovery) ProtoMessage() {} + +func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[70] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderProfileDiscovery.ProtoReflect.Descriptor instead. +func (*ProviderProfileDiscovery) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{70} +} + +func (x *ProviderProfileDiscovery) GetCredentials() []string { + if x != nil { + return x.Credentials + } + return nil +} + +type StoredProviderCredentialRefreshState struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + ProviderName string `protobuf:"bytes,3,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + CredentialKey string `protobuf:"bytes,4,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,5,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + Material map[string]string `protobuf:"bytes,6,rep,name=material,proto3" json:"material,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + SecretMaterialKeys []string `protobuf:"bytes,7,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` + ExpiresAtMs int64 `protobuf:"varint,8,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + NextRefreshAtMs int64 `protobuf:"varint,9,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"` + LastRefreshAtMs int64 `protobuf:"varint,10,opt,name=last_refresh_at_ms,json=lastRefreshAtMs,proto3" json:"last_refresh_at_ms,omitempty"` + Status string `protobuf:"bytes,11,opt,name=status,proto3" json:"status,omitempty"` + LastError string `protobuf:"bytes,12,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + TokenUrl string `protobuf:"bytes,13,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + Scopes []string `protobuf:"bytes,14,rep,name=scopes,proto3" json:"scopes,omitempty"` + RefreshBeforeSeconds int64 `protobuf:"varint,15,opt,name=refresh_before_seconds,json=refreshBeforeSeconds,proto3" json:"refresh_before_seconds,omitempty"` + MaxLifetimeSeconds int64 `protobuf:"varint,16,opt,name=max_lifetime_seconds,json=maxLifetimeSeconds,proto3" json:"max_lifetime_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredProviderCredentialRefreshState) Reset() { + *x = StoredProviderCredentialRefreshState{} + mi := &file_openshell_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredProviderCredentialRefreshState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredProviderCredentialRefreshState) ProtoMessage() {} + +func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[71] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoredProviderCredentialRefreshState.ProtoReflect.Descriptor instead. +func (*StoredProviderCredentialRefreshState) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{71} +} + +func (x *StoredProviderCredentialRefreshState) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *StoredProviderCredentialRefreshState) GetMaterial() map[string]string { + if x != nil { + return x.Material + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetSecretMaterialKeys() []string { + if x != nil { + return x.SecretMaterialKeys + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetNextRefreshAtMs() int64 { + if x != nil { + return x.NextRefreshAtMs + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetLastRefreshAtMs() int64 { + if x != nil { + return x.LastRefreshAtMs + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetRefreshBeforeSeconds() int64 { + if x != nil { + return x.RefreshBeforeSeconds + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetMaxLifetimeSeconds() int64 { + if x != nil { + return x.MaxLifetimeSeconds + } + return 0 +} + +type GetProviderRefreshStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderRefreshStatusRequest) Reset() { + *x = GetProviderRefreshStatusRequest{} + mi := &file_openshell_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderRefreshStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderRefreshStatusRequest) ProtoMessage() {} + +func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[72] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProviderRefreshStatusRequest.ProtoReflect.Descriptor instead. +func (*GetProviderRefreshStatusRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{72} +} + +func (x *GetProviderRefreshStatusRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *GetProviderRefreshStatusRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +type GetProviderRefreshStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Credentials []*ProviderCredentialRefreshStatus `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderRefreshStatusResponse) Reset() { + *x = GetProviderRefreshStatusResponse{} + mi := &file_openshell_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderRefreshStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderRefreshStatusResponse) ProtoMessage() {} + +func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[73] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProviderRefreshStatusResponse.ProtoReflect.Descriptor instead. +func (*GetProviderRefreshStatusResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{73} +} + +func (x *GetProviderRefreshStatusResponse) GetCredentials() []*ProviderCredentialRefreshStatus { + if x != nil { + return x.Credentials + } + return nil +} + +type ConfigureProviderRefreshRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,3,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + Material map[string]string `protobuf:"bytes,4,rep,name=material,proto3" json:"material,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + SecretMaterialKeys []string `protobuf:"bytes,5,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` + ExpiresAtMs *int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3,oneof" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureProviderRefreshRequest) Reset() { + *x = ConfigureProviderRefreshRequest{} + mi := &file_openshell_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureProviderRefreshRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureProviderRefreshRequest) ProtoMessage() {} + +func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[74] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigureProviderRefreshRequest.ProtoReflect.Descriptor instead. +func (*ConfigureProviderRefreshRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{74} +} + +func (x *ConfigureProviderRefreshRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ConfigureProviderRefreshRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +func (x *ConfigureProviderRefreshRequest) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *ConfigureProviderRefreshRequest) GetMaterial() map[string]string { + if x != nil { + return x.Material + } + return nil +} + +func (x *ConfigureProviderRefreshRequest) GetSecretMaterialKeys() []string { + if x != nil { + return x.SecretMaterialKeys + } + return nil +} + +func (x *ConfigureProviderRefreshRequest) GetExpiresAtMs() int64 { + if x != nil && x.ExpiresAtMs != nil { + return *x.ExpiresAtMs + } + return 0 +} + +type ConfigureProviderRefreshResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureProviderRefreshResponse) Reset() { + *x = ConfigureProviderRefreshResponse{} + mi := &file_openshell_proto_msgTypes[75] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureProviderRefreshResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureProviderRefreshResponse) ProtoMessage() {} + +func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[75] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigureProviderRefreshResponse.ProtoReflect.Descriptor instead. +func (*ConfigureProviderRefreshResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{75} +} + +func (x *ConfigureProviderRefreshResponse) GetStatus() *ProviderCredentialRefreshStatus { + if x != nil { + return x.Status + } + return nil +} + +type RotateProviderCredentialRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateProviderCredentialRequest) Reset() { + *x = RotateProviderCredentialRequest{} + mi := &file_openshell_proto_msgTypes[76] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateProviderCredentialRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateProviderCredentialRequest) ProtoMessage() {} + +func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[76] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RotateProviderCredentialRequest.ProtoReflect.Descriptor instead. +func (*RotateProviderCredentialRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{76} +} + +func (x *RotateProviderCredentialRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *RotateProviderCredentialRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +type RotateProviderCredentialResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateProviderCredentialResponse) Reset() { + *x = RotateProviderCredentialResponse{} + mi := &file_openshell_proto_msgTypes[77] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateProviderCredentialResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateProviderCredentialResponse) ProtoMessage() {} + +func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[77] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RotateProviderCredentialResponse.ProtoReflect.Descriptor instead. +func (*RotateProviderCredentialResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{77} +} + +func (x *RotateProviderCredentialResponse) GetStatus() *ProviderCredentialRefreshStatus { + if x != nil { + return x.Status + } + return nil +} + +type DeleteProviderRefreshRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderRefreshRequest) Reset() { + *x = DeleteProviderRefreshRequest{} + mi := &file_openshell_proto_msgTypes[78] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderRefreshRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderRefreshRequest) ProtoMessage() {} + +func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[78] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteProviderRefreshRequest.ProtoReflect.Descriptor instead. +func (*DeleteProviderRefreshRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{78} +} + +func (x *DeleteProviderRefreshRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *DeleteProviderRefreshRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +type DeleteProviderRefreshResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderRefreshResponse) Reset() { + *x = DeleteProviderRefreshResponse{} + mi := &file_openshell_proto_msgTypes[79] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderRefreshResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderRefreshResponse) ProtoMessage() {} + +func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[79] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteProviderRefreshResponse.ProtoReflect.Descriptor instead. +func (*DeleteProviderRefreshResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{79} +} + +func (x *DeleteProviderRefreshResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Provider type profile metadata exposed to clients. +type ProviderProfile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Category ProviderProfileCategory `protobuf:"varint,4,opt,name=category,proto3,enum=openshell.v1.ProviderProfileCategory" json:"category,omitempty"` + Credentials []*ProviderProfileCredential `protobuf:"bytes,5,rep,name=credentials,proto3" json:"credentials,omitempty"` + Endpoints []*sandboxv1.NetworkEndpoint `protobuf:"bytes,6,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + Binaries []*sandboxv1.NetworkBinary `protobuf:"bytes,7,rep,name=binaries,proto3" json:"binaries,omitempty"` + InferenceCapable bool `protobuf:"varint,8,opt,name=inference_capable,json=inferenceCapable,proto3" json:"inference_capable,omitempty"` + Discovery *ProviderProfileDiscovery `protobuf:"bytes,9,opt,name=discovery,proto3" json:"discovery,omitempty"` + // Storage resource version for custom profiles. Built-in profiles and new + // profile files use 0. Gateway responses set this for stored custom profiles. + // Update calls use this for optimistic concurrency. + ResourceVersion uint64 `protobuf:"varint,10,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfile) Reset() { + *x = ProviderProfile{} + mi := &file_openshell_proto_msgTypes[80] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfile) ProtoMessage() {} + +func (x *ProviderProfile) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[80] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderProfile.ProtoReflect.Descriptor instead. +func (*ProviderProfile) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{80} +} + +func (x *ProviderProfile) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ProviderProfile) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *ProviderProfile) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProviderProfile) GetCategory() ProviderProfileCategory { + if x != nil { + return x.Category + } + return ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED +} + +func (x *ProviderProfile) GetCredentials() []*ProviderProfileCredential { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *ProviderProfile) GetEndpoints() []*sandboxv1.NetworkEndpoint { + if x != nil { + return x.Endpoints + } + return nil +} + +func (x *ProviderProfile) GetBinaries() []*sandboxv1.NetworkBinary { + if x != nil { + return x.Binaries + } + return nil +} + +func (x *ProviderProfile) GetInferenceCapable() bool { + if x != nil { + return x.InferenceCapable + } + return false +} + +func (x *ProviderProfile) GetDiscovery() *ProviderProfileDiscovery { + if x != nil { + return x.Discovery + } + return nil +} + +func (x *ProviderProfile) GetResourceVersion() uint64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +// Stored custom provider profile object. +type StoredProviderProfile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + Profile *ProviderProfile `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredProviderProfile) Reset() { + *x = StoredProviderProfile{} + mi := &file_openshell_proto_msgTypes[81] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredProviderProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredProviderProfile) ProtoMessage() {} + +func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[81] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoredProviderProfile.ProtoReflect.Descriptor instead. +func (*StoredProviderProfile) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{81} +} + +func (x *StoredProviderProfile) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *StoredProviderProfile) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +// Provider profile response. +type ProviderProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile *ProviderProfile `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileResponse) Reset() { + *x = ProviderProfileResponse{} + mi := &file_openshell_proto_msgTypes[82] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileResponse) ProtoMessage() {} + +func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[82] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderProfileResponse.ProtoReflect.Descriptor instead. +func (*ProviderProfileResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{82} +} + +func (x *ProviderProfileResponse) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +// List provider profiles response. +type ListProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*ProviderProfile `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProviderProfilesResponse) Reset() { + *x = ListProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[83] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProviderProfilesResponse) ProtoMessage() {} + +func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[83] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*ListProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{83} +} + +func (x *ListProviderProfilesResponse) GetProfiles() []*ProviderProfile { + if x != nil { + return x.Profiles + } + return nil +} + +// Import custom provider profiles request. +type ImportProviderProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*ProviderProfileImportItem `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ImportProviderProfilesRequest) Reset() { + *x = ImportProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[84] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImportProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportProviderProfilesRequest) ProtoMessage() {} + +func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[84] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ImportProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*ImportProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{84} +} + +func (x *ImportProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { + if x != nil { + return x.Profiles + } + return nil +} + +// Import custom provider profiles response. +type ImportProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + Profiles []*ProviderProfile `protobuf:"bytes,2,rep,name=profiles,proto3" json:"profiles,omitempty"` + Imported bool `protobuf:"varint,3,opt,name=imported,proto3" json:"imported,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ImportProviderProfilesResponse) Reset() { + *x = ImportProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[85] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImportProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportProviderProfilesResponse) ProtoMessage() {} + +func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[85] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ImportProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*ImportProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{85} +} + +func (x *ImportProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { + if x != nil { + return x.Diagnostics + } + return nil +} + +func (x *ImportProviderProfilesResponse) GetProfiles() []*ProviderProfile { + if x != nil { + return x.Profiles + } + return nil +} + +func (x *ImportProviderProfilesResponse) GetImported() bool { + if x != nil { + return x.Imported + } + return false +} + +// Update one custom provider profile request. +type UpdateProviderProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile *ProviderProfileImportItem `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // Expected storage resource version for optimistic concurrency control. + // If 0, the server uses the resource_version embedded in profile.profile. + // Updates without a non-zero version are rejected to prevent stale files from + // silently overwriting newer profile definitions. + ExpectedResourceVersion uint64 `protobuf:"varint,2,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + // Existing custom provider profile ID to update. The payload ID must match. + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateProviderProfilesRequest) Reset() { + *x = UpdateProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[86] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateProviderProfilesRequest) ProtoMessage() {} + +func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[86] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*UpdateProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{86} +} + +func (x *UpdateProviderProfilesRequest) GetProfile() *ProviderProfileImportItem { + if x != nil { + return x.Profile + } + return nil +} + +func (x *UpdateProviderProfilesRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +func (x *UpdateProviderProfilesRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Update one custom provider profile response. +type UpdateProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + Profile *ProviderProfile `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"` + Updated bool `protobuf:"varint,3,opt,name=updated,proto3" json:"updated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateProviderProfilesResponse) Reset() { + *x = UpdateProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateProviderProfilesResponse) ProtoMessage() {} + +func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[87] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*UpdateProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{87} +} + +func (x *UpdateProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { + if x != nil { + return x.Diagnostics + } + return nil +} + +func (x *UpdateProviderProfilesResponse) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +func (x *UpdateProviderProfilesResponse) GetUpdated() bool { + if x != nil { + return x.Updated + } + return false +} + +// Lint provider profiles request. +type LintProviderProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*ProviderProfileImportItem `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LintProviderProfilesRequest) Reset() { + *x = LintProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LintProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LintProviderProfilesRequest) ProtoMessage() {} + +func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[88] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LintProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*LintProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{88} +} + +func (x *LintProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { + if x != nil { + return x.Profiles + } + return nil +} + +// Lint provider profiles response. +type LintProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + Valid bool `protobuf:"varint,2,opt,name=valid,proto3" json:"valid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LintProviderProfilesResponse) Reset() { + *x = LintProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LintProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LintProviderProfilesResponse) ProtoMessage() {} + +func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[89] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LintProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*LintProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{89} +} + +func (x *LintProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { + if x != nil { + return x.Diagnostics + } + return nil +} + +func (x *LintProviderProfilesResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +// Delete provider response. +type DeleteProviderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderResponse) Reset() { + *x = DeleteProviderResponse{} + mi := &file_openshell_proto_msgTypes[90] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderResponse) ProtoMessage() {} + +func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[90] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteProviderResponse.ProtoReflect.Descriptor instead. +func (*DeleteProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{90} +} + +func (x *DeleteProviderResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Delete custom provider profile request. +type DeleteProviderProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderProfileRequest) Reset() { + *x = DeleteProviderProfileRequest{} + mi := &file_openshell_proto_msgTypes[91] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderProfileRequest) ProtoMessage() {} + +func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[91] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteProviderProfileRequest.ProtoReflect.Descriptor instead. +func (*DeleteProviderProfileRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{91} +} + +func (x *DeleteProviderProfileRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Delete custom provider profile response. +type DeleteProviderProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderProfileResponse) Reset() { + *x = DeleteProviderProfileResponse{} + mi := &file_openshell_proto_msgTypes[92] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderProfileResponse) ProtoMessage() {} + +func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[92] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteProviderProfileResponse.ProtoReflect.Descriptor instead. +func (*DeleteProviderProfileResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{92} +} + +func (x *DeleteProviderProfileResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Get sandbox provider environment request. +type GetSandboxProviderEnvironmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox ID. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxProviderEnvironmentRequest) Reset() { + *x = GetSandboxProviderEnvironmentRequest{} + mi := &file_openshell_proto_msgTypes[93] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxProviderEnvironmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxProviderEnvironmentRequest) ProtoMessage() {} + +func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[93] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxProviderEnvironmentRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxProviderEnvironmentRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{93} +} + +func (x *GetSandboxProviderEnvironmentRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +// Get sandbox provider environment response. +type GetSandboxProviderEnvironmentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Provider credential environment variables. + Environment map[string]string `protobuf:"bytes,1,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Fingerprint for the provider credential inputs that produced environment. + ProviderEnvRevision uint64 `protobuf:"varint,2,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` + // Expiration timestamps for returned environment variables. + CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,3,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + // Dynamic credentials that require token grants or other runtime injection. + // Maps endpoint-bound provider metadata to credential metadata. + // Supervisor uses this to inject Authorization headers for token grant credentials. + DynamicCredentials map[string]*ProviderProfileCredential `protobuf:"bytes,4,rep,name=dynamic_credentials,json=dynamicCredentials,proto3" json:"dynamic_credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxProviderEnvironmentResponse) Reset() { + *x = GetSandboxProviderEnvironmentResponse{} + mi := &file_openshell_proto_msgTypes[94] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxProviderEnvironmentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxProviderEnvironmentResponse) ProtoMessage() {} + +func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[94] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxProviderEnvironmentResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxProviderEnvironmentResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{94} +} + +func (x *GetSandboxProviderEnvironmentResponse) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *GetSandboxProviderEnvironmentResponse) GetProviderEnvRevision() uint64 { + if x != nil { + return x.ProviderEnvRevision + } + return 0 +} + +func (x *GetSandboxProviderEnvironmentResponse) GetCredentialExpiresAtMs() map[string]int64 { + if x != nil { + return x.CredentialExpiresAtMs + } + return nil +} + +func (x *GetSandboxProviderEnvironmentResponse) GetDynamicCredentials() map[string]*ProviderProfileCredential { + if x != nil { + return x.DynamicCredentials + } + return nil +} + +// Update sandbox policy request. +type UpdateConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. + // Not required when `global=true`. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The new policy to apply. + // + // Sandbox scope (`global=false`): + // - only network_policies and inference fields may differ from create-time + // policy; static fields must match version 1. + // + // Global scope (`global=true`): + // - applies to all sandboxes in full (no merge). + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,2,opt,name=policy,proto3" json:"policy,omitempty"` + // Optional single setting key to mutate. + SettingKey string `protobuf:"bytes,3,opt,name=setting_key,json=settingKey,proto3" json:"setting_key,omitempty"` + // Setting value for upsert operations. + SettingValue *sandboxv1.SettingValue `protobuf:"bytes,4,opt,name=setting_value,json=settingValue,proto3" json:"setting_value,omitempty"` + // Delete the setting key from scope. + // Sandbox-scoped deletes are rejected; only global delete is supported. + DeleteSetting bool `protobuf:"varint,5,opt,name=delete_setting,json=deleteSetting,proto3" json:"delete_setting,omitempty"` + // Apply mutation at gateway-global scope. + Global bool `protobuf:"varint,6,opt,name=global,proto3" json:"global,omitempty"` + // Batched incremental policy merge operations. Sandbox-scoped only. + MergeOperations []*PolicyMergeOperation `protobuf:"bytes,7,rep,name=merge_operations,json=mergeOperations,proto3" json:"merge_operations,omitempty"` + // Expected resource version for optimistic concurrency control (sandbox-scoped only). + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + // Ignored for global-scoped updates. + ExpectedResourceVersion uint64 `protobuf:"varint,8,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateConfigRequest) Reset() { + *x = UpdateConfigRequest{} + mi := &file_openshell_proto_msgTypes[95] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConfigRequest) ProtoMessage() {} + +func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[95] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. +func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{95} +} + +func (x *UpdateConfigRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UpdateConfigRequest) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *UpdateConfigRequest) GetSettingKey() string { + if x != nil { + return x.SettingKey + } + return "" +} + +func (x *UpdateConfigRequest) GetSettingValue() *sandboxv1.SettingValue { + if x != nil { + return x.SettingValue + } + return nil +} + +func (x *UpdateConfigRequest) GetDeleteSetting() bool { + if x != nil { + return x.DeleteSetting + } + return false +} + +func (x *UpdateConfigRequest) GetGlobal() bool { + if x != nil { + return x.Global + } + return false +} + +func (x *UpdateConfigRequest) GetMergeOperations() []*PolicyMergeOperation { + if x != nil { + return x.MergeOperations + } + return nil +} + +func (x *UpdateConfigRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +type PolicyMergeOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Operation: + // + // *PolicyMergeOperation_AddRule + // *PolicyMergeOperation_RemoveEndpoint + // *PolicyMergeOperation_RemoveRule + // *PolicyMergeOperation_AddDenyRules + // *PolicyMergeOperation_AddAllowRules + // *PolicyMergeOperation_RemoveBinary + Operation isPolicyMergeOperation_Operation `protobuf_oneof:"operation"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyMergeOperation) Reset() { + *x = PolicyMergeOperation{} + mi := &file_openshell_proto_msgTypes[96] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyMergeOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyMergeOperation) ProtoMessage() {} + +func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[96] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyMergeOperation.ProtoReflect.Descriptor instead. +func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{96} +} + +func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { + if x != nil { + return x.Operation + } + return nil +} + +func (x *PolicyMergeOperation) GetAddRule() *AddNetworkRule { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_AddRule); ok { + return x.AddRule + } + } + return nil +} + +func (x *PolicyMergeOperation) GetRemoveEndpoint() *RemoveNetworkEndpoint { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_RemoveEndpoint); ok { + return x.RemoveEndpoint + } + } + return nil +} + +func (x *PolicyMergeOperation) GetRemoveRule() *RemoveNetworkRule { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_RemoveRule); ok { + return x.RemoveRule + } + } + return nil +} + +func (x *PolicyMergeOperation) GetAddDenyRules() *AddDenyRules { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_AddDenyRules); ok { + return x.AddDenyRules + } + } + return nil +} + +func (x *PolicyMergeOperation) GetAddAllowRules() *AddAllowRules { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_AddAllowRules); ok { + return x.AddAllowRules + } + } + return nil +} + +func (x *PolicyMergeOperation) GetRemoveBinary() *RemoveNetworkBinary { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_RemoveBinary); ok { + return x.RemoveBinary + } + } + return nil +} + +type isPolicyMergeOperation_Operation interface { + isPolicyMergeOperation_Operation() +} + +type PolicyMergeOperation_AddRule struct { + AddRule *AddNetworkRule `protobuf:"bytes,1,opt,name=add_rule,json=addRule,proto3,oneof"` +} + +type PolicyMergeOperation_RemoveEndpoint struct { + RemoveEndpoint *RemoveNetworkEndpoint `protobuf:"bytes,2,opt,name=remove_endpoint,json=removeEndpoint,proto3,oneof"` +} + +type PolicyMergeOperation_RemoveRule struct { + RemoveRule *RemoveNetworkRule `protobuf:"bytes,3,opt,name=remove_rule,json=removeRule,proto3,oneof"` +} + +type PolicyMergeOperation_AddDenyRules struct { + AddDenyRules *AddDenyRules `protobuf:"bytes,4,opt,name=add_deny_rules,json=addDenyRules,proto3,oneof"` +} + +type PolicyMergeOperation_AddAllowRules struct { + AddAllowRules *AddAllowRules `protobuf:"bytes,5,opt,name=add_allow_rules,json=addAllowRules,proto3,oneof"` +} + +type PolicyMergeOperation_RemoveBinary struct { + RemoveBinary *RemoveNetworkBinary `protobuf:"bytes,6,opt,name=remove_binary,json=removeBinary,proto3,oneof"` +} + +func (*PolicyMergeOperation_AddRule) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_RemoveEndpoint) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_RemoveRule) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_AddDenyRules) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_AddAllowRules) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_RemoveBinary) isPolicyMergeOperation_Operation() {} + +type AddNetworkRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + Rule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,2,opt,name=rule,proto3" json:"rule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddNetworkRule) Reset() { + *x = AddNetworkRule{} + mi := &file_openshell_proto_msgTypes[97] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddNetworkRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddNetworkRule) ProtoMessage() {} + +func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[97] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddNetworkRule.ProtoReflect.Descriptor instead. +func (*AddNetworkRule) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{97} +} + +func (x *AddNetworkRule) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *AddNetworkRule) GetRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.Rule + } + return nil +} + +type RemoveNetworkEndpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` + Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveNetworkEndpoint) Reset() { + *x = RemoveNetworkEndpoint{} + mi := &file_openshell_proto_msgTypes[98] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveNetworkEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveNetworkEndpoint) ProtoMessage() {} + +func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[98] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. +func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{98} +} + +func (x *RemoveNetworkEndpoint) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *RemoveNetworkEndpoint) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *RemoveNetworkEndpoint) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +type RemoveNetworkRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveNetworkRule) Reset() { + *x = RemoveNetworkRule{} + mi := &file_openshell_proto_msgTypes[99] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveNetworkRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveNetworkRule) ProtoMessage() {} + +func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[99] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveNetworkRule.ProtoReflect.Descriptor instead. +func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{99} +} + +func (x *RemoveNetworkRule) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +type AddDenyRules struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + DenyRules []*sandboxv1.L7DenyRule `protobuf:"bytes,3,rep,name=deny_rules,json=denyRules,proto3" json:"deny_rules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddDenyRules) Reset() { + *x = AddDenyRules{} + mi := &file_openshell_proto_msgTypes[100] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddDenyRules) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddDenyRules) ProtoMessage() {} + +func (x *AddDenyRules) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[100] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddDenyRules.ProtoReflect.Descriptor instead. +func (*AddDenyRules) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{100} +} + +func (x *AddDenyRules) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *AddDenyRules) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *AddDenyRules) GetDenyRules() []*sandboxv1.L7DenyRule { + if x != nil { + return x.DenyRules + } + return nil +} + +type AddAllowRules struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + Rules []*sandboxv1.L7Rule `protobuf:"bytes,3,rep,name=rules,proto3" json:"rules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddAllowRules) Reset() { + *x = AddAllowRules{} + mi := &file_openshell_proto_msgTypes[101] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddAllowRules) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddAllowRules) ProtoMessage() {} + +func (x *AddAllowRules) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[101] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddAllowRules.ProtoReflect.Descriptor instead. +func (*AddAllowRules) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{101} +} + +func (x *AddAllowRules) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *AddAllowRules) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *AddAllowRules) GetRules() []*sandboxv1.L7Rule { + if x != nil { + return x.Rules + } + return nil +} + +type RemoveNetworkBinary struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + BinaryPath string `protobuf:"bytes,2,opt,name=binary_path,json=binaryPath,proto3" json:"binary_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveNetworkBinary) Reset() { + *x = RemoveNetworkBinary{} + mi := &file_openshell_proto_msgTypes[102] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveNetworkBinary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveNetworkBinary) ProtoMessage() {} + +func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[102] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveNetworkBinary.ProtoReflect.Descriptor instead. +func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{102} +} + +func (x *RemoveNetworkBinary) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *RemoveNetworkBinary) GetBinaryPath() string { + if x != nil { + return x.BinaryPath + } + return "" +} + +// Update sandbox policy response. +type UpdateConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Assigned policy version (monotonically increasing per sandbox). + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + // SHA-256 hash of the serialized policy payload. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Settings revision for the scope that was modified. + SettingsRevision uint64 `protobuf:"varint,3,opt,name=settings_revision,json=settingsRevision,proto3" json:"settings_revision,omitempty"` + // True when a setting delete operation removed an existing key. + Deleted bool `protobuf:"varint,4,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateConfigResponse) Reset() { + *x = UpdateConfigResponse{} + mi := &file_openshell_proto_msgTypes[103] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConfigResponse) ProtoMessage() {} + +func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[103] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. +func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{103} +} + +func (x *UpdateConfigResponse) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *UpdateConfigResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *UpdateConfigResponse) GetSettingsRevision() uint64 { + if x != nil { + return x.SettingsRevision + } + return 0 +} + +func (x *UpdateConfigResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Get sandbox policy status request. +type GetSandboxPolicyStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). Ignored when global is true. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The specific policy version to query. 0 means latest. + Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + // Query global policy revisions instead of a sandbox-scoped one. + Global bool `protobuf:"varint,3,opt,name=global,proto3" json:"global,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxPolicyStatusRequest) Reset() { + *x = GetSandboxPolicyStatusRequest{} + mi := &file_openshell_proto_msgTypes[104] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxPolicyStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} + +func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[104] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{104} +} + +func (x *GetSandboxPolicyStatusRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetSandboxPolicyStatusRequest) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *GetSandboxPolicyStatusRequest) GetGlobal() bool { + if x != nil { + return x.Global + } + return false +} + +// Get sandbox policy status response. +type GetSandboxPolicyStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The queried policy revision. + Revision *SandboxPolicyRevision `protobuf:"bytes,1,opt,name=revision,proto3" json:"revision,omitempty"` + // The currently active (loaded) policy version for this sandbox. + ActiveVersion uint32 `protobuf:"varint,2,opt,name=active_version,json=activeVersion,proto3" json:"active_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxPolicyStatusResponse) Reset() { + *x = GetSandboxPolicyStatusResponse{} + mi := &file_openshell_proto_msgTypes[105] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxPolicyStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} + +func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[105] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{105} +} + +func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { + if x != nil { + return x.Revision + } + return nil +} + +func (x *GetSandboxPolicyStatusResponse) GetActiveVersion() uint32 { + if x != nil { + return x.ActiveVersion + } + return 0 +} + +// List sandbox policies request. +type ListSandboxPoliciesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). Ignored when global is true. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` + // List global policy revisions instead of sandbox-scoped ones. + Global bool `protobuf:"varint,4,opt,name=global,proto3" json:"global,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxPoliciesRequest) Reset() { + *x = ListSandboxPoliciesRequest{} + mi := &file_openshell_proto_msgTypes[106] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxPoliciesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxPoliciesRequest) ProtoMessage() {} + +func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[106] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{106} +} + +func (x *ListSandboxPoliciesRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ListSandboxPoliciesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSandboxPoliciesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *ListSandboxPoliciesRequest) GetGlobal() bool { + if x != nil { + return x.Global + } + return false +} + +// List sandbox policies response. +type ListSandboxPoliciesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Revisions []*SandboxPolicyRevision `protobuf:"bytes,1,rep,name=revisions,proto3" json:"revisions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxPoliciesResponse) Reset() { + *x = ListSandboxPoliciesResponse{} + mi := &file_openshell_proto_msgTypes[107] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxPoliciesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxPoliciesResponse) ProtoMessage() {} + +func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[107] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{107} +} + +func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { + if x != nil { + return x.Revisions + } + return nil +} + +// Report policy load status (called by sandbox runtime after reload attempt). +type ReportPolicyStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // The policy version that was attempted. + Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + // Load result status. + Status PolicyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=openshell.v1.PolicyStatus" json:"status,omitempty"` + // Error message if status is FAILED. + LoadError string `protobuf:"bytes,4,opt,name=load_error,json=loadError,proto3" json:"load_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportPolicyStatusRequest) Reset() { + *x = ReportPolicyStatusRequest{} + mi := &file_openshell_proto_msgTypes[108] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportPolicyStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportPolicyStatusRequest) ProtoMessage() {} + +func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[108] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. +func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{108} +} + +func (x *ReportPolicyStatusRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ReportPolicyStatusRequest) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *ReportPolicyStatusRequest) GetStatus() PolicyStatus { + if x != nil { + return x.Status + } + return PolicyStatus_POLICY_STATUS_UNSPECIFIED +} + +func (x *ReportPolicyStatusRequest) GetLoadError() string { + if x != nil { + return x.LoadError + } + return "" +} + +// Report policy status response. +type ReportPolicyStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportPolicyStatusResponse) Reset() { + *x = ReportPolicyStatusResponse{} + mi := &file_openshell_proto_msgTypes[109] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportPolicyStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportPolicyStatusResponse) ProtoMessage() {} + +func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[109] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. +func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{109} +} + +// A versioned policy revision with metadata. +type SandboxPolicyRevision struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Policy version (monotonically increasing per sandbox). + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + // SHA-256 hash of the serialized policy payload. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Load status of this revision. + Status PolicyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=openshell.v1.PolicyStatus" json:"status,omitempty"` + // Error message if status is FAILED. + LoadError string `protobuf:"bytes,4,opt,name=load_error,json=loadError,proto3" json:"load_error,omitempty"` + // Milliseconds since epoch when this revision was created. + CreatedAtMs int64 `protobuf:"varint,5,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // Milliseconds since epoch when this revision was loaded by the sandbox. + LoadedAtMs int64 `protobuf:"varint,6,opt,name=loaded_at_ms,json=loadedAtMs,proto3" json:"loaded_at_ms,omitempty"` + // The full policy (only populated when explicitly requested). + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxPolicyRevision) Reset() { + *x = SandboxPolicyRevision{} + mi := &file_openshell_proto_msgTypes[110] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxPolicyRevision) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxPolicyRevision) ProtoMessage() {} + +func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[110] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxPolicyRevision.ProtoReflect.Descriptor instead. +func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{110} +} + +func (x *SandboxPolicyRevision) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *SandboxPolicyRevision) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *SandboxPolicyRevision) GetStatus() PolicyStatus { + if x != nil { + return x.Status + } + return PolicyStatus_POLICY_STATUS_UNSPECIFIED +} + +func (x *SandboxPolicyRevision) GetLoadError() string { + if x != nil { + return x.LoadError + } + return "" +} + +func (x *SandboxPolicyRevision) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *SandboxPolicyRevision) GetLoadedAtMs() int64 { + if x != nil { + return x.LoadedAtMs + } + return 0 +} + +func (x *SandboxPolicyRevision) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +// Get sandbox logs request (one-shot fetch). +type GetSandboxLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Maximum number of log lines to return. 0 means use default (2000). + Lines uint32 `protobuf:"varint,2,opt,name=lines,proto3" json:"lines,omitempty"` + // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. + SinceMs int64 `protobuf:"varint,3,opt,name=since_ms,json=sinceMs,proto3" json:"since_ms,omitempty"` + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + Sources []string `protobuf:"bytes,4,rep,name=sources,proto3" json:"sources,omitempty"` + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + MinLevel string `protobuf:"bytes,5,opt,name=min_level,json=minLevel,proto3" json:"min_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxLogsRequest) Reset() { + *x = GetSandboxLogsRequest{} + mi := &file_openshell_proto_msgTypes[111] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxLogsRequest) ProtoMessage() {} + +func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[111] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxLogsRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{111} +} + +func (x *GetSandboxLogsRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *GetSandboxLogsRequest) GetLines() uint32 { + if x != nil { + return x.Lines + } + return 0 +} + +func (x *GetSandboxLogsRequest) GetSinceMs() int64 { + if x != nil { + return x.SinceMs + } + return 0 +} + +func (x *GetSandboxLogsRequest) GetSources() []string { + if x != nil { + return x.Sources + } + return nil +} + +func (x *GetSandboxLogsRequest) GetMinLevel() string { + if x != nil { + return x.MinLevel + } + return "" +} + +// Batch of log lines pushed from sandbox to server. +type PushSandboxLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox ID. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Log lines to ingest. + Logs []*SandboxLogLine `protobuf:"bytes,2,rep,name=logs,proto3" json:"logs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushSandboxLogsRequest) Reset() { + *x = PushSandboxLogsRequest{} + mi := &file_openshell_proto_msgTypes[112] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushSandboxLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushSandboxLogsRequest) ProtoMessage() {} + +func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[112] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PushSandboxLogsRequest.ProtoReflect.Descriptor instead. +func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{112} +} + +func (x *PushSandboxLogsRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PushSandboxLogsRequest) GetLogs() []*SandboxLogLine { + if x != nil { + return x.Logs + } + return nil +} + +// Push sandbox logs response. +type PushSandboxLogsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushSandboxLogsResponse) Reset() { + *x = PushSandboxLogsResponse{} + mi := &file_openshell_proto_msgTypes[113] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushSandboxLogsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushSandboxLogsResponse) ProtoMessage() {} + +func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[113] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PushSandboxLogsResponse.ProtoReflect.Descriptor instead. +func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{113} +} + +// Get sandbox logs response. +type GetSandboxLogsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Log lines in chronological order. + Logs []*SandboxLogLine `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` + // Total number of lines in the server's buffer for this sandbox. + BufferTotal uint32 `protobuf:"varint,2,opt,name=buffer_total,json=bufferTotal,proto3" json:"buffer_total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxLogsResponse) Reset() { + *x = GetSandboxLogsResponse{} + mi := &file_openshell_proto_msgTypes[114] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxLogsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxLogsResponse) ProtoMessage() {} + +func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[114] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxLogsResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{114} +} + +func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { + if x != nil { + return x.Logs + } + return nil +} + +func (x *GetSandboxLogsResponse) GetBufferTotal() uint32 { + if x != nil { + return x.BufferTotal + } + return 0 +} + +// Envelope for supervisor-to-gateway messages on the ConnectSupervisor stream. +type SupervisorMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *SupervisorMessage_Hello + // *SupervisorMessage_Heartbeat + // *SupervisorMessage_RelayOpenResult + // *SupervisorMessage_RelayClose + Payload isSupervisorMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorMessage) Reset() { + *x = SupervisorMessage{} + mi := &file_openshell_proto_msgTypes[115] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SupervisorMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SupervisorMessage) ProtoMessage() {} + +func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[115] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SupervisorMessage.ProtoReflect.Descriptor instead. +func (*SupervisorMessage) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{115} +} + +func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *SupervisorMessage) GetHello() *SupervisorHello { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *SupervisorMessage) GetHeartbeat() *SupervisorHeartbeat { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *SupervisorMessage) GetRelayOpenResult() *RelayOpenResult { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_RelayOpenResult); ok { + return x.RelayOpenResult + } + } + return nil +} + +func (x *SupervisorMessage) GetRelayClose() *RelayClose { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_RelayClose); ok { + return x.RelayClose + } + } + return nil +} + +type isSupervisorMessage_Payload interface { + isSupervisorMessage_Payload() +} + +type SupervisorMessage_Hello struct { + Hello *SupervisorHello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type SupervisorMessage_Heartbeat struct { + Heartbeat *SupervisorHeartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"` +} + +type SupervisorMessage_RelayOpenResult struct { + RelayOpenResult *RelayOpenResult `protobuf:"bytes,3,opt,name=relay_open_result,json=relayOpenResult,proto3,oneof"` +} + +type SupervisorMessage_RelayClose struct { + RelayClose *RelayClose `protobuf:"bytes,4,opt,name=relay_close,json=relayClose,proto3,oneof"` +} + +func (*SupervisorMessage_Hello) isSupervisorMessage_Payload() {} + +func (*SupervisorMessage_Heartbeat) isSupervisorMessage_Payload() {} + +func (*SupervisorMessage_RelayOpenResult) isSupervisorMessage_Payload() {} + +func (*SupervisorMessage_RelayClose) isSupervisorMessage_Payload() {} + +// Envelope for gateway-to-supervisor messages on the ConnectSupervisor stream. +type GatewayMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *GatewayMessage_SessionAccepted + // *GatewayMessage_SessionRejected + // *GatewayMessage_Heartbeat + // *GatewayMessage_RelayOpen + // *GatewayMessage_RelayClose + Payload isGatewayMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GatewayMessage) Reset() { + *x = GatewayMessage{} + mi := &file_openshell_proto_msgTypes[116] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GatewayMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GatewayMessage) ProtoMessage() {} + +func (x *GatewayMessage) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[116] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GatewayMessage.ProtoReflect.Descriptor instead. +func (*GatewayMessage) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{116} +} + +func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *GatewayMessage) GetSessionAccepted() *SessionAccepted { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_SessionAccepted); ok { + return x.SessionAccepted + } + } + return nil +} + +func (x *GatewayMessage) GetSessionRejected() *SessionRejected { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_SessionRejected); ok { + return x.SessionRejected + } + } + return nil +} + +func (x *GatewayMessage) GetHeartbeat() *GatewayHeartbeat { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *GatewayMessage) GetRelayOpen() *RelayOpen { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_RelayOpen); ok { + return x.RelayOpen + } + } + return nil +} + +func (x *GatewayMessage) GetRelayClose() *RelayClose { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_RelayClose); ok { + return x.RelayClose + } + } + return nil +} + +type isGatewayMessage_Payload interface { + isGatewayMessage_Payload() +} + +type GatewayMessage_SessionAccepted struct { + SessionAccepted *SessionAccepted `protobuf:"bytes,1,opt,name=session_accepted,json=sessionAccepted,proto3,oneof"` +} + +type GatewayMessage_SessionRejected struct { + SessionRejected *SessionRejected `protobuf:"bytes,2,opt,name=session_rejected,json=sessionRejected,proto3,oneof"` +} + +type GatewayMessage_Heartbeat struct { + Heartbeat *GatewayHeartbeat `protobuf:"bytes,3,opt,name=heartbeat,proto3,oneof"` +} + +type GatewayMessage_RelayOpen struct { + RelayOpen *RelayOpen `protobuf:"bytes,4,opt,name=relay_open,json=relayOpen,proto3,oneof"` +} + +type GatewayMessage_RelayClose struct { + RelayClose *RelayClose `protobuf:"bytes,5,opt,name=relay_close,json=relayClose,proto3,oneof"` +} + +func (*GatewayMessage_SessionAccepted) isGatewayMessage_Payload() {} + +func (*GatewayMessage_SessionRejected) isGatewayMessage_Payload() {} + +func (*GatewayMessage_Heartbeat) isGatewayMessage_Payload() {} + +func (*GatewayMessage_RelayOpen) isGatewayMessage_Payload() {} + +func (*GatewayMessage_RelayClose) isGatewayMessage_Payload() {} + +// Supervisor identifies itself and the sandbox it manages. +type SupervisorHello struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox ID this supervisor manages. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Supervisor instance ID (e.g. boot id or process epoch). + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorHello) Reset() { + *x = SupervisorHello{} + mi := &file_openshell_proto_msgTypes[117] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SupervisorHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SupervisorHello) ProtoMessage() {} + +func (x *SupervisorHello) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[117] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SupervisorHello.ProtoReflect.Descriptor instead. +func (*SupervisorHello) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{117} +} + +func (x *SupervisorHello) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SupervisorHello) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +// Gateway accepts the supervisor session. +type SessionAccepted struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-assigned session ID for this connection. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Recommended heartbeat interval in seconds. + HeartbeatIntervalSecs uint32 `protobuf:"varint,2,opt,name=heartbeat_interval_secs,json=heartbeatIntervalSecs,proto3" json:"heartbeat_interval_secs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionAccepted) Reset() { + *x = SessionAccepted{} + mi := &file_openshell_proto_msgTypes[118] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionAccepted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionAccepted) ProtoMessage() {} + +func (x *SessionAccepted) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[118] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionAccepted.ProtoReflect.Descriptor instead. +func (*SessionAccepted) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{118} +} + +func (x *SessionAccepted) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SessionAccepted) GetHeartbeatIntervalSecs() uint32 { + if x != nil { + return x.HeartbeatIntervalSecs + } + return 0 +} + +// Gateway rejects the supervisor session. +type SessionRejected struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Human-readable rejection reason. + Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionRejected) Reset() { + *x = SessionRejected{} + mi := &file_openshell_proto_msgTypes[119] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionRejected) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRejected) ProtoMessage() {} + +func (x *SessionRejected) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[119] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. +func (*SessionRejected) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{119} +} + +func (x *SessionRejected) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// Supervisor heartbeat. +type SupervisorHeartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorHeartbeat) Reset() { + *x = SupervisorHeartbeat{} + mi := &file_openshell_proto_msgTypes[120] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SupervisorHeartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SupervisorHeartbeat) ProtoMessage() {} + +func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[120] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SupervisorHeartbeat.ProtoReflect.Descriptor instead. +func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{120} +} + +// Gateway heartbeat. +type GatewayHeartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GatewayHeartbeat) Reset() { + *x = GatewayHeartbeat{} + mi := &file_openshell_proto_msgTypes[121] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GatewayHeartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GatewayHeartbeat) ProtoMessage() {} + +func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[121] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GatewayHeartbeat.ProtoReflect.Descriptor instead. +func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{121} +} + +// Gateway requests the supervisor to open a relay channel. +// +// On receiving this, the supervisor should initiate a RelayStream RPC to +// the gateway, sending a RelayInit in the first RelayFrame to associate +// the new HTTP/2 stream with the pending relay slot. The supervisor +// bridges that stream to the requested local target. +type RelayOpen struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-allocated channel identifier (UUID). + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + // Target the supervisor should dial inside the sandbox. + // If absent, supervisors treat the relay as SSH for compatibility. + // + // Types that are valid to be assigned to Target: + // + // *RelayOpen_Ssh + // *RelayOpen_Tcp + Target isRelayOpen_Target `protobuf_oneof:"target"` + // Optional service identifier for audit/correlation. + ServiceId string `protobuf:"bytes,5,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayOpen) Reset() { + *x = RelayOpen{} + mi := &file_openshell_proto_msgTypes[122] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayOpen) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayOpen) ProtoMessage() {} + +func (x *RelayOpen) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[122] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. +func (*RelayOpen) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{122} +} + +func (x *RelayOpen) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +func (x *RelayOpen) GetTarget() isRelayOpen_Target { + if x != nil { + return x.Target + } + return nil +} + +func (x *RelayOpen) GetSsh() *SshRelayTarget { + if x != nil { + if x, ok := x.Target.(*RelayOpen_Ssh); ok { + return x.Ssh + } + } + return nil +} + +func (x *RelayOpen) GetTcp() *TcpRelayTarget { + if x != nil { + if x, ok := x.Target.(*RelayOpen_Tcp); ok { + return x.Tcp + } + } + return nil +} + +func (x *RelayOpen) GetServiceId() string { + if x != nil { + return x.ServiceId + } + return "" +} + +type isRelayOpen_Target interface { + isRelayOpen_Target() +} + +type RelayOpen_Ssh struct { + Ssh *SshRelayTarget `protobuf:"bytes,2,opt,name=ssh,proto3,oneof"` +} + +type RelayOpen_Tcp struct { + Tcp *TcpRelayTarget `protobuf:"bytes,3,opt,name=tcp,proto3,oneof"` +} + +func (*RelayOpen_Ssh) isRelayOpen_Target() {} + +func (*RelayOpen_Tcp) isRelayOpen_Target() {} + +// Built-in SSH relay target. +type SshRelayTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SshRelayTarget) Reset() { + *x = SshRelayTarget{} + mi := &file_openshell_proto_msgTypes[123] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SshRelayTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SshRelayTarget) ProtoMessage() {} + +func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[123] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. +func (*SshRelayTarget) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{123} +} + +// TCP target dialed by the supervisor from inside the sandbox. +type TcpRelayTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Phase 1 accepts loopback only: 127.0.0.1, ::1, or localhost. + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + // Target port. Must fit in u16 and be non-zero. + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TcpRelayTarget) Reset() { + *x = TcpRelayTarget{} + mi := &file_openshell_proto_msgTypes[124] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TcpRelayTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TcpRelayTarget) ProtoMessage() {} + +func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[124] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. +func (*TcpRelayTarget) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{124} +} + +func (x *TcpRelayTarget) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *TcpRelayTarget) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +// Initial RelayStream frame sent by the supervisor to claim a pending relay. +type RelayInit struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-allocated channel identifier (UUID). + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayInit) Reset() { + *x = RelayInit{} + mi := &file_openshell_proto_msgTypes[125] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayInit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayInit) ProtoMessage() {} + +func (x *RelayInit) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[125] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. +func (*RelayInit) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{125} +} + +func (x *RelayInit) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +// A single frame on the RelayStream RPC. +// +// The supervisor MUST send `init` as the first frame. All subsequent frames +// in either direction carry raw bytes in `data`. +type RelayFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *RelayFrame_Init + // *RelayFrame_Data + Payload isRelayFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayFrame) Reset() { + *x = RelayFrame{} + mi := &file_openshell_proto_msgTypes[126] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayFrame) ProtoMessage() {} + +func (x *RelayFrame) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[126] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. +func (*RelayFrame) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{126} +} + +func (x *RelayFrame) GetPayload() isRelayFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *RelayFrame) GetInit() *RelayInit { + if x != nil { + if x, ok := x.Payload.(*RelayFrame_Init); ok { + return x.Init + } + } + return nil +} + +func (x *RelayFrame) GetData() []byte { + if x != nil { + if x, ok := x.Payload.(*RelayFrame_Data); ok { + return x.Data + } + } + return nil +} + +type isRelayFrame_Payload interface { + isRelayFrame_Payload() +} + +type RelayFrame_Init struct { + Init *RelayInit `protobuf:"bytes,1,opt,name=init,proto3,oneof"` +} + +type RelayFrame_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +func (*RelayFrame_Init) isRelayFrame_Payload() {} + +func (*RelayFrame_Data) isRelayFrame_Payload() {} + +// Supervisor reports the result of a relay open request. +type RelayOpenResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Channel identifier from the RelayOpen request. + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + // True if the relay was successfully established. + Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + // Error message if success is false. + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayOpenResult) Reset() { + *x = RelayOpenResult{} + mi := &file_openshell_proto_msgTypes[127] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayOpenResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayOpenResult) ProtoMessage() {} + +func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[127] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. +func (*RelayOpenResult) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{127} +} + +func (x *RelayOpenResult) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +func (x *RelayOpenResult) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *RelayOpenResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// Either side requests closure of a relay channel. +type RelayClose struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Channel identifier to close. + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + // Optional reason for closure. + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayClose) Reset() { + *x = RelayClose{} + mi := &file_openshell_proto_msgTypes[128] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayClose) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayClose) ProtoMessage() {} + +func (x *RelayClose) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[128] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. +func (*RelayClose) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{128} +} + +func (x *RelayClose) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +func (x *RelayClose) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// Observed HTTP method+path pattern from L7 inspection. +type L7RequestSample struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HTTP method: GET, POST, PUT, DELETE, etc. + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + // HTTP path: /v1/models, /repos/myorg/issues + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + // L7 decision: "audit" or "deny" (allowed requests not collected). + Decision string `protobuf:"bytes,3,opt,name=decision,proto3" json:"decision,omitempty"` + // Number of times this (method, path) was observed. + Count uint32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7RequestSample) Reset() { + *x = L7RequestSample{} + mi := &file_openshell_proto_msgTypes[129] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7RequestSample) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7RequestSample) ProtoMessage() {} + +func (x *L7RequestSample) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[129] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. +func (*L7RequestSample) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{129} +} + +func (x *L7RequestSample) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7RequestSample) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7RequestSample) GetDecision() string { + if x != nil { + return x.Decision + } + return "" +} + +func (x *L7RequestSample) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +// Structured denial summary from sandbox aggregator. +type DenialSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox ID that produced this summary. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Denied destination host. + Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` + // Denied destination port. + Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` + // Binary that attempted the connection. + Binary string `protobuf:"bytes,4,opt,name=binary,proto3" json:"binary,omitempty"` + // Process ancestor chain. + Ancestors []string `protobuf:"bytes,5,rep,name=ancestors,proto3" json:"ancestors,omitempty"` + // Denial reason from OPA evaluation. + DenyReason string `protobuf:"bytes,6,opt,name=deny_reason,json=denyReason,proto3" json:"deny_reason,omitempty"` + // First denial timestamp (ms since epoch). + FirstSeenMs int64 `protobuf:"varint,7,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` + // Most recent denial timestamp (ms since epoch). + LastSeenMs int64 `protobuf:"varint,8,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // Number of denials in the current window. + Count uint32 `protobuf:"varint,9,opt,name=count,proto3" json:"count,omitempty"` + // Events dropped during aggregator cooldown. + SuppressedCount uint32 `protobuf:"varint,10,opt,name=suppressed_count,json=suppressedCount,proto3" json:"suppressed_count,omitempty"` + // Cumulative lifetime count (never resets). + TotalCount uint32 `protobuf:"varint,11,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` + // Distinct cmdline strings observed (sanitized of credentials). + SampleCmdlines []string `protobuf:"bytes,12,rep,name=sample_cmdlines,json=sampleCmdlines,proto3" json:"sample_cmdlines,omitempty"` + // SHA-256 of the binary for audit trail. + BinarySha256 string `protobuf:"bytes,13,opt,name=binary_sha256,json=binarySha256,proto3" json:"binary_sha256,omitempty"` + // True if emitted by stale-flush rather than threshold. + Persistent bool `protobuf:"varint,14,opt,name=persistent,proto3" json:"persistent,omitempty"` + // Denial category: "l4_deny", "l7_deny", "l7_audit", "ssrf". + DenialStage string `protobuf:"bytes,15,opt,name=denial_stage,json=denialStage,proto3" json:"denial_stage,omitempty"` + // Observed HTTP request patterns (from L7 inspection). + L7RequestSamples []*L7RequestSample `protobuf:"bytes,16,rep,name=l7_request_samples,json=l7RequestSamples,proto3" json:"l7_request_samples,omitempty"` + // True if L7 inspection was active during observation window. + L7InspectionActive bool `protobuf:"varint,17,opt,name=l7_inspection_active,json=l7InspectionActive,proto3" json:"l7_inspection_active,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DenialSummary) Reset() { + *x = DenialSummary{} + mi := &file_openshell_proto_msgTypes[130] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DenialSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DenialSummary) ProtoMessage() {} + +func (x *DenialSummary) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[130] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. +func (*DenialSummary) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{130} +} + +func (x *DenialSummary) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *DenialSummary) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *DenialSummary) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *DenialSummary) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *DenialSummary) GetAncestors() []string { + if x != nil { + return x.Ancestors + } + return nil +} + +func (x *DenialSummary) GetDenyReason() string { + if x != nil { + return x.DenyReason + } + return "" +} + +func (x *DenialSummary) GetFirstSeenMs() int64 { + if x != nil { + return x.FirstSeenMs + } + return 0 +} + +func (x *DenialSummary) GetLastSeenMs() int64 { + if x != nil { + return x.LastSeenMs + } + return 0 +} + +func (x *DenialSummary) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *DenialSummary) GetSuppressedCount() uint32 { + if x != nil { + return x.SuppressedCount + } + return 0 +} + +func (x *DenialSummary) GetTotalCount() uint32 { + if x != nil { + return x.TotalCount + } + return 0 +} + +func (x *DenialSummary) GetSampleCmdlines() []string { + if x != nil { + return x.SampleCmdlines + } + return nil +} + +func (x *DenialSummary) GetBinarySha256() string { + if x != nil { + return x.BinarySha256 + } + return "" +} + +func (x *DenialSummary) GetPersistent() bool { + if x != nil { + return x.Persistent + } + return false +} + +func (x *DenialSummary) GetDenialStage() string { + if x != nil { + return x.DenialStage + } + return "" +} + +func (x *DenialSummary) GetL7RequestSamples() []*L7RequestSample { + if x != nil { + return x.L7RequestSamples + } + return nil +} + +func (x *DenialSummary) GetL7InspectionActive() bool { + if x != nil { + return x.L7InspectionActive + } + return false +} + +// Count of denied actions grouped only by sanitized telemetry category. +type DenialGroupCount struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sanitized denial category, e.g. "connect_policy", "l7_policy", "ssrf". + DenyGroup string `protobuf:"bytes,1,opt,name=deny_group,json=denyGroup,proto3" json:"deny_group,omitempty"` + // Number of denied actions in this category. + DeniedCount uint32 `protobuf:"varint,2,opt,name=denied_count,json=deniedCount,proto3" json:"denied_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DenialGroupCount) Reset() { + *x = DenialGroupCount{} + mi := &file_openshell_proto_msgTypes[131] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DenialGroupCount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DenialGroupCount) ProtoMessage() {} + +func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[131] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. +func (*DenialGroupCount) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{131} +} + +func (x *DenialGroupCount) GetDenyGroup() string { + if x != nil { + return x.DenyGroup + } + return "" +} + +func (x *DenialGroupCount) GetDeniedCount() uint32 { + if x != nil { + return x.DeniedCount + } + return 0 +} + +// Anonymous sandbox network activity counters. This intentionally excludes +// hosts, paths, binaries, raw deny reasons, sandbox IDs, and user content. +type NetworkActivitySummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Total observed network activities in the current window. + NetworkActivityCount uint32 `protobuf:"varint,1,opt,name=network_activity_count,json=networkActivityCount,proto3" json:"network_activity_count,omitempty"` + // Total denied actions in the current window. + DeniedActionCount uint32 `protobuf:"varint,2,opt,name=denied_action_count,json=deniedActionCount,proto3" json:"denied_action_count,omitempty"` + // Denied action counts grouped by sanitized category. + DenialsByGroup []*DenialGroupCount `protobuf:"bytes,3,rep,name=denials_by_group,json=denialsByGroup,proto3" json:"denials_by_group,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkActivitySummary) Reset() { + *x = NetworkActivitySummary{} + mi := &file_openshell_proto_msgTypes[132] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkActivitySummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkActivitySummary) ProtoMessage() {} + +func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[132] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. +func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{132} +} + +func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { + if x != nil { + return x.NetworkActivityCount + } + return 0 +} + +func (x *NetworkActivitySummary) GetDeniedActionCount() uint32 { + if x != nil { + return x.DeniedActionCount + } + return 0 +} + +func (x *NetworkActivitySummary) GetDenialsByGroup() []*DenialGroupCount { + if x != nil { + return x.DenialsByGroup + } + return nil +} + +// A proposed policy rule with rationale and approval status. +type PolicyChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unique chunk identifier. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Approval status: "pending", "approved", "rejected". + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + // Proposed network_policies map key. + RuleName string `protobuf:"bytes,3,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + // The proposed network policy rule. + ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,4,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + // Human-readable explanation of why this rule is proposed. + Rationale string `protobuf:"bytes,5,opt,name=rationale,proto3" json:"rationale,omitempty"` + // Security concerns flagged by analysis (empty if none). + SecurityNotes string `protobuf:"bytes,6,opt,name=security_notes,json=securityNotes,proto3" json:"security_notes,omitempty"` + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + Confidence float32 `protobuf:"fixed32,7,opt,name=confidence,proto3" json:"confidence,omitempty"` + // IDs of denial summaries that led to this chunk. + DenialSummaryIds []string `protobuf:"bytes,8,rep,name=denial_summary_ids,json=denialSummaryIds,proto3" json:"denial_summary_ids,omitempty"` + // Creation timestamp (ms since epoch). + CreatedAtMs int64 `protobuf:"varint,9,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // When the user approved/rejected (ms since epoch). 0 if undecided. + DecidedAtMs int64 `protobuf:"varint,10,opt,name=decided_at_ms,json=decidedAtMs,proto3" json:"decided_at_ms,omitempty"` + // Recommendation stage: "initial" or "refined" (progressive L7 visibility). + Stage string `protobuf:"bytes,11,opt,name=stage,proto3" json:"stage,omitempty"` + // For stage="refined": the initial chunk this replaces. + SupersedesChunkId string `protobuf:"bytes,12,opt,name=supersedes_chunk_id,json=supersedesChunkId,proto3" json:"supersedes_chunk_id,omitempty"` + // How many times this endpoint has been seen across denial flush cycles. + HitCount int32 `protobuf:"varint,13,opt,name=hit_count,json=hitCount,proto3" json:"hit_count,omitempty"` + // First time this endpoint was proposed (ms since epoch). + FirstSeenMs int64 `protobuf:"varint,14,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` + // Most recent time this endpoint was re-proposed (ms since epoch). + LastSeenMs int64 `protobuf:"varint,15,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // Binary path that triggered the denial (denormalized for display convenience). + Binary string `protobuf:"bytes,16,opt,name=binary,proto3" json:"binary,omitempty"` + // Validation verdict from gateway-side static checks (prover output). + // Free-form summary string for human consumption in the inbox card. + // Empty until the prover has run for this chunk. + ValidationResult string `protobuf:"bytes,17,opt,name=validation_result,json=validationResult,proto3" json:"validation_result,omitempty"` + // Operator-supplied free-form text accompanying a rejection. Populated + // when the reviewer rejects via `RejectDraftChunkRequest.reason`; surfaced + // back to the in-sandbox agent so it can revise the proposal. + // Empty for non-rejected chunks. + RejectionReason string `protobuf:"bytes,18,opt,name=rejection_reason,json=rejectionReason,proto3" json:"rejection_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyChunk) Reset() { + *x = PolicyChunk{} + mi := &file_openshell_proto_msgTypes[133] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyChunk) ProtoMessage() {} + +func (x *PolicyChunk) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[133] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. +func (*PolicyChunk) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{133} +} + +func (x *PolicyChunk) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PolicyChunk) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PolicyChunk) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *PolicyChunk) GetProposedRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.ProposedRule + } + return nil +} + +func (x *PolicyChunk) GetRationale() string { + if x != nil { + return x.Rationale + } + return "" +} + +func (x *PolicyChunk) GetSecurityNotes() string { + if x != nil { + return x.SecurityNotes + } + return "" +} + +func (x *PolicyChunk) GetConfidence() float32 { + if x != nil { + return x.Confidence + } + return 0 +} + +func (x *PolicyChunk) GetDenialSummaryIds() []string { + if x != nil { + return x.DenialSummaryIds + } + return nil +} + +func (x *PolicyChunk) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *PolicyChunk) GetDecidedAtMs() int64 { + if x != nil { + return x.DecidedAtMs + } + return 0 +} + +func (x *PolicyChunk) GetStage() string { + if x != nil { + return x.Stage + } + return "" +} + +func (x *PolicyChunk) GetSupersedesChunkId() string { + if x != nil { + return x.SupersedesChunkId + } + return "" +} + +func (x *PolicyChunk) GetHitCount() int32 { + if x != nil { + return x.HitCount + } + return 0 +} + +func (x *PolicyChunk) GetFirstSeenMs() int64 { + if x != nil { + return x.FirstSeenMs + } + return 0 +} + +func (x *PolicyChunk) GetLastSeenMs() int64 { + if x != nil { + return x.LastSeenMs + } + return 0 +} + +func (x *PolicyChunk) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *PolicyChunk) GetValidationResult() string { + if x != nil { + return x.ValidationResult + } + return "" +} + +func (x *PolicyChunk) GetRejectionReason() string { + if x != nil { + return x.RejectionReason + } + return "" +} + +// Notification that the draft policy was updated. +type DraftPolicyUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Current draft version. + DraftVersion uint64 `protobuf:"varint,1,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + // Number of new chunks added in this update. + NewChunks uint32 `protobuf:"varint,2,opt,name=new_chunks,json=newChunks,proto3" json:"new_chunks,omitempty"` + // Total pending chunks awaiting approval. + TotalPending uint32 `protobuf:"varint,3,opt,name=total_pending,json=totalPending,proto3" json:"total_pending,omitempty"` + // Brief description of what changed. + Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DraftPolicyUpdate) Reset() { + *x = DraftPolicyUpdate{} + mi := &file_openshell_proto_msgTypes[134] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DraftPolicyUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DraftPolicyUpdate) ProtoMessage() {} + +func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[134] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. +func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{134} +} + +func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *DraftPolicyUpdate) GetNewChunks() uint32 { + if x != nil { + return x.NewChunks + } + return 0 +} + +func (x *DraftPolicyUpdate) GetTotalPending() uint32 { + if x != nil { + return x.TotalPending + } + return 0 +} + +func (x *DraftPolicyUpdate) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +// Submit analysis results from sandbox to gateway. +type SubmitPolicyAnalysisRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Aggregated denial summaries. + Summaries []*DenialSummary `protobuf:"bytes,1,rep,name=summaries,proto3" json:"summaries,omitempty"` + // Proposed policy chunks (validated by sandbox OPA engine). + ProposedChunks []*PolicyChunk `protobuf:"bytes,2,rep,name=proposed_chunks,json=proposedChunks,proto3" json:"proposed_chunks,omitempty"` + // Analysis mode. `mechanistic` is the observation-driven path from the + // denial aggregator — chunks targeting the same host|port|binary fold + // into one row with hit_count incremented. `agent_authored` is an + // intentional proposal from an in-sandbox agent — each submission lands + // as its own chunk so the redraft-after-rejection loop has a stable id + // to watch. Other values are treated as agent-style (no dedup) so a new + // mode does not silently collapse proposals. + AnalysisMode string `protobuf:"bytes,3,opt,name=analysis_mode,json=analysisMode,proto3" json:"analysis_mode,omitempty"` + // Sandbox name. + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + // Anonymous network activity counters. + NetworkActivitySummaries []*NetworkActivitySummary `protobuf:"bytes,5,rep,name=network_activity_summaries,json=networkActivitySummaries,proto3" json:"network_activity_summaries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitPolicyAnalysisRequest) Reset() { + *x = SubmitPolicyAnalysisRequest{} + mi := &file_openshell_proto_msgTypes[135] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitPolicyAnalysisRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} + +func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[135] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. +func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{135} +} + +func (x *SubmitPolicyAnalysisRequest) GetSummaries() []*DenialSummary { + if x != nil { + return x.Summaries + } + return nil +} + +func (x *SubmitPolicyAnalysisRequest) GetProposedChunks() []*PolicyChunk { + if x != nil { + return x.ProposedChunks + } + return nil +} + +func (x *SubmitPolicyAnalysisRequest) GetAnalysisMode() string { + if x != nil { + return x.AnalysisMode + } + return "" +} + +func (x *SubmitPolicyAnalysisRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SubmitPolicyAnalysisRequest) GetNetworkActivitySummaries() []*NetworkActivitySummary { + if x != nil { + return x.NetworkActivitySummaries + } + return nil +} + +type SubmitPolicyAnalysisResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Number of chunks accepted by the gateway. + AcceptedChunks uint32 `protobuf:"varint,1,opt,name=accepted_chunks,json=acceptedChunks,proto3" json:"accepted_chunks,omitempty"` + // Number of chunks rejected by gateway validation. + RejectedChunks uint32 `protobuf:"varint,2,opt,name=rejected_chunks,json=rejectedChunks,proto3" json:"rejected_chunks,omitempty"` + // Reasons for each rejected chunk. + RejectionReasons []string `protobuf:"bytes,3,rep,name=rejection_reasons,json=rejectionReasons,proto3" json:"rejection_reasons,omitempty"` + // Server-assigned chunk IDs for the accepted chunks, in submission order. + // Agents use these to watch proposal state via policy.local's + // GET /v1/proposals/{id} and /wait endpoints. + AcceptedChunkIds []string `protobuf:"bytes,4,rep,name=accepted_chunk_ids,json=acceptedChunkIds,proto3" json:"accepted_chunk_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitPolicyAnalysisResponse) Reset() { + *x = SubmitPolicyAnalysisResponse{} + mi := &file_openshell_proto_msgTypes[136] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitPolicyAnalysisResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} + +func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[136] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. +func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{136} +} + +func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { + if x != nil { + return x.AcceptedChunks + } + return 0 +} + +func (x *SubmitPolicyAnalysisResponse) GetRejectedChunks() uint32 { + if x != nil { + return x.RejectedChunks + } + return 0 +} + +func (x *SubmitPolicyAnalysisResponse) GetRejectionReasons() []string { + if x != nil { + return x.RejectionReasons + } + return nil +} + +func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunkIds() []string { + if x != nil { + return x.AcceptedChunkIds + } + return nil +} + +// Get draft policy for a sandbox. +type GetDraftPolicyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Optional status filter: "pending", "approved", "rejected", or "" for all. + StatusFilter string `protobuf:"bytes,2,opt,name=status_filter,json=statusFilter,proto3" json:"status_filter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftPolicyRequest) Reset() { + *x = GetDraftPolicyRequest{} + mi := &file_openshell_proto_msgTypes[137] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftPolicyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftPolicyRequest) ProtoMessage() {} + +func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[137] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. +func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{137} +} + +func (x *GetDraftPolicyRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetDraftPolicyRequest) GetStatusFilter() string { + if x != nil { + return x.StatusFilter + } + return "" +} + +type GetDraftPolicyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Draft policy chunks. + Chunks []*PolicyChunk `protobuf:"bytes,1,rep,name=chunks,proto3" json:"chunks,omitempty"` + // LLM-generated summary of all analysis (empty in mechanistic mode). + RollingSummary string `protobuf:"bytes,2,opt,name=rolling_summary,json=rollingSummary,proto3" json:"rolling_summary,omitempty"` + // Current draft version. + DraftVersion uint64 `protobuf:"varint,3,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + // When the last analysis completed (ms since epoch). + LastAnalyzedAtMs int64 `protobuf:"varint,4,opt,name=last_analyzed_at_ms,json=lastAnalyzedAtMs,proto3" json:"last_analyzed_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftPolicyResponse) Reset() { + *x = GetDraftPolicyResponse{} + mi := &file_openshell_proto_msgTypes[138] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftPolicyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftPolicyResponse) ProtoMessage() {} + +func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[138] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. +func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{138} +} + +func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { + if x != nil { + return x.Chunks + } + return nil +} + +func (x *GetDraftPolicyResponse) GetRollingSummary() string { + if x != nil { + return x.RollingSummary + } + return "" +} + +func (x *GetDraftPolicyResponse) GetDraftVersion() uint64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *GetDraftPolicyResponse) GetLastAnalyzedAtMs() int64 { + if x != nil { + return x.LastAnalyzedAtMs + } + return 0 +} + +// Approve a single draft chunk. +type ApproveDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to approve. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveDraftChunkRequest) Reset() { + *x = ApproveDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[139] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveDraftChunkRequest) ProtoMessage() {} + +func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[139] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{139} +} + +func (x *ApproveDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ApproveDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +type ApproveDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // New policy version after merge. + PolicyVersion uint32 `protobuf:"varint,1,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + // SHA-256 hash of the new policy. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveDraftChunkResponse) Reset() { + *x = ApproveDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[140] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveDraftChunkResponse) ProtoMessage() {} + +func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[140] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{140} +} + +func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *ApproveDraftChunkResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +// Reject a single draft chunk. +type RejectDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to reject. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + // Optional reason for rejection (fed to LLM context in future analysis). + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectDraftChunkRequest) Reset() { + *x = RejectDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[141] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectDraftChunkRequest) ProtoMessage() {} + +func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[141] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{141} +} + +func (x *RejectDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *RejectDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +func (x *RejectDraftChunkRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type RejectDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectDraftChunkResponse) Reset() { + *x = RejectDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[142] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectDraftChunkResponse) ProtoMessage() {} + +func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[142] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{142} +} + +// Approve all pending chunks. +type ApproveAllDraftChunksRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Include chunks with security_notes (default false: skips them). + IncludeSecurityFlagged bool `protobuf:"varint,2,opt,name=include_security_flagged,json=includeSecurityFlagged,proto3" json:"include_security_flagged,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveAllDraftChunksRequest) Reset() { + *x = ApproveAllDraftChunksRequest{} + mi := &file_openshell_proto_msgTypes[143] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveAllDraftChunksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveAllDraftChunksRequest) ProtoMessage() {} + +func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[143] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. +func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{143} +} + +func (x *ApproveAllDraftChunksRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ApproveAllDraftChunksRequest) GetIncludeSecurityFlagged() bool { + if x != nil { + return x.IncludeSecurityFlagged + } + return false +} + +type ApproveAllDraftChunksResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // New policy version after merge. + PolicyVersion uint32 `protobuf:"varint,1,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + // SHA-256 hash of the new policy. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Number of chunks approved. + ChunksApproved uint32 `protobuf:"varint,3,opt,name=chunks_approved,json=chunksApproved,proto3" json:"chunks_approved,omitempty"` + // Number of chunks skipped (security-flagged). + ChunksSkipped uint32 `protobuf:"varint,4,opt,name=chunks_skipped,json=chunksSkipped,proto3" json:"chunks_skipped,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveAllDraftChunksResponse) Reset() { + *x = ApproveAllDraftChunksResponse{} + mi := &file_openshell_proto_msgTypes[144] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveAllDraftChunksResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveAllDraftChunksResponse) ProtoMessage() {} + +func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[144] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. +func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{144} +} + +func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *ApproveAllDraftChunksResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *ApproveAllDraftChunksResponse) GetChunksApproved() uint32 { + if x != nil { + return x.ChunksApproved + } + return 0 +} + +func (x *ApproveAllDraftChunksResponse) GetChunksSkipped() uint32 { + if x != nil { + return x.ChunksSkipped + } + return 0 +} + +// Edit a pending chunk in-place. +type EditDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to edit. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + // The modified rule (replaces existing proposed_rule). + ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditDraftChunkRequest) Reset() { + *x = EditDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[145] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditDraftChunkRequest) ProtoMessage() {} + +func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[145] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{145} +} + +func (x *EditDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EditDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +func (x *EditDraftChunkRequest) GetProposedRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.ProposedRule + } + return nil +} + +type EditDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditDraftChunkResponse) Reset() { + *x = EditDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[146] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditDraftChunkResponse) ProtoMessage() {} + +func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[146] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{146} +} + +// Reverse an approval (remove merged rule from active policy). +type UndoDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to undo. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UndoDraftChunkRequest) Reset() { + *x = UndoDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[147] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UndoDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UndoDraftChunkRequest) ProtoMessage() {} + +func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[147] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{147} +} + +func (x *UndoDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UndoDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +type UndoDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // New policy version after removal. + PolicyVersion uint32 `protobuf:"varint,1,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + // SHA-256 hash of the updated policy. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UndoDraftChunkResponse) Reset() { + *x = UndoDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[148] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UndoDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UndoDraftChunkResponse) ProtoMessage() {} + +func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[148] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{148} +} + +func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *UndoDraftChunkResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +// Clear all pending draft chunks for a sandbox. +type ClearDraftChunksRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearDraftChunksRequest) Reset() { + *x = ClearDraftChunksRequest{} + mi := &file_openshell_proto_msgTypes[149] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearDraftChunksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearDraftChunksRequest) ProtoMessage() {} + +func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[149] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. +func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{149} +} + +func (x *ClearDraftChunksRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type ClearDraftChunksResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Number of chunks cleared. + ChunksCleared uint32 `protobuf:"varint,1,opt,name=chunks_cleared,json=chunksCleared,proto3" json:"chunks_cleared,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearDraftChunksResponse) Reset() { + *x = ClearDraftChunksResponse{} + mi := &file_openshell_proto_msgTypes[150] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearDraftChunksResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearDraftChunksResponse) ProtoMessage() {} + +func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[150] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. +func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{150} +} + +func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { + if x != nil { + return x.ChunksCleared + } + return 0 +} + +// Get decision history for a sandbox's draft policy. +type GetDraftHistoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftHistoryRequest) Reset() { + *x = GetDraftHistoryRequest{} + mi := &file_openshell_proto_msgTypes[151] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftHistoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftHistoryRequest) ProtoMessage() {} + +func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[151] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. +func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{151} +} + +func (x *GetDraftHistoryRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DraftHistoryEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Event timestamp (ms since epoch). + TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + // Event type: "denial_detected", "analysis_cycle", "approved", + // "rejected", "edited", "undone", "cleared". + EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` + // Human-readable description. + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // Associated chunk ID (if applicable). + ChunkId string `protobuf:"bytes,4,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DraftHistoryEntry) Reset() { + *x = DraftHistoryEntry{} + mi := &file_openshell_proto_msgTypes[152] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DraftHistoryEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DraftHistoryEntry) ProtoMessage() {} + +func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[152] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. +func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{152} +} + +func (x *DraftHistoryEntry) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *DraftHistoryEntry) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *DraftHistoryEntry) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *DraftHistoryEntry) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +type GetDraftHistoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Chronological decision history. + Entries []*DraftHistoryEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftHistoryResponse) Reset() { + *x = GetDraftHistoryResponse{} + mi := &file_openshell_proto_msgTypes[153] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftHistoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftHistoryResponse) ProtoMessage() {} + +func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[153] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. +func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{153} +} + +func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { + if x != nil { + return x.Entries + } + return nil +} + +// Stored payload for a policy revision row in the generic objects table. +type PolicyRevisionPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Serialized policy contents. + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` + // Deterministic hash of the policy payload. + Hash string `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` + // Load error reported by the sandbox, if any. + LoadError string `protobuf:"bytes,3,opt,name=load_error,json=loadError,proto3" json:"load_error,omitempty"` + // When the policy version was reported as loaded (ms since epoch). 0 if unset. + LoadedAtMs int64 `protobuf:"varint,4,opt,name=loaded_at_ms,json=loadedAtMs,proto3" json:"loaded_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyRevisionPayload) Reset() { + *x = PolicyRevisionPayload{} + mi := &file_openshell_proto_msgTypes[154] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyRevisionPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyRevisionPayload) ProtoMessage() {} + +func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[154] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyRevisionPayload.ProtoReflect.Descriptor instead. +func (*PolicyRevisionPayload) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{154} +} + +func (x *PolicyRevisionPayload) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *PolicyRevisionPayload) GetHash() string { + if x != nil { + return x.Hash + } + return "" +} + +func (x *PolicyRevisionPayload) GetLoadError() string { + if x != nil { + return x.LoadError + } + return "" +} + +func (x *PolicyRevisionPayload) GetLoadedAtMs() int64 { + if x != nil { + return x.LoadedAtMs + } + return 0 +} + +// Stored payload for a draft policy chunk row in the generic objects table. +type DraftChunkPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Proposed network_policies map key. + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + // Proposed network policy rule. + ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,2,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + // Human-readable explanation of why this rule is proposed. + Rationale string `protobuf:"bytes,3,opt,name=rationale,proto3" json:"rationale,omitempty"` + // Security concerns flagged by analysis (empty if none). + SecurityNotes string `protobuf:"bytes,4,opt,name=security_notes,json=securityNotes,proto3" json:"security_notes,omitempty"` + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + Confidence float32 `protobuf:"fixed32,5,opt,name=confidence,proto3" json:"confidence,omitempty"` + // When the user approved/rejected (ms since epoch). 0 if undecided. + DecidedAtMs int64 `protobuf:"varint,6,opt,name=decided_at_ms,json=decidedAtMs,proto3" json:"decided_at_ms,omitempty"` + // Denormalized endpoint host for dedup and display. + Host string `protobuf:"bytes,7,opt,name=host,proto3" json:"host,omitempty"` + // Denormalized endpoint port for dedup and display. + Port int32 `protobuf:"varint,8,opt,name=port,proto3" json:"port,omitempty"` + // Binary path that triggered the denial. + Binary string `protobuf:"bytes,9,opt,name=binary,proto3" json:"binary,omitempty"` + // Current draft version for the owning sandbox. + DraftVersion int64 `protobuf:"varint,10,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + // Gateway prover verdict for this chunk; empty until prover runs. + // Mirrors PolicyChunk.validation_result. + ValidationResult string `protobuf:"bytes,11,opt,name=validation_result,json=validationResult,proto3" json:"validation_result,omitempty"` + // Operator-supplied free-form rejection text; empty for non-rejected + // chunks. Mirrors PolicyChunk.rejection_reason. + RejectionReason string `protobuf:"bytes,12,opt,name=rejection_reason,json=rejectionReason,proto3" json:"rejection_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DraftChunkPayload) Reset() { + *x = DraftChunkPayload{} + mi := &file_openshell_proto_msgTypes[155] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DraftChunkPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DraftChunkPayload) ProtoMessage() {} + +func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[155] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DraftChunkPayload.ProtoReflect.Descriptor instead. +func (*DraftChunkPayload) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{155} +} + +func (x *DraftChunkPayload) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *DraftChunkPayload) GetProposedRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.ProposedRule + } + return nil +} + +func (x *DraftChunkPayload) GetRationale() string { + if x != nil { + return x.Rationale + } + return "" +} + +func (x *DraftChunkPayload) GetSecurityNotes() string { + if x != nil { + return x.SecurityNotes + } + return "" +} + +func (x *DraftChunkPayload) GetConfidence() float32 { + if x != nil { + return x.Confidence + } + return 0 +} + +func (x *DraftChunkPayload) GetDecidedAtMs() int64 { + if x != nil { + return x.DecidedAtMs + } + return 0 +} + +func (x *DraftChunkPayload) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *DraftChunkPayload) GetPort() int32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *DraftChunkPayload) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *DraftChunkPayload) GetDraftVersion() int64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *DraftChunkPayload) GetValidationResult() string { + if x != nil { + return x.ValidationResult + } + return "" +} + +func (x *DraftChunkPayload) GetRejectionReason() string { + if x != nil { + return x.RejectionReason + } + return "" +} + +// Internal stored policy revision row materialized from the generic objects table. +type StoredPolicyRevision struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Version int64 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` + PolicyPayload []byte `protobuf:"bytes,4,opt,name=policy_payload,json=policyPayload,proto3" json:"policy_payload,omitempty"` + PolicyHash string `protobuf:"bytes,5,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + LoadError *string `protobuf:"bytes,7,opt,name=load_error,json=loadError,proto3,oneof" json:"load_error,omitempty"` + CreatedAtMs int64 `protobuf:"varint,8,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + LoadedAtMs *int64 `protobuf:"varint,9,opt,name=loaded_at_ms,json=loadedAtMs,proto3,oneof" json:"loaded_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredPolicyRevision) Reset() { + *x = StoredPolicyRevision{} + mi := &file_openshell_proto_msgTypes[156] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredPolicyRevision) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredPolicyRevision) ProtoMessage() {} + +func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[156] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoredPolicyRevision.ProtoReflect.Descriptor instead. +func (*StoredPolicyRevision) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{156} +} + +func (x *StoredPolicyRevision) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *StoredPolicyRevision) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *StoredPolicyRevision) GetVersion() int64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *StoredPolicyRevision) GetPolicyPayload() []byte { + if x != nil { + return x.PolicyPayload + } + return nil +} + +func (x *StoredPolicyRevision) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *StoredPolicyRevision) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *StoredPolicyRevision) GetLoadError() string { + if x != nil && x.LoadError != nil { + return *x.LoadError + } + return "" +} + +func (x *StoredPolicyRevision) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *StoredPolicyRevision) GetLoadedAtMs() int64 { + if x != nil && x.LoadedAtMs != nil { + return *x.LoadedAtMs + } + return 0 +} + +// Internal stored draft chunk row materialized from the generic objects table. +type StoredDraftChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + DraftVersion int64 `protobuf:"varint,3,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` + RuleName string `protobuf:"bytes,5,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + ProposedRule []byte `protobuf:"bytes,6,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + Rationale string `protobuf:"bytes,7,opt,name=rationale,proto3" json:"rationale,omitempty"` + SecurityNotes string `protobuf:"bytes,8,opt,name=security_notes,json=securityNotes,proto3" json:"security_notes,omitempty"` + Confidence float64 `protobuf:"fixed64,9,opt,name=confidence,proto3" json:"confidence,omitempty"` + CreatedAtMs int64 `protobuf:"varint,10,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + DecidedAtMs *int64 `protobuf:"varint,11,opt,name=decided_at_ms,json=decidedAtMs,proto3,oneof" json:"decided_at_ms,omitempty"` + Host string `protobuf:"bytes,12,opt,name=host,proto3" json:"host,omitempty"` + Port int32 `protobuf:"varint,13,opt,name=port,proto3" json:"port,omitempty"` + Binary string `protobuf:"bytes,14,opt,name=binary,proto3" json:"binary,omitempty"` + HitCount int32 `protobuf:"varint,15,opt,name=hit_count,json=hitCount,proto3" json:"hit_count,omitempty"` + FirstSeenMs int64 `protobuf:"varint,16,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` + LastSeenMs int64 `protobuf:"varint,17,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // Gateway prover verdict; empty until the prover runs. See PolicyChunk. + ValidationResult string `protobuf:"bytes,18,opt,name=validation_result,json=validationResult,proto3" json:"validation_result,omitempty"` + // Operator-supplied free-form rejection text. See PolicyChunk. + RejectionReason string `protobuf:"bytes,19,opt,name=rejection_reason,json=rejectionReason,proto3" json:"rejection_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredDraftChunk) Reset() { + *x = StoredDraftChunk{} + mi := &file_openshell_proto_msgTypes[157] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredDraftChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredDraftChunk) ProtoMessage() {} + +func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[157] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoredDraftChunk.ProtoReflect.Descriptor instead. +func (*StoredDraftChunk) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{157} +} + +func (x *StoredDraftChunk) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *StoredDraftChunk) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *StoredDraftChunk) GetDraftVersion() int64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *StoredDraftChunk) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *StoredDraftChunk) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *StoredDraftChunk) GetProposedRule() []byte { + if x != nil { + return x.ProposedRule + } + return nil +} + +func (x *StoredDraftChunk) GetRationale() string { + if x != nil { + return x.Rationale + } + return "" +} + +func (x *StoredDraftChunk) GetSecurityNotes() string { + if x != nil { + return x.SecurityNotes + } + return "" +} + +func (x *StoredDraftChunk) GetConfidence() float64 { + if x != nil { + return x.Confidence + } + return 0 +} + +func (x *StoredDraftChunk) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *StoredDraftChunk) GetDecidedAtMs() int64 { + if x != nil && x.DecidedAtMs != nil { + return *x.DecidedAtMs + } + return 0 +} + +func (x *StoredDraftChunk) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *StoredDraftChunk) GetPort() int32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *StoredDraftChunk) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *StoredDraftChunk) GetHitCount() int32 { + if x != nil { + return x.HitCount + } + return 0 +} + +func (x *StoredDraftChunk) GetFirstSeenMs() int64 { + if x != nil { + return x.FirstSeenMs + } + return 0 +} + +func (x *StoredDraftChunk) GetLastSeenMs() int64 { + if x != nil { + return x.LastSeenMs + } + return 0 +} + +func (x *StoredDraftChunk) GetValidationResult() string { + if x != nil { + return x.ValidationResult + } + return "" +} + +func (x *StoredDraftChunk) GetRejectionReason() string { + if x != nil { + return x.RejectionReason + } + return "" +} + +var File_openshell_proto protoreflect.FileDescriptor + +const file_openshell_proto_rawDesc = "" + + "\n" + + "\x0fopenshell.proto\x12\fopenshell.v1\x1a\x0fdatamodel.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\rsandbox.proto\"\x1a\n" + + "\x18IssueSandboxTokenRequest\"U\n" + + "\x19IssueSandboxTokenResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\"\n" + + "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"\x1c\n" + + "\x1aRefreshSandboxTokenRequest\"W\n" + + "\x1bRefreshSandboxTokenResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\"\n" + + "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"\x0f\n" + + "\rHealthRequest\"_\n" + + "\x0eHealthResponse\x123\n" + + "\x06status\x18\x01 \x01(\x0e2\x1b.openshell.v1.ServiceStatusR\x06status\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\"\xd8\x01\n" + + "\aSandbox\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12-\n" + + "\x04spec\x18\x02 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x123\n" + + "\x06status\x18\x03 \x01(\v2\x1b.openshell.v1.SandboxStatusR\x06statusJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x05phaseR\x16current_policy_version\"\xd7\x03\n" + + "\vSandboxSpec\x12\x1b\n" + + "\tlog_level\x18\x01 \x01(\tR\blogLevel\x12L\n" + + "\venvironment\x18\x05 \x03(\v2*.openshell.v1.SandboxSpec.EnvironmentEntryR\venvironment\x129\n" + + "\btemplate\x18\x06 \x01(\v2\x1d.openshell.v1.SandboxTemplateR\btemplate\x12;\n" + + "\x06policy\x18\a \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1c\n" + + "\tproviders\x18\b \x03(\tR\tproviders\x12W\n" + + "\x15resource_requirements\x18\t \x01(\v2\".openshell.v1.ResourceRequirementsR\x14resourceRequirements\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\n" + + "\x10\vJ\x04\b\v\x10\fR\n" + + "gpu_deviceR\x16proposal_approval_mode\"O\n" + + "\x14ResourceRequirements\x127\n" + + "\x03gpu\x18\x01 \x01(\v2%.openshell.v1.GpuResourceRequirementsR\x03gpu\">\n" + + "\x17GpuResourceRequirements\x12\x19\n" + + "\x05count\x18\x01 \x01(\rH\x00R\x05count\x88\x01\x01B\b\n" + + "\x06_count\"\xa0\x06\n" + + "\x0fSandboxTemplate\x12\x14\n" + + "\x05image\x18\x01 \x01(\tR\x05image\x12,\n" + + "\x12runtime_class_name\x18\x02 \x01(\tR\x10runtimeClassName\x12!\n" + + "\fagent_socket\x18\x03 \x01(\tR\vagentSocket\x12A\n" + + "\x06labels\x18\x04 \x03(\v2).openshell.v1.SandboxTemplate.LabelsEntryR\x06labels\x12P\n" + + "\vannotations\x18\x05 \x03(\v2..openshell.v1.SandboxTemplate.AnnotationsEntryR\vannotations\x12P\n" + + "\venvironment\x18\x06 \x03(\v2..openshell.v1.SandboxTemplate.EnvironmentEntryR\venvironment\x125\n" + + "\tresources\x18\a \x01(\v2\x17.google.protobuf.StructR\tresources\x12M\n" + + "\x16volume_claim_templates\x18\t \x01(\v2\x17.google.protobuf.StructR\x14volumeClaimTemplates\x12,\n" + + "\x0fuser_namespaces\x18\n" + + " \x01(\bH\x00R\x0euserNamespaces\x88\x01\x01\x12<\n" + + "\rdriver_config\x18\v \x01(\v2\x17.google.protobuf.StructR\fdriverConfig\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + + "\x10AnnotationsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x12\n" + + "\x10_user_namespaces\"\xb1\x02\n" + + "\rSandboxStatus\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12\x1b\n" + + "\tagent_pod\x18\x02 \x01(\tR\bagentPod\x12\x19\n" + + "\bagent_fd\x18\x03 \x01(\tR\aagentFd\x12\x1d\n" + + "\n" + + "sandbox_fd\x18\x04 \x01(\tR\tsandboxFd\x12>\n" + + "\n" + + "conditions\x18\x05 \x03(\v2\x1e.openshell.v1.SandboxConditionR\n" + + "conditions\x120\n" + + "\x05phase\x18\x06 \x01(\x0e2\x1a.openshell.v1.SandboxPhaseR\x05phase\x124\n" + + "\x16current_policy_version\x18\a \x01(\rR\x14currentPolicyVersion\"\xa2\x01\n" + + "\x10SandboxCondition\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x120\n" + + "\x14last_transition_time\x18\x05 \x01(\tR\x12lastTransitionTime\"\x94\x02\n" + + "\rPlatformEvent\x12!\n" + + "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x16\n" + + "\x06source\x18\x02 \x01(\tR\x06source\x12\x12\n" + + "\x04type\x18\x03 \x01(\tR\x04type\x12\x16\n" + + "\x06reason\x18\x04 \x01(\tR\x06reason\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12E\n" + + "\bmetadata\x18\x06 \x03(\v2).openshell.v1.PlatformEvent.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xdc\x01\n" + + "\x14CreateSandboxRequest\x12-\n" + + "\x04spec\x18\x01 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12F\n" + + "\x06labels\x18\x03 \x03(\v2..openshell.v1.CreateSandboxRequest.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + + "\x11GetSandboxRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"k\n" + + "\x14ListSandboxesRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\x12%\n" + + "\x0elabel_selector\x18\x03 \x01(\tR\rlabelSelector\"@\n" + + "\x1bListSandboxProvidersRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\"\xa2\x01\n" + + "\x1cAttachSandboxProviderRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\"\xa2\x01\n" + + "\x1cDetachSandboxProviderRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\"*\n" + + "\x14DeleteSandboxRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"B\n" + + "\x0fSandboxResponse\x12/\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\"L\n" + + "\x15ListSandboxesResponse\x123\n" + + "\tsandboxes\x18\x01 \x03(\v2\x15.openshell.v1.SandboxR\tsandboxes\"^\n" + + "\x1cListSandboxProvidersResponse\x12>\n" + + "\tproviders\x18\x01 \x03(\v2 .openshell.datamodel.v1.ProviderR\tproviders\"l\n" + + "\x1dAttachSandboxProviderResponse\x12/\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\x12\x1a\n" + + "\battached\x18\x02 \x01(\bR\battached\"l\n" + + "\x1dDetachSandboxProviderResponse\x12/\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\x12\x1a\n" + + "\bdetached\x18\x02 \x01(\bR\bdetached\"1\n" + + "\x15DeleteSandboxResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"8\n" + + "\x17CreateSshSessionRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x92\x02\n" + + "\x18CreateSshSessionResponse\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x14\n" + + "\x05token\x18\x02 \x01(\tR\x05token\x12!\n" + + "\fgateway_host\x18\x03 \x01(\tR\vgatewayHost\x12!\n" + + "\fgateway_port\x18\x04 \x01(\rR\vgatewayPort\x12%\n" + + "\x0egateway_scheme\x18\x05 \x01(\tR\rgatewayScheme\x120\n" + + "\x14host_key_fingerprint\x18\a \x01(\tR\x12hostKeyFingerprint\x12\"\n" + + "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\"\x83\x01\n" + + "\x14ExposeServiceRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\x12\x1f\n" + + "\vtarget_port\x18\x03 \x01(\rR\n" + + "targetPort\x12\x16\n" + + "\x06domain\x18\x04 \x01(\bR\x06domain\"G\n" + + "\x11GetServiceRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\"]\n" + + "\x13ListServicesRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x03 \x01(\rR\x06offset\"Y\n" + + "\x14ListServicesResponse\x12A\n" + + "\bservices\x18\x01 \x03(\v2%.openshell.v1.ServiceEndpointResponseR\bservices\"J\n" + + "\x14DeleteServiceRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\"1\n" + + "\x15DeleteServiceResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"\xef\x01\n" + + "\x0fServiceEndpoint\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12!\n" + + "\fsandbox_name\x18\x03 \x01(\tR\vsandboxName\x12!\n" + + "\fservice_name\x18\x04 \x01(\tR\vserviceName\x12\x1f\n" + + "\vtarget_port\x18\x05 \x01(\rR\n" + + "targetPort\x12\x16\n" + + "\x06domain\x18\x06 \x01(\bR\x06domain\"f\n" + + "\x17ServiceEndpointResponse\x129\n" + + "\bendpoint\x18\x01 \x01(\v2\x1d.openshell.v1.ServiceEndpointR\bendpoint\x12\x10\n" + + "\x03url\x18\x02 \x01(\tR\x03url\"/\n" + + "\x17RevokeSshSessionRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\"4\n" + + "\x18RevokeSshSessionResponse\x12\x18\n" + + "\arevoked\x18\x01 \x01(\bR\arevoked\"\xf5\x02\n" + + "\x12ExecSandboxRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + + "\acommand\x18\x02 \x03(\tR\acommand\x12\x18\n" + + "\aworkdir\x18\x03 \x01(\tR\aworkdir\x12S\n" + + "\venvironment\x18\x04 \x03(\v21.openshell.v1.ExecSandboxRequest.EnvironmentEntryR\venvironment\x12'\n" + + "\x0ftimeout_seconds\x18\x05 \x01(\rR\x0etimeoutSeconds\x12\x14\n" + + "\x05stdin\x18\x06 \x01(\fR\x05stdin\x12\x10\n" + + "\x03tty\x18\a \x01(\bR\x03tty\x12\x12\n" + + "\x04cols\x18\b \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\t \x01(\rR\x04rows\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + + "\x11ExecSandboxStdout\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"'\n" + + "\x11ExecSandboxStderr\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\".\n" + + "\x0fExecSandboxExit\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\"\xc8\x01\n" + + "\x10ExecSandboxEvent\x129\n" + + "\x06stdout\x18\x01 \x01(\v2\x1f.openshell.v1.ExecSandboxStdoutH\x00R\x06stdout\x129\n" + + "\x06stderr\x18\x02 \x01(\v2\x1f.openshell.v1.ExecSandboxStderrH\x00R\x06stderr\x123\n" + + "\x04exit\x18\x03 \x01(\v2\x1d.openshell.v1.ExecSandboxExitH\x00R\x04exitB\t\n" + + "\apayload\"\xed\x01\n" + + "\x0eTcpForwardInit\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1d\n" + + "\n" + + "service_id\x18\x04 \x01(\tR\tserviceId\x120\n" + + "\x03ssh\x18\x05 \x01(\v2\x1c.openshell.v1.SshRelayTargetH\x00R\x03ssh\x120\n" + + "\x03tcp\x18\x06 \x01(\v2\x1c.openshell.v1.TcpRelayTargetH\x00R\x03tcp\x12/\n" + + "\x13authorization_token\x18\a \x01(\tR\x12authorizationTokenB\b\n" + + "\x06target\"f\n" + + "\x0fTcpForwardFrame\x122\n" + + "\x04init\x18\x01 \x01(\v2\x1c.openshell.v1.TcpForwardInitH\x00R\x04init\x12\x14\n" + + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + + "\apayload\"\xb0\x01\n" + + "\x10ExecSandboxInput\x128\n" + + "\x05start\x18\x01 \x01(\v2 .openshell.v1.ExecSandboxRequestH\x00R\x05start\x12\x16\n" + + "\x05stdin\x18\x02 \x01(\fH\x00R\x05stdin\x12?\n" + + "\x06resize\x18\x03 \x01(\v2%.openshell.v1.ExecSandboxWindowResizeH\x00R\x06resizeB\t\n" + + "\apayload\"A\n" + + "\x17ExecSandboxWindowResize\x12\x12\n" + + "\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\x02 \x01(\rR\x04rows\"\xbf\x01\n" + + "\n" + + "SshSession\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12\x14\n" + + "\x05token\x18\x03 \x01(\tR\x05token\x12\"\n" + + "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\x12\x18\n" + + "\arevoked\x18\x05 \x01(\bR\arevoked\"\xe6\x02\n" + + "\x13WatchSandboxRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12#\n" + + "\rfollow_status\x18\x02 \x01(\bR\ffollowStatus\x12\x1f\n" + + "\vfollow_logs\x18\x03 \x01(\bR\n" + + "followLogs\x12#\n" + + "\rfollow_events\x18\x04 \x01(\bR\ffollowEvents\x12$\n" + + "\x0elog_tail_lines\x18\x05 \x01(\rR\flogTailLines\x12\x1d\n" + + "\n" + + "event_tail\x18\x06 \x01(\rR\teventTail\x12(\n" + + "\x10stop_on_terminal\x18\a \x01(\bR\x0estopOnTerminal\x12 \n" + + "\flog_since_ms\x18\b \x01(\x03R\n" + + "logSinceMs\x12\x1f\n" + + "\vlog_sources\x18\t \x03(\tR\n" + + "logSources\x12\"\n" + + "\rlog_min_level\x18\n" + + " \x01(\tR\vlogMinLevel\"\xcc\x02\n" + + "\x12SandboxStreamEvent\x121\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxH\x00R\asandbox\x120\n" + + "\x03log\x18\x02 \x01(\v2\x1c.openshell.v1.SandboxLogLineH\x00R\x03log\x123\n" + + "\x05event\x18\x03 \x01(\v2\x1b.openshell.v1.PlatformEventH\x00R\x05event\x12>\n" + + "\awarning\x18\x04 \x01(\v2\".openshell.v1.SandboxStreamWarningH\x00R\awarning\x12Q\n" + + "\x13draft_policy_update\x18\x05 \x01(\v2\x1f.openshell.v1.DraftPolicyUpdateH\x00R\x11draftPolicyUpdateB\t\n" + + "\apayload\"\xaf\x02\n" + + "\x0eSandboxLogLine\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12!\n" + + "\ftimestamp_ms\x18\x02 \x01(\x03R\vtimestampMs\x12\x14\n" + + "\x05level\x18\x03 \x01(\tR\x05level\x12\x16\n" + + "\x06target\x18\x04 \x01(\tR\x06target\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12\x16\n" + + "\x06source\x18\x06 \x01(\tR\x06source\x12@\n" + + "\x06fields\x18\a \x03(\v2(.openshell.v1.SandboxLogLine.FieldsEntryR\x06fields\x1a9\n" + + "\vFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"0\n" + + "\x14SandboxStreamWarning\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"U\n" + + "\x15CreateProviderRequest\x12<\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\"(\n" + + "\x12GetProviderRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"D\n" + + "\x14ListProvidersRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\"\x98\x02\n" + + "\x15UpdateProviderRequest\x12<\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12w\n" + + "\x18credential_expires_at_ms\x18\x02 \x03(\v2>.openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x1aH\n" + + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"+\n" + + "\x15DeleteProviderRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"P\n" + + "\x10ProviderResponse\x12<\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\"W\n" + + "\x15ListProvidersResponse\x12>\n" + + "\tproviders\x18\x01 \x03(\v2 .openshell.datamodel.v1.ProviderR\tproviders\"K\n" + + "\x1bListProviderProfilesRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\"+\n" + + "\x19GetProviderProfileRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"l\n" + + "\x19ProviderProfileImportItem\x127\n" + + "\aprofile\x18\x01 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\x12\x16\n" + + "\x06source\x18\x02 \x01(\tR\x06source\"\x9e\x01\n" + + "\x19ProviderProfileDiagnostic\x12\x16\n" + + "\x06source\x18\x01 \x01(\tR\x06source\x12\x1d\n" + + "\n" + + "profile_id\x18\x02 \x01(\tR\tprofileId\x12\x14\n" + + "\x05field\x18\x03 \x01(\tR\x05field\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12\x1a\n" + + "\bseverity\x18\x05 \x01(\tR\bseverity\"\x9e\x01\n" + + ",ProviderCredentialTokenGrantAudienceOverride\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x1a\n" + + "\baudience\x18\x04 \x01(\tR\baudience\x12\x16\n" + + "\x06scopes\x18\x05 \x03(\tR\x06scopes\"\xf0\x02\n" + + "\x1cProviderCredentialTokenGrant\x12%\n" + + "\x0etoken_endpoint\x18\x01 \x01(\tR\rtokenEndpoint\x12\x1a\n" + + "\baudience\x18\x02 \x01(\tR\baudience\x12*\n" + + "\x11jwt_svid_audience\x18\x06 \x01(\tR\x0fjwtSvidAudience\x12\x16\n" + + "\x06scopes\x18\x03 \x03(\tR\x06scopes\x12*\n" + + "\x11cache_ttl_seconds\x18\x04 \x01(\x03R\x0fcacheTtlSeconds\x12i\n" + + "\x12audience_overrides\x18\x05 \x03(\v2:.openshell.v1.ProviderCredentialTokenGrantAudienceOverrideR\x11audienceOverrides\x122\n" + + "\x15client_assertion_type\x18\a \x01(\tR\x13clientAssertionType\"\x9e\x03\n" + + "\x19ProviderProfileCredential\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" + + "\benv_vars\x18\x03 \x03(\tR\aenvVars\x12\x1a\n" + + "\brequired\x18\x04 \x01(\bR\brequired\x12\x1d\n" + + "\n" + + "auth_style\x18\x05 \x01(\tR\tauthStyle\x12\x1f\n" + + "\vheader_name\x18\x06 \x01(\tR\n" + + "headerName\x12\x1f\n" + + "\vquery_param\x18\a \x01(\tR\n" + + "queryParam\x12A\n" + + "\arefresh\x18\b \x01(\v2'.openshell.v1.ProviderCredentialRefreshR\arefresh\x12#\n" + + "\rpath_template\x18\t \x01(\tR\fpathTemplate\x12K\n" + + "\vtoken_grant\x18\n" + + " \x01(\v2*.openshell.v1.ProviderCredentialTokenGrantR\n" + + "tokenGrant\"\x8d\x01\n" + + "!ProviderCredentialRefreshMaterial\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1a\n" + + "\brequired\x18\x03 \x01(\bR\brequired\x12\x16\n" + + "\x06secret\x18\x04 \x01(\bR\x06secret\"\xd2\x02\n" + + "\x19ProviderCredentialRefresh\x12K\n" + + "\bstrategy\x18\x01 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\x1b\n" + + "\ttoken_url\x18\x02 \x01(\tR\btokenUrl\x12\x16\n" + + "\x06scopes\x18\x03 \x03(\tR\x06scopes\x124\n" + + "\x16refresh_before_seconds\x18\x04 \x01(\x03R\x14refreshBeforeSeconds\x120\n" + + "\x14max_lifetime_seconds\x18\x05 \x01(\x03R\x12maxLifetimeSeconds\x12K\n" + + "\bmaterial\x18\x06 \x03(\v2/.openshell.v1.ProviderCredentialRefreshMaterialR\bmaterial\"\x90\x03\n" + + "\x1fProviderCredentialRefreshStatus\x12#\n" + + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x1f\n" + + "\vprovider_id\x18\x02 \x01(\tR\n" + + "providerId\x12%\n" + + "\x0ecredential_key\x18\x03 \x01(\tR\rcredentialKey\x12K\n" + + "\bstrategy\x18\x04 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\x16\n" + + "\x06status\x18\x05 \x01(\tR\x06status\x12\"\n" + + "\rexpires_at_ms\x18\x06 \x01(\x03R\vexpiresAtMs\x12+\n" + + "\x12next_refresh_at_ms\x18\a \x01(\x03R\x0fnextRefreshAtMs\x12+\n" + + "\x12last_refresh_at_ms\x18\b \x01(\x03R\x0flastRefreshAtMs\x12\x1d\n" + + "\n" + + "last_error\x18\t \x01(\tR\tlastError\"<\n" + + "\x18ProviderProfileDiscovery\x12 \n" + + "\vcredentials\x18\x01 \x03(\tR\vcredentials\"\xbf\x06\n" + + "$StoredProviderCredentialRefreshState\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1f\n" + + "\vprovider_id\x18\x02 \x01(\tR\n" + + "providerId\x12#\n" + + "\rprovider_name\x18\x03 \x01(\tR\fproviderName\x12%\n" + + "\x0ecredential_key\x18\x04 \x01(\tR\rcredentialKey\x12K\n" + + "\bstrategy\x18\x05 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\\\n" + + "\bmaterial\x18\x06 \x03(\v2@.openshell.v1.StoredProviderCredentialRefreshState.MaterialEntryR\bmaterial\x120\n" + + "\x14secret_material_keys\x18\a \x03(\tR\x12secretMaterialKeys\x12\"\n" + + "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\x12+\n" + + "\x12next_refresh_at_ms\x18\t \x01(\x03R\x0fnextRefreshAtMs\x12+\n" + + "\x12last_refresh_at_ms\x18\n" + + " \x01(\x03R\x0flastRefreshAtMs\x12\x16\n" + + "\x06status\x18\v \x01(\tR\x06status\x12\x1d\n" + + "\n" + + "last_error\x18\f \x01(\tR\tlastError\x12\x1b\n" + + "\ttoken_url\x18\r \x01(\tR\btokenUrl\x12\x16\n" + + "\x06scopes\x18\x0e \x03(\tR\x06scopes\x124\n" + + "\x16refresh_before_seconds\x18\x0f \x01(\x03R\x14refreshBeforeSeconds\x120\n" + + "\x14max_lifetime_seconds\x18\x10 \x01(\x03R\x12maxLifetimeSeconds\x1a;\n" + + "\rMaterialEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"d\n" + + "\x1fGetProviderRefreshStatusRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\"s\n" + + " GetProviderRefreshStatusResponse\x12O\n" + + "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\xb4\x03\n" + + "\x1fConfigureProviderRefreshRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12K\n" + + "\bstrategy\x18\x03 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12W\n" + + "\bmaterial\x18\x04 \x03(\v2;.openshell.v1.ConfigureProviderRefreshRequest.MaterialEntryR\bmaterial\x120\n" + + "\x14secret_material_keys\x18\x05 \x03(\tR\x12secretMaterialKeys\x12'\n" + + "\rexpires_at_ms\x18\x06 \x01(\x03H\x00R\vexpiresAtMs\x88\x01\x01\x1a;\n" + + "\rMaterialEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x10\n" + + "\x0e_expires_at_ms\"i\n" + + " ConfigureProviderRefreshResponse\x12E\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"d\n" + + "\x1fRotateProviderCredentialRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\"i\n" + + " RotateProviderCredentialResponse\x12E\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"a\n" + + "\x1cDeleteProviderRefreshRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\"9\n" + + "\x1dDeleteProviderRefreshResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"\x98\x04\n" + + "\x0fProviderProfile\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12A\n" + + "\bcategory\x18\x04 \x01(\x0e2%.openshell.v1.ProviderProfileCategoryR\bcategory\x12I\n" + + "\vcredentials\x18\x05 \x03(\v2'.openshell.v1.ProviderProfileCredentialR\vcredentials\x12C\n" + + "\tendpoints\x18\x06 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + + "\bbinaries\x18\a \x03(\v2#.openshell.sandbox.v1.NetworkBinaryR\bbinaries\x12+\n" + + "\x11inference_capable\x18\b \x01(\bR\x10inferenceCapable\x12D\n" + + "\tdiscovery\x18\t \x01(\v2&.openshell.v1.ProviderProfileDiscoveryR\tdiscovery\x12)\n" + + "\x10resource_version\x18\n" + + " \x01(\x04R\x0fresourceVersion\"\x90\x01\n" + + "\x15StoredProviderProfile\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x127\n" + + "\aprofile\x18\x02 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\"R\n" + + "\x17ProviderProfileResponse\x127\n" + + "\aprofile\x18\x01 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\"Y\n" + + "\x1cListProviderProfilesResponse\x129\n" + + "\bprofiles\x18\x01 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\"d\n" + + "\x1dImportProviderProfilesRequest\x12C\n" + + "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\"\xc2\x01\n" + + "\x1eImportProviderProfilesResponse\x12I\n" + + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x129\n" + + "\bprofiles\x18\x02 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\x12\x1a\n" + + "\bimported\x18\x03 \x01(\bR\bimported\"\xae\x01\n" + + "\x1dUpdateProviderProfilesRequest\x12A\n" + + "\aprofile\x18\x01 \x01(\v2'.openshell.v1.ProviderProfileImportItemR\aprofile\x12:\n" + + "\x19expected_resource_version\x18\x02 \x01(\x04R\x17expectedResourceVersion\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"\xbe\x01\n" + + "\x1eUpdateProviderProfilesResponse\x12I\n" + + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x127\n" + + "\aprofile\x18\x02 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\x12\x18\n" + + "\aupdated\x18\x03 \x01(\bR\aupdated\"b\n" + + "\x1bLintProviderProfilesRequest\x12C\n" + + "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\"\x7f\n" + + "\x1cLintProviderProfilesResponse\x12I\n" + + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x12\x14\n" + + "\x05valid\x18\x02 \x01(\bR\x05valid\"2\n" + + "\x16DeleteProviderResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\".\n" + + "\x1cDeleteProviderProfileRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"9\n" + + "\x1dDeleteProviderProfileResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"E\n" + + "$GetSandboxProviderEnvironmentRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\xc5\x05\n" + + "%GetSandboxProviderEnvironmentResponse\x12f\n" + + "\venvironment\x18\x01 \x03(\v2D.openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntryR\venvironment\x122\n" + + "\x15provider_env_revision\x18\x02 \x01(\x04R\x13providerEnvRevision\x12\x87\x01\n" + + "\x18credential_expires_at_ms\x18\x03 \x03(\v2N.openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12|\n" + + "\x13dynamic_credentials\x18\x04 \x03(\v2K.openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntryR\x12dynamicCredentials\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aH\n" + + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\x1an\n" + + "\x17DynamicCredentialsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + + "\x05value\x18\x02 \x01(\v2'.openshell.v1.ProviderProfileCredentialR\x05value:\x028\x01\"\x9a\x03\n" + + "\x13UpdateConfigRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12;\n" + + "\x06policy\x18\x02 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1f\n" + + "\vsetting_key\x18\x03 \x01(\tR\n" + + "settingKey\x12G\n" + + "\rsetting_value\x18\x04 \x01(\v2\".openshell.sandbox.v1.SettingValueR\fsettingValue\x12%\n" + + "\x0edelete_setting\x18\x05 \x01(\bR\rdeleteSetting\x12\x16\n" + + "\x06global\x18\x06 \x01(\bR\x06global\x12M\n" + + "\x10merge_operations\x18\a \x03(\v2\".openshell.v1.PolicyMergeOperationR\x0fmergeOperations\x12:\n" + + "\x19expected_resource_version\x18\b \x01(\x04R\x17expectedResourceVersion\"\xc7\x03\n" + + "\x14PolicyMergeOperation\x129\n" + + "\badd_rule\x18\x01 \x01(\v2\x1c.openshell.v1.AddNetworkRuleH\x00R\aaddRule\x12N\n" + + "\x0fremove_endpoint\x18\x02 \x01(\v2#.openshell.v1.RemoveNetworkEndpointH\x00R\x0eremoveEndpoint\x12B\n" + + "\vremove_rule\x18\x03 \x01(\v2\x1f.openshell.v1.RemoveNetworkRuleH\x00R\n" + + "removeRule\x12B\n" + + "\x0eadd_deny_rules\x18\x04 \x01(\v2\x1a.openshell.v1.AddDenyRulesH\x00R\faddDenyRules\x12E\n" + + "\x0fadd_allow_rules\x18\x05 \x01(\v2\x1b.openshell.v1.AddAllowRulesH\x00R\raddAllowRules\x12H\n" + + "\rremove_binary\x18\x06 \x01(\v2!.openshell.v1.RemoveNetworkBinaryH\x00R\fremoveBinaryB\v\n" + + "\toperation\"j\n" + + "\x0eAddNetworkRule\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12;\n" + + "\x04rule\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x04rule\"\\\n" + + "\x15RemoveNetworkEndpoint\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12\x12\n" + + "\x04host\x18\x02 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x03 \x01(\rR\x04port\"0\n" + + "\x11RemoveNetworkRule\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\"w\n" + + "\fAddDenyRules\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12?\n" + + "\n" + + "deny_rules\x18\x03 \x03(\v2 .openshell.sandbox.v1.L7DenyRuleR\tdenyRules\"k\n" + + "\rAddAllowRules\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x122\n" + + "\x05rules\x18\x03 \x03(\v2\x1c.openshell.sandbox.v1.L7RuleR\x05rules\"S\n" + + "\x13RemoveNetworkBinary\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12\x1f\n" + + "\vbinary_path\x18\x02 \x01(\tR\n" + + "binaryPath\"\x98\x01\n" + + "\x14UpdateConfigResponse\x12\x18\n" + + "\aversion\x18\x01 \x01(\rR\aversion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\x12+\n" + + "\x11settings_revision\x18\x03 \x01(\x04R\x10settingsRevision\x12\x18\n" + + "\adeleted\x18\x04 \x01(\bR\adeleted\"e\n" + + "\x1dGetSandboxPolicyStatusRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x02 \x01(\rR\aversion\x12\x16\n" + + "\x06global\x18\x03 \x01(\bR\x06global\"\x88\x01\n" + + "\x1eGetSandboxPolicyStatusResponse\x12?\n" + + "\brevision\x18\x01 \x01(\v2#.openshell.v1.SandboxPolicyRevisionR\brevision\x12%\n" + + "\x0eactive_version\x18\x02 \x01(\rR\ractiveVersion\"v\n" + + "\x1aListSandboxPoliciesRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x03 \x01(\rR\x06offset\x12\x16\n" + + "\x06global\x18\x04 \x01(\bR\x06global\"`\n" + + "\x1bListSandboxPoliciesResponse\x12A\n" + + "\trevisions\x18\x01 \x03(\v2#.openshell.v1.SandboxPolicyRevisionR\trevisions\"\xa7\x01\n" + + "\x19ReportPolicyStatusRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + + "\aversion\x18\x02 \x01(\rR\aversion\x122\n" + + "\x06status\x18\x03 \x01(\x0e2\x1a.openshell.v1.PolicyStatusR\x06status\x12\x1d\n" + + "\n" + + "load_error\x18\x04 \x01(\tR\tloadError\"\x1c\n" + + "\x1aReportPolicyStatusResponse\"\xa8\x02\n" + + "\x15SandboxPolicyRevision\x12\x18\n" + + "\aversion\x18\x01 \x01(\rR\aversion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\x122\n" + + "\x06status\x18\x03 \x01(\x0e2\x1a.openshell.v1.PolicyStatusR\x06status\x12\x1d\n" + + "\n" + + "load_error\x18\x04 \x01(\tR\tloadError\x12\"\n" + + "\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\x12 \n" + + "\floaded_at_ms\x18\x06 \x01(\x03R\n" + + "loadedAtMs\x12;\n" + + "\x06policy\x18\a \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\"\x9e\x01\n" + + "\x15GetSandboxLogsRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x14\n" + + "\x05lines\x18\x02 \x01(\rR\x05lines\x12\x19\n" + + "\bsince_ms\x18\x03 \x01(\x03R\asinceMs\x12\x18\n" + + "\asources\x18\x04 \x03(\tR\asources\x12\x1b\n" + + "\tmin_level\x18\x05 \x01(\tR\bminLevel\"i\n" + + "\x16PushSandboxLogsRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x120\n" + + "\x04logs\x18\x02 \x03(\v2\x1c.openshell.v1.SandboxLogLineR\x04logs\"\x19\n" + + "\x17PushSandboxLogsResponse\"m\n" + + "\x16GetSandboxLogsResponse\x120\n" + + "\x04logs\x18\x01 \x03(\v2\x1c.openshell.v1.SandboxLogLineR\x04logs\x12!\n" + + "\fbuffer_total\x18\x02 \x01(\rR\vbufferTotal\"\xa2\x02\n" + + "\x11SupervisorMessage\x125\n" + + "\x05hello\x18\x01 \x01(\v2\x1d.openshell.v1.SupervisorHelloH\x00R\x05hello\x12A\n" + + "\theartbeat\x18\x02 \x01(\v2!.openshell.v1.SupervisorHeartbeatH\x00R\theartbeat\x12K\n" + + "\x11relay_open_result\x18\x03 \x01(\v2\x1d.openshell.v1.RelayOpenResultH\x00R\x0frelayOpenResult\x12;\n" + + "\vrelay_close\x18\x04 \x01(\v2\x18.openshell.v1.RelayCloseH\x00R\n" + + "relayCloseB\t\n" + + "\apayload\"\xea\x02\n" + + "\x0eGatewayMessage\x12J\n" + + "\x10session_accepted\x18\x01 \x01(\v2\x1d.openshell.v1.SessionAcceptedH\x00R\x0fsessionAccepted\x12J\n" + + "\x10session_rejected\x18\x02 \x01(\v2\x1d.openshell.v1.SessionRejectedH\x00R\x0fsessionRejected\x12>\n" + + "\theartbeat\x18\x03 \x01(\v2\x1e.openshell.v1.GatewayHeartbeatH\x00R\theartbeat\x128\n" + + "\n" + + "relay_open\x18\x04 \x01(\v2\x17.openshell.v1.RelayOpenH\x00R\trelayOpen\x12;\n" + + "\vrelay_close\x18\x05 \x01(\v2\x18.openshell.v1.RelayCloseH\x00R\n" + + "relayCloseB\t\n" + + "\apayload\"Q\n" + + "\x0fSupervisorHello\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\"h\n" + + "\x0fSessionAccepted\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x126\n" + + "\x17heartbeat_interval_secs\x18\x02 \x01(\rR\x15heartbeatIntervalSecs\")\n" + + "\x0fSessionRejected\x12\x16\n" + + "\x06reason\x18\x01 \x01(\tR\x06reason\"\x15\n" + + "\x13SupervisorHeartbeat\"\x12\n" + + "\x10GatewayHeartbeat\"\xb7\x01\n" + + "\tRelayOpen\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\x120\n" + + "\x03ssh\x18\x02 \x01(\v2\x1c.openshell.v1.SshRelayTargetH\x00R\x03ssh\x120\n" + + "\x03tcp\x18\x03 \x01(\v2\x1c.openshell.v1.TcpRelayTargetH\x00R\x03tcp\x12\x1d\n" + + "\n" + + "service_id\x18\x05 \x01(\tR\tserviceIdB\b\n" + + "\x06target\"\x10\n" + + "\x0eSshRelayTarget\"8\n" + + "\x0eTcpRelayTarget\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\"*\n" + + "\tRelayInit\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\"\\\n" + + "\n" + + "RelayFrame\x12-\n" + + "\x04init\x18\x01 \x01(\v2\x17.openshell.v1.RelayInitH\x00R\x04init\x12\x14\n" + + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + + "\apayload\"`\n" + + "\x0fRelayOpenResult\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x18\n" + + "\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"C\n" + + "\n" + + "RelayClose\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"o\n" + + "\x0fL7RequestSample\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x1a\n" + + "\bdecision\x18\x03 \x01(\tR\bdecision\x12\x14\n" + + "\x05count\x18\x04 \x01(\rR\x05count\"\xe5\x04\n" + + "\rDenialSummary\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04host\x18\x02 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x03 \x01(\rR\x04port\x12\x16\n" + + "\x06binary\x18\x04 \x01(\tR\x06binary\x12\x1c\n" + + "\tancestors\x18\x05 \x03(\tR\tancestors\x12\x1f\n" + + "\vdeny_reason\x18\x06 \x01(\tR\n" + + "denyReason\x12\"\n" + + "\rfirst_seen_ms\x18\a \x01(\x03R\vfirstSeenMs\x12 \n" + + "\flast_seen_ms\x18\b \x01(\x03R\n" + + "lastSeenMs\x12\x14\n" + + "\x05count\x18\t \x01(\rR\x05count\x12)\n" + + "\x10suppressed_count\x18\n" + + " \x01(\rR\x0fsuppressedCount\x12\x1f\n" + + "\vtotal_count\x18\v \x01(\rR\n" + + "totalCount\x12'\n" + + "\x0fsample_cmdlines\x18\f \x03(\tR\x0esampleCmdlines\x12#\n" + + "\rbinary_sha256\x18\r \x01(\tR\fbinarySha256\x12\x1e\n" + + "\n" + + "persistent\x18\x0e \x01(\bR\n" + + "persistent\x12!\n" + + "\fdenial_stage\x18\x0f \x01(\tR\vdenialStage\x12K\n" + + "\x12l7_request_samples\x18\x10 \x03(\v2\x1d.openshell.v1.L7RequestSampleR\x10l7RequestSamples\x120\n" + + "\x14l7_inspection_active\x18\x11 \x01(\bR\x12l7InspectionActive\"T\n" + + "\x10DenialGroupCount\x12\x1d\n" + + "\n" + + "deny_group\x18\x01 \x01(\tR\tdenyGroup\x12!\n" + + "\fdenied_count\x18\x02 \x01(\rR\vdeniedCount\"\xc8\x01\n" + + "\x16NetworkActivitySummary\x124\n" + + "\x16network_activity_count\x18\x01 \x01(\rR\x14networkActivityCount\x12.\n" + + "\x13denied_action_count\x18\x02 \x01(\rR\x11deniedActionCount\x12H\n" + + "\x10denials_by_group\x18\x03 \x03(\v2\x1e.openshell.v1.DenialGroupCountR\x0edenialsByGroup\"\x94\x05\n" + + "\vPolicyChunk\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" + + "\trule_name\x18\x03 \x01(\tR\bruleName\x12L\n" + + "\rproposed_rule\x18\x04 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12\x1c\n" + + "\trationale\x18\x05 \x01(\tR\trationale\x12%\n" + + "\x0esecurity_notes\x18\x06 \x01(\tR\rsecurityNotes\x12\x1e\n" + + "\n" + + "confidence\x18\a \x01(\x02R\n" + + "confidence\x12,\n" + + "\x12denial_summary_ids\x18\b \x03(\tR\x10denialSummaryIds\x12\"\n" + + "\rcreated_at_ms\x18\t \x01(\x03R\vcreatedAtMs\x12\"\n" + + "\rdecided_at_ms\x18\n" + + " \x01(\x03R\vdecidedAtMs\x12\x14\n" + + "\x05stage\x18\v \x01(\tR\x05stage\x12.\n" + + "\x13supersedes_chunk_id\x18\f \x01(\tR\x11supersedesChunkId\x12\x1b\n" + + "\thit_count\x18\r \x01(\x05R\bhitCount\x12\"\n" + + "\rfirst_seen_ms\x18\x0e \x01(\x03R\vfirstSeenMs\x12 \n" + + "\flast_seen_ms\x18\x0f \x01(\x03R\n" + + "lastSeenMs\x12\x16\n" + + "\x06binary\x18\x10 \x01(\tR\x06binary\x12+\n" + + "\x11validation_result\x18\x11 \x01(\tR\x10validationResult\x12)\n" + + "\x10rejection_reason\x18\x12 \x01(\tR\x0frejectionReason\"\x96\x01\n" + + "\x11DraftPolicyUpdate\x12#\n" + + "\rdraft_version\x18\x01 \x01(\x04R\fdraftVersion\x12\x1d\n" + + "\n" + + "new_chunks\x18\x02 \x01(\rR\tnewChunks\x12#\n" + + "\rtotal_pending\x18\x03 \x01(\rR\ftotalPending\x12\x18\n" + + "\asummary\x18\x04 \x01(\tR\asummary\"\xb9\x02\n" + + "\x1bSubmitPolicyAnalysisRequest\x129\n" + + "\tsummaries\x18\x01 \x03(\v2\x1b.openshell.v1.DenialSummaryR\tsummaries\x12B\n" + + "\x0fproposed_chunks\x18\x02 \x03(\v2\x19.openshell.v1.PolicyChunkR\x0eproposedChunks\x12#\n" + + "\ranalysis_mode\x18\x03 \x01(\tR\fanalysisMode\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x12b\n" + + "\x1anetwork_activity_summaries\x18\x05 \x03(\v2$.openshell.v1.NetworkActivitySummaryR\x18networkActivitySummaries\"\xcb\x01\n" + + "\x1cSubmitPolicyAnalysisResponse\x12'\n" + + "\x0faccepted_chunks\x18\x01 \x01(\rR\x0eacceptedChunks\x12'\n" + + "\x0frejected_chunks\x18\x02 \x01(\rR\x0erejectedChunks\x12+\n" + + "\x11rejection_reasons\x18\x03 \x03(\tR\x10rejectionReasons\x12,\n" + + "\x12accepted_chunk_ids\x18\x04 \x03(\tR\x10acceptedChunkIds\"P\n" + + "\x15GetDraftPolicyRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + + "\rstatus_filter\x18\x02 \x01(\tR\fstatusFilter\"\xc8\x01\n" + + "\x16GetDraftPolicyResponse\x121\n" + + "\x06chunks\x18\x01 \x03(\v2\x19.openshell.v1.PolicyChunkR\x06chunks\x12'\n" + + "\x0frolling_summary\x18\x02 \x01(\tR\x0erollingSummary\x12#\n" + + "\rdraft_version\x18\x03 \x01(\x04R\fdraftVersion\x12-\n" + + "\x13last_analyzed_at_ms\x18\x04 \x01(\x03R\x10lastAnalyzedAtMs\"I\n" + + "\x18ApproveDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\"c\n" + + "\x19ApproveDraftChunkResponse\x12%\n" + + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\"`\n" + + "\x17RejectDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\"\x1a\n" + + "\x18RejectDraftChunkResponse\"l\n" + + "\x1cApproveAllDraftChunksRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x128\n" + + "\x18include_security_flagged\x18\x02 \x01(\bR\x16includeSecurityFlagged\"\xb7\x01\n" + + "\x1dApproveAllDraftChunksResponse\x12%\n" + + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\x12'\n" + + "\x0fchunks_approved\x18\x03 \x01(\rR\x0echunksApproved\x12%\n" + + "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\x94\x01\n" + + "\x15EditDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12L\n" + + "\rproposed_rule\x18\x03 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\"\x18\n" + + "\x16EditDraftChunkResponse\"F\n" + + "\x15UndoDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\"`\n" + + "\x16UndoDraftChunkResponse\x12%\n" + + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\"-\n" + + "\x17ClearDraftChunksRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"A\n" + + "\x18ClearDraftChunksResponse\x12%\n" + + "\x0echunks_cleared\x18\x01 \x01(\rR\rchunksCleared\",\n" + + "\x16GetDraftHistoryRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x92\x01\n" + + "\x11DraftHistoryEntry\x12!\n" + + "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x1d\n" + + "\n" + + "event_type\x18\x02 \x01(\tR\teventType\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x19\n" + + "\bchunk_id\x18\x04 \x01(\tR\achunkId\"T\n" + + "\x17GetDraftHistoryResponse\x129\n" + + "\aentries\x18\x01 \x03(\v2\x1f.openshell.v1.DraftHistoryEntryR\aentries\"\xa9\x01\n" + + "\x15PolicyRevisionPayload\x12;\n" + + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x12\n" + + "\x04hash\x18\x02 \x01(\tR\x04hash\x12\x1d\n" + + "\n" + + "load_error\x18\x03 \x01(\tR\tloadError\x12 \n" + + "\floaded_at_ms\x18\x04 \x01(\x03R\n" + + "loadedAtMs\"\xc4\x03\n" + + "\x11DraftChunkPayload\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12L\n" + + "\rproposed_rule\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12\x1c\n" + + "\trationale\x18\x03 \x01(\tR\trationale\x12%\n" + + "\x0esecurity_notes\x18\x04 \x01(\tR\rsecurityNotes\x12\x1e\n" + + "\n" + + "confidence\x18\x05 \x01(\x02R\n" + + "confidence\x12\"\n" + + "\rdecided_at_ms\x18\x06 \x01(\x03R\vdecidedAtMs\x12\x12\n" + + "\x04host\x18\a \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\b \x01(\x05R\x04port\x12\x16\n" + + "\x06binary\x18\t \x01(\tR\x06binary\x12#\n" + + "\rdraft_version\x18\n" + + " \x01(\x03R\fdraftVersion\x12+\n" + + "\x11validation_result\x18\v \x01(\tR\x10validationResult\x12)\n" + + "\x10rejection_reason\x18\f \x01(\tR\x0frejectionReason\"\xce\x02\n" + + "\x14StoredPolicyRevision\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12\x18\n" + + "\aversion\x18\x03 \x01(\x03R\aversion\x12%\n" + + "\x0epolicy_payload\x18\x04 \x01(\fR\rpolicyPayload\x12\x1f\n" + + "\vpolicy_hash\x18\x05 \x01(\tR\n" + + "policyHash\x12\x16\n" + + "\x06status\x18\x06 \x01(\tR\x06status\x12\"\n" + + "\n" + + "load_error\x18\a \x01(\tH\x00R\tloadError\x88\x01\x01\x12\"\n" + + "\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12%\n" + + "\floaded_at_ms\x18\t \x01(\x03H\x01R\n" + + "loadedAtMs\x88\x01\x01B\r\n" + + "\v_load_errorB\x0f\n" + + "\r_loaded_at_ms\"\xff\x04\n" + + "\x10StoredDraftChunk\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12#\n" + + "\rdraft_version\x18\x03 \x01(\x03R\fdraftVersion\x12\x16\n" + + "\x06status\x18\x04 \x01(\tR\x06status\x12\x1b\n" + + "\trule_name\x18\x05 \x01(\tR\bruleName\x12#\n" + + "\rproposed_rule\x18\x06 \x01(\fR\fproposedRule\x12\x1c\n" + + "\trationale\x18\a \x01(\tR\trationale\x12%\n" + + "\x0esecurity_notes\x18\b \x01(\tR\rsecurityNotes\x12\x1e\n" + + "\n" + + "confidence\x18\t \x01(\x01R\n" + + "confidence\x12\"\n" + + "\rcreated_at_ms\x18\n" + + " \x01(\x03R\vcreatedAtMs\x12'\n" + + "\rdecided_at_ms\x18\v \x01(\x03H\x00R\vdecidedAtMs\x88\x01\x01\x12\x12\n" + + "\x04host\x18\f \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\r \x01(\x05R\x04port\x12\x16\n" + + "\x06binary\x18\x0e \x01(\tR\x06binary\x12\x1b\n" + + "\thit_count\x18\x0f \x01(\x05R\bhitCount\x12\"\n" + + "\rfirst_seen_ms\x18\x10 \x01(\x03R\vfirstSeenMs\x12 \n" + + "\flast_seen_ms\x18\x11 \x01(\x03R\n" + + "lastSeenMs\x12+\n" + + "\x11validation_result\x18\x12 \x01(\tR\x10validationResult\x12)\n" + + "\x10rejection_reason\x18\x13 \x01(\tR\x0frejectionReasonB\x10\n" + + "\x0e_decided_at_ms*\xb6\x01\n" + + "\fSandboxPhase\x12\x1d\n" + + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + + "\x13SANDBOX_PHASE_READY\x10\x02\x12\x17\n" + + "\x13SANDBOX_PHASE_ERROR\x10\x03\x12\x1a\n" + + "\x16SANDBOX_PHASE_DELETING\x10\x04\x12\x19\n" + + "\x15SANDBOX_PHASE_UNKNOWN\x10\x05*\x85\x03\n" + + "!ProviderCredentialRefreshStrategy\x124\n" + + "0PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED\x10\x00\x12/\n" + + "+PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC\x10\x01\x121\n" + + "-PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL\x10\x02\x12=\n" + + "9PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN\x10\x03\x12B\n" + + ">PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS\x10\x04\x12C\n" + + "?PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT\x10\x05*\xdb\x02\n" + + "\x17ProviderProfileCategory\x12)\n" + + "%PROVIDER_PROFILE_CATEGORY_UNSPECIFIED\x10\x00\x12#\n" + + "\x1fPROVIDER_PROFILE_CATEGORY_OTHER\x10\x01\x12'\n" + + "#PROVIDER_PROFILE_CATEGORY_INFERENCE\x10\x02\x12#\n" + + "\x1fPROVIDER_PROFILE_CATEGORY_AGENT\x10\x03\x12,\n" + + "(PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL\x10\x04\x12'\n" + + "#PROVIDER_PROFILE_CATEGORY_MESSAGING\x10\x05\x12\"\n" + + "\x1ePROVIDER_PROFILE_CATEGORY_DATA\x10\x06\x12'\n" + + "#PROVIDER_PROFILE_CATEGORY_KNOWLEDGE\x10\a*\x9a\x01\n" + + "\fPolicyStatus\x12\x1d\n" + + "\x19POLICY_STATUS_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15POLICY_STATUS_PENDING\x10\x01\x12\x18\n" + + "\x14POLICY_STATUS_LOADED\x10\x02\x12\x18\n" + + "\x14POLICY_STATUS_FAILED\x10\x03\x12\x1c\n" + + "\x18POLICY_STATUS_SUPERSEDED\x10\x04*\x86\x01\n" + + "\rServiceStatus\x12\x1e\n" + + "\x1aSERVICE_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16SERVICE_STATUS_HEALTHY\x10\x01\x12\x1b\n" + + "\x17SERVICE_STATUS_DEGRADED\x10\x02\x12\x1c\n" + + "\x18SERVICE_STATUS_UNHEALTHY\x10\x032\xf9*\n" + + "\tOpenShell\x12C\n" + + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\x12R\n" + + "\rCreateSandbox\x12\".openshell.v1.CreateSandboxRequest\x1a\x1d.openshell.v1.SandboxResponse\x12L\n" + + "\n" + + "GetSandbox\x12\x1f.openshell.v1.GetSandboxRequest\x1a\x1d.openshell.v1.SandboxResponse\x12X\n" + + "\rListSandboxes\x12\".openshell.v1.ListSandboxesRequest\x1a#.openshell.v1.ListSandboxesResponse\x12m\n" + + "\x14ListSandboxProviders\x12).openshell.v1.ListSandboxProvidersRequest\x1a*.openshell.v1.ListSandboxProvidersResponse\x12p\n" + + "\x15AttachSandboxProvider\x12*.openshell.v1.AttachSandboxProviderRequest\x1a+.openshell.v1.AttachSandboxProviderResponse\x12p\n" + + "\x15DetachSandboxProvider\x12*.openshell.v1.DetachSandboxProviderRequest\x1a+.openshell.v1.DetachSandboxProviderResponse\x12X\n" + + "\rDeleteSandbox\x12\".openshell.v1.DeleteSandboxRequest\x1a#.openshell.v1.DeleteSandboxResponse\x12a\n" + + "\x10CreateSshSession\x12%.openshell.v1.CreateSshSessionRequest\x1a&.openshell.v1.CreateSshSessionResponse\x12Z\n" + + "\rExposeService\x12\".openshell.v1.ExposeServiceRequest\x1a%.openshell.v1.ServiceEndpointResponse\x12T\n" + + "\n" + + "GetService\x12\x1f.openshell.v1.GetServiceRequest\x1a%.openshell.v1.ServiceEndpointResponse\x12U\n" + + "\fListServices\x12!.openshell.v1.ListServicesRequest\x1a\".openshell.v1.ListServicesResponse\x12X\n" + + "\rDeleteService\x12\".openshell.v1.DeleteServiceRequest\x1a#.openshell.v1.DeleteServiceResponse\x12a\n" + + "\x10RevokeSshSession\x12%.openshell.v1.RevokeSshSessionRequest\x1a&.openshell.v1.RevokeSshSessionResponse\x12Q\n" + + "\vExecSandbox\x12 .openshell.v1.ExecSandboxRequest\x1a\x1e.openshell.v1.ExecSandboxEvent0\x01\x12N\n" + + "\n" + + "ForwardTcp\x12\x1d.openshell.v1.TcpForwardFrame\x1a\x1d.openshell.v1.TcpForwardFrame(\x010\x01\x12\\\n" + + "\x16ExecSandboxInteractive\x12\x1e.openshell.v1.ExecSandboxInput\x1a\x1e.openshell.v1.ExecSandboxEvent(\x010\x01\x12U\n" + + "\x0eCreateProvider\x12#.openshell.v1.CreateProviderRequest\x1a\x1e.openshell.v1.ProviderResponse\x12O\n" + + "\vGetProvider\x12 .openshell.v1.GetProviderRequest\x1a\x1e.openshell.v1.ProviderResponse\x12X\n" + + "\rListProviders\x12\".openshell.v1.ListProvidersRequest\x1a#.openshell.v1.ListProvidersResponse\x12m\n" + + "\x14ListProviderProfiles\x12).openshell.v1.ListProviderProfilesRequest\x1a*.openshell.v1.ListProviderProfilesResponse\x12d\n" + + "\x12GetProviderProfile\x12'.openshell.v1.GetProviderProfileRequest\x1a%.openshell.v1.ProviderProfileResponse\x12s\n" + + "\x16ImportProviderProfiles\x12+.openshell.v1.ImportProviderProfilesRequest\x1a,.openshell.v1.ImportProviderProfilesResponse\x12s\n" + + "\x16UpdateProviderProfiles\x12+.openshell.v1.UpdateProviderProfilesRequest\x1a,.openshell.v1.UpdateProviderProfilesResponse\x12m\n" + + "\x14LintProviderProfiles\x12).openshell.v1.LintProviderProfilesRequest\x1a*.openshell.v1.LintProviderProfilesResponse\x12U\n" + + "\x0eUpdateProvider\x12#.openshell.v1.UpdateProviderRequest\x1a\x1e.openshell.v1.ProviderResponse\x12y\n" + + "\x18GetProviderRefreshStatus\x12-.openshell.v1.GetProviderRefreshStatusRequest\x1a..openshell.v1.GetProviderRefreshStatusResponse\x12y\n" + + "\x18ConfigureProviderRefresh\x12-.openshell.v1.ConfigureProviderRefreshRequest\x1a..openshell.v1.ConfigureProviderRefreshResponse\x12y\n" + + "\x18RotateProviderCredential\x12-.openshell.v1.RotateProviderCredentialRequest\x1a..openshell.v1.RotateProviderCredentialResponse\x12p\n" + + "\x15DeleteProviderRefresh\x12*.openshell.v1.DeleteProviderRefreshRequest\x1a+.openshell.v1.DeleteProviderRefreshResponse\x12[\n" + + "\x0eDeleteProvider\x12#.openshell.v1.DeleteProviderRequest\x1a$.openshell.v1.DeleteProviderResponse\x12p\n" + + "\x15DeleteProviderProfile\x12*.openshell.v1.DeleteProviderProfileRequest\x1a+.openshell.v1.DeleteProviderProfileResponse\x12q\n" + + "\x10GetSandboxConfig\x12-.openshell.sandbox.v1.GetSandboxConfigRequest\x1a..openshell.sandbox.v1.GetSandboxConfigResponse\x12q\n" + + "\x10GetGatewayConfig\x12-.openshell.sandbox.v1.GetGatewayConfigRequest\x1a..openshell.sandbox.v1.GetGatewayConfigResponse\x12U\n" + + "\fUpdateConfig\x12!.openshell.v1.UpdateConfigRequest\x1a\".openshell.v1.UpdateConfigResponse\x12s\n" + + "\x16GetSandboxPolicyStatus\x12+.openshell.v1.GetSandboxPolicyStatusRequest\x1a,.openshell.v1.GetSandboxPolicyStatusResponse\x12j\n" + + "\x13ListSandboxPolicies\x12(.openshell.v1.ListSandboxPoliciesRequest\x1a).openshell.v1.ListSandboxPoliciesResponse\x12g\n" + + "\x12ReportPolicyStatus\x12'.openshell.v1.ReportPolicyStatusRequest\x1a(.openshell.v1.ReportPolicyStatusResponse\x12\x88\x01\n" + + "\x1dGetSandboxProviderEnvironment\x122.openshell.v1.GetSandboxProviderEnvironmentRequest\x1a3.openshell.v1.GetSandboxProviderEnvironmentResponse\x12[\n" + + "\x0eGetSandboxLogs\x12#.openshell.v1.GetSandboxLogsRequest\x1a$.openshell.v1.GetSandboxLogsResponse\x12`\n" + + "\x0fPushSandboxLogs\x12$.openshell.v1.PushSandboxLogsRequest\x1a%.openshell.v1.PushSandboxLogsResponse(\x01\x12V\n" + + "\x11ConnectSupervisor\x12\x1f.openshell.v1.SupervisorMessage\x1a\x1c.openshell.v1.GatewayMessage(\x010\x01\x12E\n" + + "\vRelayStream\x12\x18.openshell.v1.RelayFrame\x1a\x18.openshell.v1.RelayFrame(\x010\x01\x12U\n" + + "\fWatchSandbox\x12!.openshell.v1.WatchSandboxRequest\x1a .openshell.v1.SandboxStreamEvent0\x01\x12m\n" + + "\x14SubmitPolicyAnalysis\x12).openshell.v1.SubmitPolicyAnalysisRequest\x1a*.openshell.v1.SubmitPolicyAnalysisResponse\x12[\n" + + "\x0eGetDraftPolicy\x12#.openshell.v1.GetDraftPolicyRequest\x1a$.openshell.v1.GetDraftPolicyResponse\x12d\n" + + "\x11ApproveDraftChunk\x12&.openshell.v1.ApproveDraftChunkRequest\x1a'.openshell.v1.ApproveDraftChunkResponse\x12a\n" + + "\x10RejectDraftChunk\x12%.openshell.v1.RejectDraftChunkRequest\x1a&.openshell.v1.RejectDraftChunkResponse\x12p\n" + + "\x15ApproveAllDraftChunks\x12*.openshell.v1.ApproveAllDraftChunksRequest\x1a+.openshell.v1.ApproveAllDraftChunksResponse\x12[\n" + + "\x0eEditDraftChunk\x12#.openshell.v1.EditDraftChunkRequest\x1a$.openshell.v1.EditDraftChunkResponse\x12[\n" + + "\x0eUndoDraftChunk\x12#.openshell.v1.UndoDraftChunkRequest\x1a$.openshell.v1.UndoDraftChunkResponse\x12a\n" + + "\x10ClearDraftChunks\x12%.openshell.v1.ClearDraftChunksRequest\x1a&.openshell.v1.ClearDraftChunksResponse\x12^\n" + + "\x0fGetDraftHistory\x12$.openshell.v1.GetDraftHistoryRequest\x1a%.openshell.v1.GetDraftHistoryResponse\x12d\n" + + "\x11IssueSandboxToken\x12&.openshell.v1.IssueSandboxTokenRequest\x1a'.openshell.v1.IssueSandboxTokenResponse\x12j\n" + + "\x13RefreshSandboxToken\x12(.openshell.v1.RefreshSandboxTokenRequest\x1a).openshell.v1.RefreshSandboxTokenResponseb\x06proto3" + +var ( + file_openshell_proto_rawDescOnce sync.Once + file_openshell_proto_rawDescData []byte +) + +func file_openshell_proto_rawDescGZIP() []byte { + file_openshell_proto_rawDescOnce.Do(func() { + file_openshell_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc))) + }) + return file_openshell_proto_rawDescData +} + +var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 172) +var file_openshell_proto_goTypes = []any{ + (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase + (ProviderCredentialRefreshStrategy)(0), // 1: openshell.v1.ProviderCredentialRefreshStrategy + (ProviderProfileCategory)(0), // 2: openshell.v1.ProviderProfileCategory + (PolicyStatus)(0), // 3: openshell.v1.PolicyStatus + (ServiceStatus)(0), // 4: openshell.v1.ServiceStatus + (*IssueSandboxTokenRequest)(nil), // 5: openshell.v1.IssueSandboxTokenRequest + (*IssueSandboxTokenResponse)(nil), // 6: openshell.v1.IssueSandboxTokenResponse + (*RefreshSandboxTokenRequest)(nil), // 7: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 8: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 9: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 10: openshell.v1.HealthResponse + (*Sandbox)(nil), // 11: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 12: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 13: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 14: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 15: openshell.v1.SandboxTemplate + (*SandboxStatus)(nil), // 16: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 17: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 18: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 19: openshell.v1.CreateSandboxRequest + (*GetSandboxRequest)(nil), // 20: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 21: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 22: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 23: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 24: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 25: openshell.v1.DeleteSandboxRequest + (*SandboxResponse)(nil), // 26: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 27: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 28: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 29: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 30: openshell.v1.DetachSandboxProviderResponse + (*DeleteSandboxResponse)(nil), // 31: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 32: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 33: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 34: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 35: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 36: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 37: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 38: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 39: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 40: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 41: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 42: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 43: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 44: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 45: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 46: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 47: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 48: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 49: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 50: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 51: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 52: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 53: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 54: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 55: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 56: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 57: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 58: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 59: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 60: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 61: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 62: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 63: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 64: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 65: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 66: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 67: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 68: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 69: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrant)(nil), // 70: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 71: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 72: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefresh)(nil), // 73: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 74: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 75: openshell.v1.ProviderProfileDiscovery + (*StoredProviderCredentialRefreshState)(nil), // 76: openshell.v1.StoredProviderCredentialRefreshState + (*GetProviderRefreshStatusRequest)(nil), // 77: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 78: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 79: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 80: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 81: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 82: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 83: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 84: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 85: openshell.v1.ProviderProfile + (*StoredProviderProfile)(nil), // 86: openshell.v1.StoredProviderProfile + (*ProviderProfileResponse)(nil), // 87: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 88: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 89: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 90: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 91: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 92: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 93: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 94: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 95: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 96: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 97: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 98: openshell.v1.GetSandboxProviderEnvironmentRequest + (*GetSandboxProviderEnvironmentResponse)(nil), // 99: openshell.v1.GetSandboxProviderEnvironmentResponse + (*UpdateConfigRequest)(nil), // 100: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 101: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 102: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 103: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 104: openshell.v1.RemoveNetworkRule + (*AddDenyRules)(nil), // 105: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 106: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 107: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 108: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 109: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 110: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 111: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 112: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 113: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 114: openshell.v1.ReportPolicyStatusResponse + (*SandboxPolicyRevision)(nil), // 115: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 116: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 117: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 118: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 119: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 120: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 121: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 122: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 123: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 124: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 125: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 126: openshell.v1.GatewayHeartbeat + (*RelayOpen)(nil), // 127: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 128: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 129: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 130: openshell.v1.RelayInit + (*RelayFrame)(nil), // 131: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 132: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 133: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 134: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 135: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 136: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 137: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 138: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 139: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 140: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 141: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 142: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 143: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 144: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 145: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 146: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 147: openshell.v1.RejectDraftChunkResponse + (*ApproveAllDraftChunksRequest)(nil), // 148: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 149: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 150: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 151: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 152: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 153: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 154: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 155: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 156: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 157: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 158: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 159: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 160: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 161: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 162: openshell.v1.StoredDraftChunk + nil, // 163: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 164: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 165: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 166: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 167: openshell.v1.PlatformEvent.MetadataEntry + nil, // 168: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 169: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 170: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 171: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 172: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 173: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 174: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 175: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 176: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + (*datamodelv1.ObjectMeta)(nil), // 177: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 178: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 179: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 180: openshell.datamodel.v1.Provider + (*sandboxv1.NetworkEndpoint)(nil), // 181: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 182: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 183: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 184: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 185: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 186: openshell.sandbox.v1.L7Rule + (*sandboxv1.GetSandboxConfigRequest)(nil), // 187: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 188: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 189: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 190: openshell.sandbox.v1.GetGatewayConfigResponse +} +var file_openshell_proto_depIdxs = []int32{ + 4, // 0: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 177, // 1: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 12, // 2: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 16, // 3: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 163, // 4: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 15, // 5: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 178, // 6: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 13, // 7: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 14, // 8: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 164, // 9: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 165, // 10: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 166, // 11: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 179, // 12: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 179, // 13: openshell.v1.SandboxTemplate.volume_claim_templates:type_name -> google.protobuf.Struct + 179, // 14: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 17, // 15: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 0, // 16: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase + 167, // 17: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 12, // 18: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 168, // 19: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 11, // 20: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 11, // 21: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 180, // 22: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 11, // 23: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 11, // 24: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 41, // 25: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 177, // 26: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 40, // 27: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 169, // 28: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 45, // 29: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 46, // 30: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 47, // 31: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 128, // 32: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 129, // 33: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 49, // 34: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 44, // 35: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 52, // 36: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 177, // 37: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 11, // 38: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 56, // 39: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 18, // 40: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 57, // 41: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 139, // 42: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 170, // 43: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 180, // 44: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 180, // 45: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 171, // 46: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 180, // 47: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 180, // 48: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 85, // 49: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 69, // 50: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 73, // 51: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 70, // 52: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 1, // 53: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 72, // 54: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 1, // 55: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 177, // 56: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 1, // 57: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 172, // 58: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 74, // 59: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 1, // 60: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 173, // 61: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 74, // 62: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 74, // 63: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 64: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 71, // 65: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 181, // 66: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 182, // 67: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 75, // 68: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 177, // 69: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 85, // 70: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 85, // 71: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 85, // 72: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 67, // 73: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 68, // 74: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 85, // 75: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 67, // 76: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 68, // 77: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 85, // 78: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 67, // 79: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 68, // 80: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 174, // 81: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 175, // 82: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 176, // 83: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 178, // 84: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 183, // 85: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 101, // 86: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 102, // 87: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 103, // 88: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 104, // 89: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 105, // 90: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 106, // 91: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 107, // 92: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 184, // 93: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 185, // 94: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 186, // 95: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 115, // 96: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 115, // 97: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 3, // 98: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 3, // 99: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 178, // 100: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 56, // 101: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 56, // 102: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 122, // 103: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 125, // 104: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 132, // 105: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 133, // 106: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 123, // 107: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 124, // 108: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 126, // 109: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 127, // 110: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 133, // 111: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 128, // 112: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 129, // 113: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 130, // 114: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 134, // 115: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 136, // 116: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 184, // 117: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 135, // 118: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 138, // 119: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 137, // 120: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 138, // 121: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 184, // 122: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 157, // 123: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 178, // 124: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 184, // 125: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 71, // 126: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 9, // 127: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 19, // 128: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 20, // 129: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 21, // 130: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 22, // 131: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 23, // 132: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 24, // 133: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 25, // 134: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 32, // 135: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 34, // 136: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 35, // 137: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 36, // 138: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 38, // 139: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 42, // 140: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 44, // 141: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 50, // 142: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 51, // 143: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 58, // 144: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 59, // 145: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 60, // 146: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 65, // 147: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 66, // 148: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 89, // 149: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 91, // 150: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 93, // 151: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 61, // 152: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 77, // 153: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 79, // 154: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 81, // 155: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 83, // 156: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 62, // 157: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 96, // 158: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 187, // 159: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 188, // 160: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 100, // 161: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 109, // 162: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 111, // 163: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 113, // 164: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 98, // 165: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 116, // 166: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 117, // 167: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 120, // 168: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 131, // 169: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 54, // 170: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 140, // 171: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 142, // 172: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 144, // 173: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 146, // 174: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 148, // 175: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 150, // 176: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 152, // 177: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 154, // 178: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 156, // 179: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 5, // 180: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 7, // 181: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 10, // 182: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 26, // 183: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 26, // 184: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 27, // 185: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 28, // 186: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 29, // 187: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 30, // 188: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 31, // 189: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 33, // 190: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 41, // 191: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 41, // 192: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 37, // 193: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 39, // 194: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 43, // 195: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 48, // 196: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 50, // 197: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 48, // 198: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 63, // 199: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 63, // 200: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 64, // 201: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 88, // 202: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 87, // 203: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 90, // 204: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 92, // 205: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 94, // 206: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 63, // 207: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 78, // 208: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 80, // 209: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 82, // 210: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 84, // 211: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 95, // 212: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 97, // 213: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 189, // 214: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 190, // 215: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 108, // 216: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 110, // 217: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 112, // 218: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 114, // 219: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 99, // 220: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 119, // 221: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 118, // 222: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 121, // 223: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 131, // 224: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 55, // 225: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 141, // 226: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 143, // 227: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 145, // 228: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 147, // 229: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 149, // 230: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 151, // 231: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 153, // 232: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 155, // 233: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 158, // 234: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 6, // 235: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 8, // 236: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 182, // [182:237] is the sub-list for method output_type + 127, // [127:182] is the sub-list for method input_type + 127, // [127:127] is the sub-list for extension type_name + 127, // [127:127] is the sub-list for extension extendee + 0, // [0:127] is the sub-list for field type_name +} + +func init() { file_openshell_proto_init() } +func file_openshell_proto_init() { + if File_openshell_proto != nil { + return + } + file_openshell_proto_msgTypes[9].OneofWrappers = []any{} + file_openshell_proto_msgTypes[10].OneofWrappers = []any{} + file_openshell_proto_msgTypes[43].OneofWrappers = []any{ + (*ExecSandboxEvent_Stdout)(nil), + (*ExecSandboxEvent_Stderr)(nil), + (*ExecSandboxEvent_Exit)(nil), + } + file_openshell_proto_msgTypes[44].OneofWrappers = []any{ + (*TcpForwardInit_Ssh)(nil), + (*TcpForwardInit_Tcp)(nil), + } + file_openshell_proto_msgTypes[45].OneofWrappers = []any{ + (*TcpForwardFrame_Init)(nil), + (*TcpForwardFrame_Data)(nil), + } + file_openshell_proto_msgTypes[46].OneofWrappers = []any{ + (*ExecSandboxInput_Start)(nil), + (*ExecSandboxInput_Stdin)(nil), + (*ExecSandboxInput_Resize)(nil), + } + file_openshell_proto_msgTypes[50].OneofWrappers = []any{ + (*SandboxStreamEvent_Sandbox)(nil), + (*SandboxStreamEvent_Log)(nil), + (*SandboxStreamEvent_Event)(nil), + (*SandboxStreamEvent_Warning)(nil), + (*SandboxStreamEvent_DraftPolicyUpdate)(nil), + } + file_openshell_proto_msgTypes[74].OneofWrappers = []any{} + file_openshell_proto_msgTypes[96].OneofWrappers = []any{ + (*PolicyMergeOperation_AddRule)(nil), + (*PolicyMergeOperation_RemoveEndpoint)(nil), + (*PolicyMergeOperation_RemoveRule)(nil), + (*PolicyMergeOperation_AddDenyRules)(nil), + (*PolicyMergeOperation_AddAllowRules)(nil), + (*PolicyMergeOperation_RemoveBinary)(nil), + } + file_openshell_proto_msgTypes[115].OneofWrappers = []any{ + (*SupervisorMessage_Hello)(nil), + (*SupervisorMessage_Heartbeat)(nil), + (*SupervisorMessage_RelayOpenResult)(nil), + (*SupervisorMessage_RelayClose)(nil), + } + file_openshell_proto_msgTypes[116].OneofWrappers = []any{ + (*GatewayMessage_SessionAccepted)(nil), + (*GatewayMessage_SessionRejected)(nil), + (*GatewayMessage_Heartbeat)(nil), + (*GatewayMessage_RelayOpen)(nil), + (*GatewayMessage_RelayClose)(nil), + } + file_openshell_proto_msgTypes[122].OneofWrappers = []any{ + (*RelayOpen_Ssh)(nil), + (*RelayOpen_Tcp)(nil), + } + file_openshell_proto_msgTypes[126].OneofWrappers = []any{ + (*RelayFrame_Init)(nil), + (*RelayFrame_Data)(nil), + } + file_openshell_proto_msgTypes[156].OneofWrappers = []any{} + file_openshell_proto_msgTypes[157].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), + NumEnums: 5, + NumMessages: 172, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_openshell_proto_goTypes, + DependencyIndexes: file_openshell_proto_depIdxs, + EnumInfos: file_openshell_proto_enumTypes, + MessageInfos: file_openshell_proto_msgTypes, + }.Build() + File_openshell_proto = out.File + file_openshell_proto_goTypes = nil + file_openshell_proto_depIdxs = nil +} diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go new file mode 100644 index 0000000000..eff1f9ad76 --- /dev/null +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -0,0 +1,2345 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v5.29.6 +// source: openshell.proto + +package openshellv1 + +import ( + context "context" + sandboxv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + OpenShell_Health_FullMethodName = "/openshell.v1.OpenShell/Health" + OpenShell_CreateSandbox_FullMethodName = "/openshell.v1.OpenShell/CreateSandbox" + OpenShell_GetSandbox_FullMethodName = "/openshell.v1.OpenShell/GetSandbox" + OpenShell_ListSandboxes_FullMethodName = "/openshell.v1.OpenShell/ListSandboxes" + OpenShell_ListSandboxProviders_FullMethodName = "/openshell.v1.OpenShell/ListSandboxProviders" + OpenShell_AttachSandboxProvider_FullMethodName = "/openshell.v1.OpenShell/AttachSandboxProvider" + OpenShell_DetachSandboxProvider_FullMethodName = "/openshell.v1.OpenShell/DetachSandboxProvider" + OpenShell_DeleteSandbox_FullMethodName = "/openshell.v1.OpenShell/DeleteSandbox" + OpenShell_CreateSshSession_FullMethodName = "/openshell.v1.OpenShell/CreateSshSession" + OpenShell_ExposeService_FullMethodName = "/openshell.v1.OpenShell/ExposeService" + OpenShell_GetService_FullMethodName = "/openshell.v1.OpenShell/GetService" + OpenShell_ListServices_FullMethodName = "/openshell.v1.OpenShell/ListServices" + OpenShell_DeleteService_FullMethodName = "/openshell.v1.OpenShell/DeleteService" + OpenShell_RevokeSshSession_FullMethodName = "/openshell.v1.OpenShell/RevokeSshSession" + OpenShell_ExecSandbox_FullMethodName = "/openshell.v1.OpenShell/ExecSandbox" + OpenShell_ForwardTcp_FullMethodName = "/openshell.v1.OpenShell/ForwardTcp" + OpenShell_ExecSandboxInteractive_FullMethodName = "/openshell.v1.OpenShell/ExecSandboxInteractive" + OpenShell_CreateProvider_FullMethodName = "/openshell.v1.OpenShell/CreateProvider" + OpenShell_GetProvider_FullMethodName = "/openshell.v1.OpenShell/GetProvider" + OpenShell_ListProviders_FullMethodName = "/openshell.v1.OpenShell/ListProviders" + OpenShell_ListProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/ListProviderProfiles" + OpenShell_GetProviderProfile_FullMethodName = "/openshell.v1.OpenShell/GetProviderProfile" + OpenShell_ImportProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/ImportProviderProfiles" + OpenShell_UpdateProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/UpdateProviderProfiles" + OpenShell_LintProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/LintProviderProfiles" + OpenShell_UpdateProvider_FullMethodName = "/openshell.v1.OpenShell/UpdateProvider" + OpenShell_GetProviderRefreshStatus_FullMethodName = "/openshell.v1.OpenShell/GetProviderRefreshStatus" + OpenShell_ConfigureProviderRefresh_FullMethodName = "/openshell.v1.OpenShell/ConfigureProviderRefresh" + OpenShell_RotateProviderCredential_FullMethodName = "/openshell.v1.OpenShell/RotateProviderCredential" + OpenShell_DeleteProviderRefresh_FullMethodName = "/openshell.v1.OpenShell/DeleteProviderRefresh" + OpenShell_DeleteProvider_FullMethodName = "/openshell.v1.OpenShell/DeleteProvider" + OpenShell_DeleteProviderProfile_FullMethodName = "/openshell.v1.OpenShell/DeleteProviderProfile" + OpenShell_GetSandboxConfig_FullMethodName = "/openshell.v1.OpenShell/GetSandboxConfig" + OpenShell_GetGatewayConfig_FullMethodName = "/openshell.v1.OpenShell/GetGatewayConfig" + OpenShell_UpdateConfig_FullMethodName = "/openshell.v1.OpenShell/UpdateConfig" + OpenShell_GetSandboxPolicyStatus_FullMethodName = "/openshell.v1.OpenShell/GetSandboxPolicyStatus" + OpenShell_ListSandboxPolicies_FullMethodName = "/openshell.v1.OpenShell/ListSandboxPolicies" + OpenShell_ReportPolicyStatus_FullMethodName = "/openshell.v1.OpenShell/ReportPolicyStatus" + OpenShell_GetSandboxProviderEnvironment_FullMethodName = "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" + OpenShell_GetSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/GetSandboxLogs" + OpenShell_PushSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/PushSandboxLogs" + OpenShell_ConnectSupervisor_FullMethodName = "/openshell.v1.OpenShell/ConnectSupervisor" + OpenShell_RelayStream_FullMethodName = "/openshell.v1.OpenShell/RelayStream" + OpenShell_WatchSandbox_FullMethodName = "/openshell.v1.OpenShell/WatchSandbox" + OpenShell_SubmitPolicyAnalysis_FullMethodName = "/openshell.v1.OpenShell/SubmitPolicyAnalysis" + OpenShell_GetDraftPolicy_FullMethodName = "/openshell.v1.OpenShell/GetDraftPolicy" + OpenShell_ApproveDraftChunk_FullMethodName = "/openshell.v1.OpenShell/ApproveDraftChunk" + OpenShell_RejectDraftChunk_FullMethodName = "/openshell.v1.OpenShell/RejectDraftChunk" + OpenShell_ApproveAllDraftChunks_FullMethodName = "/openshell.v1.OpenShell/ApproveAllDraftChunks" + OpenShell_EditDraftChunk_FullMethodName = "/openshell.v1.OpenShell/EditDraftChunk" + OpenShell_UndoDraftChunk_FullMethodName = "/openshell.v1.OpenShell/UndoDraftChunk" + OpenShell_ClearDraftChunks_FullMethodName = "/openshell.v1.OpenShell/ClearDraftChunks" + OpenShell_GetDraftHistory_FullMethodName = "/openshell.v1.OpenShell/GetDraftHistory" + OpenShell_IssueSandboxToken_FullMethodName = "/openshell.v1.OpenShell/IssueSandboxToken" + OpenShell_RefreshSandboxToken_FullMethodName = "/openshell.v1.OpenShell/RefreshSandboxToken" +) + +// OpenShellClient is the client API for OpenShell service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// OpenShell service provides sandbox, provider, and runtime management capabilities. +// +// Conventions: +// - This file owns the public API resource model exposed to OpenShell clients. +// - `Sandbox`, `SandboxSpec`, `SandboxStatus`, and `SandboxPhase` are gateway-owned +// public types. Internal compute drivers must not import or return them directly. +// - The gateway translates internal compute-driver observations into these public +// resource messages before persisting or returning them to clients. +type OpenShellClient interface { + // Check the health of the service. + Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) + // Create a new sandbox. + CreateSandbox(ctx context.Context, in *CreateSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) + // Fetch a sandbox by name. + GetSandbox(ctx context.Context, in *GetSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) + // List sandboxes. + ListSandboxes(ctx context.Context, in *ListSandboxesRequest, opts ...grpc.CallOption) (*ListSandboxesResponse, error) + // List provider records attached to a sandbox. + ListSandboxProviders(ctx context.Context, in *ListSandboxProvidersRequest, opts ...grpc.CallOption) (*ListSandboxProvidersResponse, error) + // Attach a provider record to an existing sandbox. + AttachSandboxProvider(ctx context.Context, in *AttachSandboxProviderRequest, opts ...grpc.CallOption) (*AttachSandboxProviderResponse, error) + // Detach a provider record from an existing sandbox. + DetachSandboxProvider(ctx context.Context, in *DetachSandboxProviderRequest, opts ...grpc.CallOption) (*DetachSandboxProviderResponse, error) + // Delete a sandbox by name. + DeleteSandbox(ctx context.Context, in *DeleteSandboxRequest, opts ...grpc.CallOption) (*DeleteSandboxResponse, error) + // Create a short-lived SSH session for a sandbox. + CreateSshSession(ctx context.Context, in *CreateSshSessionRequest, opts ...grpc.CallOption) (*CreateSshSessionResponse, error) + // Create or update a sandbox HTTP service endpoint for local routing. + ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) + // Fetch one sandbox HTTP service endpoint. + GetService(ctx context.Context, in *GetServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) + // List sandbox HTTP service endpoints. + ListServices(ctx context.Context, in *ListServicesRequest, opts ...grpc.CallOption) (*ListServicesResponse, error) + // Delete one sandbox HTTP service endpoint. + DeleteService(ctx context.Context, in *DeleteServiceRequest, opts ...grpc.CallOption) (*DeleteServiceResponse, error) + // Revoke a previously issued SSH session. + RevokeSshSession(ctx context.Context, in *RevokeSshSessionRequest, opts ...grpc.CallOption) (*RevokeSshSessionResponse, error) + // Execute a command in a ready sandbox and stream output. + ExecSandbox(ctx context.Context, in *ExecSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecSandboxEvent], error) + // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. + ForwardTcp(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TcpForwardFrame, TcpForwardFrame], error) + // Execute an interactive command with bidirectional stdin/stdout streaming. + // The first client message MUST carry an ExecSandboxInput with the start + // variant. Subsequent messages carry stdin bytes or window resize events. + ExecSandboxInteractive(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecSandboxInput, ExecSandboxEvent], error) + // Create a provider. + CreateProvider(ctx context.Context, in *CreateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) + // Fetch a provider by name. + GetProvider(ctx context.Context, in *GetProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) + // List providers. + ListProviders(ctx context.Context, in *ListProvidersRequest, opts ...grpc.CallOption) (*ListProvidersResponse, error) + // List available provider type profiles. + ListProviderProfiles(ctx context.Context, in *ListProviderProfilesRequest, opts ...grpc.CallOption) (*ListProviderProfilesResponse, error) + // Fetch one provider type profile by id. + GetProviderProfile(ctx context.Context, in *GetProviderProfileRequest, opts ...grpc.CallOption) (*ProviderProfileResponse, error) + // Import custom provider type profiles. + ImportProviderProfiles(ctx context.Context, in *ImportProviderProfilesRequest, opts ...grpc.CallOption) (*ImportProviderProfilesResponse, error) + // Update an existing custom provider type profile. + UpdateProviderProfiles(ctx context.Context, in *UpdateProviderProfilesRequest, opts ...grpc.CallOption) (*UpdateProviderProfilesResponse, error) + // Validate provider type profiles without registering them. + LintProviderProfiles(ctx context.Context, in *LintProviderProfilesRequest, opts ...grpc.CallOption) (*LintProviderProfilesResponse, error) + // Update an existing provider by name. + UpdateProvider(ctx context.Context, in *UpdateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) + // Fetch refresh status for one provider or provider credential. + GetProviderRefreshStatus(ctx context.Context, in *GetProviderRefreshStatusRequest, opts ...grpc.CallOption) (*GetProviderRefreshStatusResponse, error) + // Configure gateway-owned refresh material for one provider credential. + ConfigureProviderRefresh(ctx context.Context, in *ConfigureProviderRefreshRequest, opts ...grpc.CallOption) (*ConfigureProviderRefreshResponse, error) + // Record a gateway-owned refresh request for one provider credential. + RotateProviderCredential(ctx context.Context, in *RotateProviderCredentialRequest, opts ...grpc.CallOption) (*RotateProviderCredentialResponse, error) + // Delete gateway-owned refresh configuration for one provider credential. + DeleteProviderRefresh(ctx context.Context, in *DeleteProviderRefreshRequest, opts ...grpc.CallOption) (*DeleteProviderRefreshResponse, error) + // Delete a provider by name. + DeleteProvider(ctx context.Context, in *DeleteProviderRequest, opts ...grpc.CallOption) (*DeleteProviderResponse, error) + // Delete a custom provider type profile by id. + DeleteProviderProfile(ctx context.Context, in *DeleteProviderProfileRequest, opts ...grpc.CallOption) (*DeleteProviderProfileResponse, error) + // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + GetSandboxConfig(ctx context.Context, in *sandboxv1.GetSandboxConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetSandboxConfigResponse, error) + // Get gateway-global settings. + GetGatewayConfig(ctx context.Context, in *sandboxv1.GetGatewayConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetGatewayConfigResponse, error) + // Update settings or policy at sandbox or global scope. + UpdateConfig(ctx context.Context, in *UpdateConfigRequest, opts ...grpc.CallOption) (*UpdateConfigResponse, error) + // Get the load status of a specific policy version. + GetSandboxPolicyStatus(ctx context.Context, in *GetSandboxPolicyStatusRequest, opts ...grpc.CallOption) (*GetSandboxPolicyStatusResponse, error) + // List policy history for a sandbox. + ListSandboxPolicies(ctx context.Context, in *ListSandboxPoliciesRequest, opts ...grpc.CallOption) (*ListSandboxPoliciesResponse, error) + // Report policy load result (called by sandbox after reload attempt). + ReportPolicyStatus(ctx context.Context, in *ReportPolicyStatusRequest, opts ...grpc.CallOption) (*ReportPolicyStatusResponse, error) + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error) + // Fetch recent sandbox logs (one-shot). + GetSandboxLogs(ctx context.Context, in *GetSandboxLogsRequest, opts ...grpc.CallOption) (*GetSandboxLogsResponse, error) + // Push sandbox supervisor logs to the server (client-streaming). + PushSandboxLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushSandboxLogsRequest, PushSandboxLogsResponse], error) + // Persistent supervisor-to-gateway session (bidirectional streaming). + // + // The supervisor opens this stream at startup and keeps it alive for the + // sandbox lifetime. The gateway uses it to coordinate relay channels for + // SSH connect, ExecSandbox, and targetable sandbox services. Raw service + // bytes flow over RelayStream calls (separate HTTP/2 streams on the same + // connection), not over this stream. + ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error) + // Raw byte relay between supervisor and gateway. + // + // The supervisor initiates this call after receiving a RelayOpen message + // on its ConnectSupervisor stream. The first RelayFrame carries a + // RelayInit with the channel_id to associate the new HTTP/2 stream with + // the pending relay slot on the gateway. Subsequent frames carry raw bytes in either + // direction between the gateway-side waiter (ForwardTcp / exec handler) + // and the supervisor-side target bridge. + // + // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — + // no new TLS handshake, no reverse HTTP CONNECT. + RelayStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[RelayFrame, RelayFrame], error) + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - OpenShell server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + WatchSandbox(ctx context.Context, in *WatchSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxStreamEvent], error) + // Submit denial analysis results from sandbox (summaries + proposed chunks). + SubmitPolicyAnalysis(ctx context.Context, in *SubmitPolicyAnalysisRequest, opts ...grpc.CallOption) (*SubmitPolicyAnalysisResponse, error) + // Get draft policy recommendations for a sandbox. + GetDraftPolicy(ctx context.Context, in *GetDraftPolicyRequest, opts ...grpc.CallOption) (*GetDraftPolicyResponse, error) + // Approve a single draft policy chunk (merges into active policy). + ApproveDraftChunk(ctx context.Context, in *ApproveDraftChunkRequest, opts ...grpc.CallOption) (*ApproveDraftChunkResponse, error) + // Reject a single draft policy chunk. + RejectDraftChunk(ctx context.Context, in *RejectDraftChunkRequest, opts ...grpc.CallOption) (*RejectDraftChunkResponse, error) + // Approve all pending draft chunks (skips security-flagged unless forced). + ApproveAllDraftChunks(ctx context.Context, in *ApproveAllDraftChunksRequest, opts ...grpc.CallOption) (*ApproveAllDraftChunksResponse, error) + // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). + EditDraftChunk(ctx context.Context, in *EditDraftChunkRequest, opts ...grpc.CallOption) (*EditDraftChunkResponse, error) + // Reverse an approval (remove merged rule from active policy). + UndoDraftChunk(ctx context.Context, in *UndoDraftChunkRequest, opts ...grpc.CallOption) (*UndoDraftChunkResponse, error) + // Clear all pending draft chunks for a sandbox. + ClearDraftChunks(ctx context.Context, in *ClearDraftChunksRequest, opts ...grpc.CallOption) (*ClearDraftChunksResponse, error) + // Get decision history for a sandbox's draft policy. + GetDraftHistory(ctx context.Context, in *GetDraftHistoryRequest, opts ...grpc.CallOption) (*GetDraftHistoryResponse, error) + // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected + // ServiceAccount token) for a gateway-minted JWT bound to the calling + // sandbox's UUID. Used by the Kubernetes driver path; singleplayer + // drivers receive the gateway JWT directly from the create-sandbox flow + // and never call this RPC. + IssueSandboxToken(ctx context.Context, in *IssueSandboxTokenRequest, opts ...grpc.CallOption) (*IssueSandboxTokenResponse, error) + // Renew the calling sandbox's gateway JWT. Older tokens remain valid + // until their own expiry; deployments should keep token TTLs short to + // bound replay exposure. The supervisor calls this from a background + // task at ~80% of the token's lifetime; the new token is cached in + // memory only — the on-disk bootstrap file is intentionally not + // rewritten. + RefreshSandboxToken(ctx context.Context, in *RefreshSandboxTokenRequest, opts ...grpc.CallOption) (*RefreshSandboxTokenResponse, error) +} + +type openShellClient struct { + cc grpc.ClientConnInterface +} + +func NewOpenShellClient(cc grpc.ClientConnInterface) OpenShellClient { + return &openShellClient{cc} +} + +func (c *openShellClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, OpenShell_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) CreateSandbox(ctx context.Context, in *CreateSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SandboxResponse) + err := c.cc.Invoke(ctx, OpenShell_CreateSandbox_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandbox(ctx context.Context, in *GetSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SandboxResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandbox_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListSandboxes(ctx context.Context, in *ListSandboxesRequest, opts ...grpc.CallOption) (*ListSandboxesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSandboxesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListSandboxes_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListSandboxProviders(ctx context.Context, in *ListSandboxProvidersRequest, opts ...grpc.CallOption) (*ListSandboxProvidersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSandboxProvidersResponse) + err := c.cc.Invoke(ctx, OpenShell_ListSandboxProviders_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) AttachSandboxProvider(ctx context.Context, in *AttachSandboxProviderRequest, opts ...grpc.CallOption) (*AttachSandboxProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AttachSandboxProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_AttachSandboxProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DetachSandboxProvider(ctx context.Context, in *DetachSandboxProviderRequest, opts ...grpc.CallOption) (*DetachSandboxProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DetachSandboxProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_DetachSandboxProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteSandbox(ctx context.Context, in *DeleteSandboxRequest, opts ...grpc.CallOption) (*DeleteSandboxResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteSandboxResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteSandbox_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) CreateSshSession(ctx context.Context, in *CreateSshSessionRequest, opts ...grpc.CallOption) (*CreateSshSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateSshSessionResponse) + err := c.cc.Invoke(ctx, OpenShell_CreateSshSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ServiceEndpointResponse) + err := c.cc.Invoke(ctx, OpenShell_ExposeService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetService(ctx context.Context, in *GetServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ServiceEndpointResponse) + err := c.cc.Invoke(ctx, OpenShell_GetService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListServices(ctx context.Context, in *ListServicesRequest, opts ...grpc.CallOption) (*ListServicesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListServicesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListServices_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteService(ctx context.Context, in *DeleteServiceRequest, opts ...grpc.CallOption) (*DeleteServiceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteServiceResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RevokeSshSession(ctx context.Context, in *RevokeSshSessionRequest, opts ...grpc.CallOption) (*RevokeSshSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevokeSshSessionResponse) + err := c.cc.Invoke(ctx, OpenShell_RevokeSshSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ExecSandbox(ctx context.Context, in *ExecSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecSandboxEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[0], OpenShell_ExecSandbox_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecSandboxRequest, ExecSandboxEvent]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxClient = grpc.ServerStreamingClient[ExecSandboxEvent] + +func (c *openShellClient) ForwardTcp(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TcpForwardFrame, TcpForwardFrame], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[1], OpenShell_ForwardTcp_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[TcpForwardFrame, TcpForwardFrame]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ForwardTcpClient = grpc.BidiStreamingClient[TcpForwardFrame, TcpForwardFrame] + +func (c *openShellClient) ExecSandboxInteractive(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecSandboxInput, ExecSandboxEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[2], OpenShell_ExecSandboxInteractive_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecSandboxInput, ExecSandboxEvent]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxInteractiveClient = grpc.BidiStreamingClient[ExecSandboxInput, ExecSandboxEvent] + +func (c *openShellClient) CreateProvider(ctx context.Context, in *CreateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_CreateProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetProvider(ctx context.Context, in *GetProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_GetProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListProviders(ctx context.Context, in *ListProvidersRequest, opts ...grpc.CallOption) (*ListProvidersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListProvidersResponse) + err := c.cc.Invoke(ctx, OpenShell_ListProviders_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListProviderProfiles(ctx context.Context, in *ListProviderProfilesRequest, opts ...grpc.CallOption) (*ListProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetProviderProfile(ctx context.Context, in *GetProviderProfileRequest, opts ...grpc.CallOption) (*ProviderProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderProfileResponse) + err := c.cc.Invoke(ctx, OpenShell_GetProviderProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ImportProviderProfiles(ctx context.Context, in *ImportProviderProfilesRequest, opts ...grpc.CallOption) (*ImportProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ImportProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_ImportProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UpdateProviderProfiles(ctx context.Context, in *UpdateProviderProfilesRequest, opts ...grpc.CallOption) (*UpdateProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_UpdateProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) LintProviderProfiles(ctx context.Context, in *LintProviderProfilesRequest, opts ...grpc.CallOption) (*LintProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LintProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_LintProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UpdateProvider(ctx context.Context, in *UpdateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_UpdateProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetProviderRefreshStatus(ctx context.Context, in *GetProviderRefreshStatusRequest, opts ...grpc.CallOption) (*GetProviderRefreshStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetProviderRefreshStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_GetProviderRefreshStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ConfigureProviderRefresh(ctx context.Context, in *ConfigureProviderRefreshRequest, opts ...grpc.CallOption) (*ConfigureProviderRefreshResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConfigureProviderRefreshResponse) + err := c.cc.Invoke(ctx, OpenShell_ConfigureProviderRefresh_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RotateProviderCredential(ctx context.Context, in *RotateProviderCredentialRequest, opts ...grpc.CallOption) (*RotateProviderCredentialResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RotateProviderCredentialResponse) + err := c.cc.Invoke(ctx, OpenShell_RotateProviderCredential_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteProviderRefresh(ctx context.Context, in *DeleteProviderRefreshRequest, opts ...grpc.CallOption) (*DeleteProviderRefreshResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteProviderRefreshResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteProviderRefresh_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteProvider(ctx context.Context, in *DeleteProviderRequest, opts ...grpc.CallOption) (*DeleteProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteProviderProfile(ctx context.Context, in *DeleteProviderProfileRequest, opts ...grpc.CallOption) (*DeleteProviderProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteProviderProfileResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteProviderProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxConfig(ctx context.Context, in *sandboxv1.GetSandboxConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetSandboxConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(sandboxv1.GetSandboxConfigResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetGatewayConfig(ctx context.Context, in *sandboxv1.GetGatewayConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetGatewayConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(sandboxv1.GetGatewayConfigResponse) + err := c.cc.Invoke(ctx, OpenShell_GetGatewayConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UpdateConfig(ctx context.Context, in *UpdateConfigRequest, opts ...grpc.CallOption) (*UpdateConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateConfigResponse) + err := c.cc.Invoke(ctx, OpenShell_UpdateConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxPolicyStatus(ctx context.Context, in *GetSandboxPolicyStatusRequest, opts ...grpc.CallOption) (*GetSandboxPolicyStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSandboxPolicyStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxPolicyStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListSandboxPolicies(ctx context.Context, in *ListSandboxPoliciesRequest, opts ...grpc.CallOption) (*ListSandboxPoliciesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSandboxPoliciesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListSandboxPolicies_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ReportPolicyStatus(ctx context.Context, in *ReportPolicyStatusRequest, opts ...grpc.CallOption) (*ReportPolicyStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportPolicyStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_ReportPolicyStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSandboxProviderEnvironmentResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxProviderEnvironment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxLogs(ctx context.Context, in *GetSandboxLogsRequest, opts ...grpc.CallOption) (*GetSandboxLogsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSandboxLogsResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxLogs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) PushSandboxLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushSandboxLogsRequest, PushSandboxLogsResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[3], OpenShell_PushSandboxLogs_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[PushSandboxLogsRequest, PushSandboxLogsResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_PushSandboxLogsClient = grpc.ClientStreamingClient[PushSandboxLogsRequest, PushSandboxLogsResponse] + +func (c *openShellClient) ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[4], OpenShell_ConnectSupervisor_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SupervisorMessage, GatewayMessage]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ConnectSupervisorClient = grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage] + +func (c *openShellClient) RelayStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[RelayFrame, RelayFrame], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[5], OpenShell_RelayStream_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[RelayFrame, RelayFrame]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_RelayStreamClient = grpc.BidiStreamingClient[RelayFrame, RelayFrame] + +func (c *openShellClient) WatchSandbox(ctx context.Context, in *WatchSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxStreamEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[6], OpenShell_WatchSandbox_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[WatchSandboxRequest, SandboxStreamEvent]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_WatchSandboxClient = grpc.ServerStreamingClient[SandboxStreamEvent] + +func (c *openShellClient) SubmitPolicyAnalysis(ctx context.Context, in *SubmitPolicyAnalysisRequest, opts ...grpc.CallOption) (*SubmitPolicyAnalysisResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SubmitPolicyAnalysisResponse) + err := c.cc.Invoke(ctx, OpenShell_SubmitPolicyAnalysis_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetDraftPolicy(ctx context.Context, in *GetDraftPolicyRequest, opts ...grpc.CallOption) (*GetDraftPolicyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetDraftPolicyResponse) + err := c.cc.Invoke(ctx, OpenShell_GetDraftPolicy_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ApproveDraftChunk(ctx context.Context, in *ApproveDraftChunkRequest, opts ...grpc.CallOption) (*ApproveDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApproveDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_ApproveDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RejectDraftChunk(ctx context.Context, in *RejectDraftChunkRequest, opts ...grpc.CallOption) (*RejectDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RejectDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_RejectDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ApproveAllDraftChunks(ctx context.Context, in *ApproveAllDraftChunksRequest, opts ...grpc.CallOption) (*ApproveAllDraftChunksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApproveAllDraftChunksResponse) + err := c.cc.Invoke(ctx, OpenShell_ApproveAllDraftChunks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) EditDraftChunk(ctx context.Context, in *EditDraftChunkRequest, opts ...grpc.CallOption) (*EditDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EditDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_EditDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UndoDraftChunk(ctx context.Context, in *UndoDraftChunkRequest, opts ...grpc.CallOption) (*UndoDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UndoDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_UndoDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ClearDraftChunks(ctx context.Context, in *ClearDraftChunksRequest, opts ...grpc.CallOption) (*ClearDraftChunksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClearDraftChunksResponse) + err := c.cc.Invoke(ctx, OpenShell_ClearDraftChunks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetDraftHistory(ctx context.Context, in *GetDraftHistoryRequest, opts ...grpc.CallOption) (*GetDraftHistoryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetDraftHistoryResponse) + err := c.cc.Invoke(ctx, OpenShell_GetDraftHistory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) IssueSandboxToken(ctx context.Context, in *IssueSandboxTokenRequest, opts ...grpc.CallOption) (*IssueSandboxTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(IssueSandboxTokenResponse) + err := c.cc.Invoke(ctx, OpenShell_IssueSandboxToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RefreshSandboxToken(ctx context.Context, in *RefreshSandboxTokenRequest, opts ...grpc.CallOption) (*RefreshSandboxTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RefreshSandboxTokenResponse) + err := c.cc.Invoke(ctx, OpenShell_RefreshSandboxToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// OpenShellServer is the server API for OpenShell service. +// All implementations must embed UnimplementedOpenShellServer +// for forward compatibility. +// +// OpenShell service provides sandbox, provider, and runtime management capabilities. +// +// Conventions: +// - This file owns the public API resource model exposed to OpenShell clients. +// - `Sandbox`, `SandboxSpec`, `SandboxStatus`, and `SandboxPhase` are gateway-owned +// public types. Internal compute drivers must not import or return them directly. +// - The gateway translates internal compute-driver observations into these public +// resource messages before persisting or returning them to clients. +type OpenShellServer interface { + // Check the health of the service. + Health(context.Context, *HealthRequest) (*HealthResponse, error) + // Create a new sandbox. + CreateSandbox(context.Context, *CreateSandboxRequest) (*SandboxResponse, error) + // Fetch a sandbox by name. + GetSandbox(context.Context, *GetSandboxRequest) (*SandboxResponse, error) + // List sandboxes. + ListSandboxes(context.Context, *ListSandboxesRequest) (*ListSandboxesResponse, error) + // List provider records attached to a sandbox. + ListSandboxProviders(context.Context, *ListSandboxProvidersRequest) (*ListSandboxProvidersResponse, error) + // Attach a provider record to an existing sandbox. + AttachSandboxProvider(context.Context, *AttachSandboxProviderRequest) (*AttachSandboxProviderResponse, error) + // Detach a provider record from an existing sandbox. + DetachSandboxProvider(context.Context, *DetachSandboxProviderRequest) (*DetachSandboxProviderResponse, error) + // Delete a sandbox by name. + DeleteSandbox(context.Context, *DeleteSandboxRequest) (*DeleteSandboxResponse, error) + // Create a short-lived SSH session for a sandbox. + CreateSshSession(context.Context, *CreateSshSessionRequest) (*CreateSshSessionResponse, error) + // Create or update a sandbox HTTP service endpoint for local routing. + ExposeService(context.Context, *ExposeServiceRequest) (*ServiceEndpointResponse, error) + // Fetch one sandbox HTTP service endpoint. + GetService(context.Context, *GetServiceRequest) (*ServiceEndpointResponse, error) + // List sandbox HTTP service endpoints. + ListServices(context.Context, *ListServicesRequest) (*ListServicesResponse, error) + // Delete one sandbox HTTP service endpoint. + DeleteService(context.Context, *DeleteServiceRequest) (*DeleteServiceResponse, error) + // Revoke a previously issued SSH session. + RevokeSshSession(context.Context, *RevokeSshSessionRequest) (*RevokeSshSessionResponse, error) + // Execute a command in a ready sandbox and stream output. + ExecSandbox(*ExecSandboxRequest, grpc.ServerStreamingServer[ExecSandboxEvent]) error + // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. + ForwardTcp(grpc.BidiStreamingServer[TcpForwardFrame, TcpForwardFrame]) error + // Execute an interactive command with bidirectional stdin/stdout streaming. + // The first client message MUST carry an ExecSandboxInput with the start + // variant. Subsequent messages carry stdin bytes or window resize events. + ExecSandboxInteractive(grpc.BidiStreamingServer[ExecSandboxInput, ExecSandboxEvent]) error + // Create a provider. + CreateProvider(context.Context, *CreateProviderRequest) (*ProviderResponse, error) + // Fetch a provider by name. + GetProvider(context.Context, *GetProviderRequest) (*ProviderResponse, error) + // List providers. + ListProviders(context.Context, *ListProvidersRequest) (*ListProvidersResponse, error) + // List available provider type profiles. + ListProviderProfiles(context.Context, *ListProviderProfilesRequest) (*ListProviderProfilesResponse, error) + // Fetch one provider type profile by id. + GetProviderProfile(context.Context, *GetProviderProfileRequest) (*ProviderProfileResponse, error) + // Import custom provider type profiles. + ImportProviderProfiles(context.Context, *ImportProviderProfilesRequest) (*ImportProviderProfilesResponse, error) + // Update an existing custom provider type profile. + UpdateProviderProfiles(context.Context, *UpdateProviderProfilesRequest) (*UpdateProviderProfilesResponse, error) + // Validate provider type profiles without registering them. + LintProviderProfiles(context.Context, *LintProviderProfilesRequest) (*LintProviderProfilesResponse, error) + // Update an existing provider by name. + UpdateProvider(context.Context, *UpdateProviderRequest) (*ProviderResponse, error) + // Fetch refresh status for one provider or provider credential. + GetProviderRefreshStatus(context.Context, *GetProviderRefreshStatusRequest) (*GetProviderRefreshStatusResponse, error) + // Configure gateway-owned refresh material for one provider credential. + ConfigureProviderRefresh(context.Context, *ConfigureProviderRefreshRequest) (*ConfigureProviderRefreshResponse, error) + // Record a gateway-owned refresh request for one provider credential. + RotateProviderCredential(context.Context, *RotateProviderCredentialRequest) (*RotateProviderCredentialResponse, error) + // Delete gateway-owned refresh configuration for one provider credential. + DeleteProviderRefresh(context.Context, *DeleteProviderRefreshRequest) (*DeleteProviderRefreshResponse, error) + // Delete a provider by name. + DeleteProvider(context.Context, *DeleteProviderRequest) (*DeleteProviderResponse, error) + // Delete a custom provider type profile by id. + DeleteProviderProfile(context.Context, *DeleteProviderProfileRequest) (*DeleteProviderProfileResponse, error) + // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + GetSandboxConfig(context.Context, *sandboxv1.GetSandboxConfigRequest) (*sandboxv1.GetSandboxConfigResponse, error) + // Get gateway-global settings. + GetGatewayConfig(context.Context, *sandboxv1.GetGatewayConfigRequest) (*sandboxv1.GetGatewayConfigResponse, error) + // Update settings or policy at sandbox or global scope. + UpdateConfig(context.Context, *UpdateConfigRequest) (*UpdateConfigResponse, error) + // Get the load status of a specific policy version. + GetSandboxPolicyStatus(context.Context, *GetSandboxPolicyStatusRequest) (*GetSandboxPolicyStatusResponse, error) + // List policy history for a sandbox. + ListSandboxPolicies(context.Context, *ListSandboxPoliciesRequest) (*ListSandboxPoliciesResponse, error) + // Report policy load result (called by sandbox after reload attempt). + ReportPolicyStatus(context.Context, *ReportPolicyStatusRequest) (*ReportPolicyStatusResponse, error) + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) + // Fetch recent sandbox logs (one-shot). + GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) + // Push sandbox supervisor logs to the server (client-streaming). + PushSandboxLogs(grpc.ClientStreamingServer[PushSandboxLogsRequest, PushSandboxLogsResponse]) error + // Persistent supervisor-to-gateway session (bidirectional streaming). + // + // The supervisor opens this stream at startup and keeps it alive for the + // sandbox lifetime. The gateway uses it to coordinate relay channels for + // SSH connect, ExecSandbox, and targetable sandbox services. Raw service + // bytes flow over RelayStream calls (separate HTTP/2 streams on the same + // connection), not over this stream. + ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error + // Raw byte relay between supervisor and gateway. + // + // The supervisor initiates this call after receiving a RelayOpen message + // on its ConnectSupervisor stream. The first RelayFrame carries a + // RelayInit with the channel_id to associate the new HTTP/2 stream with + // the pending relay slot on the gateway. Subsequent frames carry raw bytes in either + // direction between the gateway-side waiter (ForwardTcp / exec handler) + // and the supervisor-side target bridge. + // + // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — + // no new TLS handshake, no reverse HTTP CONNECT. + RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - OpenShell server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + WatchSandbox(*WatchSandboxRequest, grpc.ServerStreamingServer[SandboxStreamEvent]) error + // Submit denial analysis results from sandbox (summaries + proposed chunks). + SubmitPolicyAnalysis(context.Context, *SubmitPolicyAnalysisRequest) (*SubmitPolicyAnalysisResponse, error) + // Get draft policy recommendations for a sandbox. + GetDraftPolicy(context.Context, *GetDraftPolicyRequest) (*GetDraftPolicyResponse, error) + // Approve a single draft policy chunk (merges into active policy). + ApproveDraftChunk(context.Context, *ApproveDraftChunkRequest) (*ApproveDraftChunkResponse, error) + // Reject a single draft policy chunk. + RejectDraftChunk(context.Context, *RejectDraftChunkRequest) (*RejectDraftChunkResponse, error) + // Approve all pending draft chunks (skips security-flagged unless forced). + ApproveAllDraftChunks(context.Context, *ApproveAllDraftChunksRequest) (*ApproveAllDraftChunksResponse, error) + // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). + EditDraftChunk(context.Context, *EditDraftChunkRequest) (*EditDraftChunkResponse, error) + // Reverse an approval (remove merged rule from active policy). + UndoDraftChunk(context.Context, *UndoDraftChunkRequest) (*UndoDraftChunkResponse, error) + // Clear all pending draft chunks for a sandbox. + ClearDraftChunks(context.Context, *ClearDraftChunksRequest) (*ClearDraftChunksResponse, error) + // Get decision history for a sandbox's draft policy. + GetDraftHistory(context.Context, *GetDraftHistoryRequest) (*GetDraftHistoryResponse, error) + // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected + // ServiceAccount token) for a gateway-minted JWT bound to the calling + // sandbox's UUID. Used by the Kubernetes driver path; singleplayer + // drivers receive the gateway JWT directly from the create-sandbox flow + // and never call this RPC. + IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, error) + // Renew the calling sandbox's gateway JWT. Older tokens remain valid + // until their own expiry; deployments should keep token TTLs short to + // bound replay exposure. The supervisor calls this from a background + // task at ~80% of the token's lifetime; the new token is cached in + // memory only — the on-disk bootstrap file is intentionally not + // rewritten. + RefreshSandboxToken(context.Context, *RefreshSandboxTokenRequest) (*RefreshSandboxTokenResponse, error) + mustEmbedUnimplementedOpenShellServer() +} + +// UnimplementedOpenShellServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedOpenShellServer struct{} + +func (UnimplementedOpenShellServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedOpenShellServer) CreateSandbox(context.Context, *CreateSandboxRequest) (*SandboxResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSandbox not implemented") +} +func (UnimplementedOpenShellServer) GetSandbox(context.Context, *GetSandboxRequest) (*SandboxResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandbox not implemented") +} +func (UnimplementedOpenShellServer) ListSandboxes(context.Context, *ListSandboxesRequest) (*ListSandboxesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSandboxes not implemented") +} +func (UnimplementedOpenShellServer) ListSandboxProviders(context.Context, *ListSandboxProvidersRequest) (*ListSandboxProvidersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSandboxProviders not implemented") +} +func (UnimplementedOpenShellServer) AttachSandboxProvider(context.Context, *AttachSandboxProviderRequest) (*AttachSandboxProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AttachSandboxProvider not implemented") +} +func (UnimplementedOpenShellServer) DetachSandboxProvider(context.Context, *DetachSandboxProviderRequest) (*DetachSandboxProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DetachSandboxProvider not implemented") +} +func (UnimplementedOpenShellServer) DeleteSandbox(context.Context, *DeleteSandboxRequest) (*DeleteSandboxResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteSandbox not implemented") +} +func (UnimplementedOpenShellServer) CreateSshSession(context.Context, *CreateSshSessionRequest) (*CreateSshSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSshSession not implemented") +} +func (UnimplementedOpenShellServer) ExposeService(context.Context, *ExposeServiceRequest) (*ServiceEndpointResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ExposeService not implemented") +} +func (UnimplementedOpenShellServer) GetService(context.Context, *GetServiceRequest) (*ServiceEndpointResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetService not implemented") +} +func (UnimplementedOpenShellServer) ListServices(context.Context, *ListServicesRequest) (*ListServicesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListServices not implemented") +} +func (UnimplementedOpenShellServer) DeleteService(context.Context, *DeleteServiceRequest) (*DeleteServiceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteService not implemented") +} +func (UnimplementedOpenShellServer) RevokeSshSession(context.Context, *RevokeSshSessionRequest) (*RevokeSshSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RevokeSshSession not implemented") +} +func (UnimplementedOpenShellServer) ExecSandbox(*ExecSandboxRequest, grpc.ServerStreamingServer[ExecSandboxEvent]) error { + return status.Error(codes.Unimplemented, "method ExecSandbox not implemented") +} +func (UnimplementedOpenShellServer) ForwardTcp(grpc.BidiStreamingServer[TcpForwardFrame, TcpForwardFrame]) error { + return status.Error(codes.Unimplemented, "method ForwardTcp not implemented") +} +func (UnimplementedOpenShellServer) ExecSandboxInteractive(grpc.BidiStreamingServer[ExecSandboxInput, ExecSandboxEvent]) error { + return status.Error(codes.Unimplemented, "method ExecSandboxInteractive not implemented") +} +func (UnimplementedOpenShellServer) CreateProvider(context.Context, *CreateProviderRequest) (*ProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateProvider not implemented") +} +func (UnimplementedOpenShellServer) GetProvider(context.Context, *GetProviderRequest) (*ProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProvider not implemented") +} +func (UnimplementedOpenShellServer) ListProviders(context.Context, *ListProvidersRequest) (*ListProvidersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListProviders not implemented") +} +func (UnimplementedOpenShellServer) ListProviderProfiles(context.Context, *ListProviderProfilesRequest) (*ListProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) GetProviderProfile(context.Context, *GetProviderProfileRequest) (*ProviderProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProviderProfile not implemented") +} +func (UnimplementedOpenShellServer) ImportProviderProfiles(context.Context, *ImportProviderProfilesRequest) (*ImportProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ImportProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) UpdateProviderProfiles(context.Context, *UpdateProviderProfilesRequest) (*UpdateProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) LintProviderProfiles(context.Context, *LintProviderProfilesRequest) (*LintProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method LintProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) UpdateProvider(context.Context, *UpdateProviderRequest) (*ProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateProvider not implemented") +} +func (UnimplementedOpenShellServer) GetProviderRefreshStatus(context.Context, *GetProviderRefreshStatusRequest) (*GetProviderRefreshStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProviderRefreshStatus not implemented") +} +func (UnimplementedOpenShellServer) ConfigureProviderRefresh(context.Context, *ConfigureProviderRefreshRequest) (*ConfigureProviderRefreshResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ConfigureProviderRefresh not implemented") +} +func (UnimplementedOpenShellServer) RotateProviderCredential(context.Context, *RotateProviderCredentialRequest) (*RotateProviderCredentialResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RotateProviderCredential not implemented") +} +func (UnimplementedOpenShellServer) DeleteProviderRefresh(context.Context, *DeleteProviderRefreshRequest) (*DeleteProviderRefreshResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteProviderRefresh not implemented") +} +func (UnimplementedOpenShellServer) DeleteProvider(context.Context, *DeleteProviderRequest) (*DeleteProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteProvider not implemented") +} +func (UnimplementedOpenShellServer) DeleteProviderProfile(context.Context, *DeleteProviderProfileRequest) (*DeleteProviderProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteProviderProfile not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxConfig(context.Context, *sandboxv1.GetSandboxConfigRequest) (*sandboxv1.GetSandboxConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxConfig not implemented") +} +func (UnimplementedOpenShellServer) GetGatewayConfig(context.Context, *sandboxv1.GetGatewayConfigRequest) (*sandboxv1.GetGatewayConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetGatewayConfig not implemented") +} +func (UnimplementedOpenShellServer) UpdateConfig(context.Context, *UpdateConfigRequest) (*UpdateConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateConfig not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxPolicyStatus(context.Context, *GetSandboxPolicyStatusRequest) (*GetSandboxPolicyStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxPolicyStatus not implemented") +} +func (UnimplementedOpenShellServer) ListSandboxPolicies(context.Context, *ListSandboxPoliciesRequest) (*ListSandboxPoliciesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSandboxPolicies not implemented") +} +func (UnimplementedOpenShellServer) ReportPolicyStatus(context.Context, *ReportPolicyStatusRequest) (*ReportPolicyStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReportPolicyStatus not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxProviderEnvironment not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxLogs not implemented") +} +func (UnimplementedOpenShellServer) PushSandboxLogs(grpc.ClientStreamingServer[PushSandboxLogsRequest, PushSandboxLogsResponse]) error { + return status.Error(codes.Unimplemented, "method PushSandboxLogs not implemented") +} +func (UnimplementedOpenShellServer) ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error { + return status.Error(codes.Unimplemented, "method ConnectSupervisor not implemented") +} +func (UnimplementedOpenShellServer) RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error { + return status.Error(codes.Unimplemented, "method RelayStream not implemented") +} +func (UnimplementedOpenShellServer) WatchSandbox(*WatchSandboxRequest, grpc.ServerStreamingServer[SandboxStreamEvent]) error { + return status.Error(codes.Unimplemented, "method WatchSandbox not implemented") +} +func (UnimplementedOpenShellServer) SubmitPolicyAnalysis(context.Context, *SubmitPolicyAnalysisRequest) (*SubmitPolicyAnalysisResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitPolicyAnalysis not implemented") +} +func (UnimplementedOpenShellServer) GetDraftPolicy(context.Context, *GetDraftPolicyRequest) (*GetDraftPolicyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetDraftPolicy not implemented") +} +func (UnimplementedOpenShellServer) ApproveDraftChunk(context.Context, *ApproveDraftChunkRequest) (*ApproveDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApproveDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) RejectDraftChunk(context.Context, *RejectDraftChunkRequest) (*RejectDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RejectDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) ApproveAllDraftChunks(context.Context, *ApproveAllDraftChunksRequest) (*ApproveAllDraftChunksResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApproveAllDraftChunks not implemented") +} +func (UnimplementedOpenShellServer) EditDraftChunk(context.Context, *EditDraftChunkRequest) (*EditDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method EditDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) UndoDraftChunk(context.Context, *UndoDraftChunkRequest) (*UndoDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UndoDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) ClearDraftChunks(context.Context, *ClearDraftChunksRequest) (*ClearDraftChunksResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ClearDraftChunks not implemented") +} +func (UnimplementedOpenShellServer) GetDraftHistory(context.Context, *GetDraftHistoryRequest) (*GetDraftHistoryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetDraftHistory not implemented") +} +func (UnimplementedOpenShellServer) IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IssueSandboxToken not implemented") +} +func (UnimplementedOpenShellServer) RefreshSandboxToken(context.Context, *RefreshSandboxTokenRequest) (*RefreshSandboxTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RefreshSandboxToken not implemented") +} +func (UnimplementedOpenShellServer) mustEmbedUnimplementedOpenShellServer() {} +func (UnimplementedOpenShellServer) testEmbeddedByValue() {} + +// UnsafeOpenShellServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to OpenShellServer will +// result in compilation errors. +type UnsafeOpenShellServer interface { + mustEmbedUnimplementedOpenShellServer() +} + +func RegisterOpenShellServer(s grpc.ServiceRegistrar, srv OpenShellServer) { + // If the following call panics, it indicates UnimplementedOpenShellServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&OpenShell_ServiceDesc, srv) +} + +func _OpenShell_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).Health(ctx, req.(*HealthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_CreateSandbox_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSandboxRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).CreateSandbox(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_CreateSandbox_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).CreateSandbox(ctx, req.(*CreateSandboxRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandbox_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandbox(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandbox_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandbox(ctx, req.(*GetSandboxRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListSandboxes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSandboxesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListSandboxes(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListSandboxes_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListSandboxes(ctx, req.(*ListSandboxesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListSandboxProviders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSandboxProvidersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListSandboxProviders(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListSandboxProviders_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListSandboxProviders(ctx, req.(*ListSandboxProvidersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_AttachSandboxProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AttachSandboxProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).AttachSandboxProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_AttachSandboxProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).AttachSandboxProvider(ctx, req.(*AttachSandboxProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DetachSandboxProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DetachSandboxProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DetachSandboxProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DetachSandboxProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DetachSandboxProvider(ctx, req.(*DetachSandboxProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteSandbox_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteSandboxRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteSandbox(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteSandbox_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteSandbox(ctx, req.(*DeleteSandboxRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_CreateSshSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSshSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).CreateSshSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_CreateSshSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).CreateSshSession(ctx, req.(*CreateSshSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ExposeService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExposeServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ExposeService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ExposeService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ExposeService(ctx, req.(*ExposeServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetService(ctx, req.(*GetServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListServices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListServicesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListServices(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListServices_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListServices(ctx, req.(*ListServicesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteService(ctx, req.(*DeleteServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RevokeSshSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeSshSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RevokeSshSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RevokeSshSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RevokeSshSession(ctx, req.(*RevokeSshSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ExecSandbox_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ExecSandboxRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OpenShellServer).ExecSandbox(m, &grpc.GenericServerStream[ExecSandboxRequest, ExecSandboxEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxServer = grpc.ServerStreamingServer[ExecSandboxEvent] + +func _OpenShell_ForwardTcp_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).ForwardTcp(&grpc.GenericServerStream[TcpForwardFrame, TcpForwardFrame]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ForwardTcpServer = grpc.BidiStreamingServer[TcpForwardFrame, TcpForwardFrame] + +func _OpenShell_ExecSandboxInteractive_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).ExecSandboxInteractive(&grpc.GenericServerStream[ExecSandboxInput, ExecSandboxEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxInteractiveServer = grpc.BidiStreamingServer[ExecSandboxInput, ExecSandboxEvent] + +func _OpenShell_CreateProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).CreateProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_CreateProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).CreateProvider(ctx, req.(*CreateProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetProvider(ctx, req.(*GetProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListProviders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListProvidersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListProviders(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListProviders_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListProviders(ctx, req.(*ListProvidersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListProviderProfiles(ctx, req.(*ListProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetProviderProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProviderProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetProviderProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetProviderProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetProviderProfile(ctx, req.(*GetProviderProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ImportProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ImportProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ImportProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ImportProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ImportProviderProfiles(ctx, req.(*ImportProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UpdateProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UpdateProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UpdateProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UpdateProviderProfiles(ctx, req.(*UpdateProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_LintProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LintProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).LintProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_LintProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).LintProviderProfiles(ctx, req.(*LintProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UpdateProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UpdateProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UpdateProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UpdateProvider(ctx, req.(*UpdateProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetProviderRefreshStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProviderRefreshStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetProviderRefreshStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetProviderRefreshStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetProviderRefreshStatus(ctx, req.(*GetProviderRefreshStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ConfigureProviderRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigureProviderRefreshRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ConfigureProviderRefresh(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ConfigureProviderRefresh_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ConfigureProviderRefresh(ctx, req.(*ConfigureProviderRefreshRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RotateProviderCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RotateProviderCredentialRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RotateProviderCredential(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RotateProviderCredential_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RotateProviderCredential(ctx, req.(*RotateProviderCredentialRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteProviderRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteProviderRefreshRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteProviderRefresh(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteProviderRefresh_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteProviderRefresh(ctx, req.(*DeleteProviderRefreshRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteProvider(ctx, req.(*DeleteProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteProviderProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteProviderProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteProviderProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteProviderProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteProviderProfile(ctx, req.(*DeleteProviderProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(sandboxv1.GetSandboxConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxConfig(ctx, req.(*sandboxv1.GetSandboxConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetGatewayConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(sandboxv1.GetGatewayConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetGatewayConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetGatewayConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetGatewayConfig(ctx, req.(*sandboxv1.GetGatewayConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UpdateConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UpdateConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UpdateConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UpdateConfig(ctx, req.(*UpdateConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxPolicyStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxPolicyStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxPolicyStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxPolicyStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxPolicyStatus(ctx, req.(*GetSandboxPolicyStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListSandboxPolicies_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSandboxPoliciesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListSandboxPolicies(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListSandboxPolicies_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListSandboxPolicies(ctx, req.(*ListSandboxPoliciesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ReportPolicyStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportPolicyStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ReportPolicyStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ReportPolicyStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ReportPolicyStatus(ctx, req.(*ReportPolicyStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxProviderEnvironment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxProviderEnvironmentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxProviderEnvironment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxProviderEnvironment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxProviderEnvironment(ctx, req.(*GetSandboxProviderEnvironmentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxLogsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxLogs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxLogs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxLogs(ctx, req.(*GetSandboxLogsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_PushSandboxLogs_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).PushSandboxLogs(&grpc.GenericServerStream[PushSandboxLogsRequest, PushSandboxLogsResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_PushSandboxLogsServer = grpc.ClientStreamingServer[PushSandboxLogsRequest, PushSandboxLogsResponse] + +func _OpenShell_ConnectSupervisor_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).ConnectSupervisor(&grpc.GenericServerStream[SupervisorMessage, GatewayMessage]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ConnectSupervisorServer = grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage] + +func _OpenShell_RelayStream_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).RelayStream(&grpc.GenericServerStream[RelayFrame, RelayFrame]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_RelayStreamServer = grpc.BidiStreamingServer[RelayFrame, RelayFrame] + +func _OpenShell_WatchSandbox_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(WatchSandboxRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OpenShellServer).WatchSandbox(m, &grpc.GenericServerStream[WatchSandboxRequest, SandboxStreamEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_WatchSandboxServer = grpc.ServerStreamingServer[SandboxStreamEvent] + +func _OpenShell_SubmitPolicyAnalysis_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitPolicyAnalysisRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).SubmitPolicyAnalysis(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_SubmitPolicyAnalysis_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).SubmitPolicyAnalysis(ctx, req.(*SubmitPolicyAnalysisRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetDraftPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetDraftPolicyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetDraftPolicy(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetDraftPolicy_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetDraftPolicy(ctx, req.(*GetDraftPolicyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ApproveDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApproveDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ApproveDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ApproveDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ApproveDraftChunk(ctx, req.(*ApproveDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RejectDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RejectDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RejectDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RejectDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RejectDraftChunk(ctx, req.(*RejectDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ApproveAllDraftChunks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApproveAllDraftChunksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ApproveAllDraftChunks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ApproveAllDraftChunks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ApproveAllDraftChunks(ctx, req.(*ApproveAllDraftChunksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_EditDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EditDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).EditDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_EditDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).EditDraftChunk(ctx, req.(*EditDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UndoDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UndoDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UndoDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UndoDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UndoDraftChunk(ctx, req.(*UndoDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ClearDraftChunks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClearDraftChunksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ClearDraftChunks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ClearDraftChunks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ClearDraftChunks(ctx, req.(*ClearDraftChunksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetDraftHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetDraftHistoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetDraftHistory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetDraftHistory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetDraftHistory(ctx, req.(*GetDraftHistoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_IssueSandboxToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IssueSandboxTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).IssueSandboxToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_IssueSandboxToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).IssueSandboxToken(ctx, req.(*IssueSandboxTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RefreshSandboxToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RefreshSandboxTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RefreshSandboxToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RefreshSandboxToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RefreshSandboxToken(ctx, req.(*RefreshSandboxTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// OpenShell_ServiceDesc is the grpc.ServiceDesc for OpenShell service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var OpenShell_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "openshell.v1.OpenShell", + HandlerType: (*OpenShellServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Health", + Handler: _OpenShell_Health_Handler, + }, + { + MethodName: "CreateSandbox", + Handler: _OpenShell_CreateSandbox_Handler, + }, + { + MethodName: "GetSandbox", + Handler: _OpenShell_GetSandbox_Handler, + }, + { + MethodName: "ListSandboxes", + Handler: _OpenShell_ListSandboxes_Handler, + }, + { + MethodName: "ListSandboxProviders", + Handler: _OpenShell_ListSandboxProviders_Handler, + }, + { + MethodName: "AttachSandboxProvider", + Handler: _OpenShell_AttachSandboxProvider_Handler, + }, + { + MethodName: "DetachSandboxProvider", + Handler: _OpenShell_DetachSandboxProvider_Handler, + }, + { + MethodName: "DeleteSandbox", + Handler: _OpenShell_DeleteSandbox_Handler, + }, + { + MethodName: "CreateSshSession", + Handler: _OpenShell_CreateSshSession_Handler, + }, + { + MethodName: "ExposeService", + Handler: _OpenShell_ExposeService_Handler, + }, + { + MethodName: "GetService", + Handler: _OpenShell_GetService_Handler, + }, + { + MethodName: "ListServices", + Handler: _OpenShell_ListServices_Handler, + }, + { + MethodName: "DeleteService", + Handler: _OpenShell_DeleteService_Handler, + }, + { + MethodName: "RevokeSshSession", + Handler: _OpenShell_RevokeSshSession_Handler, + }, + { + MethodName: "CreateProvider", + Handler: _OpenShell_CreateProvider_Handler, + }, + { + MethodName: "GetProvider", + Handler: _OpenShell_GetProvider_Handler, + }, + { + MethodName: "ListProviders", + Handler: _OpenShell_ListProviders_Handler, + }, + { + MethodName: "ListProviderProfiles", + Handler: _OpenShell_ListProviderProfiles_Handler, + }, + { + MethodName: "GetProviderProfile", + Handler: _OpenShell_GetProviderProfile_Handler, + }, + { + MethodName: "ImportProviderProfiles", + Handler: _OpenShell_ImportProviderProfiles_Handler, + }, + { + MethodName: "UpdateProviderProfiles", + Handler: _OpenShell_UpdateProviderProfiles_Handler, + }, + { + MethodName: "LintProviderProfiles", + Handler: _OpenShell_LintProviderProfiles_Handler, + }, + { + MethodName: "UpdateProvider", + Handler: _OpenShell_UpdateProvider_Handler, + }, + { + MethodName: "GetProviderRefreshStatus", + Handler: _OpenShell_GetProviderRefreshStatus_Handler, + }, + { + MethodName: "ConfigureProviderRefresh", + Handler: _OpenShell_ConfigureProviderRefresh_Handler, + }, + { + MethodName: "RotateProviderCredential", + Handler: _OpenShell_RotateProviderCredential_Handler, + }, + { + MethodName: "DeleteProviderRefresh", + Handler: _OpenShell_DeleteProviderRefresh_Handler, + }, + { + MethodName: "DeleteProvider", + Handler: _OpenShell_DeleteProvider_Handler, + }, + { + MethodName: "DeleteProviderProfile", + Handler: _OpenShell_DeleteProviderProfile_Handler, + }, + { + MethodName: "GetSandboxConfig", + Handler: _OpenShell_GetSandboxConfig_Handler, + }, + { + MethodName: "GetGatewayConfig", + Handler: _OpenShell_GetGatewayConfig_Handler, + }, + { + MethodName: "UpdateConfig", + Handler: _OpenShell_UpdateConfig_Handler, + }, + { + MethodName: "GetSandboxPolicyStatus", + Handler: _OpenShell_GetSandboxPolicyStatus_Handler, + }, + { + MethodName: "ListSandboxPolicies", + Handler: _OpenShell_ListSandboxPolicies_Handler, + }, + { + MethodName: "ReportPolicyStatus", + Handler: _OpenShell_ReportPolicyStatus_Handler, + }, + { + MethodName: "GetSandboxProviderEnvironment", + Handler: _OpenShell_GetSandboxProviderEnvironment_Handler, + }, + { + MethodName: "GetSandboxLogs", + Handler: _OpenShell_GetSandboxLogs_Handler, + }, + { + MethodName: "SubmitPolicyAnalysis", + Handler: _OpenShell_SubmitPolicyAnalysis_Handler, + }, + { + MethodName: "GetDraftPolicy", + Handler: _OpenShell_GetDraftPolicy_Handler, + }, + { + MethodName: "ApproveDraftChunk", + Handler: _OpenShell_ApproveDraftChunk_Handler, + }, + { + MethodName: "RejectDraftChunk", + Handler: _OpenShell_RejectDraftChunk_Handler, + }, + { + MethodName: "ApproveAllDraftChunks", + Handler: _OpenShell_ApproveAllDraftChunks_Handler, + }, + { + MethodName: "EditDraftChunk", + Handler: _OpenShell_EditDraftChunk_Handler, + }, + { + MethodName: "UndoDraftChunk", + Handler: _OpenShell_UndoDraftChunk_Handler, + }, + { + MethodName: "ClearDraftChunks", + Handler: _OpenShell_ClearDraftChunks_Handler, + }, + { + MethodName: "GetDraftHistory", + Handler: _OpenShell_GetDraftHistory_Handler, + }, + { + MethodName: "IssueSandboxToken", + Handler: _OpenShell_IssueSandboxToken_Handler, + }, + { + MethodName: "RefreshSandboxToken", + Handler: _OpenShell_RefreshSandboxToken_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "ExecSandbox", + Handler: _OpenShell_ExecSandbox_Handler, + ServerStreams: true, + }, + { + StreamName: "ForwardTcp", + Handler: _OpenShell_ForwardTcp_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "ExecSandboxInteractive", + Handler: _OpenShell_ExecSandboxInteractive_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "PushSandboxLogs", + Handler: _OpenShell_PushSandboxLogs_Handler, + ClientStreams: true, + }, + { + StreamName: "ConnectSupervisor", + Handler: _OpenShell_ConnectSupervisor_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "RelayStream", + Handler: _OpenShell_RelayStream_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "WatchSandbox", + Handler: _OpenShell_WatchSandbox_Handler, + ServerStreams: true, + }, + }, + Metadata: "openshell.proto", +} diff --git a/sdk/go/proto/sandbox.proto b/sdk/go/proto/sandbox.proto new file mode 100644 index 0000000000..ef0b0540f7 --- /dev/null +++ b/sdk/go/proto/sandbox.proto @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.sandbox.v1; + +// Sandbox-supervisor configuration and policy messages. +// +// Conventions: +// - This file owns messages exchanged between the gateway and the sandbox +// supervisor/runtime. +// - Public sandbox resource types live in `openshell.proto`. +// - Internal compute-driver sandbox observation types live in `compute_driver.proto`. + +// Sandbox security policy configuration. +message SandboxPolicy { + // Policy version. + uint32 version = 1; + // Filesystem access policy. + FilesystemPolicy filesystem = 2; + // Landlock configuration. + LandlockPolicy landlock = 3; + // Process execution policy. + ProcessPolicy process = 4; + // Network access policies keyed by name (e.g. "claude_code", "gitlab"). + map network_policies = 5; +} + +// Filesystem access policy. +message FilesystemPolicy { + // Automatically include the workdir as read-write. + bool include_workdir = 1; + // Read-only directory allow list. + repeated string read_only = 2; + // Read-write directory allow list. + repeated string read_write = 3; +} + +// Landlock policy configuration. +message LandlockPolicy { + // Compatibility mode (e.g. "best_effort", "hard_requirement"). + string compatibility = 1; +} + +// Process execution policy. +message ProcessPolicy { + // User name to run the sandboxed process as. + string run_as_user = 1; + // Group name to run the sandboxed process as. + string run_as_group = 2; +} + +// A named network access policy rule. +message NetworkPolicyRule { + // Human-readable name for this policy rule. + string name = 1; + // Allowed endpoint (host:port) pairs. + repeated NetworkEndpoint endpoints = 2; + // Allowed binary identities. + repeated NetworkBinary binaries = 3; +} + +// A network endpoint (host + port) with optional L7 inspection config. +message NetworkEndpoint { + // Hostname or host glob pattern. Exact match is case-insensitive. + // Glob patterns use "." as delimiter: "*.example.com" matches a single + // subdomain label, "**.example.com" matches across labels. + string host = 1; + // Single port (backwards compat). Use `ports` for multiple ports. + // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. + uint32 port = 2; + // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + string protocol = 3; + // TLS handling: "terminate" or "passthrough" (default). + string tls = 4; + // Enforcement mode: "enforce" or "audit" (default). + string enforcement = 5; + // Access preset shorthand: "read-only", "read-write", "full". + // Mutually exclusive with rules. + string access = 6; + // Explicit L7 rules (mutually exclusive with access). + repeated L7Rule rules = 7; + // Allowed resolved IP addresses or CIDR ranges for this endpoint. + // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: + // - If host is also set: domain must resolve to an IP in this list. + // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). + // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked + // regardless of this field. + repeated string allowed_ips = 8; + // Multiple ports. When non-empty, this endpoint covers all listed ports. + // If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`. + // If both are set, `ports` takes precedence. + repeated uint32 ports = 9; + // Explicit L7 deny rules. When present, requests matching any deny rule + // are blocked even if they match an allow rule or access preset. + // Deny rules take precedence over allow rules. + repeated L7DenyRule deny_rules = 10; + // When true, percent-encoded '/' (%2F) is preserved in path segments + // rather than rejected by the L7 path canonicalizer. Required for + // upstreams like GitLab that embed %2F in namespaced resource paths. + // Defaults to false (strict). + bool allow_encoded_slash = 11; + // GraphQL persisted-query behavior for hash-only/saved-query requests: + // "deny" (default) or "allow_registered". + string persisted_queries = 12; + // Trusted GraphQL persisted-query registry keyed by hash or service-specific ID. + // Only used when persisted_queries is "allow_registered". + map graphql_persisted_queries = 13; + // Maximum GraphQL request body bytes to buffer for inspection. + // Defaults to 65536 when unset. + uint32 graphql_max_body_bytes = 14; + // Optional HTTP path glob that scopes this L7 endpoint on shared host:port APIs. + // Example: use path "/graphql" for protocol "graphql" and "/repos/**" for + // protocol "rest" when both surfaces live under api.example.com:443. + // Empty means all paths. + string path = 15; + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside client-to-server WebSocket text messages after an allowed HTTP 101 + // upgrade. Defaults to false. + bool websocket_credential_rewrite = 16; + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside supported textual HTTP request bodies before forwarding upstream. + // Defaults to false. + bool request_body_credential_rewrite = 17; + // Internal provenance marker for policy-advisor generated endpoints. + // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless + // they are converted through an explicit user-authored policy path. + bool advisor_proposed = 18; +} + +// Trusted GraphQL operation classification. +message GraphqlOperation { + // Operation type: "query", "mutation", or "subscription". + string operation_type = 1; + // Operation name, if known. + string operation_name = 2; + // Root field names selected by the operation. + repeated string fields = 3; +} + +// An L7 deny rule that blocks specific requests. +// Mirrors L7Allow — same fields, same matching semantics, inverted effect. +// Deny rules are evaluated after allow rules and take precedence. +message L7DenyRule { + // HTTP method (REST): GET, POST, etc. or "*" for any. + string method = 1; + // URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; + // Query parameter matcher map (REST). + // Same semantics as L7Allow.query. + map query = 4; + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + string operation_type = 5; + // GraphQL operation name glob. "*" matches any operation name. + string operation_name = 6; + // GraphQL root field globs. Deny rules match when any selected root field + // matches any configured glob. + repeated string fields = 7; +} + +// An L7 policy rule (allow-only). +message L7Rule { + L7Allow allow = 1; +} + +// Allowed action definition for L7 rules. +message L7Allow { + // HTTP method (REST): GET, POST, etc. or "*" for any. + string method = 1; + // URL path glob pattern (REST): "/repos/**", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; + // Query parameter matcher map (REST). + // Key is the decoded query parameter name (case-sensitive). + // Value supports either a single glob (`glob`) or a list (`any`). + map query = 4; + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + string operation_type = 5; + // GraphQL operation name glob. "*" matches any operation name. + string operation_name = 6; + // GraphQL root field globs. Allow rules match only when every selected root + // field matches one of the configured globs. Omit to match all fields. + repeated string fields = 7; +} + +// Query value matcher for one query parameter key. +message L7QueryMatcher { + // Single glob pattern. + string glob = 1; + // Any-of glob patterns. + repeated string any = 2; +} + +// A binary identity for network policy matching. +message NetworkBinary { + string path = 1; + // Deprecated: the harness concept has been removed. This field is ignored. + bool harness = 2 [deprecated = true]; +} + +// Request to get sandbox settings by sandbox ID. +message GetSandboxConfigRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Request to get gateway-global settings. +message GetGatewayConfigRequest {} + +// Response containing gateway-global settings. +message GetGatewayConfigResponse { + // Gateway-global settings map excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty SettingValue. + map settings = 1; + // Monotonically increasing revision for gateway-global settings. + uint64 settings_revision = 2; +} + +// Scope that currently controls a setting. +enum SettingScope { + SETTING_SCOPE_UNSPECIFIED = 0; + SETTING_SCOPE_SANDBOX = 1; + SETTING_SCOPE_GLOBAL = 2; +} + +// Type-aware setting value for sandbox/gateway settings. +message SettingValue { + oneof value { + string string_value = 1; + bool bool_value = 2; + int64 int_value = 3; + bytes bytes_value = 4; + } +} + +// Effective setting value and the scope it was resolved from. +message EffectiveSetting { + SettingValue value = 1; + SettingScope scope = 2; +} + +// Source used for the policy payload in GetSandboxConfigResponse. +enum PolicySource { + POLICY_SOURCE_UNSPECIFIED = 0; + POLICY_SOURCE_SANDBOX = 1; + POLICY_SOURCE_GLOBAL = 2; +} + +// Response containing effective sandbox settings and policy. +message GetSandboxConfigResponse { + // The sandbox policy configuration. + SandboxPolicy policy = 1; + // Current policy version (monotonically increasing per sandbox). + uint32 version = 2; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 3; + // Effective settings resolved for this sandbox, excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty EffectiveSetting.value. + map settings = 4; + // Fingerprint for effective config (policy + settings). Changes when any effective input changes. + uint64 config_revision = 5; + // Source of the policy payload for this response. + PolicySource policy_source = 6; + // When policy_source is GLOBAL, the version of the global policy revision. + // Zero when no global policy is active or when policy_source is SANDBOX. + uint32 global_policy_version = 7; + // Fingerprint for provider credential inputs attached to this sandbox. + // Changes when attached provider names or attached provider records change. + uint64 provider_env_revision = 8; +} diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go new file mode 100644 index 0000000000..405f8c83fa --- /dev/null +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -0,0 +1,1753 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.6 +// source: sandbox.proto + +package sandboxv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Scope that currently controls a setting. +type SettingScope int32 + +const ( + SettingScope_SETTING_SCOPE_UNSPECIFIED SettingScope = 0 + SettingScope_SETTING_SCOPE_SANDBOX SettingScope = 1 + SettingScope_SETTING_SCOPE_GLOBAL SettingScope = 2 +) + +// Enum value maps for SettingScope. +var ( + SettingScope_name = map[int32]string{ + 0: "SETTING_SCOPE_UNSPECIFIED", + 1: "SETTING_SCOPE_SANDBOX", + 2: "SETTING_SCOPE_GLOBAL", + } + SettingScope_value = map[string]int32{ + "SETTING_SCOPE_UNSPECIFIED": 0, + "SETTING_SCOPE_SANDBOX": 1, + "SETTING_SCOPE_GLOBAL": 2, + } +) + +func (x SettingScope) Enum() *SettingScope { + p := new(SettingScope) + *p = x + return p +} + +func (x SettingScope) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SettingScope) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[0].Descriptor() +} + +func (SettingScope) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[0] +} + +func (x SettingScope) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SettingScope.Descriptor instead. +func (SettingScope) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + +// Source used for the policy payload in GetSandboxConfigResponse. +type PolicySource int32 + +const ( + PolicySource_POLICY_SOURCE_UNSPECIFIED PolicySource = 0 + PolicySource_POLICY_SOURCE_SANDBOX PolicySource = 1 + PolicySource_POLICY_SOURCE_GLOBAL PolicySource = 2 +) + +// Enum value maps for PolicySource. +var ( + PolicySource_name = map[int32]string{ + 0: "POLICY_SOURCE_UNSPECIFIED", + 1: "POLICY_SOURCE_SANDBOX", + 2: "POLICY_SOURCE_GLOBAL", + } + PolicySource_value = map[string]int32{ + "POLICY_SOURCE_UNSPECIFIED": 0, + "POLICY_SOURCE_SANDBOX": 1, + "POLICY_SOURCE_GLOBAL": 2, + } +) + +func (x PolicySource) Enum() *PolicySource { + p := new(PolicySource) + *p = x + return p +} + +func (x PolicySource) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PolicySource) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[1].Descriptor() +} + +func (PolicySource) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[1] +} + +func (x PolicySource) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PolicySource.Descriptor instead. +func (PolicySource) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{1} +} + +// Sandbox security policy configuration. +type SandboxPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Policy version. + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + // Filesystem access policy. + Filesystem *FilesystemPolicy `protobuf:"bytes,2,opt,name=filesystem,proto3" json:"filesystem,omitempty"` + // Landlock configuration. + Landlock *LandlockPolicy `protobuf:"bytes,3,opt,name=landlock,proto3" json:"landlock,omitempty"` + // Process execution policy. + Process *ProcessPolicy `protobuf:"bytes,4,opt,name=process,proto3" json:"process,omitempty"` + // Network access policies keyed by name (e.g. "claude_code", "gitlab"). + NetworkPolicies map[string]*NetworkPolicyRule `protobuf:"bytes,5,rep,name=network_policies,json=networkPolicies,proto3" json:"network_policies,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxPolicy) Reset() { + *x = SandboxPolicy{} + mi := &file_sandbox_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxPolicy) ProtoMessage() {} + +func (x *SandboxPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxPolicy.ProtoReflect.Descriptor instead. +func (*SandboxPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + +func (x *SandboxPolicy) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *SandboxPolicy) GetFilesystem() *FilesystemPolicy { + if x != nil { + return x.Filesystem + } + return nil +} + +func (x *SandboxPolicy) GetLandlock() *LandlockPolicy { + if x != nil { + return x.Landlock + } + return nil +} + +func (x *SandboxPolicy) GetProcess() *ProcessPolicy { + if x != nil { + return x.Process + } + return nil +} + +func (x *SandboxPolicy) GetNetworkPolicies() map[string]*NetworkPolicyRule { + if x != nil { + return x.NetworkPolicies + } + return nil +} + +// Filesystem access policy. +type FilesystemPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Automatically include the workdir as read-write. + IncludeWorkdir bool `protobuf:"varint,1,opt,name=include_workdir,json=includeWorkdir,proto3" json:"include_workdir,omitempty"` + // Read-only directory allow list. + ReadOnly []string `protobuf:"bytes,2,rep,name=read_only,json=readOnly,proto3" json:"read_only,omitempty"` + // Read-write directory allow list. + ReadWrite []string `protobuf:"bytes,3,rep,name=read_write,json=readWrite,proto3" json:"read_write,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesystemPolicy) Reset() { + *x = FilesystemPolicy{} + mi := &file_sandbox_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesystemPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesystemPolicy) ProtoMessage() {} + +func (x *FilesystemPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesystemPolicy.ProtoReflect.Descriptor instead. +func (*FilesystemPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{1} +} + +func (x *FilesystemPolicy) GetIncludeWorkdir() bool { + if x != nil { + return x.IncludeWorkdir + } + return false +} + +func (x *FilesystemPolicy) GetReadOnly() []string { + if x != nil { + return x.ReadOnly + } + return nil +} + +func (x *FilesystemPolicy) GetReadWrite() []string { + if x != nil { + return x.ReadWrite + } + return nil +} + +// Landlock policy configuration. +type LandlockPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Compatibility mode (e.g. "best_effort", "hard_requirement"). + Compatibility string `protobuf:"bytes,1,opt,name=compatibility,proto3" json:"compatibility,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LandlockPolicy) Reset() { + *x = LandlockPolicy{} + mi := &file_sandbox_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LandlockPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LandlockPolicy) ProtoMessage() {} + +func (x *LandlockPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LandlockPolicy.ProtoReflect.Descriptor instead. +func (*LandlockPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{2} +} + +func (x *LandlockPolicy) GetCompatibility() string { + if x != nil { + return x.Compatibility + } + return "" +} + +// Process execution policy. +type ProcessPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // User name to run the sandboxed process as. + RunAsUser string `protobuf:"bytes,1,opt,name=run_as_user,json=runAsUser,proto3" json:"run_as_user,omitempty"` + // Group name to run the sandboxed process as. + RunAsGroup string `protobuf:"bytes,2,opt,name=run_as_group,json=runAsGroup,proto3" json:"run_as_group,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessPolicy) Reset() { + *x = ProcessPolicy{} + mi := &file_sandbox_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessPolicy) ProtoMessage() {} + +func (x *ProcessPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessPolicy.ProtoReflect.Descriptor instead. +func (*ProcessPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessPolicy) GetRunAsUser() string { + if x != nil { + return x.RunAsUser + } + return "" +} + +func (x *ProcessPolicy) GetRunAsGroup() string { + if x != nil { + return x.RunAsGroup + } + return "" +} + +// A named network access policy rule. +type NetworkPolicyRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Human-readable name for this policy rule. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Allowed endpoint (host:port) pairs. + Endpoints []*NetworkEndpoint `protobuf:"bytes,2,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + // Allowed binary identities. + Binaries []*NetworkBinary `protobuf:"bytes,3,rep,name=binaries,proto3" json:"binaries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkPolicyRule) Reset() { + *x = NetworkPolicyRule{} + mi := &file_sandbox_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkPolicyRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkPolicyRule) ProtoMessage() {} + +func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkPolicyRule.ProtoReflect.Descriptor instead. +func (*NetworkPolicyRule) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{4} +} + +func (x *NetworkPolicyRule) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *NetworkPolicyRule) GetEndpoints() []*NetworkEndpoint { + if x != nil { + return x.Endpoints + } + return nil +} + +func (x *NetworkPolicyRule) GetBinaries() []*NetworkBinary { + if x != nil { + return x.Binaries + } + return nil +} + +// A network endpoint (host + port) with optional L7 inspection config. +type NetworkEndpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Hostname or host glob pattern. Exact match is case-insensitive. + // Glob patterns use "." as delimiter: "*.example.com" matches a single + // subdomain label, "**.example.com" matches across labels. + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + // Single port (backwards compat). Use `ports` for multiple ports. + // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` + // TLS handling: "terminate" or "passthrough" (default). + Tls string `protobuf:"bytes,4,opt,name=tls,proto3" json:"tls,omitempty"` + // Enforcement mode: "enforce" or "audit" (default). + Enforcement string `protobuf:"bytes,5,opt,name=enforcement,proto3" json:"enforcement,omitempty"` + // Access preset shorthand: "read-only", "read-write", "full". + // Mutually exclusive with rules. + Access string `protobuf:"bytes,6,opt,name=access,proto3" json:"access,omitempty"` + // Explicit L7 rules (mutually exclusive with access). + Rules []*L7Rule `protobuf:"bytes,7,rep,name=rules,proto3" json:"rules,omitempty"` + // Allowed resolved IP addresses or CIDR ranges for this endpoint. + // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: + // - If host is also set: domain must resolve to an IP in this list. + // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // + // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). + // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked + // regardless of this field. + AllowedIps []string `protobuf:"bytes,8,rep,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"` + // Multiple ports. When non-empty, this endpoint covers all listed ports. + // If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`. + // If both are set, `ports` takes precedence. + Ports []uint32 `protobuf:"varint,9,rep,packed,name=ports,proto3" json:"ports,omitempty"` + // Explicit L7 deny rules. When present, requests matching any deny rule + // are blocked even if they match an allow rule or access preset. + // Deny rules take precedence over allow rules. + DenyRules []*L7DenyRule `protobuf:"bytes,10,rep,name=deny_rules,json=denyRules,proto3" json:"deny_rules,omitempty"` + // When true, percent-encoded '/' (%2F) is preserved in path segments + // rather than rejected by the L7 path canonicalizer. Required for + // upstreams like GitLab that embed %2F in namespaced resource paths. + // Defaults to false (strict). + AllowEncodedSlash bool `protobuf:"varint,11,opt,name=allow_encoded_slash,json=allowEncodedSlash,proto3" json:"allow_encoded_slash,omitempty"` + // GraphQL persisted-query behavior for hash-only/saved-query requests: + // "deny" (default) or "allow_registered". + PersistedQueries string `protobuf:"bytes,12,opt,name=persisted_queries,json=persistedQueries,proto3" json:"persisted_queries,omitempty"` + // Trusted GraphQL persisted-query registry keyed by hash or service-specific ID. + // Only used when persisted_queries is "allow_registered". + GraphqlPersistedQueries map[string]*GraphqlOperation `protobuf:"bytes,13,rep,name=graphql_persisted_queries,json=graphqlPersistedQueries,proto3" json:"graphql_persisted_queries,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Maximum GraphQL request body bytes to buffer for inspection. + // Defaults to 65536 when unset. + GraphqlMaxBodyBytes uint32 `protobuf:"varint,14,opt,name=graphql_max_body_bytes,json=graphqlMaxBodyBytes,proto3" json:"graphql_max_body_bytes,omitempty"` + // Optional HTTP path glob that scopes this L7 endpoint on shared host:port APIs. + // Example: use path "/graphql" for protocol "graphql" and "/repos/**" for + // protocol "rest" when both surfaces live under api.example.com:443. + // Empty means all paths. + Path string `protobuf:"bytes,15,opt,name=path,proto3" json:"path,omitempty"` + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside client-to-server WebSocket text messages after an allowed HTTP 101 + // upgrade. Defaults to false. + WebsocketCredentialRewrite bool `protobuf:"varint,16,opt,name=websocket_credential_rewrite,json=websocketCredentialRewrite,proto3" json:"websocket_credential_rewrite,omitempty"` + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside supported textual HTTP request bodies before forwarding upstream. + // Defaults to false. + RequestBodyCredentialRewrite bool `protobuf:"varint,17,opt,name=request_body_credential_rewrite,json=requestBodyCredentialRewrite,proto3" json:"request_body_credential_rewrite,omitempty"` + // Internal provenance marker for policy-advisor generated endpoints. + // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless + // they are converted through an explicit user-authored policy path. + AdvisorProposed bool `protobuf:"varint,18,opt,name=advisor_proposed,json=advisorProposed,proto3" json:"advisor_proposed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkEndpoint) Reset() { + *x = NetworkEndpoint{} + mi := &file_sandbox_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkEndpoint) ProtoMessage() {} + +func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkEndpoint.ProtoReflect.Descriptor instead. +func (*NetworkEndpoint) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{5} +} + +func (x *NetworkEndpoint) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *NetworkEndpoint) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *NetworkEndpoint) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *NetworkEndpoint) GetTls() string { + if x != nil { + return x.Tls + } + return "" +} + +func (x *NetworkEndpoint) GetEnforcement() string { + if x != nil { + return x.Enforcement + } + return "" +} + +func (x *NetworkEndpoint) GetAccess() string { + if x != nil { + return x.Access + } + return "" +} + +func (x *NetworkEndpoint) GetRules() []*L7Rule { + if x != nil { + return x.Rules + } + return nil +} + +func (x *NetworkEndpoint) GetAllowedIps() []string { + if x != nil { + return x.AllowedIps + } + return nil +} + +func (x *NetworkEndpoint) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *NetworkEndpoint) GetDenyRules() []*L7DenyRule { + if x != nil { + return x.DenyRules + } + return nil +} + +func (x *NetworkEndpoint) GetAllowEncodedSlash() bool { + if x != nil { + return x.AllowEncodedSlash + } + return false +} + +func (x *NetworkEndpoint) GetPersistedQueries() string { + if x != nil { + return x.PersistedQueries + } + return "" +} + +func (x *NetworkEndpoint) GetGraphqlPersistedQueries() map[string]*GraphqlOperation { + if x != nil { + return x.GraphqlPersistedQueries + } + return nil +} + +func (x *NetworkEndpoint) GetGraphqlMaxBodyBytes() uint32 { + if x != nil { + return x.GraphqlMaxBodyBytes + } + return 0 +} + +func (x *NetworkEndpoint) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *NetworkEndpoint) GetWebsocketCredentialRewrite() bool { + if x != nil { + return x.WebsocketCredentialRewrite + } + return false +} + +func (x *NetworkEndpoint) GetRequestBodyCredentialRewrite() bool { + if x != nil { + return x.RequestBodyCredentialRewrite + } + return false +} + +func (x *NetworkEndpoint) GetAdvisorProposed() bool { + if x != nil { + return x.AdvisorProposed + } + return false +} + +// Trusted GraphQL operation classification. +type GraphqlOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Operation type: "query", "mutation", or "subscription". + OperationType string `protobuf:"bytes,1,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + // Operation name, if known. + OperationName string `protobuf:"bytes,2,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // Root field names selected by the operation. + Fields []string `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GraphqlOperation) Reset() { + *x = GraphqlOperation{} + mi := &file_sandbox_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GraphqlOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GraphqlOperation) ProtoMessage() {} + +func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GraphqlOperation.ProtoReflect.Descriptor instead. +func (*GraphqlOperation) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{6} +} + +func (x *GraphqlOperation) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *GraphqlOperation) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *GraphqlOperation) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +// An L7 deny rule that blocks specific requests. +// Mirrors L7Allow — same fields, same matching semantics, inverted effect. +// Deny rules are evaluated after allow rules and take precedence. +type L7DenyRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HTTP method (REST): GET, POST, etc. or "*" for any. + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + // URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any. + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + // Query parameter matcher map (REST). + // Same semantics as L7Allow.query. + Query map[string]*L7QueryMatcher `protobuf:"bytes,4,rep,name=query,proto3" json:"query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + // GraphQL operation name glob. "*" matches any operation name. + OperationName string `protobuf:"bytes,6,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // GraphQL root field globs. Deny rules match when any selected root field + // matches any configured glob. + Fields []string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7DenyRule) Reset() { + *x = L7DenyRule{} + mi := &file_sandbox_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7DenyRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7DenyRule) ProtoMessage() {} + +func (x *L7DenyRule) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7DenyRule.ProtoReflect.Descriptor instead. +func (*L7DenyRule) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{7} +} + +func (x *L7DenyRule) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7DenyRule) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7DenyRule) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *L7DenyRule) GetQuery() map[string]*L7QueryMatcher { + if x != nil { + return x.Query + } + return nil +} + +func (x *L7DenyRule) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *L7DenyRule) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *L7DenyRule) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +// An L7 policy rule (allow-only). +type L7Rule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allow *L7Allow `protobuf:"bytes,1,opt,name=allow,proto3" json:"allow,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7Rule) Reset() { + *x = L7Rule{} + mi := &file_sandbox_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7Rule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7Rule) ProtoMessage() {} + +func (x *L7Rule) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7Rule.ProtoReflect.Descriptor instead. +func (*L7Rule) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{8} +} + +func (x *L7Rule) GetAllow() *L7Allow { + if x != nil { + return x.Allow + } + return nil +} + +// Allowed action definition for L7 rules. +type L7Allow struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HTTP method (REST): GET, POST, etc. or "*" for any. + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + // URL path glob pattern (REST): "/repos/**", "**" for any. + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + // Query parameter matcher map (REST). + // Key is the decoded query parameter name (case-sensitive). + // Value supports either a single glob (`glob`) or a list (`any`). + Query map[string]*L7QueryMatcher `protobuf:"bytes,4,rep,name=query,proto3" json:"query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + // GraphQL operation name glob. "*" matches any operation name. + OperationName string `protobuf:"bytes,6,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // GraphQL root field globs. Allow rules match only when every selected root + // field matches one of the configured globs. Omit to match all fields. + Fields []string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7Allow) Reset() { + *x = L7Allow{} + mi := &file_sandbox_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7Allow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7Allow) ProtoMessage() {} + +func (x *L7Allow) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7Allow.ProtoReflect.Descriptor instead. +func (*L7Allow) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{9} +} + +func (x *L7Allow) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7Allow) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7Allow) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *L7Allow) GetQuery() map[string]*L7QueryMatcher { + if x != nil { + return x.Query + } + return nil +} + +func (x *L7Allow) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *L7Allow) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *L7Allow) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +// Query value matcher for one query parameter key. +type L7QueryMatcher struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Single glob pattern. + Glob string `protobuf:"bytes,1,opt,name=glob,proto3" json:"glob,omitempty"` + // Any-of glob patterns. + Any []string `protobuf:"bytes,2,rep,name=any,proto3" json:"any,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7QueryMatcher) Reset() { + *x = L7QueryMatcher{} + mi := &file_sandbox_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7QueryMatcher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7QueryMatcher) ProtoMessage() {} + +func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7QueryMatcher.ProtoReflect.Descriptor instead. +func (*L7QueryMatcher) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{10} +} + +func (x *L7QueryMatcher) GetGlob() string { + if x != nil { + return x.Glob + } + return "" +} + +func (x *L7QueryMatcher) GetAny() []string { + if x != nil { + return x.Any + } + return nil +} + +// A binary identity for network policy matching. +type NetworkBinary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // Deprecated: the harness concept has been removed. This field is ignored. + // + // Deprecated: Marked as deprecated in sandbox.proto. + Harness bool `protobuf:"varint,2,opt,name=harness,proto3" json:"harness,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkBinary) Reset() { + *x = NetworkBinary{} + mi := &file_sandbox_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkBinary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkBinary) ProtoMessage() {} + +func (x *NetworkBinary) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkBinary.ProtoReflect.Descriptor instead. +func (*NetworkBinary) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{11} +} + +func (x *NetworkBinary) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +// Deprecated: Marked as deprecated in sandbox.proto. +func (x *NetworkBinary) GetHarness() bool { + if x != nil { + return x.Harness + } + return false +} + +// Request to get sandbox settings by sandbox ID. +type GetSandboxConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox ID. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxConfigRequest) Reset() { + *x = GetSandboxConfigRequest{} + mi := &file_sandbox_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxConfigRequest) ProtoMessage() {} + +func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxConfigRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxConfigRequest) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{12} +} + +func (x *GetSandboxConfigRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +// Request to get gateway-global settings. +type GetGatewayConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetGatewayConfigRequest) Reset() { + *x = GetGatewayConfigRequest{} + mi := &file_sandbox_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetGatewayConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetGatewayConfigRequest) ProtoMessage() {} + +func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetGatewayConfigRequest.ProtoReflect.Descriptor instead. +func (*GetGatewayConfigRequest) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{13} +} + +// Response containing gateway-global settings. +type GetGatewayConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-global settings map excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty SettingValue. + Settings map[string]*SettingValue `protobuf:"bytes,1,rep,name=settings,proto3" json:"settings,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Monotonically increasing revision for gateway-global settings. + SettingsRevision uint64 `protobuf:"varint,2,opt,name=settings_revision,json=settingsRevision,proto3" json:"settings_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetGatewayConfigResponse) Reset() { + *x = GetGatewayConfigResponse{} + mi := &file_sandbox_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetGatewayConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetGatewayConfigResponse) ProtoMessage() {} + +func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetGatewayConfigResponse.ProtoReflect.Descriptor instead. +func (*GetGatewayConfigResponse) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{14} +} + +func (x *GetGatewayConfigResponse) GetSettings() map[string]*SettingValue { + if x != nil { + return x.Settings + } + return nil +} + +func (x *GetGatewayConfigResponse) GetSettingsRevision() uint64 { + if x != nil { + return x.SettingsRevision + } + return 0 +} + +// Type-aware setting value for sandbox/gateway settings. +type SettingValue struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *SettingValue_StringValue + // *SettingValue_BoolValue + // *SettingValue_IntValue + // *SettingValue_BytesValue + Value isSettingValue_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SettingValue) Reset() { + *x = SettingValue{} + mi := &file_sandbox_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SettingValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SettingValue) ProtoMessage() {} + +func (x *SettingValue) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SettingValue.ProtoReflect.Descriptor instead. +func (*SettingValue) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{15} +} + +func (x *SettingValue) GetValue() isSettingValue_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *SettingValue) GetStringValue() string { + if x != nil { + if x, ok := x.Value.(*SettingValue_StringValue); ok { + return x.StringValue + } + } + return "" +} + +func (x *SettingValue) GetBoolValue() bool { + if x != nil { + if x, ok := x.Value.(*SettingValue_BoolValue); ok { + return x.BoolValue + } + } + return false +} + +func (x *SettingValue) GetIntValue() int64 { + if x != nil { + if x, ok := x.Value.(*SettingValue_IntValue); ok { + return x.IntValue + } + } + return 0 +} + +func (x *SettingValue) GetBytesValue() []byte { + if x != nil { + if x, ok := x.Value.(*SettingValue_BytesValue); ok { + return x.BytesValue + } + } + return nil +} + +type isSettingValue_Value interface { + isSettingValue_Value() +} + +type SettingValue_StringValue struct { + StringValue string `protobuf:"bytes,1,opt,name=string_value,json=stringValue,proto3,oneof"` +} + +type SettingValue_BoolValue struct { + BoolValue bool `protobuf:"varint,2,opt,name=bool_value,json=boolValue,proto3,oneof"` +} + +type SettingValue_IntValue struct { + IntValue int64 `protobuf:"varint,3,opt,name=int_value,json=intValue,proto3,oneof"` +} + +type SettingValue_BytesValue struct { + BytesValue []byte `protobuf:"bytes,4,opt,name=bytes_value,json=bytesValue,proto3,oneof"` +} + +func (*SettingValue_StringValue) isSettingValue_Value() {} + +func (*SettingValue_BoolValue) isSettingValue_Value() {} + +func (*SettingValue_IntValue) isSettingValue_Value() {} + +func (*SettingValue_BytesValue) isSettingValue_Value() {} + +// Effective setting value and the scope it was resolved from. +type EffectiveSetting struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value *SettingValue `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Scope SettingScope `protobuf:"varint,2,opt,name=scope,proto3,enum=openshell.sandbox.v1.SettingScope" json:"scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EffectiveSetting) Reset() { + *x = EffectiveSetting{} + mi := &file_sandbox_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EffectiveSetting) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EffectiveSetting) ProtoMessage() {} + +func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EffectiveSetting.ProtoReflect.Descriptor instead. +func (*EffectiveSetting) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{16} +} + +func (x *EffectiveSetting) GetValue() *SettingValue { + if x != nil { + return x.Value + } + return nil +} + +func (x *EffectiveSetting) GetScope() SettingScope { + if x != nil { + return x.Scope + } + return SettingScope_SETTING_SCOPE_UNSPECIFIED +} + +// Response containing effective sandbox settings and policy. +type GetSandboxConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox policy configuration. + Policy *SandboxPolicy `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` + // Current policy version (monotonically increasing per sandbox). + Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + // SHA-256 hash of the serialized policy payload. + PolicyHash string `protobuf:"bytes,3,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Effective settings resolved for this sandbox, excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty EffectiveSetting.value. + Settings map[string]*EffectiveSetting `protobuf:"bytes,4,rep,name=settings,proto3" json:"settings,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Fingerprint for effective config (policy + settings). Changes when any effective input changes. + ConfigRevision uint64 `protobuf:"varint,5,opt,name=config_revision,json=configRevision,proto3" json:"config_revision,omitempty"` + // Source of the policy payload for this response. + PolicySource PolicySource `protobuf:"varint,6,opt,name=policy_source,json=policySource,proto3,enum=openshell.sandbox.v1.PolicySource" json:"policy_source,omitempty"` + // When policy_source is GLOBAL, the version of the global policy revision. + // Zero when no global policy is active or when policy_source is SANDBOX. + GlobalPolicyVersion uint32 `protobuf:"varint,7,opt,name=global_policy_version,json=globalPolicyVersion,proto3" json:"global_policy_version,omitempty"` + // Fingerprint for provider credential inputs attached to this sandbox. + // Changes when attached provider names or attached provider records change. + ProviderEnvRevision uint64 `protobuf:"varint,8,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxConfigResponse) Reset() { + *x = GetSandboxConfigResponse{} + mi := &file_sandbox_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxConfigResponse) ProtoMessage() {} + +func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSandboxConfigResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{17} +} + +func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *GetSandboxConfigResponse) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *GetSandboxConfigResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *GetSandboxConfigResponse) GetSettings() map[string]*EffectiveSetting { + if x != nil { + return x.Settings + } + return nil +} + +func (x *GetSandboxConfigResponse) GetConfigRevision() uint64 { + if x != nil { + return x.ConfigRevision + } + return 0 +} + +func (x *GetSandboxConfigResponse) GetPolicySource() PolicySource { + if x != nil { + return x.PolicySource + } + return PolicySource_POLICY_SOURCE_UNSPECIFIED +} + +func (x *GetSandboxConfigResponse) GetGlobalPolicyVersion() uint32 { + if x != nil { + return x.GlobalPolicyVersion + } + return 0 +} + +func (x *GetSandboxConfigResponse) GetProviderEnvRevision() uint64 { + if x != nil { + return x.ProviderEnvRevision + } + return 0 +} + +var File_sandbox_proto protoreflect.FileDescriptor + +const file_sandbox_proto_rawDesc = "" + + "\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\"\xc4\x03\n" + + "\rSandboxPolicy\x12\x18\n" + + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + + "\n" + + "filesystem\x18\x02 \x01(\v2&.openshell.sandbox.v1.FilesystemPolicyR\n" + + "filesystem\x12@\n" + + "\blandlock\x18\x03 \x01(\v2$.openshell.sandbox.v1.LandlockPolicyR\blandlock\x12=\n" + + "\aprocess\x18\x04 \x01(\v2#.openshell.sandbox.v1.ProcessPolicyR\aprocess\x12c\n" + + "\x10network_policies\x18\x05 \x03(\v28.openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x1ak\n" + + "\x14NetworkPoliciesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\"w\n" + + "\x10FilesystemPolicy\x12'\n" + + "\x0finclude_workdir\x18\x01 \x01(\bR\x0eincludeWorkdir\x12\x1b\n" + + "\tread_only\x18\x02 \x03(\tR\breadOnly\x12\x1d\n" + + "\n" + + "read_write\x18\x03 \x03(\tR\treadWrite\"6\n" + + "\x0eLandlockPolicy\x12$\n" + + "\rcompatibility\x18\x01 \x01(\tR\rcompatibility\"Q\n" + + "\rProcessPolicy\x12\x1e\n" + + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + + "\frun_as_group\x18\x02 \x01(\tR\n" + + "runAsGroup\"\xad\x01\n" + + "\x11NetworkPolicyRule\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12C\n" + + "\tendpoints\x18\x02 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + + "\bbinaries\x18\x03 \x03(\v2#.openshell.sandbox.v1.NetworkBinaryR\bbinaries\"\x9b\a\n" + + "\x0fNetworkEndpoint\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12\x1a\n" + + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12\x10\n" + + "\x03tls\x18\x04 \x01(\tR\x03tls\x12 \n" + + "\venforcement\x18\x05 \x01(\tR\venforcement\x12\x16\n" + + "\x06access\x18\x06 \x01(\tR\x06access\x122\n" + + "\x05rules\x18\a \x03(\v2\x1c.openshell.sandbox.v1.L7RuleR\x05rules\x12\x1f\n" + + "\vallowed_ips\x18\b \x03(\tR\n" + + "allowedIps\x12\x14\n" + + "\x05ports\x18\t \x03(\rR\x05ports\x12?\n" + + "\n" + + "deny_rules\x18\n" + + " \x03(\v2 .openshell.sandbox.v1.L7DenyRuleR\tdenyRules\x12.\n" + + "\x13allow_encoded_slash\x18\v \x01(\bR\x11allowEncodedSlash\x12+\n" + + "\x11persisted_queries\x18\f \x01(\tR\x10persistedQueries\x12~\n" + + "\x19graphql_persisted_queries\x18\r \x03(\v2B.openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntryR\x17graphqlPersistedQueries\x123\n" + + "\x16graphql_max_body_bytes\x18\x0e \x01(\rR\x13graphqlMaxBodyBytes\x12\x12\n" + + "\x04path\x18\x0f \x01(\tR\x04path\x12@\n" + + "\x1cwebsocket_credential_rewrite\x18\x10 \x01(\bR\x1awebsocketCredentialRewrite\x12E\n" + + "\x1frequest_body_credential_rewrite\x18\x11 \x01(\bR\x1crequestBodyCredentialRewrite\x12)\n" + + "\x10advisor_proposed\x18\x12 \x01(\bR\x0fadvisorProposed\x1ar\n" + + "\x1cGraphqlPersistedQueriesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.GraphqlOperationR\x05value:\x028\x01\"x\n" + + "\x10GraphqlOperation\x12%\n" + + "\x0eoperation_type\x18\x01 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x02 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\x03 \x03(\tR\x06fields\"\xdb\x02\n" + + "\n" + + "L7DenyRule\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + + "\acommand\x18\x03 \x01(\tR\acommand\x12A\n" + + "\x05query\x18\x04 \x03(\v2+.openshell.sandbox.v1.L7DenyRule.QueryEntryR\x05query\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x06 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\a \x03(\tR\x06fields\x1a^\n" + + "\n" + + "QueryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12:\n" + + "\x05value\x18\x02 \x01(\v2$.openshell.sandbox.v1.L7QueryMatcherR\x05value:\x028\x01\"=\n" + + "\x06L7Rule\x123\n" + + "\x05allow\x18\x01 \x01(\v2\x1d.openshell.sandbox.v1.L7AllowR\x05allow\"\xd5\x02\n" + + "\aL7Allow\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + + "\acommand\x18\x03 \x01(\tR\acommand\x12>\n" + + "\x05query\x18\x04 \x03(\v2(.openshell.sandbox.v1.L7Allow.QueryEntryR\x05query\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x06 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\a \x03(\tR\x06fields\x1a^\n" + + "\n" + + "QueryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12:\n" + + "\x05value\x18\x02 \x01(\v2$.openshell.sandbox.v1.L7QueryMatcherR\x05value:\x028\x01\"6\n" + + "\x0eL7QueryMatcher\x12\x12\n" + + "\x04glob\x18\x01 \x01(\tR\x04glob\x12\x10\n" + + "\x03any\x18\x02 \x03(\tR\x03any\"A\n" + + "\rNetworkBinary\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\aharness\x18\x02 \x01(\bB\x02\x18\x01R\aharness\"8\n" + + "\x17GetSandboxConfigRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x19\n" + + "\x17GetGatewayConfigRequest\"\x82\x02\n" + + "\x18GetGatewayConfigResponse\x12X\n" + + "\bsettings\x18\x01 \x03(\v2<.openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntryR\bsettings\x12+\n" + + "\x11settings_revision\x18\x02 \x01(\x04R\x10settingsRevision\x1a_\n" + + "\rSettingsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x128\n" + + "\x05value\x18\x02 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value:\x028\x01\"\x9f\x01\n" + + "\fSettingValue\x12#\n" + + "\fstring_value\x18\x01 \x01(\tH\x00R\vstringValue\x12\x1f\n" + + "\n" + + "bool_value\x18\x02 \x01(\bH\x00R\tboolValue\x12\x1d\n" + + "\tint_value\x18\x03 \x01(\x03H\x00R\bintValue\x12!\n" + + "\vbytes_value\x18\x04 \x01(\fH\x00R\n" + + "bytesValueB\a\n" + + "\x05value\"\x86\x01\n" + + "\x10EffectiveSetting\x128\n" + + "\x05value\x18\x01 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value\x128\n" + + "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xab\x04\n" + + "\x18GetSandboxConfigResponse\x12;\n" + + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" + + "\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" + + "\vpolicy_hash\x18\x03 \x01(\tR\n" + + "policyHash\x12X\n" + + "\bsettings\x18\x04 \x03(\v2<.openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntryR\bsettings\x12'\n" + + "\x0fconfig_revision\x18\x05 \x01(\x04R\x0econfigRevision\x12G\n" + + "\rpolicy_source\x18\x06 \x01(\x0e2\".openshell.sandbox.v1.PolicySourceR\fpolicySource\x122\n" + + "\x15global_policy_version\x18\a \x01(\rR\x13globalPolicyVersion\x122\n" + + "\x15provider_env_revision\x18\b \x01(\x04R\x13providerEnvRevision\x1ac\n" + + "\rSettingsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01*b\n" + + "\fSettingScope\x12\x1d\n" + + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" + + "\x14SETTING_SCOPE_GLOBAL\x10\x02*b\n" + + "\fPolicySource\x12\x1d\n" + + "\x19POLICY_SOURCE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15POLICY_SOURCE_SANDBOX\x10\x01\x12\x18\n" + + "\x14POLICY_SOURCE_GLOBAL\x10\x02b\x06proto3" + +var ( + file_sandbox_proto_rawDescOnce sync.Once + file_sandbox_proto_rawDescData []byte +) + +func file_sandbox_proto_rawDescGZIP() []byte { + file_sandbox_proto_rawDescOnce.Do(func() { + file_sandbox_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc))) + }) + return file_sandbox_proto_rawDescData +} + +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_sandbox_proto_goTypes = []any{ + (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy + (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkEndpoint)(nil), // 7: openshell.sandbox.v1.NetworkEndpoint + (*GraphqlOperation)(nil), // 8: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 9: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 10: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 11: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 12: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 13: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 14: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 15: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 16: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 17: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 18: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 19: openshell.sandbox.v1.GetSandboxConfigResponse + nil, // 20: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 21: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 22: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 23: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 24: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry +} +var file_sandbox_proto_depIdxs = []int32{ + 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 4, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 5, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 20, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 7, // 4: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 13, // 5: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 10, // 6: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 9, // 7: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 21, // 8: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 22, // 9: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 11, // 10: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 23, // 11: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 24, // 12: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 17, // 13: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 0, // 14: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 2, // 15: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 25, // 16: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 1, // 17: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 6, // 18: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 8, // 19: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 12, // 20: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 12, // 21: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 17, // 22: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 18, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 24, // [24:24] is the sub-list for method output_type + 24, // [24:24] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name +} + +func init() { file_sandbox_proto_init() } +func file_sandbox_proto_init() { + if File_sandbox_proto != nil { + return + } + file_sandbox_proto_msgTypes[15].OneofWrappers = []any{ + (*SettingValue_StringValue)(nil), + (*SettingValue_BoolValue)(nil), + (*SettingValue_IntValue)(nil), + (*SettingValue_BytesValue)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), + NumEnums: 2, + NumMessages: 24, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_sandbox_proto_goTypes, + DependencyIndexes: file_sandbox_proto_depIdxs, + EnumInfos: file_sandbox_proto_enumTypes, + MessageInfos: file_sandbox_proto_msgTypes, + }.Build() + File_sandbox_proto = out.File + file_sandbox_proto_goTypes = nil + file_sandbox_proto_depIdxs = nil +} diff --git a/tasks/go.toml b/tasks/go.toml new file mode 100644 index 0000000000..b92155cfc0 --- /dev/null +++ b/tasks/go.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[tasks."go:proto"] +description = "Regenerate Go protobuf bindings" +dir = "sdk/go" +run = "mise run proto:gen" + +[tasks."go:proto:check"] +description = "Verify generated Go protobuf files are up to date" +dir = "sdk/go" +run = "mise run proto:check" + +[tasks."go:lint"] +description = "Run Go linter" +dir = "sdk/go" +run = "mise run lint" + +[tasks."go:build"] +description = "Build Go SDK packages" +dir = "sdk/go" +run = "mise run build" + +[tasks."go:test"] +description = "Run Go SDK tests" +dir = "sdk/go" +run = "mise run test"