From 4cf39c3720190fc0f4da8cd52836172ea7eedeac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Thu, 19 Mar 2026 15:20:39 +0800 Subject: [PATCH 1/3] feat(sdks): passing sandbox endpoint headers --- sdks/code-interpreter/javascript/package.json | 1 + .../javascript/src/factory/adapterFactory.ts | 3 +- .../src/factory/defaultAdapterFactory.ts | 10 +- .../javascript/src/interpreter.ts | 11 +- .../defaultAdapterFactory.headers.test.mjs | 66 +++++ .../tests/interpreter.headers.test.mjs | 36 +++ .../adapters/service/CodesAdapter.kt | 17 +- .../adapters/service/CodesAdapterTest.kt | 61 +++++ .../SandboxEgressLifecycleTests.cs | 254 ++++++++++++++++++ 9 files changed, 450 insertions(+), 9 deletions(-) create mode 100644 sdks/code-interpreter/javascript/tests/defaultAdapterFactory.headers.test.mjs create mode 100644 sdks/code-interpreter/javascript/tests/interpreter.headers.test.mjs create mode 100644 sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs diff --git a/sdks/code-interpreter/javascript/package.json b/sdks/code-interpreter/javascript/package.json index fdb40b570..fa2a6557a 100644 --- a/sdks/code-interpreter/javascript/package.json +++ b/sdks/code-interpreter/javascript/package.json @@ -34,6 +34,7 @@ "packageManager": "pnpm@9.15.0", "scripts": { "build": "tsup", + "test": "pnpm run build && node --test tests/*.test.mjs", "lint": "eslint src --max-warnings 0", "clean": "rm -rf dist" }, diff --git a/sdks/code-interpreter/javascript/src/factory/adapterFactory.ts b/sdks/code-interpreter/javascript/src/factory/adapterFactory.ts index 2363916c4..a561d0ca6 100644 --- a/sdks/code-interpreter/javascript/src/factory/adapterFactory.ts +++ b/sdks/code-interpreter/javascript/src/factory/adapterFactory.ts @@ -18,6 +18,7 @@ import type { Codes } from "../services/codes.js"; export interface CreateCodesStackOptions { sandbox: Sandbox; execdBaseUrl: string; + endpointHeaders?: Record; } /** @@ -25,4 +26,4 @@ export interface CreateCodesStackOptions { */ export interface AdapterFactory { createCodes(opts: CreateCodesStackOptions): Codes; -} \ No newline at end of file +} diff --git a/sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts b/sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts index 08bb06361..9ddc1dcc4 100644 --- a/sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts +++ b/sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts @@ -19,15 +19,19 @@ import type { Codes } from "../services/codes.js"; export class DefaultAdapterFactory implements AdapterFactory { createCodes(opts: CreateCodesStackOptions): Codes { + const headers: Record = { + ...(opts.sandbox.connectionConfig.headers ?? {}), + ...(opts.endpointHeaders ?? {}), + }; const client = createExecdClient({ baseUrl: opts.execdBaseUrl, - headers: opts.sandbox.connectionConfig.headers, + headers, fetch: opts.sandbox.connectionConfig.fetch, }); return new CodesAdapter(client, { baseUrl: opts.execdBaseUrl, - headers: opts.sandbox.connectionConfig.headers, + headers, // Streaming calls (SSE) use a dedicated fetch, aligned with Kotlin/Python SDKs. fetch: opts.sandbox.connectionConfig.sseFetch, }); @@ -36,4 +40,4 @@ export class DefaultAdapterFactory implements AdapterFactory { export function createDefaultAdapterFactory(): AdapterFactory { return new DefaultAdapterFactory(); -} \ No newline at end of file +} diff --git a/sdks/code-interpreter/javascript/src/interpreter.ts b/sdks/code-interpreter/javascript/src/interpreter.ts index 0034568fc..e235f867b 100644 --- a/sdks/code-interpreter/javascript/src/interpreter.ts +++ b/sdks/code-interpreter/javascript/src/interpreter.ts @@ -39,9 +39,14 @@ export class CodeInterpreter { ) {} static async create(sandbox: Sandbox, opts: CodeInterpreterCreateOptions = {}): Promise { - const execdBaseUrl = await sandbox.getEndpointUrl(DEFAULT_EXECD_PORT); + const endpoint = await sandbox.getEndpoint(DEFAULT_EXECD_PORT); + const execdBaseUrl = `${sandbox.connectionConfig.protocol}://${endpoint.endpoint}`; const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory(); - const codes = adapterFactory.createCodes({ sandbox, execdBaseUrl }); + const codes = adapterFactory.createCodes({ + sandbox, + execdBaseUrl, + endpointHeaders: endpoint.headers, + }); return new CodeInterpreter(sandbox, codes); } @@ -61,4 +66,4 @@ export class CodeInterpreter { get metrics() { return this.sandbox.metrics; } -} \ No newline at end of file +} diff --git a/sdks/code-interpreter/javascript/tests/defaultAdapterFactory.headers.test.mjs b/sdks/code-interpreter/javascript/tests/defaultAdapterFactory.headers.test.mjs new file mode 100644 index 000000000..f3e626391 --- /dev/null +++ b/sdks/code-interpreter/javascript/tests/defaultAdapterFactory.headers.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DefaultAdapterFactory } from "../dist/index.js"; + +test("DefaultAdapterFactory merges sandbox and endpoint headers for code requests", async () => { + const recorded = []; + const fetchImpl = async (input, init = {}) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + const headers = Object.fromEntries(request.headers.entries()); + recorded.push({ + url: request.url, + method: request.method, + headers, + }); + + if (url.pathname === "/code/context") { + return new Response(JSON.stringify({ id: "ctx-1", language: "python" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + return new Response( + [ + JSON.stringify({ type: "stdout", text: "hello", timestamp: 1 }), + JSON.stringify({ type: "execution_complete", execution_time: 2, timestamp: 2 }), + ].join("\n"), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + } + ); + }; + + const sandbox = { + connectionConfig: { + headers: { "x-global": "global" }, + fetch: fetchImpl, + sseFetch: fetchImpl, + }, + }; + + const factory = new DefaultAdapterFactory(); + const codes = factory.createCodes({ + sandbox, + execdBaseUrl: "http://sandbox.internal:3456", + endpointHeaders: { "x-endpoint": "endpoint" }, + }); + + const context = await codes.createContext("python"); + assert.equal(context.id, "ctx-1"); + + const execution = await codes.run("print('hello')"); + assert.equal(execution.logs.stdout[0]?.text, "hello"); + + assert.equal(recorded.length, 2); + assert.equal(recorded[0].url, "http://sandbox.internal:3456/code/context"); + assert.equal(recorded[0].headers["x-global"], "global"); + assert.equal(recorded[0].headers["x-endpoint"], "endpoint"); + assert.equal(recorded[1].url, "http://sandbox.internal:3456/code"); + assert.equal(recorded[1].headers["x-global"], "global"); + assert.equal(recorded[1].headers["x-endpoint"], "endpoint"); + assert.equal(recorded[1].headers.accept, "text/event-stream"); +}); diff --git a/sdks/code-interpreter/javascript/tests/interpreter.headers.test.mjs b/sdks/code-interpreter/javascript/tests/interpreter.headers.test.mjs new file mode 100644 index 000000000..399c3435c --- /dev/null +++ b/sdks/code-interpreter/javascript/tests/interpreter.headers.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CodeInterpreter } from "../dist/index.js"; +import { DEFAULT_EXECD_PORT } from "../../../sandbox/javascript/dist/index.js"; + +test("CodeInterpreter.create forwards endpoint headers to adapter factory", async () => { + const calls = []; + const sandbox = { + connectionConfig: { + protocol: "https", + headers: { "x-global": "global" }, + }, + async getEndpoint(port) { + assert.equal(port, DEFAULT_EXECD_PORT); + return { + endpoint: "sandbox.internal:3456", + headers: { "x-endpoint": "endpoint" }, + }; + }, + }; + const codes = { kind: "codes" }; + const adapterFactory = { + createCodes(opts) { + calls.push(opts); + return codes; + }, + }; + + const interpreter = await CodeInterpreter.create(sandbox, { adapterFactory }); + + assert.equal(interpreter.codes, codes); + assert.equal(calls.length, 1); + assert.equal(calls[0].execdBaseUrl, "https://sandbox.internal:3456"); + assert.deepEqual(calls[0].endpointHeaders, { "x-endpoint": "endpoint" }); +}); diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt index 02f58e5b1..5f7551158 100644 --- a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt +++ b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt @@ -34,6 +34,7 @@ import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.Executi import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.jsonParser import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.parseSandboxError import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException +import okhttp3.Headers.Companion.toHeaders import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -49,8 +50,19 @@ class CodesAdapter( } private val logger = LoggerFactory.getLogger(CodesAdapter::class.java) + private val baseUrl = "${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}" + private val apiClient = + httpClientProvider.httpClient.newBuilder() + .addInterceptor { chain -> + val requestBuilder = chain.request().newBuilder() + execdEndpoint.headers.forEach { (key, value) -> + requestBuilder.header(key, value) + } + chain.proceed(requestBuilder.build()) + } + .build() private val api = - CodeInterpretingApi("${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}", httpClientProvider.httpClient) + CodeInterpretingApi(baseUrl, apiClient) override fun getContext(id: String): CodeContext { try { @@ -109,10 +121,11 @@ class CodesAdapter( val apiRequest = request.toApiRunCodeRequest() val httpRequest = Request.Builder() - .url("${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}$RUN_CODE_PATH") + .url("$baseUrl$RUN_CODE_PATH") .post( jsonParser.encodeToString(apiRequest).toRequestBody("application/json".toMediaType()), ) + .headers(execdEndpoint.headers.toHeaders()) .build() val execution = Execution() diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt index f6fc793f5..2f93d1dc8 100644 --- a/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt +++ b/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt @@ -80,6 +80,32 @@ class CodesAdapterTest { assertEquals("/code/context", request.path) } + @Test + fun `createContext should include endpoint headers`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"id":"ctx-123", "language":"python"}"""), + ) + + val host = mockWebServer.hostName + val port = mockWebServer.port + val config = + ConnectionConfig.builder() + .domain("$host:$port") + .protocol("http") + .build() + val endpoint = SandboxEndpoint("$host:$port", mapOf("X-Endpoint" to "endpoint")) + + HttpClientProvider(config).use { provider -> + val adapter = CodesAdapter(endpoint, provider) + adapter.createContext("python") + } + + val request = mockWebServer.takeRequest() + assertEquals("endpoint", request.getHeader("X-Endpoint")) + } + @Test fun `run should stream events correctly`() { // SSE format @@ -124,6 +150,41 @@ class CodesAdapterTest { assertEquals("POST", recordedRequest.method) } + @Test + fun `run should include endpoint headers`() { + val event1 = """{"type":"stdout","text":"Hello World","timestamp":1672531200000}""" + val event2 = """{"type":"execution_complete","execution_time":100,"timestamp":1672531201000}""" + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("$event1\n$event2\n"), + ) + + val host = mockWebServer.hostName + val port = mockWebServer.port + val config = + ConnectionConfig.builder() + .domain("$host:$port") + .protocol("http") + .build() + val endpoint = SandboxEndpoint("$host:$port", mapOf("X-Endpoint" to "endpoint")) + + HttpClientProvider(config).use { provider -> + val adapter = CodesAdapter(endpoint, provider) + val request = + RunCodeRequest.builder() + .code("print('Hello World')") + .handlers(ExecutionHandlers.builder().build()) + .build() + + adapter.run(request) + } + + val recordedRequest = mockWebServer.takeRequest() + assertEquals("endpoint", recordedRequest.getHeader("X-Endpoint")) + } + @Test fun `interrupt should send correct request`() { mockWebServer.enqueue(MockResponse().setResponseCode(204)) diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs new file mode 100644 index 000000000..72554325f --- /dev/null +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs @@ -0,0 +1,254 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using OpenSandbox.Config; +using OpenSandbox.Core; +using OpenSandbox.Factory; +using OpenSandbox.Models; +using OpenSandbox.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace OpenSandbox.Tests; + +public class SandboxEgressLifecycleTests +{ + [Fact] + public async Task CreateAsync_ShouldBuildEgressStackOnce_AndReuseItForOperations() + { + var sandboxes = new StubSandboxes(); + var egress = new StubEgress(); + var adapterFactory = new StubAdapterFactory(sandboxes, egress); + + var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions + { + Image = "python:3.12", + ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions + { + Domain = "127.0.0.1:8080", + Protocol = ConnectionProtocol.Http + }), + AdapterFactory = adapterFactory, + SkipHealthCheck = true, + Diagnostics = new SdkDiagnosticsOptions + { + LoggerFactory = NullLoggerFactory.Instance + } + }); + + await sandbox.GetEgressPolicyAsync(); + await sandbox.PatchEgressRulesAsync([new NetworkRule + { + Action = NetworkRuleAction.Allow, + Target = "www.github.com" + }]); + + sandboxes.EndpointCalls.Should().Equal(Constants.DefaultExecdPort, Constants.DefaultEgressPort); + adapterFactory.EgressStackCallCount.Should().Be(1); + adapterFactory.LastEgressBaseUrl.Should().Be($"http://127.0.0.1:{Constants.DefaultEgressPort}"); + egress.GetPolicyCallCount.Should().Be(1); + egress.PatchRulesCallCount.Should().Be(1); + } + + private sealed class StubAdapterFactory : IAdapterFactory + { + private readonly ISandboxes _sandboxes; + private readonly IEgress _egress; + + public StubAdapterFactory(ISandboxes sandboxes, IEgress egress) + { + _sandboxes = sandboxes; + _egress = egress; + } + + public int EgressStackCallCount { get; private set; } + + public string? LastEgressBaseUrl { get; private set; } + + public LifecycleStack CreateLifecycleStack(CreateLifecycleStackOptions options) + { + return new LifecycleStack + { + Sandboxes = _sandboxes + }; + } + + public ExecdStack CreateExecdStack(CreateExecdStackOptions options) + { + return new ExecdStack + { + Commands = new StubCommands(), + Files = new StubFiles(), + Health = new StubHealth(), + Metrics = new StubMetrics() + }; + } + + public EgressStack CreateEgressStack(CreateEgressStackOptions options) + { + EgressStackCallCount++; + LastEgressBaseUrl = options.EgressBaseUrl; + return new EgressStack + { + Egress = _egress + }; + } + } + + private sealed class StubSandboxes : ISandboxes + { + public List EndpointCalls { get; } = new(); + + public Task CreateSandboxAsync(CreateSandboxRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(new CreateSandboxResponse + { + Id = "sandbox-test-id", + Status = new SandboxStatus + { + State = "Running" + }, + CreatedAt = DateTime.UtcNow, + Entrypoint = ["/bin/sh"] + }); + } + + public Task GetSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task ListSandboxesAsync(ListSandboxesParams? @params = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task DeleteSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task PauseSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task ResumeSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task RenewSandboxExpirationAsync(string sandboxId, RenewSandboxExpirationRequest request, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task GetSandboxEndpointAsync(string sandboxId, int port, bool useServerProxy = false, CancellationToken cancellationToken = default) + { + EndpointCalls.Add(port); + return Task.FromResult(new Endpoint + { + EndpointAddress = $"127.0.0.1:{port}", + Headers = new Dictionary + { + ["X-Port"] = port.ToString() + } + }); + } + } + + private sealed class StubEgress : IEgress + { + public int GetPolicyCallCount { get; private set; } + + public int PatchRulesCallCount { get; private set; } + + public Task GetPolicyAsync(CancellationToken cancellationToken = default) + { + GetPolicyCallCount++; + return Task.FromResult(new NetworkPolicy + { + DefaultAction = NetworkRuleAction.Deny, + Egress = [new NetworkRule + { + Action = NetworkRuleAction.Allow, + Target = "pypi.org" + }] + }); + } + + public Task PatchRulesAsync(IReadOnlyList rules, CancellationToken cancellationToken = default) + { + PatchRulesCallCount++; + return Task.CompletedTask; + } + } + + private sealed class StubCommands : IExecdCommands + { + public IAsyncEnumerable RunStreamAsync(string command, RunCommandOptions? options = null, CancellationToken cancellationToken = default) => + AsyncEnumerable.Empty(); + + public Task RunAsync(string command, RunCommandOptions? options = null, ExecutionHandlers? handlers = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task GetCommandStatusAsync(string executionId, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task GetBackgroundCommandLogsAsync(string executionId, long? cursor = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task InterruptAsync(string executionId, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class StubFiles : ISandboxFiles + { + public Task> GetFileInfoAsync(IEnumerable paths, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> SearchAsync(SearchEntry entry, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task CreateDirectoriesAsync(IEnumerable entries, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task DeleteDirectoriesAsync(IEnumerable paths, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task WriteFilesAsync(IEnumerable entries, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task ReadFileAsync(string path, ReadFileOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task ReadBytesAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public IAsyncEnumerable ReadBytesStreamAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) => + AsyncEnumerable.Empty(); + + public Task DeleteFilesAsync(IEnumerable paths, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task MoveFilesAsync(IEnumerable entries, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task ReplaceContentsAsync(IEnumerable entries, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task SetPermissionsAsync(IEnumerable entries, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class StubHealth : IExecdHealth + { + public Task PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + } + + private sealed class StubMetrics : IExecdMetrics + { + public Task GetMetricsAsync(CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } +} From 962441a71775af3390109c9e7c9d0c4f2e06acd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Thu, 19 Mar 2026 17:53:07 +0800 Subject: [PATCH 2/3] feat: egress endpoint authentication --- server/src/services/constants.py | 4 + server/src/services/docker.py | 41 ++++++- server/src/services/endpoint_auth.py | 51 +++++++++ .../services/k8s/agent_sandbox_provider.py | 8 +- .../src/services/k8s/batchsandbox_provider.py | 5 + server/src/services/k8s/egress_helper.py | 24 +++- server/src/services/k8s/kubernetes_service.py | 34 +++++- server/src/services/k8s/workload_provider.py | 2 + .../tests/k8s/test_agent_sandbox_provider.py | 32 ++++++ .../tests/k8s/test_batchsandbox_provider.py | 32 ++++++ server/tests/k8s/test_egress_helper.py | 16 +++ server/tests/k8s/test_kubernetes_service.py | 58 +++++++++- server/tests/test_docker_endpoint.py | 55 +++++++++ server/tests/test_docker_service.py | 10 ++ server/tests/test_endpoint_auth.py | 56 +++++++++ server/tests/test_routes_proxy.py | 66 +++++++++++ .../OpenSandbox.E2ETests/E2ETestFixture.cs | 11 ++ .../OpenSandbox.E2ETests/SandboxE2ETests.cs | 70 ++++++++++++ .../alibaba/opensandbox/e2e/BaseE2ETest.java | 11 ++ .../opensandbox/e2e/SandboxE2ETest.java | 106 ++++++++++++++++++ tests/javascript/tests/base_e2e.ts | 7 +- .../javascript/tests/test_sandbox_e2e.test.ts | 47 ++++++++ tests/python/tests/base_e2e_test.py | 29 +++++ tests/python/tests/test_sandbox_e2e.py | 64 ++++++++++- 24 files changed, 821 insertions(+), 18 deletions(-) create mode 100644 server/src/services/endpoint_auth.py create mode 100644 server/tests/test_endpoint_auth.py diff --git a/server/src/services/constants.py b/server/src/services/constants.py index ce66220ce..08459eaa2 100644 --- a/server/src/services/constants.py +++ b/server/src/services/constants.py @@ -24,6 +24,8 @@ SANDBOX_HTTP_PORT_LABEL = "opensandbox.io/http-port" # maps container 8080 -> host port SANDBOX_OSSFS_MOUNTS_LABEL = "opensandbox.io/ossfs-mounts" OPEN_SANDBOX_INGRESS_HEADER = "OpenSandbox-Ingress-To" +OPEN_SANDBOX_EGRESS_AUTH_HEADER = "OPENSANDBOX-EGRESS-AUTH" +SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY = "opensandbox.io/egress-auth-token" class SandboxErrorCodes: """Canonical error codes for sandbox service operations.""" @@ -97,5 +99,7 @@ class SandboxErrorCodes: "SANDBOX_HTTP_PORT_LABEL", "SANDBOX_OSSFS_MOUNTS_LABEL", "OPEN_SANDBOX_INGRESS_HEADER", + "OPEN_SANDBOX_EGRESS_AUTH_HEADER", + "SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY", "SandboxErrorCodes", ] diff --git a/server/src/services/docker.py b/server/src/services/docker.py index 5526ce1b8..34661eea0 100644 --- a/server/src/services/docker.py +++ b/server/src/services/docker.py @@ -59,6 +59,7 @@ ) from src.config import AppConfig, get_config from src.services.constants import ( + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY, SANDBOX_EMBEDDING_PROXY_PORT_LABEL, SANDBOX_EXPIRES_AT_LABEL, SANDBOX_HTTP_PORT_LABEL, @@ -67,6 +68,11 @@ SANDBOX_OSSFS_MOUNTS_LABEL, SandboxErrorCodes, ) +from src.services.endpoint_auth import ( + build_egress_auth_headers, + generate_egress_token, + merge_endpoint_headers, +) from src.services.helpers import ( matches_filter, parse_memory_limit, @@ -949,6 +955,7 @@ def _provision_sandbox( labels, environment = self._build_labels_and_env(sandbox_id, request, expires_at) image_uri, auth_config = self._resolve_image_auth(request, sandbox_id) mem_limit, nano_cpus = self._resolve_resource_limits(request) + egress_token: Optional[str] = None # Prepare OSSFS mounts first so binds can reference mounted host paths. ossfs_mount_keys = self._prepare_ossfs_mounts(request.volumes) @@ -969,10 +976,13 @@ def _provision_sandbox( exposed_ports: Optional[list[str]] = None if request.network_policy: + egress_token = generate_egress_token() + labels[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] = egress_token host_execd_port, host_http_port = self._allocate_distinct_host_ports() sidecar_container = self._start_egress_sidecar( sandbox_id=sandbox_id, network_policy=request.network_policy, + egress_token=egress_token, host_execd_port=host_execd_port, host_http_port=host_http_port, ) @@ -1713,7 +1723,13 @@ def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = Fals public_host = self._resolve_public_host() if self.network_mode == HOST_NETWORK_MODE: - return Endpoint(endpoint=f"{public_host}:{port}") + endpoint = Endpoint(endpoint=f"{public_host}:{port}") + container = self._get_container_by_sandbox_id(sandbox_id) + self._attach_egress_auth_headers( + endpoint, + (container.attrs.get("Config", {}).get("Labels") or {}), + ) + return endpoint # non-host mode (bridge / user-defined network) container = self._get_container_by_sandbox_id(sandbox_id) @@ -1746,7 +1762,22 @@ def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = Fals "message": "Missing host port mapping for execd proxy port 44772.", }, ) - return Endpoint(endpoint=f"{public_host}:{execd_host_port}/proxy/{port}") + endpoint = Endpoint(endpoint=f"{public_host}:{execd_host_port}/proxy/{port}") + self._attach_egress_auth_headers(endpoint, labels) + return endpoint + + def _attach_egress_auth_headers( + self, + endpoint: Endpoint, + labels: dict[str, str], + ) -> None: + token = labels.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY) + if not token: + return + endpoint.headers = merge_endpoint_headers( + endpoint.headers, + build_egress_auth_headers(token), + ) def _get_docker_host_ip(self) -> Optional[str]: """When running inside a container, return [docker].host_ip for endpoint URLs (if set).""" @@ -1907,6 +1938,7 @@ def _start_egress_sidecar( self, sandbox_id: str, network_policy: NetworkPolicy, + egress_token: str, host_execd_port: int, host_http_port: int, ): @@ -1922,7 +1954,10 @@ def _start_egress_sidecar( self._ensure_image_available(egress_image, None, sandbox_id) policy_payload = json.dumps(network_policy.model_dump(by_alias=True, exclude_none=True)) - sidecar_env = [f"{EGRESS_RULES_ENV}={policy_payload}"] + sidecar_env = [ + f"{EGRESS_RULES_ENV}={policy_payload}", + f"OPENSANDBOX_EGRESS_TOKEN={egress_token}", + ] sidecar_host_config_kwargs: dict[str, Any] = { "network_mode": BRIDGE_NETWORK_MODE, diff --git a/server/src/services/endpoint_auth.py b/server/src/services/endpoint_auth.py new file mode 100644 index 000000000..839e0ba65 --- /dev/null +++ b/server/src/services/endpoint_auth.py @@ -0,0 +1,51 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helpers for sandbox endpoint authentication.""" + +from __future__ import annotations + +import secrets + +from src.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER + +EGRESS_AUTH_TOKEN_BYTES = 24 + + +def generate_egress_token() -> str: + """Return a random URL-safe token for egress endpoint auth.""" + return secrets.token_urlsafe(EGRESS_AUTH_TOKEN_BYTES) + + +def build_egress_auth_headers(token: str) -> dict[str, str]: + """Build endpoint headers for egress auth.""" + return {OPEN_SANDBOX_EGRESS_AUTH_HEADER: token} + + +def merge_endpoint_headers( + existing: dict[str, str] | None, + extra: dict[str, str], +) -> dict[str, str]: + """Merge auth headers into existing endpoint headers without mutating input.""" + merged: dict[str, str] = dict(existing or {}) + merged.update(extra) + return merged + + +__all__ = [ + "EGRESS_AUTH_TOKEN_BYTES", + "build_egress_auth_headers", + "generate_egress_token", + "merge_endpoint_headers", +] diff --git a/server/src/services/k8s/agent_sandbox_provider.py b/server/src/services/k8s/agent_sandbox_provider.py index fccb049a4..b1e25a316 100644 --- a/server/src/services/k8s/agent_sandbox_provider.py +++ b/server/src/services/k8s/agent_sandbox_provider.py @@ -134,6 +134,8 @@ def create_workload( network_policy: Optional[NetworkPolicy] = None, egress_image: Optional[str] = None, volumes: Optional[List[Volume]] = None, + annotations: Optional[Dict[str, str]] = None, + egress_auth_token: Optional[str] = None, ) -> Dict[str, Any]: """Create an agent-sandbox Sandbox CRD workload.""" if self.runtime_class: @@ -151,6 +153,7 @@ def create_workload( execd_image=execd_image, network_policy=network_policy, egress_image=egress_image, + egress_auth_token=egress_auth_token, ) # Add user-specified volumes if provided @@ -181,6 +184,8 @@ def create_workload( }, "spec": spec, } + if annotations: + runtime_manifest["metadata"]["annotations"] = annotations sandbox = self.template_manager.merge_with_runtime_values(runtime_manifest) # Set or strip shutdownTime after merge so we override any template value @@ -211,6 +216,7 @@ def _build_pod_spec( execd_image: str, network_policy: Optional[NetworkPolicy] = None, egress_image: Optional[str] = None, + egress_auth_token: Optional[str] = None, ) -> Dict[str, Any]: """Build pod spec dict for the Sandbox CRD.""" init_container = self._build_execd_init_container(execd_image) @@ -247,6 +253,7 @@ def _build_pod_spec( containers=containers, network_policy=network_policy, egress_image=egress_image, + egress_auth_token=egress_auth_token, ) return pod_spec @@ -560,4 +567,3 @@ def get_endpoint_info(self, workload: Dict[str, Any], port: int, sandbox_id: str return Endpoint(endpoint=f"{service_fqdn}:{port}") return None - diff --git a/server/src/services/k8s/batchsandbox_provider.py b/server/src/services/k8s/batchsandbox_provider.py index d098211e7..a4a2eab4c 100644 --- a/server/src/services/k8s/batchsandbox_provider.py +++ b/server/src/services/k8s/batchsandbox_provider.py @@ -113,6 +113,8 @@ def create_workload( network_policy: Optional[NetworkPolicy] = None, egress_image: Optional[str] = None, volumes: Optional[List[Volume]] = None, + annotations: Optional[Dict[str, str]] = None, + egress_auth_token: Optional[str] = None, ) -> Dict[str, Any]: """ Create a BatchSandbox workload. @@ -220,6 +222,7 @@ def create_workload( containers=containers, network_policy=network_policy, egress_image=egress_image, + egress_auth_token=egress_auth_token, ) # Add user-specified volumes if provided @@ -242,6 +245,8 @@ def create_workload( }, "spec": spec, } + if annotations: + runtime_manifest["metadata"]["annotations"] = annotations # Merge with template to get final manifest batchsandbox = self.template_manager.merge_with_runtime_values(runtime_manifest) diff --git a/server/src/services/k8s/egress_helper.py b/server/src/services/k8s/egress_helper.py index 505c96c9c..0e3f5efe9 100644 --- a/server/src/services/k8s/egress_helper.py +++ b/server/src/services/k8s/egress_helper.py @@ -31,6 +31,7 @@ def build_egress_sidecar_container( egress_image: str, network_policy: NetworkPolicy, + egress_auth_token: Optional[str] = None, ) -> Dict[str, Any]: """ Build egress sidecar container specification for Kubernetes Pod. @@ -82,16 +83,25 @@ def build_egress_sidecar_container( network_policy.model_dump(by_alias=True, exclude_none=True) ) + env = [ + { + "name": EGRESS_RULES_ENV, + "value": policy_payload, + } + ] + if egress_auth_token: + env.append( + { + "name": "OPENSANDBOX_EGRESS_TOKEN", + "value": egress_auth_token, + } + ) + # Build container specification container_spec: Dict[str, Any] = { "name": "egress", "image": egress_image, - "env": [ - { - "name": EGRESS_RULES_ENV, - "value": policy_payload, - } - ], + "env": env, "securityContext": _build_security_context_for_egress(), } @@ -186,6 +196,7 @@ def apply_egress_to_spec( containers: List[Dict[str, Any]], network_policy: Optional[NetworkPolicy], egress_image: Optional[str], + egress_auth_token: Optional[str] = None, ) -> None: """ Apply egress sidecar configuration to Pod spec. @@ -225,6 +236,7 @@ def apply_egress_to_spec( sidecar_container = build_egress_sidecar_container( egress_image=egress_image, network_policy=network_policy, + egress_auth_token=egress_auth_token, ) containers.append(sidecar_container) diff --git a/server/src/services/k8s/kubernetes_service.py b/server/src/services/k8s/kubernetes_service.py index 8b3f1971b..288340fcd 100644 --- a/server/src/services/k8s/kubernetes_service.py +++ b/server/src/services/k8s/kubernetes_service.py @@ -41,10 +41,13 @@ ) from src.config import AppConfig, get_config from src.services.constants import ( + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY, SANDBOX_ID_LABEL, SANDBOX_MANUAL_CLEANUP_LABEL, SandboxErrorCodes, ) +from src.services.endpoint_auth import generate_egress_token +from src.services.endpoint_auth import build_egress_auth_headers, merge_endpoint_headers from src.services.helpers import matches_filter from src.services.sandbox_service import SandboxService from src.services.validators import ( @@ -285,6 +288,7 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse labels = { SANDBOX_ID_LABEL: sandbox_id, } + annotations: Dict[str, str] = {} if expires_at is None: labels[SANDBOX_MANUAL_CLEANUP_LABEL] = "true" @@ -300,8 +304,11 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse try: # Get egress image if network policy is provided egress_image = None + egress_auth_token = None if request.network_policy: egress_image = self.app_config.egress.image if self.app_config.egress else None + egress_auth_token = generate_egress_token() + annotations[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] = egress_auth_token # Validate volumes before creating workload ensure_volumes_valid( @@ -318,11 +325,13 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse env=request.env or {}, resource_limits=resource_limits, labels=labels, + annotations=annotations or None, expires_at=expires_at, execd_image=self.execd_image, extensions=request.extensions, network_policy=request.network_policy, egress_image=egress_image, + egress_auth_token=egress_auth_token, volumes=request.volumes, ) @@ -691,6 +700,7 @@ def get_endpoint( "message": "Pod IP is not yet available. The Pod may still be starting.", }, ) + self._attach_egress_auth_headers(endpoint, workload) return endpoint except HTTPException: @@ -704,7 +714,29 @@ def get_endpoint( "message": f"Failed to get endpoint: {str(e)}", }, ) from e - + + def _attach_egress_auth_headers(self, endpoint: Endpoint, workload: Any) -> None: + token = self._get_egress_auth_token(workload) + if not token: + return + + endpoint.headers = merge_endpoint_headers( + endpoint.headers, + build_egress_auth_headers(token), + ) + + def _get_egress_auth_token(self, workload: Any) -> Optional[str]: + if isinstance(workload, dict): + metadata = workload.get("metadata", {}) + annotations = metadata.get("annotations", {}) or {} + return annotations.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY) + + metadata = getattr(workload, "metadata", None) + annotations = getattr(metadata, "annotations", None) or {} + if isinstance(annotations, dict): + return annotations.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY) + return None + def _build_sandbox_from_workload(self, workload: Any) -> Sandbox: """ Build Sandbox object from Kubernetes workload. diff --git a/server/src/services/k8s/workload_provider.py b/server/src/services/k8s/workload_provider.py index cbd8a94b4..a73b326a6 100644 --- a/server/src/services/k8s/workload_provider.py +++ b/server/src/services/k8s/workload_provider.py @@ -47,6 +47,8 @@ def create_workload( network_policy: Optional[NetworkPolicy] = None, egress_image: Optional[str] = None, volumes: Optional[List[Volume]] = None, + annotations: Optional[Dict[str, str]] = None, + egress_auth_token: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new workload resource. diff --git a/server/tests/k8s/test_agent_sandbox_provider.py b/server/tests/k8s/test_agent_sandbox_provider.py index 630fdf66a..7f57247bb 100644 --- a/server/tests/k8s/test_agent_sandbox_provider.py +++ b/server/tests/k8s/test_agent_sandbox_provider.py @@ -25,6 +25,7 @@ from src.api.schema import ImageSpec, NetworkPolicy, NetworkRule from src.config import AppConfig, ExecdInitResources, KubernetesRuntimeConfig, AgentSandboxRuntimeConfig, RuntimeConfig +from src.services.constants import SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY from src.services.k8s.agent_sandbox_provider import AgentSandboxProvider @@ -535,6 +536,37 @@ def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client) assert "add" in sidecar["securityContext"]["capabilities"] assert "NET_ADMIN" in sidecar["securityContext"]["capabilities"]["add"] + def test_create_workload_with_network_policy_persists_annotation_and_sidecar_token(self, mock_k8s_client): + provider = AgentSandboxProvider(mock_k8s_client) + mock_k8s_client.create_custom_object.return_value = { + "metadata": {"name": "test-id", "uid": "test-uid"} + } + + provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri="python:3.11"), + entrypoint=["/bin/bash"], + env={}, + resource_limits={}, + labels={}, + expires_at=None, + execd_image="execd:latest", + network_policy=NetworkPolicy(default_action="deny", egress=[]), + egress_image="opensandbox/egress:v1.0.3", + annotations={SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: "egress-token"}, + egress_auth_token="egress-token", + ) + + body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] + assert body["metadata"]["annotations"][SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == "egress-token" + + containers = body["spec"]["podTemplate"]["spec"]["containers"] + sidecar = next((c for c in containers if c["name"] == "egress"), None) + assert sidecar is not None + env_vars = {e["name"]: e["value"] for e in sidecar.get("env", [])} + assert env_vars["OPENSANDBOX_EGRESS_TOKEN"] == "egress-token" + def test_create_workload_with_network_policy_adds_ipv6_disable_sysctls(self, mock_k8s_client): """ Test case: Verify IPv6 disable sysctls are added to Pod spec diff --git a/server/tests/k8s/test_batchsandbox_provider.py b/server/tests/k8s/test_batchsandbox_provider.py index 61cb105dd..654556ed1 100644 --- a/server/tests/k8s/test_batchsandbox_provider.py +++ b/server/tests/k8s/test_batchsandbox_provider.py @@ -23,6 +23,7 @@ from src.api.schema import ImageSpec, ImageAuth, NetworkPolicy, NetworkRule from src.config import AppConfig, ExecdInitResources, KubernetesRuntimeConfig, RuntimeConfig +from src.services.constants import SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY from src.services.k8s.batchsandbox_provider import BatchSandboxProvider from src.services.k8s.image_pull_secret_helper import IMAGE_AUTH_SECRET_PREFIX from src.services.k8s.volume_helper import apply_volumes_to_pod_spec @@ -1241,6 +1242,37 @@ def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client) assert "add" in sidecar["securityContext"]["capabilities"] assert "NET_ADMIN" in sidecar["securityContext"]["capabilities"]["add"] + def test_create_workload_with_network_policy_persists_annotation_and_sidecar_token(self, mock_k8s_client): + provider = BatchSandboxProvider(mock_k8s_client) + mock_k8s_client.create_custom_object.return_value = { + "metadata": {"name": "test-id", "uid": "test-uid"} + } + + provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri="python:3.11"), + entrypoint=["/bin/bash"], + env={}, + resource_limits={}, + labels={}, + expires_at=None, + execd_image="execd:latest", + network_policy=NetworkPolicy(default_action="deny", egress=[]), + egress_image="opensandbox/egress:v1.0.3", + annotations={SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: "egress-token"}, + egress_auth_token="egress-token", + ) + + body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] + assert body["metadata"]["annotations"][SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == "egress-token" + + containers = body["spec"]["template"]["spec"]["containers"] + sidecar = next((c for c in containers if c["name"] == "egress"), None) + assert sidecar is not None + env_vars = {e["name"]: e["value"] for e in sidecar.get("env", [])} + assert env_vars["OPENSANDBOX_EGRESS_TOKEN"] == "egress-token" + def test_create_workload_with_network_policy_adds_ipv6_disable_sysctls(self, mock_k8s_client): """ Test case: Verify IPv6 disable sysctls are added to Pod spec diff --git a/server/tests/k8s/test_egress_helper.py b/server/tests/k8s/test_egress_helper.py index 113bbd49d..72c49dc42 100644 --- a/server/tests/k8s/test_egress_helper.py +++ b/server/tests/k8s/test_egress_helper.py @@ -63,6 +63,22 @@ def test_contains_egress_rules_environment_variable(self): assert env_vars[0]["name"] == EGRESS_RULES_ENV assert env_vars[0]["value"] is not None + def test_contains_egress_token_when_provided(self): + egress_image = "opensandbox/egress:v1.0.3" + network_policy = NetworkPolicy( + default_action="deny", + egress=[NetworkRule(action="allow", target="example.com")], + ) + + container = build_egress_sidecar_container( + egress_image, + network_policy, + egress_auth_token="egress-token", + ) + + env_vars = {env["name"]: env["value"] for env in container["env"]} + assert env_vars["OPENSANDBOX_EGRESS_TOKEN"] == "egress-token" + def test_serializes_network_policy_correctly(self): """Test that network policy is correctly serialized to JSON.""" egress_image = "opensandbox/egress:v1.0.3" diff --git a/server/tests/k8s/test_kubernetes_service.py b/server/tests/k8s/test_kubernetes_service.py index a05119798..7c642a0f7 100644 --- a/server/tests/k8s/test_kubernetes_service.py +++ b/server/tests/k8s/test_kubernetes_service.py @@ -22,8 +22,15 @@ from fastapi import HTTPException from src.services.k8s.kubernetes_service import KubernetesSandboxService -from src.services.constants import SANDBOX_MANUAL_CLEANUP_LABEL, SandboxErrorCodes -from src.api.schema import ImageAuth, ListSandboxesRequest +from src.services.constants import ( + OPEN_SANDBOX_EGRESS_AUTH_HEADER, + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY, + SANDBOX_MANUAL_CLEANUP_LABEL, + SandboxErrorCodes, +) +from src.api.schema import ImageAuth, ListSandboxesRequest, NetworkPolicy +from src.config import EgressConfig +from src.api.schema import Endpoint class TestKubernetesSandboxServiceInit: @@ -215,6 +222,53 @@ def test_create_sandbox_with_no_timeout_calls_provider_with_expires_at_none_and_ assert kwargs["expires_at"] is None assert kwargs["labels"].get(SANDBOX_MANUAL_CLEANUP_LABEL) == "true" + def test_create_sandbox_with_network_policy_passes_egress_token_and_annotations( + self, k8s_service, create_sandbox_request + ): + create_sandbox_request.network_policy = NetworkPolicy(default_action="deny", egress=[]) + k8s_service.app_config.egress = EgressConfig(image="opensandbox/egress:v1.0.3") + k8s_service.workload_provider.create_workload.return_value = { + "name": "test-id", "uid": "uid-1" + } + k8s_service.workload_provider.get_workload.return_value = MagicMock() + k8s_service.workload_provider.get_status.return_value = { + "state": "Running", "reason": "", "message": "", + "last_transition_at": datetime.now(timezone.utc), + } + + with patch( + "src.services.k8s.kubernetes_service.generate_egress_token", + return_value="egress-token", + ): + k8s_service.create_sandbox(create_sandbox_request) + + _, kwargs = k8s_service.workload_provider.create_workload.call_args + assert kwargs["egress_auth_token"] == "egress-token" + assert kwargs["annotations"][SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == "egress-token" + + def test_get_endpoint_merges_egress_auth_header_from_instance_metadata( + self, k8s_service + ): + k8s_service.workload_provider.get_workload.return_value = { + "metadata": { + "annotations": { + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: "egress-token", + } + } + } + k8s_service.workload_provider.get_endpoint_info.return_value = Endpoint( + endpoint="gateway.example.com", + headers={"OpenSandbox-Ingress-To": "sbx-123-44772"}, + ) + + endpoint = k8s_service.get_endpoint("sbx-123", 44772) + + assert endpoint.endpoint == "gateway.example.com" + assert endpoint.headers == { + "OpenSandbox-Ingress-To": "sbx-123-44772", + OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token", + } + def test_create_sandbox_rejects_timeout_above_configured_maximum( self, k8s_service, create_sandbox_request ): diff --git a/server/tests/test_docker_endpoint.py b/server/tests/test_docker_endpoint.py index 161e4759b..f1c0cfdc4 100644 --- a/server/tests/test_docker_endpoint.py +++ b/server/tests/test_docker_endpoint.py @@ -15,6 +15,11 @@ import pytest from unittest.mock import MagicMock, patch +from src.services.constants import ( + OPEN_SANDBOX_EGRESS_AUTH_HEADER, + SANDBOX_EMBEDDING_PROXY_PORT_LABEL, + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY, +) from src.services.docker import DockerSandboxService from src.config import AppConfig, RuntimeConfig, DockerConfig, ServerConfig @@ -103,6 +108,56 @@ def test_get_endpoint_bridge_other_port_via_execd(mock_docker_service): assert endpoint.endpoint == "192.168.1.100:50002/proxy/6000" +def test_get_endpoint_bridge_egress_port_includes_auth_header(mock_docker_service): + service, mock_client = mock_docker_service + service.app_config.docker.network_mode = "bridge" + service.network_mode = "bridge" + + labels = { + "opensandbox.io/embedding-proxy-port": "50002", + "opensandbox.io/http-port": "50001", + "opensandbox.io/egress-auth-token": "egress-token", + } + mock_container = MagicMock() + mock_container.attrs = { + "State": {"Running": True}, + "Config": {"Labels": labels}, + "NetworkSettings": {"IPAddress": "172.17.0.5"}, + } + mock_client.containers.list.return_value = [mock_container] + + with patch("src.services.sandbox_service.SandboxService._resolve_bind_ip", return_value="192.168.1.100"): + endpoint = service.get_endpoint("sbx-123", 18080, resolve_internal=False) + + assert endpoint.endpoint == "192.168.1.100:50002/proxy/18080" + assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"} + + +def test_get_endpoint_bridge_non_egress_port_still_includes_instance_auth_header( + mock_docker_service, +): + service, mock_client = mock_docker_service + service.app_config.docker.network_mode = "bridge" + service.network_mode = "bridge" + + labels = { + SANDBOX_EMBEDDING_PROXY_PORT_LABEL: "50002", + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: "egress-token", + } + mock_container = MagicMock() + mock_container.attrs = { + "State": {"Running": True}, + "Config": {"Labels": labels}, + "NetworkSettings": {"IPAddress": "172.17.0.5"}, + } + mock_client.containers.list.return_value = [mock_container] + + with patch("src.services.sandbox_service.SandboxService._resolve_bind_ip", return_value="192.168.1.100"): + endpoint = service.get_endpoint("sbx-123", 44772, resolve_internal=False) + + assert endpoint.endpoint == "192.168.1.100:50002/proxy/44772" + assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"} + def test_get_endpoint_bridge_internal_resolution(mock_docker_service): service, mock_client = mock_docker_service service.app_config.docker.network_mode = "bridge" diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py index 54ecfeea2..c414e7ca3 100644 --- a/server/tests/test_docker_service.py +++ b/server/tests/test_docker_service.py @@ -31,6 +31,7 @@ IngressConfig, ) from src.services.constants import ( + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY, SANDBOX_EXPIRES_AT_LABEL, SANDBOX_ID_LABEL, SANDBOX_MANUAL_CLEANUP_LABEL, @@ -367,6 +368,8 @@ def host_cfg_side_effect(**kwargs): ) with ( + patch("src.services.docker.generate_egress_token", return_value="egress-token"), + patch.object(service, "_allocate_distinct_host_ports", return_value=(44772, 8080)), patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): @@ -392,6 +395,10 @@ def host_cfg_side_effect(**kwargs): labels = main_kwargs["labels"] assert labels.get("opensandbox.io/embedding-proxy-port") assert labels.get("opensandbox.io/http-port") + assert labels[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == "egress-token" + + sidecar_env = sidecar_kwargs["environment"] + assert "OPENSANDBOX_EGRESS_TOKEN=egress-token" in sidecar_env # --------------------------------------------------------------------------- @@ -544,6 +551,7 @@ def host_cfg_side_effect(**kwargs): service._start_egress_sidecar( "sandbox-id", NetworkPolicy(defaultAction="deny", egress=[]), + egress_token="egress-token", host_execd_port=44772, host_http_port=8080, ) @@ -584,6 +592,7 @@ def host_cfg_side_effect(**kwargs): service._start_egress_sidecar( "sandbox-id", NetworkPolicy(defaultAction="deny", egress=[]), + egress_token="egress-token", host_execd_port=44772, host_http_port=8080, ) @@ -626,6 +635,7 @@ def host_cfg_side_effect(**kwargs): service._start_egress_sidecar( "sandbox-id", NetworkPolicy(defaultAction="deny", egress=[]), + egress_token="egress-token", host_execd_port=44772, host_http_port=8080, ) diff --git a/server/tests/test_endpoint_auth.py b/server/tests/test_endpoint_auth.py new file mode 100644 index 000000000..97c89188a --- /dev/null +++ b/server/tests/test_endpoint_auth.py @@ -0,0 +1,56 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER +from src.services.endpoint_auth import ( + build_egress_auth_headers, + generate_egress_token, + merge_endpoint_headers, +) + + +def test_generate_egress_token_returns_random_urlsafe_strings() -> None: + first = generate_egress_token() + second = generate_egress_token() + + assert first + assert second + assert first != second + + +def test_build_egress_auth_headers_uses_expected_header_name() -> None: + token = "egress-token" + + assert build_egress_auth_headers(token) == { + OPEN_SANDBOX_EGRESS_AUTH_HEADER: token, + } + + +def test_merge_endpoint_headers_preserves_existing_headers() -> None: + existing = {"OpenSandbox-Ingress-To": "sbx-1-18080"} + extra = {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"} + + merged = merge_endpoint_headers(existing, extra) + + assert merged == { + "OpenSandbox-Ingress-To": "sbx-1-18080", + OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token", + } + assert existing == {"OpenSandbox-Ingress-To": "sbx-1-18080"} + + +def test_merge_endpoint_headers_handles_missing_existing_headers() -> None: + merged = merge_endpoint_headers(None, {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"}) + + assert merged == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"} diff --git a/server/tests/test_routes_proxy.py b/server/tests/test_routes_proxy.py index cc6c183fb..83cae40c7 100644 --- a/server/tests/test_routes_proxy.py +++ b/server/tests/test_routes_proxy.py @@ -17,6 +17,7 @@ from src.api import lifecycle from src.api.schema import Endpoint +from src.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER class _FakeStreamingResponse: @@ -338,3 +339,68 @@ def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> assert response.status_code == 500 assert "An internal error occurred in the proxy" in response.json()["message"] + + +def test_proxy_forwards_18080_without_server_side_egress_auth_check( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + class StubService: + @staticmethod + def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint: + assert port == 18080 + assert resolve_internal is True + return Endpoint(endpoint="10.57.1.91:18080") + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + fake_client = _FakeAsyncClient() + fake_client.response = _FakeStreamingResponse( + status_code=401, + headers={"content-type": "application/json"}, + chunks=[b'{"code":"UNAUTHORIZED"}'], + ) + client.app.state.http_client = fake_client + + response = client.get( + "/v1/sandboxes/sbx-123/proxy/18080/policy", + headers=auth_headers, + ) + + assert response.status_code == 401 + assert response.json()["code"] == "UNAUTHORIZED" + assert fake_client.built is not None + assert fake_client.built["url"] == "http://10.57.1.91:18080/policy" + + +def test_proxy_forwards_egress_auth_header_for_18080( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + class StubService: + @staticmethod + def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint: + assert port == 18080 + assert resolve_internal is True + return Endpoint(endpoint="10.57.1.91:18080") + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + fake_client = _FakeAsyncClient() + fake_client.response = _FakeStreamingResponse( + status_code=200, + headers={"content-type": "application/json"}, + chunks=[b'{"status":"ok"}'], + ) + client.app.state.http_client = fake_client + + response = client.get( + "/v1/sandboxes/sbx-123/proxy/18080/policy", + headers={**auth_headers, OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"}, + ) + + assert response.status_code == 200 + assert fake_client.built is not None + lowered_headers = {k.lower(): v for k, v in fake_client.built["headers"].items()} + assert lowered_headers[OPEN_SANDBOX_EGRESS_AUTH_HEADER.lower()] == "egress-token" diff --git a/tests/csharp/OpenSandbox.E2ETests/E2ETestFixture.cs b/tests/csharp/OpenSandbox.E2ETests/E2ETestFixture.cs index 925ddf3af..3adc2b51d 100644 --- a/tests/csharp/OpenSandbox.E2ETests/E2ETestFixture.cs +++ b/tests/csharp/OpenSandbox.E2ETests/E2ETestFixture.cs @@ -26,6 +26,8 @@ public sealed class E2ETestFixture : IAsyncLifetime public ConnectionConfig ConnectionConfig { get; } + public ConnectionConfig ServerProxyConnectionConfig { get; } + public int DefaultTimeoutSeconds { get; } = 1200; public int DefaultReadyTimeoutSeconds { get; } = 90; @@ -62,6 +64,15 @@ public E2ETestFixture() ApiKey = apiKey, RequestTimeoutSeconds = 180 }); + + ServerProxyConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions + { + Domain = domain, + Protocol = protocol, + ApiKey = apiKey, + RequestTimeoutSeconds = 180, + UseServerProxy = true + }); } public Task InitializeAsync() diff --git a/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs b/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs index 562cf76d8..94738a702 100644 --- a/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs +++ b/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs @@ -215,6 +215,75 @@ await policySandbox.PatchEgressRulesAsync(new List } } + [Fact(Timeout = 2 * 60 * 1000)] + public async Task Sandbox_Create_With_NetworkPolicy_Get_And_Patch_Egress_Via_ServerProxy() + { + var policySandbox = await Sandbox.CreateAsync(new SandboxCreateOptions + { + ConnectionConfig = _fixture.ServerProxyConnectionConfig, + Image = _fixture.DefaultImage, + TimeoutSeconds = _fixture.DefaultTimeoutSeconds, + ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, + NetworkPolicy = new NetworkPolicy + { + DefaultAction = NetworkRuleAction.Deny, + Egress = new List { new() { Action = NetworkRuleAction.Allow, Target = "pypi.org" } } + } + }); + + try + { + await Task.Delay(5000); + + var egressEndpoint = await policySandbox.GetEndpointAsync(Constants.DefaultEgressPort); + Assert.Contains( + $"/sandboxes/{policySandbox.Id}/proxy/{Constants.DefaultEgressPort}", + egressEndpoint.EndpointAddress); + + var initialPolicy = await policySandbox.GetEgressPolicyAsync(); + Assert.NotNull(initialPolicy); + Assert.Equal(NetworkRuleAction.Deny, initialPolicy.DefaultAction); + Assert.NotNull(initialPolicy.Egress); + Assert.Contains( + initialPolicy.Egress!, + rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Allow); + + var blocked = await policySandbox.Commands.RunAsync("curl -I https://www.github.com"); + Assert.NotNull(blocked.Error); + + var allowed = await policySandbox.Commands.RunAsync("curl -I https://pypi.org"); + Assert.Null(allowed.Error); + + await policySandbox.PatchEgressRulesAsync(new List + { + new() { Action = NetworkRuleAction.Allow, Target = "www.github.com" }, + new() { Action = NetworkRuleAction.Deny, Target = "pypi.org" } + }); + await Task.Delay(2000); + + var patchedPolicy = await policySandbox.GetEgressPolicyAsync(); + Assert.NotNull(patchedPolicy.Egress); + Assert.Contains( + patchedPolicy.Egress!, + rule => rule.Target == "www.github.com" && rule.Action == NetworkRuleAction.Allow); + Assert.Contains( + patchedPolicy.Egress!, + rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Deny); + } + finally + { + try + { + await policySandbox.KillAsync(); + } + catch + { + } + + await policySandbox.DisposeAsync(); + } + } + [Fact(Timeout = 2 * 60 * 1000)] public async Task Sandbox_Create_With_HostVolumeMount() { @@ -924,6 +993,7 @@ public sealed class SandboxE2ETestFixture : IAsyncLifetime private Sandbox? _sandbox; public ConnectionConfig ConnectionConfig => _baseFixture.ConnectionConfig; + public ConnectionConfig ServerProxyConnectionConfig => _baseFixture.ServerProxyConnectionConfig; public string DefaultImage => _baseFixture.DefaultImage; public int DefaultTimeoutSeconds => _baseFixture.DefaultTimeoutSeconds; public int DefaultReadyTimeoutSeconds => _baseFixture.DefaultReadyTimeoutSeconds; diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java index 56f1ed927..99cd73d58 100644 --- a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java +++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java @@ -57,6 +57,17 @@ protected static String getSandboxImage() { return testProperties.getProperty(PROP_IMG_DEFAULT); } + protected static ConnectionConfig createConnectionConfig(boolean useServerProxy) { + String protocol = testProperties.getProperty(PROP_PROTOCOL, "https"); + return ConnectionConfig.builder() + .apiKey(testProperties.getProperty(PROP_API_KEY)) + .domain(testProperties.getProperty(PROP_DOMAIN)) + .requestTimeout(Duration.ofMinutes(1)) + .protocol(protocol) + .useServerProxy(useServerProxy) + .build(); + } + private static void loadTestProperties() { try (InputStream input = BaseE2ETest.class.getClassLoader().getResourceAsStream("test.properties")) { diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java index 27222e7d7..19b35aecf 100644 --- a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java +++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java @@ -368,6 +368,112 @@ void testSandboxCreateWithNetworkPolicy() { } } + @Test + @Order(2) + @DisplayName("Sandbox create with networkPolicy + get/patch egress via server proxy") + @Timeout(value = 2, unit = TimeUnit.MINUTES) + void testSandboxCreateWithNetworkPolicyViaServerProxy() { + NetworkPolicy networkPolicy = + NetworkPolicy.builder() + .defaultAction(NetworkPolicy.DefaultAction.DENY) + .addEgress( + NetworkRule.builder() + .action(NetworkRule.Action.ALLOW) + .target("pypi.org") + .build()) + .build(); + + Sandbox policySandbox = + Sandbox.builder() + .connectionConfig(createConnectionConfig(true)) + .image(getSandboxImage()) + .timeout(Duration.ofMinutes(2)) + .readyTimeout(Duration.ofSeconds(60)) + .networkPolicy(networkPolicy) + .build(); + try { + Thread.sleep(2000); + } catch (InterruptedException ignored) { + } + + try { + SandboxEndpoint egressEndpoint = policySandbox.getEndpoint(18080); + assertTrue( + egressEndpoint.getEndpoint().contains( + "/sandboxes/" + policySandbox.getId() + "/proxy/18080")); + + NetworkPolicy initialPolicy = policySandbox.getEgressPolicy(); + assertNotNull(initialPolicy); + assertEquals(NetworkPolicy.DefaultAction.DENY, initialPolicy.getDefaultAction()); + assertNotNull(initialPolicy.getEgress()); + assertTrue( + initialPolicy.getEgress().stream() + .anyMatch( + r -> + "pypi.org".equals(r.getTarget()) + && r.getAction() == NetworkRule.Action.ALLOW)); + + Execution blocked = + policySandbox + .commands() + .run( + RunCommandRequest.builder() + .command("curl -I https://www.github.com") + .build()); + assertNotNull(blocked); + assertNotNull(blocked.getError()); + + Execution allowed = + policySandbox + .commands() + .run( + RunCommandRequest.builder() + .command("curl -I https://pypi.org") + .build()); + assertNotNull(allowed); + assertNull(allowed.getError()); + + policySandbox.patchEgressRules( + List.of( + NetworkRule.builder() + .action(NetworkRule.Action.ALLOW) + .target("www.github.com") + .build(), + NetworkRule.builder() + .action(NetworkRule.Action.DENY) + .target("pypi.org") + .build())); + + try { + Thread.sleep(2000); + } catch (InterruptedException ignored) { + } + + NetworkPolicy patchedPolicy = policySandbox.getEgressPolicy(); + assertNotNull(patchedPolicy.getEgress()); + assertTrue( + patchedPolicy.getEgress().stream() + .anyMatch( + rule -> + "www.github.com".equals(rule.getTarget()) + && rule.getAction() + == NetworkRule.Action.ALLOW)); + assertTrue( + patchedPolicy.getEgress().stream() + .anyMatch( + rule -> + "pypi.org".equals(rule.getTarget()) + && rule.getAction() + == NetworkRule.Action.DENY)); + } finally { + try { + policySandbox.kill(); + } catch (Exception ignored) { + } + policySandbox.close(); + } + } + @Test @Order(2) @DisplayName("Sandbox create with host volume mount (read-write)") diff --git a/tests/javascript/tests/base_e2e.ts b/tests/javascript/tests/base_e2e.ts index fa8413ed7..166ab28a9 100644 --- a/tests/javascript/tests/base_e2e.ts +++ b/tests/javascript/tests/base_e2e.ts @@ -29,12 +29,13 @@ export function getSandboxImage(): string { return TEST_IMAGE; } -export function createConnectionConfig(): ConnectionConfig { +export function createConnectionConfig(useServerProxy = false): ConnectionConfig { return new ConnectionConfig({ domain: TEST_DOMAIN, protocol: TEST_PROTOCOL === "https" ? "https" : "http", apiKey: TEST_API_KEY, - requestTimeoutSeconds: 180 + requestTimeoutSeconds: 180, + useServerProxy }); } @@ -70,4 +71,4 @@ export function assertEndpointHasPort(endpoint: string, expectedPort: number): v if (!host) throw new Error(`missing host in endpoint: ${endpoint}`); if (!/^\d+$/.test(port)) throw new Error(`non-numeric port in endpoint: ${endpoint}`); if (Number(port) !== expectedPort) throw new Error(`endpoint port mismatch: ${endpoint} != :${expectedPort}`); -} \ No newline at end of file +} diff --git a/tests/javascript/tests/test_sandbox_e2e.test.ts b/tests/javascript/tests/test_sandbox_e2e.test.ts index 4ab398ad1..52dfc2969 100644 --- a/tests/javascript/tests/test_sandbox_e2e.test.ts +++ b/tests/javascript/tests/test_sandbox_e2e.test.ts @@ -19,6 +19,7 @@ import { SandboxApiException, Sandbox, DEFAULT_EXECD_PORT, + DEFAULT_EGRESS_PORT, SandboxManager, type ExecutionHandlers, type ExecutionComplete, @@ -186,6 +187,52 @@ test("01a sandbox create with networkPolicy", async () => { } }, 3 * 60_000); +test("01aa sandbox create with networkPolicy via server proxy", async () => { + const connectionConfig = createConnectionConfig(true); + const networkPolicySandbox = await Sandbox.create({ + connectionConfig, + image: getSandboxImage(), + timeoutSeconds: 2 * 60, + readyTimeoutSeconds: 60, + networkPolicy: { + defaultAction: "deny", + egress: [{ action: "allow", target: "pypi.org" }], + }, + }); + await new Promise((r) => setTimeout(r, 5000)); + try { + const egressEndpoint = await networkPolicySandbox.getEndpoint(DEFAULT_EGRESS_PORT); + expect(egressEndpoint.endpoint).toContain( + `/sandboxes/${networkPolicySandbox.id}/proxy/${DEFAULT_EGRESS_PORT}` + ); + + const initialPolicy = await networkPolicySandbox.getEgressPolicy(); + expect(initialPolicy.defaultAction).toBe("deny"); + expect(initialPolicy.egress?.some((r) => r.target === "pypi.org" && r.action === "allow")).toBe(true); + + const blocked = await networkPolicySandbox.commands.run("curl -I https://www.github.com"); + expect(blocked.error).toBeTruthy(); + const allowed = await networkPolicySandbox.commands.run("curl -I https://pypi.org"); + expect(allowed.error).toBeUndefined(); + + await networkPolicySandbox.patchEgressRules([ + { action: "allow", target: "www.github.com" }, + { action: "deny", target: "pypi.org" }, + ]); + await new Promise((r) => setTimeout(r, 2000)); + + const patchedPolicy = await networkPolicySandbox.getEgressPolicy(); + expect(patchedPolicy.egress?.some((r) => r.target === "www.github.com" && r.action === "allow")).toBe(true); + expect(patchedPolicy.egress?.some((r) => r.target === "pypi.org" && r.action === "deny")).toBe(true); + } finally { + try { + await networkPolicySandbox.kill(); + } catch { + // ignore + } + } +}, 3 * 60_000); + test("01b sandbox create with host volume mount (read-write)", async () => { const connectionConfig = createConnectionConfig(); const hostDir = "/tmp/opensandbox-e2e/host-volume-test"; diff --git a/tests/python/tests/base_e2e_test.py b/tests/python/tests/base_e2e_test.py index 396752ce4..08cf5e2bd 100644 --- a/tests/python/tests/base_e2e_test.py +++ b/tests/python/tests/base_e2e_test.py @@ -49,6 +49,17 @@ def create_connection_config() -> ConnectionConfig: ) +def create_connection_config_server_proxy() -> ConnectionConfig: + """Create async ConnectionConfig for E2E tests using server-proxied endpoints.""" + return ConnectionConfig( + domain=TEST_DOMAIN, + api_key=TEST_API_KEY, + request_timeout=timedelta(minutes=3), + protocol=TEST_PROTOCOL, + use_server_proxy=True, + ) + + def create_connection_config_sync() -> ConnectionConfigSync: """Create sync ConnectionConfig for E2E tests.""" return ConnectionConfigSync( @@ -64,3 +75,21 @@ def create_connection_config_sync() -> ConnectionConfigSync: ), protocol=TEST_PROTOCOL, ) + + +def create_connection_config_sync_server_proxy() -> ConnectionConfigSync: + """Create sync ConnectionConfig for E2E tests using server-proxied endpoints.""" + return ConnectionConfigSync( + domain=TEST_DOMAIN, + api_key=TEST_API_KEY, + request_timeout=timedelta(minutes=3), + transport=httpx.HTTPTransport( + limits=httpx.Limits( + max_connections=100, + max_keepalive_connections=20, + keepalive_expiry=15, + ) + ), + protocol=TEST_PROTOCOL, + use_server_proxy=True, + ) diff --git a/tests/python/tests/test_sandbox_e2e.py b/tests/python/tests/test_sandbox_e2e.py index d73b1f934..0cf2e893f 100644 --- a/tests/python/tests/test_sandbox_e2e.py +++ b/tests/python/tests/test_sandbox_e2e.py @@ -25,6 +25,7 @@ import pytest from opensandbox import Sandbox +from opensandbox.constants import DEFAULT_EGRESS_PORT from opensandbox.config import ConnectionConfig from opensandbox.exceptions import SandboxApiException from opensandbox.models.execd import ( @@ -57,6 +58,7 @@ TEST_DOMAIN, TEST_PROTOCOL, create_connection_config, + create_connection_config_server_proxy, get_sandbox_image, ) @@ -133,8 +135,7 @@ async def _sandbox_lifecycle(self, request): sandbox = request.cls.sandbox if sandbox is not None: try: - # await sandbox.kill() - pass + await sandbox.kill() except Exception as e: logger.warning("Teardown: sandbox.kill() failed: %s", e, exc_info=True) try: @@ -410,6 +411,65 @@ async def test_01aa_network_policy_get_and_patch(self): pass await sandbox.close() + @pytest.mark.timeout(180) + @pytest.mark.order(1) + async def test_01ab_network_policy_get_and_patch_with_server_proxy(self): + logger.info("=" * 80) + logger.info("TEST 1ab: networkPolicy get/patch with server proxy (async)") + logger.info("=" * 80) + + cfg = create_connection_config_server_proxy() + sandbox = await Sandbox.create( + image=SandboxImageSpec(get_sandbox_image()), + connection_config=cfg, + timeout=timedelta(minutes=2), + ready_timeout=timedelta(seconds=30), + network_policy=NetworkPolicy( + defaultAction="deny", + egress=[NetworkRule(action="allow", target="pypi.org")], + ), + ) + try: + await asyncio.sleep(5) + + egress_endpoint = await sandbox.get_endpoint(DEFAULT_EGRESS_PORT) + assert f"/sandboxes/{sandbox.id}/proxy/{DEFAULT_EGRESS_PORT}" in egress_endpoint.endpoint + + policy = await sandbox.get_egress_policy() + assert policy.default_action == "deny" + assert policy.egress is not None + assert any(rule.target == "pypi.org" and rule.action == "allow" for rule in policy.egress) + + blocked = await sandbox.commands.run("curl -I https://www.github.com") + assert blocked.error is not None + allowed = await sandbox.commands.run("curl -I https://pypi.org") + assert allowed.error is None + + await sandbox.patch_egress_rules( + [ + NetworkRule(action="allow", target="www.github.com"), + NetworkRule(action="deny", target="pypi.org"), + ], + ) + await asyncio.sleep(2) + + patched_policy = await sandbox.get_egress_policy() + assert patched_policy.egress is not None + assert any( + rule.target == "www.github.com" and rule.action == "allow" + for rule in patched_policy.egress + ) + assert any( + rule.target == "pypi.org" and rule.action == "deny" + for rule in patched_policy.egress + ) + finally: + try: + await sandbox.kill() + except Exception: + pass + await sandbox.close() + @pytest.mark.timeout(120) @pytest.mark.order(1) async def test_01b_host_volume_mount(self): From eac3955c4f0f965553ed135518a627d3af233e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Fri, 20 Mar 2026 10:32:53 +0800 Subject: [PATCH 3/3] fix(server): fix resolving internal endpoint when egress activated --- server/src/services/docker.py | 35 +++++++++++++ server/tests/test_docker_endpoint.py | 77 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/server/src/services/docker.py b/server/src/services/docker.py index 34661eea0..c7cf785e2 100644 --- a/server/src/services/docker.py +++ b/server/src/services/docker.py @@ -1718,6 +1718,16 @@ def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = Fals if resolve_internal: container = self._get_container_by_sandbox_id(sandbox_id) + labels = container.attrs.get("Config", {}).get("Labels") or {} + # Sandboxes created with egress sidecar share the sidecar network namespace, so the + # main container's private IP is not a stable proxy target. In that case, treat the + # server-proxy target as the server-local host-mapped endpoint instead of a container IP. + if labels.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY): + return self._resolve_host_mapped_endpoint( + self._resolve_proxy_host(), + labels, + port, + ) return self._resolve_internal_endpoint(container, port) public_host = self._resolve_public_host() @@ -1734,6 +1744,14 @@ def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = Fals # non-host mode (bridge / user-defined network) container = self._get_container_by_sandbox_id(sandbox_id) labels = container.attrs.get("Config", {}).get("Labels") or {} + return self._resolve_host_mapped_endpoint(public_host, labels, port) + + def _resolve_host_mapped_endpoint( + self, + public_host: str, + labels: dict[str, str], + port: int, + ) -> Endpoint: execd_host_port = self._parse_host_port_label( labels.get(SANDBOX_EMBEDDING_PROXY_PORT_LABEL), SANDBOX_EMBEDDING_PROXY_PORT_LABEL, @@ -1762,6 +1780,7 @@ def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = Fals "message": "Missing host port mapping for execd proxy port 44772.", }, ) + endpoint = Endpoint(endpoint=f"{public_host}:{execd_host_port}/proxy/{port}") self._attach_egress_auth_headers(endpoint, labels) return endpoint @@ -1799,6 +1818,22 @@ def _resolve_public_host(self) -> str: return self._resolve_bind_ip(socket.AF_INET) return host_cfg + def _resolve_proxy_host(self) -> str: + """Resolve the server-local host used for proxying to host-mapped Docker endpoints. + + This intentionally does not use ``server.eip`` because the proxy target must be reachable + from the server process itself, even in deployments without hairpin access to the public EIP. + """ + host_cfg = (self.app_config.server.host or "").strip() + host_key = host_cfg.lower() + if host_key in {"", "0.0.0.0", "::"}: + if _running_inside_docker_container(): + host_ip = self._get_docker_host_ip() + if host_ip: + return host_ip + return "127.0.0.1" + return host_cfg + def _resolve_internal_endpoint(self, container, port: int) -> Endpoint: """Return the internal endpoint used when bypassing host mapping.""" if self.network_mode == HOST_NETWORK_MODE: diff --git a/server/tests/test_docker_endpoint.py b/server/tests/test_docker_endpoint.py index f1c0cfdc4..353086144 100644 --- a/server/tests/test_docker_endpoint.py +++ b/server/tests/test_docker_endpoint.py @@ -174,6 +174,83 @@ def test_get_endpoint_bridge_internal_resolution(mock_docker_service): assert endpoint.endpoint == "10.0.0.5:8080" +def test_get_endpoint_bridge_internal_resolution_with_egress_sidecar_falls_back_to_host_mapped_endpoint( + mock_docker_service, +): + service, mock_client = mock_docker_service + service.app_config.docker.network_mode = "bridge" + service.network_mode = "bridge" + + labels = { + SANDBOX_EMBEDDING_PROXY_PORT_LABEL: "50002", + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: "egress-token", + } + mock_container = MagicMock() + mock_container.attrs = { + "State": {"Running": True}, + "Config": {"Labels": labels}, + "NetworkSettings": {"IPAddress": ""}, + } + mock_client.containers.list.return_value = [mock_container] + + endpoint = service.get_endpoint("sbx-123", 18080, resolve_internal=True) + + assert endpoint.endpoint == "127.0.0.1:50002/proxy/18080" + assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"} + + +def test_get_endpoint_bridge_internal_resolution_with_egress_sidecar_ignores_container_ip( + mock_docker_service, +): + service, mock_client = mock_docker_service + service.app_config.docker.network_mode = "bridge" + service.network_mode = "bridge" + + labels = { + SANDBOX_EMBEDDING_PROXY_PORT_LABEL: "50002", + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: "egress-token", + } + mock_container = MagicMock() + mock_container.attrs = { + "State": {"Running": True}, + "Config": {"Labels": labels}, + "NetworkSettings": {"IPAddress": "10.0.0.5"}, + } + mock_client.containers.list.return_value = [mock_container] + + endpoint = service.get_endpoint("sbx-123", 18080, resolve_internal=True) + + assert endpoint.endpoint == "127.0.0.1:50002/proxy/18080" + assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"} + + +def test_get_endpoint_bridge_internal_resolution_with_egress_sidecar_uses_proxy_host_not_eip( + mock_docker_service, +): + service, mock_client = mock_docker_service + service.app_config.server.host = "0.0.0.0" + service.app_config.server.eip = "203.0.113.10" + service.app_config.docker.network_mode = "bridge" + service.network_mode = "bridge" + + labels = { + SANDBOX_EMBEDDING_PROXY_PORT_LABEL: "50002", + SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: "egress-token", + } + mock_container = MagicMock() + mock_container.attrs = { + "State": {"Running": True}, + "Config": {"Labels": labels}, + "NetworkSettings": {"IPAddress": ""}, + } + mock_client.containers.list.return_value = [mock_container] + + endpoint = service.get_endpoint("sbx-123", 18080, resolve_internal=True) + + assert endpoint.endpoint == "127.0.0.1:50002/proxy/18080" + assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"} + + def test_get_endpoint_bridge_uses_docker_host_ip_when_server_in_container(): """When server runs in container (host=0.0.0.0), endpoint uses [docker].host_ip.""" config = AppConfig(