diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 0ff8fd97f..05c152af8 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -54,7 +54,7 @@ jobs:
context: ./authbridge
dockerfile: cmd/authbridge-proxy/Dockerfile
build_args: |
- GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker
+ GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_placeholderresolve,exclude_plugin_sparc,exclude_plugin_tokenbroker
# SPARC reflection service — the backend the `sparc` plugin calls.
# Deployed once per cluster via authbridge/sparc-service/deploy.
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 87bad7b2c..df7d4c84e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -108,7 +108,7 @@ jobs:
if: matrix.binary == 'authbridge-proxy'
run: |
TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser"
- TAGS="$TAGS,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker"
+ TAGS="$TAGS,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_placeholderresolve,exclude_plugin_sparc,exclude_plugin_tokenbroker"
go build -v -tags "$TAGS" ./...
go test -v -race -cover -tags "$TAGS" ./...
diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md
index 5574ebba1..5f85bb1cf 100644
--- a/authbridge/CLAUDE.md
+++ b/authbridge/CLAUDE.md
@@ -362,7 +362,7 @@ podman build -f cmd/authbridge-proxy/Dockerfile -t authbridge:latest . # p
podman build -f cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:latest . # envoy-sidecar
# authbridge-lite: the proxy Dockerfile built with exclude_plugin_* tags (auth-only)
podman build -f cmd/authbridge-proxy/Dockerfile \
- --build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" \
+ --build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_placeholderresolve,exclude_plugin_sparc,exclude_plugin_tokenbroker" \
-t authbridge-lite:latest .
kind load docker-image authbridge:latest --name kagenti
kind load docker-image authbridge-envoy:latest --name kagenti
diff --git a/authbridge/authlib/credinject/credinject.go b/authbridge/authlib/credinject/credinject.go
new file mode 100644
index 000000000..4c14284e9
--- /dev/null
+++ b/authbridge/authlib/credinject/credinject.go
@@ -0,0 +1,95 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package credinject provides reusable primitives for resolving a credential
+// and safely injecting it into an HTTP header — the shared core behind
+// credential-injecting plugins (e.g. placeholder-resolve, and a future
+// host-keyed injector). It is transport-agnostic and imports no plugin, so
+// plugins depend on it rather than the reverse.
+package credinject
+
+import (
+ "context"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/config"
+)
+
+// Resolver maps a lookup key to a real credential value. ok is false when the
+// key is unknown or unavailable — callers MUST fail closed on a false (never
+// forward the unresolved key).
+type Resolver interface {
+ Resolve(ctx context.Context, key string) (value string, ok bool)
+}
+
+// LifecycleResolver is implemented by resolvers that need background warm-up
+// (e.g. a remote/cached source). Static resolvers omit it and are treated as
+// always ready.
+type LifecycleResolver interface {
+ Start(ctx context.Context) error
+ Ready() bool
+ Stop(ctx context.Context) error
+}
+
+// DenyResolver resolves nothing. It is the fail-closed default for an
+// unconfigured source, so a missing source never silently resolves from an
+// ambient source (e.g. the process environment).
+type DenyResolver struct{}
+
+// Resolve always fails closed.
+func (DenyResolver) Resolve(context.Context, string) (string, bool) { return "", false }
+
+// MapResolver resolves from an inline map. For tests/dev only — it holds
+// cleartext credentials in process memory and is not a production source.
+type MapResolver map[string]string
+
+// Resolve returns the mapped value for key, if present.
+func (m MapResolver) Resolve(_ context.Context, key string) (string, bool) {
+ v, ok := m[key]
+ return v, ok
+}
+
+// FileResolver resolves each key by reading
/ as a credential file
+// (whitespace-trimmed via config.ReadCredentialFile). The key is path-
+// contained: any key that is not a direct child filename of Dir (i.e. contains
+// a separator or "..") is rejected, so the resolver is safe for arbitrary,
+// caller-supplied keys such as a destination host.
+type FileResolver struct{ Dir string }
+
+// Resolve reads /, rejecting keys that would escape Dir.
+func (f FileResolver) Resolve(_ context.Context, key string) (string, bool) {
+ if key == "" {
+ return "", false
+ }
+ p := filepath.Join(f.Dir, key)
+ // The joined path must be a direct child of Dir — this rejects separators,
+ // absolute keys, and "../" traversal regardless of the key's grammar.
+ if filepath.Dir(p) != filepath.Clean(f.Dir) {
+ return "", false
+ }
+ v, err := config.ReadCredentialFile(p)
+ if err != nil {
+ return "", false
+ }
+ return v, true
+}
+
+// SafeHeaderValue reports whether v is safe to place in an HTTP header value.
+// It rejects CR, LF, and NUL to prevent header/response splitting (CWE-113)
+// via a poisoned credential store.
+func SafeHeaderValue(v string) bool {
+ return !strings.ContainsAny(v, "\r\n\x00")
+}
+
+// SafeSetHeader sets h[name]=value only when value is header-safe. It returns
+// false and leaves h unmodified when value is unsafe, so the caller can fail
+// closed rather than forward a poisoned value.
+func SafeSetHeader(h http.Header, name, value string) bool {
+ if !SafeHeaderValue(value) {
+ return false
+ }
+ h.Set(name, value)
+ return true
+}
diff --git a/authbridge/authlib/credinject/credinject_test.go b/authbridge/authlib/credinject/credinject_test.go
new file mode 100644
index 000000000..6ded3e48b
--- /dev/null
+++ b/authbridge/authlib/credinject/credinject_test.go
@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package credinject
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDenyResolverFailsClosed(t *testing.T) {
+ if v, ok := (DenyResolver{}).Resolve(context.Background(), "ANYTHING"); ok || v != "" {
+ t.Errorf("DenyResolver.Resolve = (%q, %v), want (\"\", false)", v, ok)
+ }
+}
+
+func TestMapResolver(t *testing.T) {
+ m := MapResolver{"K": "v"}
+ if v, ok := m.Resolve(context.Background(), "K"); !ok || v != "v" {
+ t.Errorf("hit = (%q,%v), want (v,true)", v, ok)
+ }
+ if _, ok := m.Resolve(context.Background(), "MISS"); ok {
+ t.Error("miss should be ok=false")
+ }
+}
+
+func TestFileResolverReadsDirectChild(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "TOKEN"), []byte(" sk-real\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ v, ok := FileResolver{Dir: dir}.Resolve(context.Background(), "TOKEN")
+ if !ok || v != "sk-real" { // ReadCredentialFile trims surrounding whitespace
+ t.Errorf("Resolve = (%q,%v), want (sk-real,true)", v, ok)
+ }
+}
+
+func TestFileResolverRejectsTraversalAndSeparators(t *testing.T) {
+ dir := t.TempDir()
+ // A secret one level up that must never be reachable.
+ if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "SECRET"), []byte("leak"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ fr := FileResolver{Dir: dir}
+ for _, key := range []string{"../SECRET", "sub/TOKEN", "/etc/passwd", "", "a/../../SECRET"} {
+ if v, ok := fr.Resolve(context.Background(), key); ok {
+ t.Errorf("Resolve(%q) = (%q,true), want ok=false (containment)", key, v)
+ }
+ }
+}
+
+func TestSafeHeaderValue(t *testing.T) {
+ for _, ok := range []string{"sk-abc123", "Bearer x.y.z", ""} {
+ if !SafeHeaderValue(ok) {
+ t.Errorf("SafeHeaderValue(%q) = false, want true", ok)
+ }
+ }
+ for _, bad := range []string{"a\rb", "a\nb", "a\x00b", "tok\r\nInjected: 1"} {
+ if SafeHeaderValue(bad) {
+ t.Errorf("SafeHeaderValue(%q) = true, want false", bad)
+ }
+ }
+}
+
+func TestSafeSetHeader(t *testing.T) {
+ h := http.Header{}
+ if !SafeSetHeader(h, "Authorization", "Bearer ok") {
+ t.Fatal("safe value should set")
+ }
+ if got := h.Get("Authorization"); got != "Bearer ok" {
+ t.Errorf("Authorization = %q", got)
+ }
+ h2 := http.Header{}
+ if SafeSetHeader(h2, "Authorization", "Bearer bad\r\nX: y") {
+ t.Fatal("unsafe value should not set")
+ }
+ if h2.Get("Authorization") != "" {
+ t.Error("header must be unmodified on unsafe value")
+ }
+}
diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod
index 197612414..66859a89c 100644
--- a/authbridge/authlib/go.mod
+++ b/authbridge/authlib/go.mod
@@ -13,6 +13,7 @@ require (
golang.org/x/sys v0.46.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171
google.golang.org/grpc v1.81.1
+ google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -71,7 +72,6 @@ require (
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.12.0 // indirect
- google.golang.org/protobuf v1.36.11 // indirect
oras.land/oras-go/v2 v2.5.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
diff --git a/authbridge/authlib/openshell/buf.gen.yaml b/authbridge/authlib/openshell/buf.gen.yaml
new file mode 100644
index 000000000..b08ab3c64
--- /dev/null
+++ b/authbridge/authlib/openshell/buf.gen.yaml
@@ -0,0 +1,21 @@
+# Generates Go gRPC stubs from the trimmed OpenShell gateway proto. Only the two
+# RPCs the placeholder-resolve gateway resolver calls are vendored (see
+# proto/openshell.proto), so a single go_package override is needed. The proto
+# declares no go_package, so managed mode injects it; module= output places it
+# under genproto/openshellv1. Remote plugins are pinned to match authlib/go.mod.
+#
+# Regenerate: cd authlib/openshell && buf generate
+version: v2
+managed:
+ enabled: true
+ override:
+ - file_option: go_package
+ path: openshell.proto
+ value: github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto/openshellv1
+plugins:
+ - remote: buf.build/protocolbuffers/go:v1.36.6
+ out: genproto
+ opt: module=github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto
+ - remote: buf.build/grpc/go:v1.5.1
+ out: genproto
+ opt: module=github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto
diff --git a/authbridge/authlib/openshell/buf.yaml b/authbridge/authlib/openshell/buf.yaml
new file mode 100644
index 000000000..ba5ddf507
--- /dev/null
+++ b/authbridge/authlib/openshell/buf.yaml
@@ -0,0 +1,3 @@
+version: v2
+modules:
+ - path: proto
diff --git a/authbridge/authlib/openshell/client.go b/authbridge/authlib/openshell/client.go
new file mode 100644
index 000000000..30b0ef785
--- /dev/null
+++ b/authbridge/authlib/openshell/client.go
@@ -0,0 +1,303 @@
+// Package openshell is a Go client for the OpenShell gateway's credential
+// surface. It replicates how the sandbox supervisor authenticates and fetches
+// its resolved provider environment, so AuthBridge — running as a sidecar in
+// the sandbox pod — can do the same:
+//
+// 1. read the pod's projected SA token (audience openshell-gateway);
+// 2. dial the gateway over mTLS;
+// 3. IssueSandboxToken (Bearer ) → a gateway-minted sandbox JWT;
+// 4. GetSandboxProviderEnvironment(sandbox_id) (Bearer ) → the resolved
+// real credential values keyed by env-var name.
+//
+// The gateway restricts these RPCs to the sandbox's own identity, so this
+// client only succeeds when it runs in the sandbox pod (shared SA + the
+// openshell.io/sandbox-id annotation).
+package openshell
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "log/slog"
+ "net"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/config"
+ osv1 "github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto/openshellv1"
+)
+
+// rpcTimeout bounds each gateway RPC so a connected-but-unresponsive gateway
+// cannot block a caller (e.g. the resolver's background refresh loop) forever.
+const rpcTimeout = 10 * time.Second
+
+// Config locates the gateway and the credentials needed to talk to it. The
+// paths mirror what the OpenShell k8s driver injects into the sandbox pod.
+type Config struct {
+ // Endpoint is the gateway gRPC endpoint (OPENSHELL_ENDPOINT). An
+ // https:// scheme selects mTLS; http:// is plaintext.
+ Endpoint string
+ // MTLSCert, MTLSKey, MTLSCA are the client cert, key, and CA file paths for
+ // mTLS — independent paths mirroring OpenShell's OPENSHELL_TLS_CERT /
+ // OPENSHELL_TLS_KEY / OPENSHELL_TLS_CA (the driver splits the CA into a
+ // separate secret/dir from the client keypair). All three are required when
+ // Endpoint is https://.
+ MTLSCert string
+ MTLSKey string
+ MTLSCA string
+ // SATokenPath is the projected SA token file (OPENSHELL_K8S_SA_TOKEN_FILE,
+ // e.g. /var/run/secrets/openshell/token).
+ SATokenPath string
+ // SandboxID is this sandbox's id (OPENSHELL_SANDBOX_ID); it must equal
+ // the id the gateway binds into the minted JWT, or the gateway rejects
+ // the provider-environment fetch as cross-sandbox access.
+ SandboxID string
+ // Insecure permits a plaintext (non-TLS) connection to a non-loopback
+ // gateway. Plaintext sends the SA token and minted JWT as cleartext gRPC
+ // metadata, so it is refused by default for non-loopback targets; this
+ // flag is an explicit opt-in (logged with a warning). Loopback targets are
+ // always allowed plaintext.
+ Insecure bool
+}
+
+func (c Config) validate() error {
+ if c.Endpoint == "" {
+ return fmt.Errorf("openshell: endpoint is required")
+ }
+ if c.SATokenPath == "" {
+ return fmt.Errorf("openshell: sa_token_path is required")
+ }
+ if c.SandboxID == "" {
+ return fmt.Errorf("openshell: sandbox_id is required")
+ }
+ if strings.HasPrefix(c.Endpoint, "https://") && (c.MTLSCert == "" || c.MTLSKey == "" || c.MTLSCA == "") {
+ return fmt.Errorf("openshell: mtls_cert, mtls_key, and mtls_ca are required for an https endpoint")
+ }
+ return nil
+}
+
+// Environment is the resolved provider environment for a sandbox.
+type Environment struct {
+ // Values maps env-var name (e.g. ANTHROPIC_AUTH_TOKEN) to the real
+ // resolved credential value.
+ Values map[string]string
+ // Revision is a fingerprint of the inputs that produced Values.
+ Revision uint64
+ // ExpiresAtMs is the per-key expiry (epoch ms); absent/0 means no expiry.
+ ExpiresAtMs map[string]int64
+}
+
+// Client talks to the OpenShell gateway. It is safe for concurrent use.
+type Client struct {
+ cfg Config
+ conn *grpc.ClientConn
+ rpc osv1.OpenShellClient
+
+ mu sync.RWMutex
+ token string // current gateway JWT
+ tokenExp time.Time // zero = unknown / non-expiring
+}
+
+// Dial connects to the gateway. It does not contact the server until the
+// first RPC, so a successful Dial does not imply reachability.
+func Dial(cfg Config) (*Client, error) {
+ if err := cfg.validate(); err != nil {
+ return nil, err
+ }
+
+ target := cfg.Endpoint
+ var creds credentials.TransportCredentials
+ plaintext := false
+ switch {
+ case strings.HasPrefix(cfg.Endpoint, "https://"):
+ target = strings.TrimPrefix(cfg.Endpoint, "https://")
+ tlsCfg, err := mtlsConfig(cfg.MTLSCert, cfg.MTLSKey, cfg.MTLSCA, hostOnly(target))
+ if err != nil {
+ return nil, err
+ }
+ creds = credentials.NewTLS(tlsCfg)
+ case strings.HasPrefix(cfg.Endpoint, "http://"):
+ target = strings.TrimPrefix(cfg.Endpoint, "http://")
+ plaintext = true
+ default:
+ // No scheme: mTLS when client cert material is configured, else plaintext.
+ if cfg.MTLSCert != "" {
+ tlsCfg, err := mtlsConfig(cfg.MTLSCert, cfg.MTLSKey, cfg.MTLSCA, hostOnly(target))
+ if err != nil {
+ return nil, err
+ }
+ creds = credentials.NewTLS(tlsCfg)
+ } else {
+ plaintext = true
+ }
+ }
+
+ if plaintext {
+ // Fail closed: a plaintext transport leaks the SA token and the minted
+ // gateway JWT as cleartext metadata. Only allow it for loopback, or
+ // behind the explicit Insecure opt-in (with a warning).
+ if !cfg.Insecure && !isLoopbackHost(target) {
+ return nil, fmt.Errorf("openshell: refusing plaintext gRPC to non-loopback %q — the SA token and gateway JWT would travel in cleartext; use an https:// endpoint with mtls_cert_dir, or set insecure: true to override", target)
+ }
+ if !isLoopbackHost(target) {
+ slog.Warn("openshell: insecure plaintext gRPC to the gateway — SA token and minted JWT travel unencrypted", "endpoint", cfg.Endpoint)
+ }
+ creds = insecure.NewCredentials()
+ }
+
+ conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(creds))
+ if err != nil {
+ return nil, fmt.Errorf("openshell: dial %q: %w", target, err)
+ }
+ return &Client{cfg: cfg, conn: conn, rpc: osv1.NewOpenShellClient(conn)}, nil
+}
+
+// FetchEnvironment returns the sandbox's resolved provider environment,
+// minting (or re-minting) the gateway JWT as needed.
+func (c *Client) FetchEnvironment(ctx context.Context) (*Environment, error) {
+ tok, err := c.ensureToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+ env, err := c.fetchWithToken(ctx, tok)
+ if status.Code(err) == codes.Unauthenticated {
+ // JWT rejected (expired or rotated under us) — re-bootstrap from the
+ // (possibly rotated) SA token and retry once.
+ if mErr := c.mint(ctx); mErr != nil {
+ return nil, mErr
+ }
+ tok, _ = c.getToken()
+ env, err = c.fetchWithToken(ctx, tok)
+ }
+ return env, err
+}
+
+// Close releases the underlying connection.
+func (c *Client) Close() error {
+ if c.conn != nil {
+ return c.conn.Close()
+ }
+ return nil
+}
+
+func (c *Client) fetchWithToken(ctx context.Context, tok string) (*Environment, error) {
+ ctx, cancel := context.WithTimeout(ctx, rpcTimeout)
+ defer cancel()
+ md := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+tok)
+ resp, err := c.rpc.GetSandboxProviderEnvironment(md, &osv1.GetSandboxProviderEnvironmentRequest{
+ SandboxId: c.cfg.SandboxID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("openshell: GetSandboxProviderEnvironment: %w", err)
+ }
+ return &Environment{
+ Values: resp.GetEnvironment(),
+ Revision: resp.GetProviderEnvRevision(),
+ ExpiresAtMs: resp.GetCredentialExpiresAtMs(),
+ }, nil
+}
+
+// ensureToken returns a non-expired gateway JWT, minting one if absent or
+// expired.
+func (c *Client) ensureToken(ctx context.Context) (string, error) {
+ if tok, exp := c.getToken(); tok != "" && (exp.IsZero() || time.Now().Before(exp)) {
+ return tok, nil
+ }
+ if err := c.mint(ctx); err != nil {
+ return "", err
+ }
+ tok, _ := c.getToken()
+ return tok, nil
+}
+
+// mint exchanges the projected SA token for a gateway JWT via IssueSandboxToken.
+func (c *Client) mint(ctx context.Context) error {
+ sa, err := config.ReadCredentialFile(c.cfg.SATokenPath)
+ if err != nil {
+ return fmt.Errorf("openshell: read SA token %q: %w", c.cfg.SATokenPath, err)
+ }
+ ctx, cancel := context.WithTimeout(ctx, rpcTimeout)
+ defer cancel()
+ md := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+sa)
+ resp, err := c.rpc.IssueSandboxToken(md, &osv1.IssueSandboxTokenRequest{})
+ if err != nil {
+ return fmt.Errorf("openshell: IssueSandboxToken: %w", err)
+ }
+ c.setToken(resp.GetToken(), resp.GetExpiresAtMs())
+ return nil
+}
+
+func (c *Client) setToken(tok string, expMs int64) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.token = tok
+ if expMs > 0 {
+ c.tokenExp = time.UnixMilli(expMs)
+ } else {
+ c.tokenExp = time.Time{}
+ }
+}
+
+func (c *Client) getToken() (string, time.Time) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.token, c.tokenExp
+}
+
+// mtlsConfig builds a client mTLS config from independent cert, key, and CA
+// file paths (mirroring OpenShell's OPENSHELL_TLS_CERT/KEY/CA — the driver puts
+// the CA in a different secret/dir than the client keypair). The CA pool extends
+// the system roots (mirrors authlib/tlsbridge/upstream.go).
+func mtlsConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) {
+ cert, err := tls.LoadX509KeyPair(certPath, keyPath)
+ if err != nil {
+ return nil, fmt.Errorf("openshell: load client cert (%q, %q): %w", certPath, keyPath, err)
+ }
+ pool, err := x509.SystemCertPool()
+ if err != nil || pool == nil {
+ pool = x509.NewCertPool()
+ }
+ caPEM, err := os.ReadFile(caPath)
+ if err != nil {
+ return nil, fmt.Errorf("openshell: read ca %q: %w", caPath, err)
+ }
+ if !pool.AppendCertsFromPEM(caPEM) {
+ return nil, fmt.Errorf("openshell: ca %q is not valid PEM", caPath)
+ }
+ return &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ RootCAs: pool,
+ ServerName: serverName,
+ MinVersion: tls.VersionTLS12,
+ }, nil
+}
+
+// hostOnly strips a :port suffix, leaving the host for ServerName.
+func hostOnly(hostport string) string {
+ if h, _, err := net.SplitHostPort(hostport); err == nil {
+ return h
+ }
+ return hostport
+}
+
+// isLoopbackHost reports whether the host part of hostport is localhost or a
+// loopback IP — the only targets for which plaintext gRPC is allowed without
+// the explicit Insecure opt-in.
+func isLoopbackHost(hostport string) bool {
+ h := hostOnly(hostport)
+ if h == "localhost" {
+ return true
+ }
+ ip := net.ParseIP(h)
+ return ip != nil && ip.IsLoopback()
+}
diff --git a/authbridge/authlib/openshell/client_test.go b/authbridge/authlib/openshell/client_test.go
new file mode 100644
index 000000000..4a477bbd6
--- /dev/null
+++ b/authbridge/authlib/openshell/client_test.go
@@ -0,0 +1,297 @@
+package openshell
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+
+ osv1 "github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto/openshellv1"
+)
+
+// mockGateway is an in-process OpenShell gateway for client tests. It records
+// the bearer credentials it observed so tests can assert the two-step auth
+// (SA token -> IssueSandboxToken, then JWT -> GetSandboxProviderEnvironment).
+type mockGateway struct {
+ osv1.UnimplementedOpenShellServer
+
+ mu sync.Mutex
+ sawSAToken string
+ sawJWT string
+ sawSandboxID string
+ issueCalls int
+ fetchCalls int
+ failFirstFetch bool // return Unauthenticated on the first fetch
+}
+
+func bearerOf(ctx context.Context) string {
+ md, _ := metadata.FromIncomingContext(ctx)
+ v := md.Get("authorization")
+ if len(v) == 0 {
+ return ""
+ }
+ return strings.TrimPrefix(v[0], "Bearer ")
+}
+
+func (m *mockGateway) IssueSandboxToken(ctx context.Context, _ *osv1.IssueSandboxTokenRequest) (*osv1.IssueSandboxTokenResponse, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.issueCalls++
+ m.sawSAToken = bearerOf(ctx)
+ return &osv1.IssueSandboxTokenResponse{Token: "jwt-" + strconv.Itoa(m.issueCalls)}, nil
+}
+
+func (m *mockGateway) GetSandboxProviderEnvironment(ctx context.Context, req *osv1.GetSandboxProviderEnvironmentRequest) (*osv1.GetSandboxProviderEnvironmentResponse, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.fetchCalls++
+ m.sawJWT = bearerOf(ctx)
+ m.sawSandboxID = req.GetSandboxId()
+ if m.failFirstFetch && m.fetchCalls == 1 {
+ return nil, status.Error(codes.Unauthenticated, "jwt expired")
+ }
+ return &osv1.GetSandboxProviderEnvironmentResponse{
+ Environment: map[string]string{"ANTHROPIC_AUTH_TOKEN": "sk-real"},
+ }, nil
+}
+
+func startMockGateway(t *testing.T, m *mockGateway) string {
+ t.Helper()
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ srv := grpc.NewServer()
+ osv1.RegisterOpenShellServer(srv, m)
+ go func() { _ = srv.Serve(lis) }()
+ t.Cleanup(srv.Stop)
+ return lis.Addr().String()
+}
+
+func writeSAToken(t *testing.T, value string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "token")
+ if err := os.WriteFile(p, []byte(value), 0o600); err != nil {
+ t.Fatalf("write SA token: %v", err)
+ }
+ return p
+}
+
+func TestClientFetchEnvironment(t *testing.T) {
+ m := &mockGateway{}
+ addr := startMockGateway(t, m)
+ c, err := Dial(Config{
+ Endpoint: "http://" + addr,
+ SATokenPath: writeSAToken(t, "sa-token-abc"),
+ SandboxID: "sb-123",
+ })
+ if err != nil {
+ t.Fatalf("Dial: %v", err)
+ }
+ defer func() { _ = c.Close() }()
+
+ env, err := c.FetchEnvironment(context.Background())
+ if err != nil {
+ t.Fatalf("FetchEnvironment: %v", err)
+ }
+ if got := env.Values["ANTHROPIC_AUTH_TOKEN"]; got != "sk-real" {
+ t.Errorf("env[ANTHROPIC_AUTH_TOKEN] = %q, want sk-real", got)
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.sawSAToken != "sa-token-abc" {
+ t.Errorf("IssueSandboxToken saw bearer %q, want the SA token", m.sawSAToken)
+ }
+ if m.sawJWT != "jwt-1" {
+ t.Errorf("GetSandboxProviderEnvironment saw bearer %q, want the minted jwt-1", m.sawJWT)
+ }
+ if m.sawSandboxID != "sb-123" {
+ t.Errorf("saw sandbox_id %q, want sb-123", m.sawSandboxID)
+ }
+}
+
+func TestClientReMintOnUnauthenticated(t *testing.T) {
+ m := &mockGateway{failFirstFetch: true}
+ addr := startMockGateway(t, m)
+ c, err := Dial(Config{
+ Endpoint: "http://" + addr,
+ SATokenPath: writeSAToken(t, "sa-token-abc"),
+ SandboxID: "sb-123",
+ })
+ if err != nil {
+ t.Fatalf("Dial: %v", err)
+ }
+ defer func() { _ = c.Close() }()
+
+ env, err := c.FetchEnvironment(context.Background())
+ if err != nil {
+ t.Fatalf("FetchEnvironment: %v", err)
+ }
+ if env.Values["ANTHROPIC_AUTH_TOKEN"] != "sk-real" {
+ t.Error("expected sk-real after re-mint")
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.issueCalls != 2 {
+ t.Errorf("issueCalls = %d, want 2 (initial mint + re-mint after Unauthenticated)", m.issueCalls)
+ }
+}
+
+func TestDialRefusesPlaintextNonLoopback(t *testing.T) {
+ _, err := Dial(Config{
+ Endpoint: "http://gw.example.com:8080",
+ SATokenPath: writeSAToken(t, "sa"),
+ SandboxID: "sb",
+ })
+ if err == nil {
+ t.Fatal("expected fail-closed error for non-loopback plaintext")
+ }
+ if !strings.Contains(err.Error(), "refusing plaintext") {
+ t.Errorf("error = %v, want a refusing-plaintext error", err)
+ }
+}
+
+func TestDialInsecureOptIn(t *testing.T) {
+ c, err := Dial(Config{
+ Endpoint: "http://gw.example.com:8080",
+ SATokenPath: writeSAToken(t, "sa"),
+ SandboxID: "sb",
+ Insecure: true,
+ })
+ if err != nil {
+ t.Fatalf("Insecure opt-in should dial plaintext: %v", err)
+ }
+ _ = c.Close()
+}
+
+func TestDialLoopbackPlaintextAllowed(t *testing.T) {
+ for _, ep := range []string{"http://127.0.0.1:8080", "http://localhost:8080", "http://[::1]:8080"} {
+ c, err := Dial(Config{Endpoint: ep, SATokenPath: writeSAToken(t, "sa"), SandboxID: "sb"})
+ if err != nil {
+ t.Errorf("loopback %q should dial without Insecure: %v", ep, err)
+ continue
+ }
+ _ = c.Close()
+ }
+}
+
+func TestDialHTTPSUsesMTLS(t *testing.T) {
+ dir := writeMTLSCertDir(t)
+ c, err := Dial(Config{
+ Endpoint: "https://gw.example.com:8080",
+ MTLSCert: filepath.Join(dir, "tls.crt"),
+ MTLSKey: filepath.Join(dir, "tls.key"),
+ MTLSCA: filepath.Join(dir, "ca.crt"),
+ SATokenPath: writeSAToken(t, "sa"),
+ SandboxID: "sb",
+ })
+ if err != nil {
+ t.Fatalf("https dial with cert dir: %v", err)
+ }
+ _ = c.Close()
+}
+
+func TestDialHTTPSRequiresMTLS(t *testing.T) {
+ _, err := Dial(Config{
+ Endpoint: "https://gw.example.com:8080",
+ SATokenPath: writeSAToken(t, "sa"),
+ SandboxID: "sb",
+ })
+ if err == nil {
+ t.Fatal("https without mtls_cert/key/ca should fail validation")
+ }
+}
+
+func TestMTLSConfig(t *testing.T) {
+ dir := writeMTLSCertDir(t)
+ cfg, err := mtlsConfig(filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key"), filepath.Join(dir, "ca.crt"), "gw.example.com")
+ if err != nil {
+ t.Fatalf("mtlsConfig: %v", err)
+ }
+ if cfg.ServerName != "gw.example.com" {
+ t.Errorf("ServerName = %q, want gw.example.com", cfg.ServerName)
+ }
+ if len(cfg.Certificates) != 1 {
+ t.Errorf("client certs = %d, want 1", len(cfg.Certificates))
+ }
+}
+
+func TestHostOnly(t *testing.T) {
+ for in, want := range map[string]string{
+ "host:443": "host",
+ "host": "host",
+ "127.0.0.1:8080": "127.0.0.1",
+ "[::1]:8080": "::1",
+ } {
+ if got := hostOnly(in); got != want {
+ t.Errorf("hostOnly(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestIsLoopbackHost(t *testing.T) {
+ for _, h := range []string{"127.0.0.1:8080", "localhost:443", "[::1]:80", "127.0.0.1"} {
+ if !isLoopbackHost(h) {
+ t.Errorf("isLoopbackHost(%q) = false, want true", h)
+ }
+ }
+ for _, h := range []string{"gw.example.com:8080", "10.0.0.5:443", "8.8.8.8"} {
+ if isLoopbackHost(h) {
+ t.Errorf("isLoopbackHost(%q) = true, want false", h)
+ }
+ }
+}
+
+// writeMTLSCertDir generates a self-signed CA cert + key and writes it as
+// ca.crt / tls.crt / tls.key in a temp dir, for exercising the mTLS branch.
+func writeMTLSCertDir(t *testing.T) string {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("gen key: %v", err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "test-ca"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ IsCA: true,
+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
+ BasicConstraintsValid: true,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatalf("create cert: %v", err)
+ }
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ keyDER, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ t.Fatalf("marshal key: %v", err)
+ }
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
+ dir := t.TempDir()
+ for name, data := range map[string][]byte{"ca.crt": certPEM, "tls.crt": certPEM, "tls.key": keyPEM} {
+ if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+ }
+ return dir
+}
diff --git a/authbridge/authlib/openshell/genproto/openshellv1/openshell.pb.go b/authbridge/authlib/openshell/genproto/openshellv1/openshell.pb.go
new file mode 100644
index 000000000..738cfd866
--- /dev/null
+++ b/authbridge/authlib/openshell/genproto/openshellv1/openshell.pb.go
@@ -0,0 +1,313 @@
+// 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.6
+// protoc (unknown)
+// source: openshell.proto
+
+package openshellv1
+
+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)
+)
+
+// IssueSandboxToken request. The caller is identified by its SA-token bearer
+// credential, so the request body carries no fields.
+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.
+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
+}
+
+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[2]
+ 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[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 GetSandboxProviderEnvironmentRequest.ProtoReflect.Descriptor instead.
+func (*GetSandboxProviderEnvironmentRequest) Descriptor() ([]byte, []int) {
+ return file_openshell_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *GetSandboxProviderEnvironmentRequest) GetSandboxId() string {
+ if x != nil {
+ return x.SandboxId
+ }
+ return ""
+}
+
+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"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetSandboxProviderEnvironmentResponse) Reset() {
+ *x = GetSandboxProviderEnvironmentResponse{}
+ mi := &file_openshell_proto_msgTypes[3]
+ 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[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 GetSandboxProviderEnvironmentResponse.ProtoReflect.Descriptor instead.
+func (*GetSandboxProviderEnvironmentResponse) Descriptor() ([]byte, []int) {
+ return file_openshell_proto_rawDescGZIP(), []int{3}
+}
+
+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
+}
+
+var File_openshell_proto protoreflect.FileDescriptor
+
+const file_openshell_proto_rawDesc = "" +
+ "\n" +
+ "\x0fopenshell.proto\x12\fopenshell.v1\"\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\"E\n" +
+ "$GetSandboxProviderEnvironmentRequest\x12\x1d\n" +
+ "\n" +
+ "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\xd7\x03\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\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\x012\xfc\x01\n" +
+ "\tOpenShell\x12\x88\x01\n" +
+ "\x1dGetSandboxProviderEnvironment\x122.openshell.v1.GetSandboxProviderEnvironmentRequest\x1a3.openshell.v1.GetSandboxProviderEnvironmentResponse\x12d\n" +
+ "\x11IssueSandboxToken\x12&.openshell.v1.IssueSandboxTokenRequest\x1a'.openshell.v1.IssueSandboxTokenResponseB\xcc\x01\n" +
+ "\x10com.openshell.v1B\x0eOpenshellProtoP\x01ZWgithub.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto/openshellv1\xa2\x02\x03OXX\xaa\x02\fOpenshell.V1\xca\x02\fOpenshell\\V1\xe2\x02\x18Openshell\\V1\\GPBMetadata\xea\x02\rOpenshell::V1b\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_msgTypes = make([]protoimpl.MessageInfo, 6)
+var file_openshell_proto_goTypes = []any{
+ (*IssueSandboxTokenRequest)(nil), // 0: openshell.v1.IssueSandboxTokenRequest
+ (*IssueSandboxTokenResponse)(nil), // 1: openshell.v1.IssueSandboxTokenResponse
+ (*GetSandboxProviderEnvironmentRequest)(nil), // 2: openshell.v1.GetSandboxProviderEnvironmentRequest
+ (*GetSandboxProviderEnvironmentResponse)(nil), // 3: openshell.v1.GetSandboxProviderEnvironmentResponse
+ nil, // 4: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry
+ nil, // 5: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry
+}
+var file_openshell_proto_depIdxs = []int32{
+ 4, // 0: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry
+ 5, // 1: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry
+ 2, // 2: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest
+ 0, // 3: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest
+ 3, // 4: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse
+ 1, // 5: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse
+ 4, // [4:6] is the sub-list for method output_type
+ 2, // [2:4] is the sub-list for method input_type
+ 2, // [2:2] is the sub-list for extension type_name
+ 2, // [2:2] is the sub-list for extension extendee
+ 0, // [0:2] 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
+ }
+ 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: 0,
+ NumMessages: 6,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_openshell_proto_goTypes,
+ DependencyIndexes: file_openshell_proto_depIdxs,
+ MessageInfos: file_openshell_proto_msgTypes,
+ }.Build()
+ File_openshell_proto = out.File
+ file_openshell_proto_goTypes = nil
+ file_openshell_proto_depIdxs = nil
+}
diff --git a/authbridge/authlib/openshell/genproto/openshellv1/openshell_grpc.pb.go b/authbridge/authlib/openshell/genproto/openshellv1/openshell_grpc.pb.go
new file mode 100644
index 000000000..23ad9cc49
--- /dev/null
+++ b/authbridge/authlib/openshell/genproto/openshellv1/openshell_grpc.pb.go
@@ -0,0 +1,180 @@
+// 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.5.1
+// - protoc (unknown)
+// source: openshell.proto
+
+package openshellv1
+
+import (
+ context "context"
+ 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_GetSandboxProviderEnvironment_FullMethodName = "/openshell.v1.OpenShell/GetSandboxProviderEnvironment"
+ OpenShell_IssueSandboxToken_FullMethodName = "/openshell.v1.OpenShell/IssueSandboxToken"
+)
+
+// 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.
+//
+// Minimal subset of the OpenShell gateway API: only the two RPCs the AuthBridge
+// placeholder-resolve gateway resolver calls, plus their messages. The full
+// OpenShell API is intentionally NOT vendored here. The package, service, RPC,
+// and message names and field numbers match the gateway's openshell.v1.OpenShell
+// service exactly, so the wire contract is unchanged.
+type OpenShellClient interface {
+ // Get provider environment for a sandbox (called by the sandbox supervisor, and
+ // by an AuthBridge sidecar, at startup). Restricted to the sandbox's own identity.
+ GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error)
+ // Mint a gateway JWT bound to the calling sandbox's identity (Bearer SA token).
+ IssueSandboxToken(ctx context.Context, in *IssueSandboxTokenRequest, opts ...grpc.CallOption) (*IssueSandboxTokenResponse, error)
+}
+
+type openShellClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewOpenShellClient(cc grpc.ClientConnInterface) OpenShellClient {
+ return &openShellClient{cc}
+}
+
+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) 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
+}
+
+// OpenShellServer is the server API for OpenShell service.
+// All implementations must embed UnimplementedOpenShellServer
+// for forward compatibility.
+//
+// Minimal subset of the OpenShell gateway API: only the two RPCs the AuthBridge
+// placeholder-resolve gateway resolver calls, plus their messages. The full
+// OpenShell API is intentionally NOT vendored here. The package, service, RPC,
+// and message names and field numbers match the gateway's openshell.v1.OpenShell
+// service exactly, so the wire contract is unchanged.
+type OpenShellServer interface {
+ // Get provider environment for a sandbox (called by the sandbox supervisor, and
+ // by an AuthBridge sidecar, at startup). Restricted to the sandbox's own identity.
+ GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error)
+ // Mint a gateway JWT bound to the calling sandbox's identity (Bearer SA token).
+ IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, 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) GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method GetSandboxProviderEnvironment not implemented")
+}
+func (UnimplementedOpenShellServer) IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method IssueSandboxToken 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 pancis, 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_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_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)
+}
+
+// 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: "GetSandboxProviderEnvironment",
+ Handler: _OpenShell_GetSandboxProviderEnvironment_Handler,
+ },
+ {
+ MethodName: "IssueSandboxToken",
+ Handler: _OpenShell_IssueSandboxToken_Handler,
+ },
+ },
+ Streams: []grpc.StreamDesc{},
+ Metadata: "openshell.proto",
+}
diff --git a/authbridge/authlib/openshell/proto/openshell.proto b/authbridge/authlib/openshell/proto/openshell.proto
new file mode 100644
index 000000000..68800f4d0
--- /dev/null
+++ b/authbridge/authlib/openshell/proto/openshell.proto
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+syntax = "proto3";
+
+package openshell.v1;
+
+// Minimal subset of the OpenShell gateway API: only the two RPCs the AuthBridge
+// placeholder-resolve gateway resolver calls, plus their messages. The full
+// OpenShell API is intentionally NOT vendored here. The package, service, RPC,
+// and message names and field numbers match the gateway's openshell.v1.OpenShell
+// service exactly, so the wire contract is unchanged.
+service OpenShell {
+ // Get provider environment for a sandbox (called by the sandbox supervisor, and
+ // by an AuthBridge sidecar, at startup). Restricted to the sandbox's own identity.
+ rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest)
+ returns (GetSandboxProviderEnvironmentResponse);
+
+ // Mint a gateway JWT bound to the calling sandbox's identity (Bearer SA token).
+ rpc IssueSandboxToken(IssueSandboxTokenRequest) returns (IssueSandboxTokenResponse);
+}
+
+// IssueSandboxToken request. The caller is identified by its SA-token bearer
+// credential, so the request body carries no fields.
+message IssueSandboxTokenRequest {}
+
+// IssueSandboxToken response.
+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;
+}
+
+message GetSandboxProviderEnvironmentRequest {
+ // The sandbox ID.
+ string sandbox_id = 1;
+}
+
+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;
+}
diff --git a/authbridge/authlib/plugins/placeholderresolve/gateway/resolver.go b/authbridge/authlib/plugins/placeholderresolve/gateway/resolver.go
new file mode 100644
index 000000000..c94330041
--- /dev/null
+++ b/authbridge/authlib/plugins/placeholderresolve/gateway/resolver.go
@@ -0,0 +1,205 @@
+// Package gateway provides a placeholderresolve.Resolver backed by the
+// OpenShell gateway: it fetches the sandbox's resolved provider environment
+// once (and on a refresh schedule) and serves placeholder lookups from an
+// in-memory cache, so per-request resolution does no network I/O.
+//
+// It satisfies the plugin's Resolver and lifecycle interfaces structurally —
+// this package does not import placeholderresolve, avoiding an import cycle.
+package gateway
+
+import (
+ "context"
+ "log/slog"
+ "sync/atomic"
+ "time"
+
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell"
+)
+
+const (
+ // defaultRefreshInterval bounds how often the cache is refreshed when no
+ // shorter per-credential expiry applies.
+ defaultRefreshInterval = 5 * time.Minute
+ // retryInterval is how often Start/refresh retries before the first
+ // successful fetch (the gateway may not be reachable at pod boot).
+ retryInterval = 3 * time.Second
+ // minRefreshInterval floors the expiry-derived refresh delay.
+ minRefreshInterval = 30 * time.Second
+)
+
+// Config locates the gateway and the sandbox identity material.
+type Config struct {
+ Endpoint string
+ MTLSCert string
+ MTLSKey string
+ MTLSCA string
+ SATokenPath string
+ SandboxID string
+ // Insecure permits plaintext gRPC to a non-loopback gateway (opt-in).
+ Insecure bool
+}
+
+// Resolver caches a sandbox's resolved provider environment from the gateway.
+type Resolver struct {
+ client *openshell.Client
+ refreshInterval time.Duration
+
+ env atomic.Pointer[openshell.Environment]
+ ready atomic.Bool
+ bgCancel atomic.Pointer[context.CancelFunc]
+}
+
+// New dials the gateway and returns an unstarted Resolver. Call Start to prime
+// the cache and begin background refresh.
+func New(cfg Config) (*Resolver, error) {
+ client, err := openshell.Dial(openshell.Config{
+ Endpoint: cfg.Endpoint,
+ MTLSCert: cfg.MTLSCert,
+ MTLSKey: cfg.MTLSKey,
+ MTLSCA: cfg.MTLSCA,
+ SATokenPath: cfg.SATokenPath,
+ SandboxID: cfg.SandboxID,
+ Insecure: cfg.Insecure,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &Resolver{client: client, refreshInterval: defaultRefreshInterval}, nil
+}
+
+// Resolve returns the real value for an env-var key from the cached
+// environment. It fails closed (ok=false) when the cache is not yet primed,
+// the key is absent, or the key's credential has expired.
+//
+// OpenShell prepends a "v_" revision prefix to provider-env placeholders
+// (secrets.rs: format!("{PREFIX}v{revision}_{key}")), but the gateway returns
+// the environment keyed by the bare name. So if the literal key misses, strip a
+// single revision prefix and retry the bare name. The literal lookup runs first,
+// so a real env var named like "v2_FOO" still resolves to itself.
+func (r *Resolver) Resolve(_ context.Context, key string) (string, bool) {
+ env := r.env.Load()
+ if env == nil {
+ return "", false
+ }
+ lookup := func(k string) (string, bool) {
+ v, ok := env.Values[k]
+ if !ok {
+ return "", false
+ }
+ if exp, has := env.ExpiresAtMs[k]; has && exp > 0 && time.Now().UnixMilli() >= exp {
+ return "", false
+ }
+ return v, true
+ }
+ if v, ok := lookup(key); ok {
+ return v, true
+ }
+ if bare, stripped := stripRevisionPrefix(key); stripped {
+ return lookup(bare)
+ }
+ return "", false
+}
+
+// stripRevisionPrefix removes a single leading "v_" revision prefix
+// (the form OpenShell prepends to provider-env placeholders). It returns the
+// bare key and true only when the prefix is well-formed — a "v", at least one
+// decimal digit, an underscore, and a non-empty remainder — otherwise "" and
+// false.
+func stripRevisionPrefix(key string) (string, bool) {
+ if len(key) < 3 || key[0] != 'v' {
+ return "", false
+ }
+ i := 1
+ for i < len(key) && key[i] >= '0' && key[i] <= '9' {
+ i++
+ }
+ if i == 1 || i >= len(key) || key[i] != '_' || i+1 >= len(key) {
+ return "", false
+ }
+ return key[i+1:], true
+}
+
+// Start launches the refresh loop on a process-lifetime context (so it
+// outlives the pipeline's Init budget) and returns immediately. The loop
+// fetches right away; Ready() flips true once the first fetch succeeds.
+func (r *Resolver) Start(_ context.Context) error {
+ bgCtx, cancel := context.WithCancel(context.Background())
+ // Atomic guard against a double Start (correct by construction even though the
+ // pipeline calls Init once): only the goroutine that wins the CAS owns the loop.
+ if !r.bgCancel.CompareAndSwap(nil, &cancel) {
+ cancel() // already started — discard the unused context
+ return nil
+ }
+ go r.refreshLoop(bgCtx)
+ return nil
+}
+
+// Ready reports whether the cache has been primed at least once.
+func (r *Resolver) Ready() bool { return r.ready.Load() }
+
+// Stop cancels the refresh loop and closes the gateway connection.
+func (r *Resolver) Stop(_ context.Context) error {
+ if cancel := r.bgCancel.Swap(nil); cancel != nil {
+ (*cancel)()
+ }
+ return r.client.Close()
+}
+
+func (r *Resolver) fetch(ctx context.Context) error {
+ env, err := r.client.FetchEnvironment(ctx)
+ if err != nil {
+ return err
+ }
+ r.env.Store(env)
+ r.ready.Store(true)
+ return nil
+}
+
+func (r *Resolver) refreshLoop(ctx context.Context) {
+ for {
+ if err := r.fetch(ctx); err != nil {
+ slog.Warn("gateway resolver: provider-environment fetch failed", "error", err)
+ }
+ timer := time.NewTimer(r.nextDelay())
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return
+ case <-timer.C:
+ }
+ }
+}
+
+// nextDelay schedules the next refresh: a short retry until the cache is
+// primed, then 80% of the soonest per-credential expiry (clamped), or the
+// default interval when nothing expires.
+func (r *Resolver) nextDelay() time.Duration {
+ if !r.ready.Load() {
+ return retryInterval
+ }
+ env := r.env.Load()
+ if env == nil || len(env.ExpiresAtMs) == 0 {
+ return r.refreshInterval
+ }
+ now := time.Now().UnixMilli()
+ soonest := int64(-1)
+ for _, exp := range env.ExpiresAtMs {
+ if exp <= 0 {
+ continue
+ }
+ if soonest < 0 || exp < soonest {
+ soonest = exp
+ }
+ }
+ if soonest < 0 {
+ return r.refreshInterval
+ }
+ d := time.Duration(soonest-now) * time.Millisecond * 8 / 10
+ if d < minRefreshInterval {
+ d = minRefreshInterval
+ }
+ if d > r.refreshInterval {
+ d = r.refreshInterval
+ }
+ return d
+}
diff --git a/authbridge/authlib/plugins/placeholderresolve/gateway/resolver_test.go b/authbridge/authlib/plugins/placeholderresolve/gateway/resolver_test.go
new file mode 100644
index 000000000..8407b2941
--- /dev/null
+++ b/authbridge/authlib/plugins/placeholderresolve/gateway/resolver_test.go
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package gateway
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell"
+)
+
+// TestNextDelay exercises the refresh schedule: a short retry until primed,
+// then a per-credential-expiry-derived delay clamped to [min, refreshInterval].
+func TestNextDelay(t *testing.T) {
+ r := &Resolver{refreshInterval: defaultRefreshInterval}
+
+ // Not primed yet -> retry quickly.
+ if d := r.nextDelay(); d != retryInterval {
+ t.Errorf("unready nextDelay = %v, want %v", d, retryInterval)
+ }
+
+ r.ready.Store(true)
+
+ // Primed, no expiry -> default refresh interval.
+ r.env.Store(&openshell.Environment{Values: map[string]string{"K": "v"}})
+ if d := r.nextDelay(); d != r.refreshInterval {
+ t.Errorf("no-expiry nextDelay = %v, want %v", d, r.refreshInterval)
+ }
+
+ // Soonest expiry very near -> clamped up to minRefreshInterval.
+ r.env.Store(&openshell.Environment{
+ ExpiresAtMs: map[string]int64{"K": time.Now().Add(10 * time.Second).UnixMilli()},
+ })
+ if d := r.nextDelay(); d != minRefreshInterval {
+ t.Errorf("near-expiry nextDelay = %v, want %v (clamped to min)", d, minRefreshInterval)
+ }
+
+ // Soonest expiry far out -> clamped down to refreshInterval.
+ r.env.Store(&openshell.Environment{
+ ExpiresAtMs: map[string]int64{"K": time.Now().Add(time.Hour).UnixMilli()},
+ })
+ if d := r.nextDelay(); d != r.refreshInterval {
+ t.Errorf("far-expiry nextDelay = %v, want %v (clamped to refresh)", d, r.refreshInterval)
+ }
+}
+
+// TestResolveStripsRevisionPrefix covers the bug fix: OpenShell injects
+// "v_" placeholders but the gateway returns the env keyed by the bare
+// , so the resolver must strip a single "v_" prefix on a miss.
+func TestResolveStripsRevisionPrefix(t *testing.T) {
+ ctx := context.Background()
+
+ // Primed cache keyed by the bare name (as the gateway returns it).
+ r := &Resolver{}
+ r.env.Store(&openshell.Environment{
+ Values: map[string]string{"ANTHROPIC_AUTH_TOKEN": "sk-real"},
+ })
+
+ cases := []struct {
+ name string
+ key string
+ want string
+ wantOK bool
+ }{
+ {"revision-keyed resolves to bare value", "v7_ANTHROPIC_AUTH_TOKEN", "sk-real", true},
+ {"bare key still resolves", "ANTHROPIC_AUTH_TOKEN", "sk-real", true},
+ {"revision-keyed but absent fails closed", "v7_MISSING", "", false},
+ {"non-numeric revision is not stripped", "vX_ANTHROPIC_AUTH_TOKEN", "", false},
+ {"empty revision is not stripped", "v_ANTHROPIC_AUTH_TOKEN", "", false},
+ {"empty key after prefix fails closed", "v7_", "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := r.Resolve(ctx, tc.key)
+ if got != tc.want || ok != tc.wantOK {
+ t.Errorf("Resolve(%q) = (%q, %v), want (%q, %v)", tc.key, got, ok, tc.want, tc.wantOK)
+ }
+ })
+ }
+
+ // A literal key matching the revision shape wins over the bare fallback.
+ t.Run("literal key takes precedence over strip", func(t *testing.T) {
+ lit := &Resolver{}
+ lit.env.Store(&openshell.Environment{
+ Values: map[string]string{"v2_FOO": "literal", "FOO": "bare"},
+ })
+ if got, ok := lit.Resolve(ctx, "v2_FOO"); got != "literal" || !ok {
+ t.Errorf("Resolve(v2_FOO) = (%q, %v), want (literal, true)", got, ok)
+ }
+ })
+
+ // Expiry is checked against the bare key on the stripped path.
+ t.Run("expired credential on stripped key fails closed", func(t *testing.T) {
+ exp := &Resolver{}
+ exp.env.Store(&openshell.Environment{
+ Values: map[string]string{"TOK": "x"},
+ ExpiresAtMs: map[string]int64{"TOK": time.Now().Add(-time.Minute).UnixMilli()},
+ })
+ if got, ok := exp.Resolve(ctx, "v3_TOK"); ok {
+ t.Errorf("Resolve(v3_TOK) = (%q, %v), want fail-closed", got, ok)
+ }
+ })
+
+ // Cold cache fails closed regardless of key shape.
+ t.Run("cold cache fails closed", func(t *testing.T) {
+ cold := &Resolver{}
+ if got, ok := cold.Resolve(ctx, "v1_ANTHROPIC_AUTH_TOKEN"); ok {
+ t.Errorf("Resolve on cold cache = (%q, %v), want fail-closed", got, ok)
+ }
+ })
+}
diff --git a/authbridge/authlib/plugins/placeholderresolve/gateway_e2e_test.go b/authbridge/authlib/plugins/placeholderresolve/gateway_e2e_test.go
new file mode 100644
index 000000000..484fb2788
--- /dev/null
+++ b/authbridge/authlib/plugins/placeholderresolve/gateway_e2e_test.go
@@ -0,0 +1,90 @@
+package placeholderresolve
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+
+ osv1 "github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto/openshellv1"
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
+)
+
+// e2eMockGateway is a minimal in-process OpenShell gateway: it mints a fixed
+// JWT and returns one resolved credential.
+type e2eMockGateway struct {
+ osv1.UnimplementedOpenShellServer
+}
+
+func (e2eMockGateway) IssueSandboxToken(context.Context, *osv1.IssueSandboxTokenRequest) (*osv1.IssueSandboxTokenResponse, error) {
+ return &osv1.IssueSandboxTokenResponse{Token: "jwt-1"}, nil
+}
+
+func (e2eMockGateway) GetSandboxProviderEnvironment(context.Context, *osv1.GetSandboxProviderEnvironmentRequest) (*osv1.GetSandboxProviderEnvironmentResponse, error) {
+ return &osv1.GetSandboxProviderEnvironmentResponse{
+ Environment: map[string]string{"ANTHROPIC_AUTH_TOKEN": "sk-real"},
+ }, nil
+}
+
+// TestOnRequestGatewayResolver drives the whole gateway path end-to-end:
+// Configure(gateway block) -> Init (background warm-up) -> Ready -> OnRequest
+// resolves the placeholder against the gateway's provider environment.
+func TestOnRequestGatewayResolver(t *testing.T) {
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ srv := grpc.NewServer()
+ osv1.RegisterOpenShellServer(srv, e2eMockGateway{})
+ go func() { _ = srv.Serve(lis) }()
+ t.Cleanup(srv.Stop)
+
+ saPath := filepath.Join(t.TempDir(), "token")
+ if err := os.WriteFile(saPath, []byte("sa-token"), 0o600); err != nil {
+ t.Fatalf("write SA token: %v", err)
+ }
+
+ raw, _ := json.Marshal(map[string]any{
+ "source": "gateway",
+ "gateway": map[string]any{
+ "endpoint": "http://" + lis.Addr().String(),
+ "sandbox_id": "sb-123",
+ "sa_token_path": saPath,
+ },
+ })
+
+ p := New()
+ if err := p.Configure(raw); err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+ if err := p.Init(context.Background()); err != nil {
+ t.Fatalf("Init: %v", err)
+ }
+ t.Cleanup(func() { _ = p.Shutdown(context.Background()) })
+
+ // The gateway resolver primes its cache asynchronously.
+ deadline := time.Now().Add(3 * time.Second)
+ for !p.Ready() {
+ if time.Now().After(deadline) {
+ t.Fatal("plugin never became Ready (gateway resolver did not prime)")
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ pctx := &pipeline.Context{Direction: pipeline.Outbound, Headers: http.Header{}}
+ pctx.Headers.Set("Authorization", "Bearer openshell:resolve:env:ANTHROPIC_AUTH_TOKEN")
+
+ act := p.OnRequest(context.Background(), pctx)
+ if act.Type != pipeline.Continue {
+ t.Fatalf("OnRequest = %v (violation %+v), want Continue", act.Type, act.Violation)
+ }
+ if got := pctx.Headers.Get("Authorization"); got != "Bearer sk-real" {
+ t.Errorf("Authorization = %q, want Bearer sk-real", got)
+ }
+}
diff --git a/authbridge/authlib/plugins/placeholderresolve/plugin.go b/authbridge/authlib/plugins/placeholderresolve/plugin.go
new file mode 100644
index 000000000..c73c3594f
--- /dev/null
+++ b/authbridge/authlib/plugins/placeholderresolve/plugin.go
@@ -0,0 +1,317 @@
+// Package placeholderresolve provides an outbound pipeline plugin that
+// resolves OpenShell-style credential placeholders in the Authorization
+// header to their real secret values, on the wire, so the agent never holds
+// the real credential.
+//
+// OpenShell injects an agent's credential env (e.g. ANTHROPIC_AUTH_TOKEN)
+// as a placeholder string of the form "openshell:resolve:env:". The
+// agent emits that placeholder verbatim (for the `claude` provider it rides
+// in `Authorization: Bearer ` — the only header any listener
+// reconciles to the upstream wire). This plugin scans the Authorization
+// header, resolves each placeholder via the configured source, and rewrites
+// the header in place. Unresolvable placeholders fail closed (the request is
+// denied rather than forwarded with the placeholder).
+//
+// The credential source is selected by the required `source` field:
+// - "gateway": the OpenShell gateway (fetches the sandbox's resolved
+// provider environment; requires running as a sidecar in the sandbox pod).
+// - "secret_dir": a mounted directory, one file per KEY.
+//
+// The resolution + safe header-injection primitives live in authlib/credinject
+// so a future host-keyed injector can reuse them.
+package placeholderresolve
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/credinject"
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/placeholderresolve/gateway"
+)
+
+// defaultPrefix is the OpenShell placeholder prefix — kept identical to the
+// supervisor's so agents are unchanged when AuthBridge replaces OpenShell's
+// internal proxy (OpenShell secrets.rs PLACEHOLDER_PREFIX).
+const defaultPrefix = "openshell:resolve:env:"
+
+// Default OpenShell sidecar paths for the gateway source (match the k8s
+// driver's pod-spec conventions).
+const (
+ defaultSATokenPath = "/var/run/secrets/openshell/token"
+)
+
+// Credential source discriminators for the `source` config field.
+const (
+ sourceGateway = "gateway"
+ sourceSecretDir = "secret_dir"
+)
+
+// placeholderResolveConfig is the plugin's config schema. Field tags drive both
+// runtime decoding (json) and operator-facing schema introspection.
+type placeholderResolveConfig struct {
+ // Prefix is the placeholder prefix to match; the KEY following it is
+ // matched against the OpenShell env-key grammar.
+ Prefix string `json:"prefix" description:"Placeholder prefix to match before the env key." default:"openshell:resolve:env:"`
+
+ // Source selects where credentials are resolved from. Required; there is
+ // no implicit fallback — an unconfigured source fails closed.
+ Source string `json:"source" required:"true" description:"Credential source: 'gateway' (OpenShell sandbox sidecar) or 'secret_dir' (mounted files)."`
+
+ // Gateway configures the OpenShell-gateway source (source: gateway).
+ Gateway *gatewayConfig `json:"gateway" description:"OpenShell gateway source config (used when source=gateway)."`
+
+ // SecretDir is the directory for the file source (source: secret_dir):
+ // each KEY is read from /.
+ SecretDir string `json:"secret_dir" description:"Directory to resolve each KEY from a file named (used when source=secret_dir)."`
+}
+
+// gatewayConfig configures the OpenShell-gateway resolver source.
+type gatewayConfig struct {
+ Endpoint string `json:"endpoint" required:"true" description:"OpenShell gateway gRPC endpoint, e.g. https://openshell..svc:8080."`
+ MTLSCert string `json:"mtls_cert" description:"Client cert file for mTLS to the gateway (OPENSHELL_TLS_CERT). Required for https endpoints."`
+ MTLSKey string `json:"mtls_key" description:"Client key file for mTLS to the gateway (OPENSHELL_TLS_KEY). Required for https endpoints."`
+ MTLSCA string `json:"mtls_ca" description:"CA file verifying the gateway server cert (OPENSHELL_TLS_CA). Required for https endpoints."`
+ SATokenPath string `json:"sa_token_path" description:"Projected SA token file (audience openshell-gateway)." default:"/var/run/secrets/openshell/token"`
+ SandboxID string `json:"sandbox_id" required:"true" description:"This sandbox's id (OPENSHELL_SANDBOX_ID); must match the gateway-minted JWT."`
+ Insecure bool `json:"insecure" description:"Permit plaintext gRPC to a non-loopback gateway (opt-in). Sends the SA token + JWT in cleartext; refused by default for non-loopback endpoints." default:"false"`
+}
+
+func (c *placeholderResolveConfig) applyDefaults() {
+ if c.Prefix == "" {
+ c.Prefix = defaultPrefix
+ }
+ if c.Source == sourceGateway && c.Gateway != nil {
+ if c.Gateway.SATokenPath == "" {
+ c.Gateway.SATokenPath = defaultSATokenPath
+ }
+ }
+}
+
+func (c *placeholderResolveConfig) validate() error {
+ if c.Prefix == "" {
+ return errors.New("prefix must not be empty")
+ }
+ switch c.Source {
+ case sourceGateway:
+ if c.Gateway == nil {
+ return errors.New("source 'gateway' requires a gateway block")
+ }
+ if c.Gateway.Endpoint == "" {
+ return errors.New("gateway.endpoint is required")
+ }
+ if c.Gateway.SandboxID == "" {
+ return errors.New("gateway.sandbox_id is required")
+ }
+ case sourceSecretDir:
+ if c.SecretDir == "" {
+ return errors.New("source 'secret_dir' requires secret_dir")
+ }
+ case "":
+ return errors.New("source is required (one of: gateway, secret_dir)")
+ default:
+ return fmt.Errorf("unknown source %q (want one of: gateway, secret_dir)", c.Source)
+ }
+ return nil
+}
+
+// PlaceholderResolve is the outbound plugin. cfg/resolver are immutable after
+// Configure returns, so OnRequest reads them without synchronization.
+type PlaceholderResolve struct {
+ cfg placeholderResolveConfig
+ resolver credinject.Resolver
+}
+
+// New constructs an unconfigured plugin.
+func New() *PlaceholderResolve { return &PlaceholderResolve{} }
+
+func init() {
+ plugins.RegisterPlugin("placeholder-resolve", func() pipeline.Plugin { return New() })
+}
+
+func (p *PlaceholderResolve) Name() string { return "placeholder-resolve" }
+
+func (p *PlaceholderResolve) Capabilities() pipeline.PluginCapabilities {
+ return pipeline.PluginCapabilities{
+ Description: "Resolve OpenShell credential placeholders in the Authorization header to real values (fail-closed).",
+ }
+}
+
+// ConfigSchema surfaces field metadata to config-aware tooling. A non-nil
+// Gateway is passed so the nested block's fields are reflected.
+func (p *PlaceholderResolve) ConfigSchema() []pipeline.FieldSchema {
+ return pipeline.SchemaOf(placeholderResolveConfig{Gateway: &gatewayConfig{}})
+}
+
+func (p *PlaceholderResolve) Configure(raw json.RawMessage) error {
+ var c placeholderResolveConfig
+ if len(raw) > 0 {
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&c); err != nil {
+ return fmt.Errorf("placeholder-resolve config: %w", err)
+ }
+ }
+ c.applyDefaults()
+ if err := c.validate(); err != nil {
+ return fmt.Errorf("placeholder-resolve config: %w", err)
+ }
+
+ var resolver credinject.Resolver
+ switch c.Source {
+ case sourceGateway:
+ gw, gerr := gateway.New(gateway.Config{
+ Endpoint: c.Gateway.Endpoint,
+ MTLSCert: c.Gateway.MTLSCert,
+ MTLSKey: c.Gateway.MTLSKey,
+ MTLSCA: c.Gateway.MTLSCA,
+ SATokenPath: c.Gateway.SATokenPath,
+ SandboxID: c.Gateway.SandboxID,
+ Insecure: c.Gateway.Insecure,
+ })
+ if gerr != nil {
+ return fmt.Errorf("placeholder-resolve: gateway resolver: %w", gerr)
+ }
+ resolver = gw
+ case sourceSecretDir:
+ resolver = credinject.FileResolver{Dir: c.SecretDir}
+ }
+
+ // Commit only after all fallible construction succeeded, so a failed
+ // Configure leaves the plugin in its zero (deny) state.
+ p.cfg = c
+ p.resolver = resolver
+ return nil
+}
+
+// Init starts a lifecycle-capable resolver's background warm-up (the gateway
+// source). Static resolvers are always ready.
+func (p *PlaceholderResolve) Init(ctx context.Context) error {
+ if lr, ok := p.resolver.(credinject.LifecycleResolver); ok {
+ return lr.Start(ctx)
+ }
+ return nil
+}
+
+// Ready reports resolver readiness — always true for the static (file)
+// resolver; gated on the primed cache for the gateway source.
+func (p *PlaceholderResolve) Ready() bool {
+ if lr, ok := p.resolver.(credinject.LifecycleResolver); ok {
+ return lr.Ready()
+ }
+ return true
+}
+
+// Shutdown stops a lifecycle-capable resolver's background work.
+func (p *PlaceholderResolve) Shutdown(ctx context.Context) error {
+ if lr, ok := p.resolver.(credinject.LifecycleResolver); ok {
+ return lr.Stop(ctx)
+ }
+ return nil
+}
+
+// authHeader is the only header any listener reconciles to the upstream wire,
+// so it is the only header this plugin scans and rewrites.
+const authHeader = "Authorization"
+
+func (p *PlaceholderResolve) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action {
+ if p.resolver == nil {
+ return pipeline.DenyStatus(503, "upstream.unreachable", "placeholder-resolve not configured")
+ }
+
+ val := pctx.Headers.Get(authHeader)
+ if val == "" {
+ pctx.Skip("no_authorization")
+ return pipeline.Action{Type: pipeline.Continue}
+ }
+
+ rewritten, found, ok := p.rewrite(ctx, val)
+ if !found {
+ pctx.Skip("no_placeholder")
+ return pipeline.Action{Type: pipeline.Continue}
+ }
+ if !ok {
+ // A placeholder was present but could not be resolved (unknown key or
+ // unsafe value). Fail closed — never forward the placeholder upstream.
+ return pctx.DenyAndRecord("placeholder_unresolved", "auth.unauthorized", "unresolvable credential placeholder")
+ }
+ if !credinject.SafeSetHeader(pctx.Headers, authHeader, rewritten) {
+ return pctx.DenyAndRecord("placeholder_unsafe", "auth.unauthorized", "unsafe resolved credential value")
+ }
+ pctx.Modify("placeholder_resolved")
+ return pipeline.Action{Type: pipeline.Continue}
+}
+
+func (p *PlaceholderResolve) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action {
+ return pipeline.Action{Type: pipeline.Continue}
+}
+
+// rewrite replaces every placeholder (prefix + an OpenShell env key matching
+// [A-Za-z_][A-Za-z0-9_]*) in val with its resolved value. Returns
+// (rewritten, found, ok): found is true when at least one placeholder matched;
+// ok is false when a matched placeholder could not be resolved or resolved to
+// an unsafe value (the caller must fail closed). A prefix not followed by a
+// valid key is left literal.
+func (p *PlaceholderResolve) rewrite(ctx context.Context, val string) (string, bool, bool) {
+ if !strings.Contains(val, p.cfg.Prefix) {
+ return val, false, true
+ }
+ found := false
+ var b strings.Builder
+ i := 0
+ for {
+ rel := strings.Index(val[i:], p.cfg.Prefix)
+ if rel < 0 {
+ b.WriteString(val[i:])
+ break
+ }
+ start := i + rel
+ b.WriteString(val[i:start]) // text before the prefix
+ keyStart := start + len(p.cfg.Prefix)
+ keyEnd := keyStart
+ for keyEnd < len(val) && isEnvKeyChar(val[keyEnd], keyEnd == keyStart) {
+ keyEnd++
+ }
+ if keyEnd == keyStart {
+ // Prefix not followed by a valid env key — leave it literal.
+ b.WriteString(p.cfg.Prefix)
+ i = keyStart
+ continue
+ }
+ found = true
+ v, okv := p.resolver.Resolve(ctx, val[keyStart:keyEnd])
+ if !okv || !credinject.SafeHeaderValue(v) {
+ return "", true, false // fail closed; the request is denied
+ }
+ b.WriteString(v)
+ i = keyEnd
+ }
+ return b.String(), found, true
+}
+
+// isEnvKeyChar reports whether c is allowed in an OpenShell env key. The first
+// character must be a letter or underscore; later characters may also be digits.
+func isEnvKeyChar(c byte, first bool) bool {
+ switch {
+ case c == '_', c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z':
+ return true
+ case !first && c >= '0' && c <= '9':
+ return true
+ default:
+ return false
+ }
+}
+
+// Compile-time interface checks.
+var (
+ _ pipeline.Plugin = (*PlaceholderResolve)(nil)
+ _ pipeline.Configurable = (*PlaceholderResolve)(nil)
+ _ pipeline.Initializer = (*PlaceholderResolve)(nil)
+ _ pipeline.Readier = (*PlaceholderResolve)(nil)
+ _ pipeline.Shutdowner = (*PlaceholderResolve)(nil)
+)
diff --git a/authbridge/authlib/plugins/placeholderresolve/plugin_test.go b/authbridge/authlib/plugins/placeholderresolve/plugin_test.go
new file mode 100644
index 000000000..42e783585
--- /dev/null
+++ b/authbridge/authlib/plugins/placeholderresolve/plugin_test.go
@@ -0,0 +1,184 @@
+package placeholderresolve
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/credinject"
+ "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
+)
+
+// withMap returns a plugin whose resolver is an inline map, to exercise
+// OnRequest/rewrite without a real credential source (the production sources
+// are gateway/secret_dir; the map is a test seam).
+func withMap(t *testing.T, mappings map[string]string) *PlaceholderResolve {
+ t.Helper()
+ p := New()
+ p.cfg.Prefix = defaultPrefix
+ p.resolver = credinject.MapResolver(mappings)
+ return p
+}
+
+func configureJSON(t *testing.T, cfg map[string]any) (*PlaceholderResolve, error) {
+ t.Helper()
+ raw, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatalf("marshal config: %v", err)
+ }
+ p := New()
+ return p, p.Configure(raw)
+}
+
+func TestConfigureSecretDirDefaults(t *testing.T) {
+ p, err := configureJSON(t, map[string]any{"source": "secret_dir", "secret_dir": t.TempDir()})
+ if err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+ if p.cfg.Prefix != defaultPrefix {
+ t.Errorf("prefix default = %q, want %q", p.cfg.Prefix, defaultPrefix)
+ }
+}
+
+func TestConfigureRequiresSource(t *testing.T) {
+ if _, err := configureJSON(t, map[string]any{"secret_dir": "/tmp"}); err == nil {
+ t.Fatal("expected error: source is required")
+ }
+}
+
+func TestConfigureRejectsUnknownSource(t *testing.T) {
+ // `env` was removed as a source (it let the agent read the proxy's env);
+ // an unknown source must be rejected, not silently honored.
+ if _, err := configureJSON(t, map[string]any{"source": "env"}); err == nil {
+ t.Fatal("expected error for unknown source 'env'")
+ }
+}
+
+func TestConfigureGatewayRequiresBlock(t *testing.T) {
+ if _, err := configureJSON(t, map[string]any{"source": "gateway"}); err == nil {
+ t.Fatal("expected error: gateway source requires a gateway block")
+ }
+}
+
+func TestConfigureGatewayDefaults(t *testing.T) {
+ // Loopback http endpoint so Dial succeeds without certs; we only assert
+ // that the gateway sub-defaults are applied.
+ p, err := configureJSON(t, map[string]any{
+ "source": "gateway",
+ "gateway": map[string]any{
+ "endpoint": "http://127.0.0.1:8080",
+ "sandbox_id": "sb-1",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+ if p.cfg.Gateway.SATokenPath != defaultSATokenPath {
+ t.Errorf("gateway sa_token_path default not applied: %+v", p.cfg.Gateway)
+ }
+ _ = p.Shutdown(context.Background())
+}
+
+func TestOnRequest(t *testing.T) {
+ mappings := map[string]string{
+ "ANTHROPIC_AUTH_TOKEN": "sk-real-token",
+ "BAD": "evil\r\nInjected: 1",
+ }
+
+ tests := []struct {
+ name string
+ authIn string
+ wantReject bool
+ wantAuth string
+ }{
+ {"resolves placeholder bearer", "Bearer openshell:resolve:env:ANTHROPIC_AUTH_TOKEN", false, "Bearer sk-real-token"},
+ {"non-placeholder passthrough", "Bearer already-a-real-token", false, "Bearer already-a-real-token"},
+ {"unknown key fails closed", "Bearer openshell:resolve:env:UNKNOWN", true, ""},
+ {"invalid resolved value fails closed", "Bearer openshell:resolve:env:BAD", true, ""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ p := withMap(t, mappings)
+ pctx := &pipeline.Context{Direction: pipeline.Outbound, Headers: http.Header{}}
+ pctx.Headers.Set("Authorization", tc.authIn)
+
+ act := p.OnRequest(context.Background(), pctx)
+
+ if tc.wantReject {
+ if act.Type != pipeline.Reject {
+ t.Fatalf("expected Reject, got %v", act.Type)
+ }
+ if act.Violation == nil || act.Violation.Code != "auth.unauthorized" {
+ t.Errorf("expected auth.unauthorized violation, got %+v", act.Violation)
+ }
+ if got := pctx.Headers.Get("Authorization"); got == "Bearer sk-real-token" {
+ t.Errorf("rejected request unexpectedly carries a resolved secret")
+ }
+ return
+ }
+
+ if act.Type != pipeline.Continue {
+ t.Fatalf("expected Continue, got %v (violation %+v)", act.Type, act.Violation)
+ }
+ if got := pctx.Headers.Get("Authorization"); got != tc.wantAuth {
+ t.Errorf("Authorization = %q, want %q", got, tc.wantAuth)
+ }
+ })
+ }
+}
+
+func TestRewriteMultiplePlaceholders(t *testing.T) {
+ p := withMap(t, map[string]string{"A": "1", "B": "2"})
+ got, found, ok := p.rewrite(context.Background(),
+ "openshell:resolve:env:A and openshell:resolve:env:B")
+ if !found || !ok {
+ t.Fatalf("found=%v ok=%v, want both true", found, ok)
+ }
+ if want := "1 and 2"; got != want {
+ t.Errorf("rewrite = %q, want %q", got, want)
+ }
+}
+
+func TestRewritePrefixWithoutValidKey(t *testing.T) {
+ // A prefix not followed by a valid env key is left literal (no match).
+ p := withMap(t, map[string]string{})
+ got, found, ok := p.rewrite(context.Background(), "openshell:resolve:env:123")
+ if found || !ok {
+ t.Fatalf("found=%v ok=%v, want found=false ok=true", found, ok)
+ }
+ if got != "openshell:resolve:env:123" {
+ t.Errorf("rewrite = %q, want the input unchanged", got)
+ }
+}
+
+func TestSecretDirResolvesEndToEnd(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "ANTHROPIC_AUTH_TOKEN"), []byte("sk-real\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ p, err := configureJSON(t, map[string]any{"source": "secret_dir", "secret_dir": dir})
+ if err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+ pctx := &pipeline.Context{Direction: pipeline.Outbound, Headers: http.Header{}}
+ pctx.Headers.Set("Authorization", "Bearer openshell:resolve:env:ANTHROPIC_AUTH_TOKEN")
+ if act := p.OnRequest(context.Background(), pctx); act.Type != pipeline.Continue {
+ t.Fatalf("expected Continue, got %v", act.Type)
+ }
+ if got := pctx.Headers.Get("Authorization"); got != "Bearer sk-real" {
+ t.Errorf("Authorization = %q, want Bearer sk-real", got)
+ }
+}
+
+func TestUnconfiguredFailsClosed(t *testing.T) {
+ p := New() // never Configured
+ pctx := &pipeline.Context{Direction: pipeline.Outbound, Headers: http.Header{}}
+ pctx.Headers.Set("Authorization", "Bearer openshell:resolve:env:FOO")
+ if act := p.OnRequest(context.Background(), pctx); act.Type != pipeline.Reject {
+ t.Fatalf("unconfigured plugin must reject, got %v", act.Type)
+ }
+}
diff --git a/authbridge/cmd/abctl/tui/app.go b/authbridge/cmd/abctl/tui/app.go
index 67904b982..82d4977d9 100644
--- a/authbridge/cmd/abctl/tui/app.go
+++ b/authbridge/cmd/abctl/tui/app.go
@@ -231,6 +231,14 @@ type model struct {
// every rebuild.
visibleRows []eventRow
+ // follow, when true, pins the events cursor to the newest message as the
+ // stream advances. Set when the user is at the tail (entering a session,
+ // End/G, or a Down that reaches the last row); cleared on any upward or
+ // Home/g navigation. When false, rebuildEventsTable keeps the cursor on the
+ // event it was on by IDENTITY, not by row index — so folding/filtering
+ // can't scroll the view out from under the user.
+ follow bool
+
// pipeline is the fetched plugin composition. nil until the initial
// GetPipeline response arrives; the pipeline pane shows "(loading…)"
// until then.
@@ -301,6 +309,7 @@ func New(ctx context.Context, c *apiclient.Client) tea.Model {
cancel: cancel,
events: make(map[string][]pipeline.SessionEvent),
pane: paneSessions,
+ follow: true, // start pinned to the latest until the user scrolls up
sessionsTbl: newSessionsTable(),
eventsTbl: newEventsTable(),
pipelineTbl: newPipelineTable(),
diff --git a/authbridge/cmd/abctl/tui/events_pane.go b/authbridge/cmd/abctl/tui/events_pane.go
index 574fff9d5..f49b5c3be 100644
--- a/authbridge/cmd/abctl/tui/events_pane.go
+++ b/authbridge/cmd/abctl/tui/events_pane.go
@@ -81,8 +81,17 @@ func (m *model) rebuildEventsTable() {
m.eventsTbl.SetHeight(h)
}
+ // Anchor the cursor to the EVENT it's on, captured before the rebuild
+ // (m.visibleRows is still the previous build, in sync with the table's
+ // current cursor). It's restored below by re-finding that event — its row
+ // index can shift when a CONNECT tunnel folds into its inner request or
+ // hide-inactive filters — so a streamed rebuild never scrolls the view out
+ // from under the user. follow mode pins to the newest event instead.
prevRow := m.eventsTbl.Cursor()
- wasAtEnd := prevRow >= len(m.eventsTbl.Rows())-1
+ var anchor *pipeline.SessionEvent
+ if prevRow >= 0 && prevRow < len(m.visibleRows) {
+ anchor = m.visibleRows[prevRow].event
+ }
// One display row per network message. CONNECT tunnel-opens are folded
// into the decrypted inner request that immediately follows them (TLS
@@ -110,10 +119,23 @@ func (m *model) rebuildEventsTable() {
// network messages". Turning it on focuses the timeline on plugin
// activity (deny/modify/observe/allow). Both the filter and the
// headline consider the folded tunnel's invocations too.
+ //
+ // Suppress per EXCHANGE, not per event: a request and its paired
+ // response are hidden only when BOTH sides are inactive. Evaluating each
+ // event against its own phase's invocations alone would hide, say, a
+ // skip-only request while keeping its (invocation-less) response —
+ // orphaning the response. Unpaired rows (a lone CONNECT tunnel-open, or
+ // an unmatched request/response) fall back to per-event.
invs := er.invocations()
if m.hideInactive && eventInactive(invs) {
- m.hiddenInactive++
- continue
+ partnerInactive := true
+ if j, ok := partner[i]; ok {
+ partnerInactive = eventInactive(eventRows[j].invocations())
+ }
+ if partnerInactive {
+ m.hiddenInactive++
+ continue
+ }
}
action, plugin := eventAction(invs)
@@ -147,15 +169,43 @@ func (m *model) rebuildEventsTable() {
}
m.eventsTbl.SetRows(rows)
- // Auto-follow: if user was at the bottom, stay at the bottom. Otherwise
- // preserve position so reading isn't disturbed by new events.
- if wasAtEnd && len(rows) > 0 {
+ // Restore the cursor: follow the newest event in follow mode; otherwise
+ // re-find the anchored event by identity (timestamp + direction + phase) so a
+ // shifted row index doesn't jump the view. If the anchored event is gone
+ // (trimmed, folded away, or filtered out), clamp the old index best-effort.
+ switch {
+ case m.follow && len(rows) > 0:
m.eventsTbl.SetCursor(len(rows) - 1)
- } else if prevRow < len(rows) {
- m.eventsTbl.SetCursor(prevRow)
+ case anchor != nil:
+ same := func(e *pipeline.SessionEvent) bool {
+ return e != nil && e.At.Equal(anchor.At) &&
+ e.Direction == anchor.Direction && e.Phase == anchor.Phase
+ }
+ idx := -1
+ for i := range m.visibleRows {
+ if same(m.visibleRows[i].event) || same(m.visibleRows[i].tunnel) {
+ idx = i
+ break
+ }
+ }
+ if idx >= 0 {
+ m.eventsTbl.SetCursor(idx)
+ } else if prevRow >= 0 && prevRow < len(rows) {
+ m.eventsTbl.SetCursor(prevRow)
+ }
}
}
+// syncEventsFollow recomputes follow mode from the cursor: follow the newest
+// event only when the cursor is at (or past) the last row. Called after manual
+// navigation in the events pane so follow reflects where the user landed — a
+// Down that reaches the bottom re-enables following; any other position is a
+// deliberate scroll-away and pins the view there.
+func (m *model) syncEventsFollow() {
+ n := len(m.eventsTbl.Rows())
+ m.follow = n == 0 || m.eventsTbl.Cursor() >= n-1
+}
+
// selectedEvent returns the event at the cursor row, or nil. The cursor
// points into m.visibleRows (one entry per rendered message), and each row
// carries a reference to its source event.
diff --git a/authbridge/cmd/abctl/tui/events_pane_test.go b/authbridge/cmd/abctl/tui/events_pane_test.go
index 02855b35a..112fdc7f7 100644
--- a/authbridge/cmd/abctl/tui/events_pane_test.go
+++ b/authbridge/cmd/abctl/tui/events_pane_test.go
@@ -492,6 +492,89 @@ func TestRebuildEventsTable_HideInactive(t *testing.T) {
}
}
+// TestRebuildEventsTable_HideInactivePerExchange checks that hideInactive
+// suppresses a request/response pair only when BOTH halves are inactive — an
+// active half keeps its (invocation-less) partner visible rather than orphaning
+// it. Exchange A: active request + passthrough response → both shown. Exchange
+// B: skip-only request + invocation-less response → both hidden.
+func TestRebuildEventsTable_HideInactivePerExchange(t *testing.T) {
+ events := []pipeline.SessionEvent{
+ {Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "llm",
+ Invocations: &pipeline.Invocations{Outbound: []pipeline.Invocation{
+ {Plugin: "placeholder-resolve", Action: pipeline.ActionModify},
+ }}},
+ {Direction: pipeline.Outbound, Phase: pipeline.SessionResponse, Host: "llm", StatusCode: 200},
+ {Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "pass",
+ Invocations: &pipeline.Invocations{Outbound: []pipeline.Invocation{
+ {Plugin: "placeholder-resolve", Action: pipeline.ActionSkip},
+ }}},
+ {Direction: pipeline.Outbound, Phase: pipeline.SessionResponse, Host: "pass", StatusCode: 200},
+ }
+ m := &model{selectedSess: "s", events: map[string][]pipeline.SessionEvent{"s": events}}
+ m.eventsTbl = newEventsTable()
+ m.hideInactive = true
+ m.rebuildEventsTable()
+
+ if len(m.visibleRows) != 2 || m.hiddenInactive != 2 {
+ t.Fatalf("per-exchange hide: rows=%d hidden=%d, want 2/2 (active pair shown, passthrough pair hidden)",
+ len(m.visibleRows), m.hiddenInactive)
+ }
+ for _, er := range m.visibleRows {
+ if hostOnly(er.event.Host) != "llm" {
+ t.Errorf("visible row host = %q, want llm (both halves of the active exchange, no orphan)", er.event.Host)
+ }
+ }
+}
+
+// TestRebuildEventsTable_FollowPinsToNewest: in follow mode the cursor tracks
+// the newest event as the stream grows.
+func TestRebuildEventsTable_FollowPinsToNewest(t *testing.T) {
+ t0 := time.Now()
+ mk := func(i int) pipeline.SessionEvent {
+ return pipeline.SessionEvent{At: t0.Add(time.Duration(i) * time.Second),
+ Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "h"}
+ }
+ m := &model{selectedSess: "s", events: map[string][]pipeline.SessionEvent{"s": {mk(0), mk(1)}}}
+ m.eventsTbl = newEventsTable()
+ m.follow = true
+ m.rebuildEventsTable()
+
+ m.events["s"] = append(m.events["s"], mk(2)) // new event streams in
+ m.rebuildEventsTable()
+
+ if got, want := m.eventsTbl.Cursor(), len(m.visibleRows)-1; got != want {
+ t.Errorf("follow: cursor=%d, want newest row %d", got, want)
+ }
+}
+
+// TestRebuildEventsTable_PreservesCursorByIdentity: when NOT following, a rebuild
+// that shifts row indices (here simulated by an event landing ahead of the
+// anchored one — the effect of a CONNECT fold, a hide-inactive change, or a
+// buffer trim) keeps the cursor on the SAME event, not the same index.
+func TestRebuildEventsTable_PreservesCursorByIdentity(t *testing.T) {
+ t0 := time.Now()
+ x := pipeline.SessionEvent{At: t0, Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "x"}
+ y := pipeline.SessionEvent{At: t0.Add(time.Second), Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "y"}
+ z := pipeline.SessionEvent{At: t0.Add(2 * time.Second), Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "z"}
+
+ m := &model{selectedSess: "s", events: map[string][]pipeline.SessionEvent{"s": {x, y}}}
+ m.eventsTbl = newEventsTable()
+ m.follow = false
+ m.rebuildEventsTable()
+ m.eventsTbl.SetCursor(0) // park on x (row 0)
+ if got := m.visibleRows[m.eventsTbl.Cursor()].event.Host; got != "x" {
+ t.Fatalf("setup: cursor on %q, want x", got)
+ }
+
+ // x's row index shifts from 0 to 1.
+ m.events["s"] = []pipeline.SessionEvent{z, x, y}
+ m.rebuildEventsTable()
+
+ if got := m.visibleRows[m.eventsTbl.Cursor()].event.Host; got != "x" {
+ t.Errorf("after index shift: cursor on %q, want x (followed by identity, not stuck at row 0=z)", got)
+ }
+}
+
// TestComputeEventPairIDs pairs each response row with its preceding request
// row by direction + host + method, sharing one # across the exchange and
// minting fresh integers for unpaired rows.
diff --git a/authbridge/cmd/abctl/tui/keys.go b/authbridge/cmd/abctl/tui/keys.go
index 8898ef3f3..534bb6887 100644
--- a/authbridge/cmd/abctl/tui/keys.go
+++ b/authbridge/cmd/abctl/tui/keys.go
@@ -202,6 +202,7 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
}
m.selectedSess = id
m.pane = paneEvents
+ m.follow = true // entering a session: show the latest + follow new events
m.rebuildEventsTable()
// Snapshot in case the stream hasn't yet delivered history.
return m.snapshotCmd(id)
@@ -315,6 +316,7 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
case paneEvents:
var cmd tea.Cmd
m.eventsTbl, cmd = m.eventsTbl.Update(msg)
+ m.syncEventsFollow() // follow only if the nav left the cursor at the last row
return cmd
case paneDetail, panePluginDetail:
var cmd tea.Cmd
@@ -367,6 +369,7 @@ func (m *model) goTop() {
m.sessionsTbl.SetCursor(0)
case paneEvents:
m.eventsTbl.SetCursor(0)
+ m.follow = false // jumped to the top: stop following the tail
case panePipeline:
m.pipelineTbl.SetCursor(0)
case paneDetail, panePluginDetail:
@@ -384,6 +387,7 @@ func (m *model) goBottom() {
if n := len(m.eventsTbl.Rows()); n > 0 {
m.eventsTbl.SetCursor(n - 1)
}
+ m.follow = true // jumped to the tail: resume following new events
case panePipeline:
if n := len(m.pipelineTbl.Rows()); n > 0 {
m.pipelineTbl.SetCursor(n - 1)
diff --git a/authbridge/cmd/authbridge-proxy/plugins_placeholderresolve.go b/authbridge/cmd/authbridge-proxy/plugins_placeholderresolve.go
new file mode 100644
index 000000000..719f1e323
--- /dev/null
+++ b/authbridge/cmd/authbridge-proxy/plugins_placeholderresolve.go
@@ -0,0 +1,5 @@
+//go:build !exclude_plugin_placeholderresolve
+
+package main
+
+import _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/placeholderresolve"
diff --git a/authbridge/docs/plugin-reference.md b/authbridge/docs/plugin-reference.md
index b5f52b227..5a578f567 100644
--- a/authbridge/docs/plugin-reference.md
+++ b/authbridge/docs/plugin-reference.md
@@ -103,6 +103,51 @@ Mint requires `pctx.Shared` to be wired by the listener; if it is nil
when `placeholder_mode` is on, the plugin fails fast at init (a deploy
error) rather than silently forwarding the real token.
+### `placeholder-resolve`: credential injection config
+
+Resolves OpenShell credential placeholders (`openshell:resolve:env:`) in the
+**`Authorization`** header to their real values on the wire, fail-closed. Outbound
+plugin; opt-in (inert unless listed in `pipeline.outbound`). Only `Authorization` is
+rewritten — it is the only header any listener reconciles to the upstream wire.
+
+```yaml
+- name: placeholder-resolve
+ config:
+ prefix: "openshell:resolve:env:" # default; the KEY follows this prefix
+ source: gateway # REQUIRED: "gateway" or "secret_dir" (no default — fail-closed)
+ gateway: # used when source: gateway (OpenShell sandbox sidecar)
+ endpoint: "https://openshell..svc:8080" # required
+ sandbox_id: "" # required; must match the minted JWT
+ mtls_cert: "/tls/client/tls.crt" # client cert (OPENSHELL_TLS_CERT); required for https
+ mtls_key: "/tls/client/tls.key" # client key (OPENSHELL_TLS_KEY); required for https
+ mtls_ca: "/tls/ca/ca.crt" # gateway CA (OPENSHELL_TLS_CA); required for https
+ sa_token_path: "/var/run/secrets/openshell/token"
+ insecure: false # opt-in plaintext (non-loopback refused by default)
+ # secret_dir: "/etc/authbridge-cred" # used when source: secret_dir (file per KEY)
+```
+
+| Field | Default | Notes |
+|-------|---------|-------|
+| `prefix` | `openshell:resolve:env:` | Placeholder prefix; the KEY after it matches `[A-Za-z_][A-Za-z0-9_]*`. |
+| `source` | — (**required**) | `gateway` or `secret_dir`. There is **no implicit fallback** — an unset/unknown source fails `Configure`, so the plugin never silently resolves from an ambient source. |
+| `gateway.endpoint` | — (required for `gateway`) | Gateway gRPC endpoint; `https://` selects mTLS, `http://` plaintext (non-loopback refused unless `insecure`). |
+| `gateway.sandbox_id` | — (required for `gateway`) | Must equal the id bound into the gateway-minted JWT. |
+| `gateway.mtls_cert` / `mtls_key` / `mtls_ca` | — (required for `https://`) | Client cert, client key, and gateway CA — independent file paths, mirroring OpenShell's `OPENSHELL_TLS_CERT`/`KEY`/`CA` (the driver splits the CA from the client keypair, so they need not share a dir). |
+| `gateway.sa_token_path` | `/var/run/secrets/openshell/token` | Projected SA token (audience `openshell-gateway`). |
+| `gateway.insecure` | `false` | Permit plaintext gRPC to a **non-loopback** gateway. Plaintext sends the SA token + JWT in cleartext, so it is **refused by default** for non-loopback endpoints (loopback always allowed). |
+| `secret_dir` | — (required for `secret_dir`) | Directory; each KEY is read from a path-contained `/` file. |
+
+An unresolved placeholder, or a resolved value containing CR/LF/NUL, fails the
+request closed (`401 auth.unauthorized`) — the placeholder is never forwarded **under
+the default `enforce` policy**. Run placeholder-resolve under `enforce`: with
+`on_error: observe` the deny is shadowed and the request is forwarded carrying the
+literal placeholder. No real credential leaks (the deny precedes header injection, and
+upstream rejects the bogus bearer), but fail-closed no longer holds. The
+`gateway` source requires running as a sidecar in the sandbox pod (shared SA + the
+`openshell.io/sandbox-id` annotation) and warms its cache in the background, so the
+pipeline gates traffic on `Ready()` until the first fetch succeeds. (The shared
+resolution + safe-header-injection primitives live in `authlib/credinject`.)
+
## `on_error` policy
> **Naming caveat.** Despite the name, `on_error` controls how the