mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-18 05:39:50 +08:00
> **🏗️ build-from-issue-agent** Closes #79 ## Summary Replaces the gateway-proxied inference model (`ProxyInference` RPC) with sandbox-local execution. The sandbox now resolves routes from either a standalone YAML route file or a cluster bundle fetched via the new `GetSandboxInferenceBundle` RPC, then forwards requests directly to inference backends using `navigator-router`. ### Benefits of removing the gRPC hop Moving inference execution from the gateway to the sandbox eliminates the gRPC round-trip for every inference request: - **No payload size limits**: Direct HTTP forwarding replaces protobuf-over-gRPC serialization. - **Streaming-ready**: Preserves connection semantics for future SSE support (e.g., `stream: true`). - **Lower latency**: Direct sandbox → backend path instead of sandbox → gateway → backend round-trip. - **Reduced gateway load**: Gateway only serves the control-plane bundle delivery RPC. - **Simpler error model**: HTTP status codes flow through directly without gRPC wrapping. ## Changes Made - **Proto**: Removed `ProxyInference` RPC. Added `GetSandboxInferenceBundle` RPC for route bundle delivery. - **Gateway**: Replaced `proxy_inference` with `get_sandbox_inference_bundle`. Removed `Router` and `navigator-router` dependency — gateway is now control-plane only for inference. - **Router**: Switched config file format from TOML to YAML. Added `Debug` impl that redacts API keys. - **Sandbox**: New `InferenceContext` with local `Router` + route cache. Routes loaded from `--inference-routes` file (standalone) or cluster bundle via gRPC, with 30s background refresh. New `--inference-routes` / `NAVIGATOR_INFERENCE_ROUTES` CLI arg. - **Dev sandbox**: Added `inference-routes.yaml` at repo root with default NVIDIA NIM route. `mise run sandbox` now mounts it automatically and supports `-e` flag to forward env vars (e.g., `mise run sandbox`). - **Example**: Added standalone example (`examples/inference/routes.yaml`) and rewrote README to cover both standalone and cluster workflows. - **E2E tests**: Updated existing test names/docs. Added tests for Anthropic messages protocol and multi-route policy filtering. ## Tests Added - **Unit (21 new):** `router_error_to_http` (6 variants), `load_from_file` YAML round-trip (5), `resolve_sandbox_inference_bundle` gRPC handler (6), `build_inference_context` route loading (4) - **E2E (2 new):** Anthropic messages protocol routing, route filtering by `allowed_routes` - **Total:** 188 unit tests passing across 3 crates ## Documentation Updated - `architecture/gateway.md`: Removed router, updated Inference Service - `architecture/sandbox.md`: Updated orchestration flow, InferenceContext, route loading - `architecture/inference-routing.md`: Complete rewrite for sandbox-local architecture ## Verification - [x] All unit tests passing (188 across 3 crates) - [x] Pre-commit Rust checks passing (fmt, clippy, tests) - [x] Architecture documentation updated - [x] E2E tests updated with new test cases - [x] Standalone example and documentation added - [x] Default inference-routes.yaml with env passthrough for mise run sandbox
254 lines
8.8 KiB
Python
254 lines
8.8 KiB
Python
"""E2E tests for inference interception and routing.
|
|
|
|
When a process inside the sandbox makes an inference API call (e.g. POST
|
|
/v1/chat/completions) to an endpoint not explicitly allowed by network policy,
|
|
the proxy intercepts it, TLS-terminates the connection, detects the inference
|
|
API pattern, and the sandbox routes the request locally to the configured
|
|
backend (configured with `mock://` for testing).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from navigator._proto import datamodel_pb2, sandbox_pb2
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from navigator import Sandbox
|
|
|
|
|
|
# =============================================================================
|
|
# Policy helpers
|
|
# =============================================================================
|
|
|
|
_BASE_FILESYSTEM = sandbox_pb2.FilesystemPolicy(
|
|
include_workdir=True,
|
|
read_only=["/usr", "/lib", "/etc", "/app", "/var/log"],
|
|
read_write=["/sandbox", "/tmp"],
|
|
)
|
|
_BASE_LANDLOCK = sandbox_pb2.LandlockPolicy(compatibility="best_effort")
|
|
_BASE_PROCESS = sandbox_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox")
|
|
|
|
|
|
def _inference_routing_policy() -> sandbox_pb2.SandboxPolicy:
|
|
"""Policy with inference routing enabled.
|
|
|
|
No network_policies needed — any connection from any binary to an endpoint
|
|
not in an explicit policy will be intercepted for inference when
|
|
allowed_routes is non-empty.
|
|
"""
|
|
return sandbox_pb2.SandboxPolicy(
|
|
version=1,
|
|
inference=sandbox_pb2.InferencePolicy(allowed_routes=["e2e_mock_local"]),
|
|
filesystem=_BASE_FILESYSTEM,
|
|
landlock=_BASE_LANDLOCK,
|
|
process=_BASE_PROCESS,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Tests
|
|
# =============================================================================
|
|
|
|
|
|
def test_inference_call_routed_to_backend(
|
|
sandbox: Callable[..., Sandbox],
|
|
mock_inference_route: str,
|
|
) -> None:
|
|
"""Inference call to undeclared endpoint is intercepted and routed.
|
|
|
|
A Python process inside the sandbox calls the OpenAI chat completions
|
|
endpoint via raw urllib. Since api.openai.com is not in any network
|
|
policy, but inference routing is configured, the proxy should:
|
|
1. Detect no explicit policy match (inspect_for_inference)
|
|
2. TLS-terminate the connection
|
|
3. Detect the inference API pattern (POST /v1/chat/completions)
|
|
4. Forward locally via sandbox router to the policy-allowed backend
|
|
5. Return the mock response from the configured route
|
|
"""
|
|
spec = datamodel_pb2.SandboxSpec(policy=_inference_routing_policy())
|
|
|
|
def call_chat_completions() -> str:
|
|
import json
|
|
import ssl
|
|
import urllib.request
|
|
|
|
body = json.dumps(
|
|
{
|
|
"model": "test-model",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}
|
|
).encode()
|
|
|
|
req = urllib.request.Request(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
data=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer dummy-key",
|
|
},
|
|
method="POST",
|
|
)
|
|
# The proxy will TLS-terminate, so we need to accept its cert.
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
|
|
return resp.read().decode()
|
|
|
|
with sandbox(spec=spec, delete_on_exit=True) as sb:
|
|
result = sb.exec_python(call_chat_completions, timeout_seconds=60)
|
|
assert result.exit_code == 0, f"stderr: {result.stderr}"
|
|
output = result.stdout.strip()
|
|
assert "Hello from navigator mock backend" in output
|
|
assert "mock/test-model" in output
|
|
|
|
|
|
def test_non_inference_request_denied(
|
|
sandbox: Callable[..., Sandbox],
|
|
mock_inference_route: str,
|
|
) -> None:
|
|
"""Non-inference HTTP request on an intercepted connection is denied.
|
|
|
|
A process making a non-inference request (e.g. GET /v1/models) to an
|
|
undeclared endpoint should be denied with 403 when inference routing
|
|
is configured — only recognized inference API patterns are routed.
|
|
"""
|
|
spec = datamodel_pb2.SandboxSpec(policy=_inference_routing_policy())
|
|
|
|
def make_non_inference_request() -> str:
|
|
import ssl
|
|
import urllib.request
|
|
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
try:
|
|
req = urllib.request.Request("https://api.openai.com/v1/models")
|
|
urllib.request.urlopen(req, timeout=10, context=ctx)
|
|
return "unexpected_success"
|
|
except urllib.error.HTTPError as e:
|
|
return f"http_error_{e.code}"
|
|
except Exception as e:
|
|
return f"error: {e}"
|
|
|
|
with sandbox(spec=spec, delete_on_exit=True) as sb:
|
|
result = sb.exec_python(make_non_inference_request, timeout_seconds=30)
|
|
assert result.exit_code == 0, f"stderr: {result.stderr}"
|
|
assert "403" in result.stdout.strip()
|
|
|
|
|
|
def test_inference_anthropic_messages_protocol(
|
|
sandbox: Callable[..., Sandbox],
|
|
mock_anthropic_route: str,
|
|
) -> None:
|
|
"""Anthropic messages protocol (POST /v1/messages) is intercepted and routed.
|
|
|
|
Verifies multi-protocol routing: a request using the Anthropic messages
|
|
format is correctly detected and forwarded to a route configured with
|
|
the anthropic_messages protocol.
|
|
"""
|
|
policy = sandbox_pb2.SandboxPolicy(
|
|
version=1,
|
|
inference=sandbox_pb2.InferencePolicy(
|
|
allowed_routes=["e2e_mock_anthropic"],
|
|
),
|
|
filesystem=_BASE_FILESYSTEM,
|
|
landlock=_BASE_LANDLOCK,
|
|
process=_BASE_PROCESS,
|
|
)
|
|
spec = datamodel_pb2.SandboxSpec(policy=policy)
|
|
|
|
def call_anthropic_messages() -> str:
|
|
import json
|
|
import ssl
|
|
import urllib.request
|
|
|
|
body = json.dumps(
|
|
{
|
|
"model": "claude-test",
|
|
"max_tokens": 64,
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}
|
|
).encode()
|
|
|
|
req = urllib.request.Request(
|
|
"https://api.anthropic.com/v1/messages",
|
|
data=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"x-api-key": "dummy-key",
|
|
"anthropic-version": "2023-06-01",
|
|
},
|
|
method="POST",
|
|
)
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
|
|
return resp.read().decode()
|
|
|
|
with sandbox(spec=spec, delete_on_exit=True) as sb:
|
|
result = sb.exec_python(call_anthropic_messages, timeout_seconds=60)
|
|
assert result.exit_code == 0, f"stderr: {result.stderr}"
|
|
output = result.stdout.strip()
|
|
assert "Hello from navigator mock backend" in output
|
|
assert "mock/claude-test" in output
|
|
|
|
|
|
def test_inference_route_filtering_by_allowed_routes(
|
|
sandbox: Callable[..., Sandbox],
|
|
mock_inference_route: str,
|
|
mock_disallowed_route: str,
|
|
) -> None:
|
|
"""Only routes in allowed_routes are available; others produce errors.
|
|
|
|
Two routes exist (e2e_mock_local and e2e_mock_disallowed), but the
|
|
policy only allows e2e_mock_local. A request that would match the
|
|
allowed route should succeed, while inference requests that can't
|
|
match any allowed route get an error from the sandbox router.
|
|
"""
|
|
# Policy only allows e2e_mock_local, NOT e2e_mock_disallowed
|
|
spec = datamodel_pb2.SandboxSpec(policy=_inference_routing_policy())
|
|
|
|
def call_allowed_route() -> str:
|
|
import json
|
|
import ssl
|
|
import urllib.request
|
|
|
|
body = json.dumps(
|
|
{
|
|
"model": "test-model",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}
|
|
).encode()
|
|
|
|
req = urllib.request.Request(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
data=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer dummy",
|
|
},
|
|
method="POST",
|
|
)
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
|
|
return resp.read().decode()
|
|
|
|
with sandbox(spec=spec, delete_on_exit=True) as sb:
|
|
result = sb.exec_python(call_allowed_route, timeout_seconds=60)
|
|
assert result.exit_code == 0, f"stderr: {result.stderr}"
|
|
output = result.stdout.strip()
|
|
# The allowed route (e2e_mock_local) should serve the request
|
|
assert "Hello from navigator mock backend" in output
|
|
assert "mock/test-model" in output
|