diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs index d9cbf9f1..f481ef2d 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs @@ -59,6 +59,19 @@ public async IAsyncEnumerable RunStreamAsync( RunCommandOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (options?.Gid.HasValue == true && options.Uid.HasValue != true) + { + throw new InvalidArgumentException("uid is required when gid is provided"); + } + if (options?.Uid.HasValue == true && options.Uid.Value < 0) + { + throw new InvalidArgumentException("uid must be >= 0"); + } + if (options?.Gid.HasValue == true && options.Gid.Value < 0) + { + throw new InvalidArgumentException("gid must be >= 0"); + } + var url = $"{_baseUrl}/command"; _logger.LogDebug("Running command stream (commandLength={CommandLength})", command.Length); var requestBody = new RunCommandRequest @@ -66,7 +79,10 @@ public async IAsyncEnumerable RunStreamAsync( Command = command, Cwd = options?.WorkingDirectory, Background = options?.Background, - Timeout = options?.TimeoutSeconds.HasValue == true ? options.TimeoutSeconds.Value * 1000L : null + Timeout = options?.TimeoutSeconds.HasValue == true ? options.TimeoutSeconds.Value * 1000L : null, + Uid = options?.Uid, + Gid = options?.Gid, + Envs = options?.Envs }; var json = JsonSerializer.Serialize(requestBody, JsonOptions); diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs b/sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs index 5d2aa493..d62912b6 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs @@ -133,6 +133,25 @@ public class RunCommandRequest /// [JsonPropertyName("timeout")] public long? Timeout { get; set; } + + /// + /// Gets or sets the Unix user ID used to run the command process. + /// + [JsonPropertyName("uid")] + public int? Uid { get; set; } + + /// + /// Gets or sets the Unix group ID used to run the command process. + /// Requires to be set. + /// + [JsonPropertyName("gid")] + public int? Gid { get; set; } + + /// + /// Gets or sets environment variables injected into the command process. + /// + [JsonPropertyName("envs")] + public Dictionary? Envs { get; set; } } /// @@ -155,6 +174,22 @@ public class RunCommandOptions /// The server terminates the command when this duration is reached. /// public int? TimeoutSeconds { get; set; } + + /// + /// Gets or sets the Unix user ID used to run the command process. + /// + public int? Uid { get; set; } + + /// + /// Gets or sets the Unix group ID used to run the command process. + /// Requires to be set. + /// + public int? Gid { get; set; } + + /// + /// Gets or sets environment variables injected into the command process. + /// + public Dictionary? Envs { get; set; } } /// diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs index 087fd1f0..00757d8c 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs @@ -17,6 +17,7 @@ using System.Text.Json; using FluentAssertions; using OpenSandbox.Adapters; +using OpenSandbox.Core; using OpenSandbox.Internal; using OpenSandbox.Models; using Microsoft.Extensions.Logging; @@ -116,6 +117,70 @@ public async Task RunStreamAsync_ShouldSendTimeoutInMilliseconds() } } + [Fact] + public async Task RunStreamAsync_ShouldSendUidGidAndEnvs() + { + var handler = new StubHttpMessageHandler(async (request, _) => + { + request.Content.Should().NotBeNull(); + var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false); + using var doc = JsonDocument.Parse(body); + doc.RootElement.GetProperty("uid").GetInt32().Should().Be(1000); + doc.RootElement.GetProperty("gid").GetInt32().Should().Be(1000); + var envs = doc.RootElement.GetProperty("envs"); + envs.GetProperty("APP_ENV").GetString().Should().Be("test"); + envs.GetProperty("LOG_LEVEL").GetString().Should().Be("debug"); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("data: {\"type\":\"init\",\"text\":\"cmd-1\"}\n\n", Encoding.UTF8, "text/event-stream") + }; + }); + var adapter = CreateAdapter(handler); + + var options = new RunCommandOptions + { + Uid = 1000, + Gid = 1000, + Envs = new Dictionary + { + ["APP_ENV"] = "test", + ["LOG_LEVEL"] = "debug" + } + }; + + await foreach (var _ in adapter.RunStreamAsync("id", options)) + { + // Drain events. + } + } + + [Fact] + public async Task RunStreamAsync_ShouldRejectGidWithoutUid() + { + var handler = new StubHttpMessageHandler((_, _) => + { + throw new InvalidOperationException("HTTP should not be called when options are invalid."); + }); + var adapter = CreateAdapter(handler); + + var options = new RunCommandOptions + { + Gid = 1000 + }; + + var act = async () => + { + await foreach (var _ in adapter.RunStreamAsync("id", options)) + { + // Drain events. + } + }; + + await act.Should().ThrowAsync() + .WithMessage("*uid is required when gid is provided*"); + } + private static CommandsAdapter CreateAdapter(HttpMessageHandler httpHandler) { var baseUrl = "http://execd.local"; diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs index 70c81696..27485de5 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs @@ -307,13 +307,22 @@ public void RunCommandOptions_ShouldStoreProperties() { WorkingDirectory = "/home/user", Background = true, - TimeoutSeconds = 30 + TimeoutSeconds = 30, + Uid = 1000, + Gid = 1000, + Envs = new Dictionary + { + ["APP_ENV"] = "test" + } }; // Assert options.WorkingDirectory.Should().Be("/home/user"); options.Background.Should().BeTrue(); options.TimeoutSeconds.Should().Be(30); + options.Uid.Should().Be(1000); + options.Gid.Should().Be(1000); + options.Envs.Should().ContainKey("APP_ENV"); } [Fact] diff --git a/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts b/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts index 6a457d3f..3846d37f 100644 --- a/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts @@ -33,6 +33,7 @@ function joinUrl(baseUrl: string, pathname: string): string { return `${base}${path}`; } +/** Request body for POST /command (from generated spec; includes uid, gid, envs). */ type ApiRunCommandRequest = ExecdPaths["/command"]["post"]["requestBody"]["content"]["application/json"]; type ApiCommandStatusOk = @@ -41,6 +42,10 @@ type ApiCommandLogsOk = ExecdPaths["/command/{id}/logs"]["get"]["responses"][200]["content"]["text/plain"]; function toRunCommandRequest(command: string, opts?: RunCommandOpts): ApiRunCommandRequest { + if (opts?.gid != null && opts.uid == null) { + throw new Error("uid is required when gid is provided"); + } + const body: ApiRunCommandRequest = { command, cwd: opts?.workingDirectory, @@ -49,6 +54,15 @@ function toRunCommandRequest(command: string, opts?: RunCommandOpts): ApiRunComm if (opts?.timeoutSeconds != null) { body.timeout = Math.round(opts.timeoutSeconds * 1000); } + if (opts?.uid != null) { + body.uid = opts.uid; + } + if (opts?.gid != null) { + body.gid = opts.gid; + } + if (opts?.envs != null) { + body.envs = opts.envs; + } return body; } diff --git a/sdks/sandbox/javascript/src/api/execd.ts b/sdks/sandbox/javascript/src/api/execd.ts index 3c42a494..541cb5b5 100644 --- a/sdks/sandbox/javascript/src/api/execd.ts +++ b/sdks/sandbox/javascript/src/api/execd.ts @@ -158,7 +158,8 @@ export interface paths { * The command can run in foreground or background mode. The response includes stdout, stderr, * execution status, and completion events. * Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will - * terminate the process when the timeout is reached. + * terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run + * with specific user/group IDs, and `envs` to inject environment variables. */ post: operations["runCommand"]; /** @@ -527,6 +528,28 @@ export interface components { * @example 60000 */ timeout?: number; + /** + * Format: int32 + * @description Unix user ID used to run the command. If `gid` is provided, `uid` is required. + * @example 1000 + */ + uid?: number; + /** + * Format: int32 + * @description Unix group ID used to run the command. Requires `uid` to be provided. + * @example 1000 + */ + gid?: number; + /** + * @description Environment variables injected into the command process. + * @example { + * "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + * "PYTHONUNBUFFERED": "1" + * } + */ + envs?: { + [key: string]: string; + }; }; /** @description Command execution status (foreground or background) */ CommandStatusResponse: { diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index 571eac18..99dc66c2 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -800,15 +800,20 @@ export interface components { path: string; }; /** - * @description Kubernetes PersistentVolumeClaim mount backend. References an existing - * PVC in the same namespace as the sandbox pod. + * @description Platform-managed named volume backend. A runtime-neutral abstraction + * for referencing a pre-existing, platform-managed named volume. * - * Only available in Kubernetes runtime. + * - Kubernetes: maps to a PersistentVolumeClaim in the same namespace. + * - Docker: maps to a Docker named volume (created via `docker volume create`). + * + * The volume must already exist on the target platform before sandbox + * creation. */ PVC: { /** - * @description Name of the PersistentVolumeClaim in the same namespace. - * Must be a valid Kubernetes resource name. + * @description Name of the volume on the target platform. + * In Kubernetes this is the PVC name; in Docker this is the named + * volume name. Must be a valid DNS label. */ claimName: string; }; diff --git a/sdks/sandbox/javascript/src/models/execd.ts b/sdks/sandbox/javascript/src/models/execd.ts index 6ffd59ef..992dd894 100644 --- a/sdks/sandbox/javascript/src/models/execd.ts +++ b/sdks/sandbox/javascript/src/models/execd.ts @@ -63,6 +63,18 @@ export interface RunCommandOpts { * If omitted, the server will not enforce any timeout. */ timeoutSeconds?: number; + /** + * Unix user ID used to run the command process. + */ + uid?: number; + /** + * Unix group ID used to run the command process. Requires `uid`. + */ + gid?: number; + /** + * Environment variables injected into the command process. + */ + envs?: Record; } export interface CommandStatus { diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/executions/RunCommandRequest.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/executions/RunCommandRequest.kt index 624a90a7..f080486a 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/executions/RunCommandRequest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/executions/RunCommandRequest.kt @@ -25,6 +25,9 @@ import kotlin.time.Duration * @property background Whether to run in background (detached) * @property workingDirectory Directory to execute command in * @property timeout Maximum execution time; server will terminate when reached. Null means the server will not enforce any timeout. + * @property uid Unix user ID used to run the command process + * @property gid Unix group ID used to run the command process. Requires uid. + * @property envs Environment variables injected into the command process * @property handlers Optional execution handlers */ class RunCommandRequest private constructor( @@ -32,6 +35,9 @@ class RunCommandRequest private constructor( val background: Boolean, val workingDirectory: String?, val timeout: Duration?, + val uid: Int?, + val gid: Int?, + val envs: Map, val handlers: ExecutionHandlers?, ) { companion object { @@ -44,6 +50,9 @@ class RunCommandRequest private constructor( private var background: Boolean = false private var workingDirectory: String? = null private var timeout: Duration? = null + private var uid: Int? = null + private var gid: Int? = null + private val envs: MutableMap = mutableMapOf() private var handlers: ExecutionHandlers? = null fun command(command: String): Builder { @@ -71,6 +80,35 @@ class RunCommandRequest private constructor( return this } + fun uid(uid: Int?): Builder { + require(uid == null || uid >= 0) { "Uid must be >= 0" } + this.uid = uid + return this + } + + fun gid(gid: Int?): Builder { + require(gid == null || gid >= 0) { "Gid must be >= 0" } + this.gid = gid + return this + } + + fun env( + key: String, + value: String, + ): Builder { + require(key.isNotBlank()) { "Environment variable key cannot be blank" } + this.envs[key] = value + return this + } + + fun envs(envs: Map): Builder { + envs.keys.forEach { key -> + require(key.isNotBlank()) { "Environment variable key cannot be blank" } + } + this.envs.putAll(envs) + return this + } + fun handlers(handlers: ExecutionHandlers?): Builder { this.handlers = handlers return this @@ -78,11 +116,15 @@ class RunCommandRequest private constructor( fun build(): RunCommandRequest { val commandValue = command ?: throw IllegalArgumentException("Command must be specified") + require(gid == null || uid != null) { "Uid is required when gid is provided" } return RunCommandRequest( command = commandValue, background = background, workingDirectory = workingDirectory, timeout = timeout, + uid = uid, + gid = gid, + envs = envs.toMap(), handlers = handlers, ) } diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExecutionConverter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExecutionConverter.kt index 72cdff36..bd05d998 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExecutionConverter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExecutionConverter.kt @@ -28,6 +28,9 @@ object ExecutionConverter { background = background, cwd = workingDirectory, timeout = timeout?.inWholeMilliseconds, + uid = uid, + gid = gid, + envs = envs ) } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt index ddfc792c..9974453e 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt @@ -21,6 +21,11 @@ import com.alibaba.opensandbox.sandbox.config.ConnectionConfig import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach @@ -28,10 +33,12 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit class CommandsAdapterTest { + // CommandsAdapter unit tests private lateinit var mockWebServer: MockWebServer private lateinit var commandsAdapter: CommandsAdapter private lateinit var httpClientProvider: HttpClientProvider @@ -41,7 +48,6 @@ class CommandsAdapterTest { mockWebServer = MockWebServer() mockWebServer.start() - val baseUrl = mockWebServer.url("/").toString() // We need to parse the port from MockWebServer to simulate the Execd endpoint val host = mockWebServer.hostName val port = mockWebServer.port @@ -93,6 +99,10 @@ class CommandsAdapterTest { val request = RunCommandRequest.builder() .command("echo Hello") + .uid(1000) + .gid(1000) + .env("APP_ENV", "test") + .env("LOG_LEVEL", "debug") .handlers(handlers) .build() @@ -105,5 +115,24 @@ class CommandsAdapterTest { val recordedRequest = mockWebServer.takeRequest() assertEquals("/command", recordedRequest.path) assertEquals("POST", recordedRequest.method) + val requestBodyJson = Json.parseToJsonElement(recordedRequest.body.readUtf8()).jsonObject + assertEquals("echo Hello", requestBodyJson["command"]?.jsonPrimitive?.content) + assertEquals(1000, requestBodyJson["uid"]?.jsonPrimitive?.intOrNull) + assertEquals(1000, requestBodyJson["gid"]?.jsonPrimitive?.intOrNull) + val envs = requestBodyJson["envs"]?.jsonObject + assertEquals("test", envs?.get("APP_ENV")?.jsonPrimitive?.content) + assertEquals("debug", envs?.get("LOG_LEVEL")?.jsonPrimitive?.content) + // Builder defaults background to false; request body always includes it + assertEquals(false, requestBodyJson["background"]?.jsonPrimitive?.booleanOrNull) + } + + @Test + fun `run command builder should require uid when gid is provided`() { + assertThrows { + RunCommandRequest.builder() + .command("id") + .gid(1000) + .build() + } } } diff --git a/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py b/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py index c203d7bf..bd658c0b 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py @@ -43,6 +43,9 @@ class ExecutionConverter: @staticmethod def to_api_run_command_request(command: str, opts: RunCommandOpts) -> ApiRunCommandRequest: """Convert domain command + options to API RunCommandRequest.""" + from opensandbox.api.execd.models.run_command_request_envs import ( + RunCommandRequestEnvs, + ) from opensandbox.api.execd.types import UNSET # Convert working_directory to cwd, handling None @@ -58,11 +61,29 @@ def to_api_run_command_request(command: str, opts: RunCommandOpts) -> ApiRunComm if opts.timeout is not None: timeout_milliseconds = int(opts.timeout.total_seconds() * 1000) + uid = UNSET + if opts.uid is not None: + uid = opts.uid + + gid = UNSET + if opts.gid is not None: + gid = opts.gid + + envs = UNSET + if opts.envs is not None: + envs_payload = RunCommandRequestEnvs() + for key, value in opts.envs.items(): + envs_payload[key] = value + envs = envs_payload + return ApiRunCommandRequest( command=command, background=background, cwd=cwd, # Domain uses 'working_directory', API uses 'cwd' timeout=timeout_milliseconds, + uid=uid, + gid=gid, + envs=envs, # Note: handlers are not included in API request as they are for local processing ) diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/api/command/run_command.py b/sdks/sandbox/python/src/opensandbox/api/execd/api/command/run_command.py index 31b325fc..21b1e143 100644 --- a/sdks/sandbox/python/src/opensandbox/api/execd/api/command/run_command.py +++ b/sdks/sandbox/python/src/opensandbox/api/execd/api/command/run_command.py @@ -92,7 +92,8 @@ def sync_detailed( The command can run in foreground or background mode. The response includes stdout, stderr, execution status, and completion events. Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will - terminate the process when the timeout is reached. + terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run + with specific user/group IDs, and `envs` to inject environment variables. Args: body (RunCommandRequest): Request to execute a shell command @@ -127,7 +128,8 @@ def sync( The command can run in foreground or background mode. The response includes stdout, stderr, execution status, and completion events. Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will - terminate the process when the timeout is reached. + terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run + with specific user/group IDs, and `envs` to inject environment variables. Args: body (RunCommandRequest): Request to execute a shell command @@ -157,7 +159,8 @@ async def asyncio_detailed( The command can run in foreground or background mode. The response includes stdout, stderr, execution status, and completion events. Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will - terminate the process when the timeout is reached. + terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run + with specific user/group IDs, and `envs` to inject environment variables. Args: body (RunCommandRequest): Request to execute a shell command @@ -190,7 +193,8 @@ async def asyncio( The command can run in foreground or background mode. The response includes stdout, stderr, execution status, and completion events. Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will - terminate the process when the timeout is reached. + terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run + with specific user/group IDs, and `envs` to inject environment variables. Args: body (RunCommandRequest): Request to execute a shell command diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py b/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py index 2ee9cf5e..4051b68c 100644 --- a/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py @@ -32,6 +32,7 @@ from .replace_file_content_item import ReplaceFileContentItem from .run_code_request import RunCodeRequest from .run_command_request import RunCommandRequest +from .run_command_request_envs import RunCommandRequestEnvs from .server_stream_event import ServerStreamEvent from .server_stream_event_error import ServerStreamEventError from .server_stream_event_results import ServerStreamEventResults @@ -55,6 +56,7 @@ "ReplaceFileContentItem", "RunCodeRequest", "RunCommandRequest", + "RunCommandRequestEnvs", "ServerStreamEvent", "ServerStreamEventError", "ServerStreamEventResults", diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request.py b/sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request.py index 800dce12..7e15fd28 100644 --- a/sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request.py +++ b/sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request.py @@ -17,13 +17,17 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.run_command_request_envs import RunCommandRequestEnvs + + T = TypeVar("T", bound="RunCommandRequest") @@ -37,12 +41,21 @@ class RunCommandRequest: background (bool | Unset): Whether to run command in detached mode Default: False. timeout (int | Unset): Maximum allowed execution time in milliseconds before the command is forcefully terminated by the server. If omitted, the server will not enforce any timeout. Example: 60000. + uid (int | Unset): Unix user ID used to run the command. If `gid` is provided, `uid` is required. + Example: 1000. + gid (int | Unset): Unix group ID used to run the command. Requires `uid` to be provided. + Example: 1000. + envs (RunCommandRequestEnvs | Unset): Environment variables injected into the command process. Example: {'PATH': + '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'PYTHONUNBUFFERED': '1'}. """ command: str cwd: str | Unset = UNSET background: bool | Unset = False timeout: int | Unset = UNSET + uid: int | Unset = UNSET + gid: int | Unset = UNSET + envs: RunCommandRequestEnvs | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -54,6 +67,14 @@ def to_dict(self) -> dict[str, Any]: timeout = self.timeout + uid = self.uid + + gid = self.gid + + envs: dict[str, Any] | Unset = UNSET + if not isinstance(self.envs, Unset): + envs = self.envs.to_dict() + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -67,11 +88,19 @@ def to_dict(self) -> dict[str, Any]: field_dict["background"] = background if timeout is not UNSET: field_dict["timeout"] = timeout + if uid is not UNSET: + field_dict["uid"] = uid + if gid is not UNSET: + field_dict["gid"] = gid + if envs is not UNSET: + field_dict["envs"] = envs return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.run_command_request_envs import RunCommandRequestEnvs + d = dict(src_dict) command = d.pop("command") @@ -81,11 +110,25 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: timeout = d.pop("timeout", UNSET) + uid = d.pop("uid", UNSET) + + gid = d.pop("gid", UNSET) + + _envs = d.pop("envs", UNSET) + envs: RunCommandRequestEnvs | Unset + if isinstance(_envs, Unset): + envs = UNSET + else: + envs = RunCommandRequestEnvs.from_dict(_envs) + run_command_request = cls( command=command, cwd=cwd, background=background, timeout=timeout, + uid=uid, + gid=gid, + envs=envs, ) run_command_request.additional_properties = d diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request_envs.py b/sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request_envs.py new file mode 100644 index 00000000..e651c822 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request_envs.py @@ -0,0 +1,67 @@ +# +# 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 __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="RunCommandRequestEnvs") + + +@_attrs_define +class RunCommandRequestEnvs: + """Environment variables injected into the command process. + + Example: + {'PATH': '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'PYTHONUNBUFFERED': '1'} + + """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + run_command_request_envs = cls() + + run_command_request_envs.additional_properties = d + return run_command_request_envs + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/sdks/sandbox/python/src/opensandbox/models/execd.py b/sdks/sandbox/python/src/opensandbox/models/execd.py index 31e944b9..28251d08 100644 --- a/sdks/sandbox/python/src/opensandbox/models/execd.py +++ b/sdks/sandbox/python/src/opensandbox/models/execd.py @@ -23,7 +23,7 @@ from datetime import datetime, timedelta from typing import Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class OutputMessage(BaseModel): @@ -234,6 +234,27 @@ class RunCommandOpts(BaseModel): default=None, description="Maximum execution time; server will terminate the command when reached. If omitted, the server will not enforce any timeout.", ) + uid: int | None = Field( + default=None, + ge=0, + description="Unix user ID used to run the command process.", + ) + gid: int | None = Field( + default=None, + ge=0, + description="Unix group ID used to run the command process. Requires uid to be set.", + ) + envs: dict[str, str] | None = Field( + default=None, + description="Environment variables injected into the command process.", + ) + + @model_validator(mode="after") + def validate_uid_gid_dependency(self) -> "RunCommandOpts": + """Ensure gid is not used without uid to match server contract.""" + if self.gid is not None and self.uid is None: + raise ValueError("uid is required when gid is provided") + return self model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) diff --git a/sdks/sandbox/python/tests/test_converters_and_error_handling.py b/sdks/sandbox/python/tests/test_converters_and_error_handling.py index fd5be5f4..0488bbfa 100644 --- a/sdks/sandbox/python/tests/test_converters_and_error_handling.py +++ b/sdks/sandbox/python/tests/test_converters_and_error_handling.py @@ -160,6 +160,25 @@ def test_execution_converter_to_api_run_command_request() -> None: ).to_dict() ) + api4 = ExecutionConverter.to_api_run_command_request( + "id", + RunCommandOpts( + uid=1000, + gid=1000, + envs={"APP_ENV": "test", "LOG_LEVEL": "debug"}, + ), + ) + d4 = api4.to_dict() + assert d4["uid"] == 1000 + assert d4["gid"] == 1000 + assert d4["envs"] == {"APP_ENV": "test", "LOG_LEVEL": "debug"} + assert "cwd" not in d4 + + +def test_run_command_opts_validates_gid_requires_uid() -> None: + with pytest.raises(ValueError, match="uid is required when gid is provided"): + RunCommandOpts(gid=1000) + def test_filesystem_and_metrics_converters() -> None: from datetime import datetime, timezone diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml index 77f690a2..85c15081 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -290,7 +290,8 @@ paths: The command can run in foreground or background mode. The response includes stdout, stderr, execution status, and completion events. Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will - terminate the process when the timeout is reached. + terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run + with specific user/group IDs, and `envs` to inject environment variables. operationId: runCommand tags: - Command @@ -308,6 +309,11 @@ paths: cwd: /workspace background: false timeout: 30000 + uid: 1000 + gid: 1000 + envs: + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + PYTHONUNBUFFERED: "1" background: summary: Background command value: @@ -315,6 +321,10 @@ paths: cwd: /app background: true timeout: 120000 + uid: 1000 + envs: + APP_ENV: production + LOG_LEVEL: info responses: "200": description: Stream of command execution events @@ -938,6 +948,28 @@ components: format: int64 description: Maximum allowed execution time in milliseconds before the command is forcefully terminated by the server. If omitted, the server will not enforce any timeout. example: 60000 + uid: + type: integer + format: int32 + minimum: 0 + description: | + Unix user ID used to run the command. If `gid` is provided, `uid` is required. + example: 1000 + gid: + type: integer + format: int32 + minimum: 0 + description: | + Unix group ID used to run the command. Requires `uid` to be provided. + example: 1000 + envs: + type: object + description: Environment variables injected into the command process. + additionalProperties: + type: string + example: + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + PYTHONUNBUFFERED: "1" CommandStatusResponse: type: object diff --git a/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs b/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs index af4e4221..30885b1a 100644 --- a/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs +++ b/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs @@ -488,6 +488,35 @@ public async Task Command_Status_And_Background_Logs() Assert.Contains("log-line-2", finalLogs, StringComparison.Ordinal); } + [Fact(Timeout = 2 * 60 * 1000)] + public async Task Command_Env_Injection() + { + var sandbox = _fixture.Sandbox; + var envKey = "OPEN_SANDBOX_E2E_CMD_ENV"; + var envValue = $"env-ok-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + var probeCommand = + $"sh -c 'if [ -z \"${{{envKey}:-}}\" ]; then echo \"__EMPTY__\"; else echo \"${{{envKey}}}\"; fi'"; + + var baseline = await sandbox.Commands.RunAsync(probeCommand); + Assert.Null(baseline.Error); + var baselineOutput = string.Join("\n", baseline.Logs.Stdout.Select(m => m.Text)).Trim(); + Assert.Equal("__EMPTY__", baselineOutput); + + var injected = await sandbox.Commands.RunAsync( + probeCommand, + options: new RunCommandOptions + { + Envs = new Dictionary + { + [envKey] = envValue, + ["OPEN_SANDBOX_E2E_SECOND_ENV"] = "second-ok" + } + }); + Assert.Null(injected.Error); + var injectedOutput = string.Join("\n", injected.Logs.Stdout.Select(m => m.Text)).Trim(); + Assert.Equal(envValue, injectedOutput); + } + [Fact(Timeout = 2 * 60 * 1000)] public async Task Filesystem_Operations_CRUD_Replace_Move_Delete() { 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 b2af7c42..c8dfae1b 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 @@ -813,6 +813,53 @@ void testBasicCommandExecution() { assertTrue(completedEvents.isEmpty(), "Failing command should not emit completion event"); } + @Test + @Order(4) + @DisplayName("Command execution with env injection") + @Timeout(value = 2, unit = TimeUnit.MINUTES) + void testRunCommandWithEnvInjection() { + assertNotNull(sandbox); + + String envKey = "OPEN_SANDBOX_E2E_CMD_ENV"; + String envValue = "env-ok-" + System.currentTimeMillis(); + String probeCommand = + "sh -c 'if [ -z \"${" + + envKey + + "-}\" ]; then echo \"__EMPTY__\"; else echo \"${" + + envKey + + "}\"; fi'"; + + // Baseline: variable should be empty when not injected. + Execution baseline = + sandbox.commands().run(RunCommandRequest.builder().command(probeCommand).build()); + assertNotNull(baseline); + assertNull(baseline.getError()); + String baselineOutput = + baseline.getLogs().getStdout().stream() + .map(OutputMessage::getText) + .reduce("", (a, b) -> a.isEmpty() ? b : a + "\n" + b) + .trim(); + assertEquals("__EMPTY__", baselineOutput); + + // Inject env vars for this command and verify visibility. + Execution injected = + sandbox.commands() + .run( + RunCommandRequest.builder() + .command(probeCommand) + .env(envKey, envValue) + .env("OPEN_SANDBOX_E2E_SECOND_ENV", "second-ok") + .build()); + assertNotNull(injected); + assertNull(injected.getError()); + String injectedOutput = + injected.getLogs().getStdout().stream() + .map(OutputMessage::getText) + .reduce("", (a, b) -> a.isEmpty() ? b : a + "\n" + b) + .trim(); + assertEquals(envValue, injectedOutput); + } + // ========================================== // Filesystem Operations Tests // ========================================== diff --git a/tests/javascript/tests/test_sandbox_e2e.test.ts b/tests/javascript/tests/test_sandbox_e2e.test.ts index d4cc1a2d..a071c99f 100644 --- a/tests/javascript/tests/test_sandbox_e2e.test.ts +++ b/tests/javascript/tests/test_sandbox_e2e.test.ts @@ -557,6 +557,29 @@ test("02a command status + background logs", async () => { expect(logsText.includes("log-line-2")).toBe(true); }); +test("02b command env injection", async () => { + if (!sandbox) throw new Error("sandbox not created"); + + const envKey = "OPEN_SANDBOX_E2E_CMD_ENV"; + const envValue = `env-ok-${Date.now()}`; + const probeCommand = `sh -c 'if [ -z "\${${envKey}:-}" ]; then echo "__EMPTY__"; else echo "\${${envKey}}"; fi'`; + + const baseline = await sandbox.commands.run(probeCommand); + expect(baseline.error).toBeUndefined(); + const baselineOutput = baseline.logs.stdout.map((m) => m.text).join("\n").trim(); + expect(baselineOutput).toBe("__EMPTY__"); + + const injected = await sandbox.commands.run(probeCommand, { + envs: { + [envKey]: envValue, + OPEN_SANDBOX_E2E_SECOND_ENV: "second-ok", + }, + }); + expect(injected.error).toBeUndefined(); + const injectedOutput = injected.logs.stdout.map((m) => m.text).join("\n").trim(); + expect(injectedOutput).toBe(envValue); +}); + test("03 filesystem operations: CRUD + replace/move/delete + range + stream", async () => { if (!sandbox) throw new Error("sandbox not created"); diff --git a/tests/python/tests/test_sandbox_e2e.py b/tests/python/tests/test_sandbox_e2e.py index 3ecccca2..53018297 100644 --- a/tests/python/tests/test_sandbox_e2e.py +++ b/tests/python/tests/test_sandbox_e2e.py @@ -816,6 +816,40 @@ async def test_02a_command_status_and_logs(self): assert "log-line-1" in logs_text assert "log-line-2" in logs_text + @pytest.mark.timeout(120) + @pytest.mark.order(3) + async def test_02b_run_command_with_envs(self): + """Test run_command env injection via RunCommandOpts.envs.""" + await self._ensure_sandbox_created() + sandbox = TestSandboxE2E.sandbox + + env_key = "OPEN_SANDBOX_E2E_CMD_ENV" + env_value = f"env-ok-{int(time.time())}" + probe_command = ( + f"sh -c 'if [ -z \"${{{env_key}:-}}\" ]; then echo \"__EMPTY__\"; " + f"else echo \"${{{env_key}}}\"; fi'" + ) + + # Baseline: variable should be empty when not injected. + baseline = await sandbox.commands.run(probe_command) + assert baseline.error is None + baseline_output = "\n".join(msg.text for msg in baseline.logs.stdout).strip() + assert baseline_output == "__EMPTY__" + + # Inject environment variables for this command only. + injected = await sandbox.commands.run( + probe_command, + opts=RunCommandOpts( + envs={ + env_key: env_value, + "OPEN_SANDBOX_E2E_SECOND_ENV": "second-ok", + } + ), + ) + assert injected.error is None + injected_output = "\n".join(msg.text for msg in injected.logs.stdout).strip() + assert injected_output == env_value + @pytest.mark.timeout(120) @pytest.mark.order(4) async def test_03_basic_filesystem_operations(self): diff --git a/tests/python/tests/test_sandbox_e2e_sync.py b/tests/python/tests/test_sandbox_e2e_sync.py index 07eb3e22..2b19620c 100644 --- a/tests/python/tests/test_sandbox_e2e_sync.py +++ b/tests/python/tests/test_sandbox_e2e_sync.py @@ -758,6 +758,41 @@ def test_02a_command_status_and_logs(self) -> None: assert "log-line-1" in logs_text assert "log-line-2" in logs_text + @pytest.mark.timeout(120) + @pytest.mark.order(3) + def test_02b_run_command_with_envs(self) -> None: + """Test run_command env injection via RunCommandOpts.envs (sync).""" + TestSandboxE2ESync._ensure_sandbox_created() + sandbox = TestSandboxE2ESync.sandbox + assert sandbox is not None + + env_key = "OPEN_SANDBOX_E2E_CMD_ENV" + env_value = f"env-ok-{int(time.time())}" + probe_command = ( + f"sh -c 'if [ -z \"${{{env_key}:-}}\" ]; then echo \"__EMPTY__\"; " + f"else echo \"${{{env_key}}}\"; fi'" + ) + + # Baseline: variable should be empty when not injected. + baseline = sandbox.commands.run(probe_command) + assert baseline.error is None + baseline_output = "\n".join(msg.text for msg in baseline.logs.stdout).strip() + assert baseline_output == "__EMPTY__" + + # Inject environment variables for this command only. + injected = sandbox.commands.run( + probe_command, + opts=RunCommandOpts( + envs={ + env_key: env_value, + "OPEN_SANDBOX_E2E_SECOND_ENV": "second-ok", + } + ), + ) + assert injected.error is None + injected_output = "\n".join(msg.text for msg in injected.logs.stdout).strip() + assert injected_output == env_value + @pytest.mark.timeout(120) @pytest.mark.order(4) def test_03_basic_filesystem_operations(self) -> None: