From e71589a669cd64e57977d234d54abd7bf0e624d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sun, 9 Aug 2026 08:10:43 +0200 Subject: [PATCH 1/6] docs: add edge and fake package API documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs:check CI step was failing with 4 missing documentation gaps for the edge and fake packages. Add API docs pages and SUMMARY.md entries to get CI green. Assisted-By: 🤖 Claude Code --- docs/src/SUMMARY.md | 2 + docs/src/api/edge.md | 91 +++++++++++++++++++++++++++++++++++ docs/src/api/fake.md | 112 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 docs/src/api/edge.md create mode 100644 docs/src/api/fake.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 81eaab9..7a0915b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -28,6 +28,8 @@ - [Policy](api/policy.md) - [Gateway](api/gateway.md) - [OIDC](api/oidc.md) +- [Edge](api/edge.md) +- [Fake](api/fake.md) # Guides diff --git a/docs/src/api/edge.md b/docs/src/api/edge.md new file mode 100644 index 0000000..3ee72aa --- /dev/null +++ b/docs/src/api/edge.md @@ -0,0 +1,91 @@ +# Edge + +Package: `openshell/v1/edge` + +The edge package provides utilities for connecting to OpenShell gateways +through edge proxies such as Cloudflare Access. It includes auth wrappers +for edge proxy headers and a WebSocket tunnel proxy for gRPC transport +through HTTP/1.1-only proxies. + +## Cloudflare Access + +Wrap any `AuthProvider` with Cloudflare Access headers +(`cf-access-jwt-assertion` and `CF_Authorization` cookie): + +```go +import "github.com/rhuss/openshell-sdk-go/openshell/v1/edge" + +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, +}) +``` + +CloudflareAccess composes with any auth provider, including `RefreshableToken` +for automatic token refresh: + +```go +tokenSource := oauth2Config.TokenSource(ctx, initialToken) +refreshAuth, err := v1.RefreshableToken(tokenSource) +if err != nil { + log.Fatal(err) +} +auth, err := edge.CloudflareAccess(refreshAuth, cfToken) +``` + +## 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. + +```go +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 +}) +``` + +## Functions + +| Function | Description | +|----------|-------------| +| `CloudflareAccess(base, edgeToken)` | Wrap an AuthProvider with Cloudflare Access headers | +| `NewTunnelProxy(url, edgeToken, opts...)` | Create a WebSocket tunnel proxy for gRPC-over-HTTP/1.1 | + +## TunnelProxy Methods + +| Method | Description | +|--------|-------------| +| `Addr()` | Local listener address for gRPC client to dial | +| `Close()` | Gracefully drain in-flight connections and shut down | + +## TunnelOption + +| Constructor | Effect | +|-------------|--------| +| `WithTunnelTLS(cfg)` | Configure TLS for the WebSocket connection | +| `WithTunnelLogger(l)` | Set a logger for tunnel events | +| `WithCloseTimeout(d)` | Override the graceful shutdown timeout (default 5s) | + +## Thread Safety + +All exported functions and methods are safe for concurrent use. +`Close` is idempotent and safe to call multiple times. diff --git a/docs/src/api/fake.md b/docs/src/api/fake.md new file mode 100644 index 0000000..95e25d1 --- /dev/null +++ b/docs/src/api/fake.md @@ -0,0 +1,112 @@ +# Fake + +Package: `openshell/v1/fake` + +The fake package provides an in-memory fake implementation of all SDK +client interfaces for use in consumer test suites. It follows the +`client-go/kubernetes/fake` pattern: in-memory stores, watch event +broadcasting, and matching `StatusError` codes for equivalent error +conditions (`NotFound`, `AlreadyExists`, `Unavailable`, `Unimplemented`). + +## Quick Start + +```go +import "github.com/rhuss/openshell-sdk-go/openshell/v1/fake" + +func TestSandboxLifecycle(t *testing.T) { + client := fake.NewClient() + defer client.Close() + + ctx := context.Background() + + sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + require.NoError(t, err) + assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) + + sb, err = client.Sandboxes().WaitReady(ctx, "default", "my-sandbox") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) + + require.NoError(t, client.Sandboxes().Delete(ctx, "default", "my-sandbox")) +} +``` + +## Creating a Client + +```go +func NewClient(opts ...ClientOption) *Client +``` + +Returns a fake client implementing `v1.ClientInterface` with all +sub-clients wired up. Default health result is healthy. Use options +to customize initial state: + +```go +client := fake.NewClient( + fake.WithHealthResult(&types.HealthResult{Healthy: false}), + fake.WithCurrentUser(&types.CurrentUser{Subject: "test-user"}), + fake.WithGatewayInfo(&types.GatewayInfo{Version: "1.0.0"}), +) +``` + +## Pre-populating State + +Seed objects directly into the fake stores for test setup: + +```go +client := fake.NewClient() + +client.AddSandbox("default", &types.Sandbox{ + Name: "pre-existing", + Status: types.SandboxStatus{Phase: types.SandboxReady}, +}) + +client.AddProvider("default", &types.Provider{ + Name: "my-provider", + Spec: types.ProviderSpec{Type: "docker"}, +}) + +client.AddWorkspace(&types.Workspace{Name: "staging"}) +client.AddMember("staging", &types.WorkspaceMember{ + PrincipalSubject: "user@example.com", + Role: types.WorkspaceRoleAdmin, +}) +``` + +All `Add*` methods deep-copy their arguments; mutating the input after +insertion does not affect the stored object. + +## Sub-Client Coverage + +The fake client implements every interface in `v1.ClientInterface`: + +| Accessor | Interface | Behavior | +|----------|-----------|----------| +| `Sandboxes()` | `SandboxInterface` | Full CRUD, Watch, WaitReady | +| `Providers()` | `ProviderInterface` | Full CRUD, Ensure | +| `Workspaces()` | `WorkspaceInterface` | Full CRUD, Members | +| `Health()` | `HealthInterface` | Configurable result | +| `Inference()` | `InferenceInterface` | Route CRUD | +| `Policy()` | `PolicyInterface` | List, GetStatus (draft ops return Unimplemented) | +| `Exec()` | `ExecInterface` | Returns Unimplemented | +| `Files()` | `FileInterface` | Returns Unimplemented | +| `Services()` | `ServiceInterface` | Returns Unimplemented | +| `SSH()` | `SSHInterface` | Input validation, then Unimplemented | +| `TCP()` | `TCPInterface` | Input validation, then Unimplemented | +| `Config()` | `ConfigInterface` | Returns Unimplemented | + +## ClientOption + +| Constructor | Effect | +|-------------|--------| +| `WithHealthResult(r)` | Set the health check return value | +| `WithCurrentUser(u)` | Set the current user return value | +| `WithGatewayInfo(i)` | Set the gateway info return value | + +## Thread Safety + +All operations are safe for concurrent use from multiple goroutines. +`Close` is idempotent and causes all subsequent operations to return +`Unavailable`. + +See also: [Testing Guide](../testing.md) From 6d54bf0253b6ae07e3bf57e851a2ef65376c1ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sun, 9 Aug 2026 08:10:54 +0200 Subject: [PATCH 2/6] feat: close proto gaps for profile, credential, and refresh types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add previously unmapped proto fields to SDK domain types: - ProviderProfile: annotations, source, scope - ProfileCredential: env_vars, auth_style, header_name, query_param, path_template, token_grant (with CredentialTokenGrant sub-types) - RefreshStrategy: add AWSStsAssumeRole enum value All new fields include deep-copy at proto/SDK boundaries and coverage tests using proto reflection to catch future drift. Assisted-By: 🤖 Claude Code --- .../v1/internal/converter/coverage_test.go | 78 +++++++ openshell/v1/internal/converter/profile.go | 104 ++++++++- .../v1/internal/converter/profile_test.go | 203 +++++++++++++++++- openshell/v1/internal/converter/refresh.go | 4 + .../v1/internal/converter/refresh_test.go | 2 + openshell/v1/types/profile.go | 37 +++- openshell/v1/types/refresh.go | 1 + 7 files changed, 409 insertions(+), 20 deletions(-) diff --git a/openshell/v1/internal/converter/coverage_test.go b/openshell/v1/internal/converter/coverage_test.go index aa380da..5099bad 100644 --- a/openshell/v1/internal/converter/coverage_test.go +++ b/openshell/v1/internal/converter/coverage_test.go @@ -195,6 +195,84 @@ func TestConverterCoversAllProtoFields_CredentialHandle(t *testing.T) { assertAllFieldsCovered(t, (&dm.CredentialHandle{}).ProtoReflect().Descriptor(), handled, nil) } +func TestConverterCoversAllProtoFields_SandboxPolicyRevision(t *testing.T) { + handled := fieldSet{ + "version": true, + "policy_hash": true, + "status": true, + "load_error": true, + "created_at_ms": true, + "loaded_at_ms": true, + "policy": true, + "provenance": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxPolicyRevision{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderProfile(t *testing.T) { + handled := fieldSet{ + "id": true, + "display_name": true, + "description": true, + "category": true, + "credentials": true, + "endpoints": true, + "binaries": true, + "inference_capable": true, + "discovery": true, + "resource_version": true, + "annotations": true, + "source": true, + "scope": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderProfile{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderProfileCredential(t *testing.T) { + handled := fieldSet{ + "name": true, + "description": true, + "env_vars": true, + "required": true, + "auth_style": true, + "header_name": true, + "query_param": true, + "refresh": true, + "path_template": true, + "token_grant": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderProfileCredential{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderCredentialTokenGrant(t *testing.T) { + handled := fieldSet{ + "token_endpoint": true, + "audience": true, + "jwt_svid_audience": true, + "scopes": true, + "cache_ttl_seconds": true, + "audience_overrides": true, + "client_assertion_type": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderCredentialTokenGrant{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderCredentialTokenGrantAudienceOverride(t *testing.T) { + handled := fieldSet{ + "host": true, + "port": true, + "path": true, + "audience": true, + "scopes": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderCredentialTokenGrantAudienceOverride{}).ProtoReflect().Descriptor(), handled, nil) +} + func TestConverterCoversAllProtoFields_McpOptions(t *testing.T) { handled := fieldSet{ "strict_tool_names": true, diff --git a/openshell/v1/internal/converter/profile.go b/openshell/v1/internal/converter/profile.go index 2573fc3..822307e 100644 --- a/openshell/v1/internal/converter/profile.go +++ b/openshell/v1/internal/converter/profile.go @@ -107,31 +107,109 @@ func NetworkBinaryToProto(b *types.NetworkBinary) *sbv1.NetworkBinary { // --- 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, + Name: c.GetName(), + Description: c.GetDescription(), + EnvVars: CopyStringSlice(c.GetEnvVars()), + Required: c.GetRequired(), + Secret: c.GetRefresh() != nil, + AuthStyle: c.GetAuthStyle(), + HeaderName: c.GetHeaderName(), + QueryParam: c.GetQueryParam(), + PathTemplate: c.GetPathTemplate(), + TokenGrant: tokenGrantFromProto(c.GetTokenGrant()), } } // 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. +// 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, + Name: c.Name, + Description: c.Description, + EnvVars: CopyStringSlice(c.EnvVars), + Required: c.Required, + AuthStyle: c.AuthStyle, + HeaderName: c.HeaderName, + QueryParam: c.QueryParam, + PathTemplate: c.PathTemplate, + TokenGrant: tokenGrantToProto(c.TokenGrant), + } +} + +func tokenGrantFromProto(tg *pb.ProviderCredentialTokenGrant) *types.CredentialTokenGrant { + if tg == nil { + return nil + } + result := &types.CredentialTokenGrant{ + TokenEndpoint: tg.GetTokenEndpoint(), + Audience: tg.GetAudience(), + JWTSVIDAudience: tg.GetJwtSvidAudience(), + Scopes: CopyStringSlice(tg.GetScopes()), + CacheTTLSeconds: tg.GetCacheTtlSeconds(), + ClientAssertionType: tg.GetClientAssertionType(), + } + if overrides := tg.GetAudienceOverrides(); len(overrides) > 0 { + result.AudienceOverrides = make([]types.TokenGrantAudienceOverride, len(overrides)) + for i, o := range overrides { + result.AudienceOverrides[i] = audienceOverrideFromProto(o) + } + } + return result +} + +func tokenGrantToProto(tg *types.CredentialTokenGrant) *pb.ProviderCredentialTokenGrant { + if tg == nil { + return nil + } + result := &pb.ProviderCredentialTokenGrant{ + TokenEndpoint: tg.TokenEndpoint, + Audience: tg.Audience, + JwtSvidAudience: tg.JWTSVIDAudience, + Scopes: CopyStringSlice(tg.Scopes), + CacheTtlSeconds: tg.CacheTTLSeconds, + ClientAssertionType: tg.ClientAssertionType, + } + if len(tg.AudienceOverrides) > 0 { + result.AudienceOverrides = make([]*pb.ProviderCredentialTokenGrantAudienceOverride, len(tg.AudienceOverrides)) + for i := range tg.AudienceOverrides { + result.AudienceOverrides[i] = audienceOverrideToProto(&tg.AudienceOverrides[i]) + } + } + return result +} + +func audienceOverrideFromProto(o *pb.ProviderCredentialTokenGrantAudienceOverride) types.TokenGrantAudienceOverride { + if o == nil { + return types.TokenGrantAudienceOverride{} + } + return types.TokenGrantAudienceOverride{ + Host: o.GetHost(), + Port: o.GetPort(), + Path: o.GetPath(), + Audience: o.GetAudience(), + Scopes: CopyStringSlice(o.GetScopes()), + } +} + +func audienceOverrideToProto(o *types.TokenGrantAudienceOverride) *pb.ProviderCredentialTokenGrantAudienceOverride { + if o == nil { + return nil + } + return &pb.ProviderCredentialTokenGrantAudienceOverride{ + Host: o.Host, + Port: o.Port, + Path: o.Path, + Audience: o.Audience, + Scopes: CopyStringSlice(o.Scopes), } } @@ -166,6 +244,9 @@ func ProviderProfileFromProto(p *pb.ProviderProfile) *types.ProviderProfile { Category: ProfileCategoryFromProto(p.GetCategory()), InferenceCapable: p.GetInferenceCapable(), ResourceVersion: p.GetResourceVersion(), + Annotations: CopyStringMap(p.GetAnnotations()), + Source: p.GetSource(), + Scope: p.GetScope(), } // Credentials @@ -221,6 +302,9 @@ func ProviderProfileToProto(p *types.ProviderProfile) *pb.ProviderProfile { Category: ProfileCategoryToProto(p.Category), InferenceCapable: p.InferenceCapable, ResourceVersion: p.ResourceVersion, + Annotations: CopyStringMap(p.Annotations), + Source: p.Source, + Scope: p.Scope, } // Credentials diff --git a/openshell/v1/internal/converter/profile_test.go b/openshell/v1/internal/converter/profile_test.go index d984cfe..38f8333 100644 --- a/openshell/v1/internal/converter/profile_test.go +++ b/openshell/v1/internal/converter/profile_test.go @@ -138,12 +138,28 @@ func TestNetworkBinaryToProto_Nil(t *testing.T) { func TestProfileCredentialFromProto(t *testing.T) { proto := &pb.ProviderProfileCredential{ - Name: "API_KEY", - Description: "API key for auth", - Required: true, + Name: "API_KEY", + Description: "API key for auth", + EnvVars: []string{"ANTHROPIC_API_KEY", "API_KEY"}, + Required: true, + AuthStyle: "header", + HeaderName: "X-API-Key", + QueryParam: "api_key", + PathTemplate: "/v1/{credential}/chat", Refresh: &pb.ProviderCredentialRefresh{ Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, }, + TokenGrant: &pb.ProviderCredentialTokenGrant{ + TokenEndpoint: "https://auth.example.com/token", + Audience: "https://api.example.com", + JwtSvidAudience: "spiffe://example.com", + Scopes: []string{"read", "write"}, + CacheTtlSeconds: 300, + ClientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + AudienceOverrides: []*pb.ProviderCredentialTokenGrantAudienceOverride{ + {Host: "special.example.com", Port: 8443, Path: "/api", Audience: "https://special.example.com", Scopes: []string{"admin"}}, + }, + }, } cred := ProfileCredentialFromProto(proto) @@ -151,8 +167,51 @@ func TestProfileCredentialFromProto(t *testing.T) { require.NotNil(t, cred) assert.Equal(t, "API_KEY", cred.Name) assert.Equal(t, "API key for auth", cred.Description) + assert.Equal(t, []string{"ANTHROPIC_API_KEY", "API_KEY"}, cred.EnvVars) assert.True(t, cred.Required) assert.True(t, cred.Secret, "credential with refresh config is secret") + assert.Equal(t, "header", cred.AuthStyle) + assert.Equal(t, "X-API-Key", cred.HeaderName) + assert.Equal(t, "api_key", cred.QueryParam) + assert.Equal(t, "/v1/{credential}/chat", cred.PathTemplate) + + require.NotNil(t, cred.TokenGrant) + assert.Equal(t, "https://auth.example.com/token", cred.TokenGrant.TokenEndpoint) + assert.Equal(t, "https://api.example.com", cred.TokenGrant.Audience) + assert.Equal(t, "spiffe://example.com", cred.TokenGrant.JWTSVIDAudience) + assert.Equal(t, []string{"read", "write"}, cred.TokenGrant.Scopes) + assert.Equal(t, int64(300), cred.TokenGrant.CacheTTLSeconds) + assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", cred.TokenGrant.ClientAssertionType) + require.Len(t, cred.TokenGrant.AudienceOverrides, 1) + assert.Equal(t, "special.example.com", cred.TokenGrant.AudienceOverrides[0].Host) + assert.Equal(t, uint32(8443), cred.TokenGrant.AudienceOverrides[0].Port) + assert.Equal(t, "/api", cred.TokenGrant.AudienceOverrides[0].Path) + assert.Equal(t, "https://special.example.com", cred.TokenGrant.AudienceOverrides[0].Audience) + assert.Equal(t, []string{"admin"}, cred.TokenGrant.AudienceOverrides[0].Scopes) +} + +func TestProfileCredentialFromProto_DeepCopy(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "KEY", + EnvVars: []string{"ENV_A"}, + TokenGrant: &pb.ProviderCredentialTokenGrant{ + Scopes: []string{"read"}, + AudienceOverrides: []*pb.ProviderCredentialTokenGrantAudienceOverride{ + {Scopes: []string{"admin"}}, + }, + }, + } + + cred := ProfileCredentialFromProto(proto) + + proto.EnvVars[0] = "MUTATED" + assert.Equal(t, "ENV_A", cred.EnvVars[0], "env_vars must be deep copied") + + proto.TokenGrant.Scopes[0] = "MUTATED" + assert.Equal(t, "read", cred.TokenGrant.Scopes[0], "token grant scopes must be deep copied") + + proto.TokenGrant.AudienceOverrides[0].Scopes[0] = "MUTATED" + assert.Equal(t, "admin", cred.TokenGrant.AudienceOverrides[0].Scopes[0], "audience override scopes must be deep copied") } func TestProfileCredentialFromProto_NotSecret(t *testing.T) { @@ -167,6 +226,7 @@ func TestProfileCredentialFromProto_NotSecret(t *testing.T) { assert.Equal(t, "ENDPOINT_URL", cred.Name) assert.False(t, cred.Required) assert.False(t, cred.Secret, "credential without refresh config is not secret") + assert.Nil(t, cred.TokenGrant) } func TestProfileCredentialFromProto_Nil(t *testing.T) { @@ -174,6 +234,77 @@ func TestProfileCredentialFromProto_Nil(t *testing.T) { assert.Nil(t, cred) } +func TestProfileCredentialToProto(t *testing.T) { + cred := &v1.ProfileCredential{ + Name: "API_KEY", + Description: "API key", + EnvVars: []string{"ANTHROPIC_API_KEY"}, + Required: true, + Secret: true, + AuthStyle: "header", + HeaderName: "X-API-Key", + QueryParam: "api_key", + PathTemplate: "/v1/{credential}/chat", + TokenGrant: &v1.CredentialTokenGrant{ + TokenEndpoint: "https://auth.example.com/token", + Audience: "https://api.example.com", + JWTSVIDAudience: "spiffe://example.com", + Scopes: []string{"read"}, + CacheTTLSeconds: 300, + ClientAssertionType: "urn:custom", + AudienceOverrides: []v1.TokenGrantAudienceOverride{ + {Host: "h", Port: 443, Path: "/p", Audience: "aud", Scopes: []string{"s"}}, + }, + }, + } + + proto := ProfileCredentialToProto(cred) + + require.NotNil(t, proto) + assert.Equal(t, "API_KEY", proto.Name) + assert.Equal(t, "API key", proto.Description) + assert.Equal(t, []string{"ANTHROPIC_API_KEY"}, proto.EnvVars) + assert.True(t, proto.Required) + assert.Equal(t, "header", proto.AuthStyle) + assert.Equal(t, "X-API-Key", proto.HeaderName) + assert.Equal(t, "api_key", proto.QueryParam) + assert.Equal(t, "/v1/{credential}/chat", proto.PathTemplate) + assert.Nil(t, proto.Refresh, "Secret is not round-trippable to Refresh") + + require.NotNil(t, proto.TokenGrant) + assert.Equal(t, "https://auth.example.com/token", proto.TokenGrant.TokenEndpoint) + assert.Equal(t, "https://api.example.com", proto.TokenGrant.Audience) + assert.Equal(t, "spiffe://example.com", proto.TokenGrant.JwtSvidAudience) + assert.Equal(t, []string{"read"}, proto.TokenGrant.Scopes) + assert.Equal(t, int64(300), proto.TokenGrant.CacheTtlSeconds) + assert.Equal(t, "urn:custom", proto.TokenGrant.ClientAssertionType) + require.Len(t, proto.TokenGrant.AudienceOverrides, 1) + assert.Equal(t, "h", proto.TokenGrant.AudienceOverrides[0].Host) +} + +func TestProfileCredentialToProto_Nil(t *testing.T) { + proto := ProfileCredentialToProto(nil) + assert.Nil(t, proto) +} + +func TestProfileCredentialToProto_DeepCopy(t *testing.T) { + cred := &v1.ProfileCredential{ + Name: "KEY", + EnvVars: []string{"ENV_A"}, + TokenGrant: &v1.CredentialTokenGrant{ + Scopes: []string{"read"}, + }, + } + + proto := ProfileCredentialToProto(cred) + + cred.EnvVars[0] = "MUTATED" + assert.Equal(t, "ENV_A", proto.EnvVars[0], "env_vars must be deep copied") + + cred.TokenGrant.Scopes[0] = "MUTATED" + assert.Equal(t, "read", proto.TokenGrant.Scopes[0], "token grant scopes must be deep copied") +} + // --- ProfileDiagnostic --- func TestProfileDiagnosticFromProto(t *testing.T) { @@ -222,6 +353,9 @@ func TestProviderProfileFromProto(t *testing.T) { Credentials: []string{"API_KEY"}, }, ResourceVersion: 7, + Annotations: map[string]string{"env": "prod", "team": "ai"}, + Source: "builtin", + Scope: "platform", } profile := ProviderProfileFromProto(proto) @@ -233,6 +367,9 @@ func TestProviderProfileFromProto(t *testing.T) { assert.Equal(t, v1.ProfileCategoryInference, profile.Category) assert.True(t, profile.InferenceCapable) assert.Equal(t, uint64(7), profile.ResourceVersion) + assert.Equal(t, map[string]string{"env": "prod", "team": "ai"}, profile.Annotations) + assert.Equal(t, "builtin", profile.Source) + assert.Equal(t, "platform", profile.Scope) require.Len(t, profile.Credentials, 1) assert.Equal(t, "API_KEY", profile.Credentials[0].Name) @@ -246,6 +383,9 @@ func TestProviderProfileFromProto(t *testing.T) { assert.Equal(t, "/usr/bin/claude", profile.Binaries[0].Path) assert.Equal(t, []string{"API_KEY"}, profile.Discovery.Credentials) + + proto.Annotations["env"] = "MUTATED" + assert.Equal(t, "prod", profile.Annotations["env"], "annotations must be deep copied") } func TestProviderProfileFromProto_NilDiscovery(t *testing.T) { @@ -284,6 +424,9 @@ func TestProviderProfileToProto(t *testing.T) { Credentials: []string{"API_KEY"}, }, ResourceVersion: 7, + Annotations: map[string]string{"env": "prod"}, + Source: "user", + Scope: "workspace", } proto := ProviderProfileToProto(profile) @@ -295,6 +438,9 @@ func TestProviderProfileToProto(t *testing.T) { assert.Equal(t, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, proto.Category) assert.True(t, proto.InferenceCapable) assert.Equal(t, uint64(7), proto.ResourceVersion) + assert.Equal(t, map[string]string{"env": "prod"}, proto.Annotations) + assert.Equal(t, "user", proto.Source) + assert.Equal(t, "workspace", proto.Scope) require.Len(t, proto.Credentials, 1) assert.Equal(t, "API_KEY", proto.Credentials[0].Name) @@ -307,6 +453,9 @@ func TestProviderProfileToProto(t *testing.T) { require.NotNil(t, proto.Discovery) assert.Equal(t, []string{"API_KEY"}, proto.Discovery.Credentials) + + profile.Annotations["env"] = "MUTATED" + assert.Equal(t, "prod", proto.Annotations["env"], "annotations must be deep copied") } func TestProviderProfileToProto_Nil(t *testing.T) { @@ -370,7 +519,28 @@ func TestProviderProfileRoundTrip(t *testing.T) { Description: "Testing round trip", Category: v1.ProfileCategoryAgent, Credentials: []v1.ProfileCredential{ - {Name: "TOKEN", Description: "auth token", Required: true, Secret: false}, + { + Name: "TOKEN", + Description: "auth token", + EnvVars: []string{"MY_TOKEN"}, + Required: true, + Secret: false, + AuthStyle: "header", + HeaderName: "Authorization", + QueryParam: "token", + PathTemplate: "/api/{credential}", + TokenGrant: &v1.CredentialTokenGrant{ + TokenEndpoint: "https://auth.example.com/token", + Audience: "https://api.example.com", + JWTSVIDAudience: "spiffe://example.com", + Scopes: []string{"read"}, + CacheTTLSeconds: 600, + ClientAssertionType: "urn:custom", + AudienceOverrides: []v1.TokenGrantAudienceOverride{ + {Host: "h", Port: 443, Path: "/p", Audience: "aud", Scopes: []string{"s"}}, + }, + }, + }, }, Endpoints: []v1.NetworkEndpoint{ {Host: "agent.example.com", Port: 8080, Protocol: "websocket"}, @@ -383,6 +553,9 @@ func TestProviderProfileRoundTrip(t *testing.T) { Credentials: []string{"TOKEN"}, }, ResourceVersion: 42, + Annotations: map[string]string{"env": "staging"}, + Source: "interceptor/custom", + Scope: "workspace", } proto := ProviderProfileToProto(original) @@ -395,10 +568,28 @@ func TestProviderProfileRoundTrip(t *testing.T) { assert.Equal(t, original.Category, back.Category) assert.Equal(t, original.InferenceCapable, back.InferenceCapable) assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + assert.Equal(t, original.Annotations, back.Annotations) + assert.Equal(t, original.Source, back.Source) + assert.Equal(t, original.Scope, back.Scope) 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) + c := back.Credentials[0] + assert.Equal(t, original.Credentials[0].Name, c.Name) + assert.Equal(t, original.Credentials[0].Required, c.Required) + assert.Equal(t, original.Credentials[0].EnvVars, c.EnvVars) + assert.Equal(t, original.Credentials[0].AuthStyle, c.AuthStyle) + assert.Equal(t, original.Credentials[0].HeaderName, c.HeaderName) + assert.Equal(t, original.Credentials[0].QueryParam, c.QueryParam) + assert.Equal(t, original.Credentials[0].PathTemplate, c.PathTemplate) + require.NotNil(t, c.TokenGrant) + assert.Equal(t, original.Credentials[0].TokenGrant.TokenEndpoint, c.TokenGrant.TokenEndpoint) + assert.Equal(t, original.Credentials[0].TokenGrant.Audience, c.TokenGrant.Audience) + assert.Equal(t, original.Credentials[0].TokenGrant.JWTSVIDAudience, c.TokenGrant.JWTSVIDAudience) + assert.Equal(t, original.Credentials[0].TokenGrant.Scopes, c.TokenGrant.Scopes) + assert.Equal(t, original.Credentials[0].TokenGrant.CacheTTLSeconds, c.TokenGrant.CacheTTLSeconds) + assert.Equal(t, original.Credentials[0].TokenGrant.ClientAssertionType, c.TokenGrant.ClientAssertionType) + require.Len(t, c.TokenGrant.AudienceOverrides, 1) + assert.Equal(t, original.Credentials[0].TokenGrant.AudienceOverrides[0], c.TokenGrant.AudienceOverrides[0]) require.Len(t, back.Endpoints, 1) assert.Equal(t, original.Endpoints[0].Host, back.Endpoints[0].Host) diff --git a/openshell/v1/internal/converter/refresh.go b/openshell/v1/internal/converter/refresh.go index db40edb..f01f1e9 100644 --- a/openshell/v1/internal/converter/refresh.go +++ b/openshell/v1/internal/converter/refresh.go @@ -23,6 +23,8 @@ func RefreshStrategyFromProto(s pb.ProviderCredentialRefreshStrategy) types.Refr return types.RefreshStrategyOAuth2ClientCredentials case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT: return types.RefreshStrategyGoogleServiceAccountJWT + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE: + return types.RefreshStrategyAWSStsAssumeRole default: return types.RefreshStrategy("") } @@ -41,6 +43,8 @@ func RefreshStrategyToProto(s types.RefreshStrategy) pb.ProviderCredentialRefres return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS case types.RefreshStrategyGoogleServiceAccountJWT: return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT + case types.RefreshStrategyAWSStsAssumeRole: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE default: return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED } diff --git a/openshell/v1/internal/converter/refresh_test.go b/openshell/v1/internal/converter/refresh_test.go index a06f564..a5758d7 100644 --- a/openshell/v1/internal/converter/refresh_test.go +++ b/openshell/v1/internal/converter/refresh_test.go @@ -25,6 +25,7 @@ func TestRefreshStrategyFromProto(t *testing.T) { {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_AWS_STS_ASSUME_ROLE, v1.RefreshStrategyAWSStsAssumeRole}, {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED, v1.RefreshStrategy("")}, } for _, tt := range tests { @@ -44,6 +45,7 @@ func TestRefreshStrategyToProto(t *testing.T) { {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.RefreshStrategyAWSStsAssumeRole, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE}, {v1.RefreshStrategy(""), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, {v1.RefreshStrategy("Unknown"), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, } diff --git a/openshell/v1/types/profile.go b/openshell/v1/types/profile.go index 9d57c6d..de405e9 100644 --- a/openshell/v1/types/profile.go +++ b/openshell/v1/types/profile.go @@ -30,14 +30,43 @@ type ProviderProfile struct { InferenceCapable bool Discovery ProfileDiscovery ResourceVersion uint64 + Annotations map[string]string + Source string + Scope string } // ProfileCredential defines a single credential required by a provider profile. type ProfileCredential struct { - Name string - Description string - Required bool - Secret bool + Name string + Description string + EnvVars []string + Required bool + Secret bool + AuthStyle string + HeaderName string + QueryParam string + PathTemplate string + TokenGrant *CredentialTokenGrant +} + +// CredentialTokenGrant configures dynamic credential acquisition via OAuth2 grant. +type CredentialTokenGrant struct { + TokenEndpoint string + Audience string + JWTSVIDAudience string + Scopes []string + CacheTTLSeconds int64 + AudienceOverrides []TokenGrantAudienceOverride + ClientAssertionType string +} + +// TokenGrantAudienceOverride selects an endpoint-specific resource audience. +type TokenGrantAudienceOverride struct { + Host string + Port uint32 + Path string + Audience string + Scopes []string } // NetworkEndpoint describes a network endpoint provided by a profile. diff --git a/openshell/v1/types/refresh.go b/openshell/v1/types/refresh.go index fc73315..3b897ba 100644 --- a/openshell/v1/types/refresh.go +++ b/openshell/v1/types/refresh.go @@ -15,6 +15,7 @@ const ( RefreshStrategyOAuth2RefreshToken RefreshStrategy = "OAuth2RefreshToken" RefreshStrategyOAuth2ClientCredentials RefreshStrategy = "OAuth2ClientCredentials" RefreshStrategyGoogleServiceAccountJWT RefreshStrategy = "GoogleServiceAccountJWT" + RefreshStrategyAWSStsAssumeRole RefreshStrategy = "AWSStsAssumeRole" ) // RefreshStatus reports the current state of credential refresh for a specific From f1b403e292e1b8db612b8efde65bb565737aa5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sun, 9 Aug 2026 08:11:04 +0200 Subject: [PATCH 3/6] feat: add policy provenance and sandbox template resource fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SandboxPolicyRevision: add provenance map for audit metadata - SandboxTemplate: expose resources and driver_config as map[string]any using structpb.Struct round-trip (same pattern as middleware config) - Add coverage test for SandboxPolicyRevision proto fields Assisted-By: 🤖 Claude Code --- openshell/v1/internal/converter/policy.go | 1 + .../v1/internal/converter/policy_test.go | 13 +++++++---- openshell/v1/internal/converter/sandbox.go | 21 ++++++++++++++++-- .../v1/internal/converter/sandbox_test.go | 22 ++++++++++++++++++- openshell/v1/types/policy.go | 2 ++ openshell/v1/types/sandbox.go | 2 ++ 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/openshell/v1/internal/converter/policy.go b/openshell/v1/internal/converter/policy.go index d3cfae5..0ba5b4e 100644 --- a/openshell/v1/internal/converter/policy.go +++ b/openshell/v1/internal/converter/policy.go @@ -274,6 +274,7 @@ func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxP CreatedAt: TimeFromMillis(r.GetCreatedAtMs()), LoadedAt: TimeFromMillis(r.GetLoadedAtMs()), Policy: SandboxPolicyFromProto(r.GetPolicy()), + Provenance: CopyStringMap(r.GetProvenance()), } } diff --git a/openshell/v1/internal/converter/policy_test.go b/openshell/v1/internal/converter/policy_test.go index 3e89dab..d63fec2 100644 --- a/openshell/v1/internal/converter/policy_test.go +++ b/openshell/v1/internal/converter/policy_test.go @@ -438,12 +438,13 @@ func TestProcessPolicyNil(t *testing.T) { func TestSandboxPolicyRevisionFromProto(t *testing.T) { proto := &pb.SandboxPolicyRevision{ - Version: 3, - PolicyHash: "sha256:abc123", - Status: pb.PolicyStatus_POLICY_STATUS_LOADED, - LoadError: "", + Version: 3, + PolicyHash: "sha256:abc123", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + LoadError: "", CreatedAtMs: 1700000000000, LoadedAtMs: 1700000001000, + Provenance: map[string]string{"source": "api", "user": "admin"}, } rev := SandboxPolicyRevisionFromProto(proto) @@ -455,6 +456,10 @@ func TestSandboxPolicyRevisionFromProto(t *testing.T) { assert.Empty(t, rev.LoadError) assert.False(t, rev.CreatedAt.IsZero()) assert.False(t, rev.LoadedAt.IsZero()) + assert.Equal(t, map[string]string{"source": "api", "user": "admin"}, rev.Provenance) + + proto.Provenance["source"] = "MUTATED" + assert.Equal(t, "api", rev.Provenance["source"], "provenance must be deep copied") } func TestSandboxPolicyRevisionFromProto_Nil(t *testing.T) { diff --git a/openshell/v1/internal/converter/sandbox.go b/openshell/v1/internal/converter/sandbox.go index 21c379b..e990313 100644 --- a/openshell/v1/internal/converter/sandbox.go +++ b/openshell/v1/internal/converter/sandbox.go @@ -7,6 +7,7 @@ import ( "github.com/rhuss/openshell-sdk-go/openshell/v1/types" dm "github.com/rhuss/openshell-sdk-go/proto/datamodelv1" pb "github.com/rhuss/openshell-sdk-go/proto/openshellv1" + "google.golang.org/protobuf/types/known/structpb" ) // SandboxFromProto converts a proto Sandbox to an SDK Sandbox. @@ -50,7 +51,7 @@ func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { } if tmpl := spec.GetTemplate(); tmpl != nil { - result.Template = &types.SandboxTemplate{ + t := &types.SandboxTemplate{ Image: tmpl.GetImage(), RuntimeClassName: tmpl.GetRuntimeClassName(), AgentSocket: tmpl.GetAgentSocket(), @@ -59,6 +60,13 @@ func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { Environment: CopyStringMap(tmpl.GetEnvironment()), UserNamespaces: CopyBoolPtr(tmpl.UserNamespaces), } + if res := tmpl.GetResources(); res != nil { + t.Resources = res.AsMap() + } + if dc := tmpl.GetDriverConfig(); dc != nil { + t.DriverConfig = dc.AsMap() + } + result.Template = t } if rr := spec.GetResourceRequirements(); rr != nil { @@ -164,7 +172,7 @@ func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { } if spec.Template != nil { - result.Template = &pb.SandboxTemplate{ + tmpl := &pb.SandboxTemplate{ Image: spec.Template.Image, RuntimeClassName: spec.Template.RuntimeClassName, AgentSocket: spec.Template.AgentSocket, @@ -173,6 +181,15 @@ func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { Environment: CopyStringMap(spec.Template.Environment), UserNamespaces: CopyBoolPtr(spec.Template.UserNamespaces), } + if spec.Template.Resources != nil { + s, _ := structpb.NewStruct(spec.Template.Resources) + tmpl.Resources = s + } + if spec.Template.DriverConfig != nil { + s, _ := structpb.NewStruct(spec.Template.DriverConfig) + tmpl.DriverConfig = s + } + result.Template = tmpl } if spec.GPUCount != nil { diff --git a/openshell/v1/internal/converter/sandbox_test.go b/openshell/v1/internal/converter/sandbox_test.go index 8dc6900..4a127e9 100644 --- a/openshell/v1/internal/converter/sandbox_test.go +++ b/openshell/v1/internal/converter/sandbox_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" ) func TestSandboxFromProto(t *testing.T) { @@ -40,6 +41,14 @@ func TestSandboxFromProto(t *testing.T) { Annotations: map[string]string{"note": "hello"}, Environment: map[string]string{"TMPL_VAR": "val"}, UserNamespaces: &userNS, + Resources: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"cpu": "2", "memory": "4Gi"}) + return s + }(), + DriverConfig: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"runtime": "kata", "nested": map[string]any{"key": "val"}}) + return s + }(), }, Providers: []string{"claude", "github"}, ResourceRequirements: &pb.ResourceRequirements{ @@ -97,6 +106,11 @@ func TestSandboxFromProto(t *testing.T) { 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) + assert.Equal(t, map[string]any{"cpu": "2", "memory": "4Gi"}, s.Spec.Template.Resources) + assert.Equal(t, "kata", s.Spec.Template.DriverConfig["runtime"]) + nested, ok := s.Spec.Template.DriverConfig["nested"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "val", nested["key"]) // Status assert.Equal(t, "sb-compute-1", s.Status.SandboxName) @@ -348,7 +362,9 @@ func TestSandboxSpecToProto(t *testing.T) { LogLevel: "debug", Environment: map[string]string{"X": "Y"}, Template: &v1.SandboxTemplate{ - Image: "img:spec", + Image: "img:spec", + Resources: map[string]any{"cpu": "4"}, + DriverConfig: map[string]any{"runtime": "kata"}, }, Providers: []string{"prov"}, GPUCount: &gpuCount, @@ -370,6 +386,10 @@ func TestSandboxSpecToProto(t *testing.T) { assert.Equal(t, uint32(3), p.ResourceRequirements.Gpu.GetCount()) require.NotNil(t, p.Template) assert.Equal(t, "img:spec", p.Template.Image) + require.NotNil(t, p.Template.Resources) + assert.Equal(t, "4", p.Template.Resources.Fields["cpu"].GetStringValue()) + require.NotNil(t, p.Template.DriverConfig) + assert.Equal(t, "kata", p.Template.DriverConfig.Fields["runtime"].GetStringValue()) // Policy conversion require.NotNil(t, p.Policy) diff --git a/openshell/v1/types/policy.go b/openshell/v1/types/policy.go index 90e6f0e..8713fce 100644 --- a/openshell/v1/types/policy.go +++ b/openshell/v1/types/policy.go @@ -176,6 +176,8 @@ type SandboxPolicyRevision struct { LoadedAt time.Time // Policy is the typed security policy for this revision. Nil when not requested or absent. Policy *SandboxPolicy + // Provenance is immutable metadata supplied with this policy revision. + Provenance map[string]string } // PolicyStatusResult contains the status of a sandbox's policy. diff --git a/openshell/v1/types/sandbox.go b/openshell/v1/types/sandbox.go index 7f2e14d..b3f31cf 100644 --- a/openshell/v1/types/sandbox.go +++ b/openshell/v1/types/sandbox.go @@ -39,6 +39,8 @@ type SandboxTemplate struct { Annotations map[string]string Environment map[string]string UserNamespaces *bool + Resources map[string]any + DriverConfig map[string]any } // SandboxStatus holds the observed state of a sandbox. From 26e93748a5dcc92d6a0f1a3c8c43011d1d076f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sun, 9 Aug 2026 08:11:16 +0200 Subject: [PATCH 4/6] feat: add CreateOptions annotations and ConfigUpdate annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SandboxInterface.Create now accepts variadic ...CreateOptions with Annotations field for setting metadata at sandbox creation time - ConfigUpdate and ConfigUpdateResult gain Annotations for caller- provided and response metadata on config/policy updates - Update all SandboxInterface implementations (real client, fake, test stubs) for the new signature Assisted-By: 🤖 Claude Code --- openshell/v1/exec_client_test.go | 2 +- openshell/v1/fake/sandbox.go | 2 +- openshell/v1/internal/converter/setting.go | 2 ++ openshell/v1/internal/converter/setting_test.go | 10 ++++++++++ openshell/v1/sandbox.go | 2 +- openshell/v1/sandbox_client.go | 10 +++++++--- openshell/v1/ssh_client_test.go | 2 +- openshell/v1/tcp_client_test.go | 2 +- openshell/v1/types/options.go | 4 +++- openshell/v1/types/setting.go | 4 ++++ 10 files changed, 31 insertions(+), 9 deletions(-) diff --git a/openshell/v1/exec_client_test.go b/openshell/v1/exec_client_test.go index 4ad387b..853c399 100644 --- a/openshell/v1/exec_client_test.go +++ b/openshell/v1/exec_client_test.go @@ -33,7 +33,7 @@ func (r *stubSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox, return &Sandbox{ID: "sb-" + name, Name: name}, nil } -func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string) (*Sandbox, error) { +func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) { panic("not implemented") } func (r *stubSandboxResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { diff --git a/openshell/v1/fake/sandbox.go b/openshell/v1/fake/sandbox.go index 2d29b16..14e8ada 100644 --- a/openshell/v1/fake/sandbox.go +++ b/openshell/v1/fake/sandbox.go @@ -211,7 +211,7 @@ func newFakeSandboxClient( } // Create creates a new sandbox with Provisioning phase. -func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, spec *types.SandboxSpec, labels map[string]string) (*types.Sandbox, error) { +func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, spec *types.SandboxSpec, labels map[string]string, _ ...types.CreateOptions) (*types.Sandbox, error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } diff --git a/openshell/v1/internal/converter/setting.go b/openshell/v1/internal/converter/setting.go index 9a77590..c76bc0b 100644 --- a/openshell/v1/internal/converter/setting.go +++ b/openshell/v1/internal/converter/setting.go @@ -186,6 +186,7 @@ func ConfigUpdateToProto(cu *v1.ConfigUpdate) (*pb.UpdateConfigRequest, error) { DeleteSetting: cu.DeleteSetting, Global: cu.Global, ExpectedResourceVersion: cu.ExpectedResourceVersion, + Annotations: CopyStringMap(cu.Annotations), } // Convert typed SDK SandboxPolicy to proto SandboxPolicy. @@ -296,5 +297,6 @@ func ConfigUpdateResultFromProto(resp *pb.UpdateConfigResponse) *v1.ConfigUpdate PolicyHash: resp.GetPolicyHash(), SettingsRevision: resp.GetSettingsRevision(), Deleted: resp.GetDeleted(), + Annotations: CopyStringMap(resp.GetAnnotations()), } } diff --git a/openshell/v1/internal/converter/setting_test.go b/openshell/v1/internal/converter/setting_test.go index e4ffdc7..1ef0a81 100644 --- a/openshell/v1/internal/converter/setting_test.go +++ b/openshell/v1/internal/converter/setting_test.go @@ -419,6 +419,7 @@ func TestConfigUpdateToProto(t *testing.T) { DeleteSetting: false, Global: false, ExpectedResourceVersion: 7, + Annotations: map[string]string{"source": "cli", "user": "admin"}, } req, err := ConfigUpdateToProto(cu) @@ -434,6 +435,10 @@ func TestConfigUpdateToProto(t *testing.T) { assert.Equal(t, uint64(7), req.ExpectedResourceVersion) assert.Nil(t, req.Policy) assert.Empty(t, req.MergeOperations) + assert.Equal(t, map[string]string{"source": "cli", "user": "admin"}, req.Annotations) + + cu.Annotations["source"] = "MUTATED" + assert.Equal(t, "cli", req.Annotations["source"], "annotations must be deep copied") } func TestConfigUpdateToProto_WithPolicy(t *testing.T) { @@ -529,6 +534,7 @@ func TestConfigUpdateResultFromProto(t *testing.T) { PolicyHash: "sha256:updated", SettingsRevision: 55, Deleted: true, + Annotations: map[string]string{"sandbox_id": "sb-123"}, } result := ConfigUpdateResultFromProto(resp) @@ -538,6 +544,10 @@ func TestConfigUpdateResultFromProto(t *testing.T) { assert.Equal(t, "sha256:updated", result.PolicyHash) assert.Equal(t, uint64(55), result.SettingsRevision) assert.True(t, result.Deleted) + assert.Equal(t, map[string]string{"sandbox_id": "sb-123"}, result.Annotations) + + resp.Annotations["sandbox_id"] = "MUTATED" + assert.Equal(t, "sb-123", result.Annotations["sandbox_id"], "annotations must be deep copied") } func TestConfigUpdateResultFromProto_DefaultValues(t *testing.T) { diff --git a/openshell/v1/sandbox.go b/openshell/v1/sandbox.go index b02d0ac..c3909df 100644 --- a/openshell/v1/sandbox.go +++ b/openshell/v1/sandbox.go @@ -53,7 +53,7 @@ var WithLogMinLevel = types.WithLogMinLevel // SandboxInterface defines lifecycle operations on sandboxes. type SandboxInterface interface { - Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) + Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) Get(ctx context.Context, workspace, name string) (*Sandbox, error) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) Delete(ctx context.Context, workspace, name string) error diff --git a/openshell/v1/sandbox_client.go b/openshell/v1/sandbox_client.go index 4c5e8d9..09b1b5d 100644 --- a/openshell/v1/sandbox_client.go +++ b/openshell/v1/sandbox_client.go @@ -25,13 +25,17 @@ func newSandboxClient(conn grpc.ClientConnInterface) *sandboxClient { return &sandboxClient{client: pb.NewOpenShellClient(conn)} } -func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) { - resp, err := s.client.CreateSandbox(ctx, &pb.CreateSandboxRequest{ +func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) { + req := &pb.CreateSandboxRequest{ Name: name, Spec: converter.SandboxSpecToProto(spec), Labels: labels, Workspace: workspace, - }) + } + if len(opts) > 0 { + req.Annotations = converter.CopyStringMap(opts[0].Annotations) + } + resp, err := s.client.CreateSandbox(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } diff --git a/openshell/v1/ssh_client_test.go b/openshell/v1/ssh_client_test.go index 605b594..a5a5ee1 100644 --- a/openshell/v1/ssh_client_test.go +++ b/openshell/v1/ssh_client_test.go @@ -132,7 +132,7 @@ type mockSandboxResolver struct { err error } -func (m *mockSandboxResolver) Create(_ context.Context, _, _ string, _ *SandboxSpec, _ map[string]string) (*Sandbox, error) { +func (m *mockSandboxResolver) Create(_ context.Context, _, _ string, _ *SandboxSpec, _ map[string]string, _ ...CreateOptions) (*Sandbox, error) { return nil, nil } diff --git a/openshell/v1/tcp_client_test.go b/openshell/v1/tcp_client_test.go index bc1f9ae..8d88ae4 100644 --- a/openshell/v1/tcp_client_test.go +++ b/openshell/v1/tcp_client_test.go @@ -1096,7 +1096,7 @@ func (r *flippableResolver) Get(_ context.Context, _, name string) (*Sandbox, er return &Sandbox{ID: "sb-" + name, Name: name}, nil } -func (r *flippableResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string) (*Sandbox, error) { +func (r *flippableResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) { panic("not implemented") } func (r *flippableResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { diff --git a/openshell/v1/types/options.go b/openshell/v1/types/options.go index 533226e..ec9600a 100644 --- a/openshell/v1/types/options.go +++ b/openshell/v1/types/options.go @@ -6,7 +6,9 @@ package types import "time" // CreateOptions configures resource creation. -type CreateOptions struct{} +type CreateOptions struct { + Annotations map[string]string +} // GetOptions configures resource retrieval. type GetOptions struct{} diff --git a/openshell/v1/types/setting.go b/openshell/v1/types/setting.go index bbdbfe1..6387daa 100644 --- a/openshell/v1/types/setting.go +++ b/openshell/v1/types/setting.go @@ -102,6 +102,8 @@ type ConfigUpdate struct { MergeOperations []PolicyMergeOperation // ExpectedResourceVersion is for optimistic concurrency (0 = skip check). ExpectedResourceVersion uint64 + // Annotations is caller-provided metadata for sandbox-scoped updates. + Annotations map[string]string } // ConfigUpdateResult holds the result of a configuration update operation. @@ -115,4 +117,6 @@ type ConfigUpdateResult struct { SettingsRevision uint64 // Deleted is true when a setting delete removed an existing key. Deleted bool + // Annotations contains sandbox metadata annotations after the update. + Annotations map[string]string } From 3834fd4c7828c86758cfc45e35d075b6d289eb51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sun, 9 Aug 2026 14:35:28 +0200 Subject: [PATCH 5/6] fix: address review findings from cc-review and bot comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deep-copy Resources/DriverConfig in fake copySandboxTemplate (correctness) - Guard structpb.NewStruct with err==nil check (production consistency) - Apply CreateOptions.Annotations in fake Create (fake-real parity) - Add deep-copy mutation tests for Resources/DriverConfig and bytes ToProto - Use opaque OIDC subject in fake.md example (docs accuracy) Assisted-By: 🤖 Claude Code --- docs/src/api/fake.md | 2 +- openshell/v1/fake/sandbox.go | 36 ++++++++++++++++++- openshell/v1/internal/converter/sandbox.go | 14 +++++--- .../v1/internal/converter/sandbox_test.go | 27 ++++++++++++++ .../v1/internal/converter/setting_test.go | 3 ++ 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/docs/src/api/fake.md b/docs/src/api/fake.md index 95e25d1..2975e04 100644 --- a/docs/src/api/fake.md +++ b/docs/src/api/fake.md @@ -68,7 +68,7 @@ client.AddProvider("default", &types.Provider{ client.AddWorkspace(&types.Workspace{Name: "staging"}) client.AddMember("staging", &types.WorkspaceMember{ - PrincipalSubject: "user@example.com", + PrincipalSubject: "subject-123", Role: types.WorkspaceRoleAdmin, }) ``` diff --git a/openshell/v1/fake/sandbox.go b/openshell/v1/fake/sandbox.go index 14e8ada..3fdae98 100644 --- a/openshell/v1/fake/sandbox.go +++ b/openshell/v1/fake/sandbox.go @@ -155,9 +155,37 @@ func copySandboxTemplate(t types.SandboxTemplate) types.SandboxTemplate { v := *t.UserNamespaces t.UserNamespaces = &v } + t.Resources = copyAnyMap(t.Resources) + t.DriverConfig = copyAnyMap(t.DriverConfig) return t } +func copyAnyMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + cp := make(map[string]any, len(m)) + for k, v := range m { + cp[k] = copyAnyValue(v) + } + return cp +} + +func copyAnyValue(v any) any { + switch val := v.(type) { + case map[string]any: + return copyAnyMap(val) + case []any: + s := make([]any, len(val)) + for i, elem := range val { + s[i] = copyAnyValue(elem) + } + return s + default: + return v + } +} + func copySandboxStatus(s types.SandboxStatus) types.SandboxStatus { if s.Conditions != nil { conds := make([]types.SandboxCondition, len(s.Conditions)) @@ -211,7 +239,7 @@ func newFakeSandboxClient( } // Create creates a new sandbox with Provisioning phase. -func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, spec *types.SandboxSpec, labels map[string]string, _ ...types.CreateOptions) (*types.Sandbox, error) { +func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, spec *types.SandboxSpec, labels map[string]string, opts ...types.CreateOptions) (*types.Sandbox, error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } @@ -220,11 +248,17 @@ func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, sp spec = &types.SandboxSpec{} } + var annotations map[string]string + if len(opts) > 0 { + annotations = copyStringMap(opts[0].Annotations) + } + sb := &types.Sandbox{ Name: name, Workspace: workspace, CreatedAt: time.Now(), Labels: copyStringMap(labels), + Annotations: annotations, ResourceVersion: 1, Spec: copySandboxSpec(*spec), Status: types.SandboxStatus{ diff --git a/openshell/v1/internal/converter/sandbox.go b/openshell/v1/internal/converter/sandbox.go index e990313..e9f8bc5 100644 --- a/openshell/v1/internal/converter/sandbox.go +++ b/openshell/v1/internal/converter/sandbox.go @@ -182,12 +182,18 @@ func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { UserNamespaces: CopyBoolPtr(spec.Template.UserNamespaces), } if spec.Template.Resources != nil { - s, _ := structpb.NewStruct(spec.Template.Resources) - tmpl.Resources = s + // Non-JSON-compatible values (e.g., chan, func) are silently dropped. + // Round-trip data from structpb.AsMap is always re-serializable. + s, err := structpb.NewStruct(spec.Template.Resources) + if err == nil { + tmpl.Resources = s + } } if spec.Template.DriverConfig != nil { - s, _ := structpb.NewStruct(spec.Template.DriverConfig) - tmpl.DriverConfig = s + s, err := structpb.NewStruct(spec.Template.DriverConfig) + if err == nil { + tmpl.DriverConfig = s + } } result.Template = tmpl } diff --git a/openshell/v1/internal/converter/sandbox_test.go b/openshell/v1/internal/converter/sandbox_test.go index 4a127e9..25ef9b6 100644 --- a/openshell/v1/internal/converter/sandbox_test.go +++ b/openshell/v1/internal/converter/sandbox_test.go @@ -127,6 +127,33 @@ func TestSandboxFromProto(t *testing.T) { assert.Equal(t, "2024-01-01T00:00:00Z", s.Status.Conditions[0].LastTransitionTime) } +func TestSandboxFromProto_TemplateResourcesDeepCopy(t *testing.T) { + proto := &pb.Sandbox{ + Spec: &pb.SandboxSpec{ + Template: &pb.SandboxTemplate{ + Image: "img:v1", + Resources: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"cpu": "2"}) + return s + }(), + DriverConfig: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"runtime": "kata"}) + return s + }(), + }, + }, + } + + s := SandboxFromProto(proto) + require.NotNil(t, s) + + proto.Spec.Template.Resources.Fields["cpu"] = structpb.NewStringValue("MUTATED") + assert.Equal(t, "2", s.Spec.Template.Resources["cpu"], "Resources must be deep copied") + + proto.Spec.Template.DriverConfig.Fields["runtime"] = structpb.NewStringValue("MUTATED") + assert.Equal(t, "kata", s.Spec.Template.DriverConfig["runtime"], "DriverConfig must be deep copied") +} + func TestSandboxFromProto_NilFields(t *testing.T) { proto := &pb.Sandbox{} diff --git a/openshell/v1/internal/converter/setting_test.go b/openshell/v1/internal/converter/setting_test.go index 1ef0a81..a74db75 100644 --- a/openshell/v1/internal/converter/setting_test.go +++ b/openshell/v1/internal/converter/setting_test.go @@ -143,6 +143,9 @@ func TestSettingValueToProto_BytesValue(t *testing.T) { require.NotNil(t, pv) assert.Equal(t, data, pv.GetBytesValue()) + + data[0] = 0xFF + assert.Equal(t, byte(0xCA), pv.GetBytesValue()[0], "deep copy must isolate proto from SDK") } func TestSettingValueToProto_Nil(t *testing.T) { From 97464d569157c753bd66794d19f985185e65a1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sun, 9 Aug 2026 16:02:28 +0200 Subject: [PATCH 6/6] test: improve coverage for fake sandbox deep-copy and annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added tests to address Codecov coverage regression: - TestSandbox_Create_WithAnnotations: verify annotations pass-through - TestSandbox_Create_WithAnnotationsDeepCopy: mutation isolation - TestCopyAnyMap: nil, flat, nested map, nested slice, scalar types - TestCopySandboxTemplate_ResourcesDeepCopy: Resources/DriverConfig isolation Assisted-By: 🤖 Claude Code --- openshell/v1/fake/sandbox_test.go | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/openshell/v1/fake/sandbox_test.go b/openshell/v1/fake/sandbox_test.go index bec7db0..69fb6f3 100644 --- a/openshell/v1/fake/sandbox_test.go +++ b/openshell/v1/fake/sandbox_test.go @@ -52,6 +52,110 @@ func TestSandbox_Create_AlreadyExists(t *testing.T) { assert.True(t, types.IsAlreadyExists(err)) } +func TestSandbox_Create_WithAnnotations(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "annotated", &types.SandboxSpec{}, nil, + types.CreateOptions{Annotations: map[string]string{"source": "cli", "user": "admin"}}) + require.NoError(t, err) + assert.Equal(t, "cli", sb.Annotations["source"]) + assert.Equal(t, "admin", sb.Annotations["user"]) + + got, err := sc.Get(ctx, "default", "annotated") + require.NoError(t, err) + assert.Equal(t, "cli", got.Annotations["source"]) +} + +func TestSandbox_Create_WithAnnotationsDeepCopy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + input := map[string]string{"key": "original"} + sb, err := sc.Create(ctx, "default", "dc-test", &types.SandboxSpec{}, nil, + types.CreateOptions{Annotations: input}) + require.NoError(t, err) + + input["key"] = "MUTATED" + assert.Equal(t, "original", sb.Annotations["key"], "annotations must be deep copied") +} + +func TestSandbox_Create_NoAnnotations(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "no-ann", &types.SandboxSpec{}, nil) + require.NoError(t, err) + assert.Nil(t, sb.Annotations) +} + +func TestCopyAnyMap(t *testing.T) { + t.Run("nil", func(t *testing.T) { + assert.Nil(t, copyAnyMap(nil)) + }) + + t.Run("flat", func(t *testing.T) { + original := map[string]any{"cpu": "2", "memory": "4Gi"} + copied := copyAnyMap(original) + assert.Equal(t, original, copied) + + original["cpu"] = "MUTATED" + assert.Equal(t, "2", copied["cpu"]) + }) + + t.Run("nested map", func(t *testing.T) { + original := map[string]any{ + "limits": map[string]any{"cpu": "4", "memory": "8Gi"}, + } + copied := copyAnyMap(original) + + nested := original["limits"].(map[string]any) + nested["cpu"] = "MUTATED" + + copiedNested := copied["limits"].(map[string]any) + assert.Equal(t, "4", copiedNested["cpu"]) + }) + + t.Run("nested slice", func(t *testing.T) { + original := map[string]any{ + "ports": []any{float64(80), float64(443)}, + } + copied := copyAnyMap(original) + + original["ports"].([]any)[0] = float64(9999) + assert.Equal(t, float64(80), copied["ports"].([]any)[0]) + }) + + t.Run("scalar types", func(t *testing.T) { + original := map[string]any{ + "str": "hello", "num": float64(42), "flag": true, "null": nil, + } + copied := copyAnyMap(original) + assert.Equal(t, original, copied) + }) +} + +func TestCopySandboxTemplate_ResourcesDeepCopy(t *testing.T) { + tmpl := types.SandboxTemplate{ + Image: "img:v1", + Resources: map[string]any{"cpu": "2", "nested": map[string]any{"key": "val"}}, + DriverConfig: map[string]any{"runtime": "kata"}, + } + + copied := copySandboxTemplate(tmpl) + + tmpl.Resources["cpu"] = "MUTATED" + assert.Equal(t, "2", copied.Resources["cpu"]) + + tmpl.DriverConfig["runtime"] = "MUTATED" + assert.Equal(t, "kata", copied.DriverConfig["runtime"]) + + nested := tmpl.Resources["nested"].(map[string]any) + nested["key"] = "MUTATED" + copiedNested := copied.Resources["nested"].(map[string]any) + assert.Equal(t, "val", copiedNested["key"]) +} + func TestSandbox_Create_NilSpec(t *testing.T) { sc := newTestSandboxClient() ctx := context.Background()