feat: add openshell_direct adapter for NVIDIA OpenShell sandboxed execution

New adapter type that runs agents inside NVIDIA OpenShell sandboxed
containers via direct gRPC. Each agent run creates an isolated pod
with OPA policies, Landlock filesystem, seccomp, and network proxy.

Flow: CreateSandbox -> WaitReady -> ExecSandbox (stream) -> DeleteSandbox

Tested end-to-end on OpenShift 4.x with OpenShell gateway (Helm),
Agent Sandbox controller (sandboxes.agents.x-k8s.io CRD), and
Paperclip external adapter loading.

Closes #8950
This commit is contained in:
Raghuram Banda 2026-07-03 18:38:40 -04:00
parent 47448721e1
commit e1248f1be0
9 changed files with 3080 additions and 0 deletions

View File

@ -0,0 +1,23 @@
{
"name": "@paperclip/adapter-openshell-direct",
"version": "0.1.0",
"description": "Paperclip adapter for running agents in NVIDIA OpenShell sandboxes via direct gRPC",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/node": "^20.0.0"
},
"peerDependencies": {
"@paperclipai/adapter-utils": "*"
}
}

View File

@ -0,0 +1,292 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
syntax = "proto3";
package openshell.compute.v1;
import "google/protobuf/struct.proto";
// Internal compute-driver contract used by the gateway.
//
// Conventions:
// - This file owns driver-native request, response, and observation types.
// - Compute drivers must not import or return the public `openshell.v1.Sandbox`
// resource model.
// - The gateway translates between these internal driver-native messages and
// the public OpenShell API resource model.
service ComputeDriver {
// Report driver capabilities and defaults.
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
// Validate a sandbox before create-time provisioning.
rpc ValidateSandboxCreate(ValidateSandboxCreateRequest)
returns (ValidateSandboxCreateResponse);
// Fetch the platform-observed sandbox state for one sandbox.
rpc GetSandbox(GetSandboxRequest) returns (GetSandboxResponse);
// List platform-observed sandbox state for all sandboxes.
rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse);
// Provision platform resources for a sandbox.
rpc CreateSandbox(CreateSandboxRequest) returns (CreateSandboxResponse);
// Stop platform resources for a sandbox without deleting its record.
rpc StopSandbox(StopSandboxRequest) returns (StopSandboxResponse);
// Tear down platform resources for a sandbox.
rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse);
// Stream sandbox observations from the platform.
rpc WatchSandboxes(WatchSandboxesRequest) returns (stream WatchSandboxesEvent);
}
message GetCapabilitiesRequest {}
message GetCapabilitiesResponse {
reserved 4, 5;
reserved "supports_gpu", "gpu_count";
// Human-readable driver name.
string driver_name = 1;
// Driver implementation version string.
string driver_version = 2;
// Default sandbox image recommended by the driver.
string default_image = 3;
}
// Driver-owned sandbox model used for create requests and platform observations.
//
// This intentionally omits gateway-owned lifecycle fields such as the public
// `openshell.v1.SandboxPhase` and persisted metadata. The gateway derives and
// stores those fields after translating driver observations.
message DriverSandbox {
// Stable sandbox ID assigned by the gateway.
string id = 1;
// Compute-runtime sandbox name.
string name = 2;
// Compute-platform namespace or equivalent tenancy boundary.
string namespace = 3;
// Provisioning input supplied by the gateway. Drivers may omit this in
// observed snapshots returned by Get/List/Watch.
DriverSandboxSpec spec = 4;
// Raw platform-observed status.
DriverSandboxStatus status = 5;
}
// Driver-owned provisioning inputs required to create a sandbox.
message DriverSandboxSpec {
// Log level exposed to processes running inside the sandbox.
string log_level = 1;
// Environment variables injected into the sandbox runtime.
map<string, string> environment = 5;
// Runtime template consumed by the driver during provisioning.
DriverSandboxTemplate template = 6;
// Portable resource requirements used by the gateway for driver selection
// and by drivers for provisioning.
ResourceRequirements resource_requirements = 9;
reserved 10;
reserved "gpu_device";
// Gateway-minted JWT identifying this sandbox to the gateway. Set by
// the gateway on create; the driver materialises it via its native
// secret mechanism (Docker/Podman/VM bind-mount a per-sandbox file;
// the Kubernetes driver ignores this field and relies on its projected
// ServiceAccount token bootstrap instead). Never echoed to the public
// Sandbox proto.
string sandbox_token = 11;
}
message ResourceRequirements {
// GPU requirements for the sandbox. Presence indicates a GPU request.
GpuResourceRequirements gpu = 1;
}
// Driver GPU resource requirements.
message GpuResourceRequirements {
// Optional number of GPUs requested. When omitted, the request is for one
// GPU using the selected driver's default assignment behavior.
optional uint32 count = 1;
}
// Driver-owned runtime template consumed by the compute platform.
//
// This message describes the sandbox workload in backend-neutral terms.
// Platform-specific knobs (Kubernetes runtimeClassName, annotations,
// volumeClaimTemplates, etc.) belong in `platform_config`.
message DriverSandboxTemplate {
// Fully-qualified OCI image reference used to boot the sandbox.
string image = 1;
// Socket path inside the sandbox where the agent service listens.
string agent_socket_path = 3;
// Metadata labels applied to compute-platform resources.
// Drivers map these to the platform's native tagging mechanism
// (Kubernetes labels, cloud instance tags, etc.).
map<string, string> labels = 4;
// Additional environment variables injected into the sandbox runtime.
map<string, string> environment = 6;
// Typed compute-resource requirements for the sandbox workload.
DriverResourceRequirements resources = 10;
// Opaque, platform-specific configuration passed through to the driver.
// The gateway does not inspect this; each driver defines its own schema.
// For the Kubernetes driver this carries fields such as runtimeClassName,
// annotations, and volumeClaimTemplates.
google.protobuf.Struct platform_config = 11;
// Caller-provided config for the selected driver only.
// This is the inner block selected from public SandboxTemplate.driver_config.
// The selected driver owns nested schema validation.
google.protobuf.Struct driver_config = 12;
}
// Typed compute-resource requirements.
//
// Values use Kubernetes-style quantity strings (e.g. "500m", "2", "4Gi")
// because they are a well-known, widely-adopted notation. Drivers for
// non-Kubernetes platforms must parse these strings into their native units.
message DriverResourceRequirements {
// Minimum CPU cores requested (e.g. "500m", "2").
string cpu_request = 1;
// Maximum CPU cores allowed (e.g. "500m", "4").
string cpu_limit = 2;
// Minimum memory requested (e.g. "256Mi", "4Gi").
string memory_request = 3;
// Maximum memory allowed (e.g. "512Mi", "8Gi").
string memory_limit = 4;
}
// Raw status observed directly from the compute platform.
//
// The gateway derives the public `openshell.v1.SandboxPhase` from these
// conditions plus `deleting`.
message DriverSandboxStatus {
// Compute-platform sandbox object name.
string sandbox_name = 1;
// Platform-assigned instance identifier for the compute unit running the
// sandbox agent (e.g. Kubernetes pod name, VM instance ID, hostname).
// The gateway uses this to correlate incoming connections back to a sandbox.
string instance_id = 2;
// File descriptor or address for reaching the agent service inside the
// sandbox, when available.
string agent_fd = 3;
// File descriptor or address for reaching the sandbox supervisor service,
// when available.
string sandbox_fd = 4;
// Raw readiness and lifecycle conditions reported by the platform.
repeated DriverCondition conditions = 5;
// True when the compute platform has begun deleting this sandbox.
bool deleting = 6;
}
// Raw compute-platform condition.
message DriverCondition {
// Condition class reported by the compute platform.
string type = 1;
// Condition status value such as `True`, `False`, or `Unknown`.
string status = 2;
// Short machine-readable reason associated with the condition.
string reason = 3;
// Human-readable condition message.
string message = 4;
// Timestamp reported by the platform for the last transition.
string last_transition_time = 5;
}
// Raw compute-platform event correlated to a sandbox.
message DriverPlatformEvent {
// Event timestamp in milliseconds since epoch.
int64 timestamp_ms = 1;
// Event source (for example `kubernetes`).
string source = 2;
// Event type or severity (for example `Normal` or `Warning`).
string type = 3;
// Short machine-readable reason code.
string reason = 4;
// Human-readable event message.
string message = 5;
// Optional platform-specific metadata attached to the event.
map<string, string> metadata = 6;
}
message ValidateSandboxCreateRequest {
// Proposed sandbox configuration to validate before provisioning.
DriverSandbox sandbox = 1;
}
message ValidateSandboxCreateResponse {}
message GetSandboxRequest {
// Stable sandbox ID stored by the gateway.
string sandbox_id = 1;
// Compute-runtime name used by the driver.
string sandbox_name = 2;
}
message GetSandboxResponse {
// Platform-observed sandbox snapshot returned by the driver.
DriverSandbox sandbox = 1;
}
message ListSandboxesRequest {}
message ListSandboxesResponse {
// Platform-observed sandbox snapshots returned by the driver.
repeated DriverSandbox sandboxes = 1;
}
message CreateSandboxRequest {
// Sandbox configuration to provision on the compute platform.
DriverSandbox sandbox = 1;
}
message CreateSandboxResponse {}
message StopSandboxRequest {
// Stable sandbox ID stored by the gateway.
string sandbox_id = 1;
// Compute-runtime name used by the driver.
string sandbox_name = 2;
}
message StopSandboxResponse {}
message DeleteSandboxRequest {
// Stable sandbox ID stored by the gateway.
string sandbox_id = 1;
// Compute-runtime name used by the driver.
string sandbox_name = 2;
}
message DeleteSandboxResponse {
// True when a platform resource was deleted by this request.
bool deleted = 1;
}
message WatchSandboxesRequest {}
message WatchSandboxesSandboxEvent {
// Updated driver-native snapshot for one sandbox.
DriverSandbox sandbox = 1;
}
message WatchSandboxesDeletedEvent {
// Sandbox ID removed from the compute platform.
string sandbox_id = 1;
}
message WatchSandboxesPlatformEvent {
// Sandbox ID correlated to the platform event.
string sandbox_id = 1;
// Raw platform event emitted for the sandbox.
DriverPlatformEvent event = 2;
}
message WatchSandboxesEvent {
oneof payload {
// Updated or newly observed sandbox snapshot.
WatchSandboxesSandboxEvent sandbox = 1;
// Sandbox deletion observation.
WatchSandboxesDeletedEvent deleted = 2;
// Raw platform event correlated to a sandbox.
WatchSandboxesPlatformEvent platform_event = 3;
}
}

View File

@ -0,0 +1 @@
404: Not Found

View File

@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
syntax = "proto3";
package openshell.datamodel.v1;
// Kubernetes-style metadata shared by all top-level OpenShell domain objects.
//
// This structure provides consistent metadata (identity, labels, timestamps,
// resource versioning) across Sandbox, Provider, SshSession, and other resources.
message ObjectMeta {
// Stable object ID generated by the gateway.
string id = 1;
// Human-readable object name (unique per object type).
string name = 2;
// Milliseconds since Unix epoch when the object was created.
int64 created_at_ms = 3;
// Key-value labels for filtering and organization.
// Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment.
map<string, string> labels = 4;
// Optimistic concurrency control version.
// Incremented by the gateway on each update. Clients can use this for compare-and-swap operations.
uint64 resource_version = 5;
}
// Provider model stored by OpenShell.
message Provider {
// Kubernetes-style metadata (id, name, labels, timestamps, resource version).
ObjectMeta metadata = 1;
// Canonical provider type slug (for example: "claude", "gitlab").
string type = 2;
// Secret values used for authentication.
map<string, string> credentials = 3;
// Non-secret provider configuration.
map<string, string> config = 4;
// Expiration timestamps for credential values, keyed by credential/env var
// name. A zero or missing value means the credential does not expire.
map<string, int64> credential_expires_at_ms = 5;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,332 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
syntax = "proto3";
package openshell.sandbox.v1;
// Sandbox-supervisor configuration and policy messages.
//
// Conventions:
// - This file owns messages exchanged between the gateway and the sandbox
// supervisor/runtime.
// - Public sandbox resource types live in `openshell.proto`.
// - Internal compute-driver sandbox observation types live in `compute_driver.proto`.
// Sandbox security policy configuration.
message SandboxPolicy {
// Policy version.
uint32 version = 1;
// Filesystem access policy.
FilesystemPolicy filesystem = 2;
// Landlock configuration.
LandlockPolicy landlock = 3;
// Process execution policy.
ProcessPolicy process = 4;
// Network access policies keyed by name (e.g. "claude_code", "gitlab").
map<string, NetworkPolicyRule> network_policies = 5;
}
// Filesystem access policy.
message FilesystemPolicy {
// Automatically include the workdir as read-write.
bool include_workdir = 1;
// Read-only directory allow list.
repeated string read_only = 2;
// Read-write directory allow list.
repeated string read_write = 3;
}
// Landlock policy configuration.
message LandlockPolicy {
// Compatibility mode (e.g. "best_effort", "hard_requirement").
string compatibility = 1;
}
// Process execution policy.
message ProcessPolicy {
// User name to run the sandboxed process as.
string run_as_user = 1;
// Group name to run the sandboxed process as.
string run_as_group = 2;
}
// A named network access policy rule.
message NetworkPolicyRule {
// Human-readable name for this policy rule.
string name = 1;
// Allowed endpoint (host:port) pairs.
repeated NetworkEndpoint endpoints = 2;
// Allowed binary identities.
repeated NetworkBinary binaries = 3;
}
// A network endpoint (host + port) with optional L7 inspection config.
message NetworkEndpoint {
// Hostname or host glob pattern. Exact match is case-insensitive.
// Glob patterns use "." as delimiter: "*.example.com" matches a single
// subdomain label, "**.example.com" matches across labels.
string host = 1;
// Single port (backwards compat). Use `ports` for multiple ports.
// Mutually exclusive with `ports` if both are set, `ports` takes precedence.
uint32 port = 2;
// Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only).
string protocol = 3;
// TLS handling: "terminate" or "passthrough" (default).
string tls = 4;
// Enforcement mode: "enforce" or "audit" (default).
string enforcement = 5;
// Access preset shorthand: "read-only", "read-write", "full".
// Mutually exclusive with rules.
string access = 6;
// Explicit L7 rules (mutually exclusive with access).
repeated L7Rule rules = 7;
// Allowed resolved IP addresses or CIDR ranges for this endpoint.
// When non-empty, the SSRF internal-IP check is replaced by an allowlist check:
// - If host is also set: domain must resolve to an IP in this list.
// - If host is empty: any domain is allowed as long as it resolves to an IP in this list.
// Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24").
// Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked
// regardless of this field.
repeated string allowed_ips = 8;
// Multiple ports. When non-empty, this endpoint covers all listed ports.
// If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`.
// If both are set, `ports` takes precedence.
repeated uint32 ports = 9;
// Explicit L7 deny rules. When present, requests matching any deny rule
// are blocked even if they match an allow rule or access preset.
// Deny rules take precedence over allow rules.
repeated L7DenyRule deny_rules = 10;
// When true, percent-encoded '/' (%2F) is preserved in path segments
// rather than rejected by the L7 path canonicalizer. Required for
// upstreams like GitLab that embed %2F in namespaced resource paths.
// Defaults to false (strict).
bool allow_encoded_slash = 11;
// GraphQL persisted-query behavior for hash-only/saved-query requests:
// "deny" (default) or "allow_registered".
string persisted_queries = 12;
// Trusted GraphQL persisted-query registry keyed by hash or service-specific ID.
// Only used when persisted_queries is "allow_registered".
map<string, GraphqlOperation> graphql_persisted_queries = 13;
// Maximum GraphQL request body bytes to buffer for inspection.
// Defaults to 65536 when unset.
uint32 graphql_max_body_bytes = 14;
// Optional HTTP path glob that scopes this L7 endpoint on shared host:port APIs.
// Example: use path "/graphql" for protocol "graphql" and "/repos/**" for
// protocol "rest" when both surfaces live under api.example.com:443.
// Empty means all paths.
string path = 15;
// When true on a "rest" endpoint, OpenShell rewrites credential placeholders
// inside client-to-server WebSocket text messages after an allowed HTTP 101
// upgrade. Defaults to false.
bool websocket_credential_rewrite = 16;
// When true on a "rest" endpoint, OpenShell rewrites credential placeholders
// inside supported textual HTTP request bodies before forwarding upstream.
// Defaults to false.
bool request_body_credential_rewrite = 17;
// Internal provenance marker for policy-advisor generated endpoints.
// Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless
// they are converted through an explicit user-authored policy path.
bool advisor_proposed = 18;
// Proxy-side credential signing mode: "sigv4" for AWS SigV4 re-signing.
// When set, the proxy strips the client's Authorization header and computes
// a fresh SigV4 signature using real credentials from the provider.
string credential_signing = 19;
// AWS signing service name override. Required when credential_signing is
// "sigv4" e.g. "bedrock" for bedrock-runtime endpoints.
string signing_service = 20;
// AWS region override for SigV4 signing. When set, takes precedence over
// hostname-based region extraction. Required for non-standard endpoints.
string signing_region = 21;
// Maximum JSON-RPC-over-HTTP request body bytes to buffer for inspection.
// Defaults to 65536 when unset.
uint32 json_rpc_max_body_bytes = 22;
// MCP-only policy and inspection options. Only used when protocol is "mcp".
McpOptions mcp = 23;
}
// MCP options are grouped so MCP-specific policy can grow without adding more
// top-level NetworkEndpoint fields. Current enforcement targets the active
// 2025-11-25 Streamable HTTP/tools behavior, while preserving space for
// version-profile policy if OpenShell adopts 2026-07-28 draft behavior later.
//
// Planned policy extensions should use OpenShell-owned static definitions for
// MCP method/version profiles rather than treating dependency enums as the
// policy contract. Candidate profile checks include request metadata/header
// validation, response/SSE introspection, trusted annotation handling,
// resultType/cache metadata validation, x-mcp-header tool-definition checks,
// and subscriptions/listen handling.
//
// Sources:
// - https://modelcontextprotocol.io/specification/2025-11-25/server/tools
// - https://modelcontextprotocol.io/specification/draft/changelog
// - https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http
// - https://modelcontextprotocol.io/specification/draft/server/tools
message McpOptions {
// Hardening boundary for tools/call params.name. When unset or true, the
// supervisor enforces the MCP recommended tool-name syntax
// ^[A-Za-z0-9_.-]{1,128}$ before policy evaluation. Set false only for
// compatibility with servers that intentionally use non-recommended names.
//
// Source:
// - https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names
optional bool strict_tool_names = 1;
// Method-layer default for MCP endpoints. When true, OpenShell allows parsed
// MCP-family methods at the method layer unless a tool-name policy narrows
// tools/call. When unset or false, explicit method rules are required.
optional bool allow_all_known_mcp_methods = 2;
}
// Trusted GraphQL operation classification.
message GraphqlOperation {
// Operation type: "query", "mutation", or "subscription".
string operation_type = 1;
// Operation name, if known.
string operation_name = 2;
// Root field names selected by the operation.
repeated string fields = 3;
}
// An L7 deny rule that blocks specific requests.
// Mirrors L7Allow same fields, same matching semantics, inverted effect.
// Deny rules are evaluated after allow rules and take precedence.
message L7DenyRule {
// Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or
// "*" for any when supported by the protocol.
string method = 1;
// URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any.
string path = 2;
// SQL command (SQL): SELECT, INSERT, etc. or "*" for any.
string command = 3;
// Query parameter matcher map (REST).
// Same semantics as L7Allow.query.
map<string, L7QueryMatcher> query = 4;
// GraphQL operation type: "query", "mutation", "subscription", or "*" for any.
string operation_type = 5;
// GraphQL operation name glob. "*" matches any operation name.
string operation_name = 6;
// GraphQL root field globs. Deny rules match when any selected root field
// matches any configured glob.
repeated string fields = 7;
reserved 8;
// MCP params matcher map. Currently only params.name is supported for
// tools/call filtering. Generic protocol "json-rpc" rejects params matchers.
map<string, L7QueryMatcher> params = 9;
}
// An L7 policy rule (allow-only).
message L7Rule {
L7Allow allow = 1;
}
// Allowed action definition for L7 rules.
message L7Allow {
// Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or
// "*" for any when supported by the protocol.
string method = 1;
// URL path glob pattern (REST): "/repos/**", "**" for any.
string path = 2;
// SQL command (SQL): SELECT, INSERT, etc. or "*" for any.
string command = 3;
// Query parameter matcher map (REST).
// Key is the decoded query parameter name (case-sensitive).
// Value supports either a single glob (`glob`) or a list (`any`).
map<string, L7QueryMatcher> query = 4;
// GraphQL operation type: "query", "mutation", "subscription", or "*" for any.
string operation_type = 5;
// GraphQL operation name glob. "*" matches any operation name.
string operation_name = 6;
// GraphQL root field globs. Allow rules match only when every selected root
// field matches one of the configured globs. Omit to match all fields.
repeated string fields = 7;
reserved 8;
// MCP params matcher map. Currently only params.name is supported for
// tools/call filtering. Generic protocol "json-rpc" rejects params matchers.
map<string, L7QueryMatcher> params = 9;
}
// Query value matcher for one query parameter key.
message L7QueryMatcher {
// Single glob pattern.
string glob = 1;
// Any-of glob patterns.
repeated string any = 2;
}
// A binary identity for network policy matching.
message NetworkBinary {
string path = 1;
// Deprecated: the harness concept has been removed. This field is ignored.
bool harness = 2 [deprecated = true];
}
// Request to get sandbox settings by sandbox ID.
message GetSandboxConfigRequest {
// The sandbox ID.
string sandbox_id = 1;
}
// Request to get gateway-global settings.
message GetGatewayConfigRequest {}
// Response containing gateway-global settings.
message GetGatewayConfigResponse {
// Gateway-global settings map excluding the reserved policy key.
// Registered keys without a configured value are returned with an empty SettingValue.
map<string, SettingValue> settings = 1;
// Monotonically increasing revision for gateway-global settings.
uint64 settings_revision = 2;
}
// Scope that currently controls a setting.
enum SettingScope {
SETTING_SCOPE_UNSPECIFIED = 0;
SETTING_SCOPE_SANDBOX = 1;
SETTING_SCOPE_GLOBAL = 2;
}
// Type-aware setting value for sandbox/gateway settings.
message SettingValue {
oneof value {
string string_value = 1;
bool bool_value = 2;
int64 int_value = 3;
bytes bytes_value = 4;
}
}
// Effective setting value and the scope it was resolved from.
message EffectiveSetting {
SettingValue value = 1;
SettingScope scope = 2;
}
// Source used for the policy payload in GetSandboxConfigResponse.
enum PolicySource {
POLICY_SOURCE_UNSPECIFIED = 0;
POLICY_SOURCE_SANDBOX = 1;
POLICY_SOURCE_GLOBAL = 2;
}
// Response containing effective sandbox settings and policy.
message GetSandboxConfigResponse {
// The sandbox policy configuration.
SandboxPolicy policy = 1;
// Current policy version (monotonically increasing per sandbox).
uint32 version = 2;
// SHA-256 hash of the serialized policy payload.
string policy_hash = 3;
// Effective settings resolved for this sandbox, excluding the reserved policy key.
// Registered keys without a configured value are returned with an empty EffectiveSetting.value.
map<string, EffectiveSetting> settings = 4;
// Fingerprint for effective config (policy + settings). Changes when any effective input changes.
uint64 config_revision = 5;
// Source of the policy payload for this response.
PolicySource policy_source = 6;
// When policy_source is GLOBAL, the version of the global policy revision.
// Zero when no global policy is active or when policy_source is SANDBOX.
uint32 global_policy_version = 7;
// Fingerprint for provider credential inputs attached to this sandbox.
// Changes when attached provider names or attached provider records change.
uint64 provider_env_revision = 8;
}

View File

@ -0,0 +1,275 @@
/**
* Paperclip adapter for NVIDIA OpenShell -- runs agents in sandboxed containers.
*
* This adapter calls the OpenShell gateway's gRPC API directly (no ShoreGuard).
* Each agent run:
* 1. Creates (or reuses) an OpenShell sandbox pod
* 2. Executes the agent command inside the sandbox
* 3. Streams stdout/stderr back to Paperclip's run log
* 4. Optionally cleans up the sandbox
*/
import {
createSandbox,
waitForSandboxReady,
execInSandbox,
deleteSandbox,
healthCheck,
listSandboxes,
} from "./openshell-client.js";
function asString(v: unknown, fallback: string): string {
return typeof v === "string" && v.trim() ? v.trim() : fallback;
}
function asNumber(v: unknown, fallback: number): number {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function parseObject(v: unknown): Record<string, unknown> {
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
return {};
}
// Paperclip ServerAdapterModule interface
interface AdapterExecutionContext {
runId: string;
agent: { id: string; name: string; companyId: string };
runtime: any;
config: Record<string, unknown>;
context: Record<string, unknown>;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
}
interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
summary?: string;
errorMessage?: string;
errorCode?: string;
}
interface AdapterEnvironmentTestContext {
config: Record<string, unknown>;
}
interface AdapterEnvironmentTestResult {
checks: Array<{ code: string; level: string; passed: boolean; message: string }>;
}
interface ServerAdapterModule {
type: string;
execute: (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
testEnvironment: (ctx: AdapterEnvironmentTestContext) => Promise<AdapterEnvironmentTestResult>;
models: Array<{ id: string; label: string }>;
agentConfigurationDoc: string;
}
async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { config, runId, agent, context, onLog } = ctx;
const endpoint = asString(config.gatewayEndpoint, "openshell.openshell.svc:8080");
const image = asString(config.sandboxImage, "ghcr.io/nvidia/openshell-community/sandboxes/base:latest");
const cpu = asString(config.cpu, "2");
const memory = asString(config.memory, "4Gi");
const gpu = Boolean(config.gpu);
const reuseStrategy = asString(config.reuseStrategy, "per-run");
const timeoutSecs = asNumber(config.timeoutSecs, 600);
const agentCommand = asString(config.agentCommand, "claude");
const sandboxName = reuseStrategy === "per-agent"
? `pc-${agent.id.slice(0, 8)}`
: `pc-${runId.slice(0, 8)}`;
const wakePrompt = asString(
(context as any).paperclipTaskMarkdown || (context as any).wakePrompt,
`Task: ${asString((context as any).issueTitle, "No task assigned")}`
);
await onLog("stdout", `[openshell] Endpoint: ${endpoint}\n`);
await onLog("stdout", `[openshell] Sandbox: ${sandboxName} (strategy: ${reuseStrategy})\n`);
await onLog("stdout", `[openshell] Image: ${image}\n`);
await onLog("stdout", `[openshell] Agent command: ${agentCommand}\n\n`);
try {
// Check if sandbox already exists (reuse case)
let needsCreate = true;
if (reuseStrategy === "per-agent") {
try {
const existing = await listSandboxes(endpoint);
if (existing.some((s) => s.name === sandboxName && s.phase.toLowerCase().includes("running"))) {
await onLog("stdout", `[openshell] Reusing existing sandbox: ${sandboxName}\n`);
needsCreate = false;
}
} catch {
// List failed, will create
}
}
// Create sandbox
if (needsCreate) {
await onLog("stdout", `[openshell] Creating sandbox...\n`);
const sb = await createSandbox(endpoint, {
name: sandboxName,
image,
cpu,
memory,
gpu,
environment: {
PAPERCLIP_RUN_ID: runId,
PAPERCLIP_AGENT_ID: agent.id,
PAPERCLIP_AGENT_NAME: agent.name,
},
labels: {
"paperclip.ai/agent-id": agent.id,
"paperclip.ai/run-id": runId,
},
});
await onLog("stdout", `[openshell] Sandbox created: ${sb.name} (phase: ${sb.phase})\n`);
// Wait for sandbox to be ready
await onLog("stdout", `[openshell] Waiting for sandbox to be ready...\n`);
await waitForSandboxReady(endpoint, sandboxName, 120000);
await onLog("stdout", `[openshell] Sandbox is ready.\n\n`);
}
// Execute agent command in sandbox
await onLog("stdout", `[openshell] Executing: ${agentCommand} --prompt "..."\n`);
await onLog("stdout", `[openshell] Timeout: ${timeoutSecs}s\n\n`);
if (ctx.onSpawn) {
await ctx.onSpawn({ pid: 0, processGroupId: null, startedAt: new Date().toISOString() });
}
// Get sandbox ID for exec
const sbInfo = await new Promise<any>((resolve, reject) => {
const { getClient: _gc, ...mod } = require("./openshell-client.js");
// Use the client directly
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const protoPath = "/paperclip/adapters/openshell-direct/proto/openshell.proto";
const pkgDef = protoLoader.loadSync(protoPath, {
keepCase: false, longs: String, enums: String, defaults: true, oneofs: true,
includeDirs: ["/paperclip/adapters/openshell-direct/proto"],
});
const proto = grpc.loadPackageDefinition(pkgDef);
const c = new proto.openshell.v1.OpenShell(endpoint, grpc.credentials.createInsecure());
c.GetSandbox({ name: sandboxName }, { deadline: Date.now() + 10000 }, (err: any, res: any) => {
if (err) return reject(err);
resolve(res?.sandbox);
});
});
const sandboxId = sbInfo?.metadata?.id;
await onLog("stdout", `[openshell] Sandbox ID: ${sandboxId}\n`);
const cmd = [
"sh", "-c",
`${agentCommand} "${wakePrompt.replace(/"/g, '\\"')}" 2>&1 || echo "[openshell] Agent exited with code $?"`,
];
const result = await execInSandbox(endpoint, sandboxId, cmd, {
timeoutSecs,
});
// Stream output
if (result.stdout) {
await onLog("stdout", result.stdout);
}
if (result.stderr) {
await onLog("stderr", result.stderr);
}
// Cleanup per-run sandboxes
if (reuseStrategy === "per-run") {
await onLog("stdout", `\n[openshell] Cleaning up sandbox...\n`);
try {
await deleteSandbox(endpoint, sandboxName);
await onLog("stdout", `[openshell] Sandbox deleted.\n`);
} catch (err) {
await onLog("stderr", `[openshell] Cleanup warning: ${err}\n`);
}
}
return {
exitCode: result.exitCode,
signal: null,
timedOut: false,
summary: `OpenShell sandbox ${sandboxName}: exit ${result.exitCode}`,
};
} catch (err: any) {
await onLog("stderr", `[openshell] Error: ${err.message}\n`);
// Try cleanup on error
if (reuseStrategy === "per-run") {
try { await deleteSandbox(endpoint, sandboxName); } catch {}
}
return {
exitCode: 1,
signal: null,
timedOut: err.message?.includes("timeout"),
errorMessage: err.message,
errorCode: "openshell_error",
};
}
}
async function testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentTestResult["checks"] = [];
const config = parseObject(ctx.config);
const endpoint = asString(config.gatewayEndpoint, "openshell.openshell.svc:8080");
try {
const healthy = await healthCheck(endpoint);
checks.push({
code: "openshell_gateway_reachable",
level: "required",
passed: healthy,
message: healthy
? `OpenShell gateway at ${endpoint} is reachable`
: `Cannot reach OpenShell gateway at ${endpoint}`,
});
} catch (err: any) {
checks.push({
code: "openshell_gateway_reachable",
level: "required",
passed: false,
message: `OpenShell gateway check failed: ${err.message}`,
});
}
return { checks };
}
export const openshellDirectAdapter: ServerAdapterModule = {
type: "openshell_direct",
execute,
testEnvironment,
models: [],
agentConfigurationDoc: `# OpenShell Direct Adapter
Runs agents inside NVIDIA OpenShell sandboxed containers via direct gRPC.
Required fields:
- gatewayEndpoint (string): OpenShell gateway gRPC endpoint (default: openshell.openshell.svc:8080)
Optional fields:
- sandboxImage (string): Container image for sandboxes (default: base image)
- agentCommand (string): Agent CLI to run inside sandbox (default: claude)
- cpu (string): CPU request/limit (default: 2)
- memory (string): Memory request/limit (default: 4Gi)
- gpu (boolean): Request GPU (default: false)
- reuseStrategy (string): "per-run" (ephemeral) or "per-agent" (reuse) (default: per-run)
- timeoutSecs (number): Command execution timeout (default: 600)
`,
};
// Export for Paperclip external adapter loading
export function createServerAdapter() {
return openshellDirectAdapter;
}
export default openshellDirectAdapter;

View File

@ -0,0 +1,199 @@
/**
* OpenShell gRPC client -- calls the OpenShell gateway directly
* without ShoreGuard as a middleman.
*
* Uses dynamic proto loading via @grpc/proto-loader to avoid
* needing a proto compilation step.
*/
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROTO_PATH_RELATIVE = resolve(__dirname, "../proto/openshell.proto");
const PROTO_PATH_ABSOLUTE = "/paperclip/adapters/openshell-direct/proto/openshell.proto";
const PROTO_PATH = existsSync(PROTO_PATH_RELATIVE) ? PROTO_PATH_RELATIVE : PROTO_PATH_ABSOLUTE;
function existsSync(p: string): boolean {
try { require("fs").accessSync(p); return true; } catch { return false; }
}
let _client: any = null;
function getClient(endpoint: string): any {
if (_client) return _client;
const packageDef = protoLoader.loadSync(PROTO_PATH, {
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
includeDirs: [resolve(__dirname, "../proto")],
});
const proto = grpc.loadPackageDefinition(packageDef) as any;
_client = new proto.openshell.v1.OpenShell(
endpoint,
grpc.credentials.createInsecure()
);
return _client;
}
export interface SandboxInfo {
name: string;
phase: string;
}
export async function healthCheck(endpoint: string): Promise<boolean> {
const client = getClient(endpoint);
return new Promise((resolve) => {
client.Health({}, { deadline: Date.now() + 5000 }, (err: any) => {
resolve(!err);
});
});
}
export async function createSandbox(
endpoint: string,
opts: {
name?: string;
image?: string;
cpu?: string;
memory?: string;
gpu?: boolean;
environment?: Record<string, string>;
labels?: Record<string, string>;
}
): Promise<SandboxInfo> {
const client = getClient(endpoint);
const spec: any = {};
if (opts.image) {
spec.template = { image: opts.image };
}
if (opts.cpu || opts.memory) {
spec.resources = {};
if (opts.cpu) spec.resources.cpu = opts.cpu;
if (opts.memory) spec.resources.memory = opts.memory;
}
if (opts.gpu) spec.gpu = true;
if (opts.environment) spec.environment = opts.environment;
const request: any = { spec };
if (opts.name) request.name = opts.name;
if (opts.labels) request.labels = opts.labels;
return new Promise((resolve, reject) => {
client.CreateSandbox(request, { deadline: Date.now() + 120000 }, (err: any, response: any) => {
if (err) return reject(new Error(`CreateSandbox failed: ${err.message}`));
const sb = response?.sandbox;
resolve({
name: sb?.metadata?.name || opts.name || "unknown",
phase: sb?.status?.phase || "unknown",
});
});
});
}
export async function waitForSandboxReady(
endpoint: string,
name: string,
timeoutMs = 120000
): Promise<void> {
const client = getClient(endpoint);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const phase = await new Promise<string>((resolve, reject) => {
client.GetSandbox({ name }, { deadline: Date.now() + 10000 }, (err: any, res: any) => {
if (err) return reject(err);
resolve(res?.sandbox?.status?.phase || "unknown");
});
});
if (phase === "RUNNING" || phase === "Running" || phase === "running") return;
if (phase === "FAILED" || phase === "Failed") {
throw new Error(`Sandbox ${name} failed to start`);
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`Sandbox ${name} did not become ready within ${timeoutMs}ms`);
}
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function execInSandbox(
endpoint: string,
sandboxId: string,
command: string[],
opts?: { timeoutSecs?: number; env?: Record<string, string> }
): Promise<ExecResult> {
const client = getClient(endpoint);
return new Promise((resolve, reject) => {
const deadline = Date.now() + ((opts?.timeoutSecs || 600) + 30) * 1000;
let stdout = "";
let stderr = "";
let exitCode = -1;
const stream = client.ExecSandbox({
sandboxId,
command,
environment: opts?.env || {},
timeoutSeconds: opts?.timeoutSecs || 600,
}, { deadline });
stream.on("data", (event: any) => {
if (event.stdout) stdout += Buffer.from(event.stdout.data).toString();
if (event.stderr) stderr += Buffer.from(event.stderr.data).toString();
if (event.exit) exitCode = event.exit.exitCode;
});
stream.on("end", () => {
resolve({ stdout, stderr, exitCode });
});
stream.on("error", (err: any) => {
if (stdout || stderr) {
resolve({ stdout, stderr, exitCode });
} else {
reject(new Error(`ExecSandbox failed: ${err.message}`));
}
});
});
}
export async function deleteSandbox(endpoint: string, name: string): Promise<void> {
const client = getClient(endpoint);
return new Promise((resolve, reject) => {
client.DeleteSandbox({ name }, { deadline: Date.now() + 30000 }, (err: any) => {
if (err && err.code !== 5 /* NOT_FOUND */) {
return reject(new Error(`DeleteSandbox failed: ${err.message}`));
}
resolve();
});
});
}
export async function listSandboxes(endpoint: string): Promise<SandboxInfo[]> {
const client = getClient(endpoint);
return new Promise((resolve, reject) => {
client.ListSandboxes({}, { deadline: Date.now() + 10000 }, (err: any, res: any) => {
if (err) return reject(err);
const items = (res?.sandboxes || []).map((sb: any) => ({
name: sb?.metadata?.name || "unknown",
phase: sb?.status?.phase || "unknown",
}));
resolve(items);
});
});
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}