Merge 2dd1fbe8f3 into c9e3bb7ca4
This commit is contained in:
commit
494e1cb9fd
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
404: Not Found
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue