This commit is contained in:
Raghuram Banda 2026-09-13 20:50:17 +09:00 committed by GitHub
commit 494e1cb9fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 3079 additions and 0 deletions

View File

@ -40,6 +40,7 @@ COPY packages/adapters/hermes/package.json packages/adapters/hermes/
COPY packages/adapters/hermes-gateway/package.json packages/adapters/hermes-gateway/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/openshell-direct/package.json packages/adapters/openshell-direct/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/

View File

@ -0,0 +1,24 @@
{
"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": "*"
},
"private": true
}

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,252 @@
/**
* 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,
getSandbox,
} 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 {};
}
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;
}
/**
* Shell-escape a string for safe inclusion in a single-quoted shell argument.
* Replaces each ' with '\'' (end quote, escaped quote, reopen quote).
*/
function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
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 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 {
let needsCreate = true;
if (reuseStrategy === "per-agent") {
try {
const existing = await listSandboxes(endpoint);
if (existing.some((s) => s.name === sandboxName && s.phase === "SANDBOX_PHASE_READY")) {
await onLog("stdout", `[openshell] Reusing existing sandbox: ${sandboxName}\n`);
needsCreate = false;
}
} catch {
// List failed, will create
}
}
if (needsCreate) {
await onLog("stdout", `[openshell] Creating sandbox...\n`);
const sb = await createSandbox(endpoint, {
name: sandboxName,
image,
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`);
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`);
}
await onLog("stdout", `[openshell] Executing: ${agentCommand}\n`);
await onLog("stdout", `[openshell] Timeout: ${timeoutSecs}s\n\n`);
if (ctx.onSpawn) {
await ctx.onSpawn({ pid: 0, processGroupId: null, startedAt: new Date().toISOString() });
}
const sbInfo = await getSandbox(endpoint, sandboxName);
const sandboxId = sbInfo.id;
if (!sandboxId) {
throw new Error(`Sandbox ${sandboxName} has no ID -- cannot exec`);
}
await onLog("stdout", `[openshell] Sandbox ID: ${sandboxId}\n`);
const cmd = [agentCommand, "--print", "-", "--prompt", wakePrompt];
const result = await execInSandbox(endpoint, sandboxId, cmd, {
timeoutSecs,
});
if (result.stdout) {
await onLog("stdout", result.stdout);
}
if (result.stderr) {
await onLog("stderr", result.stderr);
}
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`);
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)
- 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 function createServerAdapter() {
return openshellDirectAdapter;
}
export default openshellDirectAdapter;

View File

@ -0,0 +1,219 @@
/**
* OpenShell gRPC client -- calls the OpenShell gateway directly.
*
* 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";
import { accessSync } from "node:fs";
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";
function existsSync(p: string): boolean {
try { accessSync(p); return true; } catch { return false; }
}
const PROTO_PATH = existsSync(PROTO_PATH_RELATIVE) ? PROTO_PATH_RELATIVE : PROTO_PATH_ABSOLUTE;
const _clients = new Map<string, any>();
function getClient(endpoint: string): any {
const existing = _clients.get(endpoint);
if (existing) return existing;
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;
const client = new proto.openshell.v1.OpenShell(
endpoint,
grpc.credentials.createInsecure()
);
_clients.set(endpoint, client);
return client;
}
export interface SandboxInfo {
name: string;
phase: string;
id?: 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 getSandbox(endpoint: string, name: string): Promise<SandboxInfo> {
const client = getClient(endpoint);
return new Promise((resolve, reject) => {
client.GetSandbox({ name }, { deadline: Date.now() + 10000 }, (err: any, res: any) => {
if (err) return reject(new Error(`GetSandbox failed: ${err.message}`));
const sb = res?.sandbox;
resolve({
name: sb?.metadata?.name || name,
phase: sb?.status?.phase || "unknown",
id: sb?.metadata?.id,
});
});
});
}
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 template: any = {};
if (opts.image) template.image = opts.image;
if (opts.environment) template.environment = opts.environment;
if (opts.labels) template.labels = opts.labels;
const spec: any = { template };
if (opts.gpu) {
spec.resourceRequirements = { gpu: { count: 1 } };
}
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",
id: sb?.metadata?.id,
});
});
});
}
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 === "SANDBOX_PHASE_READY") return;
if (phase === "SANDBOX_PHASE_ERROR") {
throw new Error(`Sandbox ${name} failed to start (phase: ${phase})`);
}
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",
id: sb?.metadata?.id,
}));
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/**/*"]
}