Merge remote-tracking branch 'origin/main' into HEAD

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
This commit is contained in:
Matthew Grossman
2026-08-07 11:11:50 -07:00
119 changed files with 31497 additions and 279 deletions
@@ -210,9 +210,13 @@ Common findings:
host's IPv4 default route. Rootless pasta uses the private source address
selected by that route; rootful Podman uses the bridge gateway address.
- Callback discovery reports that the requested address equals the primary
listener: configure a distinct primary address. For Podman Machine, keep the
IPv4 loopback callback separate by using an IPv6-loopback primary such as
`[::1]:17670`.
listener: configure a distinct primary address. For Podman Machine, bind the
primary listener to IPv6 loopback, for example
`bind_address = "[::1]:17670"`, and register the CLI endpoint as
`https://localhost:17670`. The generated certificate includes `localhost`,
while a raw `https://[::1]:17670` endpoint can fail TLS setup with
`invalid dns name`. This leaves `127.0.0.1:17670` available for the
callback-only listener.
- Rootless slirp4netns, another named helper, or missing helper metadata
requires an explicitly remote `grpc_endpoint`. An explicit `host_gateway_ip`
cannot bypass slirp4netns host-loopback isolation. Do not work around
+19
View File
@@ -218,6 +218,25 @@ jobs:
- name: Test
run: mise run test:python
go:
name: Go SDK
needs: pr_metadata
if: needs.pr_metadata.outputs.should_run == 'true'
runs-on: linux-amd64-cpu8
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: mise install --locked
- name: Lint, build, test, proto-check
run: mise run go:ci
markdown:
name: Markdown
needs: pr_metadata
+1
View File
@@ -64,6 +64,7 @@ pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
coverage.out
htmlcov/
.tox/
.nox/
+9
View File
@@ -204,6 +204,15 @@ ocsf_emit!(event);
- `mise run e2e` — End-to-end tests against a running gateway. Run for infrastructure, sandbox, or policy changes.
- `mise run ci` — Full local CI (lint + compile/type checks + tests). Run before opening a PR.
## Go SDK (`sdk/go/`)
- The Go SDK lives in `sdk/go/` with module path `github.com/NVIDIA/OpenShell/sdk/go`.
- Run `mise run go:ci` for the full SDK CI pipeline (lint, build, test, proto-check, docs-check).
- Proto bindings are generated with `mise run go:proto:gen` from the `.proto` files in `proto/`.
- Domain types in `sdk/go/openshell/v1/types/` must not import proto packages.
- Converters in `sdk/go/openshell/v1/internal/converter/` deep-copy slices and maps at boundaries.
- Tests use bufconn for in-process gRPC and testify for assertions.
## Python
- Always use `uv` for Python commands (e.g., `uv pip install`, `uv run`, `uv venv`)
+1
View File
@@ -434,6 +434,7 @@ Bazel does not yet cover `mise run gateway`, `mise run sandbox`, `mise run e2e`,
| --------------- | --------------------------------------------- |
| `crates/` | Rust crates |
| `python/` | Python SDK and bindings |
| `sdk/go/` | Go SDK (types, gRPC clients, converters) |
| `proto/` | Protocol buffer definitions |
| `tasks/` | `mise` task definitions and build scripts |
| `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests |
Generated
+82 -63
View File
@@ -127,7 +127,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -138,7 +138,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1356,15 +1356,16 @@ dependencies = [
[[package]]
name = "curve25519-dalek"
version = "5.0.0-rc.0"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.2",
"fiat-crypto",
"rand_core 0.10.1",
"rustc_version",
"subtle",
"zeroize",
@@ -1615,9 +1616,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecdsa"
version = "0.17.0-rc.18"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96"
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
dependencies = [
"der 0.8.0",
"digest 0.11.2",
@@ -1640,9 +1641,9 @@ dependencies = [
[[package]]
name = "ed25519-dalek"
version = "3.0.0-rc.0"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
@@ -1665,9 +1666,9 @@ dependencies = [
[[package]]
name = "elliptic-curve"
version = "0.14.0-rc.33"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935"
checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
dependencies = [
"base16ct",
"crypto-bigint",
@@ -1677,7 +1678,6 @@ dependencies = [
"group",
"hkdf 0.13.0",
"hybrid-array",
"once_cell",
"pem-rfc7468 1.0.0",
"pkcs8 0.11.0",
"rand_core 0.10.1",
@@ -1737,7 +1737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2869,33 +2869,21 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "jsonwebtoken"
version = "10.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
dependencies = [
"aws-lc-rs",
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"pem",
"serde",
"serde_json",
"signature 2.2.0",
"simple_asn1",
]
[[package]]
@@ -3371,7 +3359,7 @@ dependencies = [
"module-lattice",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"sha3",
"sha3 0.11.0",
]
[[package]]
@@ -3467,7 +3455,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3588,7 +3576,7 @@ dependencies = [
"futures-util",
"http 1.4.0",
"http-auth",
"jsonwebtoken 10.3.0",
"jsonwebtoken",
"lazy_static",
"oci-spec",
"olpc-cjson",
@@ -4109,7 +4097,7 @@ dependencies = [
"hyper-rustls 0.27.9",
"hyper-util",
"ipnet",
"jsonwebtoken 9.3.1",
"jsonwebtoken",
"k8s-openapi",
"kube",
"metrics",
@@ -4145,6 +4133,7 @@ dependencies = [
"rcgen",
"reqwest 0.12.28",
"ring",
"rsa 0.9.10",
"russh",
"rustix 1.1.4",
"rustls 0.23.38",
@@ -4428,9 +4417,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "p256"
version = "0.14.0-rc.10"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e"
checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
dependencies = [
"ecdsa",
"elliptic-curve",
@@ -4441,9 +4430,9 @@ dependencies = [
[[package]]
name = "p384"
version = "0.14.0-rc.10"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02"
checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
dependencies = [
"ecdsa",
"elliptic-curve",
@@ -4455,9 +4444,9 @@ dependencies = [
[[package]]
name = "p521"
version = "0.14.0-rc.10"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae"
checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
dependencies = [
"base16ct",
"ecdsa",
@@ -4848,11 +4837,15 @@ dependencies = [
[[package]]
name = "primeorder"
version = "0.14.0-rc.10"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf"
checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
dependencies = [
"elliptic-curve",
"once_cell",
"primefield",
"serdect",
"wnaf",
]
[[package]]
@@ -5355,12 +5348,12 @@ dependencies = [
[[package]]
name = "rfc6979"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3"
checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
dependencies = [
"crypto-bigint",
"hmac 0.13.0",
"subtle",
]
[[package]]
@@ -5430,9 +5423,9 @@ dependencies = [
[[package]]
name = "russh"
version = "0.61.2"
version = "0.62.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"
checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e"
dependencies = [
"aes",
"aws-lc-rs",
@@ -5487,7 +5480,7 @@ dependencies = [
"sec1",
"sha1 0.11.0",
"sha2 0.11.0",
"sha3",
"sha3 0.12.0",
"signature 3.0.0",
"spki 0.8.0",
"ssh-encoding",
@@ -5502,9 +5495,9 @@ dependencies = [
[[package]]
name = "russh-cryptovec"
version = "0.61.0"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "443f6bbcfacb34a1aab2b12b99bf08e0c63abdc5a0db261901365df9d57fff51"
checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438"
dependencies = [
"log",
"nix 0.31.3",
@@ -5583,7 +5576,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5663,7 +5656,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6047,6 +6040,17 @@ dependencies = [
"keccak",
]
[[package]]
name = "sha3"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
dependencies = [
"digest 0.11.2",
"keccak",
"sponge-cursor",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -6182,7 +6186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6242,6 +6246,12 @@ dependencies = [
"der 0.8.0",
]
[[package]]
name = "sponge-cursor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.8.6"
@@ -6432,17 +6442,15 @@ dependencies = [
[[package]]
name = "ssh-cipher"
version = "0.3.0-rc.9"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36"
checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
dependencies = [
"aead",
"aes",
"aes-gcm",
"cbc",
"chacha20",
"cipher",
"ctr",
"ctutils",
"des",
"poly1305",
@@ -6452,9 +6460,9 @@ dependencies = [
[[package]]
name = "ssh-encoding"
version = "0.3.0-rc.9"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a"
checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e"
dependencies = [
"base64ct",
"bytes",
@@ -6467,9 +6475,9 @@ dependencies = [
[[package]]
name = "ssh-key"
version = "0.7.0-rc.10"
version = "0.7.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b"
checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3"
dependencies = [
"argon2",
"bcrypt-pbkdf",
@@ -6647,9 +6655,9 @@ dependencies = [
[[package]]
name = "tar"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -6675,7 +6683,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6711,7 +6719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7708,7 +7716,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -8250,6 +8258,17 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wnaf"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
dependencies = [
"ff",
"group",
"hybrid-array",
]
[[package]]
name = "writeable"
version = "0.6.3"
+1 -1
View File
@@ -102,7 +102,7 @@ base64 = "0.22"
# Crypto / Auth
sha2 = "0.10"
rand = "0.9"
jsonwebtoken = "9"
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
getrandom = "0.3"
ring = "0.17"
spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "tracing"] }
+50
View File
@@ -6,6 +6,56 @@ bazel_dep(name = "rules_cc", version = "0.2.20")
bazel_dep(name = "rules_proto", version = "7.1.0")
bazel_dep(name = "protobuf", version = "34.0.bcr.1")
bazel_lib_toolchains = use_extension("@bazel_lib//lib:extensions.bzl", "toolchains")
bazel_lib_toolchains.zstd()
use_repo(bazel_lib_toolchains, "zstd_toolchains")
register_toolchains("@zstd_toolchains//:all")
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "vm_runtime_darwin_aarch64",
build_file_content = """
exports_files([
"gvproxy",
"libkrun.dylib",
"libkrunfw.5.dylib",
"umoci",
])
""",
integrity = "sha256-KSAKryBFZiytwYwXB0CR++u4tGlA5ficf7X/nen7qQE=",
urls = ["https://github.com/NVIDIA/OpenShell/releases/download/vm-runtime/vm-runtime-darwin-aarch64.tar.zst"],
)
http_archive(
name = "vm_runtime_linux_aarch64",
build_file_content = """
exports_files([
"gvproxy",
"libkrun.so",
"libkrunfw.so.5",
"umoci",
])
""",
integrity = "sha256-zn0P4NtEKp7Euy44HfQ3YoBNijF2VWFTaLLsase+Y1A=",
urls = ["https://github.com/NVIDIA/OpenShell/releases/download/vm-runtime/vm-runtime-linux-aarch64.tar.zst"],
)
http_archive(
name = "vm_runtime_linux_x86_64",
build_file_content = """
exports_files([
"gvproxy",
"libkrun.so",
"libkrunfw.so.5",
"umoci",
])
""",
integrity = "sha256-urdMjarDN5WJ6eCmpddlcnEn2v3qp28D7+e6PtyhPr0=",
urls = ["https://github.com/NVIDIA/OpenShell/releases/download/vm-runtime/vm-runtime-linux-x86_64.tar.zst"],
)
include("//bazel/annotations:aws-lc-sys.MODULE.bazel")
include("//bazel/annotations:z3-sys.MODULE.bazel")
include("//bazel/annotations:zstd-sys.MODULE.bazel")
+9 -1
View File
@@ -1,4 +1,12 @@
# ![OpenShell](docs/brand/assets/openshell-lockup-horizontal.svg)
<!-- markdownlint-disable MD033 MD041 -->
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/assets/openshell-banner-dark.png">
<source media="(prefers-color-scheme: light)" srcset="docs/brand/assets/openshell-banner-light.png">
<img alt="OpenShell" src="docs/brand/assets/openshell-banner-light.png" width="430">
</picture>
<!-- markdownlint-enable MD033 MD041 -->
[![License](https://img.shields.io/badge/License-Apache_2.0-blue)](https://github.com/NVIDIA/OpenShell/blob/main/LICENSE)
[![PyPI](https://img.shields.io/badge/PyPI-openshell-orange?logo=pypi)](https://pypi.org/project/openshell/)
+4 -1
View File
@@ -1 +1,4 @@
exports_files(["cargo_version.bzl"])
exports_files([
"cargo_version.bzl",
"vm_runtime.bzl",
])
+2
View File
@@ -44,6 +44,7 @@ platform_transition_binary(
binary = "//crates/openshell-sandbox:openshell-sandbox-bin",
tags = ["manual"],
target_platform = "@rules_rs//rs/platforms:x86_64-unknown-linux-musl",
visibility = ["//visibility:public"],
)
platform_transition_binary(
@@ -52,6 +53,7 @@ platform_transition_binary(
binary = "//crates/openshell-sandbox:openshell-sandbox-bin",
tags = ["manual"],
target_platform = "@rules_rs//rs/platforms:aarch64-unknown-linux-musl",
visibility = ["//visibility:public"],
)
platform_transition_binary(
+72
View File
@@ -0,0 +1,72 @@
load("//bazel:vm_runtime.bzl", "vm_runtime_bundle")
config_setting(
name = "darwin_aarch64",
constraint_values = [
"@platforms//cpu:aarch64",
"@platforms//os:osx",
],
)
config_setting(
name = "linux_aarch64",
constraint_values = [
"@platforms//cpu:aarch64",
"@platforms//os:linux",
],
)
config_setting(
name = "linux_x86_64",
constraint_values = [
"@platforms//cpu:x86_64",
"@platforms//os:linux",
],
)
vm_runtime_bundle(
name = "runtime",
gvproxy = select({
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:gvproxy",
":linux_aarch64": "@vm_runtime_linux_aarch64//:gvproxy",
":linux_x86_64": "@vm_runtime_linux_x86_64//:gvproxy",
}),
libkrun = select({
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:libkrun.dylib",
":linux_aarch64": "@vm_runtime_linux_aarch64//:libkrun.so",
":linux_x86_64": "@vm_runtime_linux_x86_64//:libkrun.so",
}),
libkrun_name = select({
":darwin_aarch64": "libkrun.dylib",
":linux_aarch64": "libkrun.so",
":linux_x86_64": "libkrun.so",
}),
libkrunfw = select({
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:libkrunfw.5.dylib",
":linux_aarch64": "@vm_runtime_linux_aarch64//:libkrunfw.so.5",
":linux_x86_64": "@vm_runtime_linux_x86_64//:libkrunfw.so.5",
}),
libkrunfw_name = select({
":darwin_aarch64": "libkrunfw.5.dylib",
":linux_aarch64": "libkrunfw.so.5",
":linux_x86_64": "libkrunfw.so.5",
}),
supervisor = select({
":darwin_aarch64": "//bazel/releases:openshell_sandbox_linux_aarch64",
":linux_aarch64": "//bazel/releases:openshell_sandbox_linux_aarch64",
":linux_x86_64": "//bazel/releases:openshell_sandbox_linux_x86_64",
}),
tags = ["manual"],
target_compatible_with = select({
":darwin_aarch64": [],
":linux_aarch64": [],
":linux_x86_64": [],
"//conditions:default": ["@platforms//:incompatible"],
}),
umoci = select({
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:umoci",
":linux_aarch64": "@vm_runtime_linux_aarch64//:umoci",
":linux_x86_64": "@vm_runtime_linux_x86_64//:umoci",
}),
visibility = ["//visibility:public"],
)
+46
View File
@@ -0,0 +1,46 @@
"""Rules for staging the embedded openshell-driver-vm runtime."""
_ZSTD_TOOLCHAIN = "@bazel_lib//lib:zstd_toolchain_type"
def _vm_runtime_bundle_impl(ctx):
output = ctx.actions.declare_directory(ctx.label.name)
zstd = ctx.toolchains[_ZSTD_TOOLCHAIN].zstdinfo.binary
resources = [
(ctx.file.libkrun, ctx.attr.libkrun_name),
(ctx.file.libkrunfw, ctx.attr.libkrunfw_name),
(ctx.file.gvproxy, "gvproxy"),
(ctx.executable.supervisor, "openshell-sandbox"),
(ctx.file.umoci, "umoci"),
]
commands = ["mkdir -p '{}'".format(output.path)]
for source, name in resources:
commands.append("'{}' -q -f '{}' -o '{}/{}.zst'".format(
zstd.path,
source.path,
output.path,
name,
))
ctx.actions.run_shell(
command = "set -euo pipefail\n{}".format("\n".join(commands)),
inputs = [source for source, _ in resources],
outputs = [output],
tools = [zstd],
)
return [DefaultInfo(files = depset([output]))]
vm_runtime_bundle = rule(
implementation = _vm_runtime_bundle_impl,
attrs = {
"gvproxy": attr.label(allow_single_file = True, mandatory = True),
"libkrun": attr.label(allow_single_file = True, mandatory = True),
"libkrun_name": attr.string(mandatory = True),
"libkrunfw": attr.label(allow_single_file = True, mandatory = True),
"libkrunfw_name": attr.string(mandatory = True),
"supervisor": attr.label(executable = True, cfg = "target", mandatory = True),
"umoci": attr.label(allow_single_file = True, mandatory = True),
},
toolchains = [_ZSTD_TOOLCHAIN],
)
+31
View File
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Repo-level buf module. Declares proto/ as the single module so buf generate,
# buf lint, buf breaking, and the editor LSP all resolve imports the same way.
# Code generation lives with each consumer (see sdk/go/buf.gen.yaml,
# sdk/typescript/buf.gen.yaml); this file owns the module boundary and proto
# validation policy.
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
# Flat proto/ layout: all files live in one directory with nested
# packages (openshell.v1, openshell.sandbox.v1, ...). Adopting these
# would require restructuring the tree into openshell/<svc>/v1/ and
# updating every Rust/Python/TS codegen path and import.
- DIRECTORY_SAME_PACKAGE
- PACKAGE_DIRECTORY_MATCH
# Established API shape: services are unsuffixed (OpenShell, not
# OpenShellService) and RPCs reuse shared request/response messages with
# short names. Renaming these is a breaking change across the codebase.
- RPC_REQUEST_RESPONSE_UNIQUE
- RPC_REQUEST_STANDARD_NAME
- RPC_RESPONSE_STANDARD_NAME
- SERVICE_SUFFIX
breaking:
use:
- FILE
+12 -5
View File
@@ -745,11 +745,11 @@ pub fn resolve_ssh_gateway(
// Remote cluster: use the remote host but keep the cluster URL port.
return (host.to_string(), cluster_port);
}
// Both endpoints loopback. The unspecified addresses (0.0.0.0 / ::)
// are bind-only — they aren't valid connect targets and aren't in TLS
// cert SANs, so fall back to the cluster URL's host (which the CLI
// is already using to reach the gateway).
if gateway_host == "0.0.0.0" || gateway_host == "::" {
// Unspecified addresses are bind-only, and tonic cannot use an IPv6
// literal as a TLS DNS name. In those cases, keep the cluster URL's
// already-reachable authority. Other loopback addresses retain the
// gateway-reported host.
if matches!(gateway_host, "0.0.0.0" | "::" | "::1") {
return (host.to_string(), cluster_port);
}
return (gateway_host.to_string(), cluster_port);
@@ -1026,6 +1026,13 @@ mod tests {
assert_eq!(port, 443);
}
#[test]
fn resolve_ssh_gateway_preserves_loopback_tls_authority() {
let (host, port) = resolve_ssh_gateway("::1", 8080, "https://localhost:8443");
assert_eq!(host, "localhost");
assert_eq!(port, 8443);
}
#[test]
fn resolve_ssh_gateway_swaps_zeros_for_loopback_cluster_host() {
// The gateway binds 0.0.0.0 but advertises that bind address via the
+13 -9
View File
@@ -46,16 +46,20 @@ pub mod compute {
pub use super::generated::openshell::compute::v1;
}
#[allow(
clippy::all,
clippy::pedantic,
clippy::nursery,
dead_code,
unused_imports,
unused_qualifications,
rust_2018_idioms
)]
pub mod credentials {
#[allow(
clippy::all,
clippy::pedantic,
clippy::nursery,
dead_code,
unused_imports,
unused_qualifications,
rust_2018_idioms
)]
#[cfg(bazel)]
pub use super::generated::openshell::credentials::v1;
#[cfg(not(bazel))]
pub mod v1 {
include!(concat!(env!("OUT_DIR"), "/openshell.credentials.v1.rs"));
}
@@ -0,0 +1,27 @@
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
load("@rules_rs//rs:rust_library.bzl", "rust_library")
load("@rules_rs//rs:rust_test.bzl", "rust_test")
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
rust_library(
name = "openshell-driver-db-credstore",
srcs = glob(["src/**/*.rs"]),
aliases = aliases(),
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True),
)
rust_test(
name = "openshell-driver-db-credstore_test",
crate = ":openshell-driver-db-credstore",
deps = all_crate_deps(normal_dev = True),
)
rustfmt_test(
name = "rustfmt_test",
targets = [
":openshell-driver-db-credstore",
":openshell-driver-db-credstore_test",
],
visibility = ["//crates:__pkg__"],
)
@@ -0,0 +1,41 @@
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
load("@rules_rs//rs:rust_binary.bzl", "rust_binary")
load("@rules_rs//rs:rust_library.bzl", "rust_library")
load("@rules_rs//rs:rust_test.bzl", "rust_test")
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
rust_library(
name = "openshell-driver-kubernetes-secrets",
srcs = glob(
["src/**/*.rs"],
exclude = ["src/main.rs"],
),
aliases = aliases(),
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True),
)
rust_binary(
name = "openshell-driver-kubernetes-secrets_bin",
srcs = ["src/main.rs"],
aliases = aliases(),
binary_name = "openshell-driver-kubernetes-secrets",
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True) + [":openshell-driver-kubernetes-secrets"],
)
rust_test(
name = "openshell-driver-kubernetes-secrets_test",
crate = ":openshell-driver-kubernetes-secrets",
deps = all_crate_deps(normal_dev = True),
)
rustfmt_test(
name = "rustfmt_test",
targets = [
":openshell-driver-kubernetes-secrets",
":openshell-driver-kubernetes-secrets_bin",
":openshell-driver-kubernetes-secrets_test",
],
visibility = ["//crates:__pkg__"],
)
+41
View File
@@ -0,0 +1,41 @@
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
load("@rules_rs//rs:rust_binary.bzl", "rust_binary")
load("@rules_rs//rs:rust_library.bzl", "rust_library")
load("@rules_rs//rs:rust_test.bzl", "rust_test")
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
rust_library(
name = "openshell-driver-vault",
srcs = glob(
["src/**/*.rs"],
exclude = ["src/main.rs"],
),
aliases = aliases(),
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True),
)
rust_binary(
name = "openshell-driver-vault_bin",
srcs = ["src/main.rs"],
aliases = aliases(),
binary_name = "openshell-driver-vault",
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True) + [":openshell-driver-vault"],
)
rust_test(
name = "openshell-driver-vault_test",
crate = ":openshell-driver-vault",
deps = all_crate_deps(normal_dev = True),
)
rustfmt_test(
name = "rustfmt_test",
targets = [
":openshell-driver-vault",
":openshell-driver-vault_bin",
":openshell-driver-vault_test",
],
visibility = ["//crates:__pkg__"],
)
+74
View File
@@ -0,0 +1,74 @@
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
load("@rules_rs//rs:rust_binary.bzl", "rust_binary")
load("@rules_rs//rs:rust_library.bzl", "rust_library")
load("@rules_rs//rs:rust_test.bzl", "rust_test")
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
load("@workspace_version//:version.bzl", "WORKSPACE_VERSION")
VM_RUNTIME = "//bazel/vm-runtime:runtime"
VM_RUNTIME_ENV = {
"OUT_DIR": "$(execpath //bazel/vm-runtime:runtime)",
}
rust_library(
name = "openshell-driver-vm",
srcs = glob(
["src/**/*.rs"],
exclude = ["src/main.rs"],
),
aliases = aliases(),
compile_data = [
"scripts/openshell-vm-sandbox-init.sh",
VM_RUNTIME,
],
crate_features = ["telemetry"],
rustc_env = VM_RUNTIME_ENV,
version = WORKSPACE_VERSION,
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True),
)
rust_binary(
name = "openshell-driver-vm_bin",
srcs = ["src/main.rs"],
aliases = aliases(),
binary_name = "openshell-driver-vm",
version = WORKSPACE_VERSION,
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True) + [":openshell-driver-vm"],
)
rust_test(
name = "openshell-driver-vm_lib_test",
compile_data = [
"scripts/openshell-vm-sandbox-init.sh",
VM_RUNTIME,
],
crate = ":openshell-driver-vm",
crate_features = ["telemetry"],
rustc_env = VM_RUNTIME_ENV,
deps = all_crate_deps(normal_dev = True),
)
rust_test(
name = "openshell-driver-vm_bin_test",
srcs = ["src/main.rs"],
aliases = aliases(),
version = WORKSPACE_VERSION,
deps = all_crate_deps(
normal = True,
normal_dev = True,
) + [":openshell-driver-vm"],
)
rustfmt_test(
name = "rustfmt_test",
targets = [
":openshell-driver-vm",
":openshell-driver-vm_bin",
":openshell-driver-vm_bin_test",
":openshell-driver-vm_lib_test",
],
visibility = ["//crates:__pkg__"],
)
+8 -2
View File
@@ -12,7 +12,10 @@ rust_library(
exclude = ["src/main.rs"],
),
aliases = aliases(),
crate_features = ["telemetry"],
crate_features = [
"bundled-ca-roots",
"telemetry",
],
version = WORKSPACE_VERSION,
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True),
@@ -33,7 +36,10 @@ rust_test(
name = "openshell-sandbox_lib_test",
compile_data = ["//crates/openshell-supervisor-network:sandbox-policy-rego"],
crate = ":openshell-sandbox",
crate_features = ["telemetry"],
crate_features = [
"bundled-ca-roots",
"telemetry",
],
deps = all_crate_deps(normal_dev = True),
)
+3 -1
View File
@@ -106,7 +106,7 @@ async-trait = "0.1"
url = { workspace = true }
glob = { workspace = true }
hex = "0.4"
russh = "0.61"
russh = "0.62"
rand = { workspace = true }
petname = "2"
ipnet = "2"
@@ -128,6 +128,8 @@ test-support = []
[dev-dependencies]
hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "ring"] }
rcgen = { version = "0.13", features = ["crypto", "pem"] }
rsa = { version = "0.9", features = ["pem"] }
base64 = { workspace = true }
tokio-tungstenite = { workspace = true }
futures-util = "0.3"
wiremock = "0.6"
+252
View File
@@ -510,4 +510,256 @@ mod tests {
let scopes = claims.extract_scopes("scope");
assert!(scopes.is_empty());
}
// -----------------------------------------------------------------------
// RS256 verification through the real JWKS path
//
// The tests above only cover claim extraction from an already-trusted
// payload. These sign real RS256 tokens and push them through
// `JwksCache::new` + `validate_token`, so the JWKS `n`/`e` decoding and the
// RSA signature check are exercised against whichever crypto backend
// `jsonwebtoken` is built with — a backend swap is otherwise invisible to
// the test suite.
// -----------------------------------------------------------------------
const TEST_KID: &str = "test-signing-key";
const TEST_AUDIENCE: &str = "openshell-cli";
/// One RSA key per test binary. Key generation dominates the runtime of
/// these tests and the key carries no meaning beyond being valid.
static TEST_RSA_KEY: std::sync::LazyLock<TestRsaKey> =
std::sync::LazyLock::new(TestRsaKey::generate);
struct TestRsaKey {
private_pem: String,
modulus_b64: String,
exponent_b64: String,
}
impl TestRsaKey {
fn generate() -> Self {
use base64::Engine as _;
use rsa::pkcs1::EncodeRsaPrivateKey as _;
use rsa::traits::PublicKeyParts as _;
let private = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048)
.expect("generate RSA test key");
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
Self {
private_pem: private
.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
.expect("encode RSA private key as PEM")
.to_string(),
modulus_b64: b64.encode(private.n().to_bytes_be()),
exponent_b64: b64.encode(private.e().to_bytes_be()),
}
}
}
fn now_secs() -> i64 {
i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is after the unix epoch")
.as_secs(),
)
.expect("current time fits in i64")
}
/// Sign `claims` with the test key, tagging the header with `kid`.
fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String {
let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
header.kid = Some(kid.to_owned());
let key = jsonwebtoken::EncodingKey::from_rsa_pem(TEST_RSA_KEY.private_pem.as_bytes())
.expect("load RSA signing key");
jsonwebtoken::encode(&header, claims, &key).expect("sign RS256 token")
}
fn claims_for(issuer: &str, audience: &str, exp: i64) -> serde_json::Value {
serde_json::json!({
"sub": "user-42",
"preferred_username": "ada",
"iss": issuer,
"aud": audience,
"exp": exp,
"scope": "openid profile sandbox:write",
"realm_access": { "roles": ["openshell-user"] },
})
}
/// Serve an OIDC discovery document and a JWKS carrying the test key, then
/// build a cache against them the same way production does.
async fn cache_with_mock_issuer(server: &wiremock::MockServer) -> JwksCache {
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
let issuer = server.uri();
Mock::given(method("GET"))
.and(path("/.well-known/openid-configuration"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issuer": issuer,
"jwks_uri": format!("{issuer}/jwks"),
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/jwks"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"keys": [{
"kid": TEST_KID,
"kty": "RSA",
"n": TEST_RSA_KEY.modulus_b64,
"e": TEST_RSA_KEY.exponent_b64,
}],
})))
.mount(server)
.await;
JwksCache::new(&OidcConfig {
issuer,
audience: TEST_AUDIENCE.to_owned(),
jwks_ttl_secs: 3600,
roles_claim: "realm_access.roles".to_owned(),
admin_role: "openshell-admin".to_owned(),
user_role: "openshell-user".to_owned(),
scopes_claim: "scope".to_owned(),
})
.await
.expect("cache should build from the mock issuer")
}
#[tokio::test]
async fn rs256_token_signed_by_jwks_key_is_accepted() {
let server = wiremock::MockServer::start().await;
let cache = cache_with_mock_issuer(&server).await;
let token = mint_rs256(
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600),
TEST_KID,
);
let identity = cache
.validate_token(&token)
.await
.expect("a correctly signed token must be accepted");
assert_eq!(identity.subject, "user-42");
assert_eq!(identity.display_name.as_deref(), Some("ada"));
assert_eq!(identity.roles, vec!["openshell-user".to_owned()]);
assert_eq!(identity.scopes, vec!["sandbox:write".to_owned()]);
assert_eq!(identity.provider, IdentityProvider::Oidc);
}
#[tokio::test]
async fn rs256_token_with_tampered_payload_is_rejected() {
let server = wiremock::MockServer::start().await;
let cache = cache_with_mock_issuer(&server).await;
let exp = now_secs() + 3600;
let token = mint_rs256(&claims_for(&server.uri(), TEST_AUDIENCE, exp), TEST_KID);
// Keep the header and signature but swap in a payload that escalates
// the subject: only the RSA check stands between this and an identity.
let segments: Vec<&str> = token.split('.').collect();
assert_eq!(segments.len(), 3, "a JWT has three segments");
let mut forged_claims = claims_for(&server.uri(), TEST_AUDIENCE, exp);
forged_claims["sub"] = serde_json::json!("root");
let forged_payload = {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&forged_claims).expect("serialize forged claims"))
};
let forged = format!("{}.{forged_payload}.{}", segments[0], segments[2]);
cache
.validate_token(&forged)
.await
.expect_err("a swapped payload must fail the signature check");
}
#[tokio::test]
async fn rs256_token_signed_by_unrelated_key_is_rejected() {
let server = wiremock::MockServer::start().await;
let cache = cache_with_mock_issuer(&server).await;
let other = TestRsaKey::generate();
let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
header.kid = Some(TEST_KID.to_owned());
let token = jsonwebtoken::encode(
&header,
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600),
&jsonwebtoken::EncodingKey::from_rsa_pem(other.private_pem.as_bytes())
.expect("load unrelated signing key"),
)
.expect("sign with unrelated key");
cache
.validate_token(&token)
.await
.expect_err("a token signed by a key outside the JWKS must be rejected");
}
#[tokio::test]
async fn rs256_expired_token_is_rejected() {
let server = wiremock::MockServer::start().await;
let cache = cache_with_mock_issuer(&server).await;
// Beyond the 60s default leeway.
let token = mint_rs256(
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() - 3600),
TEST_KID,
);
cache
.validate_token(&token)
.await
.expect_err("an expired token must be rejected");
}
#[tokio::test]
async fn rs256_token_from_other_issuer_is_rejected() {
let server = wiremock::MockServer::start().await;
let cache = cache_with_mock_issuer(&server).await;
let token = mint_rs256(
&claims_for("https://evil.example.com", TEST_AUDIENCE, now_secs() + 3600),
TEST_KID,
);
cache
.validate_token(&token)
.await
.expect_err("a token from another issuer must be rejected");
}
#[tokio::test]
async fn rs256_token_for_other_audience_is_rejected() {
let server = wiremock::MockServer::start().await;
let cache = cache_with_mock_issuer(&server).await;
let token = mint_rs256(
&claims_for(&server.uri(), "some-other-client", now_secs() + 3600),
TEST_KID,
);
cache
.validate_token(&token)
.await
.expect_err("a token minted for another audience must be rejected");
}
#[tokio::test]
async fn rs256_token_with_unknown_kid_is_rejected() {
let server = wiremock::MockServer::start().await;
let cache = cache_with_mock_issuer(&server).await;
let token = mint_rs256(
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600),
"rotated-away-key",
);
cache
.validate_token(&token)
.await
.expect_err("a token naming a kid absent from the JWKS must be rejected");
}
}
@@ -16,6 +16,8 @@ use tonic::{Request, Response, Status};
use crate::MIDDLEWARE_GRPC_MESSAGE_BYTES;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Clone)]
pub struct RemoteMiddlewareService {
@@ -30,7 +32,12 @@ impl RemoteMiddlewareService {
format!(
"middleware registration '{registration_name}' has an invalid grpc_endpoint"
)
})?;
})?
.http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL)
.keep_alive_while_idle(true)
.keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT)
.http2_adaptive_window(true);
if grpc_endpoint.starts_with("https://") {
endpoint = endpoint
.tls_config(ClientTlsConfig::new().with_enabled_roots())
@@ -39,6 +46,7 @@ impl RemoteMiddlewareService {
format!("middleware registration '{registration_name}' could not configure TLS")
})?;
}
let channel = endpoint
.connect_timeout(CONNECT_TIMEOUT)
.connect()
@@ -49,6 +57,7 @@ impl RemoteMiddlewareService {
"middleware registration '{registration_name}' could not connect to {grpc_endpoint}"
)
})?;
Ok(Self {
client: SupervisorMiddlewareClient::new(channel)
.max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES)
@@ -14,6 +14,7 @@ rust_library(
srcs = glob(["src/**/*.rs"]),
aliases = aliases(),
compile_data = glob(["data/**/*"]),
crate_features = ["bundled-ca-roots"],
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True),
)
@@ -22,6 +23,7 @@ rust_test(
name = "openshell-supervisor-network_test",
compile_data = glob(["testdata/**/*"]),
crate = ":openshell-supervisor-network",
crate_features = ["bundled-ca-roots"],
deps = all_crate_deps(normal_dev = True),
)
@@ -21,7 +21,7 @@ hex = "0.4"
miette = { workspace = true }
nix = { workspace = true }
rand = "0.10"
russh = "0.61"
russh = "0.62"
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true }
+195 -8
View File
@@ -20,9 +20,9 @@ use openshell_core::provider_credentials::ProviderCredentialState;
use openshell_ocsf::{
ActionId, ActivityId, DispositionId, SeverityId, SshActivityBuilder, StatusId, ocsf_emit,
};
use russh::ChannelId;
use russh::keys::{Algorithm, PrivateKey};
use russh::server::{Auth, Handle, Session};
use russh::server::{Auth, ChannelOpenHandle, Handle, Session};
use russh::{ChannelId, ChannelOpenFailure};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, RawFd};
@@ -297,10 +297,12 @@ impl russh::server::Handler for SshHandler {
async fn channel_open_session(
&mut self,
channel: russh::Channel<russh::server::Msg>,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<bool, Self::Error> {
) -> Result<(), Self::Error> {
self.channels.insert(channel.id(), ChannelState::default());
Ok(true)
reply.accept().await;
Ok(())
}
/// Clean up per-channel state when the channel is closed.
@@ -324,8 +326,9 @@ impl russh::server::Handler for SshHandler {
port_to_connect: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<bool, Self::Error> {
) -> Result<(), Self::Error> {
// Validate port range before truncating u32 -> u16. The SSH protocol
// uses u32 for ports, but valid TCP ports are 0-65535. Without this
// check, port 65537 truncates to port 1 (privileged).
@@ -339,7 +342,10 @@ impl russh::server::Handler for SshHandler {
"direct-tcpip rejected: port {port_to_connect} exceeds valid TCP range for host {host_to_connect}"
))
.build());
return Ok(false);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
// Only allow forwarding to loopback destinations to prevent the
@@ -354,7 +360,10 @@ impl russh::server::Handler for SshHandler {
"direct-tcpip rejected: non-loopback destination {host_to_connect}:{port_to_connect}"
))
.build());
return Ok(false);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
let host = host_to_connect.to_string();
@@ -363,6 +372,10 @@ impl russh::server::Handler for SshHandler {
let port = u16::try_from(port_to_connect).unwrap_or(u16::MAX);
let netns_fd = self.netns_fd;
// Confirm the channel before spawning: the task below writes to it, and
// the peer must see the open-confirmation first.
reply.accept().await;
tokio::spawn(async move {
let addr = format!("{host}:{port}");
let tcp = match connect_in_netns(&addr, netns_fd).await {
@@ -387,7 +400,7 @@ impl russh::server::Handler for SshHandler {
let _ = tokio::io::copy_bidirectional(&mut channel_stream, &mut tcp_stream).await;
});
Ok(true)
Ok(())
}
async fn pty_request(
@@ -1951,4 +1964,178 @@ mod tests {
"resolved-identity-ok"
);
}
// -----------------------------------------------------------------------
// direct-tcpip authorization wiring (SEC-007)
//
// The `loopback_host_*` tests above cover the predicate in isolation.
// These drive the real `russh::server::Handler` over an in-memory duplex
// so the deny path itself is covered: channel-open authorization travels
// through a reply handle rather than the handler's return value, so a
// handler that never rejects anything still type-checks and still passes
// every predicate test.
// -----------------------------------------------------------------------
struct AcceptAnyServerKey;
impl russh::client::Handler for AcceptAnyServerKey {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
fn forwarding_test_policy() -> SandboxPolicy {
use openshell_core::policy::{
FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy,
};
SandboxPolicy {
version: 0,
filesystem: FilesystemPolicy::default(),
network: NetworkPolicy::default(),
landlock: LandlockPolicy::default(),
process: ProcessPolicy {
run_as_user: None,
run_as_group: None,
},
}
}
/// Serve `SshHandler` on one end of an in-memory duplex and return an
/// authenticated client handle for the other end.
///
/// The handler gets `netns_fd: None` so `connect_in_netns` performs a plain
/// TCP connect, making the forwarding path reachable without a network
/// namespace.
async fn authenticated_test_client() -> russh::client::Handle<AcceptAnyServerKey> {
// Scoped so the `!Send` ThreadRng is dropped before the first await.
let host_key = {
let mut rng = rand::rng();
PrivateKey::random(&mut rng, Algorithm::Ed25519).expect("host key")
};
let mut server_config = russh::server::Config {
auth_rejection_time: Duration::from_millis(1),
..Default::default()
};
server_config.keys.push(host_key);
let handler = SshHandler::new(
forwarding_test_policy(),
ResolvedWorkspace::default(),
None,
None,
None,
ProviderCredentialState::from_child_env_snapshot(0, HashMap::new()),
HashMap::new(),
ResolvedProcessIdentity::default(),
ProcessEnforcementMode::NetworkOnly,
);
let (server_stream, client_stream) = tokio::io::duplex(64 * 1024);
tokio::spawn(async move {
if let Ok(session) =
russh::server::run_stream(Arc::new(server_config), server_stream, handler).await
{
let _ = session.await;
}
});
let mut client = russh::client::connect_stream(
Arc::new(russh::client::Config::default()),
client_stream,
AcceptAnyServerKey,
)
.await
.expect("SSH handshake should complete over the duplex");
let auth = client
.authenticate_none("sandbox")
.await
.expect("auth_none should not error");
assert!(
matches!(auth, russh::client::AuthResult::Success),
"sandbox SSH server accepts the none auth method"
);
client
}
#[tokio::test]
async fn direct_tcpip_rejects_non_loopback_destination() {
let client = authenticated_test_client().await;
let err = client
.channel_open_direct_tcpip("10.0.0.1", 80, "127.0.0.1", 0)
.await
.expect_err("forwarding to a non-loopback host must be refused");
assert!(
matches!(
err,
russh::Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited)
),
"expected AdministrativelyProhibited, got {err:?}"
);
}
#[tokio::test]
async fn direct_tcpip_rejects_port_above_tcp_range() {
let client = authenticated_test_client().await;
// 65_537 truncates to port 1 when cast to u16, so the guard has to
// reject it before the cast rather than forward to a privileged port.
let err = client
.channel_open_direct_tcpip("127.0.0.1", 65_537, "127.0.0.1", 0)
.await
.expect_err("a port outside the TCP range must be refused");
assert!(
matches!(
err,
russh::Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited)
),
"expected AdministrativelyProhibited, got {err:?}"
);
}
#[tokio::test]
async fn direct_tcpip_forwards_to_loopback_listener() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback echo listener");
let port = listener.local_addr().expect("listener address").port();
tokio::spawn(async move {
if let Ok((mut socket, _)) = listener.accept().await {
let mut buf = [0u8; 64];
if let Ok(n) = socket.read(&mut buf).await
&& n > 0
{
let _ = socket.write_all(&buf[..n]).await;
}
}
});
let client = authenticated_test_client().await;
let channel = client
.channel_open_direct_tcpip("127.0.0.1", u32::from(port), "127.0.0.1", 0)
.await
.expect("forwarding to a loopback listener must be allowed");
let mut stream = channel.into_stream();
stream.write_all(b"ping").await.expect("write to channel");
let mut echoed = [0u8; 4];
tokio::time::timeout(Duration::from_secs(10), stream.read_exact(&mut echoed))
.await
.expect("relayed response should arrive before the timeout")
.expect("read from channel");
assert_eq!(&echoed, b"ping", "bytes round-trip through the tunnel");
}
}
+1
View File
@@ -9,6 +9,7 @@ rust_library(
srcs = glob(["src/**/*.rs"]),
aliases = aliases(),
target_compatible_with = ["@platforms//os:linux"],
visibility = ["//visibility:public"],
deps = all_crate_deps(normal = True),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+4 -2
View File
@@ -114,8 +114,10 @@ reflection, inference-route management, and HTTP requests. A
is expected for those requests. The gateway fails startup if a callback
requirement resolves to the exact primary listener address because one socket
cannot preserve both authorization scopes. For the IPv4-loopback callback used
by Podman Machine, bind the primary listener to a distinct address such as
`[::1]:17670`.
by Podman Machine, set `bind_address = "[::1]:17670"` for the primary listener
and register `https://localhost:17670` as the CLI endpoint. The hostname matches
the generated certificate and avoids the TLS transport error produced by a raw
IPv6-literal endpoint. Do not broaden the primary listener to `0.0.0.0`.
## Docker Driver
+22 -10
View File
@@ -386,6 +386,16 @@ export OPENSHELL_E2E_GATEWAY_CA_CERT="${PKI_DIR}/ca.crt"
HOST_PORT=$(e2e_pick_port)
HEALTH_PORT=$(e2e_pick_port)
if [ "$(uname -s)" = "Darwin" ]; then
# Podman Machine reserves IPv4 loopback for its callback-only listener.
PRIMARY_BIND_IP="::1"
CLI_ENDPOINT_HOST="localhost"
HEALTH_ENDPOINT_HOST="[::1]"
else
PRIMARY_BIND_IP="127.0.0.1"
CLI_ENDPOINT_HOST="127.0.0.1"
HEALTH_ENDPOINT_HOST="127.0.0.1"
fi
STATE_DIR="${WORKDIR}/state"
mkdir -p "${STATE_DIR}"
export XDG_STATE_HOME="${STATE_DIR}"
@@ -415,11 +425,11 @@ toml_string() {
GATEWAY_CONFIG="${STATE_DIR}/gateway.toml"
# Start from the RPM default template so this e2e test exercises the same
# TOML config path that RPM users get on first start. The template leaves
# bind_address unset and sets compute_drivers = ["podman"], so this test
# exercises the built-in loopback listener plus the callback listener
# requested by the Podman driver.
# Start from the RPM default template so this e2e test exercises the same TOML
# config path that RPM users get on first start. The template leaves
# bind_address unset and sets compute_drivers = ["podman"]. On Podman Machine,
# the driver reserves IPv4 loopback for its callback-only listener, so the
# primary listener uses IPv6 loopback. Native Linux keeps the IPv4 default.
#
# We append the driver-specific table and override the port via CLI flag
# (CLI > TOML in the merge precedence) so the test can use an ephemeral port.
@@ -458,8 +468,9 @@ cp "${ROOT}/deploy/rpm/gateway.toml.default" "${GATEWAY_CONFIG}"
GATEWAY_ARGS=(
--config "${GATEWAY_CONFIG}"
# compute_drivers comes from the RPM template, while bind_address uses the
# built-in loopback default. Override only the port for ephemeral selection.
# compute_drivers comes from the RPM template. Override the loopback address
# and port so Podman Machine can keep its IPv4 callback listener distinct.
--bind-address "${PRIMARY_BIND_IP}"
--port "${HOST_PORT}"
--health-port "${HEALTH_PORT}"
--tls-cert "${PKI_DIR}/server/tls.crt"
@@ -495,10 +506,10 @@ printf '%s\n' "${GATEWAY_PID}" >"${GATEWAY_PID_FILE}"
GATEWAY_NAME="openshell-e2e-podman-${HOST_PORT}"
if [ "${OIDC_MODE}" = "1" ]; then
CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}"
CLI_GATEWAY_ENDPOINT="https://${CLI_ENDPOINT_HOST}:${HOST_PORT}"
export OPENSHELL_E2E_OIDC_GATEWAY_ENDPOINT="${CLI_GATEWAY_ENDPOINT}"
else
CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}"
CLI_GATEWAY_ENDPOINT="https://${CLI_ENDPOINT_HOST}:${HOST_PORT}"
e2e_register_mtls_gateway \
"${XDG_CONFIG_HOME}" \
"${GATEWAY_NAME}" \
@@ -524,7 +535,8 @@ while [ "${elapsed}" -lt "${timeout}" ]; do
echo "ERROR: openshell-gateway exited before becoming healthy"
exit 1
fi
if curl -sf "http://127.0.0.1:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
# Keep this loopback probe direct even when ::1 is absent from NO_PROXY.
if curl --noproxy '*' -sf "http://${HEALTH_ENDPOINT_HOST}:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
echo "Gateway healthy after ${elapsed}s."
break
fi
+66
View File
@@ -1,5 +1,27 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.buf]]
version = "1.72.0"
backend = "aqua:bufbuild/buf"
[tools.buf."platforms.linux-arm64"]
checksum = "sha256:7641bd7e06a37a54cbb8c789f53465899def96196ab5c08057432f781a15d517"
url = "https://github.com/bufbuild/buf/releases/download/v1.72.0/buf-Linux-aarch64.tar.gz"
url_api = "https://api.github.com/repos/bufbuild/buf/releases/assets/480772526"
provenance = "minisign"
[tools.buf."platforms.linux-x64"]
checksum = "sha256:a9c6186cf6fcf062b247345e1b7b12c26f580c1b2a4bbf4d3fe080abf85ceee8"
url = "https://github.com/bufbuild/buf/releases/download/v1.72.0/buf-Linux-x86_64.tar.gz"
url_api = "https://api.github.com/repos/bufbuild/buf/releases/assets/480772583"
provenance = "minisign"
[tools.buf."platforms.macos-arm64"]
checksum = "sha256:be040ae0ca381103dfda68a36738695c4db3e48de8e91412acdc3d991f39b91e"
url = "https://github.com/bufbuild/buf/releases/download/v1.72.0/buf-Darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/bufbuild/buf/releases/assets/480772487"
provenance = "minisign"
[[tools."github:EmbarkStudios/cargo-about"]]
version = "0.8.4"
backend = "github:EmbarkStudios/cargo-about"
@@ -104,6 +126,38 @@ checksum = "sha256:29caf036bdbb4e6f07afea31706b6f386cb5a4db9a46a3a8b462b9b78157e
url = "https://github.com/rust-cross/cargo-zigbuild/releases/download/v0.22.3/cargo-zigbuild-aarch64-apple-darwin.tar.xz"
url_api = "https://api.github.com/repos/rust-cross/cargo-zigbuild/releases/assets/405676922"
[[tools.go]]
version = "1.26.5"
backend = "core:go"
[tools.go."platforms.linux-arm64"]
checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49"
url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz"
[tools.go."platforms.linux-x64"]
checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053"
url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz"
[tools.go."platforms.macos-arm64"]
checksum = "sha256:efb87ff28af9a188d0536ef5d42e63dd52ba8263cd7344a993cc48dd11dedb6a"
url = "https://dl.google.com/go/go1.26.5.darwin-arm64.tar.gz"
[[tools."go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint"]]
version = "2.12.2"
backend = "go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint"
[[tools."go:golang.org/x/tools/cmd/goimports"]]
version = "0.48.0"
backend = "go:golang.org/x/tools/cmd/goimports"
[[tools."go:google.golang.org/grpc/cmd/protoc-gen-go-grpc"]]
version = "1.6.2"
backend = "go:google.golang.org/grpc/cmd/protoc-gen-go-grpc"
[[tools."go:google.golang.org/protobuf/cmd/protoc-gen-go"]]
version = "1.36.11"
backend = "go:google.golang.org/protobuf/cmd/protoc-gen-go"
[[tools.helm]]
version = "4.2.0"
backend = "aqua:helm/helm"
@@ -127,14 +181,17 @@ backend = "aqua:norwoodj/helm-docs"
[tools.helm-docs."platforms.linux-arm64"]
checksum = "sha256:c3787212332386dcd122debef7848feb165aa701467ae3e3442df7638f3ac4e4"
url = "https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/norwoodj/helm-docs/releases/assets/178327216"
[tools.helm-docs."platforms.linux-x64"]
checksum = "sha256:a8cf72ada34fad93285ba2a452b38bdc5bd52cc9a571236244ec31022928d6cc"
url = "https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Linux_x86_64.tar.gz"
url_api = "https://api.github.com/repos/norwoodj/helm-docs/releases/assets/178327210"
[tools.helm-docs."platforms.macos-arm64"]
checksum = "sha256:2d8399db5b33d240d5f8985241bcf5483563150b968e3229823822979f3e4b8b"
url = "https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/norwoodj/helm-docs/releases/assets/178327215"
[[tools.k3d]]
version = "5.8.3"
@@ -143,14 +200,17 @@ backend = "aqua:k3d-io/k3d"
[tools.k3d."platforms.linux-arm64"]
checksum = "sha256:0b8110f2229631af7402fb828259330985918b08fefd38b7f1b788a1c8687216"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-arm64"
url_api = "https://api.github.com/repos/k3d-io/k3d/releases/assets/229450023"
[tools.k3d."platforms.linux-x64"]
checksum = "sha256:dbaa79a76ace7f4ca230a1ff41dc7d8a5036a8ad0309e9c54f9bf3836dbe853e"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-amd64"
url_api = "https://api.github.com/repos/k3d-io/k3d/releases/assets/229450045"
[tools.k3d."platforms.macos-arm64"]
checksum = "sha256:8da468daa7dc7cf7cdd4735f90a9bb05179fa27858250f62e3d8cdf5b5ca0698"
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-darwin-arm64"
url_api = "https://api.github.com/repos/k3d-io/k3d/releases/assets/229450067"
[[tools.kubectl]]
version = "1.36.1"
@@ -195,14 +255,17 @@ backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc."platforms.linux-arm64"]
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076"
[tools.protoc."platforms.linux-x64"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083"
[tools.protoc."platforms.macos-arm64"]
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795082"
[[tools.python]]
version = "3.14.5"
@@ -251,16 +314,19 @@ backend = "aqua:astral-sh/uv"
[tools.uv."platforms.linux-arm64"]
checksum = "sha256:55bd1c1c10ec8b95a8c184f5e18b566703c6ab105f0fc118aaa4d748aabf28e4"
url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-musl.tar.gz"
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491942"
provenance = "github-attestations"
[tools.uv."platforms.linux-x64"]
checksum = "sha256:adccf40b5d1939a5e0093081ec2307ea24235adf7c2d96b122c561fa37711c46"
url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-musl.tar.gz"
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491998"
provenance = "github-attestations"
[tools.uv."platforms.macos-arm64"]
checksum = "sha256:ae738b5661a900579ec621d3918c0ef17bdec0da2a8a6d8b161137cd15f25414"
url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-apple-darwin.tar.gz"
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491929"
provenance = "github-attestations"
[[tools.zig]]
+6
View File
@@ -25,6 +25,12 @@ node = "24.15.0"
kubectl = "1.36.1"
uv = "0.10.12"
protoc = "29.6"
go = "1.26"
"go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint" = "2.12"
"go:google.golang.org/protobuf/cmd/protoc-gen-go" = "1.36.11"
"go:google.golang.org/grpc/cmd/protoc-gen-go-grpc" = "1.6.2"
"go:golang.org/x/tools/cmd/goimports" = "0.48.0"
buf = "1.72.0"
helm = "4.2.0"
helm-docs = "1.14.2"
skaffold = "2.20.0"
@@ -0,0 +1,589 @@
---
authors:
- "@johntmyers"
state: review
links:
- https://github.com/NVIDIA/OpenShell/issues/1107
- https://github.com/NVIDIA/OpenShell/pull/2155
- https://github.com/NVIDIA/OpenShell/pull/1083
- https://github.com/NVIDIA/OpenShell/pull/1151
- https://github.com/NVIDIA/OpenShell/pull/1286
- https://github.com/NVIDIA/OpenShell/pull/1511
- https://github.com/NVIDIA/OpenShell/pull/1738
- https://github.com/NVIDIA/OpenShell/pull/2027
- https://github.com/NVIDIA/OpenShell/pull/1865
- https://github.com/NVIDIA/OpenShell/pull/1938
---
# RFC 0005 - Sandbox Proxy Egress Adapter Model
<!--
See rfc/README.md for the full RFC process and state definitions.
-->
## Summary
Refactor sandbox egress around shared authorization, destination-validation,
and relay boundaries. CONNECT, forward HTTP, native TCP capture, policy DNS,
`inference.local`, `policy.local`, and metadata loopback become narrow adapters
that translate userland entry points into common runtime intents. Policy
evaluation, destination validation, supervisor middleware, credential
injection, request-body rewrite, WebSocket handling, protocol processing, and
upstream dialing happen behind shared boundaries.
The RFC describes the complete forward-looking architecture. It is designed
to land incrementally across multiple pull requests. The first milestone only
restructures CONNECT and forward HTTP, extracts shared primitives, and
preserves every current user-facing feature and enforcement behavior. Later
milestones add policy DNS, transparent TCP capture, native protocol processors,
and optional deployment shapes on top of those boundaries.
The codebase has already moved in this direction by splitting networking into
`openshell-supervisor-network` and process/netns work into
`openshell-supervisor-process`. This RFC proposes the next internal boundary:
make proxy entry mechanisms pluggable without duplicating authorization,
destination validation, or relay behavior.
Supporting detail lives in:
- [Current shape appendix](current-shape.md)
- [Technical design appendix](technical-design.md)
- [Implementation plan](implementation-plan.md)
## Motivation
The sandbox proxy supports several connection surfaces: explicit CONNECT,
forward HTTP, local inference and policy APIs, metadata loopback, TLS
termination, REST, GraphQL, JSON-RPC, MCP, and WebSocket inspection,
credential injection, supervisor middleware, and nftables-backed bypass
detection. These features are valuable, but changes to policy and enforcement
still tend to touch multiple entry paths.
The risk is asymmetric enforcement. A security fix can be added to CONNECT and
missed in forward HTTP; endpoint metadata can be selected differently from the
logged policy; a credential path can gain request-body or WebSocket support
without the same behavior existing in another relay mode.
The target shape separates three concerns:
- **Adapters** describe how userland reached the networking component.
- **Authorization** decides whether the egress is allowed and what endpoint
behavior applies.
- **Relays** own bytes, credentials, protocol parsing, and upstream dialing.
The first milestone targets the current embedded/network-only supervisor
runtime and preserves its existing user-facing behavior while the internal
seams move. The same boundaries then support policy DNS and transparent TCP,
native protocol processing, and future deployment modes without duplicating
authorization or relay logic.
## Non-goals
- Replace CONNECT with forward proxy as the only explicit proxy mode.
- Add SOCKS support.
- Add HTTP/2 L7 parsing in this refactor. Inspected HTTP paths should continue
to reject unsupported h2c upgrades instead of silently upgrading to raw
traffic.
- Redesign provider credential storage.
- Reintroduce iptables as the sandbox packet filtering backend.
- Use eBPF connect hooks for transparent capture. Native TCP capture needs a
userland proxy in the byte stream for TLS termination and protocol parsing.
- Add policy-declared supervisor-proxied host-local endpoints. Issue
[#1633](https://github.com/NVIDIA/OpenShell/issues/1633) can consume these
boundaries in separate feature work.
- Change the existing endpoint-only runtime's process-identity semantics during
the compatibility refactor. Future identity-less deployment modes require an
explicit capability contract and cannot inherit endpoint-only behavior by
accident.
## Proposal
### Migration Big Rocks
1. **Transport and local-service adapters.** CONNECT, forward HTTP,
transparent TCP, policy DNS, `inference.local`, `policy.local`, and metadata
loopback become small adapters. They parse their surface and produce either
an egress intent, a local response, or a DNS answer. They do not duplicate
policy evaluation.
2. **Egress intent and decision.** Shared authorization evaluates L4 policy and
endpoint selection once per connection intent and returns one decision
containing the matched policy, matched endpoint, optional process identity
evidence used for evaluation, allowed IP metadata, TLS behavior, protocol
enforcement, and credential injection and middleware plans.
3. **Relays.** Relays receive an authorized destination connector, not an
already-open upstream socket. HTTP relays evaluate every request before
upstream write. TCP relays copy bytes for L4-only endpoints or hand the
stream to a protocol processor when endpoint policy requires native protocol
enforcement.
The first implementation milestone populates a compatibility
`EgressDecision` through the existing separate queries so type extraction does
not change behavior. That transitional envelope is not the target single
authorization result. Generation-consistent materialization and deterministic
endpoint selection cut over separately after shadow comparison, before later
transport adapters depend on the result.
### Unified Adapter Flow
```mermaid
flowchart TD
User["Userland payload / harness"]
subgraph ExplicitProxy["Explicit proxy listener"]
ProxyBytes["HTTP proxy bytes"]
IsConnect{"CONNECT request?"}
Connect["CONNECT adapter"]
Forward["Forward HTTP adapter"]
ProxyBytes --> IsConnect
IsConnect -- Yes --> Connect
IsConnect -- No --> Forward
end
subgraph NativeTcp["Policy DNS + native TCP"]
NameLookup["Userland DNS lookup"]
PolicyDns["Policy DNS adapter"]
DnsEligible{"Eligible native TCP<br/>policy endpoint?"}
DnsDeny["Local DNS refusal<br/>no upstream lookup"]
TrustedDns["Trusted upstream lookup<br/>and destination filtering"]
DnsMapping["Synthetic IP + active mapping<br/>to validated real addresses"]
DnsAnswer["Return synthetic IP"]
NativeConnect["Userland connect(synthetic_ip:port)"]
TcpAdapter["Transparent TCP adapter<br/>recover mapping"]
NameLookup --> PolicyDns
PolicyDns --> DnsEligible
DnsEligible -- No --> DnsDeny
DnsEligible -- Yes --> TrustedDns
TrustedDns --> DnsMapping
DnsMapping --> DnsAnswer
DnsAnswer --> NativeConnect
NativeConnect --> TcpAdapter
end
subgraph LocalApis["Sandbox-local services"]
InferenceReq["Request to inference.local"]
PolicyReq["Request to policy.local"]
MetadataReq["Request to metadata loopback"]
InferenceAdapter["Inference local adapter"]
PolicyAdapter["Policy local adapter"]
MetadataAdapter["Metadata loopback adapter"]
InferenceReq --> InferenceAdapter
PolicyReq --> PolicyAdapter
MetadataReq --> MetadataAdapter
end
subgraph Shared["Shared external egress pipeline"]
Intent["EgressIntent"]
Auth["Authorize and select endpoint"]
Decision["EgressDecision"]
Validate["Resolve or consume pinned destination<br/>and validate"]
Relay["Relay"]
Deny["Adapter-specific deny response"]
Intent --> Auth
Auth --> Allowed{"Allowed?"}
Allowed -- No --> Deny
Allowed -- Yes --> Decision
Decision --> Validate
Validate --> Relay
end
User --> ProxyBytes
User --> NameLookup
User --> NativeConnect
User --> InferenceReq
User --> PolicyReq
User --> MetadataReq
Connect --> Intent
Forward --> Intent
TcpAdapter --> Intent
InferenceAdapter --> InferenceResp["Local inference response"]
PolicyAdapter --> PolicyResp["Local policy response"]
MetadataAdapter --> MetadataResp["Local metadata credential response"]
```
Each adapter still owns its response shape. If authorization denies a CONNECT
intent, the CONNECT adapter returns a tunnel denial. If forward HTTP is denied,
the forward adapter returns an HTTP denial. If policy DNS refuses a name, it
returns the appropriate DNS response. The shared layer decides the outcome;
the adapter renders it for its protocol.
### Relay Flow
```mermaid
flowchart TD
Start["Authorized egress + destination connector"]
Start --> FirstReq{"Forward HTTP adapter<br/>already has first request?"}
FirstReq -- Yes --> ForwardEnforcement{"Endpoint enforcement"}
ForwardEnforcement -- "None or HTTP" --> HttpReq["Parsed HTTP request"]
ForwardEnforcement -- "Protocol processor" --> BadForward["Deny: HTTP request for native protocol endpoint"]
FirstReq -- No --> Prepare["Prepare readable client stream"]
Prepare --> TlsPolicy{"TLS handling enabled?"}
TlsPolicy -- No --> Readable["Client stream"]
TlsPolicy -- Yes --> Peek["Peek client bytes"]
Peek --> Tls{"TLS ClientHello?"}
Tls -- Yes --> Terminate["Shared TLS terminator"]
Tls -- No --> Readable
Terminate --> Readable
Readable --> Enforce{"Endpoint enforcement"}
Enforce -- "None" --> Sniff{"HTTP request detected?"}
Sniff -- Yes --> ParseHttp["Parse HTTP request"]
Sniff -- No --> TcpRelay["TcpRelay<br/>connect upstream and copy bytes"]
ParseHttp --> HttpReq
Enforce -- "HTTP" --> MustHttp{"HTTP request detected?"}
MustHttp -- Yes --> ParseHttp
MustHttp -- No --> DenyHttp["Deny: expected HTTP"]
Enforce -- "Protocol processor" --> Processor["TcpRelay hands stream to protocol processor"]
Processor --> ProcessorOwns["Processor owns message loop<br/>and calls connector when allowed"]
subgraph HttpLoop["HTTP relay request loop"]
HttpReq --> HttpMode{"HTTP endpoint policy?"}
HttpMode -- "L4-only HTTP" --> ReqAllowed["Request admitted by connection decision"]
HttpMode -- "REST / GraphQL / JSON-RPC / MCP / WebSocket" --> ReqPolicy{"Request policy allowed?"}
ReqPolicy -- No --> ReqDeny["Local HTTP deny<br/>no upstream write"]
ReqPolicy -- Yes --> ReqAllowed
ReqAllowed --> Middleware{"Supervisor middleware<br/>configured?"}
Middleware -- Yes --> MwEval["Run HTTP_REQUEST / PRE_CREDENTIALS middleware"]
Middleware -- No --> Creds["Resolve static placeholders<br/>and token grants"]
MwEval --> MwAllowed{"Middleware allowed?"}
MwAllowed -- No --> MwDeny["Local middleware deny<br/>no credential injection"]
MwAllowed -- Yes --> Recheck["Re-parse transformed protocol body<br/>and re-evaluate request policy"]
Recheck --> PostMwAllowed{"Allowed under endpoint<br/>enforcement mode?"}
PostMwAllowed -- No --> PostMwDeny["Local policy deny<br/>no credential injection"]
PostMwAllowed -- Yes --> Creds
Creds --> Rewrite["Inject credentials into configured slots"]
Rewrite --> HttpDial["Connect or reuse upstream"]
HttpDial --> HttpResponse["Write request and relay response"]
HttpResponse --> Upgrade{"101 WebSocket upgrade?"}
Upgrade -- No --> NextReq{"Another HTTP request<br/>on this connection?"}
NextReq -- Yes --> HttpReq
NextReq -- No --> Done["HTTP relay done"]
Upgrade -- Yes --> WsInspect{"WebSocket inspection<br/>or rewrite configured?"}
WsInspect -- No --> RawUpgrade["Raw upgraded stream"]
WsInspect -- Yes --> WsRelay["WebSocket relay<br/>text-frame rewrite / message policy"]
end
```
Read this as two phases. The top half chooses the relay shape from the adapter
surface and endpoint enforcement. The `HTTP relay request loop` only receives a
parsed HTTP request. Supervisor middleware is not another policy funnel; it is
an optional request-path hook after HTTP policy allows the request and before
OpenShell-managed credential injection. When middleware changes a request
body, the relay re-parses body-dependent protocol inputs and re-evaluates
request policy before credential injection or upstream write.
Relay rules:
- HTTP credential injection happens in both HTTP modes: L4-only HTTP and
HTTP-inspected.
- HTTP-inspected endpoints include `rest`, `graphql`, `json-rpc`, `mcp`, and
`websocket`. JSON-RPC and MCP are HTTP L7 protocols, not native TCP protocol
processors.
- Supervisor middleware is a typed relay hook. V1 middleware runs on parsed
HTTP requests at `HTTP_REQUEST / PRE_CREDENTIALS`, after network and request
policy admit the request and before OpenShell injects credentials.
- Middleware can allow, deny, replace the bounded request body, add approved
headers, and emit audit-safe findings/metadata. External middleware must not
receive OpenShell-managed credentials.
- Middleware mutation cannot bypass request policy. After a body replacement,
the relay re-parses and re-evaluates body-dependent GraphQL, JSON-RPC, and MCP
policy inputs before credential injection or upstream write. A policy
mismatch follows the endpoint's configured enforcement mode; a malformed or
unclassifiable transformed protocol body fails closed even in audit mode.
- Credential injection includes static placeholder rewrite and endpoint-bound
dynamic token grants. Token grants run after policy allow and before upstream
write; failures deny without forwarding the request.
- Middleware-transformed content must not create a new path for resolving
OpenShell credential placeholders unless the middleware hook is explicitly
trusted as credential-capable. The safe default is to fail closed on newly
introduced reserved placeholders before credential injection.
- Static credential rewrite covers request target, query, headers, opt-in REST
request bodies, and opt-in client-to-server WebSocket text frames.
- HTTP L7 policy is evaluated before upstream write for each request. JSON-RPC
and MCP evaluation parse bounded JSON-RPC-over-HTTP bodies; MCP adds
tool-aware selectors for `tools/call`.
- WebSocket upgrade policy is evaluated as HTTP first. After an allowed `101`
upgrade, the WebSocket relay owns frame parsing when text-frame credential
rewrite, WebSocket transport policy, GraphQL-over-WebSocket policy, or safe
compression handling is configured. Other upgraded streams remain raw.
- Forward HTTP must stay in the shared HTTP relay loop or in an equivalent
guarded single-request relay. It must not evaluate one request and then
switch to raw bidirectional copy.
- `protocol: tcp` or an omitted protocol means L4 authorization plus byte copy,
except that HTTP-looking streams may still use HTTP credential injection.
- Future native protocol processors, such as Redis, Postgres, or MySQL, own the
full message loop and can parse multiple commands or queries on one TCP
session. A processor may be in-tree, middleware-backed, or a combination
where in-tree framing exposes typed middleware hooks.
### Adapter Responsibilities
CONNECT remains the generic explicit proxy mode for HTTPS and arbitrary TCP.
The CONNECT adapter parses `CONNECT host:port` into an `EgressIntent`, asks the
shared authorization boundary for an `EgressDecision`, returns the tunnel-ready
response only after the connection is allowed, and then hands the tunnel to the
relay. The upstream connection is opened by the HTTP relay or protocol
processor when payload policy allows it. The compatibility milestone preserves
the current raw-relay dial point until the processor boundary exists.
Forward HTTP is compatibility for clients that send absolute-form HTTP
requests. The adapter parses the first request, rewrites proxy framing only at
the relay boundary, rejects `https://` absolute-form requests, rejects
unsupported h2c upgrades on inspected routes, and either stays in a shared HTTP
request loop or forces `Connection: close` for a guarded single request.
Transparent TCP is for native clients that do not know they are using a proxy.
It depends on policy DNS and nftables capture. For a policy-eligible native TCP
name, policy DNS returns a supervisor-owned synthetic IP and creates an active
mapping from that IP to the normalized name, matched endpoint, allowed ports,
and validated real addresses. Userland later calls
`connect(synthetic_ip:port)`, nftables redirects the traffic to a userland
listener, and the TCP adapter recovers the synthetic destination and exact
mapping before building an intent.
Policy DNS replaces static `/etc/hosts` snapshots for native TCP names. It is
query-driven. It first checks whether the normalized name matches an endpoint
whose transport and protocol contract enables native TCP through policy DNS. A
name without such an endpoint receives a local policy-denial DNS response,
normally `REFUSED`, and is never sent to upstream DNS. An eligible name is
resolved through trusted DNS, and every returned address is filtered through
destination and SSRF controls before the adapter atomically publishes the
mapping and capture state and returns the synthetic IP to userland.
The later connect still runs through normal authorization. The connector may
dial only the validated real addresses pinned in the unexpired mapping; it must
not perform an unrelated fresh resolution or treat a direct connection to one
of those real IPs as correlated. Process identity remains independent
authorization evidence evaluated at connect time, not the mechanism that joins
the DNS request to the TCP connection.
Local service adapters stay outside the normal external egress relay:
`inference.local` routes chat, completion, model discovery, embeddings, and
provider-specific inference traffic through the router with local limits;
`policy.local` exposes current policy, denial summaries, proposal submission,
and proposal wait routes; metadata loopback serves provider metadata
credentials to SDKs that bypass HTTP proxy variables.
### Network Enforcement Substrate
Current main uses nftables for sandbox bypass enforcement. It accepts
proxy-bound traffic, loopback, and established flows, then rejects and
optionally logs other TCP/UDP traffic for the bypass monitor. That is current
enforcement, not native TCP capture.
```mermaid
flowchart TD
Packet["Userland packet"] --> ProxyDest{"Proxy destination?"}
ProxyDest -- Yes --> AcceptProxy["nftables accept"]
ProxyDest -- No --> Capture{"Active synthetic-IP<br/>capture match?"}
Capture -- Yes --> Redirect["nftables redirect/TPROXY to transparent adapter"]
Capture -- No --> Reject["nftables log + reject bypass"]
Reject --> Monitor["Bypass monitor emits OCSF"]
```
Transparent TCP extends this nftables model with explicit capture rules that
run before the reject path and are scoped to unexpired synthetic-IP mappings.
The sandbox resolver points to policy DNS, while direct external DNS traffic
continues to the reject path. DNS-over-HTTPS is ordinary HTTPS egress and
requires its own allowed endpoint. Transparent capture does not add a parallel
iptables path. The compatibility milestone leaves the current table unchanged;
capture arrives in a later feature phase.
### Deployment Modes
| Mode | Shape | Status |
|------|-------|--------|
| Embedded supervisor | `openshell-sandbox` orchestrates `openshell-supervisor-network` and `openshell-supervisor-process` | Current |
| Network-only supervisor | Networking, policy, proxy, local services, and background tasks run without a payload process leaf | Current runtime mode |
| Standalone proxy binary | Supervisor launches networking as a separate process with explicit APIs | Future packaging/API work |
| Sidecar proxy | Proxy runs outside the payload container but inside the sandbox boundary | Future isolation mode |
A pluggable proxy must expose the right userland surfaces, implement the
gateway APIs it needs, and prove equivalent policy enforcement through tests.
If supervisor middleware is configured, the proxy runtime must also receive the
effective middleware service registry, validate and refresh bindings, enforce
`fail_open` and `fail_closed`, buffer within configured caps, invoke middleware
on the request path, and emit middleware OCSF events.
Process identity is mode-dependent. Embedded supervisor mode normally requires
successful workload process, binary, and ancestor resolution; a lookup failure
continues to deny. A trusted runtime can explicitly select the existing
endpoint-only mode, in which identity is recorded as intentionally unavailable
and policy evaluation keeps its current endpoint-only semantics. The refactor
must not represent either case as a fabricated empty identity or accidentally
convert a lookup failure into endpoint-only evaluation.
Future standalone and sidecar modes must advertise identity capability. A mode
without local identity needs an explicit unavailable-identity contract and
policy validation for binary/path predicates; it does not automatically inherit
endpoint-only semantics. The nftables rules that force, capture, or reject
userland traffic remain owned by the sandbox network boundary even if the proxy
process later moves into a standalone binary or sidecar.
### Migration And Operational Contract
Mechanical adapter, destination, and relay extractions ship as isolated,
revertible changes without a permanent feature flag. The deterministic
authorization result is different: it first runs beside the legacy queries in
shadow mode, reports audit-safe mismatches through internal telemetry, and
retains the legacy evaluator through the cutover observation window.
Policy DNS, transparent TCP, native processors, and alternate deployment modes
land only after the shared authorization and relay contracts are authoritative.
Each is a separate, feature-bearing pull request or series with its own
capability gating, migration, telemetry, and rollback plan; they are not bundled
into the compatibility-only refactor.
Adapter response bytes and OCSF event class, action, disposition, severity,
status, destination, actor, firewall rule, message, and status detail are
compatibility surfaces. Moving code does not justify changing them. Performance
is also measured at each phase; fewer OPA evaluations are a target to verify,
not an unmeasured claim.
## Implementation plan
The detailed migration plan lives in [implementation-plan.md](implementation-plan.md).
The intended order is:
1. Lock down current responses, OCSF events, policy outcomes, credential
behavior, upstream-dial timing, and local-service behavior with regression
coverage.
2. Introduce compatibility `EgressIntent` and `EgressDecision` envelopes while
preserving current lookup precedence and failure defaults.
3. Centralize destination validation behind an unopened connector.
4. Materialize one generation-consistent authorization decision, compare it
against the legacy queries, then cut over deterministic endpoint selection
and fail-closed metadata handling as an independently reviewable step.
5. Consolidate HTTP request-loop, credential, WebSocket, and middleware relay
behavior in separately shippable subphases.
6. Consolidate TLS handling and existing raw TCP relay selection.
7. Preserve current local-service boundaries and remove compatibility plumbing.
8. Add the native protocol-processor dispatch contract, then add protocol
implementations as independently reviewed features.
9. Add policy DNS state and transparent TCP capture with mandatory
DNS-answer-to-connect correlation and separate mapping generations.
10. Define capability-checked standalone or sidecar runtime contracts and
complete cleanup after each boundary is in use.
Steps 1 through 7 are the compatibility foundation and may themselves span
several pull requests. Steps 8 through 10 are later feature milestones; their
presence in this RFC defines the direction without adding them to the initial
refactor branch.
## Risks
- Tightening L7 and TLS metadata failures from fail-open to deny may expose
latent policy or Rego errors. `allowed_ips` and SSRF validation already fail
more conservatively; tests must cover each query independently.
- Deterministic endpoint selection may change ambiguous overlapping policies.
The new decision must shadow the legacy queries and report mismatches before
any semantic cutover.
- Token grants add a runtime dependency on SPIFFE Workload API and token
endpoints. Failures should remain fail-closed and sanitized.
- Transparent TCP capture adds network-namespace interception and mutable DNS
mapping state. Synthetic address allocation must coexist with runtime
networks, avoid premature reuse, prevent unrelated bare-IP connections from
inheriting DNS authorization, and fail closed across policy or mapping
generation changes.
- Sidecar or standalone modes may intentionally lack process identity.
Binary/path-scoped policy needs an advertised identity capability and policy
validation; missing identity cannot silently broaden an allow.
- Metadata loopback and `policy.local` expand sandbox-local control surfaces
and need strict route validation, body limits, redaction, and authentication
boundaries.
- Provider-composed policy rules use a reserved namespace. Decisions and logs
must distinguish provider-derived policy from user-authored policy without
exposing provider rules as editable sandbox proposals.
- Supervisor middleware adds a synchronous request-path dependency. Body caps,
timeout behavior, registry reloads, and `fail_open` choices must be visible
in telemetry so operators can diagnose whether content inspection ran.
- Moving OCSF emission sites can accidentally change event class, action,
disposition, severity, message, or actor/destination fields. Adapter response
shapes and OCSF schemas are compatibility requirements, not cleanup targets.
- New decision/context objects add per-connection allocations on a hot path.
Performance claims require before/after measurements of OPA evaluation count,
allocation volume, and connection/request latency.
- Structural phases back out by reverting their isolated commits. The
deterministic-decision cutover must retain the legacy evaluator long enough
for shadow comparison and immediate rollback; a permanent feature flag is not
required for the purely mechanical phases.
## Alternatives
### Keep patching each entry path
This has the lowest short-term cost but keeps security behavior duplicated
across CONNECT, forward HTTP, and local services. It also makes future TCP
application protocol support harder because each parser must be wired through
multiple entry mechanisms.
### Replace CONNECT with forward proxy
Forward proxy only covers plaintext absolute-form HTTP requests. It is not a
replacement for HTTPS tunnels, WebSocket tunnels, or arbitrary TCP clients.
CONNECT should remain the generic explicit proxy mode.
### Build only transparent TCP
Transparent TCP helps native clients but does not replace explicit proxy
support used by common HTTP tooling. It also requires the shared authorization,
destination, and relay boundaries plus policy DNS and nftables capture before
it can safely preserve endpoint identity. For that reason it is a later phase
of this RFC, not the first implementation change.
### Return real addresses from policy DNS
Returning validated real addresses avoids a synthetic address pool, but the
later TCP connection carries only an IP and port. Two policy names can share
the same real address and port, and a direct bare-IP connection is
indistinguishable from one caused by the earlier lookup. Process identity does
not solve shared resolvers, caches, cross-process handoff, or two names resolved
by the same process. The proposal therefore uses a synthetic address as the
correlation handle and keeps process identity as separate authorization
evidence.
## Prior art
The current `openshell-supervisor-network` split is the immediate prior step:
it already separates proxy, OPA, L7, inference routing, policy-local routes,
TLS, and token grants from process supervision.
The current `openshell-supervisor-process` netns and bypass monitor are the
packet-enforcement substrate. Transparent TCP extends that nftables model in a
later phase rather than creating a second firewall path.
The existing L7 relay is the behavioral prior art for this RFC. It already
proves per-request HTTP evaluation, GraphQL parsing, JSON-RPC/MCP body
inspection, WebSocket frame handling, request-body rewrite, and token-grant
injection can live behind relay boundaries.
RFC 0009 supervisor middleware is the extension prior art. It defines
`HTTP_REQUEST / PRE_CREDENTIALS` as a supervisor-owned hook that can inspect,
deny, or transform admitted HTTP requests before credentials are injected. RFC
0005 should place that hook inside the shared relay rather than making each
adapter wire middleware separately.
## Open questions
1. Should overlapping endpoint metadata be rejected at policy load time, or
should one documented policy/endpoint precedence key select the complete
decision? The initial compatibility refactor does not choose between them.
2. What mismatch-free observation window is sufficient before the
deterministic decision replaces the legacy endpoint queries?
3. Should metadata loopback be modeled as an adapter inside
`openshell-supervisor-network`, or remain orchestrated by `openshell-sandbox`
with shared credential/provider helpers?
4. What TTL cap should policy DNS use, and should policy reload immediately
invalidate all active mappings or permit a bounded drain period that cannot
authorize new connections?
5. Which non-routable synthetic IPv4 and IPv6 ranges can each runtime reserve,
and what quarantine period prevents address reuse while stale DNS answers
may remain cached?
6. Which original-destination mechanism and nftables redirect mode should each
supported runtime use while keeping capture rules ahead of bypass rejection?
7. Which identity capabilities must standalone and sidecar runtimes advertise
before the gateway accepts binary/path-scoped policy for them?
@@ -0,0 +1,286 @@
# Current Shape Appendix
This appendix records the current proxy shape and the review findings that
motivate the adapter model. The main RFC intentionally keeps these details out
of the direction document.
## Current Runtime Split
The proxy is no longer only a large module inside `openshell-sandbox`.
Current main has three relevant runtime owners:
```mermaid
flowchart TD
Sandbox["openshell-sandbox<br/>orchestrator"]
Network["openshell-supervisor-network<br/>proxy, OPA, L7, TLS, inference,<br/>policy.local, token grants"]
Process["openshell-supervisor-process<br/>process leaf, SSH, netns,<br/>nftables, bypass monitor"]
Denials["Denial/activity aggregators"]
Gateway["Gateway policy/provider APIs"]
Sandbox --> Network
Sandbox --> Process
Network --> Denials
Process --> Denials
Sandbox --> Gateway
Network --> Gateway
```
`openshell-sandbox` creates the shared network namespace, owns denial/activity
channels, starts the policy poll loop, starts networking, starts the metadata
loopback server when needed, and then optionally starts the process leaf. If
`process_enabled` is false, the supervisor can run in network-only mode and
keep networking/background tasks alive until shutdown.
`openshell-supervisor-network` owns the explicit proxy listener, OPA engine
integration, L7 enforcement, TLS termination, inference routing, policy-local
routes, identity cache, provider credential injection, and token grants.
`openshell-supervisor-process` owns process execution, SSH, network namespace
helpers, nftables bypass rules, and the bypass monitor that turns nftables LOG
entries into OCSF events.
In embedded supervisor mode, the network leaf normally uses process metadata
resolved by the process/orchestrator side for binary-scoped policy and OCSF
context. Current runtime configuration can instead select endpoint-only policy
evaluation. The adapter model must preserve that intentional mode separately
from an identity lookup failure; the latter continues to deny when binary
identity is required.
## Current Userland-Facing Surfaces
The networking surface currently includes:
- CONNECT proxy traffic for HTTPS and generic TCP tunnels.
- Forward HTTP proxy traffic for absolute-form HTTP requests.
- `inference.local` for local inference routing.
- `policy.local` for current policy, denial summaries, proposal submission,
and proposal wait routes.
- GCE metadata loopback for SDKs that bypass HTTP proxy variables.
- nftables bypass enforcement for direct TCP/UDP egress that does not enter
the proxy.
- OPA/Rego policy and endpoint metadata lookups.
- DNS resolution and endpoint validation for CONNECT and forward HTTP egress.
- Static provider credential injection and redaction.
- Endpoint-bound dynamic token grant injection.
- Opt-in REST request-body credential rewrite.
- L7 REST, GraphQL, JSON-RPC, MCP, WebSocket, and
GraphQL-over-WebSocket enforcement.
The issue is not that these features exist. The issue is that entry mechanisms,
policy evaluation, endpoint metadata lookup, credential injection, and byte
relay decisions are still interleaved.
## Current CONNECT Shape
```mermaid
flowchart TD
Client["Client CONNECT host:port"] --> Parse["Parse CONNECT target"]
Parse --> L4["Evaluate network policy"]
L4 --> Allowed{"Allowed?"}
Allowed -- No --> Deny["CONNECT denial"]
Allowed -- Yes --> Meta["Query endpoint metadata"]
Meta --> Config{"L7, TLS, or credential config?"}
Config -- No --> Tunnel["Return tunnel-ready response"]
Config -- Yes --> Tunnel
Tunnel --> Inspect["Inspect tunneled bytes when possible"]
Inspect --> Relay["HTTP/WebSocket/TCP relay selection"]
Relay --> Inject["Middleware, static credentials, and token grants if configured"]
Inject --> Upstream["Open upstream when relay policy allows"]
```
CONNECT is still the strongest entry shape because the tunnel relay can keep
parsing HTTP requests on long-lived connections and enforce request policy per
request.
## Current Forward HTTP Shape
```mermaid
flowchart TD
Client["Absolute-form HTTP request"] --> Parse["Parse first request"]
Parse --> L4["Evaluate network policy"]
L4 --> Allowed{"Allowed?"}
Allowed -- No --> Deny["HTTP denial"]
Allowed -- Yes --> L7{"Matching L7 endpoint?"}
L7 -- Yes --> Eval["Evaluate REST/GraphQL/JSON-RPC/MCP/WebSocket policy"]
Eval --> Guard["Reject unsupported h2c upgrade when inspected"]
Guard --> Rewrite["Rewrite to origin-form + configured credentials"]
L7 -- No --> Rewrite
Rewrite --> Token["Apply token grant if endpoint-bound"]
Token --> Close["Force Connection: close except WebSocket upgrade"]
Close --> Upstream["Open upstream"]
Upstream --> Relay["Guarded HTTP relay / upgrade relay"]
```
Latest main no longer has the old raw-copy-after-first-request shape for
ordinary forward HTTP. It rewrites ordinary requests with `Connection: close`,
uses guarded HTTP relay helpers for body handling, rejects inspected h2c
upgrades, injects token grants, and sends allowed WebSocket upgrades through
the upgrade relay. That is a narrower surface than the historical bidirectional
copy, but it is still orchestrated separately from the CONNECT relay path.
## Current Local Service Shape
```mermaid
flowchart TD
Request["Request to local name"] --> Match{"Known local route?"}
Match -- "inference.local" --> Inference["Inference route adapter"]
Match -- "policy.local" --> Policy["Policy local adapter"]
Match -- "metadata loopback" --> Metadata["Metadata credential server"]
Match -- No --> External["Normal egress path"]
Inference --> InferenceResp["Local inference response"]
Policy --> PolicyResp["Local policy response"]
Metadata --> MetadataResp["Metadata response"]
```
`inference.local` now covers buffered and streaming inference shapes including
chat/completion routes, model discovery, embeddings, and provider-specific
routes. `policy.local` supports the agentic approval loop: agents can submit
narrow proposals and wait on approval/reload before retrying. Metadata
loopback exists for provider credentials consumed by SDKs that do not honor
HTTP proxy variables.
These are userland-facing network surfaces. They should stay distinct from
external egress while still fitting the adapter model.
## Adjacent In-Flight Supervisor Middleware Shape
PRs #1738 and #2027 propose supervisor middleware as an HTTP request hook in
the proxy relay. That work is adjacent to this RFC rather than a separate entry
adapter.
```mermaid
flowchart TD
Req["Parsed admitted HTTP request"] --> Policy["Network and request policy already allowed"]
Policy --> Hook["HTTP_REQUEST / PRE_CREDENTIALS middleware"]
Hook --> Outcome{"Middleware outcome"}
Outcome -- "deny" --> Deny["Local deny, no credential injection"]
Outcome -- "allow / mutate" --> Recheck["Re-parse transformed body and re-evaluate policy"]
Recheck --> Allowed{"Allowed under endpoint enforcement mode?"}
Allowed -- "no" --> PolicyDeny["Local policy deny, no credential injection"]
Allowed -- "yes" --> Creds["Credential injection"]
Creds --> Upstream["Upstream write"]
```
The proposed middleware chain is selected by admitted destination host, runs in
deterministic order, buffers bounded request bodies, applies `fail_open` or
`fail_closed`, emits audit-safe findings, and runs before OpenShell-managed
credentials are injected. Middleware-transformed GraphQL, JSON-RPC, and MCP
bodies are re-parsed and re-evaluated before credential injection or upstream
write, so a mutation cannot bypass request policy. Policy mismatches retain the
endpoint's audit or enforce behavior, while malformed transformed protocol
bodies fail closed in either mode. Middleware can inspect WebSocket upgrade
requests because they are HTTP requests, but it does not inspect post-upgrade
WebSocket frames in v1.
RFC 0005 should account for this by treating middleware as part of the shared
request processing plan. CONNECT and forward HTTP should not each learn how to
select and invoke middleware independently.
## Current Network Namespace Enforcement
```mermaid
flowchart TD
Start["Process in sandbox network namespace"] --> Dest{"Destination"}
Dest -- "Proxy host_ip:port" --> Proxy["Accept to sandbox proxy"]
Dest -- "Loopback" --> Loopback["Accept loopback"]
Dest -- "Established/related" --> Established["Accept response packet"]
Dest -- "Other TCP/UDP" --> Reject["nftables log + reject"]
Reject --> Monitor["Bypass monitor reads dmesg"]
Monitor --> OCSF["OCSF network + detection events"]
```
The process leaf installs an `inet` nftables filter table for bypass
enforcement. The table accepts proxy-bound traffic, loopback, and established
flows, then rejects and optionally logs other TCP/UDP traffic. It does not
currently redirect native TCP connections into the proxy.
## Findings To Preserve
### Invariant: forward proxy must not relay unevaluated follow-on HTTP bytes
The historical forward path evaluated at most the first absolute-form request,
rewrote it, then switched to bidirectional copy. Bytes already buffered after
the first header block, or later pipelined requests on the same client/upstream
connection, could reach upstream without the CONNECT L7 relay's per-request
parser/evaluator.
Latest main mitigates this by forcing ordinary forward HTTP to one request per
connection and by using guarded relay helpers. The adapter model should
preserve the invariant either by keeping forward HTTP single-request/close or
by passing the first parsed request into a shared HTTP relay loop.
### Endpoint config is not tied to deterministic matched policy
The policy name used for L4 authorization and logging is the lexicographically
smallest matching policy. L7 candidates are collected independently and later
selected by request-path specificity. TLS and `allowed_ips` use the first
extended endpoint config returned by a separate query. Exact-declared-host is
another independent existential query. With overlapping host, port, and binary
rules, those results can describe different endpoints and policies on the same
connection.
The adapter model requires authorization to return one decision with one
deterministic matched endpoint.
### Policy materialization can span generations
The L4 decision, L7 route, TLS mode, `allowed_ips`, and exact-declared-host
signal are materialized through separate engine calls. The L7 route records a
generation for its tunnel evaluator, but the sibling metadata queries are not
all asserted against the L4 decision generation. A reload during setup can
therefore assemble one logical connection decision from different policy
generations.
The target decision carries one top-level policy generation. Every
policy-derived field must be evaluated from that generation, and relay startup
must reject a stale decision before an upstream request is written.
### Endpoint metadata query failures should not erase enforcement
Failure behavior is not uniform today. L7 configuration failure becomes no L7
configuration, and TLS configuration failure becomes automatic TLS handling;
either can erase intended enforcement. `allowed_ips` and the downstream SSRF
validation path are already more conservative. The migration must test each
query independently instead of describing all metadata failures as equivalent.
The adapter model treats endpoint metadata as part of the authorization result.
Failure to materialize required metadata should deny rather than erase extended
configuration.
### Destination validation must be shared
Private address checks, `allowed_ips`, exact declared private endpoint trust,
trusted gateway aliases, SSRF checks, and control-plane port blocks have grown
over time. They should be centralized so CONNECT and forward HTTP use the same
resolved-destination rules. Existing local services remain outside normal
external destination validation.
## Existing Feature Inventory
The refactor should preserve:
- CONNECT explicit proxy support.
- Forward HTTP explicit proxy support.
- Network-only supervisor mode.
- nftables bypass reject/log enforcement.
- Provider credential injection and redaction.
- Dynamic token grant injection through SPIFFE-backed provider credentials.
- Supervisor middleware `HTTP_REQUEST / PRE_CREDENTIALS` when it lands.
- REST request-body credential rewrite.
- WebSocket text-frame credential rewrite.
- REST endpoint method/path policy.
- GraphQL-over-HTTP policy.
- JSON-RPC-over-HTTP method policy.
- MCP Streamable HTTP method and tool policy.
- WebSocket transport and GraphQL-over-WebSocket policy.
- h2c rejection on inspected HTTP routes.
- Inference routing through `inference.local`, including embeddings.
- Agent-facing policy advisor routes through `policy.local`.
- GCE metadata loopback for supported provider credentials.
- Timeout and resource tracking for client, upstream, and local service work.
- Structured OCSF logging for network and HTTP policy outcomes.
- SSRF and internal address protections.
- Exact declared private endpoint handling.
- Control-plane port protection.
- `allowed_ips` endpoint restrictions.
- TLS auto-detection and termination for inspectable client connections.
@@ -0,0 +1,279 @@
# Implementation Plan
This plan is intentionally separate from the main RFC so the proposal can stay
direction-focused. The RFC is an incremental roadmap, not one pull request.
Phases 0 through 7 form the compatibility foundation: they restructure current
CONNECT, forward HTTP, raw TCP, and local-service behavior without adding a new
user-facing transport. Phases 8 and later add the forward-looking capabilities
after the shared contracts are authoritative.
## Phase 0 - Compatibility Baseline
- Cover CONNECT and forward HTTP allow/deny responses, including exact status,
headers, and adapter-specific error bodies.
- Cover forward HTTP pipelining, keep-alive follow-on requests, the current
`Connection: close` mitigation, `https://` absolute-form rejection, and h2c
rejection on inspected endpoints.
- Cover the current overlapping-policy outcomes separately for matched policy,
L7 route selection, TLS, `allowed_ips`, and exact-declared-host.
- Inject failures into L7, TLS, `allowed_ips`, and exact-declared-host queries.
Record the current fail-open or fail-closed result for each query rather than
treating all endpoint metadata errors as equivalent.
- Cover control-plane ports, cloud metadata, always-blocked addresses, exact
declared private endpoints, IP-literal synthesis, trusted gateway aliases,
and explicit `allowed_ips` through CONNECT and forward HTTP.
- Cover identity-required success/failure, unsupported-platform behavior where
possible, and intentional endpoint-only evaluation. Prove an empty
`exec.path` cannot satisfy binary-scoped policy while identity is required.
- Cover static credential injection, token grants, REST body rewrite,
WebSocket text-frame rewrite and policy, GraphQL, JSON-RPC, and MCP behavior.
- Cover `inference.local`, `policy.local`, metadata loopback, and unchanged
nftables bypass reject/log behavior.
- Capture stable OCSF event class, activity/action/disposition, severity,
status, destination, actor, firewall rule, message, and status detail for
representative allow and deny paths.
- Record a performance baseline for OPA evaluations, per-connection
allocations, and CONNECT/forward request latency.
## Phase 1 - Adapters And Compatibility Decision Envelope
- Introduce CONNECT and forward HTTP `EgressIntent` construction inside
`openshell-supervisor-network`.
- Introduce a transitional `EgressDecision` carrying L4 outcome, policy
generation, process evidence, and endpoint fields while preserving the
current query timing, precedence, and failure defaults.
- Keep `LookupFailed`/unsupported identity as a denial when identity is
required. Keep explicitly configured endpoint-only mode behavior unchanged.
- Keep adapter-specific responses and OCSF emission at the protocol boundary.
- Do not claim the transitional decision is one atomic OPA result; document its
compatibility hydration until Phase 3 cuts over.
This phase is a mechanical extraction. It must be independently shippable and
revertible without changing user-visible policy or relay behavior.
## Phase 2 - Shared Destination Validation
- Move DNS resolution, explicit `allowed_ips`, exact declared endpoints,
implicit IP-literal handling, trusted gateway aliases, SSRF checks,
cloud-metadata blocks, and control-plane-port blocks into one validator.
- Represent the selected validation mode explicitly instead of passing an
ambiguous collection of booleans.
- Return an unopened `UpstreamConnector` so adapters and relays preserve the
current point at which upstream TCP is created.
- Prove CONNECT and forward HTTP retain their existing denial responses, OCSF
fields, and dial timing while using the shared validator.
## Phase 3 - Generation-Consistent Authorization Cutover
- Define either rejection of ambiguous overlapping endpoint metadata or one
documented policy/endpoint precedence key before changing enforcement.
- Add one OPA result that materializes matched policy/source, matched endpoint,
destination constraints, TLS, HTTP enforcement, credential plan, and
middleware selection from one policy generation.
- Attach a generation-pinned `TunnelPolicyEngine` to relay context for
per-request REST, GraphQL, JSON-RPC, MCP, and WebSocket evaluation. Relays do
not rematerialize connection-level endpoint policy.
- Run the new query in shadow mode beside legacy queries. Emit internal,
audit-safe mismatch telemetry without changing existing OCSF network/HTTP
events or enforcement.
- Add reload-race tests proving every materialized field matches the top-level
generation and stale decisions stop before upstream request write.
- Make deterministic selection and fail-closed L7/TLS metadata errors a
dedicated cutover only after mismatch cases are understood. Retain the
legacy evaluator temporarily for immediate rollback.
This is the only phase that intentionally tightens ambiguous or error behavior;
it must not be hidden inside the structural refactor commits.
## Phase 4 - Forward HTTP Adapter
- Keep absolute-form parsing and adapter-specific errors at the forward HTTP
boundary.
- Pass the buffered first request into a shared HTTP relay, or retain the
guarded single-request/`Connection: close` path until Phase 5a is ready.
- Preserve `https://` absolute-form rejection and inspected h2c rejection.
- Preserve the invariant that no unevaluated follow-on request can reach raw
bidirectional copy.
## Phase 5 - Relay Consolidation
### Phase 5a - HTTP request loop
- Centralize HTTP parsing and per-request REST, GraphQL, JSON-RPC, and MCP
evaluation behind the generation-pinned request-policy handle.
- Evaluate every request before upstream write and preserve the current rule
that a denied request does not create an upstream session.
- Preserve bounded JSON-RPC/MCP inspection and audit-safe logging that omits
params and tool arguments.
### Phase 5b - Credential injection
- Unify static target/query/header rewrite, endpoint-bound token grants, and
opt-in REST request-body rewrite after request allow and before upstream
write.
- Preserve buffering limits, supported content types, `Content-Length`
recomputation, redaction, token caching, and fail-closed unresolved secrets.
### Phase 5c - WebSocket
- Move allowed upgrades behind the shared relay while preserving raw upgraded
passthrough, opt-in text-frame credential rewrite, WebSocket transport policy,
GraphQL-over-WebSocket policy, and safe compression behavior.
### Phase 5d - Supervisor middleware
- Land only after the supervisor middleware dependency is available.
- Run `HTTP_REQUEST / PRE_CREDENTIALS` after request allow and before static or
dynamic credential injection.
- Re-parse middleware-transformed bodies and re-evaluate GraphQL, JSON-RPC, and
MCP policy inputs before credential injection or upstream write. Preserve the
endpoint's audit or enforce behavior for policy mismatches, and fail closed
on malformed transformed protocol bodies in either mode.
- Preserve ordering, body caps, `fail_open`/`fail_closed`, safe headers,
findings, metadata, and rejection of middleware-introduced credential
placeholders.
- Test allowed requests that middleware rewrites into denied GraphQL, JSON-RPC,
and MCP operations, including audit-mode forwarding and fail-closed malformed
replacements.
Each subphase must be independently testable and shippable; Phase 5 is not a
single flag-day cutover.
## Phase 6 - Shared TLS And TCP Relay Boundary
- Move client-side TLS detection and termination before the HTTP/raw-TCP relay
split without changing handshake, certificate, or upstream-connect timing.
- Keep endpoint TLS behavior on `EgressDecision` and preserve `tls: skip` as the
explicit raw-tunnel path.
- Use one existing raw `TcpRelay` byte-copy primitive for L4 traffic.
- Add a protocol-processor dispatch contract without enabling a concrete new
protocol in the compatibility milestone.
- Let processors own their message loop and call the validated connector only
when protocol state allows. Permit in-tree, middleware-backed, and hybrid
processors with typed middleware operations.
## Phase 7 - Existing Local Services And Cleanup
- Keep `inference.local` as a local adapter with its existing TLS, route,
provider-auth, streaming/buffered limit, and OCSF behavior.
- Keep `policy.local` as a local adapter for current policy, bounded denial
summaries, proposals, and proposal wait.
- Decide whether metadata loopback remains orchestrated by `openshell-sandbox`
or moves behind a local adapter boundary; preserve startup/failure behavior
either way.
- Keep the local-routing and destination contracts extensible for issue
[#1633](https://github.com/NVIDIA/OpenShell/issues/1633), while leaving its
policy surface and host-loopback authorization to separate feature work.
- Remove compatibility endpoint queries only after Phase 3 is authoritative.
- Remove duplicated destination/relay plumbing without centralizing
adapter-specific response rendering.
- Update the living architecture documentation once each implemented boundary
reflects current code.
Completion of Phase 7 is the compatibility milestone: existing user-facing
features and capabilities are preserved on the new internal structure. The
following phases are feature-bearing work and land in separate pull requests or
series.
## Phase 8 - Policy DNS And Transparent TCP
- Add policy DNS registration for native TCP endpoint names.
- Reject names that do not match an eligible native TCP endpoint before making
an upstream DNS query.
- Replace static host-file mapping with query-driven synthetic DNS answers.
Resolve eligible names through trusted DNS and filter every real address
through destination controls.
- Allocate a supervisor-owned synthetic IP and store the normalized name,
endpoint ID, allowed ports, validated real addresses, policy generation,
distinct DNS mapping generation, mapping ID, and expiration in active mapping
state.
- Require every captured connect to correlate with the unexpired mapping
selected by its synthetic destination and requested port. Do not allow
unrelated bare-IP traffic to inherit a policy-DNS decision.
- Publish mapping and nftables capture updates atomically from the adapter's
perspective before returning the synthetic DNS answer.
- Add nftables REDIRECT/TPROXY capture rules ahead of the bypass reject path;
do not add a parallel iptables path.
- Coordinate capture-rule ownership with
`openshell-supervisor-process::netns` and preserve reject/log fallback for
unmatched traffic.
- Recover the original destination, construct a transparent-TCP intent, and run
normal generation-consistent authorization and destination validation.
- Restrict the connector to the mapping's pinned validated real addresses; do
not independently re-resolve at connect time.
- Keep direct external DNS blocked and treat DNS-over-HTTPS as ordinary
policy-controlled HTTPS egress.
- Define synthetic address pools and reuse quarantine, TTL caps, policy-reload
invalidation, stale-mapping behavior, and rollback before enabling capture by
default.
## Phase 9 - Native Protocol Processors
- Add concrete Redis, Postgres, MySQL, or other processors one protocol at a
time, each with a separately reviewed policy schema and operational limits.
- Keep omitted/`tcp` endpoints on raw L4 byte copy; never infer a native
processor from traffic alone.
- Test multi-message sessions, pre-dial denial, handshake-required dialing,
per-command/query evaluation, middleware hooks, timeouts, and redaction.
- Capability-gate policy that names a processor unavailable in the running
proxy build.
## Phase 10 - Runtime Boundary
- Keep embedded and network-only supervisor modes as the migration baseline.
- Define the proxy runtime API needed for a future standalone binary or
sidecar: configured listeners, policy updates, provider credentials, token
grants, middleware registry, gateway calls, telemetry, denial/activity
events, and shutdown.
- Advertise process-identity and protocol-processor capabilities. Reject policy
that requires unavailable binary/path identity or processor support.
- Represent intentional runtime identity unavailability separately from the
existing endpoint-only mode and from lookup failure.
- Add gateway capability negotiation if proxy and gateway versions can differ.
## Phase 11 - Final Cleanup
- Remove any compatibility query/evaluator retained for deterministic-decision
rollback after its observation window closes.
- Remove stale static `/etc/hosts`, iptables, or single-process assumptions from
proxy design and architecture documentation as the corresponding later phase
lands.
- Keep adapter-specific response rendering and OCSF contracts at their protocol
boundaries.
## Testing And Operational Validation
- Unit-test adapter intent construction, response rendering, explicit
destination modes, identity evidence, and authorization precedence.
- Integration-test destination validation across CONNECT and forward HTTP,
then reuse the same suite for transparent TCP when Phase 8 lands.
- Integration-test HTTP keep-alive/pipelining, REST, GraphQL, JSON-RPC, MCP,
WebSocket, credentials, token grants, middleware, and TLS/raw-TCP selection.
- Integration-test `inference.local`, `policy.local`, and metadata loopback body
limits, timeouts, redaction, and local denial responses.
- Compare OCSF fixtures before and after each migration subphase.
- Exercise policy reload between L4 decision, endpoint materialization, relay
startup, and long-lived per-request evaluation.
- Add protocol-processor harness tests before adding Redis, Postgres, MySQL, or
similar enforcement. Each concrete processor adds multi-message, handshake,
timeout, denial, redaction, and middleware coverage.
- Integration-test policy DNS filtering, denial without an upstream query,
synthetic answer allocation, TTL and reuse quarantine, distinct mapping
generations, policy-reload invalidation, atomic capture-rule updates,
original-destination recovery, allowed-port correlation, connector
restriction to pinned real addresses, and rejection of unrelated bare-IP
connects.
- Prove two names that resolve to the same real IP and port receive distinct
correlations and cannot inherit each other's endpoint policy.
- Test standalone/sidecar capability negotiation and prove missing identity or
processor support fails during policy validation rather than broadening an
allow at runtime.
- Re-run the performance baseline after the compatibility envelope, after the
single-decision query, and after relay consolidation. Treat reduced OPA calls
as a measured result rather than an assumed benefit.
- Back out structural phases by reverting their isolated commits. Keep shadow
comparison and the legacy evaluator available through the deterministic
cutover observation window.
- Gate later transport/runtime phases independently so disabling policy DNS or
transparent capture restores the existing explicit-proxy and bypass-reject
behavior without reverting the compatibility foundation.
@@ -0,0 +1,594 @@
# Technical Design Appendix
This appendix carries implementation-level design details behind the main RFC.
## Existing Runtime Boundary
`openshell-supervisor-network::run::run_networking` is the current networking
startup boundary. It builds policy-local context, waits for policy binary
symlink resolution, creates the identity cache, writes the TLS CA, builds TLS
state, resolves inference routes, wires provider credentials and token grants,
and starts the proxy. The supervisor middleware work extends this boundary with
middleware registry construction and reload behavior.
This is a useful outer boundary, but it is not yet the proxy adapter boundary.
The proxy still needs internal `EgressIntent` and `EgressDecision` boundaries
so CONNECT, forward HTTP, local routes, and future native TCP capture do not
duplicate policy and relay orchestration. The first implementation milestone
wires only current surfaces; later milestones add new adapters to the same
contract.
## Shared Data Boundaries
### EgressIntent
`EgressIntent` is the normalized description of what userland is trying to do.
It should carry:
- entry transport: CONNECT, forward HTTP, transparent TCP, local HTTP, policy
DNS, or metadata loopback;
- requested destination host/port or captured original IP/port;
- optional process identity inputs collected by the adapter/runtime;
- optional first HTTP request for forward proxy traffic;
- optional local service route;
- policy generation and, for policy DNS/transparent TCP, a distinct DNS
mapping generation and correlation handle.
Adapters build intents. They should not query endpoint metadata, select TLS
mode, or select relays.
### EgressDecision
`EgressDecision` is the policy result consumed by validation and relay code.
It should carry:
- allow or deny;
- one top-level policy generation used for every policy-derived field;
- deterministic matched policy identifier;
- whether the policy is user-authored, provider-derived, or local-service
internal;
- deterministic matched endpoint identifier and endpoint metadata;
- process identity availability and any identity fields used for evaluation;
- destination and allowed IP constraints;
- TLS behavior;
- protocol enforcement;
- credential injection plan;
- supervisor middleware plan;
- the request-policy selection needed to create a pinned per-request L7
evaluator when HTTP inspection is configured;
- logging context and denial reason.
Relay code should read this decision. It should not query OPA again for
endpoint metadata, TLS mode, allowed IPs, credential behavior, middleware
selection, or relay selection. Long-lived HTTP relays still evaluate each
request through the generation-pinned L7 evaluator carried in `RelayContext`;
that is request authorization, not endpoint rematerialization. Later native
protocol processors use the same pattern with a generation-pinned protocol
evaluator for per-command or per-query decisions.
## Protocol Enforcement
Use a protocol enforcement value derived from endpoint policy:
| Policy protocol | Enforcement | Relay behavior |
|-----------------|-------------|----------------|
| omitted / `tcp` | None | L4 authorization plus byte relay, with optional HTTP sniff for credential injection |
| `rest` | HTTP | HTTP request parser with REST rules, plus opt-in request-body and WebSocket text-frame credential rewrite |
| `graphql` | HTTP | HTTP request parser with GraphQL-over-HTTP rules |
| `json-rpc` | HTTP | HTTP request parser plus bounded JSON-RPC-over-HTTP method inspection |
| `mcp` | HTTP | HTTP request parser plus bounded MCP Streamable HTTP method/tool inspection |
| `websocket` | HTTP | HTTP upgrade policy followed by WebSocket frame policy or GraphQL-over-WebSocket policy |
| future `redis`, `postgres`, `mysql`, ... | Protocol processor | Protocol-specific processor owns framing, middleware hooks, and the message loop |
`protocol: tcp` is effectively the default L4 mode. It should not run native
protocol processors. Avoid using the term "provider" for processor concepts
because providers are already a first-class credential and routing domain in
OpenShell. Concrete native processors land after the shared dispatch contract.
## Suggested Types
The exact Rust shape can evolve, but the boundaries should look like this:
```rust
enum EgressTransport {
Connect,
ForwardHttp,
TransparentTcp,
PolicyDns,
LocalHttp,
MetadataLoopback,
}
struct EgressIntent {
transport: EgressTransport,
destination: RequestedDestination,
process: ProcessIdentityEvidence,
first_request: Option<ParsedHttpRequest>,
local_route: Option<LocalRoute>,
correlation: Option<ResolvedEndpointCorrelation>,
}
struct EgressDecision {
policy_generation: PolicyGeneration,
outcome: PolicyOutcome,
matched_policy: Option<MatchedPolicy>,
endpoint: Option<MatchedEndpoint>,
process: EvaluatedProcessIdentity,
request_processing: RequestProcessingPlan,
log_context: EgressLogContext,
}
enum ProcessIdentityEvidence {
Available(ProcessIdentity),
Unavailable(ProcessIdentityUnavailableReason),
}
enum ProcessIdentityUnavailableReason {
EndpointOnlyMode,
DeclaredRuntimeMode(RuntimeMode),
UnsupportedPlatform,
LookupFailed,
}
struct EvaluatedProcessIdentity {
evidence: ProcessIdentityEvidence,
fields_used: Vec<ProcessIdentityField>,
}
struct MatchedPolicy {
id: PolicyId,
source: PolicySource,
}
enum PolicySource {
User,
ProviderDerived,
LocalService,
}
struct MatchedEndpoint {
id: EndpointId,
destination: DestinationValidationPlan,
tls: TlsPolicy,
enforcement: ProtocolEnforcement,
}
struct DestinationValidationPlan {
address_authorization: AddressAuthorization,
}
enum AddressAuthorization {
DefaultPublicOnly,
ExplicitAllowedIps(Vec<IpNet>),
ExactDeclaredHost,
ImplicitIpLiteral(IpAddr),
TrustedGatewayAlias { expected_ip: IpAddr },
}
struct RequestProcessingPlan {
middleware: SupervisorMiddlewarePlan,
credentials: CredentialInjectionPlan,
}
enum ProtocolEnforcement {
None,
Http(HttpL7Config),
ProtocolProcessor(ProtocolProcessorConfig),
}
enum HttpL7Protocol {
Rest,
Graphql,
JsonRpc,
Mcp,
Websocket,
}
struct HttpL7Config {
protocol: HttpL7Protocol,
path: EndpointPathScope,
allow_encoded_slash: bool,
enforcement_mode: L7EnforcementMode,
websocket_credential_rewrite: bool,
request_body_credential_rewrite: bool,
websocket_graphql_policy: bool,
graphql_max_body_bytes: usize,
json_rpc_max_body_bytes: usize,
mcp_strict_tool_names: bool,
}
struct CredentialInjectionPlan {
static_placeholders: StaticPlaceholderPlan,
token_grant: Option<TokenGrantPlan>,
}
struct StaticPlaceholderPlan {
http_target_query_header: bool,
rest_request_body: bool,
websocket_text_frames: bool,
}
struct TokenGrantPlan {
provider_key: String,
auth_style: TokenGrantAuthStyle,
token_endpoint: String,
}
struct SupervisorMiddlewarePlan {
stages: Vec<SupervisorMiddlewareStage>,
min_body_limit: Option<usize>,
registry_generation: PolicyGeneration,
}
struct SupervisorMiddlewareStage {
policy_name: String,
binding_id: String,
operation: MiddlewareOperation,
phase: MiddlewarePhase,
order: i32,
on_error: MiddlewareOnError,
config: MiddlewareConfig,
}
enum MiddlewareOperation {
HttpRequest,
Future(String),
}
enum MiddlewarePhase {
PreCredentials,
Future(String),
}
struct RelayContext {
decision: EgressDecision,
request_policy: Option<PinnedRequestPolicy>,
protocol_policy: Option<PinnedProtocolPolicy>,
connector: UpstreamConnector,
deadlines: RelayDeadlines,
telemetry: RelayTelemetry,
}
struct ResolvedEndpointCorrelation {
policy_generation: PolicyGeneration,
mapping_generation: DnsMappingGeneration,
mapping_id: DnsMappingId,
synthetic_ip: IpAddr,
}
struct PinnedRequestPolicy {
generation: PolicyGeneration,
evaluator: TunnelPolicyEngine,
}
struct PinnedProtocolPolicy {
generation: PolicyGeneration,
evaluator: ProtocolPolicyEngine,
}
```
`UpstreamConnector` is the relay-owned dial boundary. It encapsulates the
validated destination and lets relays or processors open an upstream connection
only after current request or protocol policy allows it.
`DestinationValidationPlan` selects one current validation mode. All modes
retain control-plane-port and cloud-metadata blocks. Default and explicit IP
paths retain the always-blocked loopback, link-local, and unspecified-address
checks. `ImplicitIpLiteral` is synthesized only for an explicitly declared IP
host. `TrustedGatewayAlias` may accept the one runtime-discovered gateway IP but
does not become a general private-address exemption.
`policy_generation`, the optional pinned request/protocol evaluators, endpoint
metadata, and middleware selection must describe one policy snapshot.
Authorization asserts that every sub-materialization used that generation. If
the generation changes before relay startup, the adapter receives a
stale-policy denial rather than a mixed decision.
## Process Identity Availability
Process identity is evidence, not a string to fabricate when lookup fails.
Embedded mode normally populates binary, PID, ancestry, command-line path, and
binary hash data. When binary identity is required, `LookupFailed` and
`UnsupportedPlatform` remain denials. An explicitly configured endpoint-only
runtime records `Unavailable(EndpointOnlyMode)` and keeps the current
endpoint-only policy behavior. This RFC does not silently turn one state into
the other or change the endpoint-only trust contract.
A future standalone or sidecar runtime that intentionally lacks local identity
uses `DeclaredRuntimeMode`, not `EndpointOnlyMode`, and advertises that
capability to policy validation. The runtime contract must define binary/path
predicates as unavailable and reject incompatible policy before traffic starts,
unless a later accepted policy design specifies a different fail-closed rule.
The decision records identity availability and fields used so OCSF logs and
deny responses distinguish a binary policy denial, an identity lookup failure,
and intentional endpoint-only evaluation. Tests must prove an empty synthetic
`exec.path` cannot satisfy a binary-scoped rule while identity is required.
Adding new identity-less deployment modes, or changing how binary predicates
behave in endpoint-only mode, requires the capability work in the later runtime
phase and cannot be smuggled into the compatibility refactor.
## Current Owners And Proposed Cleanup
| Current owner | Current responsibility | Proposed cleanup |
|---------------|------------------------|------------------|
| `openshell-sandbox` | Orchestrator, policy poll loop, denial/activity channels, metadata loopback startup, network-only lifecycle | Keep as orchestration; avoid embedding per-entry proxy policy decisions |
| `openshell-supervisor-network::run` | Networking startup and handles | Become the stable runtime API for embedded and future standalone modes |
| `openshell-supervisor-network::proxy` | CONNECT, forward HTTP, local route dispatch, destination validation, denial rendering | Split into adapters, authorization, destination, relay selection, and adapter response rendering |
| `openshell-supervisor-network::opa` | Policy engine and Rego queries | Return deterministic `EgressDecision` data instead of separate policy and endpoint lookups |
| `openshell-supervisor-network::l7` | REST, GraphQL, JSON-RPC, MCP, WebSocket, inference helpers, TLS, token grants | Keep as protocol/relay implementation behind shared relay boundaries |
| `openshell-supervisor-network::policy_local` | `policy.local` state and routes | Model as a local adapter with explicit limits and proposal/wait behavior |
| `openshell-supervisor-middleware` | Middleware registry, built-ins, service contract, and chain execution | Treat as a relay hook dependency selected by `EgressDecision`, not as adapter-specific policy logic |
| `openshell-supervisor-process::netns` | nftables bypass rules and namespace helpers | Remain owner of bypass enforcement; coordinate future capture rules with network proxy mappings |
| `openshell-supervisor-process::bypass_monitor` | nftables LOG parsing and OCSF bypass telemetry | Remain telemetry producer for bypass violations |
| `openshell-core::secrets` and provider credential state | Static placeholder sources and dynamic credential metadata | Feed credential injection plans; do not leak secrets into decision logs |
## Policy DNS And Resolved TCP State
Policy DNS is query-driven rather than a static `/etc/hosts` snapshot.
1. Policy load registers eligible native TCP endpoint names.
2. Userland performs a DNS lookup.
3. Policy DNS checks whether the normalized name matches an endpoint whose
transport and protocol contract enables native TCP through policy DNS in the
current policy generation.
4. An ineligible name receives a local policy-denial DNS response without an
upstream query.
5. Policy DNS resolves an eligible name through trusted upstream DNS and
filters every answer through endpoint metadata and SSRF controls.
6. The adapter allocates a supervisor-owned synthetic IP and creates an active
mapping containing the synthetic IP, normalized name, endpoint identifier,
allowed ports, validated real addresses, policy generation, distinct DNS
mapping generation, opaque mapping ID, and expiration.
7. The adapter publishes the mapping and capture state atomically before
returning the synthetic IP to userland with a bounded TTL.
8. Userland later calls `connect(synthetic_ip:port)`.
9. Transparent TCP recovers the synthetic original destination and requires an
unexpired exact mapping whose allowed ports contain the requested port.
10. Normal egress authorization and relay selection run against a policy
generation consistent with the mapping contract.
11. The connector dials only a real address pinned in that mapping. It does not
re-resolve the name independently at connect time.
The resolved endpoint store is active state produced by policy-eligible lookups
and consumed by transparent TCP connects. Policy generation and DNS mapping
generation are separate values: a DNS refresh can replace mappings without a
policy reload, while a policy reload can invalidate mappings whose endpoint
contract is no longer current. A captured connection with no mapping, a stale
mapping, or a mismatched endpoint/port fails closed. An unrelated bare-IP
connection cannot inherit a policy-DNS authorization merely because it targets
a real IP present in the mapping store. Synthetic IPs are correlation handles,
not upstream destinations, and must never be routed directly or reassigned
while a stale answer could still refer to the prior mapping.
The mapping is sandbox-scoped rather than process-scoped. Process identity is
looked up and evaluated independently when the captured TCP connection is
authorized. It is not used to join DNS and TCP because name resolution may be
cached, delegated to a resolver helper, or consumed by a different process, and
multiple names resolved by one process may share a real address and port.
## nftables Boundary
Current main uses nftables, not iptables, for sandbox network bypass
enforcement. The installed `inet` table accepts traffic to the sandbox proxy,
loopback, and established/related flows, then rejects and optionally logs other
TCP/UDP traffic. The bypass monitor reads those log lines and emits OCSF
network and detection events.
Transparent TCP capture builds on this same nftables substrate in a later
feature phase:
- capture rules run before the generic bypass reject rules;
- capture rules are scoped to active synthetic-IP and allowed-port mappings;
- mapping and capture-rule updates are atomic from the adapter's perspective;
- direct external DNS remains blocked; policy DNS is the sandbox resolver, and
DNS-over-HTTPS remains ordinary policy-controlled HTTPS egress;
- reject/log rules remain the fallback for unmatched TCP/UDP egress;
- VM or Podman driver nftables rules are infrastructure NAT/isolation and are
not the proxy policy enforcement point.
The initial CONNECT/forward refactor does not change the installed table. This
section defines the consumer contract that the shared adapter and decision
boundaries must support when transparent capture lands.
## Endpoint Selection And OPA
Today the matched policy name, L7 candidates, first TLS/`allowed_ips` endpoint,
and exact-declared-host signal are selected through independent rules. OPA/Rego
should return policy and endpoint metadata through one deterministic
authorization result. It should not let those fields describe different
matches.
Two acceptable approaches:
- Reject overlapping endpoint metadata at load or merge time.
- Define a single deterministic precedence key and use it for both policy name
and endpoint metadata.
Endpoint metadata query failures should fail closed when metadata is required
for the selected endpoint. They should not silently downgrade to L4 behavior.
The top-level decision generation must also match every policy-derived field;
reload during materialization yields a stale decision instead of mixing
generations.
This semantic cutover is separate from introducing the Rust types. The new
query first runs in shadow mode beside the legacy queries, records audit-safe
mismatches through internal telemetry, and preserves legacy enforcement. After
the precedence rule is accepted and mismatch cases are understood, a dedicated
change switches the authoritative result and retains the legacy evaluator long
enough for immediate rollback.
Provider-derived policies use a reserved rule-name namespace. The gateway and
sandbox sync should prevent user-authored `_provider_*` rules, and
`policy.local` proposal surfaces should not expose provider-derived rules as
editable user policy. `EgressDecision` should still identify provider-derived
matches for logging and debugging.
## Credential Injection Boundary
Credential injection belongs in the HTTP/WebSocket relay after policy allow and
supervisor middleware, and before upstream write.
1. Authorization selects the endpoint and computes a credential injection plan.
2. Supervisor middleware runs on the admitted request before credentials are
visible.
3. If middleware replaces the body, the relay re-parses body-dependent
protocol inputs and re-evaluates request policy.
4. The HTTP relay resolves credentials only when it still has an allowed
request under the endpoint's enforcement mode.
5. Static placeholder values are resolved and redacted from logs.
6. Endpoint-bound token grants obtain or reuse a dynamic access token.
7. The final upstream request or WebSocket frame is rewritten immediately
before write.
Both L4-only HTTP and HTTP-inspected paths can inject credentials. The
difference is whether REST, GraphQL, or WebSocket policy is evaluated before
the rewrite.
Credential rewrite slots should be explicit:
- request target, query values, and headers for HTTP-family traffic;
- REST request bodies only when `request_body_credential_rewrite` is enabled;
- client-to-server WebSocket text frames only when
`websocket_credential_rewrite` is enabled;
- GraphQL-over-WebSocket connection/control messages when they are carried in
text frames and the endpoint enables the WebSocket rewrite path;
- token grant headers for endpoint-bound provider credentials.
Request-body rewrite is REST-only. It should buffer bounded UTF-8 textual
bodies, including JSON, form-url-encoded, and `text/*`, recompute
`Content-Length`, preserve unsupported bodies that contain no reserved
credential markers, and fail closed when a reserved placeholder cannot be
resolved safely. Binary WebSocket frames are not rewritten.
Token grants are dynamic credential injection. They use provider metadata to
request a SPIFFE JWT-SVID, exchange it for an OAuth2 access token, cache the
token, and inject either an `Authorization: Bearer` header or a configured
custom header. Token grant failures should return a local relay error and must
not forward the request upstream.
Middleware-transformed content should be treated as untrusted input from a
credential perspective. External middleware must not receive OpenShell-managed
credentials, and it should not be able to synthesize new reserved credential
placeholders that OpenShell later resolves into secrets. Unless a future hook
is explicitly built-in-only and credential-capable, the relay should fail
closed or strip newly introduced reserved placeholders before static
placeholder rewrite and token grant injection.
## Supervisor Middleware Boundary
Supervisor middleware is a typed relay hook, not a replacement for protocol
framing. The relay or protocol processor must first parse enough structure to
construct the operation-specific middleware input.
For v1, the operation is `HTTP_REQUEST / PRE_CREDENTIALS`:
1. Network policy, destination validation, and request policy admit the
request.
2. The HTTP relay selects the middleware chain from the request processing
plan.
3. The relay buffers the request body within the smallest selected stage limit.
4. The chain evaluates in deterministic order.
5. A deny short-circuits before credential injection or upstream write.
6. An allow can replace the request body, add approved headers, emit findings,
and pass metadata forward.
7. When the body changes, the relay re-parses and re-evaluates body-dependent
request policy inputs.
8. The transformed request enters credential injection and upstream write only
after that re-evaluation admits it under the endpoint's enforcement mode.
The re-evaluation uses the original request method, path, and query because v1
middleware cannot mutate them. It re-derives the GraphQL operation, JSON-RPC
method, and MCP method or tool name from the transformed body. A policy mismatch
preserves the endpoint's audit or enforce behavior. A malformed or
unclassifiable transformed protocol body fails closed in both modes because the
relay can no longer prove which operation it would forward.
Middleware selection is independent from the matched endpoint policy. It is a
request processing plan selected by admitted destination host, order, and
binding metadata. The decision boundary should materialize it with the same
policy generation used for endpoint selection so a long-lived tunnel cannot mix
old endpoint policy with a new middleware registry.
V1 middleware can inspect WebSocket upgrade requests because those are HTTP
requests. It does not inspect post-upgrade WebSocket frames. A future frame
hook should be a separate operation such as `WEBSOCKET_MESSAGE /
BEFORE_FORWARD` owned by the WebSocket relay.
## Protocol Processor Boundary
Protocol processors operate on streams owned by the relay.
- HTTP parsing converts bytes into request metadata, evaluates request policy,
runs the `HTTP_REQUEST / PRE_CREDENTIALS` middleware hook when configured,
and loops for keep-alive or pipelined requests.
- JSON-RPC and MCP processing are HTTP L7 processors: they parse bounded
JSON-RPC-over-HTTP request bodies after HTTP parsing and before upstream
forwarding. Generic JSON-RPC policy matches methods; MCP policy can also
match `tools/call` tool names.
- WebSocket parsing starts only after an allowed HTTP upgrade. It validates the
handshake/frame stream and owns client-to-server text-frame inspection when
credential rewrite, transport message policy, GraphQL-over-WebSocket policy,
or compression handling is configured.
- Native TCP protocol processors read client and upstream streams as needed and
own their message loop.
- A protocol processor can deny before dialing, dial for a server handshake, or
keep evaluating commands or queries throughout the session.
- A protocol processor may be in-tree, middleware-backed, or a hybrid where
in-tree framing exposes typed middleware operations for content evaluation.
HTTP and WebSocket relays receive the generation-pinned request evaluator
because request policy must continue throughout long-lived sessions. No
processor rematerializes endpoint, TLS, allowed-IP, credential, or middleware
selection. This avoids a separate dial-strategy enum: each processor knows
which protocol milestone is sufficient to call the validated connector.
## Local Service Adapter Boundary
Local services are network surfaces but not normal external egress:
- `inference.local` terminates local client traffic, validates known inference
routes, strips caller auth, injects provider routing/auth, and applies
streaming or buffered limits based on route type.
- `policy.local` serves policy snapshots, denial summaries, proposal
submission, and proposal wait. It should never expose secrets or provider
rules as editable policy.
- Metadata loopback serves provider metadata credentials for SDKs that bypass
HTTP proxy variables. It should use the same provider credential state and
redaction discipline as other credential paths.
These adapters may call gateway APIs or local credential helpers, but they
should not bypass policy and credential invariants that apply to external
egress.
Issue [#1633](https://github.com/NVIDIA/OpenShell/issues/1633) is a prospective
consumer of these boundaries, not a feature defined by this RFC. A
policy-declared host-local endpoint should use an explicit local-routing adapter
or destination mode; it must not become a general loopback exemption in the
external destination validator. Its feature design still needs to choose the
policy surface (reserved hostname versus endpoint flag), define authorization
before the supervisor connects to host loopback, and specify driver/runtime
capabilities. That work can reuse `EgressIntent`, adapter-specific responses,
and the unopened connector boundary without changing this RFC's compatibility
milestone.
## Timeout And Resource Ownership
| Owner | Resource |
|-------|----------|
| Adapter | Client-side parse timeout and adapter-specific deny response |
| Authorization | OPA deadline and policy evaluation telemetry |
| Destination validator | DNS timeout, allowed IP checks, SSRF checks, control-plane port checks |
| TLS terminator | Client TLS handshake timeout and certificate selection |
| HTTP relay | Per-request read/write deadlines, body caps, request-body rewrite caps, upstream reuse |
| WebSocket relay | Upgrade validation, frame limits, text-frame rewrite, compression limits, message policy |
| TCP relay | Byte-copy idle timeout and half-close handling |
| Protocol processor | Protocol message timeouts, middleware hook timeouts, and processor-specific limits |
| Local service adapter | Local route body limits, response caps, gateway call timeout |
| Token grant resolver | SPIFFE Workload API timeout, token endpoint timeout, cache TTL |
| Middleware runner | Service timeout, body cap, failure policy, registry generation |
Timeouts should be recorded in telemetry at the owner boundary that can explain
the failure.
+41 -170
View File
@@ -44,42 +44,10 @@
"integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "12.0.0",
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/gast": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
"license": "Apache-2.0"
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
@@ -209,12 +177,11 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
"license": "MIT",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
"dependencies": {
"langium": "^4.0.0"
"@chevrotain/types": "~11.1.2"
}
},
"node_modules/@types/d3": {
@@ -533,34 +500,6 @@
"node": ">= 0.4"
}
},
"node_modules/chevrotain": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "12.0.0",
"@chevrotain/gast": "12.0.0",
"@chevrotain/regexp-to-ast": "12.0.0",
"@chevrotain/types": "12.0.0",
"@chevrotain/utils": "12.0.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^12.0.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -617,10 +556,9 @@
"license": "MIT"
},
"node_modules/cytoscape": {
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
"license": "MIT",
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
"engines": {
"node": ">=0.10"
}
@@ -1255,17 +1193,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -1363,10 +1305,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -1509,24 +1450,6 @@
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/langium": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
"license": "MIT",
"dependencies": {
"@chevrotain/regexp-to-ast": "~12.0.0",
"chevrotain": "~12.0.0",
"chevrotain-allstar": "~0.4.1",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.1.0"
},
"engines": {
"node": ">=20.10.0",
"npm": ">=10.2.3"
}
},
"node_modules/layout-base": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
@@ -1567,32 +1490,31 @@
}
},
"node_modules/mermaid": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
"license": "MIT",
"version": "11.16.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@braintree/sanitize-url": "^7.1.2",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.0",
"@mermaid-js/parser": "^1.2.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape": "^3.33.3",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"katex": "^0.16.25",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"es-toolkit": "^1.45.1",
"katex": "^0.16.45",
"khroma": "^2.1.0",
"lodash-es": "^4.17.23",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/mime-db": {
@@ -1833,67 +1755,17 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -1951,10 +1823,9 @@
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"engines": {
"node": ">=10.0.0"
},
+36
View File
@@ -0,0 +1,36 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Code generation for the Go SDK. The proto module boundary and validation
# policy live in the repo-level buf.yaml; this template only drives generation.
# buf compiles the module with its own compiler and runs protoc-gen-go /
# protoc-gen-go-grpc from mise-managed binaries. Limited to the client-surface
# closure (openshell, datamodel, sandbox, options); well-known types resolve
# through google.golang.org/protobuf and are not generated.
version: v2
inputs:
- directory: ../../proto
paths:
- ../../proto/openshell.proto
- ../../proto/datamodel.proto
- ../../proto/sandbox.proto
- ../../proto/options.proto
plugins:
- local: protoc-gen-go
out: .
opt:
- module=github.com/NVIDIA/OpenShell/sdk/go
- Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1
- Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1
- Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1
- Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1
- local: protoc-gen-go-grpc
out: .
opt:
- module=github.com/NVIDIA/OpenShell/sdk/go
- Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1
- Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1
- Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1
- Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1
+22
View File
@@ -0,0 +1,22 @@
module github.com/NVIDIA/OpenShell/sdk/go
go 1.24.0
toolchain go1.26.4
require (
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.35.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+50
View File
@@ -0,0 +1,50 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+48
View File
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// AuthProvider supplies per-RPC credentials. It implements the
// grpc credentials.PerRPCCredentials interface.
type AuthProvider = types.AuthProvider
type noAuth struct{}
// NoAuth returns an AuthProvider that sends no credentials.
func NoAuth() AuthProvider {
return &noAuth{}
}
func (n *noAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return nil, nil
}
func (n *noAuth) RequireTransportSecurity() bool {
return false
}
type staticToken struct {
token string
}
// StaticToken returns an AuthProvider that sends a fixed Bearer token.
func StaticToken(token string) AuthProvider {
return &staticToken{token: token}
}
func (s *staticToken) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + s.token,
}, nil
}
func (s *staticToken) RequireTransportSecurity() bool {
return true
}
+80
View File
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"errors"
"maps"
"strings"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// extraHeadersAuth wraps a base AuthProvider with additional static headers
// that are merged into every GetRequestMetadata call. Extra headers take
// precedence over base headers on key collision (case-insensitive).
type extraHeadersAuth struct {
base types.AuthProvider
headers map[string]string // keys already lowercase, empty values filtered out
}
// WithExtraHeaders wraps base with additional per-RPC headers. Keys are
// normalized to lowercase per HTTP/2 (RFC 9113). Empty-string values are
// silently dropped. The headers map is deep-copied at construction time,
// so later mutations to the caller's map have no effect.
//
// Returns an error if base is nil or if headers is nil, empty, or contains
// only empty-string values.
func WithExtraHeaders(base AuthProvider, headers map[string]string) (AuthProvider, error) {
if base == nil {
return nil, errors.New("base auth provider must not be nil")
}
if len(headers) == 0 {
return nil, errors.New("headers must not be nil or empty")
}
// Deep-copy and normalize: lowercase keys, skip empty values.
normalized := make(map[string]string, len(headers))
for k, v := range headers {
if v == "" {
continue
}
normalized[strings.ToLower(k)] = v
}
if len(normalized) == 0 {
return nil, errors.New("headers must contain at least one non-empty value")
}
return &extraHeadersAuth{
base: base,
headers: normalized,
}, nil
}
// GetRequestMetadata merges base metadata with extra headers. Extra headers
// win on key collision because they are applied after the base metadata.
func (e *extraHeadersAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
baseMD, err := e.base.GetRequestMetadata(ctx, uri...)
if err != nil {
return nil, err
}
// Start with base metadata (may be nil for NoAuth).
// Normalize base keys to lowercase for case-insensitive collision.
merged := make(map[string]string, len(baseMD)+len(e.headers))
for k, v := range baseMD {
merged[strings.ToLower(k)] = v
}
// Extra headers overwrite base on collision.
maps.Copy(merged, e.headers)
return merged, nil
}
// RequireTransportSecurity delegates to the base auth provider.
func (e *extraHeadersAuth) RequireTransportSecurity() bool {
return e.base.RequireTransportSecurity()
}
+177
View File
@@ -0,0 +1,177 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithExtraHeaders_NilBase(t *testing.T) {
_, err := WithExtraHeaders(nil, map[string]string{"x-key": "val"})
require.Error(t, err)
assert.Contains(t, err.Error(), "base")
}
func TestWithExtraHeaders_NilHeaders(t *testing.T) {
_, err := WithExtraHeaders(NoAuth(), nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "headers")
}
func TestWithExtraHeaders_EmptyHeaders(t *testing.T) {
_, err := WithExtraHeaders(NoAuth(), map[string]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "headers")
}
func TestWithExtraHeaders_AllEmptyValues(t *testing.T) {
// All values are empty strings, so after filtering, headers map is empty.
_, err := WithExtraHeaders(NoAuth(), map[string]string{"x-key": ""})
require.Error(t, err)
assert.Contains(t, err.Error(), "headers")
}
func TestWithExtraHeaders_MergesWithBase(t *testing.T) {
base := StaticToken("my-token")
auth, err := WithExtraHeaders(base, map[string]string{
"x-proxy-key": "proxy-secret",
"x-tenant-id": "acme-corp",
})
require.NoError(t, err)
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer my-token", md["authorization"])
assert.Equal(t, "proxy-secret", md["x-proxy-key"])
assert.Equal(t, "acme-corp", md["x-tenant-id"])
}
func TestWithExtraHeaders_ExtraPrecedenceOnCollision(t *testing.T) {
base := StaticToken("my-token")
auth, err := WithExtraHeaders(base, map[string]string{
"authorization": "Custom override-token",
})
require.NoError(t, err)
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
// Extra header wins over base.
assert.Equal(t, "Custom override-token", md["authorization"])
}
func TestWithExtraHeaders_CaseInsensitiveCollision(t *testing.T) {
base := StaticToken("my-token")
// "Authorization" with uppercase should still override "authorization".
auth, err := WithExtraHeaders(base, map[string]string{
"Authorization": "Custom override-token",
})
require.NoError(t, err)
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Custom override-token", md["authorization"])
}
func TestWithExtraHeaders_EmptyValueSkipped(t *testing.T) {
base := StaticToken("my-token")
auth, err := WithExtraHeaders(base, map[string]string{
"x-proxy-key": "proxy-secret",
"x-empty": "", // Should be silently skipped.
})
require.NoError(t, err)
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "proxy-secret", md["x-proxy-key"])
_, hasEmpty := md["x-empty"]
assert.False(t, hasEmpty, "empty-string header values should be skipped")
}
func TestWithExtraHeaders_RequireTransportSecurity_Delegates(t *testing.T) {
tests := []struct {
name string
base AuthProvider
expected bool
}{
{
name: "delegates to NoAuth (false)",
base: NoAuth(),
expected: false,
},
{
name: "delegates to StaticToken (true)",
base: StaticToken("tok"),
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
auth, err := WithExtraHeaders(tt.base, map[string]string{"x-key": "val"})
require.NoError(t, err)
assert.Equal(t, tt.expected, auth.RequireTransportSecurity())
})
}
}
func TestWithExtraHeaders_WithNoAuth(t *testing.T) {
auth, err := WithExtraHeaders(NoAuth(), map[string]string{
"x-proxy-key": "proxy-secret",
})
require.NoError(t, err)
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
// NoAuth returns nil metadata, extra headers should still appear.
assert.Equal(t, "proxy-secret", md["x-proxy-key"])
}
func TestWithExtraHeaders_BaseError_Propagated(t *testing.T) {
base := &errAuth{err: errors.New("auth failure")}
auth, err := WithExtraHeaders(base, map[string]string{"x-key": "val"})
require.NoError(t, err)
_, err = auth.GetRequestMetadata(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "auth failure")
}
func TestWithExtraHeaders_DeepCopiesHeaders(t *testing.T) {
original := map[string]string{
"x-key": "original-value",
}
auth, err := WithExtraHeaders(NoAuth(), original)
require.NoError(t, err)
// Mutate the original map after construction.
original["x-key"] = "mutated-value"
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
// The wrapper should use the value at construction time, not the mutated value.
assert.Equal(t, "original-value", md["x-key"])
}
// errAuth is a test helper that always returns an error.
type errAuth struct {
err error
}
func (e *errAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return nil, e.err
}
func (e *errAuth) RequireTransportSecurity() bool {
return false
}
+137
View File
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"errors"
"sync"
"time"
"golang.org/x/oauth2"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
const defaultLeeway = 10 * time.Second
var errNilTokenSource = errors.New("openshell: TokenSource must not be nil")
// RefreshOption configures the behavior of RefreshableToken.
type RefreshOption func(*refreshConfig)
type refreshConfig struct {
leeway time.Duration
logger types.Logger
}
func defaultRefreshConfig() refreshConfig {
return refreshConfig{
leeway: defaultLeeway,
}
}
// WithLeeway sets the duration before token expiry at which a proactive
// refresh is triggered. Default is 10 seconds.
func WithLeeway(d time.Duration) RefreshOption {
return func(c *refreshConfig) {
if d < 0 {
d = 0
}
c.leeway = d
}
}
// WithLogger sets the logger used for stale-token fallback warnings.
// When not set, warnings are silently dropped.
func WithLogger(l types.Logger) RefreshOption {
return func(c *refreshConfig) {
c.logger = l
}
}
type refreshableAuth struct {
source oauth2.TokenSource
mu sync.RWMutex
tok *oauth2.Token
leeway time.Duration
logger types.Logger
}
func (r *refreshableAuth) isTokenValid() bool {
if r.tok == nil {
return false
}
if r.tok.Expiry.IsZero() {
return true
}
return time.Now().Before(r.tok.Expiry.Add(-r.leeway))
}
func (r *refreshableAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
// Fast path: RLock, return cached token if valid.
r.mu.RLock()
if r.isTokenValid() {
tok := r.tok.AccessToken
r.mu.RUnlock()
return map[string]string{"authorization": "Bearer " + tok}, nil
}
r.mu.RUnlock()
// Slow path: Lock, re-check, fetch if still stale.
r.mu.Lock()
defer r.mu.Unlock()
if r.isTokenValid() {
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
}
newTok, err := r.source.Token()
if err != nil {
if r.tok != nil {
if r.logger != nil {
r.logger.Error(err, "token refresh failed, using cached token")
}
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
}
return nil, err
}
if newTok == nil {
if r.tok != nil {
if r.logger != nil {
r.logger.Error(errors.New("token source returned nil token"), "token refresh returned nil, using cached token")
}
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
}
return nil, errors.New("openshell: token source returned nil token")
}
r.tok = newTok
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
}
func (r *refreshableAuth) RequireTransportSecurity() bool {
return true
}
// RefreshableToken returns an AuthProvider that caches tokens from src
// and refreshes them before expiry. Concurrent callers share a single
// refresh call (coalesced via RWMutex double-checked locking).
func RefreshableToken(src oauth2.TokenSource, opts ...RefreshOption) (AuthProvider, error) {
if src == nil {
return nil, errNilTokenSource
}
cfg := defaultRefreshConfig()
for _, o := range opts {
o(&cfg)
}
return &refreshableAuth{
source: src,
leeway: cfg.leeway,
logger: cfg.logger,
}, nil
}
+373
View File
@@ -0,0 +1,373 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
)
// mockTokenSource implements oauth2.TokenSource for testing.
type mockTokenSource struct {
mu sync.Mutex
tokenFunc func() (*oauth2.Token, error)
callCount int
}
func (m *mockTokenSource) Token() (*oauth2.Token, error) {
m.mu.Lock()
m.callCount++
m.mu.Unlock()
return m.tokenFunc()
}
func (m *mockTokenSource) calls() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.callCount
}
// --- Phase 2 tests: constructor validation ---
func TestRefreshableToken_NilSource(t *testing.T) {
_, err := RefreshableToken(nil)
require.Error(t, err)
assert.Equal(t, "openshell: TokenSource must not be nil", err.Error())
}
func TestRefreshableToken_ValidSource(t *testing.T) {
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil
},
}
provider, err := RefreshableToken(src)
require.NoError(t, err)
assert.NotNil(t, provider)
}
// --- Phase 3 / US1 tests: automatic token refresh ---
func TestGetRequestMetadata_FirstCallFetchesToken(t *testing.T) {
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "fresh-token", Expiry: time.Now().Add(time.Hour)}, nil
},
}
provider, err := RefreshableToken(src)
require.NoError(t, err)
md, err := provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer fresh-token", md["authorization"])
assert.Equal(t, 1, src.calls())
}
func TestGetRequestMetadata_CachedTokenNoExtraCall(t *testing.T) {
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "cached-token", Expiry: time.Now().Add(time.Hour)}, nil
},
}
provider, err := RefreshableToken(src)
require.NoError(t, err)
_, err = provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
md, err := provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer cached-token", md["authorization"])
assert.Equal(t, 1, src.calls(), "second call should use cache, not invoke TokenSource")
}
func TestGetRequestMetadata_RefreshesExpiredToken(t *testing.T) {
var callNum atomic.Int32
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
n := callNum.Add(1)
if n == 1 {
return &oauth2.Token{AccessToken: "old", Expiry: time.Now().Add(-time.Minute)}, nil
}
return &oauth2.Token{AccessToken: "new", Expiry: time.Now().Add(time.Hour)}, nil
},
}
provider, err := RefreshableToken(src, WithLeeway(0))
require.NoError(t, err)
// First call gets the expired token, which is immediately stale.
md, err := provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer old", md["authorization"])
// Second call should trigger a refresh since the cached token is expired.
md, err = provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer new", md["authorization"])
assert.Equal(t, 2, src.calls())
}
func TestGetRequestMetadata_ConcurrentSingleFlight(t *testing.T) {
var fetchCount atomic.Int32
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
fetchCount.Add(1)
time.Sleep(10 * time.Millisecond) // simulate slow token fetch
return &oauth2.Token{AccessToken: "shared-token", Expiry: time.Now().Add(time.Hour)}, nil
},
}
provider, err := RefreshableToken(src)
require.NoError(t, err)
const goroutines = 1000
var wg sync.WaitGroup
wg.Add(goroutines)
results := make([]string, goroutines)
errs := make([]error, goroutines)
for i := range goroutines {
go func(idx int) {
defer wg.Done()
md, e := provider.GetRequestMetadata(context.Background())
errs[idx] = e
if md != nil {
results[idx] = md["authorization"]
}
}(i)
}
wg.Wait()
for i := range goroutines {
require.NoError(t, errs[i], "goroutine %d failed", i)
assert.Equal(t, "Bearer shared-token", results[i], "goroutine %d got wrong token", i)
}
assert.Equal(t, int32(1), fetchCount.Load(), "expected exactly 1 TokenSource.Token() call, got %d", fetchCount.Load())
}
func TestRefreshableAuth_RequireTransportSecurity(t *testing.T) {
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "t"}, nil
},
}
provider, err := RefreshableToken(src)
require.NoError(t, err)
assert.True(t, provider.RequireTransportSecurity())
}
// --- Phase 4 / US2 tests: graceful degradation ---
func TestGetRequestMetadata_RefreshFailureReturnsStaleCachedToken(t *testing.T) {
var callNum atomic.Int32
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
n := callNum.Add(1)
if n == 1 {
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
}
return nil, fmt.Errorf("idp unavailable")
},
}
provider, err := RefreshableToken(src, WithLeeway(0))
require.NoError(t, err)
// First call succeeds but returns already-expired token.
_, err = provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
// Second call: refresh fails, should return stale token.
md, err := provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer stale", md["authorization"])
}
func TestGetRequestMetadata_RefreshFailureLogsWarning(t *testing.T) {
var callNum atomic.Int32
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
n := callNum.Add(1)
if n == 1 {
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
}
return nil, fmt.Errorf("idp unavailable")
},
}
logger := &captureLogger{}
provider, err := RefreshableToken(src, WithLeeway(0), WithLogger(logger))
require.NoError(t, err)
_, _ = provider.GetRequestMetadata(context.Background())
_, _ = provider.GetRequestMetadata(context.Background())
require.Len(t, logger.errors, 1)
assert.Contains(t, logger.errors[0].msg, "token refresh failed")
}
func TestGetRequestMetadata_RefreshFailureNoCachedTokenReturnsError(t *testing.T) {
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
return nil, fmt.Errorf("idp unavailable")
},
}
provider, err := RefreshableToken(src)
require.NoError(t, err)
_, err = provider.GetRequestMetadata(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "idp unavailable")
}
func TestGetRequestMetadata_RefreshFailureNoLoggerNoPanic(t *testing.T) {
var callNum atomic.Int32
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
n := callNum.Add(1)
if n == 1 {
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
}
return nil, fmt.Errorf("idp unavailable")
},
}
provider, err := RefreshableToken(src, WithLeeway(0))
require.NoError(t, err)
_, _ = provider.GetRequestMetadata(context.Background())
assert.NotPanics(t, func() {
_, _ = provider.GetRequestMetadata(context.Background())
})
}
// --- Phase 5 / US3 tests: configurable leeway ---
func TestGetRequestMetadata_DefaultLeewayTriggersRefresh(t *testing.T) {
var callNum atomic.Int32
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
n := callNum.Add(1)
return &oauth2.Token{
AccessToken: fmt.Sprintf("token-%d", n),
Expiry: time.Now().Add(5 * time.Second), // within default 10s leeway
}, nil
},
}
provider, err := RefreshableToken(src) // default 10s leeway
require.NoError(t, err)
_, err = provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
// Token expires in 5s, which is within 10s leeway, so next call should refresh.
md, err := provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer token-2", md["authorization"])
assert.Equal(t, 2, src.calls())
}
func TestGetRequestMetadata_CustomLeewayTriggersRefresh(t *testing.T) {
var callNum atomic.Int32
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
n := callNum.Add(1)
return &oauth2.Token{
AccessToken: fmt.Sprintf("token-%d", n),
Expiry: time.Now().Add(25 * time.Second), // within custom 30s leeway
}, nil
},
}
provider, err := RefreshableToken(src, WithLeeway(30*time.Second))
require.NoError(t, err)
_, err = provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
// Token expires in 25s, which is within 30s leeway, so next call should refresh.
md, err := provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer token-2", md["authorization"])
assert.Equal(t, 2, src.calls())
}
func TestGetRequestMetadata_ZeroExpiryNeverRefreshes(t *testing.T) {
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "forever-token"}, nil // zero Expiry
},
}
provider, err := RefreshableToken(src)
require.NoError(t, err)
_, err = provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
// Call multiple times; should never refresh since expiry is zero.
for range 10 {
_, err = provider.GetRequestMetadata(context.Background())
require.NoError(t, err)
}
assert.Equal(t, 1, src.calls(), "zero-expiry token should never be refreshed")
}
// --- benchmarks ---
func BenchmarkGetRequestMetadata_CachedToken(b *testing.B) {
src := &mockTokenSource{
tokenFunc: func() (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "bench-token", Expiry: time.Now().Add(time.Hour)}, nil
},
}
provider, err := RefreshableToken(src)
require.NoError(b, err)
// Prime the cache.
_, err = provider.GetRequestMetadata(context.Background())
require.NoError(b, err)
b.ResetTimer()
b.ReportAllocs()
for range b.N {
_, _ = provider.GetRequestMetadata(context.Background())
}
}
// --- helpers ---
type logEntry struct {
err error
msg string
}
type captureLogger struct {
mu sync.Mutex
debugs []string
infos []string
errors []logEntry
}
func (l *captureLogger) Debug(msg string, _ ...any) {
l.mu.Lock()
defer l.mu.Unlock()
l.debugs = append(l.debugs, msg)
}
func (l *captureLogger) Info(msg string, _ ...any) {
l.mu.Lock()
defer l.mu.Unlock()
l.infos = append(l.infos, msg)
}
func (l *captureLogger) Error(err error, msg string, _ ...any) {
l.mu.Lock()
defer l.mu.Unlock()
l.errors = append(l.errors, logEntry{err: err, msg: msg})
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNoAuth_GetRequestMetadata(t *testing.T) {
auth := NoAuth()
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Empty(t, md)
}
func TestNoAuth_RequireTransportSecurity(t *testing.T) {
auth := NoAuth()
assert.False(t, auth.RequireTransportSecurity())
}
func TestStaticToken_GetRequestMetadata(t *testing.T) {
auth := StaticToken("my-secret-token")
md, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer my-secret-token", md["authorization"])
}
func TestStaticToken_RequireTransportSecurity(t *testing.T) {
auth := StaticToken("token")
assert.True(t, auth.RequireTransportSecurity())
}
+142
View File
@@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"sync"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
internalgrpc "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/grpc"
"google.golang.org/grpc"
)
// Config holds all settings needed to create a Client.
type Config = types.Config
// ClientInterface defines the top-level SDK surface.
type ClientInterface interface {
Sandboxes() SandboxInterface
Providers() ProviderInterface
Services() ServiceInterface
Exec() ExecInterface
Files() FileInterface
Health() HealthInterface
SSH() SSHInterface
TCP() TCPInterface
Config() ConfigInterface
Policy() PolicyInterface
Close() error
}
// SandboxInterface is defined in sandbox.go
// ProviderInterface is defined in provider.go
// ExecInterface is defined in exec.go
// FileInterface is defined in file.go
// Client implements ClientInterface. It holds a gRPC connection and provides
// sub-client accessors following the Kubernetes client-go pattern.
type Client struct {
conn *grpc.ClientConn
config Config
closeOnce sync.Once
closeErr error
sandboxes SandboxInterface
providers ProviderInterface
services ServiceInterface
exec ExecInterface
files FileInterface
health HealthInterface
ssh SSHInterface
tcp TCPInterface
cfg ConfigInterface
policy PolicyInterface
}
// NewClient creates a new SDK client connected to the given gateway.
func NewClient(cfg Config) (*Client, error) {
if cfg.Address == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "address must not be empty"}
}
if cfg.Auth == nil {
cfg.Auth = NoAuth()
}
var tlsParams *internalgrpc.TLSParams
if cfg.TLS != nil {
tlsParams = &internalgrpc.TLSParams{
CertFile: cfg.TLS.CertFile,
KeyFile: cfg.TLS.KeyFile,
CAFile: cfg.TLS.CAFile,
Insecure: cfg.TLS.Insecure,
}
}
conn, err := internalgrpc.NewConnection(cfg.Address, tlsParams, cfg.Auth)
if err != nil {
return nil, err
}
c := &Client{
conn: conn,
config: cfg,
}
c.sandboxes = newSandboxClient(conn)
c.providers = &stubProviders{}
c.services = &stubServices{}
c.exec = &stubExec{}
c.files = &stubFiles{}
c.health = &stubHealth{}
c.ssh = &stubSSH{}
c.tcp = &stubTCP{}
c.cfg = &stubConfig{}
c.policy = &stubPolicy{}
return c, nil
}
// Sandboxes returns the sandbox sub-client.
func (c *Client) Sandboxes() SandboxInterface { return c.sandboxes }
// Providers returns the provider sub-client.
func (c *Client) Providers() ProviderInterface { return c.providers }
// Services returns the service sub-client.
func (c *Client) Services() ServiceInterface { return c.services }
// Exec returns the exec sub-client.
func (c *Client) Exec() ExecInterface { return c.exec }
// Files returns the file sub-client.
func (c *Client) Files() FileInterface { return c.files }
// Health returns the health sub-client.
func (c *Client) Health() HealthInterface { return c.health }
// SSH returns the SSH session sub-client.
func (c *Client) SSH() SSHInterface { return c.ssh }
// TCP returns the TCP port forwarding sub-client.
func (c *Client) TCP() TCPInterface { return c.tcp }
// Config returns the configuration sub-client.
func (c *Client) Config() ConfigInterface { return c.cfg }
// Policy returns the policy management sub-client.
func (c *Client) Policy() PolicyInterface { return c.policy }
// Close closes the underlying gRPC connection. Safe to call multiple times.
func (c *Client) Close() error {
c.closeOnce.Do(func() {
c.closeErr = c.conn.Close()
})
return c.closeErr
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewClient_EmptyAddress(t *testing.T) {
_, err := NewClient(Config{Address: ""})
require.Error(t, err)
assert.Contains(t, err.Error(), "address")
}
func TestNewClient_ValidConfig(t *testing.T) {
client, err := NewClient(Config{
Address: "localhost:50051",
Auth: NoAuth(),
TLS: &TLSConfig{Insecure: true},
})
require.NoError(t, err)
require.NotNil(t, client)
assert.NotNil(t, client.Sandboxes())
assert.NotNil(t, client.Providers())
assert.NotNil(t, client.Exec())
assert.NotNil(t, client.Files())
assert.NotNil(t, client.Health())
err = client.Close()
assert.NoError(t, err)
}
func TestClient_CloseIdempotent(t *testing.T) {
client, err := NewClient(Config{
Address: "localhost:50051",
Auth: NoAuth(),
TLS: &TLSConfig{Insecure: true},
})
require.NoError(t, err)
err = client.Close()
assert.NoError(t, err)
err = client.Close()
assert.NoError(t, err)
}
func TestNewClient_DefaultAuth(t *testing.T) {
client, err := NewClient(Config{
Address: "localhost:50051",
TLS: &TLSConfig{Insecure: true},
})
require.NoError(t, err)
require.NotNil(t, client)
_ = client.Close()
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// SandboxConfig represents the full configuration state of a sandbox.
type SandboxConfig = types.SandboxConfig
// GatewayConfig represents gateway-global settings.
type GatewayConfig = types.GatewayConfig
// ConfigUpdate represents a configuration mutation request.
type ConfigUpdate = types.ConfigUpdate
// ConfigUpdateResult holds the result of a configuration update operation.
type ConfigUpdateResult = types.ConfigUpdateResult
// SettingValue is a typed setting value (string, bool, int64, or bytes).
type SettingValue = types.SettingValue
// SettingValueType identifies which typed field of a SettingValue is active.
type SettingValueType = types.SettingValueType
// EffectiveSetting is a setting value paired with its resolved scope.
type EffectiveSetting = types.EffectiveSetting
// SettingScope indicates whether a setting is sandbox or global.
type SettingScope = types.SettingScope
// PolicySource indicates the source of a policy payload.
type PolicySource = types.PolicySource
// SettingValueType constants re-exported from types package.
const (
SettingValueString = types.SettingValueString
SettingValueBool = types.SettingValueBool
SettingValueInt = types.SettingValueInt
SettingValueBytes = types.SettingValueBytes
)
// SettingScope constants re-exported from types package.
const (
SettingScopeUnspecified = types.SettingScopeUnspecified
SettingScopeSandbox = types.SettingScopeSandbox
SettingScopeGlobal = types.SettingScopeGlobal
)
// PolicySource constants re-exported from types package.
const (
PolicySourceUnspecified = types.PolicySourceUnspecified
PolicySourceSandbox = types.PolicySourceSandbox
PolicySourceGlobal = types.PolicySourceGlobal
)
// ConfigInterface defines operations for reading and updating gateway and
// sandbox configuration.
type ConfigInterface interface {
// GetSandbox retrieves the full configuration state for a sandbox,
// including policy, effective settings, and revision metadata.
// The sandbox is identified by name; the SDK resolves it to an ID internally.
GetSandbox(ctx context.Context, workspace, sandboxName string) (*SandboxConfig, error)
// GetGateway retrieves gateway-global settings.
GetGateway(ctx context.Context) (*GatewayConfig, error)
// Update applies a configuration mutation. For sandbox-scoped updates,
// set ConfigUpdate.Name to the sandbox name. For global-scoped updates,
// set ConfigUpdate.Global to true.
Update(ctx context.Context, workspace string, update *ConfigUpdate) (*ConfigUpdateResult, error)
}
+358
View File
@@ -0,0 +1,358 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package v1 provides a Go SDK for interacting with OpenShell servers.
//
// The SDK follows the Kubernetes client-go sub-client pattern: a single Client
// provides typed accessors for each resource domain (Sandboxes, Providers, Exec,
// Files, Health, Services, SSH, TCP, Config). All operations accept a context.Context and return idiomatic
// Go types. Proto-generated types never appear in the public API.
//
// # Quick Start
//
// client, err := v1.NewClient(v1.Config{
// Address: "gateway.example.com:443",
// Auth: v1.StaticToken("my-token"),
// })
// if err != nil {
// log.Fatal(err)
// }
// defer client.Close()
//
// # Sandbox Lifecycle
//
// sandbox, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{
// Template: &v1.SandboxTemplate{Image: "python:3.12"},
// Environment: map[string]string{"LANG": "en_US.UTF-8"},
// }, nil)
// if err != nil {
// log.Fatal(err)
// }
//
// sandbox, err = client.Sandboxes().WaitReady(ctx, "default", sandbox.Name)
// if err != nil {
// log.Fatal(err)
// }
//
// # Command Execution (available in a future release)
//
// result, err := client.Exec().Run(ctx, "default", sandbox.Name, []string{"echo", "hello"}, v1.ExecOptions{})
// if err != nil {
// log.Fatal(err)
// }
// fmt.Println(string(result.Stdout)) // "hello\n"
//
// # Error Handling
//
// _, err = client.Sandboxes().Get(ctx, "default", "missing")
// if v1.IsNotFound(err) {
// // handle not found
// }
//
// # Watching
//
// watcher, err := client.Sandboxes().Watch(ctx, "default", sandbox.Name)
// if err != nil {
// log.Fatal(err)
// }
// defer watcher.Stop()
// for event := range watcher.ResultChan() {
// fmt.Printf("%s: %s\n", event.Type, event.Object.Name)
// }
//
// # Watching with StopOnTerminal
//
// Use StopOnTerminal to auto-close the watcher when the sandbox reaches a
// terminal phase (Ready or Error):
//
// watcher, err := client.Sandboxes().Watch(ctx, "default", sandbox.Name,
// v1.WatchOptions{StopOnTerminal: true},
// )
// if err != nil {
// log.Fatal(err)
// }
// for event := range watcher.ResultChan() {
// fmt.Printf("phase: %s\n", event.Object.Status.Phase)
// }
// // channel closes automatically after Ready or Error
//
// # Service Exposure (available in a future release)
//
// Expose an HTTP service running inside a sandbox and retrieve its public URL:
//
// endpoint, err := client.Services().Expose(ctx, "default", "my-sandbox", "api", 8080, true)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Service URL: %s\n", endpoint.URL)
//
// endpoints, err := client.Services().List(ctx, "default", "my-sandbox")
// if err != nil {
// log.Fatal(err)
// }
// for _, ep := range endpoints {
// fmt.Printf(" %s → port %d (URL: %s)\n", ep.ServiceName, ep.TargetPort, ep.URL)
// }
//
// # Provider Profiles (available in a future release)
//
// List available provider profiles and import new ones:
//
// profiles, err := client.Providers().Profiles().List(ctx, "default")
// if err != nil {
// log.Fatal(err)
// }
// for _, p := range profiles {
// fmt.Printf("%s (%s): %s\n", p.DisplayName, p.Category, p.Description)
// }
//
// result, err := client.Providers().Profiles().Import(ctx, "default", []v1.ProfileImportItem{
// {Source: "openai-profile.yaml", Profile: v1.ProviderProfile{
// DisplayName: "OpenAI",
// Category: v1.ProfileCategoryInference,
// }},
// })
// if err != nil {
// log.Fatal(err)
// }
// for _, d := range result.Diagnostics {
// fmt.Printf("[%s] %s: %s\n", d.Severity, d.Field, d.Message)
// }
//
// # Credential Refresh (available in a future release)
//
// Configure gateway-owned credential refresh for a provider:
//
// status, err := client.Providers().Refresh().Configure(ctx, "default", &v1.RefreshConfig{
// Provider: "openai",
// CredentialKey: "api-key",
// Strategy: v1.RefreshStrategyOAuth2ClientCredentials,
// Material: map[string]string{"client_id": "xxx", "client_secret": "yyy"},
// })
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Refresh status: %s (next: %s)\n", status.Status, status.NextRefreshAt)
//
// # Token Refresh
//
// Use RefreshableToken for automatic OAuth2 token caching and refresh.
// Concurrent callers share a single refresh call:
//
// tokenSource := oauth2Config.TokenSource(ctx, initialToken)
// auth, err := v1.RefreshableToken(tokenSource,
// v1.WithLeeway(30*time.Second),
// )
// if err != nil {
// log.Fatal(err)
// }
// client, err := v1.NewClient(v1.Config{
// Address: "gateway.example.com:443",
// Auth: auth,
// })
// if err != nil {
// log.Fatal(err)
// }
// defer client.Close()
//
// # Extra Headers
//
// Use WithExtraHeaders to attach additional per-RPC headers to any auth
// provider. This is useful for edge proxies, API gateways, or any middleware
// that requires custom headers alongside standard authentication:
//
// base := v1.StaticToken("my-token")
// auth, err := v1.WithExtraHeaders(base, map[string]string{
// "x-proxy-key": "proxy-secret",
// "x-tenant-id": "acme-corp",
// })
// if err != nil {
// log.Fatal(err)
// }
// client, err := v1.NewClient(v1.Config{
// Address: "gateway.example.com:443",
// Auth: auth,
// })
// if err != nil {
// log.Fatal(err)
// }
// defer client.Close()
//
// Keys are normalized to lowercase (per HTTP/2 RFC 9113). On key collision,
// extra headers take precedence over base auth headers. Empty-string values
// are silently dropped. WithExtraHeaders composes with any AuthProvider,
// including RefreshableToken:
//
// tokenSource := oauth2Config.TokenSource(ctx, initialToken)
// refreshAuth, err := v1.RefreshableToken(tokenSource)
// if err != nil {
// log.Fatal(err)
// }
// auth, err := v1.WithExtraHeaders(refreshAuth, map[string]string{
// "x-proxy-key": "proxy-secret",
// })
//
// # SSH Session Management (available in a future release)
//
// Create an SSH session for a sandbox and use the returned connection details.
// Note: CreateSession accepts a sandbox ID, not a name. For name-based access
// with automatic session cleanup, prefer SSH().Tunnel() instead.
//
// session, err := client.SSH().CreateSession(ctx, "default", sandbox.ID)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("SSH to %s:%d (scheme: %s)\n",
// session.GatewayHost, session.GatewayPort, session.GatewayScheme)
// fmt.Printf("Host key: %s\n", session.HostKeyFingerprint)
// // Use session.Token to authenticate the SSH connection.
//
// revoked, err := client.SSH().RevokeSession(ctx, "default", session.Token)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Session revoked: %v\n", revoked)
//
// # TCP Port Forwarding (available in a future release)
//
// Forward a local connection to a port inside a sandbox:
//
// conn, err := client.TCP().Forward(ctx, "default", "my-sandbox", 5432)
// if err != nil {
// log.Fatal(err)
// }
// defer conn.Close()
//
// // conn implements io.ReadWriteCloser, use it like a net.Conn.
// _, err = conn.Write([]byte("PING\n"))
// if err != nil {
// log.Fatal(err)
// }
// buf := make([]byte, 1024)
// n, err := conn.Read(buf)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Response: %s\n", buf[:n])
//
// Use WithForwardServiceID to tag the forwarding session with a service
// identifier for audit logging:
//
// conn, err := client.TCP().Forward(ctx, "default", "my-sandbox", 5432,
// v1.WithForwardServiceID("billing-db"),
// )
//
// # SSH Tunneling (available in a future release)
//
// Create an SSH tunnel to a sandbox port in a single call. Tunnel combines
// session creation, TCP forwarding with an SSH relay target, and automatic
// session cleanup into one operation:
//
// tunnel, err := client.SSH().Tunnel(ctx, "default", "my-sandbox", 22)
// if err != nil {
// log.Fatal(err)
// }
// defer tunnel.Close()
//
// // tunnel implements io.ReadWriteCloser. The underlying SSH session
// // is automatically revoked when Close is called.
// _, err = tunnel.Write([]byte("SSH-2.0-client\r\n"))
// if err != nil {
// log.Fatal(err)
// }
// buf := make([]byte, 256)
// n, err := tunnel.Read(buf)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Server banner: %s\n", buf[:n])
//
// Use WithTunnelServiceID to associate a service identifier with the tunnel:
//
// tunnel, err := client.SSH().Tunnel(ctx, "default", "my-sandbox", 22,
// v1.WithTunnelServiceID("dev-ssh"),
// )
//
// # Sandbox Policy
//
// Set an initial security policy when creating a sandbox:
//
// sandbox, err := client.Sandboxes().Create(ctx, "default", "secure-sandbox", &v1.SandboxSpec{
// Template: &v1.SandboxTemplate{Image: "python:3.12"},
// Policy: &v1.SandboxPolicy{
// Version: 1,
// Filesystem: &v1.FilesystemPolicy{
// IncludeWorkdir: true,
// ReadOnly: []string{"/usr", "/lib"},
// },
// Process: &v1.ProcessPolicy{
// RunAsUser: "sandbox",
// RunAsGroup: "sandbox",
// },
// NetworkPolicies: map[string]v1.NetworkPolicyRule{
// "allow-api": {
// Name: "allow-api",
// Endpoints: []v1.PolicyNetworkEndpoint{
// {Host: "api.example.com", Port: 443, Protocol: "tcp"},
// },
// },
// },
// },
// }, nil)
//
// Replace the full policy at runtime via configuration update (available in a future release):
//
// result, err := client.Config().Update(ctx, "default", &v1.ConfigUpdate{
// Name: "secure-sandbox",
// Policy: &v1.SandboxPolicy{
// Version: 2,
// NetworkPolicies: map[string]v1.NetworkPolicyRule{
// "allow-all": {Name: "allow-all"},
// },
// },
// })
//
// Read a policy back from revision history (available in a future release):
//
// revisions, err := client.Policy().List(ctx, "default")
// if err != nil {
// log.Fatal(err)
// }
// for _, rev := range revisions {
// if rev.Policy != nil {
// fmt.Printf("v%d: %d network rules\n", rev.Version, len(rev.Policy.NetworkPolicies))
// }
// }
//
// # Configuration Management (available in a future release)
//
// Read sandbox and gateway configuration, and update settings:
//
// sbCfg, err := client.Config().GetSandbox(ctx, "default", "my-sandbox")
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Config revision: %d\n", sbCfg.ConfigRevision)
// for name, setting := range sbCfg.Settings {
// fmt.Printf(" %s = %v (scope: %s)\n", name, setting.Value, setting.Scope)
// }
//
// gwCfg, err := client.Config().GetGateway(ctx)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Gateway settings revision: %d\n", gwCfg.SettingsRevision)
//
// result, err := client.Config().Update(ctx, "default", &v1.ConfigUpdate{
// Name: "my-sandbox",
// SettingKey: "max_tokens",
// SettingValue: &v1.SettingValue{
// Type: v1.SettingValueInt,
// IntVal: 8192,
// },
// })
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("New settings revision: %d\n", result.SettingsRevision)
package v1
+60
View File
@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// ErrorCode classifies SDK errors by their gRPC origin.
type ErrorCode = types.ErrorCode
// ErrorCode values for classifying gRPC errors.
const (
ErrorNotFound = types.ErrorNotFound
ErrorAlreadyExists = types.ErrorAlreadyExists
ErrorUnavailable = types.ErrorUnavailable
ErrorPermissionDenied = types.ErrorPermissionDenied
ErrorInvalidArgument = types.ErrorInvalidArgument
ErrorDeadlineExceeded = types.ErrorDeadlineExceeded
ErrorCancelled = types.ErrorCancelled
ErrorInternal = types.ErrorInternal
ErrorUnimplemented = types.ErrorUnimplemented
ErrorConflict = types.ErrorConflict
ErrorUnauthenticated = types.ErrorUnauthenticated
)
// StatusError is the typed error returned by all SDK operations.
type StatusError = types.StatusError
// IsNotFound returns true if the error indicates a resource was not found.
func IsNotFound(err error) bool { return types.IsNotFound(err) }
// IsAlreadyExists returns true if the error indicates a resource already exists.
func IsAlreadyExists(err error) bool { return types.IsAlreadyExists(err) }
// IsUnavailable returns true if the error indicates the service is unavailable.
func IsUnavailable(err error) bool { return types.IsUnavailable(err) }
// IsPermissionDenied returns true if the error indicates insufficient permissions.
func IsPermissionDenied(err error) bool { return types.IsPermissionDenied(err) }
// IsInvalidArgument returns true if the error indicates an invalid argument.
func IsInvalidArgument(err error) bool { return types.IsInvalidArgument(err) }
// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded.
func IsDeadlineExceeded(err error) bool { return types.IsDeadlineExceeded(err) }
// IsCancelled returns true if the error indicates the operation was cancelled.
func IsCancelled(err error) bool { return types.IsCancelled(err) }
// IsUnimplemented returns true if the error indicates the operation is not implemented.
func IsUnimplemented(err error) bool { return types.IsUnimplemented(err) }
// IsConflict returns true if the error indicates a conflict, such as
// optimistic concurrency or an invalid state transition.
func IsConflict(err error) bool { return types.IsConflict(err) }
// IsUnauthenticated returns true if the error indicates missing or invalid credentials.
func IsUnauthenticated(err error) bool { return types.IsUnauthenticated(err) }
+127
View File
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStatusError_Error(t *testing.T) {
err := &StatusError{
Code: ErrorNotFound,
Message: "sandbox not found",
}
s := err.Error()
assert.Contains(t, s, "NotFound")
assert.Contains(t, s, "sandbox not found")
}
func TestStatusError_ErrorWithCause(t *testing.T) {
cause := fmt.Errorf("underlying issue")
err := &StatusError{
Code: ErrorInvalidArgument,
Message: "bad name",
Cause: cause,
}
s := err.Error()
assert.Contains(t, s, "InvalidArgument")
assert.Contains(t, s, "bad name")
assert.ErrorIs(t, err, cause)
}
func TestIsNotFound(t *testing.T) {
err := &StatusError{Code: ErrorNotFound, Message: "not found"}
assert.True(t, IsNotFound(err))
assert.False(t, IsAlreadyExists(err))
}
func TestIsAlreadyExists(t *testing.T) {
err := &StatusError{Code: ErrorAlreadyExists, Message: "exists"}
assert.True(t, IsAlreadyExists(err))
assert.False(t, IsNotFound(err))
}
func TestIsUnavailable(t *testing.T) {
err := &StatusError{Code: ErrorUnavailable, Message: "down"}
assert.True(t, IsUnavailable(err))
}
func TestIsPermissionDenied(t *testing.T) {
err := &StatusError{Code: ErrorPermissionDenied, Message: "denied"}
assert.True(t, IsPermissionDenied(err))
}
func TestIsInvalidArgument(t *testing.T) {
err := &StatusError{Code: ErrorInvalidArgument, Message: "invalid"}
assert.True(t, IsInvalidArgument(err))
}
func TestIsDeadlineExceeded(t *testing.T) {
err := &StatusError{Code: ErrorDeadlineExceeded, Message: "timeout"}
assert.True(t, IsDeadlineExceeded(err))
}
func TestIsCancelled(t *testing.T) {
err := &StatusError{Code: ErrorCancelled, Message: "cancelled"}
assert.True(t, IsCancelled(err))
}
func TestIsConflict(t *testing.T) {
err := &StatusError{Code: ErrorConflict, Message: "version conflict"}
assert.True(t, IsConflict(err))
assert.False(t, IsNotFound(err))
}
func TestIsHelpers_NonStatusError(t *testing.T) {
err := errors.New("plain error")
assert.False(t, IsNotFound(err))
assert.False(t, IsAlreadyExists(err))
assert.False(t, IsUnavailable(err))
assert.False(t, IsPermissionDenied(err))
assert.False(t, IsInvalidArgument(err))
assert.False(t, IsDeadlineExceeded(err))
assert.False(t, IsCancelled(err))
assert.False(t, IsConflict(err))
}
func TestIsHelpers_NilError(t *testing.T) {
assert.False(t, IsNotFound(nil))
assert.False(t, IsConflict(nil))
}
func TestStatusError_WrappedError(t *testing.T) {
inner := &StatusError{Code: ErrorNotFound, Message: "not found"}
wrapped := fmt.Errorf("operation failed: %w", inner)
assert.True(t, IsNotFound(wrapped))
var se *StatusError
require.True(t, errors.As(wrapped, &se))
assert.Equal(t, ErrorNotFound, se.Code)
}
func TestErrorCode_String(t *testing.T) {
tests := []struct {
code ErrorCode
want string
}{
{ErrorNotFound, "NotFound"},
{ErrorAlreadyExists, "AlreadyExists"},
{ErrorUnavailable, "Unavailable"},
{ErrorPermissionDenied, "PermissionDenied"},
{ErrorInvalidArgument, "InvalidArgument"},
{ErrorDeadlineExceeded, "DeadlineExceeded"},
{ErrorCancelled, "Cancelled"},
{ErrorInternal, "Internal"},
{ErrorUnimplemented, "Unimplemented"},
{ErrorConflict, "Conflict"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, tt.code.String())
}
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// ExecResult holds the collected output of a completed command execution.
type ExecResult = types.ExecResult
// ExecChunk represents a single chunk of output from a streaming command execution.
type ExecChunk = types.ExecChunk
// ExecStream provides an iterator interface over streaming command output.
type ExecStream interface {
Next() (*ExecChunk, error)
ExitCode() (int, error)
Close() error
}
// InteractiveSession provides bidirectional I/O for interactive command execution.
type InteractiveSession interface {
Read(p []byte) (int, error)
Write(p []byte) (int, error)
Resize(cols, rows uint32) error
ExitCode() (int, error)
Close() error
}
// ExecInterface defines command execution operations on sandboxes.
// Methods accept a sandbox name and resolve it to an ID internally.
type ExecInterface interface {
Run(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error)
Stream(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error)
Interactive(ctx context.Context, workspace, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error)
}
+13
View File
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import "context"
// FileInterface defines file transfer operations on sandboxes.
// Methods accept a sandbox name and resolve it to an ID internally.
type FileInterface interface {
Upload(ctx context.Context, workspace, sandboxName string, localPath string, remotePath string) error
Download(ctx context.Context, workspace, sandboxName string, remotePath string, localPath string) error
}
+22
View File
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package v1 provides the OpenShell SDK client.
// gRPC error conversion is handled by the internal/converter package.
package v1
import "context"
func contextError(err error) error {
if err == nil {
return nil
}
switch err {
case context.DeadlineExceeded:
return &StatusError{Code: ErrorDeadlineExceeded, Message: err.Error(), Cause: err}
case context.Canceled:
return &StatusError{Code: ErrorCancelled, Message: err.Error(), Cause: err}
default:
return &StatusError{Code: ErrorInternal, Message: err.Error(), Cause: err}
}
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// HealthResult holds the result of a health check.
type HealthResult = types.HealthResult
// HealthInterface defines health check operations.
type HealthInterface interface {
Check(ctx context.Context) (*HealthResult, error)
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//go:build integration
package v1
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/require"
)
func gatewayAddress(t *testing.T) string {
t.Helper()
addr := os.Getenv("OPENSHELL_GATEWAY_ADDRESS")
if addr == "" {
t.Skip("OPENSHELL_GATEWAY_ADDRESS not set")
}
return addr
}
func TestIntegration_HealthCheck(t *testing.T) {
addr := gatewayAddress(t)
client, err := NewClient(Config{Address: addr})
require.NoError(t, err)
defer client.Close()
t.Skip("TODO: Health().Check() is a stub until PR B lands")
_, err = client.Health().Check(context.Background())
require.NoError(t, err)
}
func TestIntegration_ProviderLifecycle(t *testing.T) {
addr := gatewayAddress(t)
client, err := NewClient(Config{Address: addr})
require.NoError(t, err)
defer client.Close()
t.Skip("TODO: implement provider create/get/list/delete integration test")
}
func TestIntegration_SandboxLifecycle(t *testing.T) {
addr := gatewayAddress(t)
client, err := NewClient(Config{Address: addr})
require.NoError(t, err)
defer client.Close()
t.Skip("TODO: implement sandbox create/wait-ready/delete integration test")
}
func TestIntegration_ExecRun(t *testing.T) {
addr := gatewayAddress(t)
client, err := NewClient(Config{Address: addr})
require.NoError(t, err)
defer client.Close()
t.Skip("TODO: implement exec run integration test")
}
func TestIntegration_FileTransfer(t *testing.T) {
addr := gatewayAddress(t)
client, err := NewClient(Config{Address: addr})
require.NoError(t, err)
defer client.Close()
t.Skip("TODO: implement file upload/download integration test")
}
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import "google.golang.org/protobuf/types/known/structpb"
// CopyStringMap returns a shallow copy of a string-to-string map.
// Returns nil for nil input.
func CopyStringMap(m map[string]string) map[string]string {
if m == nil {
return nil
}
c := make(map[string]string, len(m))
for k, v := range m {
c[k] = v
}
return c
}
// CopyBoolPtr returns a copy of a *bool pointer.
// Returns nil for nil input.
func CopyBoolPtr(p *bool) *bool {
if p == nil {
return nil
}
v := *p
return &v
}
// CopyStringSlice returns a copy of a string slice.
// Returns nil for nil input.
func CopyStringSlice(s []string) []string {
if s == nil {
return nil
}
c := make([]string, len(s))
copy(c, s)
return c
}
// CopyByteSlice returns a copy of a byte slice.
// Returns nil for nil input.
func CopyByteSlice(b []byte) []byte {
if b == nil {
return nil
}
c := make([]byte, len(b))
copy(c, b)
return c
}
func structToMap(s *structpb.Struct) map[string]any {
if s == nil {
return nil
}
return s.AsMap()
}
func mapToStruct(m map[string]any) (*structpb.Struct, error) {
if m == nil {
return nil, nil
}
return structpb.NewStruct(m)
}
@@ -0,0 +1,234 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"testing"
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
sandboxpb "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1"
"google.golang.org/protobuf/reflect/protoreflect"
)
// These tests use protobuf reflection to detect proto fields that the
// converter layer does not handle. When buf generates new fields from an
// updated .proto, the field name appears in the proto descriptor but not in
// the "handled" set below.
//
// Unhandled fields FAIL the test so that proto drift is caught immediately.
// If a field is intentionally deferred, add it to the "skipped" set with a
// justification comment.
func TestConverterCoversAllProtoFields_SandboxSpec(t *testing.T) {
handled := fieldSet{
"log_level": true,
"environment": true,
"template": true,
"policy": true,
"providers": true,
"resource_requirements": true,
}
assertAllFieldsCovered(t, (&pb.SandboxSpec{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_SandboxTemplate(t *testing.T) {
handled := fieldSet{
"image": true,
"runtime_class_name": true,
"agent_socket": true,
"labels": true,
"annotations": true,
"environment": true,
"resources": true,
"user_namespaces": true,
"driver_config": true,
}
assertAllFieldsCovered(t, (&pb.SandboxTemplate{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) {
handled := fieldSet{
"sandbox_name": true,
"agent_pod": true,
"agent_fd": true,
"sandbox_fd": true,
"phase": true,
"conditions": true,
"current_policy_version": true,
}
assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) {
handled := fieldSet{
"type": true,
"status": true,
"reason": true,
"message": true,
"last_transition_time": true,
}
assertAllFieldsCovered(t, (&pb.SandboxCondition{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) {
handled := fieldSet{
"version": true,
"filesystem": true,
"network_policies": true,
"process": true,
"landlock": true,
}
skipped := fieldSet{
// Middleware support is not yet exposed in the SDK domain model.
// Tracked in GitHub issue #36 for Drop D.
"network_middlewares": true,
}
assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, skipped)
}
func TestConverterCoversAllProtoFields_NetworkEndpoint(t *testing.T) {
handled := fieldSet{
"host": true,
"port": true,
"ports": true,
"protocol": true,
"tls": true,
"enforcement": true,
"access": true,
"rules": true,
"allowed_ips": true,
"deny_rules": true,
"allow_encoded_slash": true,
"persisted_queries": true,
"graphql_persisted_queries": true,
"graphql_max_body_bytes": true,
"path": true,
"websocket_credential_rewrite": true,
"request_body_credential_rewrite": true,
"advisor_proposed": true,
"credential_signing": true,
"signing_service": true,
"signing_region": true,
"json_rpc_max_body_bytes": true,
"mcp": true,
}
assertAllFieldsCovered(t, (&sandboxpb.NetworkEndpoint{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_L7Allow(t *testing.T) {
handled := fieldSet{
"method": true,
"path": true,
"command": true,
"query": true,
"operation_type": true,
"operation_name": true,
"fields": true,
"params": true,
}
assertAllFieldsCovered(t, (&sandboxpb.L7Allow{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_L7DenyRule(t *testing.T) {
handled := fieldSet{
"method": true,
"path": true,
"command": true,
"query": true,
"operation_type": true,
"operation_name": true,
"fields": true,
"params": true,
}
assertAllFieldsCovered(t, (&sandboxpb.L7DenyRule{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_Provider(t *testing.T) {
handled := fieldSet{
"metadata": true,
"type": true,
"credentials": true,
"config": true,
"credential_expires_at_ms": true,
"profile_workspace": true,
"credential_handles": true,
}
assertAllFieldsCovered(t, (&dm.Provider{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_CredentialHandle(t *testing.T) {
handled := fieldSet{
"driver": true,
"handle": true,
"metadata": true,
}
assertAllFieldsCovered(t, (&dm.CredentialHandle{}).ProtoReflect().Descriptor(), handled, nil)
}
func TestConverterCoversAllProtoFields_McpOptions(t *testing.T) {
handled := fieldSet{
"strict_tool_names": true,
"allow_all_known_mcp_methods": true,
}
assertAllFieldsCovered(t, (&sandboxpb.McpOptions{}).ProtoReflect().Descriptor(), handled, nil)
}
// fieldSet tracks proto field names that the converter handles.
type fieldSet map[string]bool
// assertAllFieldsCovered fails the test for proto fields not present in
// either handled or skipped. Stale entries in the handled set (fields
// removed from the proto) also fail.
func assertAllFieldsCovered(
t *testing.T,
desc protoreflect.MessageDescriptor,
handled fieldSet,
skipped fieldSet,
) {
t.Helper()
fields := desc.Fields()
for i := 0; i < fields.Len(); i++ {
name := string(fields.Get(i).Name())
if handled[name] || skipped[name] {
continue
}
t.Errorf(
"proto %s field %q is not handled by the converter and not explicitly skipped. "+
"Add converter support in the appropriate FromProto/ToProto function, "+
"or add it to the skipped set with a justification.",
desc.FullName(), name,
)
}
for name := range handled {
found := false
for i := 0; i < fields.Len(); i++ {
if string(fields.Get(i).Name()) == name {
found = true
break
}
}
if !found {
t.Errorf(
"handled field %q is listed for proto %s but does not exist in the descriptor. "+
"The proto field may have been removed or renamed.",
name, desc.FullName(),
)
}
}
}
@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package converter maps between gRPC/proto types and SDK domain types.
package converter
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var grpcToSDK = map[codes.Code]types.ErrorCode{
codes.NotFound: types.ErrorNotFound,
codes.AlreadyExists: types.ErrorAlreadyExists,
codes.Unavailable: types.ErrorUnavailable,
codes.PermissionDenied: types.ErrorPermissionDenied,
codes.Unauthenticated: types.ErrorUnauthenticated,
codes.InvalidArgument: types.ErrorInvalidArgument,
codes.DeadlineExceeded: types.ErrorDeadlineExceeded,
codes.Canceled: types.ErrorCancelled,
codes.Internal: types.ErrorInternal,
codes.Unimplemented: types.ErrorUnimplemented,
codes.Aborted: types.ErrorConflict,
codes.FailedPrecondition: types.ErrorConflict,
}
// FromGRPCError converts a gRPC error to a typed StatusError.
// Returns nil for nil errors and OK status. Non-gRPC errors pass through unchanged.
func FromGRPCError(err error) error {
if err == nil {
return nil
}
st, ok := status.FromError(err)
if !ok {
return err
}
if st.Code() == codes.OK {
return nil
}
code, mapped := grpcToSDK[st.Code()]
if !mapped {
code = types.ErrorInternal
}
return &types.StatusError{
Code: code,
Message: st.Message(),
Cause: err,
}
}
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"testing"
v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestFromGRPCError_NotFound(t *testing.T) {
grpcErr := status.Error(codes.NotFound, "sandbox not found")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsNotFound(err))
}
func TestFromGRPCError_AlreadyExists(t *testing.T) {
grpcErr := status.Error(codes.AlreadyExists, "already exists")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsAlreadyExists(err))
}
func TestFromGRPCError_Unavailable(t *testing.T) {
grpcErr := status.Error(codes.Unavailable, "service down")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsUnavailable(err))
}
func TestFromGRPCError_PermissionDenied(t *testing.T) {
grpcErr := status.Error(codes.PermissionDenied, "denied")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsPermissionDenied(err))
}
func TestFromGRPCError_InvalidArgument(t *testing.T) {
grpcErr := status.Error(codes.InvalidArgument, "bad arg")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsInvalidArgument(err))
}
func TestFromGRPCError_DeadlineExceeded(t *testing.T) {
grpcErr := status.Error(codes.DeadlineExceeded, "timeout")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsDeadlineExceeded(err))
}
func TestFromGRPCError_Cancelled(t *testing.T) {
grpcErr := status.Error(codes.Canceled, "cancelled")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsCancelled(err))
}
func TestFromGRPCError_Internal(t *testing.T) {
grpcErr := status.Error(codes.Internal, "internal error")
err := FromGRPCError(grpcErr)
require.Error(t, err)
var se *v1.StatusError
require.ErrorAs(t, err, &se)
assert.Equal(t, v1.ErrorInternal, se.Code)
}
func TestFromGRPCError_Unimplemented(t *testing.T) {
grpcErr := status.Error(codes.Unimplemented, "not implemented")
err := FromGRPCError(grpcErr)
require.Error(t, err)
var se *v1.StatusError
require.ErrorAs(t, err, &se)
assert.Equal(t, v1.ErrorUnimplemented, se.Code)
}
func TestFromGRPCError_Aborted(t *testing.T) {
grpcErr := status.Error(codes.Aborted, "version conflict")
err := FromGRPCError(grpcErr)
require.Error(t, err)
assert.True(t, v1.IsConflict(err))
var se *v1.StatusError
require.ErrorAs(t, err, &se)
assert.Equal(t, v1.ErrorConflict, se.Code)
assert.Equal(t, "version conflict", se.Message)
}
func TestFromGRPCError_UnmappedCode(t *testing.T) {
grpcErr := status.Error(codes.DataLoss, "data loss")
err := FromGRPCError(grpcErr)
require.Error(t, err)
var se *v1.StatusError
require.ErrorAs(t, err, &se)
assert.Equal(t, v1.ErrorInternal, se.Code)
}
func TestFromGRPCError_NilError(t *testing.T) {
err := FromGRPCError(nil)
assert.NoError(t, err)
}
func TestFromGRPCError_NonGRPCError(t *testing.T) {
err := FromGRPCError(assert.AnError)
require.Error(t, err)
assert.Equal(t, assert.AnError, err)
}
func TestFromGRPCError_OKStatus(t *testing.T) {
grpcErr := status.Error(codes.OK, "")
err := FromGRPCError(grpcErr)
assert.NoError(t, err)
}
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
)
// --- LogLine ---
// LogLineFromProto converts a proto SandboxLogLine to an SDK LogLine.
func LogLineFromProto(l *pb.SandboxLogLine) *types.LogLine {
if l == nil {
return nil
}
return &types.LogLine{
Timestamp: TimeFromMillis(l.GetTimestampMs()),
Level: l.GetLevel(),
Target: l.GetTarget(),
Message: l.GetMessage(),
Source: l.GetSource(),
Fields: CopyStringMap(l.GetFields()),
}
}
// --- LogResult ---
// LogResultFromProto converts a proto GetSandboxLogsResponse to an SDK LogResult.
func LogResultFromProto(r *pb.GetSandboxLogsResponse) *types.LogResult {
if r == nil {
return nil
}
result := &types.LogResult{
BufferTotal: r.GetBufferTotal(),
}
if logs := r.GetLogs(); len(logs) > 0 {
result.Lines = make([]types.LogLine, 0, len(logs))
for _, l := range logs {
if converted := LogLineFromProto(l); converted != nil {
result.Lines = append(result.Lines, *converted)
}
}
}
return result
}
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"testing"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- LogLine ---
func TestLogLineFromProto(t *testing.T) {
proto := &pb.SandboxLogLine{
SandboxId: "sbx-1",
TimestampMs: 1700000000000,
Level: "INFO",
Target: "network",
Message: "Connection established",
Source: "sandbox-agent",
Fields: map[string]string{
"host": "api.example.com",
"port": "443",
},
}
line := LogLineFromProto(proto)
require.NotNil(t, line)
assert.False(t, line.Timestamp.IsZero())
assert.Equal(t, "INFO", line.Level)
assert.Equal(t, "network", line.Target)
assert.Equal(t, "Connection established", line.Message)
assert.Equal(t, "sandbox-agent", line.Source)
assert.Equal(t, "api.example.com", line.Fields["host"])
assert.Equal(t, "443", line.Fields["port"])
}
func TestLogLineFromProto_Nil(t *testing.T) {
assert.Nil(t, LogLineFromProto(nil))
}
func TestLogLineDeepCopy(t *testing.T) {
proto := &pb.SandboxLogLine{
TimestampMs: 1700000000000,
Level: "WARN",
Message: "test",
Fields: map[string]string{
"key": "value",
},
}
line := LogLineFromProto(proto)
proto.Fields["key"] = "changed"
assert.Equal(t, "value", line.Fields["key"])
}
// --- LogResult ---
func TestLogResultFromProto(t *testing.T) {
proto := &pb.GetSandboxLogsResponse{
Logs: []*pb.SandboxLogLine{
{TimestampMs: 1700000000000, Level: "INFO", Message: "first"},
{TimestampMs: 1700000001000, Level: "DEBUG", Message: "second"},
},
BufferTotal: 100,
}
result := LogResultFromProto(proto)
require.NotNil(t, result)
assert.Len(t, result.Lines, 2)
assert.Equal(t, "INFO", result.Lines[0].Level)
assert.Equal(t, "first", result.Lines[0].Message)
assert.Equal(t, "DEBUG", result.Lines[1].Level)
assert.Equal(t, "second", result.Lines[1].Message)
assert.Equal(t, uint32(100), result.BufferTotal)
}
func TestLogResultFromProto_Nil(t *testing.T) {
assert.Nil(t, LogResultFromProto(nil))
}
func TestLogResultFromProto_EmptyLogs(t *testing.T) {
proto := &pb.GetSandboxLogsResponse{
BufferTotal: 0,
}
result := LogResultFromProto(proto)
require.NotNil(t, result)
assert.Empty(t, result.Lines)
assert.Equal(t, uint32(0), result.BufferTotal)
}
@@ -0,0 +1,332 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1"
)
// --- NetworkPolicyRule ---
// NetworkPolicyRuleFromProto converts a proto NetworkPolicyRule to an SDK NetworkPolicyRule.
func NetworkPolicyRuleFromProto(r *sbv1.NetworkPolicyRule) *types.NetworkPolicyRule {
if r == nil {
return nil
}
result := &types.NetworkPolicyRule{
Name: r.GetName(),
}
if eps := r.GetEndpoints(); len(eps) > 0 {
result.Endpoints = make([]types.PolicyNetworkEndpoint, len(eps))
for i, ep := range eps {
if ep != nil {
result.Endpoints[i] = policyNetworkEndpointFromProto(ep)
}
}
}
if bins := r.GetBinaries(); len(bins) > 0 {
result.Binaries = make([]types.PolicyNetworkBinary, len(bins))
for i, b := range bins {
if b != nil {
result.Binaries[i] = types.PolicyNetworkBinary{Path: b.GetPath()}
}
}
}
return result
}
// NetworkPolicyRuleToProto converts an SDK NetworkPolicyRule to a proto NetworkPolicyRule.
func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *sbv1.NetworkPolicyRule {
if r == nil {
return nil
}
result := &sbv1.NetworkPolicyRule{
Name: r.Name,
}
if len(r.Endpoints) > 0 {
result.Endpoints = make([]*sbv1.NetworkEndpoint, len(r.Endpoints))
for i := range r.Endpoints {
result.Endpoints[i] = policyNetworkEndpointToProto(&r.Endpoints[i])
}
}
if len(r.Binaries) > 0 {
result.Binaries = make([]*sbv1.NetworkBinary, len(r.Binaries))
for i := range r.Binaries {
result.Binaries[i] = &sbv1.NetworkBinary{Path: r.Binaries[i].Path}
}
}
return result
}
// --- PolicyNetworkEndpoint ---
func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetworkEndpoint {
result := types.PolicyNetworkEndpoint{
Host: ep.GetHost(),
Port: ep.GetPort(),
Protocol: ep.GetProtocol(),
TLS: ep.GetTls(),
Enforcement: ep.GetEnforcement(),
Access: ep.GetAccess(),
AllowEncodedSlash: ep.GetAllowEncodedSlash(),
PersistedQueries: ep.GetPersistedQueries(),
GraphqlMaxBodyBytes: ep.GetGraphqlMaxBodyBytes(),
Path: ep.GetPath(),
WebsocketCredentialRewrite: ep.GetWebsocketCredentialRewrite(),
RequestBodyCredentialRewrite: ep.GetRequestBodyCredentialRewrite(),
AdvisorProposed: ep.GetAdvisorProposed(),
CredentialSigning: ep.GetCredentialSigning(),
SigningService: ep.GetSigningService(),
SigningRegion: ep.GetSigningRegion(),
JsonRpcMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(),
}
if mcp := ep.GetMcp(); mcp != nil {
result.Mcp = mcpOptionsFromProto(mcp)
}
if ports := ep.GetPorts(); len(ports) > 0 {
result.Ports = make([]uint32, len(ports))
copy(result.Ports, ports)
}
if ips := ep.GetAllowedIps(); len(ips) > 0 {
result.AllowedIPs = CopyStringSlice(ips)
}
if rules := ep.GetRules(); len(rules) > 0 {
result.Rules = make([]types.L7Rule, len(rules))
for i, r := range rules {
if r != nil {
result.Rules[i] = l7RuleFromProto(r)
}
}
}
if deny := ep.GetDenyRules(); len(deny) > 0 {
result.DenyRules = make([]types.L7DenyRule, len(deny))
for i, r := range deny {
if r != nil {
result.DenyRules[i] = l7DenyRuleFromProto(r)
}
}
}
if gql := ep.GetGraphqlPersistedQueries(); len(gql) > 0 {
result.GraphqlPersistedQueries = make(map[string]types.GraphqlOperation, len(gql))
for k, v := range gql {
if v != nil {
result.GraphqlPersistedQueries[k] = graphqlOperationFromProto(v)
}
}
}
return result
}
func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.NetworkEndpoint {
result := &sbv1.NetworkEndpoint{
Host: ep.Host,
Port: ep.Port,
Protocol: ep.Protocol,
Tls: ep.TLS,
Enforcement: ep.Enforcement,
Access: ep.Access,
AllowEncodedSlash: ep.AllowEncodedSlash,
PersistedQueries: ep.PersistedQueries,
GraphqlMaxBodyBytes: ep.GraphqlMaxBodyBytes,
Path: ep.Path,
WebsocketCredentialRewrite: ep.WebsocketCredentialRewrite,
RequestBodyCredentialRewrite: ep.RequestBodyCredentialRewrite,
AdvisorProposed: ep.AdvisorProposed,
CredentialSigning: ep.CredentialSigning,
SigningService: ep.SigningService,
SigningRegion: ep.SigningRegion,
JsonRpcMaxBodyBytes: ep.JsonRpcMaxBodyBytes,
}
if ep.Mcp != nil {
result.Mcp = mcpOptionsToProto(ep.Mcp)
}
if len(ep.Ports) > 0 {
result.Ports = make([]uint32, len(ep.Ports))
copy(result.Ports, ep.Ports)
}
if len(ep.AllowedIPs) > 0 {
result.AllowedIps = CopyStringSlice(ep.AllowedIPs)
}
if len(ep.Rules) > 0 {
result.Rules = make([]*sbv1.L7Rule, len(ep.Rules))
for i := range ep.Rules {
result.Rules[i] = l7RuleToProto(&ep.Rules[i])
}
}
if len(ep.DenyRules) > 0 {
result.DenyRules = make([]*sbv1.L7DenyRule, len(ep.DenyRules))
for i := range ep.DenyRules {
result.DenyRules[i] = l7DenyRuleToProto(&ep.DenyRules[i])
}
}
if len(ep.GraphqlPersistedQueries) > 0 {
result.GraphqlPersistedQueries = make(map[string]*sbv1.GraphqlOperation, len(ep.GraphqlPersistedQueries))
for k, v := range ep.GraphqlPersistedQueries {
result.GraphqlPersistedQueries[k] = graphqlOperationToProto(&v)
}
}
return result
}
// --- L7Rule ---
func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule {
result := types.L7Rule{}
if a := r.GetAllow(); a != nil {
result.Allow = &types.L7Allow{
Method: a.GetMethod(),
Path: a.GetPath(),
Command: a.GetCommand(),
OperationType: a.GetOperationType(),
OperationName: a.GetOperationName(),
Fields: CopyStringSlice(a.GetFields()),
}
if q := a.GetQuery(); len(q) > 0 {
result.Allow.Query = l7QueryMapFromProto(q)
}
if p := a.GetParams(); len(p) > 0 {
result.Allow.Params = l7QueryMapFromProto(p)
}
}
return result
}
func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule {
result := &sbv1.L7Rule{}
if r.Allow != nil {
result.Allow = &sbv1.L7Allow{
Method: r.Allow.Method,
Path: r.Allow.Path,
Command: r.Allow.Command,
OperationType: r.Allow.OperationType,
OperationName: r.Allow.OperationName,
Fields: CopyStringSlice(r.Allow.Fields),
}
if len(r.Allow.Query) > 0 {
result.Allow.Query = l7QueryMapToProto(r.Allow.Query)
}
if len(r.Allow.Params) > 0 {
result.Allow.Params = l7QueryMapToProto(r.Allow.Params)
}
}
return result
}
// --- L7DenyRule ---
func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule {
result := types.L7DenyRule{
Method: r.GetMethod(),
Path: r.GetPath(),
Command: r.GetCommand(),
OperationType: r.GetOperationType(),
OperationName: r.GetOperationName(),
Fields: CopyStringSlice(r.GetFields()),
Query: l7QueryMapFromProtoDeny(r.GetQuery()),
}
if p := r.GetParams(); len(p) > 0 {
result.Params = l7QueryMapFromProto(p)
}
return result
}
func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule {
result := &sbv1.L7DenyRule{
Method: r.Method,
Path: r.Path,
Command: r.Command,
OperationType: r.OperationType,
OperationName: r.OperationName,
Fields: CopyStringSlice(r.Fields),
}
if len(r.Query) > 0 {
result.Query = l7QueryMapToProtoDeny(r.Query)
}
if len(r.Params) > 0 {
result.Params = l7QueryMapToProto(r.Params)
}
return result
}
// --- L7QueryMatcher helpers ---
func l7QueryMapFromProto(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher {
if len(m) == 0 {
return nil
}
result := make(map[string]types.L7QueryMatcher, len(m))
for k, v := range m {
if v != nil {
result[k] = types.L7QueryMatcher{
Glob: v.GetGlob(),
Any: CopyStringSlice(v.GetAny()),
}
}
}
return result
}
func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher {
if len(m) == 0 {
return nil
}
result := make(map[string]*sbv1.L7QueryMatcher, len(m))
for k, v := range m {
result[k] = &sbv1.L7QueryMatcher{
Glob: v.Glob,
Any: CopyStringSlice(v.Any),
}
}
return result
}
// L7DenyRule uses the same L7QueryMatcher proto type but on a different message.
func l7QueryMapFromProtoDeny(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher {
return l7QueryMapFromProto(m)
}
func l7QueryMapToProtoDeny(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher {
return l7QueryMapToProto(m)
}
// --- GraphqlOperation ---
func graphqlOperationFromProto(op *sbv1.GraphqlOperation) types.GraphqlOperation {
return types.GraphqlOperation{
OperationType: op.GetOperationType(),
OperationName: op.GetOperationName(),
Fields: CopyStringSlice(op.GetFields()),
}
}
func graphqlOperationToProto(op *types.GraphqlOperation) *sbv1.GraphqlOperation {
return &sbv1.GraphqlOperation{
OperationType: op.OperationType,
OperationName: op.OperationName,
Fields: CopyStringSlice(op.Fields),
}
}
// --- McpOptions ---
func mcpOptionsFromProto(m *sbv1.McpOptions) *types.McpOptions {
if m == nil {
return nil
}
return &types.McpOptions{
StrictToolNames: m.StrictToolNames,
AllowAllKnownMcpMethods: m.AllowAllKnownMcpMethods,
}
}
func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions {
if m == nil {
return nil
}
return &sbv1.McpOptions{
StrictToolNames: m.StrictToolNames,
AllowAllKnownMcpMethods: m.AllowAllKnownMcpMethods,
}
}
@@ -0,0 +1,304 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1"
)
// --- PolicyLoadStatus enum mapping ---
// PolicyLoadStatusFromProto converts a proto PolicyStatus to an SDK PolicyLoadStatus.
func PolicyLoadStatusFromProto(s pb.PolicyStatus) types.PolicyLoadStatus {
switch s {
case pb.PolicyStatus_POLICY_STATUS_PENDING:
return types.PolicyLoadStatusPending
case pb.PolicyStatus_POLICY_STATUS_LOADED:
return types.PolicyLoadStatusLoaded
case pb.PolicyStatus_POLICY_STATUS_FAILED:
return types.PolicyLoadStatusFailed
case pb.PolicyStatus_POLICY_STATUS_SUPERSEDED:
return types.PolicyLoadStatusSuperseded
default:
return types.PolicyLoadStatusUnspecified
}
}
// PolicyLoadStatusToProto converts an SDK PolicyLoadStatus to a proto PolicyStatus.
func PolicyLoadStatusToProto(s types.PolicyLoadStatus) pb.PolicyStatus {
switch s {
case types.PolicyLoadStatusPending:
return pb.PolicyStatus_POLICY_STATUS_PENDING
case types.PolicyLoadStatusLoaded:
return pb.PolicyStatus_POLICY_STATUS_LOADED
case types.PolicyLoadStatusFailed:
return pb.PolicyStatus_POLICY_STATUS_FAILED
case types.PolicyLoadStatusSuperseded:
return pb.PolicyStatus_POLICY_STATUS_SUPERSEDED
default:
return pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED
}
}
// --- PolicyChunk ---
// PolicyChunkFromProto converts a proto PolicyChunk to an SDK PolicyChunk.
func PolicyChunkFromProto(c *pb.PolicyChunk) *types.PolicyChunk {
if c == nil {
return nil
}
return &types.PolicyChunk{
ID: c.GetId(),
Status: c.GetStatus(),
RuleName: c.GetRuleName(),
ProposedRule: NetworkPolicyRuleFromProto(c.GetProposedRule()),
Rationale: c.GetRationale(),
SecurityNotes: c.GetSecurityNotes(),
Confidence: c.GetConfidence(),
DenialSummaryIDs: CopyStringSlice(c.GetDenialSummaryIds()),
CreatedAt: TimeFromMillis(c.GetCreatedAtMs()),
DecidedAt: TimeFromMillis(c.GetDecidedAtMs()),
Stage: c.GetStage(),
SupersedesChunkID: c.GetSupersedesChunkId(),
HitCount: c.GetHitCount(),
FirstSeen: TimeFromMillis(c.GetFirstSeenMs()),
LastSeen: TimeFromMillis(c.GetLastSeenMs()),
Binary: c.GetBinary(),
ValidationResult: c.GetValidationResult(),
RejectionReason: c.GetRejectionReason(),
}
}
// --- DraftPolicy ---
// DraftPolicyFromProto converts a proto GetDraftPolicyResponse to an SDK DraftPolicy.
func DraftPolicyFromProto(r *pb.GetDraftPolicyResponse) *types.DraftPolicy {
if r == nil {
return nil
}
result := &types.DraftPolicy{
RollingSummary: r.GetRollingSummary(),
DraftVersion: r.GetDraftVersion(),
LastAnalyzedAt: TimeFromMillis(r.GetLastAnalyzedAtMs()),
}
if chunks := r.GetChunks(); len(chunks) > 0 {
result.Chunks = make([]types.PolicyChunk, 0, len(chunks))
for _, c := range chunks {
if converted := PolicyChunkFromProto(c); converted != nil {
result.Chunks = append(result.Chunks, *converted)
}
}
}
return result
}
// --- SandboxPolicy ---
// SandboxPolicyFromProto converts a proto SandboxPolicy to an SDK SandboxPolicy.
// Returns nil for nil input. All slice and map fields are deep-copied.
func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy {
if p == nil {
return nil
}
result := &types.SandboxPolicy{
Version: p.GetVersion(),
Filesystem: filesystemPolicyFromProto(p.GetFilesystem()),
Landlock: landlockPolicyFromProto(p.GetLandlock()),
Process: processPolicyFromProto(p.GetProcess()),
}
if np := p.GetNetworkPolicies(); np != nil {
result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np))
for k, v := range np {
if converted := NetworkPolicyRuleFromProto(v); converted != nil {
result.NetworkPolicies[k] = *converted
}
}
}
return result
}
// SandboxPolicyToProto converts an SDK SandboxPolicy to a proto SandboxPolicy.
// Returns nil for nil input. All slice and map fields are deep-copied.
func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy {
if p == nil {
return nil
}
result := &sbv1.SandboxPolicy{
Version: p.Version,
Filesystem: filesystemPolicyToProto(p.Filesystem),
Landlock: landlockPolicyToProto(p.Landlock),
Process: processPolicyToProto(p.Process),
}
if p.NetworkPolicies != nil {
result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies))
for k, v := range p.NetworkPolicies {
result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v)
}
}
return result
}
func filesystemPolicyFromProto(f *sbv1.FilesystemPolicy) *types.FilesystemPolicy {
if f == nil {
return nil
}
return &types.FilesystemPolicy{
IncludeWorkdir: f.GetIncludeWorkdir(),
ReadOnly: CopyStringSlice(f.GetReadOnly()),
ReadWrite: CopyStringSlice(f.GetReadWrite()),
}
}
func filesystemPolicyToProto(f *types.FilesystemPolicy) *sbv1.FilesystemPolicy {
if f == nil {
return nil
}
return &sbv1.FilesystemPolicy{
IncludeWorkdir: f.IncludeWorkdir,
ReadOnly: CopyStringSlice(f.ReadOnly),
ReadWrite: CopyStringSlice(f.ReadWrite),
}
}
func landlockPolicyFromProto(l *sbv1.LandlockPolicy) *types.LandlockPolicy {
if l == nil {
return nil
}
return &types.LandlockPolicy{
Compatibility: l.GetCompatibility(),
}
}
func landlockPolicyToProto(l *types.LandlockPolicy) *sbv1.LandlockPolicy {
if l == nil {
return nil
}
return &sbv1.LandlockPolicy{
Compatibility: l.Compatibility,
}
}
func processPolicyFromProto(p *sbv1.ProcessPolicy) *types.ProcessPolicy {
if p == nil {
return nil
}
return &types.ProcessPolicy{
RunAsUser: p.GetRunAsUser(),
RunAsGroup: p.GetRunAsGroup(),
}
}
func processPolicyToProto(p *types.ProcessPolicy) *sbv1.ProcessPolicy {
if p == nil {
return nil
}
return &sbv1.ProcessPolicy{
RunAsUser: p.RunAsUser,
RunAsGroup: p.RunAsGroup,
}
}
// --- SandboxPolicyRevision ---
// SandboxPolicyRevisionFromProto converts a proto SandboxPolicyRevision to an SDK SandboxPolicyRevision.
func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxPolicyRevision {
if r == nil {
return nil
}
return &types.SandboxPolicyRevision{
Version: r.GetVersion(),
PolicyHash: r.GetPolicyHash(),
Status: PolicyLoadStatusFromProto(r.GetStatus()),
LoadError: r.GetLoadError(),
CreatedAt: TimeFromMillis(r.GetCreatedAtMs()),
LoadedAt: TimeFromMillis(r.GetLoadedAtMs()),
Policy: SandboxPolicyFromProto(r.GetPolicy()),
}
}
// --- PolicyStatusResult ---
// PolicyStatusResultFromProto converts a proto GetSandboxPolicyStatusResponse to an SDK PolicyStatusResult.
func PolicyStatusResultFromProto(r *pb.GetSandboxPolicyStatusResponse) *types.PolicyStatusResult {
if r == nil {
return nil
}
result := &types.PolicyStatusResult{
ActiveVersion: r.GetActiveVersion(),
}
if rev := SandboxPolicyRevisionFromProto(r.GetRevision()); rev != nil {
result.Revision = *rev
}
return result
}
// --- ApproveResult ---
// ApproveResultFromProto converts a proto ApproveDraftChunkResponse to an SDK ApproveResult.
func ApproveResultFromProto(r *pb.ApproveDraftChunkResponse) *types.ApproveResult {
if r == nil {
return nil
}
return &types.ApproveResult{
PolicyVersion: r.GetPolicyVersion(),
PolicyHash: r.GetPolicyHash(),
}
}
// --- ApproveAllResult ---
// ApproveAllResultFromProto converts a proto ApproveAllDraftChunksResponse to an SDK ApproveAllResult.
func ApproveAllResultFromProto(r *pb.ApproveAllDraftChunksResponse) *types.ApproveAllResult {
if r == nil {
return nil
}
return &types.ApproveAllResult{
PolicyVersion: r.GetPolicyVersion(),
PolicyHash: r.GetPolicyHash(),
ChunksApproved: r.GetChunksApproved(),
ChunksSkipped: r.GetChunksSkipped(),
}
}
// --- UndoResult ---
// UndoResultFromProto converts a proto UndoDraftChunkResponse to an SDK UndoResult.
func UndoResultFromProto(r *pb.UndoDraftChunkResponse) *types.UndoResult {
if r == nil {
return nil
}
return &types.UndoResult{
PolicyVersion: r.GetPolicyVersion(),
PolicyHash: r.GetPolicyHash(),
}
}
// --- ClearResult ---
// ClearResultFromProto converts a proto ClearDraftChunksResponse to an SDK ClearResult.
func ClearResultFromProto(r *pb.ClearDraftChunksResponse) *types.ClearResult {
if r == nil {
return nil
}
return &types.ClearResult{
ChunksCleared: r.GetChunksCleared(),
}
}
// --- DraftHistoryEntry ---
// DraftHistoryEntryFromProto converts a proto DraftHistoryEntry to an SDK DraftHistoryEntry.
func DraftHistoryEntryFromProto(e *pb.DraftHistoryEntry) *types.DraftHistoryEntry {
if e == nil {
return nil
}
return &types.DraftHistoryEntry{
Timestamp: TimeFromMillis(e.GetTimestampMs()),
EventType: e.GetEventType(),
Description: e.GetDescription(),
ChunkID: e.GetChunkId(),
}
}
@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"time"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
)
// ProviderFromProto converts a proto Provider to an SDK Provider.
func ProviderFromProto(p *dm.Provider) *types.Provider {
if p == nil {
return nil
}
result := &types.Provider{
Type: p.GetType(),
Spec: types.ProviderSpec{
Config: CopyStringMap(p.GetConfig()),
ProfileWorkspace: p.GetProfileWorkspace(),
},
}
if m := p.GetMetadata(); m != nil {
result.ID = m.GetId()
result.Name = m.GetName()
result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs())
result.Labels = CopyStringMap(m.GetLabels())
result.Annotations = CopyStringMap(m.GetAnnotations())
result.ResourceVersion = m.GetResourceVersion()
result.Workspace = m.GetWorkspace()
result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs())
}
if expires := p.GetCredentialExpiresAtMs(); len(expires) > 0 {
result.Spec.CredentialExpiresAt = make(map[string]time.Time, len(expires))
for k, ms := range expires {
result.Spec.CredentialExpiresAt[k] = TimeFromMillis(ms)
}
}
if handles := p.GetCredentialHandles(); len(handles) > 0 {
result.Spec.CredentialHandles = make(map[string]types.CredentialHandle, len(handles))
for k, h := range handles {
result.Spec.CredentialHandles[k] = types.CredentialHandle{
Driver: h.GetDriver(),
Handle: h.GetHandle(),
Metadata: CopyStringMap(h.GetMetadata()),
}
}
}
return result
}
// ProviderToProto converts an SDK Provider to a proto Provider.
func ProviderToProto(p *types.Provider) *dm.Provider {
if p == nil {
return nil
}
result := &dm.Provider{
Metadata: &dm.ObjectMeta{
Id: p.ID,
Name: p.Name,
CreatedAtMs: MillisFromTime(p.CreatedAt),
Labels: CopyStringMap(p.Labels),
Annotations: CopyStringMap(p.Annotations),
ResourceVersion: p.ResourceVersion,
Workspace: p.Workspace,
DeletionTimestampMs: MillisFromTimePtr(p.DeletionTimestamp),
},
Type: p.Type,
Credentials: CopyStringMap(p.Spec.Credentials),
Config: CopyStringMap(p.Spec.Config),
ProfileWorkspace: p.Spec.ProfileWorkspace,
}
if len(p.Spec.CredentialExpiresAt) > 0 {
result.CredentialExpiresAtMs = make(map[string]int64, len(p.Spec.CredentialExpiresAt))
for k, t := range p.Spec.CredentialExpiresAt {
result.CredentialExpiresAtMs[k] = MillisFromTime(t)
}
}
if len(p.Spec.CredentialHandles) > 0 {
result.CredentialHandles = make(map[string]*dm.CredentialHandle, len(p.Spec.CredentialHandles))
for k, h := range p.Spec.CredentialHandles {
result.CredentialHandles[k] = &dm.CredentialHandle{
Driver: h.Driver,
Handle: h.Handle,
Metadata: CopyStringMap(h.Metadata),
}
}
}
return result
}
@@ -0,0 +1,174 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"testing"
"time"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProviderFromProto_Nil(t *testing.T) {
assert.Nil(t, ProviderFromProto(nil))
}
func TestProviderFromProto_Full(t *testing.T) {
proto := &dm.Provider{
Metadata: &dm.ObjectMeta{
Id: "prov-1",
Name: "claude-provider",
CreatedAtMs: 1700000000000,
Labels: map[string]string{"env": "prod"},
Annotations: map[string]string{"note": "test"},
ResourceVersion: 42,
Workspace: "default",
},
Type: "claude",
Credentials: map[string]string{"api_key": "secret"},
Config: map[string]string{"base_url": "https://api.example.com"},
ProfileWorkspace: "shared",
CredentialExpiresAtMs: map[string]int64{
"api_key": 1700003600000,
},
CredentialHandles: map[string]*dm.CredentialHandle{
"api_key": {
Driver: "vault",
Handle: "secret/data/claude",
Metadata: map[string]string{"version": "3"},
},
},
}
result := ProviderFromProto(proto)
require.NotNil(t, result)
assert.Equal(t, "prov-1", result.ID)
assert.Equal(t, "claude-provider", result.Name)
assert.Equal(t, "claude", result.Type)
assert.Equal(t, uint64(42), result.ResourceVersion)
assert.Equal(t, "default", result.Workspace)
assert.Equal(t, map[string]string{"env": "prod"}, result.Labels)
assert.Equal(t, map[string]string{"note": "test"}, result.Annotations)
assert.Equal(t, map[string]string{"base_url": "https://api.example.com"}, result.Spec.Config)
assert.Equal(t, "shared", result.Spec.ProfileWorkspace)
require.Len(t, result.Spec.CredentialExpiresAt, 1)
assert.False(t, result.Spec.CredentialExpiresAt["api_key"].IsZero())
require.Len(t, result.Spec.CredentialHandles, 1)
h := result.Spec.CredentialHandles["api_key"]
assert.Equal(t, "vault", h.Driver)
assert.Equal(t, "secret/data/claude", h.Handle)
assert.Equal(t, map[string]string{"version": "3"}, h.Metadata)
}
func TestProviderFromProto_NilMetadata(t *testing.T) {
proto := &dm.Provider{
Type: "openai",
Config: map[string]string{"key": "val"},
}
result := ProviderFromProto(proto)
require.NotNil(t, result)
assert.Equal(t, "", result.ID)
assert.Equal(t, "", result.Name)
assert.Equal(t, "openai", result.Type)
assert.Equal(t, map[string]string{"key": "val"}, result.Spec.Config)
}
func TestProviderFromProto_EmptyHandles(t *testing.T) {
proto := &dm.Provider{
Type: "test",
CredentialHandles: map[string]*dm.CredentialHandle{},
}
result := ProviderFromProto(proto)
require.NotNil(t, result)
assert.Nil(t, result.Spec.CredentialHandles)
}
func TestProviderToProto_Nil(t *testing.T) {
assert.Nil(t, ProviderToProto(nil))
}
func TestProviderToProto_Full(t *testing.T) {
expires := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
provider := &types.Provider{
ID: "prov-1",
Name: "test-provider",
Type: "claude",
Labels: map[string]string{"env": "dev"},
Annotations: map[string]string{"note": "x"},
ResourceVersion: 7,
Workspace: "ws-1",
Spec: types.ProviderSpec{
Credentials: map[string]string{"token": "abc"},
Config: map[string]string{"url": "https://example.com"},
ProfileWorkspace: "global",
CredentialExpiresAt: map[string]time.Time{"token": expires},
CredentialHandles: map[string]types.CredentialHandle{
"token": {
Driver: "k8s-secrets",
Handle: "ns/secret-name",
Metadata: map[string]string{"k": "v"},
},
},
},
}
result := ProviderToProto(provider)
require.NotNil(t, result)
assert.Equal(t, "prov-1", result.Metadata.Id)
assert.Equal(t, "test-provider", result.Metadata.Name)
assert.Equal(t, "claude", result.Type)
assert.Equal(t, "global", result.ProfileWorkspace)
assert.Equal(t, map[string]string{"token": "abc"}, result.Credentials)
assert.Equal(t, map[string]string{"url": "https://example.com"}, result.Config)
require.Len(t, result.CredentialExpiresAtMs, 1)
assert.Greater(t, result.CredentialExpiresAtMs["token"], int64(0))
require.Len(t, result.CredentialHandles, 1)
h := result.CredentialHandles["token"]
assert.Equal(t, "k8s-secrets", h.Driver)
assert.Equal(t, "ns/secret-name", h.Handle)
assert.Equal(t, map[string]string{"k": "v"}, h.Metadata)
}
func TestProviderRoundTrip(t *testing.T) {
original := &types.Provider{
ID: "rt-1",
Name: "roundtrip",
Type: "gitlab",
ResourceVersion: 3,
Workspace: "default",
Labels: map[string]string{"team": "infra"},
Spec: types.ProviderSpec{
Config: map[string]string{"url": "https://gitlab.com"},
ProfileWorkspace: "shared",
CredentialHandles: map[string]types.CredentialHandle{
"pat": {Driver: "vault", Handle: "secret/gitlab", Metadata: map[string]string{"ver": "1"}},
},
},
}
proto := ProviderToProto(original)
back := ProviderFromProto(proto)
assert.Equal(t, original.ID, back.ID)
assert.Equal(t, original.Name, back.Name)
assert.Equal(t, original.Type, back.Type)
assert.Equal(t, original.Workspace, back.Workspace)
assert.Equal(t, original.Labels, back.Labels)
assert.Equal(t, original.Spec.Config, back.Spec.Config)
assert.Equal(t, original.Spec.ProfileWorkspace, back.Spec.ProfileWorkspace)
assert.Equal(t, original.Spec.CredentialHandles, back.Spec.CredentialHandles)
}
@@ -0,0 +1,206 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"fmt"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
)
// SandboxFromProto converts a proto Sandbox to an SDK Sandbox.
func SandboxFromProto(s *pb.Sandbox) *types.Sandbox {
if s == nil {
return nil
}
result := &types.Sandbox{}
if m := s.GetMetadata(); m != nil {
result.ID = m.GetId()
result.Name = m.GetName()
result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs())
result.Labels = CopyStringMap(m.GetLabels())
result.Annotations = CopyStringMap(m.GetAnnotations())
result.ResourceVersion = m.GetResourceVersion()
result.Workspace = m.GetWorkspace()
result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs())
}
if spec := s.GetSpec(); spec != nil {
result.Spec = sandboxSpecFromProto(spec)
}
if status := s.GetStatus(); status != nil {
result.Status = sandboxStatusFromProto(status)
} else {
result.Status.Phase = types.SandboxUnknown
}
return result
}
func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec {
result := types.SandboxSpec{
LogLevel: spec.GetLogLevel(),
Environment: CopyStringMap(spec.GetEnvironment()),
Providers: CopyStringSlice(spec.GetProviders()),
Policy: SandboxPolicyFromProto(spec.GetPolicy()),
}
if tmpl := spec.GetTemplate(); tmpl != nil {
result.Template = &types.SandboxTemplate{
Image: tmpl.GetImage(),
RuntimeClassName: tmpl.GetRuntimeClassName(),
AgentSocket: tmpl.GetAgentSocket(),
Labels: CopyStringMap(tmpl.GetLabels()),
Annotations: CopyStringMap(tmpl.GetAnnotations()),
Environment: CopyStringMap(tmpl.GetEnvironment()),
Resources: structToMap(tmpl.GetResources()),
UserNamespaces: CopyBoolPtr(tmpl.UserNamespaces),
DriverConfig: structToMap(tmpl.GetDriverConfig()),
}
}
if rr := spec.GetResourceRequirements(); rr != nil {
if gpu := rr.GetGpu(); gpu != nil && gpu.Count != nil {
result.GPUCount = gpu.Count
}
}
return result
}
func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus {
result := types.SandboxStatus{
SandboxName: status.GetSandboxName(),
AgentPod: status.GetAgentPod(),
AgentFd: status.GetAgentFd(),
SandboxFd: status.GetSandboxFd(),
Phase: SandboxPhaseFromProto(status.GetPhase()),
CurrentPolicyVersion: status.GetCurrentPolicyVersion(),
}
for _, c := range status.GetConditions() {
result.Conditions = append(result.Conditions, types.SandboxCondition{
Type: c.GetType(),
Status: c.GetStatus(),
Reason: c.GetReason(),
Message: c.GetMessage(),
LastTransitionTime: c.GetLastTransitionTime(),
})
}
return result
}
// SandboxPhaseFromProto converts a proto SandboxPhase to an SDK SandboxPhase.
func SandboxPhaseFromProto(phase pb.SandboxPhase) types.SandboxPhase {
switch phase {
case pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING:
return types.SandboxProvisioning
case pb.SandboxPhase_SANDBOX_PHASE_READY:
return types.SandboxReady
case pb.SandboxPhase_SANDBOX_PHASE_ERROR:
return types.SandboxError
case pb.SandboxPhase_SANDBOX_PHASE_DELETING:
return types.SandboxDeleting
case pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN:
return types.SandboxUnknown
default:
return types.SandboxUnknown
}
}
// SandboxPhaseToProto converts an SDK SandboxPhase to a proto SandboxPhase.
func SandboxPhaseToProto(phase types.SandboxPhase) pb.SandboxPhase {
switch phase {
case types.SandboxProvisioning:
return pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING
case types.SandboxReady:
return pb.SandboxPhase_SANDBOX_PHASE_READY
case types.SandboxError:
return pb.SandboxPhase_SANDBOX_PHASE_ERROR
case types.SandboxDeleting:
return pb.SandboxPhase_SANDBOX_PHASE_DELETING
case types.SandboxUnknown:
return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN
default:
return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN
}
}
// SandboxToProto converts an SDK Sandbox to a proto Sandbox.
func SandboxToProto(s *types.Sandbox) (*pb.Sandbox, error) {
if s == nil {
return nil, nil
}
spec, err := SandboxSpecToProto(&s.Spec)
if err != nil {
return nil, fmt.Errorf("convert sandbox spec: %w", err)
}
return &pb.Sandbox{
Metadata: &dm.ObjectMeta{
Id: s.ID,
Name: s.Name,
CreatedAtMs: MillisFromTime(s.CreatedAt),
Labels: CopyStringMap(s.Labels),
Annotations: CopyStringMap(s.Annotations),
ResourceVersion: s.ResourceVersion,
Workspace: s.Workspace,
DeletionTimestampMs: MillisFromTimePtr(s.DeletionTimestamp),
},
Spec: spec,
}, nil
}
// SandboxSpecToProto converts an SDK SandboxSpec to a proto SandboxSpec.
func SandboxSpecToProto(spec *types.SandboxSpec) (*pb.SandboxSpec, error) {
if spec == nil {
return nil, nil
}
result := &pb.SandboxSpec{
LogLevel: spec.LogLevel,
Environment: CopyStringMap(spec.Environment),
Providers: CopyStringSlice(spec.Providers),
Policy: SandboxPolicyToProto(spec.Policy),
}
if spec.Template != nil {
resources, err := mapToStruct(spec.Template.Resources)
if err != nil {
return nil, fmt.Errorf("convert template resources: %w", err)
}
driverConfig, err := mapToStruct(spec.Template.DriverConfig)
if err != nil {
return nil, fmt.Errorf("convert template driver config: %w", err)
}
result.Template = &pb.SandboxTemplate{
Image: spec.Template.Image,
RuntimeClassName: spec.Template.RuntimeClassName,
AgentSocket: spec.Template.AgentSocket,
Labels: CopyStringMap(spec.Template.Labels),
Annotations: CopyStringMap(spec.Template.Annotations),
Environment: CopyStringMap(spec.Template.Environment),
Resources: resources,
UserNamespaces: CopyBoolPtr(spec.Template.UserNamespaces),
DriverConfig: driverConfig,
}
}
if spec.GPUCount != nil {
result.ResourceRequirements = &pb.ResourceRequirements{
Gpu: &pb.GpuResourceRequirements{
Count: spec.GPUCount,
},
}
}
return result, nil
}
@@ -0,0 +1,404 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"testing"
"time"
v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
func TestSandboxFromProto(t *testing.T) {
userNS := true
gpuCount := uint32(2)
proto := &pb.Sandbox{
Metadata: &dm.ObjectMeta{
Id: "sb-1",
Name: "my-sandbox",
CreatedAtMs: 1700000000000,
Labels: map[string]string{"env": "dev"},
Annotations: map[string]string{"owner": "team-a"},
ResourceVersion: 3,
Workspace: "prod",
DeletionTimestampMs: 1700000060000,
},
Spec: &pb.SandboxSpec{
LogLevel: "debug",
Environment: map[string]string{"FOO": "bar"},
Template: &pb.SandboxTemplate{
Image: "nvidia/sandbox:latest",
RuntimeClassName: "kata",
AgentSocket: "/var/run/agent.sock",
Labels: map[string]string{"app": "test"},
Annotations: map[string]string{"note": "hello"},
Environment: map[string]string{"TMPL_VAR": "val"},
UserNamespaces: &userNS,
},
Providers: []string{"claude", "github"},
ResourceRequirements: &pb.ResourceRequirements{
Gpu: &pb.GpuResourceRequirements{
Count: &gpuCount,
},
},
},
Status: &pb.SandboxStatus{
SandboxName: "sb-compute-1",
AgentPod: "agent-pod-xyz",
AgentFd: "fd-agent",
SandboxFd: "fd-sandbox",
Phase: pb.SandboxPhase_SANDBOX_PHASE_READY,
CurrentPolicyVersion: 7,
Conditions: []*pb.SandboxCondition{
{
Type: "Ready",
Status: "True",
Reason: "AllGood",
Message: "Sandbox is ready",
LastTransitionTime: "2024-01-01T00:00:00Z",
},
},
},
}
s := SandboxFromProto(proto)
require.NotNil(t, s)
assert.Equal(t, "sb-1", s.ID)
assert.Equal(t, "my-sandbox", s.Name)
assert.Equal(t, time.UnixMilli(1700000000000).UTC(), s.CreatedAt)
assert.Equal(t, map[string]string{"env": "dev"}, s.Labels)
assert.Equal(t, map[string]string{"owner": "team-a"}, s.Annotations)
assert.Equal(t, uint64(3), s.ResourceVersion)
assert.Equal(t, "prod", s.Workspace)
require.NotNil(t, s.DeletionTimestamp)
assert.Equal(t, time.UnixMilli(1700000060000).UTC(), *s.DeletionTimestamp)
// Spec
assert.Equal(t, "debug", s.Spec.LogLevel)
assert.Equal(t, map[string]string{"FOO": "bar"}, s.Spec.Environment)
assert.Equal(t, []string{"claude", "github"}, s.Spec.Providers)
require.NotNil(t, s.Spec.GPUCount)
assert.Equal(t, uint32(2), *s.Spec.GPUCount)
// Template
require.NotNil(t, s.Spec.Template)
assert.Equal(t, "nvidia/sandbox:latest", s.Spec.Template.Image)
assert.Equal(t, "kata", s.Spec.Template.RuntimeClassName)
assert.Equal(t, "/var/run/agent.sock", s.Spec.Template.AgentSocket)
assert.Equal(t, map[string]string{"app": "test"}, s.Spec.Template.Labels)
assert.Equal(t, map[string]string{"note": "hello"}, s.Spec.Template.Annotations)
assert.Equal(t, map[string]string{"TMPL_VAR": "val"}, s.Spec.Template.Environment)
require.NotNil(t, s.Spec.Template.UserNamespaces)
assert.True(t, *s.Spec.Template.UserNamespaces)
// Status
assert.Equal(t, "sb-compute-1", s.Status.SandboxName)
assert.Equal(t, "agent-pod-xyz", s.Status.AgentPod)
assert.Equal(t, "fd-agent", s.Status.AgentFd)
assert.Equal(t, "fd-sandbox", s.Status.SandboxFd)
assert.Equal(t, v1.SandboxReady, s.Status.Phase)
assert.Equal(t, uint32(7), s.Status.CurrentPolicyVersion)
require.Len(t, s.Status.Conditions, 1)
assert.Equal(t, "Ready", s.Status.Conditions[0].Type)
assert.Equal(t, "True", s.Status.Conditions[0].Status)
assert.Equal(t, "AllGood", s.Status.Conditions[0].Reason)
assert.Equal(t, "Sandbox is ready", s.Status.Conditions[0].Message)
assert.Equal(t, "2024-01-01T00:00:00Z", s.Status.Conditions[0].LastTransitionTime)
}
func TestSandboxFromProto_NilFields(t *testing.T) {
proto := &pb.Sandbox{}
s := SandboxFromProto(proto)
require.NotNil(t, s)
assert.Empty(t, s.ID)
assert.Empty(t, s.Name)
assert.True(t, s.CreatedAt.IsZero())
assert.Nil(t, s.Spec.Template)
assert.Nil(t, s.Spec.GPUCount)
assert.Equal(t, v1.SandboxUnknown, s.Status.Phase)
}
func TestSandboxFromProto_Nil(t *testing.T) {
s := SandboxFromProto(nil)
assert.Nil(t, s)
}
func TestSandboxPhaseFromProto(t *testing.T) {
tests := []struct {
proto pb.SandboxPhase
expected v1.SandboxPhase
}{
{pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING, v1.SandboxProvisioning},
{pb.SandboxPhase_SANDBOX_PHASE_READY, v1.SandboxReady},
{pb.SandboxPhase_SANDBOX_PHASE_ERROR, v1.SandboxError},
{pb.SandboxPhase_SANDBOX_PHASE_DELETING, v1.SandboxDeleting},
{pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN, v1.SandboxUnknown},
{pb.SandboxPhase_SANDBOX_PHASE_UNSPECIFIED, v1.SandboxUnknown},
{pb.SandboxPhase(999), v1.SandboxUnknown},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, SandboxPhaseFromProto(tt.proto), "phase %v", tt.proto)
}
}
func TestSandboxPhaseToProto(t *testing.T) {
tests := []struct {
sdk v1.SandboxPhase
expected pb.SandboxPhase
}{
{v1.SandboxProvisioning, pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING},
{v1.SandboxReady, pb.SandboxPhase_SANDBOX_PHASE_READY},
{v1.SandboxError, pb.SandboxPhase_SANDBOX_PHASE_ERROR},
{v1.SandboxDeleting, pb.SandboxPhase_SANDBOX_PHASE_DELETING},
{v1.SandboxUnknown, pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN},
{v1.SandboxPhase("bogus"), pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, SandboxPhaseToProto(tt.sdk), "phase %v", tt.sdk)
}
}
func TestSandboxToProto(t *testing.T) {
userNS := true
gpuCount := uint32(4)
delTime := time.UnixMilli(1700000060000).UTC()
s := &v1.Sandbox{
ID: "sb-1",
Name: "my-sandbox",
CreatedAt: time.UnixMilli(1700000000000).UTC(),
Labels: map[string]string{"env": "dev"},
Annotations: map[string]string{"owner": "team-a"},
ResourceVersion: 3,
Workspace: "prod",
DeletionTimestamp: &delTime,
Spec: v1.SandboxSpec{
LogLevel: "info",
Environment: map[string]string{"KEY": "val"},
Template: &v1.SandboxTemplate{
Image: "img:v1",
RuntimeClassName: "runc",
AgentSocket: "/sock",
Labels: map[string]string{"l": "v"},
Annotations: map[string]string{"a": "v"},
Environment: map[string]string{"E": "V"},
UserNamespaces: &userNS,
},
Providers: []string{"prov-a"},
GPUCount: &gpuCount,
},
}
p, err := SandboxToProto(s)
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.Metadata)
assert.Equal(t, "sb-1", p.Metadata.Id)
assert.Equal(t, "my-sandbox", p.Metadata.Name)
assert.Equal(t, int64(1700000000000), p.Metadata.CreatedAtMs)
assert.Equal(t, map[string]string{"env": "dev"}, p.Metadata.Labels)
assert.Equal(t, map[string]string{"owner": "team-a"}, p.Metadata.Annotations)
assert.Equal(t, uint64(3), p.Metadata.ResourceVersion)
assert.Equal(t, "prod", p.Metadata.Workspace)
assert.Equal(t, int64(1700000060000), p.Metadata.DeletionTimestampMs)
require.NotNil(t, p.Spec)
assert.Equal(t, "info", p.Spec.LogLevel)
assert.Equal(t, map[string]string{"KEY": "val"}, p.Spec.Environment)
assert.Equal(t, []string{"prov-a"}, p.Spec.Providers)
require.NotNil(t, p.Spec.ResourceRequirements)
require.NotNil(t, p.Spec.ResourceRequirements.Gpu)
assert.Equal(t, uint32(4), p.Spec.ResourceRequirements.Gpu.GetCount())
require.NotNil(t, p.Spec.Template)
assert.Equal(t, "img:v1", p.Spec.Template.Image)
assert.Equal(t, "runc", p.Spec.Template.RuntimeClassName)
assert.Equal(t, "/sock", p.Spec.Template.AgentSocket)
assert.Equal(t, map[string]string{"l": "v"}, p.Spec.Template.Labels)
assert.Equal(t, map[string]string{"a": "v"}, p.Spec.Template.Annotations)
assert.Equal(t, map[string]string{"E": "V"}, p.Spec.Template.Environment)
require.NotNil(t, p.Spec.Template.UserNamespaces)
assert.True(t, *p.Spec.Template.UserNamespaces)
}
func TestSandboxToProto_Nil(t *testing.T) {
p, err := SandboxToProto(nil)
require.NoError(t, err)
assert.Nil(t, p)
}
func TestSandboxToProto_NilTemplate(t *testing.T) {
s := &v1.Sandbox{
Spec: v1.SandboxSpec{
LogLevel: "warn",
},
}
p, err := SandboxToProto(s)
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.Spec)
assert.Nil(t, p.Spec.Template)
assert.Nil(t, p.Spec.ResourceRequirements)
}
func TestSandboxRoundTrip(t *testing.T) {
userNS := false
gpuCount := uint32(1)
rtDelTime := time.UnixMilli(1700000090000).UTC()
original := &v1.Sandbox{
ID: "sb-rt",
Name: "round-trip",
CreatedAt: time.UnixMilli(1700000000000).UTC(),
Labels: map[string]string{"team": "platform"},
Annotations: map[string]string{"note": "rt-test"},
ResourceVersion: 10,
Workspace: "staging",
DeletionTimestamp: &rtDelTime,
Spec: v1.SandboxSpec{
LogLevel: "trace",
Environment: map[string]string{"A": "B"},
Template: &v1.SandboxTemplate{
Image: "img:rt",
UserNamespaces: &userNS,
},
Providers: []string{"p1", "p2"},
GPUCount: &gpuCount,
Policy: &v1.SandboxPolicy{
Version: 3,
Filesystem: &v1.FilesystemPolicy{
IncludeWorkdir: true,
ReadOnly: []string{"/etc", "/usr/share"},
ReadWrite: []string{"/tmp"},
},
Landlock: &v1.LandlockPolicy{
Compatibility: "best_effort",
},
Process: &v1.ProcessPolicy{
RunAsUser: "sandbox",
RunAsGroup: "sandbox-group",
},
NetworkPolicies: map[string]v1.NetworkPolicyRule{
"web": {
Name: "web",
Endpoints: []v1.PolicyNetworkEndpoint{
{Host: "api.example.com", Port: 443, Protocol: "rest"},
},
},
},
},
},
}
p, err := SandboxToProto(original)
require.NoError(t, err)
back := SandboxFromProto(p)
assert.Equal(t, original.ID, back.ID)
assert.Equal(t, original.Name, back.Name)
assert.Equal(t, original.CreatedAt, back.CreatedAt)
assert.Equal(t, original.Labels, back.Labels)
assert.Equal(t, original.Annotations, back.Annotations)
assert.Equal(t, original.ResourceVersion, back.ResourceVersion)
assert.Equal(t, original.Workspace, back.Workspace)
require.NotNil(t, back.DeletionTimestamp)
assert.Equal(t, *original.DeletionTimestamp, *back.DeletionTimestamp)
assert.Equal(t, original.Spec.LogLevel, back.Spec.LogLevel)
assert.Equal(t, original.Spec.Environment, back.Spec.Environment)
assert.Equal(t, original.Spec.Providers, back.Spec.Providers)
require.NotNil(t, back.Spec.GPUCount)
assert.Equal(t, *original.Spec.GPUCount, *back.Spec.GPUCount)
require.NotNil(t, back.Spec.Template)
assert.Equal(t, original.Spec.Template.Image, back.Spec.Template.Image)
require.NotNil(t, back.Spec.Template.UserNamespaces)
assert.Equal(t, *original.Spec.Template.UserNamespaces, *back.Spec.Template.UserNamespaces)
// Policy round-trip
require.NotNil(t, back.Spec.Policy)
assert.Equal(t, uint32(3), back.Spec.Policy.Version)
require.NotNil(t, back.Spec.Policy.Filesystem)
assert.True(t, back.Spec.Policy.Filesystem.IncludeWorkdir)
assert.Equal(t, []string{"/etc", "/usr/share"}, back.Spec.Policy.Filesystem.ReadOnly)
assert.Equal(t, []string{"/tmp"}, back.Spec.Policy.Filesystem.ReadWrite)
require.NotNil(t, back.Spec.Policy.Landlock)
assert.Equal(t, "best_effort", back.Spec.Policy.Landlock.Compatibility)
require.NotNil(t, back.Spec.Policy.Process)
assert.Equal(t, "sandbox", back.Spec.Policy.Process.RunAsUser)
assert.Equal(t, "sandbox-group", back.Spec.Policy.Process.RunAsGroup)
require.Len(t, back.Spec.Policy.NetworkPolicies, 1)
webRule, ok := back.Spec.Policy.NetworkPolicies["web"]
require.True(t, ok)
assert.Equal(t, "web", webRule.Name)
require.Len(t, webRule.Endpoints, 1)
assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host)
}
func TestSandboxSpecToProto(t *testing.T) {
gpuCount := uint32(3)
spec := &v1.SandboxSpec{
LogLevel: "debug",
Environment: map[string]string{"X": "Y"},
Template: &v1.SandboxTemplate{
Image: "img:spec",
},
Providers: []string{"prov"},
GPUCount: &gpuCount,
Policy: &v1.SandboxPolicy{
Version: 2,
Filesystem: &v1.FilesystemPolicy{
ReadOnly: []string{"/etc"},
},
},
}
p, err := SandboxSpecToProto(spec)
require.NoError(t, err)
require.NotNil(t, p)
assert.Equal(t, "debug", p.LogLevel)
assert.Equal(t, map[string]string{"X": "Y"}, p.Environment)
assert.Equal(t, []string{"prov"}, p.Providers)
require.NotNil(t, p.ResourceRequirements)
assert.Equal(t, uint32(3), p.ResourceRequirements.Gpu.GetCount())
require.NotNil(t, p.Template)
assert.Equal(t, "img:spec", p.Template.Image)
// Policy conversion
require.NotNil(t, p.Policy)
assert.Equal(t, uint32(2), p.Policy.Version)
require.NotNil(t, p.Policy.Filesystem)
assert.Equal(t, []string{"/etc"}, p.Policy.Filesystem.ReadOnly)
}
func TestSandboxSpecToProto_Nil(t *testing.T) {
p, err := SandboxSpecToProto(nil)
require.NoError(t, err)
assert.Nil(t, p)
}
func TestSandboxSpecToProto_InvalidMapReturnsError(t *testing.T) {
spec := &v1.SandboxSpec{
Template: &v1.SandboxTemplate{
Image: "img:v1",
Resources: map[string]any{"bad": make(chan int)},
},
}
p, err := SandboxSpecToProto(spec)
require.Error(t, err, "SandboxSpecToProto must return an error for unconvertible map values")
assert.Nil(t, p)
assert.Contains(t, err.Error(), "convert template resources")
}
// Verify proto import is used (suppress unused import warning).
var _ = proto.Marshal
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import "time"
// TimeFromMillis converts a millisecond epoch timestamp to time.Time.
// A zero value returns the zero time.
func TimeFromMillis(ms int64) time.Time {
if ms == 0 {
return time.Time{}
}
return time.UnixMilli(ms).UTC()
}
// MillisFromTime converts a time.Time to a millisecond epoch timestamp.
// A zero time returns 0.
func MillisFromTime(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UnixMilli()
}
// TimeFromMillisPtr converts a millisecond epoch timestamp to a *time.Time.
// A zero value returns nil (the resource is not being deleted).
func TimeFromMillisPtr(ms int64) *time.Time {
if ms == 0 {
return nil
}
t := time.UnixMilli(ms).UTC()
return &t
}
// MillisFromTimePtr converts a *time.Time to a millisecond epoch timestamp.
// A nil pointer returns 0.
func MillisFromTimePtr(t *time.Time) int64 {
if t == nil {
return 0
}
return t.UnixMilli()
}
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package converter
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestTimeFromMillis(t *testing.T) {
ms := int64(1719475200000) // 2024-06-27T08:00:00Z
tm := TimeFromMillis(ms)
assert.Equal(t, 2024, tm.Year())
assert.Equal(t, time.June, tm.Month())
assert.Equal(t, 27, tm.Day())
}
func TestTimeFromMillis_Zero(t *testing.T) {
tm := TimeFromMillis(0)
assert.True(t, tm.IsZero())
}
func TestMillisFromTime(t *testing.T) {
tm := time.Date(2024, time.June, 27, 12, 0, 0, 0, time.UTC)
ms := MillisFromTime(tm)
assert.Equal(t, int64(1719489600000), ms)
}
func TestMillisFromTime_Zero(t *testing.T) {
ms := MillisFromTime(time.Time{})
assert.Equal(t, int64(0), ms)
}
func TestRoundTrip(t *testing.T) {
original := time.Date(2025, time.March, 15, 10, 30, 0, 0, time.UTC)
ms := MillisFromTime(original)
restored := TimeFromMillis(ms)
assert.Equal(t, original.Unix(), restored.Unix())
}
func TestTimeFromMillisPtr_NonZero(t *testing.T) {
ms := int64(1719475200000)
tp := TimeFromMillisPtr(ms)
assert.NotNil(t, tp)
assert.Equal(t, 2024, tp.Year())
}
func TestTimeFromMillisPtr_Zero(t *testing.T) {
tp := TimeFromMillisPtr(0)
assert.Nil(t, tp)
}
func TestMillisFromTimePtr_NonNil(t *testing.T) {
tm := time.Date(2024, time.June, 27, 12, 0, 0, 0, time.UTC)
ms := MillisFromTimePtr(&tm)
assert.Equal(t, int64(1719489600000), ms)
}
func TestMillisFromTimePtr_Nil(t *testing.T) {
ms := MillisFromTimePtr(nil)
assert.Equal(t, int64(0), ms)
}
func TestPtrRoundTrip(t *testing.T) {
original := time.Date(2025, time.March, 15, 10, 30, 0, 0, time.UTC)
ms := MillisFromTimePtr(&original)
restored := TimeFromMillisPtr(ms)
assert.NotNil(t, restored)
assert.Equal(t, original.Unix(), restored.Unix())
}
+96
View File
@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package grpc provides gRPC connection setup utilities.
package grpc
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// TLSParams holds TLS settings without importing the v1 package.
type TLSParams struct {
CertFile string
KeyFile string
CAFile string
Insecure bool
}
// NewConnection creates a gRPC client connection.
// The address may include an http:// or https:// scheme (as written by the
// upstream gateway). The scheme drives transport selection: http:// uses
// plaintext, https:// or no scheme uses TLS.
func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCredentials) (*grpc.ClientConn, error) {
usePlaintext := false
if strings.HasPrefix(address, "http://") {
usePlaintext = true
address = strings.TrimPrefix(address, "http://")
} else {
address = strings.TrimPrefix(address, "https://")
}
opts := []grpc.DialOption{}
if usePlaintext {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else if tlsCfg != nil {
creds, err := buildTLSCredentials(tlsCfg)
if err != nil {
return nil, fmt.Errorf("tls config: %w", err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12})))
}
if auth != nil {
if usePlaintext && auth.RequireTransportSecurity() {
return nil, fmt.Errorf("grpc connect: auth provider requires transport security but address uses plaintext (http://)")
}
opts = append(opts, grpc.WithPerRPCCredentials(auth))
}
conn, err := grpc.NewClient(address, opts...)
if err != nil {
return nil, fmt.Errorf("grpc connect: %w", err)
}
return conn, nil
}
func buildTLSCredentials(cfg *TLSParams) (credentials.TransportCredentials, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.Insecure, //nolint:gosec // user-requested skip for dev gateways
}
if cfg.CAFile != "" {
caCert, err := os.ReadFile(cfg.CAFile)
if err != nil {
return nil, fmt.Errorf("read CA file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("invalid CA certificate")
}
tlsConfig.RootCAs = pool
}
if cfg.CertFile != "" && cfg.KeyFile != "" {
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
if err != nil {
return nil, fmt.Errorf("load client cert: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
} else if cfg.CertFile != "" || cfg.KeyFile != "" {
return nil, fmt.Errorf("both CertFile and KeyFile must be provided for client certificate authentication")
}
return credentials.NewTLS(tlsConfig), nil
}
@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package grpc
import (
"context"
"net"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestNewConnectionHTTPSchemeUsesPlaintext(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() { _ = lis.Close() }()
srv := grpc.NewServer()
go func() { _ = srv.Serve(lis) }()
defer srv.Stop()
conn, err := NewConnection("http://"+lis.Addr().String(), nil, nil)
if err != nil {
t.Fatalf("NewConnection with http:// scheme failed: %v", err)
}
defer func() { _ = conn.Close() }()
}
func TestNewConnectionHTTPSSchemeUsesTLS(t *testing.T) {
// https:// with nil TLS config should default to system TLS.
// We cannot dial a real TLS server here, but we can verify the
// connection is created (it will fail on handshake, not on dial).
conn, err := NewConnection("https://127.0.0.1:1", nil, nil)
if err != nil {
t.Fatalf("NewConnection with https:// scheme should not fail on create: %v", err)
}
defer func() { _ = conn.Close() }()
}
func TestNewConnectionNoSchemeUsesTLS(t *testing.T) {
conn, err := NewConnection("127.0.0.1:1", nil, nil)
if err != nil {
t.Fatalf("NewConnection without scheme should not fail on create: %v", err)
}
defer func() { _ = conn.Close() }()
}
func TestNewConnectionInsecureTLSConfig(t *testing.T) {
// Insecure: true means TLS with InsecureSkipVerify, not plaintext.
// We can verify the connection is created (handshake will fail since
// the server is not TLS, but NewClient itself should succeed).
conn, err := NewConnection("127.0.0.1:1", &TLSParams{Insecure: true}, nil)
if err != nil {
t.Fatalf("NewConnection with Insecure TLS config failed: %v", err)
}
defer func() { _ = conn.Close() }()
}
func TestNewConnectionHTTPWithSecureAuthRejects(t *testing.T) {
auth := &testTokenAuth{token: "dev-token", requireSecurity: true}
_, err := NewConnection("http://127.0.0.1:1", nil, auth)
if err == nil {
t.Fatal("expected error when using http:// with auth that requires transport security")
}
}
func TestNewConnectionHTTPWithInsecureAuth(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() { _ = lis.Close() }()
srv := grpc.NewServer(grpc.Creds(insecure.NewCredentials()))
go func() { _ = srv.Serve(lis) }()
defer srv.Stop()
auth := &testTokenAuth{token: "dev-token", requireSecurity: false}
conn, err := NewConnection("http://"+lis.Addr().String(), nil, auth)
if err != nil {
t.Fatalf("NewConnection with http:// + insecure auth failed: %v", err)
}
defer func() { _ = conn.Close() }()
}
type testTokenAuth struct {
token string
requireSecurity bool
}
func (a *testTokenAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return map[string]string{"authorization": "Bearer " + a.token}, nil
}
func (a *testTokenAuth) RequireTransportSecurity() bool {
return a.requireSecurity
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// Logger defines structured logging for the SDK. Compatible with logr.Logger
// and slog.Logger adapters.
type Logger = types.Logger
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// CreateOptions configures resource creation.
type CreateOptions = types.CreateOptions
// GetOptions configures resource retrieval.
type GetOptions = types.GetOptions
// ListOptions configures resource listing with pagination and filtering.
type ListOptions = types.ListOptions
// DeleteOptions configures resource deletion.
type DeleteOptions = types.DeleteOptions
// UpdateOptions configures resource updates.
type UpdateOptions = types.UpdateOptions
// WatchOptions configures watch behavior.
type WatchOptions = types.WatchOptions
// WaitOptions configures wait behavior. Use context for timeout control.
type WaitOptions = types.WaitOptions
// ExecOptions configures command execution.
type ExecOptions = types.ExecOptions
+173
View File
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// SandboxPolicy is the top-level security policy configuration for a sandbox.
type SandboxPolicy = types.SandboxPolicy
// FilesystemPolicy controls which directories the sandbox can access.
type FilesystemPolicy = types.FilesystemPolicy
// LandlockPolicy configures the Linux Landlock LSM.
type LandlockPolicy = types.LandlockPolicy
// ProcessPolicy controls the user and group identity for sandboxed processes.
type ProcessPolicy = types.ProcessPolicy
// PolicyChunk represents a single proposed policy change in the draft inbox.
type PolicyChunk = types.PolicyChunk
// DraftPolicy contains the full draft policy state returned by GetDraft.
type DraftPolicy = types.DraftPolicy
// PolicyStatusResult contains the status of a sandbox's policy.
type PolicyStatusResult = types.PolicyStatusResult
// SandboxPolicyRevision represents a versioned policy revision for a sandbox.
type SandboxPolicyRevision = types.SandboxPolicyRevision
// PolicyLoadStatus represents the load state of a policy revision.
type PolicyLoadStatus = types.PolicyLoadStatus
// PolicyLoadStatus constants re-exported from types package.
const (
PolicyLoadStatusUnspecified = types.PolicyLoadStatusUnspecified
PolicyLoadStatusPending = types.PolicyLoadStatusPending
PolicyLoadStatusLoaded = types.PolicyLoadStatusLoaded
PolicyLoadStatusFailed = types.PolicyLoadStatusFailed
PolicyLoadStatusSuperseded = types.PolicyLoadStatusSuperseded
)
// ApproveResult contains the result of approving a single draft chunk.
type ApproveResult = types.ApproveResult
// ApproveAllResult contains the result of approving all draft chunks.
type ApproveAllResult = types.ApproveAllResult
// UndoResult contains the result of undoing a draft chunk approval.
type UndoResult = types.UndoResult
// ClearResult contains the result of clearing all draft chunks.
type ClearResult = types.ClearResult
// DraftHistoryEntry represents a single event in the draft policy history.
type DraftHistoryEntry = types.DraftHistoryEntry
// GetDraftOption configures a GetDraft call.
type GetDraftOption = types.GetDraftOption
// WithStatusFilter filters draft chunks by approval status.
var WithStatusFilter = types.WithStatusFilter
// ApproveAllOption configures an ApproveAllDraftChunks call.
type ApproveAllOption = types.ApproveAllOption
// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval.
var WithIncludeSecurityFlagged = types.WithIncludeSecurityFlagged
// GetStatusOption configures a GetStatus call.
type GetStatusOption = types.GetStatusOption
// WithVersion queries a specific policy version instead of the latest.
var WithVersion = types.WithVersion
// ListPolicyOption configures a List call.
type ListPolicyOption = types.ListPolicyOption
// WithLimit sets the maximum number of revisions to return.
var WithLimit = types.WithLimit
// WithOffset sets the pagination offset.
var WithOffset = types.WithOffset
// PolicyInterface defines operations for managing sandbox policy drafts,
// approvals, and revision history.
type PolicyInterface interface {
// GetDraft retrieves the current draft policy for a sandbox, including
// all pending, approved, and rejected chunks. Use WithStatusFilter to
// return only chunks matching a specific status.
//
// Errors: NotFound if the sandbox does not exist; InvalidArgument if the
// sandbox name is empty; Unimplemented by the fake client.
GetDraft(ctx context.Context, workspace, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error)
// ApproveDraftChunk approves a single pending draft chunk, merging
// its proposed rule into the active policy.
//
// Errors: NotFound if the sandbox or chunk does not exist;
// InvalidArgument if the sandbox name or chunk ID is empty;
// Conflict if the chunk has already been approved or rejected;
// Unimplemented by the fake client.
ApproveDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*ApproveResult, error)
// RejectDraftChunk rejects a single pending draft chunk with an
// optional reason that is fed to future LLM analysis context.
//
// Errors: NotFound if the sandbox or chunk does not exist;
// InvalidArgument if the sandbox name or chunk ID is empty;
// Conflict if the chunk has already been approved or rejected;
// Unimplemented by the fake client.
RejectDraftChunk(ctx context.Context, workspace, sandboxName, chunkID, reason string) error
// ApproveAllDraftChunks approves all pending draft chunks at once.
// By default, security-flagged chunks are skipped. Use
// WithIncludeSecurityFlagged to include them.
//
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
// the sandbox name is empty; Unimplemented by the fake client.
ApproveAllDraftChunks(ctx context.Context, workspace, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error)
// ClearDraftChunks removes all pending draft chunks for a sandbox.
//
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
// the sandbox name is empty; Unimplemented by the fake client.
ClearDraftChunks(ctx context.Context, workspace, sandboxName string) (*ClearResult, error)
// GetDraftHistory returns the chronological decision history for a
// sandbox's draft policy (approvals, rejections, edits, undos, clears).
//
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
// the sandbox name is empty; Unimplemented by the fake client.
GetDraftHistory(ctx context.Context, workspace, sandboxName string) ([]DraftHistoryEntry, error)
// GetStatus retrieves the policy status for a sandbox, including the
// queried revision and the active version. Use WithVersion to query a
// specific version instead of the latest.
//
// Errors: NotFound if the sandbox or requested version does not exist;
// InvalidArgument if the sandbox name is empty;
// Unimplemented by the fake client.
GetStatus(ctx context.Context, workspace, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error)
// List returns policy revisions for a sandbox, ordered by version.
// Use WithLimit and WithOffset for pagination.
//
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
// the sandbox name is empty; Unimplemented by the fake client.
List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error)
// EditDraftChunk replaces the proposed rule of a pending draft chunk
// with the given network policy rule.
//
// Errors: NotFound if the sandbox or chunk does not exist;
// InvalidArgument if the sandbox name, chunk ID, or proposed rule is
// empty/nil; Conflict if the chunk is not in a pending state;
// Unimplemented by the fake client.
EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error
// UndoDraftChunk reverses a previously approved chunk, removing its
// merged rule from the active policy.
//
// Errors: NotFound if the sandbox or chunk does not exist;
// InvalidArgument if the sandbox name or chunk ID is empty;
// Conflict if the chunk has not been approved;
// Unimplemented by the fake client.
UndoDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*UndoResult, error)
}
+70
View File
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// ProviderProfile represents a provider type template.
type ProviderProfile = types.ProviderProfile
// ProfileCredential defines a single credential required by a provider profile.
type ProfileCredential = types.ProfileCredential
// ProfileCategory classifies a provider profile.
type ProfileCategory = types.ProfileCategory
// NetworkEndpoint describes a network endpoint provided by a profile.
type NetworkEndpoint = types.NetworkEndpoint
// NetworkBinary describes a binary artifact provided by a profile.
type NetworkBinary = types.NetworkBinary
// ProfileDiscovery holds local discovery configuration for a profile.
type ProfileDiscovery = types.ProfileDiscovery
// ProfileImportItem is an item submitted for profile import or lint validation.
type ProfileImportItem = types.ProfileImportItem
// ProfileDiagnostic is a validation finding from Import, Update, or Lint.
type ProfileDiagnostic = types.ProfileDiagnostic
// ImportResult holds the result of a profile import operation.
type ImportResult = types.ImportResult
// UpdateResult holds the result of a profile update operation.
type UpdateResult = types.UpdateResult
// LintResult holds the result of a profile lint operation.
type LintResult = types.LintResult
// ProfileCategory values.
const (
ProfileCategoryOther = types.ProfileCategoryOther
ProfileCategoryInference = types.ProfileCategoryInference
ProfileCategoryAgent = types.ProfileCategoryAgent
ProfileCategorySourceControl = types.ProfileCategorySourceControl
ProfileCategoryMessaging = types.ProfileCategoryMessaging
ProfileCategoryData = types.ProfileCategoryData
ProfileCategoryKnowledge = types.ProfileCategoryKnowledge
)
// ProfileInterface defines operations for managing provider profiles.
type ProfileInterface interface {
// List returns all provider profiles.
List(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error)
// Get retrieves a provider profile by ID.
Get(ctx context.Context, workspace, id string) (*ProviderProfile, error)
// Import submits profiles for import and returns the result with diagnostics.
Import(ctx context.Context, workspace string, items []ProfileImportItem) (*ImportResult, error)
// Update replaces an existing profile identified by ID and expected resource version.
Update(ctx context.Context, workspace, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error)
// Lint validates profiles without persisting them and returns diagnostics.
Lint(ctx context.Context, workspace string, items []ProfileImportItem) (*LintResult, error)
// Delete removes a provider profile by ID. Returns true if deleted.
Delete(ctx context.Context, workspace, id string) (bool, error)
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// Provider represents an AI provider registration.
type Provider = types.Provider
// ProviderSpec holds provider-specific configuration and credentials.
type ProviderSpec = types.ProviderSpec
// ProviderInterface defines CRUD and Ensure operations on providers,
// plus sub-client accessors for profiles and credential refresh.
type ProviderInterface interface {
Create(ctx context.Context, workspace string, provider *Provider) (*Provider, error)
Get(ctx context.Context, workspace, name string) (*Provider, error)
List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error)
Update(ctx context.Context, workspace string, provider *Provider) (*Provider, error)
Delete(ctx context.Context, workspace, name string) error
Ensure(ctx context.Context, workspace string, provider *Provider) (*Provider, error)
Profiles() ProfileInterface
Refresh() RefreshInterface
}
+42
View File
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// RefreshStrategy describes how credentials are refreshed.
type RefreshStrategy = types.RefreshStrategy
// RefreshStatus reports the current state of credential refresh for a provider credential.
type RefreshStatus = types.RefreshStatus
// RefreshConfig holds configuration parameters for credential refresh.
type RefreshConfig = types.RefreshConfig
// RefreshStrategy values.
const (
RefreshStrategyStatic = types.RefreshStrategyStatic
RefreshStrategyExternal = types.RefreshStrategyExternal
RefreshStrategyOAuth2RefreshToken = types.RefreshStrategyOAuth2RefreshToken
RefreshStrategyOAuth2ClientCredentials = types.RefreshStrategyOAuth2ClientCredentials
RefreshStrategyGoogleServiceAccountJWT = types.RefreshStrategyGoogleServiceAccountJWT
RefreshStrategyAWSStsAssumeRole = types.RefreshStrategyAWSStsAssumeRole
)
// RefreshInterface defines operations for managing provider credential refresh.
type RefreshInterface interface {
// GetStatus returns the refresh status for a provider's credential.
// If credentialKey is empty, statuses for all credentials are returned.
GetStatus(ctx context.Context, workspace, provider, credentialKey string) ([]*RefreshStatus, error)
// Configure sets up credential refresh for a provider credential.
Configure(ctx context.Context, workspace string, config *RefreshConfig) (*RefreshStatus, error)
// Rotate triggers an immediate credential rotation.
Rotate(ctx context.Context, workspace, provider, credentialKey string) (*RefreshStatus, error)
// Delete removes credential refresh configuration. Returns true if deleted.
Delete(ctx context.Context, workspace, provider, credentialKey string) (bool, error)
}
+73
View File
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// Sandbox represents a sandbox instance.
type Sandbox = types.Sandbox
// SandboxSpec holds the desired state of a sandbox.
type SandboxSpec = types.SandboxSpec
// SandboxTemplate defines the container template for a sandbox.
type SandboxTemplate = types.SandboxTemplate
// SandboxStatus holds the observed state of a sandbox.
type SandboxStatus = types.SandboxStatus
// SandboxCondition describes an observed condition of a sandbox.
type SandboxCondition = types.SandboxCondition
// AttachProviderResult holds the result of attaching a provider to a sandbox.
type AttachProviderResult = types.AttachProviderResult
// DetachProviderResult holds the result of detaching a provider from a sandbox.
type DetachProviderResult = types.DetachProviderResult
// LogLine represents a single log entry from a sandbox.
type LogLine = types.LogLine
// LogResult contains the result of a GetLogs call.
type LogResult = types.LogResult
// LogOption configures a GetLogs call.
type LogOption = types.LogOption
// WithLogLines sets the maximum number of log lines to return.
var WithLogLines = types.WithLogLines
// WithLogSince filters logs to entries at or after the given time.
var WithLogSince = types.WithLogSince
// WithLogSources filters logs by source (e.g., "gateway", "sandbox").
var WithLogSources = types.WithLogSources
// WithLogMinLevel sets the minimum log level to include.
var WithLogMinLevel = types.WithLogMinLevel
// SandboxInterface defines lifecycle operations on sandboxes.
type SandboxInterface interface {
Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error)
Get(ctx context.Context, workspace, name string) (*Sandbox, error)
List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error)
Delete(ctx context.Context, workspace, name string) error
AttachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error)
DetachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error)
ListProviders(ctx context.Context, workspace, sandboxName string) ([]*Provider, error)
WaitReady(ctx context.Context, workspace, name string, opts ...WaitOptions) (*Sandbox, error)
Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error)
// GetLogs retrieves log entries for a sandbox. The sandbox is resolved
// by name (an internal Get call translates name to ID). Use
// WithLogLines, WithLogSince, WithLogSources, and WithLogMinLevel to
// filter the results.
//
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
// the sandbox name is empty; Unimplemented by the fake client.
GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error)
}
+291
View File
@@ -0,0 +1,291 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"fmt"
"io"
"time"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"google.golang.org/grpc"
)
const defaultPollInterval = 500 * time.Millisecond
type sandboxClient struct {
client pb.OpenShellClient
}
func newSandboxClient(conn grpc.ClientConnInterface) *sandboxClient {
return &sandboxClient{client: pb.NewOpenShellClient(conn)}
}
func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) {
pbSpec, err := converter.SandboxSpecToProto(spec)
if err != nil {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: err.Error()}
}
resp, err := s.client.CreateSandbox(ctx, &pb.CreateSandboxRequest{
Name: name,
Spec: pbSpec,
Labels: labels,
Workspace: workspace,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
return converter.SandboxFromProto(resp.GetSandbox()), nil
}
func (s *sandboxClient) Get(ctx context.Context, workspace, name string) (*Sandbox, error) {
resp, err := s.client.GetSandbox(ctx, &pb.GetSandboxRequest{
Name: name,
Workspace: workspace,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
return converter.SandboxFromProto(resp.GetSandbox()), nil
}
func (s *sandboxClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) {
req := &pb.ListSandboxesRequest{
Workspace: workspace,
}
if len(opts) > 0 {
if opts[0].Limit > 0 {
req.Limit = uint32(opts[0].Limit)
}
if opts[0].Offset > 0 {
req.Offset = uint32(opts[0].Offset)
}
req.LabelSelector = opts[0].LabelSelector
req.AllWorkspaces = opts[0].AllWorkspaces
}
resp, err := s.client.ListSandboxes(ctx, req)
if err != nil {
return nil, converter.FromGRPCError(err)
}
sandboxes := make([]*Sandbox, 0, len(resp.GetSandboxes()))
for _, proto := range resp.GetSandboxes() {
sandboxes = append(sandboxes, converter.SandboxFromProto(proto))
}
return sandboxes, nil
}
func (s *sandboxClient) Delete(ctx context.Context, workspace, name string) error {
_, err := s.client.DeleteSandbox(ctx, &pb.DeleteSandboxRequest{
Name: name,
Workspace: workspace,
})
if err != nil {
return converter.FromGRPCError(err)
}
return nil
}
func (s *sandboxClient) AttachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error) {
resp, err := s.client.AttachSandboxProvider(ctx, &pb.AttachSandboxProviderRequest{
SandboxName: sandboxName,
ProviderName: providerName,
ExpectedResourceVersion: expectedResourceVersion,
Workspace: workspace,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
return &AttachProviderResult{
Sandbox: converter.SandboxFromProto(resp.GetSandbox()),
Attached: resp.GetAttached(),
}, nil
}
func (s *sandboxClient) DetachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error) {
resp, err := s.client.DetachSandboxProvider(ctx, &pb.DetachSandboxProviderRequest{
SandboxName: sandboxName,
ProviderName: providerName,
ExpectedResourceVersion: expectedResourceVersion,
Workspace: workspace,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
return &DetachProviderResult{
Sandbox: converter.SandboxFromProto(resp.GetSandbox()),
Detached: resp.GetDetached(),
}, nil
}
func (s *sandboxClient) ListProviders(ctx context.Context, workspace, sandboxName string) ([]*Provider, error) {
resp, err := s.client.ListSandboxProviders(ctx, &pb.ListSandboxProvidersRequest{
SandboxName: sandboxName,
Workspace: workspace,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
providers := make([]*Provider, 0, len(resp.GetProviders()))
for _, proto := range resp.GetProviders() {
providers = append(providers, converter.ProviderFromProto(proto))
}
return providers, nil
}
func (s *sandboxClient) WaitReady(ctx context.Context, workspace, name string, opts ...WaitOptions) (*Sandbox, error) {
interval := defaultPollInterval
if len(opts) > 0 && opts[0].PollInterval > 0 {
interval = opts[0].PollInterval
}
sb, err := s.Get(ctx, workspace, name)
if err != nil {
return nil, err
}
if sb.Status.Phase == SandboxReady {
return sb, nil
}
if sb.Status.Phase == SandboxError {
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)}
}
if sb.Status.Phase == SandboxDeleting {
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)}
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, contextError(ctx.Err())
case <-ticker.C:
sb, err = s.Get(ctx, workspace, name)
if err != nil {
return nil, err
}
if sb.Status.Phase == SandboxReady {
return sb, nil
}
if sb.Status.Phase == SandboxError {
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)}
}
if sb.Status.Phase == SandboxDeleting {
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)}
}
}
}
}
func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) {
if name == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
var watchOpts WatchOptions
if len(opts) > 0 {
watchOpts = opts[0]
}
// Resolve sandbox name to ID — the proto RPC takes Id, not name.
sb, err := s.Get(ctx, workspace, name)
if err != nil {
return nil, err
}
streamCtx, streamCancel := context.WithCancel(ctx)
stream, err := s.client.WatchSandbox(streamCtx, &pb.WatchSandboxRequest{
Id: sb.ID,
FollowStatus: true,
StopOnTerminal: watchOpts.StopOnTerminal,
})
if err != nil {
streamCancel()
return nil, converter.FromGRPCError(err)
}
first, err := stream.Recv()
if err != nil {
streamCancel()
return nil, converter.FromGRPCError(err)
}
ch := make(chan Event[*Sandbox], 64)
w := newWatcher(ch, streamCancel)
go func() {
defer close(ch)
defer streamCancel()
ev := first
isFirst := true
for {
if sbPayload, ok := ev.Payload.(*pb.SandboxStreamEvent_Sandbox); ok && sbPayload.Sandbox != nil {
sandbox := converter.SandboxFromProto(sbPayload.Sandbox)
eventType := EventModified
if isFirst {
eventType = EventAdded
isFirst = false
} else if sandbox.Status.Phase == SandboxDeleting {
eventType = EventDeleted
}
select {
case ch <- Event[*Sandbox]{Type: eventType, Object: sandbox}:
case <-w.done:
return
}
// StopOnTerminal: close watcher after delivering a terminal phase event
if watchOpts.StopOnTerminal && (sandbox.Status.Phase == SandboxReady || sandbox.Status.Phase == SandboxError) {
w.Stop()
return
}
}
var recvErr error
ev, recvErr = stream.Recv()
if recvErr != nil {
if recvErr != io.EOF {
select {
case ch <- Event[*Sandbox]{Type: EventError, Err: converter.FromGRPCError(recvErr)}:
case <-w.done:
}
}
return
}
}
}()
return w, nil
}
func (s *sandboxClient) GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error) {
// Resolve sandbox name to ID — the proto RPC takes SandboxId, not name.
sb, err := s.Get(ctx, workspace, sandboxName)
if err != nil {
return nil, err
}
cfg := types.ApplyLogOptions(opts)
req := &pb.GetSandboxLogsRequest{
SandboxId: sb.ID,
Lines: cfg.Lines(),
Sources: cfg.Sources(),
MinLevel: cfg.MinLevel(),
Workspace: workspace,
}
if !cfg.Since().IsZero() {
req.SinceMs = converter.MillisFromTime(cfg.Since())
}
resp, err := s.client.GetSandboxLogs(ctx, req)
if err != nil {
return nil, converter.FromGRPCError(err)
}
return converter.LogResultFromProto(resp), nil
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// ServiceEndpoint represents an exposed HTTP service endpoint within a sandbox.
type ServiceEndpoint = types.ServiceEndpoint
// ServiceInterface defines operations for managing sandbox service endpoints.
type ServiceInterface interface {
// Expose creates a new service endpoint in the given sandbox.
Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error)
// Get retrieves a service endpoint by sandbox and service name.
Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error)
// List returns all service endpoints for a sandbox. An empty sandboxName returns endpoints across all sandboxes.
List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error)
// Delete removes a service endpoint by sandbox and service name.
Delete(ctx context.Context, workspace, sandboxName, serviceName string) error
}
+58
View File
@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"io"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// SSHSession represents an SSH session created for a sandbox.
type SSHSession = types.SSHSession
// tunnelConfig accumulates options for the Tunnel method.
type tunnelConfig struct {
serviceID string
}
// TunnelOption configures an SSH tunnel opened via [SSHInterface.Tunnel].
type TunnelOption func(*tunnelConfig)
// WithTunnelServiceID sets an optional service identifier on the tunnel's
// init frame for audit and correlation purposes.
func WithTunnelServiceID(id string) TunnelOption {
return func(c *tunnelConfig) {
c.serviceID = id
}
}
// SSHInterface defines operations for managing SSH sessions.
type SSHInterface interface {
// CreateSession creates a new SSH session for the given sandbox.
// The returned SSHSession contains connection details including the
// sensitive Token field that must not be logged.
//
// Note: CreateSession accepts a raw sandbox ID, not a name.
// For name-based access with automatic session lifecycle management,
// prefer [SSHInterface.Tunnel] which resolves sandbox names internally
// and revokes the session on Close.
CreateSession(ctx context.Context, workspace, sandboxID string) (*SSHSession, error)
// RevokeSession revokes an existing SSH session by its token.
// Returns true if the session was actively revoked, false if it was
// already expired or not found.
RevokeSession(ctx context.Context, workspace, token string) (bool, error)
// Tunnel opens a bidirectional SSH tunnel to the given port inside a
// sandbox. It combines CreateSession and ForwardTcp(SshRelayTarget)
// into a single call with automatic session cleanup on Close.
//
// The sandboxName is resolved to a sandbox ID internally. Port must
// be in the range 1-65535.
//
// Errors: InvalidArgument if port is out of range or sandboxName is
// empty; NotFound if the sandbox does not exist; Unimplemented by
// the fake client; Unavailable if the client is closed.
Tunnel(ctx context.Context, workspace, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error)
}
+195
View File
@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"io"
"net"
)
func stubError(method string) error {
return &StatusError{
Code: ErrorUnimplemented,
Message: method + " not yet available - see https://github.com/NVIDIA/OpenShell/issues/2270",
}
}
// stubExec implements ExecInterface as a placeholder.
type stubExec struct{}
func (s *stubExec) Run(_ context.Context, _, _ string, _ []string, _ ...ExecOptions) (*ExecResult, error) {
return nil, stubError("Exec.Run")
}
func (s *stubExec) Stream(_ context.Context, _, _ string, _ []string, _ ...ExecOptions) (ExecStream, error) {
return nil, stubError("Exec.Stream")
}
func (s *stubExec) Interactive(_ context.Context, _, _ string, _ []string, _, _ uint32, _ ...ExecOptions) (InteractiveSession, error) {
return nil, stubError("Exec.Interactive")
}
// stubFiles implements FileInterface as a placeholder.
type stubFiles struct{}
func (s *stubFiles) Upload(_ context.Context, _, _, _, _ string) error {
return stubError("Files.Upload")
}
func (s *stubFiles) Download(_ context.Context, _, _, _, _ string) error {
return stubError("Files.Download")
}
// stubHealth implements HealthInterface as a placeholder.
type stubHealth struct{}
func (s *stubHealth) Check(_ context.Context) (*HealthResult, error) {
return nil, stubError("Health.Check")
}
// stubProviders implements ProviderInterface as a placeholder.
type stubProviders struct{}
func (s *stubProviders) Create(_ context.Context, _ string, _ *Provider) (*Provider, error) {
return nil, stubError("Providers.Create")
}
func (s *stubProviders) Get(_ context.Context, _, _ string) (*Provider, error) {
return nil, stubError("Providers.Get")
}
func (s *stubProviders) List(_ context.Context, _ string, _ ...ListOptions) ([]*Provider, error) {
return nil, stubError("Providers.List")
}
func (s *stubProviders) Update(_ context.Context, _ string, _ *Provider) (*Provider, error) {
return nil, stubError("Providers.Update")
}
func (s *stubProviders) Delete(_ context.Context, _, _ string) error {
return stubError("Providers.Delete")
}
func (s *stubProviders) Ensure(_ context.Context, _ string, _ *Provider) (*Provider, error) {
return nil, stubError("Providers.Ensure")
}
func (s *stubProviders) Profiles() ProfileInterface { return &stubProfiles{} }
func (s *stubProviders) Refresh() RefreshInterface { return &stubRefresh{} }
// stubProfiles implements ProfileInterface as a placeholder.
type stubProfiles struct{}
func (s *stubProfiles) List(_ context.Context, _ string, _ ...ListOptions) ([]*ProviderProfile, error) {
return nil, stubError("Profiles.List")
}
func (s *stubProfiles) Get(_ context.Context, _, _ string) (*ProviderProfile, error) {
return nil, stubError("Profiles.Get")
}
func (s *stubProfiles) Import(_ context.Context, _ string, _ []ProfileImportItem) (*ImportResult, error) {
return nil, stubError("Profiles.Import")
}
func (s *stubProfiles) Update(_ context.Context, _, _ string, _ uint64, _ ProfileImportItem) (*UpdateResult, error) {
return nil, stubError("Profiles.Update")
}
func (s *stubProfiles) Lint(_ context.Context, _ string, _ []ProfileImportItem) (*LintResult, error) {
return nil, stubError("Profiles.Lint")
}
func (s *stubProfiles) Delete(_ context.Context, _, _ string) (bool, error) {
return false, stubError("Profiles.Delete")
}
// stubRefresh implements RefreshInterface as a placeholder.
type stubRefresh struct{}
func (s *stubRefresh) GetStatus(_ context.Context, _, _, _ string) ([]*RefreshStatus, error) {
return nil, stubError("Refresh.GetStatus")
}
func (s *stubRefresh) Configure(_ context.Context, _ string, _ *RefreshConfig) (*RefreshStatus, error) {
return nil, stubError("Refresh.Configure")
}
func (s *stubRefresh) Rotate(_ context.Context, _, _, _ string) (*RefreshStatus, error) {
return nil, stubError("Refresh.Rotate")
}
func (s *stubRefresh) Delete(_ context.Context, _, _, _ string) (bool, error) {
return false, stubError("Refresh.Delete")
}
// stubServices implements ServiceInterface as a placeholder.
type stubServices struct{}
func (s *stubServices) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool) (*ServiceEndpoint, error) {
return nil, stubError("Services.Expose")
}
func (s *stubServices) Get(_ context.Context, _, _, _ string) (*ServiceEndpoint, error) {
return nil, stubError("Services.Get")
}
func (s *stubServices) List(_ context.Context, _, _ string, _ ...ListOptions) ([]*ServiceEndpoint, error) {
return nil, stubError("Services.List")
}
func (s *stubServices) Delete(_ context.Context, _, _, _ string) error {
return stubError("Services.Delete")
}
// stubSSH implements SSHInterface as a placeholder.
type stubSSH struct{}
func (s *stubSSH) CreateSession(_ context.Context, _, _ string) (*SSHSession, error) {
return nil, stubError("SSH.CreateSession")
}
func (s *stubSSH) RevokeSession(_ context.Context, _, _ string) (bool, error) {
return false, stubError("SSH.RevokeSession")
}
func (s *stubSSH) Tunnel(_ context.Context, _, _ string, _ uint32, _ ...TunnelOption) (io.ReadWriteCloser, error) {
return nil, stubError("SSH.Tunnel")
}
// stubTCP implements TCPInterface as a placeholder.
type stubTCP struct{}
func (s *stubTCP) Forward(_ context.Context, _, _ string, _ uint32, _ ...ForwardOption) (io.ReadWriteCloser, error) {
return nil, stubError("TCP.Forward")
}
func (s *stubTCP) Listen(_ context.Context, _, _ string, _, _ uint32, _ ...ListenOption) (net.Listener, error) {
return nil, stubError("TCP.Listen")
}
// stubConfig implements ConfigInterface as a placeholder.
type stubConfig struct{}
func (s *stubConfig) GetSandbox(_ context.Context, _, _ string) (*SandboxConfig, error) {
return nil, stubError("Config.GetSandbox")
}
func (s *stubConfig) GetGateway(_ context.Context) (*GatewayConfig, error) {
return nil, stubError("Config.GetGateway")
}
func (s *stubConfig) Update(_ context.Context, _ string, _ *ConfigUpdate) (*ConfigUpdateResult, error) {
return nil, stubError("Config.Update")
}
// stubPolicy implements PolicyInterface as a placeholder.
type stubPolicy struct{}
func (s *stubPolicy) GetDraft(_ context.Context, _, _ string, _ ...GetDraftOption) (*DraftPolicy, error) {
return nil, stubError("Policy.GetDraft")
}
func (s *stubPolicy) ApproveDraftChunk(_ context.Context, _, _, _ string) (*ApproveResult, error) {
return nil, stubError("Policy.ApproveDraftChunk")
}
func (s *stubPolicy) RejectDraftChunk(_ context.Context, _, _, _, _ string) error {
return stubError("Policy.RejectDraftChunk")
}
func (s *stubPolicy) ApproveAllDraftChunks(_ context.Context, _, _ string, _ ...ApproveAllOption) (*ApproveAllResult, error) {
return nil, stubError("Policy.ApproveAllDraftChunks")
}
func (s *stubPolicy) ClearDraftChunks(_ context.Context, _, _ string) (*ClearResult, error) {
return nil, stubError("Policy.ClearDraftChunks")
}
func (s *stubPolicy) GetDraftHistory(_ context.Context, _, _ string) ([]DraftHistoryEntry, error) {
return nil, stubError("Policy.GetDraftHistory")
}
func (s *stubPolicy) GetStatus(_ context.Context, _, _ string, _ ...GetStatusOption) (*PolicyStatusResult, error) {
return nil, stubError("Policy.GetStatus")
}
func (s *stubPolicy) List(_ context.Context, _ string, _ ...ListPolicyOption) ([]SandboxPolicyRevision, error) {
return nil, stubError("Policy.List")
}
func (s *stubPolicy) EditDraftChunk(_ context.Context, _, _, _ string, _ *NetworkPolicyRule) error {
return stubError("Policy.EditDraftChunk")
}
func (s *stubPolicy) UndoDraftChunk(_ context.Context, _, _, _ string) (*UndoResult, error) {
return nil, stubError("Policy.UndoDraftChunk")
}
+97
View File
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"io"
"net"
)
// forwardConfig accumulates options for the Forward method.
type forwardConfig struct {
serviceID string
}
// ForwardOption configures a TCP forward opened via [TCPInterface.Forward].
type ForwardOption func(*forwardConfig)
// WithForwardServiceID sets an optional service identifier on the forward's
// init frame for audit and correlation purposes.
func WithForwardServiceID(id string) ForwardOption {
return func(c *forwardConfig) {
c.serviceID = id
}
}
// listenConfig accumulates options for the Listen method.
type listenConfig struct {
bindAddress string
useSSHTunnel bool
serviceID string
}
// ListenOption configures a local listener opened via [TCPInterface.Listen].
type ListenOption func(*listenConfig)
// WithBindAddress overrides the default local bind address ("127.0.0.1").
// Pass "0.0.0.0" to accept connections from any interface.
func WithBindAddress(addr string) ListenOption {
return func(c *listenConfig) {
c.bindAddress = addr
}
}
// WithSSHTunnel routes each accepted connection through an SSH tunnel
// ([SSHInterface.Tunnel]) instead of the default TCP forward
// ([TCPInterface.Forward]).
func WithSSHTunnel() ListenOption {
return func(c *listenConfig) {
c.useSSHTunnel = true
}
}
// WithListenServiceID sets an optional service identifier on each tunneled
// connection's init frame for audit and correlation purposes.
func WithListenServiceID(id string) ListenOption {
return func(c *listenConfig) {
c.serviceID = id
}
}
// TCPInterface defines operations for TCP port forwarding to sandboxes.
// Methods accept a sandbox name and resolve it to an ID internally.
type TCPInterface interface {
// Forward opens a bidirectional TCP connection to the given port inside a
// sandbox. The sandbox is identified by name; the SDK resolves it to an
// ID internally. The returned io.ReadWriteCloser wraps the underlying
// gRPC stream; closing it terminates the stream. Port must be in the
// range 1-65535; out-of-range values are rejected client-side with an
// InvalidArgument error before opening the gRPC stream.
//
// The connection respects context cancellation: if ctx is cancelled,
// the stream is closed and pending Read/Write calls return a context error.
Forward(ctx context.Context, workspace, sandboxName string, port uint32, opts ...ForwardOption) (io.ReadWriteCloser, error)
// Listen binds a local TCP port and tunnels every accepted connection to
// the given port inside a sandbox, returning a standard [net.Listener].
// Each call to Accept on the returned listener establishes a new tunnel
// to the sandbox port, bridging data bidirectionally.
//
// The sandbox is identified by name; the SDK resolves it to an ID
// internally. remotePort must be in the range 1-65535; localPort must be
// in the range 0-65535, where 0 lets the OS assign an ephemeral port
// (discoverable via Addr).
//
// Closing the listener stops accepting new connections, tears down all
// active tunnels, and blocks until all bridge goroutines finish.
// Cancelling ctx triggers the same shutdown behavior.
//
// Errors:
// - InvalidArgument: sandboxName is empty, remotePort is 0 or > 65535,
// or localPort is > 65535
// - Unimplemented: returned by the fake client
// - Unavailable: client is closed
Listen(ctx context.Context, workspace, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (net.Listener, error)
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
)
// SandboxPhase represents the lifecycle phase of a sandbox.
type SandboxPhase = types.SandboxPhase
// SandboxPhase values for sandbox lifecycle.
const (
SandboxProvisioning = types.SandboxProvisioning
SandboxReady = types.SandboxReady
SandboxError = types.SandboxError
SandboxDeleting = types.SandboxDeleting
SandboxUnknown = types.SandboxUnknown
)
// EventType classifies watch events.
type EventType = types.EventType
// EventType values for watch events.
const (
EventAdded = types.EventAdded
EventModified = types.EventModified
EventDeleted = types.EventDeleted
EventError = types.EventError
)
// StreamType identifies which output stream a chunk belongs to.
type StreamType = types.StreamType
// StreamType values for exec output.
const (
StreamStdout = types.StreamStdout
StreamStderr = types.StreamStderr
)
// TLSConfig holds TLS connection settings.
type TLSConfig = types.TLSConfig
// RetryPolicy configures automatic retry behavior for failed RPCs.
type RetryPolicy = types.RetryPolicy
+13
View File
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
import "context"
// AuthProvider supplies per-RPC credentials. It implements the
// grpc credentials.PerRPCCredentials interface.
type AuthProvider interface {
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
RequireTransportSecurity() bool
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
import "time"
// Config holds all settings needed to create a Client.
type Config struct {
Address string
TLS *TLSConfig
Auth AuthProvider
// Timeout is reserved for future use. It is not yet applied.
Timeout time.Duration
// RetryPolicy is reserved for future use. It is not yet applied.
RetryPolicy *RetryPolicy
// Logger is reserved for future use. It is not yet applied.
Logger Logger
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package types defines all domain data types for the OpenShell SDK v1 API.
//
// These types are the canonical definitions used by both the client layer
// (openshell/v1) and the converter layer (openshell/v1/internal/converter).
// The v1 package re-exports all types via type aliases for backward
// compatibility.
package types
+134
View File
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
import (
"errors"
"fmt"
)
// ErrorCode classifies SDK errors by their gRPC origin.
type ErrorCode int
// ErrorCode values for classifying gRPC errors.
const (
ErrorNotFound ErrorCode = iota + 1
ErrorAlreadyExists
ErrorUnavailable
ErrorPermissionDenied
ErrorInvalidArgument
ErrorDeadlineExceeded
ErrorCancelled
ErrorInternal
ErrorUnimplemented
ErrorConflict
ErrorUnauthenticated
)
// String returns the human-readable name of the error code.
func (c ErrorCode) String() string {
switch c {
case ErrorNotFound:
return "NotFound"
case ErrorAlreadyExists:
return "AlreadyExists"
case ErrorUnavailable:
return "Unavailable"
case ErrorPermissionDenied:
return "PermissionDenied"
case ErrorInvalidArgument:
return "InvalidArgument"
case ErrorDeadlineExceeded:
return "DeadlineExceeded"
case ErrorCancelled:
return "Cancelled"
case ErrorInternal:
return "Internal"
case ErrorUnimplemented:
return "Unimplemented"
case ErrorConflict:
return "Conflict"
case ErrorUnauthenticated:
return "Unauthenticated"
default:
return fmt.Sprintf("Unknown(%d)", int(c))
}
}
// StatusError is the typed error returned by all SDK operations.
type StatusError struct {
Code ErrorCode
Message string
Cause error
}
func (e *StatusError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *StatusError) Unwrap() error {
return e.Cause
}
// IsNotFound returns true if the error indicates a resource was not found.
func IsNotFound(err error) bool {
return hasCode(err, ErrorNotFound)
}
// IsAlreadyExists returns true if the error indicates a resource already exists.
func IsAlreadyExists(err error) bool {
return hasCode(err, ErrorAlreadyExists)
}
// IsUnavailable returns true if the error indicates the service is unavailable.
func IsUnavailable(err error) bool {
return hasCode(err, ErrorUnavailable)
}
// IsPermissionDenied returns true if the error indicates insufficient permissions.
func IsPermissionDenied(err error) bool {
return hasCode(err, ErrorPermissionDenied)
}
// IsInvalidArgument returns true if the error indicates an invalid argument.
func IsInvalidArgument(err error) bool {
return hasCode(err, ErrorInvalidArgument)
}
// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded.
func IsDeadlineExceeded(err error) bool {
return hasCode(err, ErrorDeadlineExceeded)
}
// IsCancelled returns true if the error indicates the operation was cancelled.
func IsCancelled(err error) bool {
return hasCode(err, ErrorCancelled)
}
// IsUnimplemented returns true if the error indicates the operation is not implemented.
func IsUnimplemented(err error) bool {
return hasCode(err, ErrorUnimplemented)
}
// IsConflict returns true if the error indicates a conflict, such as
// optimistic concurrency or an invalid state transition.
func IsConflict(err error) bool {
return hasCode(err, ErrorConflict)
}
// IsUnauthenticated returns true if the error indicates missing or invalid credentials.
func IsUnauthenticated(err error) bool {
return hasCode(err, ErrorUnauthenticated)
}
func hasCode(err error, code ErrorCode) bool {
if err == nil {
return false
}
var se *StatusError
if errors.As(err, &se) {
return se.Code == code
}
return false
}
+17
View File
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
// ExecResult holds the collected output of a completed command execution.
type ExecResult struct {
ExitCode int
Stdout []byte
Stderr []byte
}
// ExecChunk represents a single chunk of output from a streaming command execution.
type ExecChunk struct {
Stream StreamType
Data []byte
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
// HealthResult holds the result of a health check.
type HealthResult struct {
Healthy bool
Version string
}
+98
View File
@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
import "time"
// LogLine represents a single log entry from a sandbox.
type LogLine struct {
// Timestamp is when the log entry was recorded.
Timestamp time.Time
// Level is the log severity level (e.g., "INFO", "WARN", "ERROR").
Level string
// Target is the log target/module.
Target string
// Message is the log message text.
Message string
// Source is the log source: "gateway" or "sandbox".
Source string
// Fields contains structured key-value fields from the tracing event.
Fields map[string]string
}
// LogResult contains the result of a GetLogs call.
type LogResult struct {
// Lines contains the log entries in chronological order.
Lines []LogLine
// BufferTotal is the total number of lines in the server's buffer.
BufferTotal uint32
}
// logConfig holds configuration for GetLogs calls.
type logConfig struct {
lines uint32
since time.Time
sources []string
minLevel string
}
// LogOption configures a GetLogs call.
type LogOption func(*logConfig)
// WithLogLines sets the maximum number of log lines to return.
func WithLogLines(n uint32) LogOption {
return func(c *logConfig) {
c.lines = n
}
}
// WithLogSince filters logs to entries at or after the given time.
func WithLogSince(t time.Time) LogOption {
return func(c *logConfig) {
c.since = t
}
}
// WithLogSources filters logs by source (e.g., "gateway", "sandbox").
func WithLogSources(sources ...string) LogOption {
return func(c *logConfig) {
c.sources = sources
}
}
// WithLogMinLevel sets the minimum log level to include.
func WithLogMinLevel(level string) LogOption {
return func(c *logConfig) {
c.minLevel = level
}
}
// ApplyLogOptions applies options and returns the config.
func ApplyLogOptions(opts []LogOption) logConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
var cfg logConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// Lines returns the configured max lines (0 means server default).
func (c *logConfig) Lines() uint32 {
return c.lines
}
// Since returns the configured since timestamp (zero means no filter).
func (c *logConfig) Since() time.Time {
return c.since
}
// Sources returns the configured source filters.
func (c *logConfig) Sources() []string {
return c.sources
}
// MinLevel returns the configured minimum log level.
func (c *logConfig) MinLevel() string {
return c.minLevel
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
// Logger defines structured logging for the SDK. Compatible with logr.Logger
// and slog.Logger adapters.
type Logger interface {
Debug(msg string, keysAndValues ...any)
Info(msg string, keysAndValues ...any)
Error(err error, msg string, keysAndValues ...any)
}
+170
View File
@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
// NetworkPolicyRule defines a named network policy rule containing endpoints and binaries.
type NetworkPolicyRule struct {
// Name is the map key for this rule in the sandbox policy.
Name string
// Endpoints lists the network endpoints governed by this rule.
Endpoints []PolicyNetworkEndpoint
// Binaries lists the binaries governed by this rule.
Binaries []PolicyNetworkBinary
}
// PolicyNetworkEndpoint describes a full network endpoint with its access controls
// as used in sandbox network policy rules. This is distinct from [NetworkEndpoint]
// which is the simplified profile-level endpoint (Host, Port, Protocol only).
type PolicyNetworkEndpoint struct {
Host string
Port uint32
Ports []uint32
Protocol string
TLS string
Enforcement string
Access string
Rules []L7Rule
AllowedIPs []string
DenyRules []L7DenyRule
AllowEncodedSlash bool
PersistedQueries string
GraphqlPersistedQueries map[string]GraphqlOperation
GraphqlMaxBodyBytes uint32
Path string
WebsocketCredentialRewrite bool
RequestBodyCredentialRewrite bool
AdvisorProposed bool
CredentialSigning string
SigningService string
SigningRegion string
JsonRpcMaxBodyBytes uint32
Mcp *McpOptions
}
// PolicyNetworkBinary identifies a binary subject to network policy enforcement.
// This is distinct from [NetworkBinary] which is the simplified profile-level binary.
type PolicyNetworkBinary struct {
// Path is the filesystem path to the binary.
Path string
}
// L7Rule wraps an L7 allow rule.
type L7Rule struct {
// Allow holds the layer-7 allow criteria.
Allow *L7Allow
}
// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL/MCP traffic.
type L7Allow struct {
Method string
Path string
Command string
Query map[string]L7QueryMatcher
OperationType string
OperationName string
Fields []string
Params map[string]L7QueryMatcher
}
// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL/MCP traffic.
type L7DenyRule struct {
Method string
Path string
Command string
Query map[string]L7QueryMatcher
OperationType string
OperationName string
Fields []string
Params map[string]L7QueryMatcher
}
// McpOptions holds MCP-specific policy and inspection options.
type McpOptions struct {
StrictToolNames *bool
AllowAllKnownMcpMethods *bool
}
// L7QueryMatcher matches query parameters by glob pattern or exact values.
type L7QueryMatcher struct {
Glob string
Any []string
}
// GraphqlOperation describes a GraphQL operation for persisted-query validation.
type GraphqlOperation struct {
OperationType string
OperationName string
Fields []string
}
// --- MergeOperation types ---
// PolicyMergeOperation represents a single atomic policy mutation.
// Exactly one of the pointer fields must be non-nil, modelling the proto oneof.
type PolicyMergeOperation struct {
// AddRule adds a new named network policy rule.
AddRule *AddNetworkRule
// RemoveEndpoint removes a single endpoint from a rule.
RemoveEndpoint *RemoveNetworkEndpoint
// RemoveRule removes an entire named rule.
RemoveRule *RemoveNetworkRule
// AddDenyRules appends deny rules to an endpoint.
AddDenyRules *AddDenyRules
// AddAllowRules appends allow rules to an endpoint.
AddAllowRules *AddAllowRules
// RemoveBinary removes a binary from a rule.
RemoveBinary *RemoveNetworkBinary
}
// AddNetworkRule adds a named network policy rule with a full rule definition.
type AddNetworkRule struct {
// RuleName is the name key for the rule.
RuleName string
// Rule is the full network policy rule to add.
Rule NetworkPolicyRule
}
// RemoveNetworkEndpoint removes a specific endpoint from a named rule.
type RemoveNetworkEndpoint struct {
// RuleName is the name of the rule containing the endpoint.
RuleName string
// Host is the endpoint host to remove.
Host string
// Port is the endpoint port to remove.
Port uint32
}
// RemoveNetworkRule removes an entire named rule from the policy.
type RemoveNetworkRule struct {
// RuleName is the name of the rule to remove.
RuleName string
}
// AddDenyRules appends layer-7 deny rules to a specific endpoint.
type AddDenyRules struct {
// Host identifies the target endpoint host.
Host string
// Port identifies the target endpoint port.
Port uint32
// DenyRules are the deny rules to append.
DenyRules []L7DenyRule
}
// AddAllowRules appends layer-7 allow rules to a specific endpoint.
type AddAllowRules struct {
// Host identifies the target endpoint host.
Host string
// Port identifies the target endpoint port.
Port uint32
// Rules are the allow rules to append.
Rules []L7Rule
}
// RemoveNetworkBinary removes a binary from a named rule.
type RemoveNetworkBinary struct {
// RuleName is the name of the rule containing the binary.
RuleName string
// BinaryPath is the filesystem path of the binary to remove.
BinaryPath string
}
+48
View File
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
import "time"
// CreateOptions configures resource creation.
type CreateOptions struct{}
// GetOptions configures resource retrieval.
type GetOptions struct{}
// ListOptions configures resource listing with pagination and filtering.
type ListOptions struct {
Limit int
Offset int
LabelSelector string
AllWorkspaces bool
}
// DeleteOptions configures resource deletion.
type DeleteOptions struct{}
// UpdateOptions configures resource updates.
type UpdateOptions struct{}
// WatchOptions configures watch behavior.
type WatchOptions struct {
// TimeoutSeconds is reserved for future use. Use context for timeout control.
TimeoutSeconds int64
// LabelSelector is reserved for future use.
LabelSelector string
// StopOnTerminal causes the watch to close automatically when the sandbox
// reaches a terminal phase (Ready or Error).
StopOnTerminal bool
}
// WaitOptions configures wait behavior. Use context for timeout control.
type WaitOptions struct {
PollInterval time.Duration
}
// ExecOptions configures command execution.
type ExecOptions struct {
Env map[string]string
WorkDir string
}
+341
View File
@@ -0,0 +1,341 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package types
import "time"
// PolicyLoadStatus represents the load state of a policy revision.
type PolicyLoadStatus int
const (
// PolicyLoadStatusUnspecified is the default zero value.
PolicyLoadStatusUnspecified PolicyLoadStatus = iota
// PolicyLoadStatusPending means the policy is queued for loading.
PolicyLoadStatusPending
// PolicyLoadStatusLoaded means the policy was successfully loaded.
PolicyLoadStatusLoaded
// PolicyLoadStatusFailed means the policy failed to load.
PolicyLoadStatusFailed
// PolicyLoadStatusSuperseded means a newer revision replaced this one.
PolicyLoadStatusSuperseded
)
// String returns the human-readable name of the load status.
func (s PolicyLoadStatus) String() string {
switch s {
case PolicyLoadStatusUnspecified:
return "Unspecified"
case PolicyLoadStatusPending:
return "Pending"
case PolicyLoadStatusLoaded:
return "Loaded"
case PolicyLoadStatusFailed:
return "Failed"
case PolicyLoadStatusSuperseded:
return "Superseded"
default:
return "Unknown"
}
}
// PolicyChunk represents a single proposed policy change in the draft inbox.
type PolicyChunk struct {
// ID is the unique chunk identifier.
ID string
// Status is the approval status: "pending", "approved", "rejected".
Status string
// RuleName is the proposed network_policies map key.
RuleName string
// ProposedRule is the proposed network policy rule.
ProposedRule *NetworkPolicyRule
// Rationale is a human-readable explanation of why this rule is proposed.
Rationale string
// SecurityNotes contains security concerns flagged by analysis (empty if none).
SecurityNotes string
// Confidence is the analysis confidence score (0.0-1.0).
Confidence float32
// DenialSummaryIDs lists the IDs of denial summaries that led to this chunk.
DenialSummaryIDs []string
// CreatedAt is when the chunk was created.
CreatedAt time.Time
// DecidedAt is when the user approved/rejected (zero if undecided).
DecidedAt time.Time
// Stage is the recommendation stage: "initial" or "refined".
Stage string
// SupersedesChunkID is the initial chunk ID this refined chunk replaces.
SupersedesChunkID string
// HitCount is how many times this endpoint was seen across denial flush cycles.
HitCount int32
// FirstSeen is the first time this endpoint was proposed.
FirstSeen time.Time
// LastSeen is the most recent time this endpoint was re-proposed.
LastSeen time.Time
// Binary is the binary path that triggered the denial.
Binary string
// ValidationResult is the prover output from gateway-side static checks.
ValidationResult string
// RejectionReason is the operator-supplied text accompanying a rejection.
RejectionReason string
}
// DraftPolicy contains the full draft policy state returned by GetDraft.
type DraftPolicy struct {
// Chunks contains the draft policy chunks.
Chunks []PolicyChunk
// RollingSummary is an LLM-generated summary of all analysis.
RollingSummary string
// DraftVersion is the current draft version number.
DraftVersion uint64
// LastAnalyzedAt is when the last analysis completed.
LastAnalyzedAt time.Time
}
// SandboxPolicy is the top-level security policy configuration for a sandbox.
// It contains filesystem access rules, Landlock LSM configuration, process
// identity rules, and named network access policies.
type SandboxPolicy struct {
// Version is the policy version number. The server may override this on write.
Version uint32
// Filesystem controls which directories the sandbox can access.
// Nil means no filesystem policy is specified.
Filesystem *FilesystemPolicy
// Landlock configures the Linux Landlock LSM.
// Nil means no landlock policy is specified.
Landlock *LandlockPolicy
// Process controls the user and group identity for sandboxed processes.
// Nil means no process policy is specified.
Process *ProcessPolicy
// NetworkPolicies contains named network access rules.
// Nil means no network policies are specified; an empty map is distinct from nil.
NetworkPolicies map[string]NetworkPolicyRule
}
// FilesystemPolicy controls which directories the sandbox can access
// in read-only or read-write mode.
type FilesystemPolicy struct {
// IncludeWorkdir auto-includes the working directory as read-write.
IncludeWorkdir bool
// ReadOnly is the list of read-only directory paths.
// Nil means no read-only directories; an empty slice is distinct from nil.
ReadOnly []string
// ReadWrite is the list of read-write directory paths.
// Nil means no read-write directories; an empty slice is distinct from nil.
ReadWrite []string
}
// LandlockPolicy configures the Linux Landlock LSM for filesystem restriction enforcement.
type LandlockPolicy struct {
// Compatibility is the compatibility mode (e.g., "best_effort", "hard_requirement").
Compatibility string
}
// ProcessPolicy controls the user and group identity under which sandboxed processes execute.
type ProcessPolicy struct {
// RunAsUser is the user name for sandboxed processes.
RunAsUser string
// RunAsGroup is the group name for sandboxed processes.
RunAsGroup string
}
// SandboxPolicyRevision represents a versioned policy revision for a sandbox.
type SandboxPolicyRevision struct {
// Version is the policy version (monotonically increasing per sandbox).
Version uint32
// PolicyHash is the SHA-256 hash of the serialized policy payload.
PolicyHash string
// Status is the load status of this revision.
Status PolicyLoadStatus
// LoadError is the error message if status is Failed.
LoadError string
// CreatedAt is when this revision was created.
CreatedAt time.Time
// LoadedAt is when this revision was loaded by the sandbox.
LoadedAt time.Time
// Policy is the typed security policy for this revision. Nil when not requested or absent.
Policy *SandboxPolicy
}
// PolicyStatusResult contains the status of a sandbox's policy.
type PolicyStatusResult struct {
// Revision is the queried policy revision.
Revision SandboxPolicyRevision
// ActiveVersion is the currently active (loaded) policy version.
ActiveVersion uint32
}
// ApproveResult contains the result of approving a single draft chunk.
type ApproveResult struct {
// PolicyVersion is the new policy version after merge.
PolicyVersion uint32
// PolicyHash is the SHA-256 hash of the new policy.
PolicyHash string
}
// ApproveAllResult contains the result of approving all draft chunks.
type ApproveAllResult struct {
// PolicyVersion is the new policy version after merge.
PolicyVersion uint32
// PolicyHash is the SHA-256 hash of the new policy.
PolicyHash string
// ChunksApproved is the number of chunks approved.
ChunksApproved uint32
// ChunksSkipped is the number of chunks skipped (security-flagged).
ChunksSkipped uint32
}
// UndoResult contains the result of undoing a draft chunk approval.
type UndoResult struct {
// PolicyVersion is the new policy version after removal.
PolicyVersion uint32
// PolicyHash is the SHA-256 hash of the updated policy.
PolicyHash string
}
// ClearResult contains the result of clearing all draft chunks.
type ClearResult struct {
// ChunksCleared is the number of chunks cleared.
ChunksCleared uint32
}
// DraftHistoryEntry represents a single event in the draft policy history.
type DraftHistoryEntry struct {
// Timestamp is when the event occurred.
Timestamp time.Time
// EventType is the event type (e.g., "approved", "rejected", "cleared").
EventType string
// Description is a human-readable description.
Description string
// ChunkID is the associated chunk ID (if applicable).
ChunkID string
}
// getDraftConfig holds configuration for GetDraft calls.
type getDraftConfig struct {
statusFilter string
}
// GetDraftOption configures a GetDraft call.
type GetDraftOption func(*getDraftConfig)
// WithStatusFilter filters draft chunks by approval status.
func WithStatusFilter(status string) GetDraftOption {
return func(c *getDraftConfig) {
c.statusFilter = status
}
}
// ApplyGetDraftOptions applies options and returns the config.
func ApplyGetDraftOptions(opts []GetDraftOption) getDraftConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
var cfg getDraftConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// StatusFilter returns the configured status filter.
func (c *getDraftConfig) StatusFilter() string {
return c.statusFilter
}
// approveAllConfig holds configuration for ApproveAllDraftChunks calls.
type approveAllConfig struct {
includeSecurityFlagged bool
}
// ApproveAllOption configures an ApproveAllDraftChunks call.
type ApproveAllOption func(*approveAllConfig)
// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval.
func WithIncludeSecurityFlagged() ApproveAllOption {
return func(c *approveAllConfig) {
c.includeSecurityFlagged = true
}
}
// ApplyApproveAllOptions applies options and returns the config.
func ApplyApproveAllOptions(opts []ApproveAllOption) approveAllConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
var cfg approveAllConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// IncludeSecurityFlagged returns whether security-flagged chunks are included.
func (c *approveAllConfig) IncludeSecurityFlagged() bool {
return c.includeSecurityFlagged
}
// getStatusConfig holds configuration for GetStatus calls.
type getStatusConfig struct {
version uint32
}
// GetStatusOption configures a GetStatus call.
type GetStatusOption func(*getStatusConfig)
// WithVersion queries a specific policy version instead of the latest.
func WithVersion(version uint32) GetStatusOption {
return func(c *getStatusConfig) {
c.version = version
}
}
// ApplyGetStatusOptions applies options and returns the config.
func ApplyGetStatusOptions(opts []GetStatusOption) getStatusConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
var cfg getStatusConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// Version returns the configured version (0 means latest).
func (c *getStatusConfig) Version() uint32 {
return c.version
}
// listPolicyConfig holds configuration for List calls.
type listPolicyConfig struct {
limit uint32
offset uint32
}
// ListPolicyOption configures a List call.
type ListPolicyOption func(*listPolicyConfig)
// WithLimit sets the maximum number of revisions to return.
func WithLimit(limit uint32) ListPolicyOption {
return func(c *listPolicyConfig) {
c.limit = limit
}
}
// WithOffset sets the pagination offset.
func WithOffset(offset uint32) ListPolicyOption {
return func(c *listPolicyConfig) {
c.offset = offset
}
}
// ApplyListPolicyOptions applies options and returns the config.
func ApplyListPolicyOptions(opts []ListPolicyOption) listPolicyConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
var cfg listPolicyConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// Limit returns the configured limit (0 means server default).
func (c *listPolicyConfig) Limit() uint32 {
return c.limit
}
// Offset returns the configured offset.
func (c *listPolicyConfig) Offset() uint32 {
return c.offset
}

Some files were not shown because too many files have changed in this diff Show More