From 2bbc08246cc7019db339be4f12737adbf3b3367c Mon Sep 17 00:00:00 2001 From: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:59:58 +0800 Subject: [PATCH] feat(execd): support uid and gid for command execution --- components/execd/pkg/runtime/command.go | 12 +++-- .../pkg/runtime/command_credentials_unix.go | 43 ++++++++++++++++ .../runtime/command_credentials_unix_test.go | 49 +++++++++++++++++++ .../runtime/command_credentials_windows.go | 27 ++++++++++ .../command_credentials_windows_test.go | 34 +++++++++++++ components/execd/pkg/runtime/errors.go | 2 + components/execd/pkg/runtime/types.go | 2 + .../execd/pkg/web/controller/command.go | 30 ++++++++++++ .../execd/pkg/web/controller/command_test.go | 30 ++++++++++++ .../execd/pkg/web/model/codeinterpreting.go | 4 ++ .../pkg/web/model/codeinterpreting_test.go | 21 ++++++++ .../OpenSandbox/Adapters/CommandsAdapter.cs | 4 +- .../csharp/src/OpenSandbox/Models/Execd.cs | 22 +++++++++ .../OpenSandbox.Tests/CommandsAdapterTests.cs | 6 ++- .../tests/OpenSandbox.Tests/ModelsTests.cs | 6 ++- .../src/adapters/commandsAdapter.ts | 4 +- sdks/sandbox/javascript/src/api/execd.ts | 12 +++++ sdks/sandbox/javascript/src/models/execd.ts | 10 +++- .../execd/executions/RunCommandRequest.kt | 18 +++++++ .../adapters/converter/ExecutionConverter.kt | 2 + .../adapters/service/CommandsAdapterTest.kt | 5 ++ .../adapters/converter/execution_converter.py | 10 ++++ .../api/execd/models/run_command_request.py | 20 ++++++++ .../python/src/opensandbox/models/execd.py | 8 +++ .../test_converters_and_error_handling.py | 8 +++ specs/execd-api.yaml | 14 ++++++ 26 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 components/execd/pkg/runtime/command_credentials_unix.go create mode 100644 components/execd/pkg/runtime/command_credentials_unix_test.go create mode 100644 components/execd/pkg/runtime/command_credentials_windows.go create mode 100644 components/execd/pkg/runtime/command_credentials_windows_test.go diff --git a/components/execd/pkg/runtime/command.go b/components/execd/pkg/runtime/command.go index 11f2cf09..3dfb15d0 100644 --- a/components/execd/pkg/runtime/command.go +++ b/components/execd/pkg/runtime/command.go @@ -71,8 +71,9 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest }) cmd.Dir = request.Cwd - // use a dedicated process group so signals propagate to children. - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := applyCommandSysProcAttr(cmd, request); err != nil { + return err + } err = cmd.Start() if err != nil { @@ -151,7 +152,6 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest // runBackgroundCommand executes shell commands in detached mode. func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error { session := c.newContextID() - request.Hooks.OnExecuteInit(session) pipe, err := c.combinedOutputDescriptor(session) if err != nil { @@ -171,10 +171,14 @@ func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.Ca cmd := exec.CommandContext(ctx, "bash", "-c", request.Code) cmd.Dir = request.Cwd - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Stdout = pipe cmd.Stderr = pipe cmd.Env = mergeEnvs(os.Environ(), loadExtraEnvFromFile()) + if err := applyCommandSysProcAttr(cmd, request); err != nil { + cancel() + return err + } + request.Hooks.OnExecuteInit(session) // use DevNull as stdin so interactive programs exit immediately. cmd.Stdin = os.NewFile(uintptr(syscall.Stdin), os.DevNull) diff --git a/components/execd/pkg/runtime/command_credentials_unix.go b/components/execd/pkg/runtime/command_credentials_unix.go new file mode 100644 index 00000000..76f70aa5 --- /dev/null +++ b/components/execd/pkg/runtime/command_credentials_unix.go @@ -0,0 +1,43 @@ +// 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. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "os" + "os/exec" + "syscall" +) + +func applyCommandSysProcAttr(cmd *exec.Cmd, request *ExecuteCodeRequest) error { + sysProcAttr := &syscall.SysProcAttr{Setpgid: true} + if request.Uid != nil || request.Gid != nil { + credential := &syscall.Credential{ + Uid: uint32(os.Getuid()), + Gid: uint32(os.Getgid()), + } + if request.Uid != nil { + credential.Uid = *request.Uid + } + if request.Gid != nil { + credential.Gid = *request.Gid + } + sysProcAttr.Credential = credential + } + cmd.SysProcAttr = sysProcAttr + return nil +} diff --git a/components/execd/pkg/runtime/command_credentials_unix_test.go b/components/execd/pkg/runtime/command_credentials_unix_test.go new file mode 100644 index 00000000..62458156 --- /dev/null +++ b/components/execd/pkg/runtime/command_credentials_unix_test.go @@ -0,0 +1,49 @@ +// 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. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "os" + "os/exec" + "testing" +) + +func TestApplyCommandSysProcAttr_UsesCurrentIdentityFallback(t *testing.T) { + cmd := exec.Command("bash", "-lc", "true") + gid := uint32(2002) + + err := applyCommandSysProcAttr(cmd, &ExecuteCodeRequest{Gid: &gid}) + if err != nil { + t.Fatalf("applyCommandSysProcAttr returned error: %v", err) + } + if cmd.SysProcAttr == nil { + t.Fatalf("expected SysProcAttr to be populated") + } + if !cmd.SysProcAttr.Setpgid { + t.Fatalf("expected Setpgid to be true") + } + if cmd.SysProcAttr.Credential == nil { + t.Fatalf("expected Credential to be set when gid is provided") + } + if cmd.SysProcAttr.Credential.Uid != uint32(os.Getuid()) { + t.Fatalf("expected uid fallback to current process uid") + } + if cmd.SysProcAttr.Credential.Gid != gid { + t.Fatalf("expected gid %d, got %d", gid, cmd.SysProcAttr.Credential.Gid) + } +} diff --git a/components/execd/pkg/runtime/command_credentials_windows.go b/components/execd/pkg/runtime/command_credentials_windows.go new file mode 100644 index 00000000..f7fdd3ac --- /dev/null +++ b/components/execd/pkg/runtime/command_credentials_windows.go @@ -0,0 +1,27 @@ +// 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. + +//go:build windows +// +build windows + +package runtime + +import "os/exec" + +func applyCommandSysProcAttr(_ *exec.Cmd, request *ExecuteCodeRequest) error { + if request.Uid != nil || request.Gid != nil { + return ErrCommandCredentialsUnsupported + } + return nil +} diff --git a/components/execd/pkg/runtime/command_credentials_windows_test.go b/components/execd/pkg/runtime/command_credentials_windows_test.go new file mode 100644 index 00000000..daa4aa20 --- /dev/null +++ b/components/execd/pkg/runtime/command_credentials_windows_test.go @@ -0,0 +1,34 @@ +// 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. + +//go:build windows +// +build windows + +package runtime + +import ( + "errors" + "os/exec" + "testing" +) + +func TestApplyCommandSysProcAttr_RejectsCredentials(t *testing.T) { + cmd := exec.Command("cmd", "/C", "echo ok") + uid := uint32(1001) + + err := applyCommandSysProcAttr(cmd, &ExecuteCodeRequest{Uid: &uid}) + if !errors.Is(err, ErrCommandCredentialsUnsupported) { + t.Fatalf("expected unsupported credentials error, got %v", err) + } +} diff --git a/components/execd/pkg/runtime/errors.go b/components/execd/pkg/runtime/errors.go index 2517167d..2fe72654 100644 --- a/components/execd/pkg/runtime/errors.go +++ b/components/execd/pkg/runtime/errors.go @@ -17,3 +17,5 @@ package runtime import "errors" var ErrContextNotFound = errors.New("context not found") + +var ErrCommandCredentialsUnsupported = errors.New("uid/gid are only supported on POSIX platforms") diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index cb82a11b..4dc459b3 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -40,6 +40,8 @@ type ExecuteCodeRequest struct { Timeout time.Duration `json:"timeout"` Cwd string `json:"cwd"` Envs map[string]string `json:"envs"` + Uid *uint32 `json:"uid,omitempty"` + Gid *uint32 `json:"gid,omitempty"` Hooks ExecuteResultHook } diff --git a/components/execd/pkg/web/controller/command.go b/components/execd/pkg/web/controller/command.go index 9d61308b..73019784 100644 --- a/components/execd/pkg/web/controller/command.go +++ b/components/execd/pkg/web/controller/command.go @@ -16,10 +16,12 @@ package controller import ( "context" + "errors" "fmt" "net/http" "strconv" "time" + goruntime "runtime" "github.com/alibaba/opensandbox/execd/pkg/flag" "github.com/alibaba/opensandbox/execd/pkg/runtime" @@ -47,6 +49,14 @@ func (c *CodeInterpretingController) RunCommand() { ) return } + if goruntime.GOOS == "windows" && (request.Uid != nil || request.Gid != nil) { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + runtime.ErrCommandCredentialsUnsupported.Error(), + ) + return + } ctx, cancel := context.WithCancel(c.ctx.Request.Context()) defer cancel() @@ -58,6 +68,14 @@ func (c *CodeInterpretingController) RunCommand() { c.setupSSEResponse() err = codeRunner.Execute(runCodeRequest) if err != nil { + if errors.Is(err, runtime.ErrCommandCredentialsUnsupported) { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + err.Error(), + ) + return + } c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, @@ -133,6 +151,8 @@ func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.Ru Code: request.Command, Cwd: request.Cwd, Timeout: timeout, + Uid: toOptionalUint32(request.Uid), + Gid: toOptionalUint32(request.Gid), } } else { return &runtime.ExecuteCodeRequest{ @@ -140,6 +160,16 @@ func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.Ru Code: request.Command, Cwd: request.Cwd, Timeout: timeout, + Uid: toOptionalUint32(request.Uid), + Gid: toOptionalUint32(request.Gid), } } } + +func toOptionalUint32(value *int64) *uint32 { + if value == nil { + return nil + } + converted := uint32(*value) + return &converted +} diff --git a/components/execd/pkg/web/controller/command_test.go b/components/execd/pkg/web/controller/command_test.go index 29455783..c3daeda9 100644 --- a/components/execd/pkg/web/controller/command_test.go +++ b/components/execd/pkg/web/controller/command_test.go @@ -19,7 +19,9 @@ import ( "net/http" "net/http/httptest" "testing" + "time" + "github.com/alibaba/opensandbox/execd/pkg/runtime" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) @@ -70,3 +72,31 @@ func TestGetBackgroundCommandOutput_MissingID(t *testing.T) { t.Fatalf("unexpected message: %s", resp.Message) } } + +func TestBuildExecuteCommandRequest_MapsUidGidAndTimeout(t *testing.T) { + ctrl := &CodeInterpretingController{} + uid := int64(1001) + gid := int64(1002) + + execReq := ctrl.buildExecuteCommandRequest(model.RunCommandRequest{ + Command: "id", + Cwd: "/tmp", + TimeoutMs: 2500, + Uid: &uid, + Gid: &gid, + Background: true, + }) + + if execReq.Language != runtime.BackgroundCommand { + t.Fatalf("expected background command language, got %s", execReq.Language) + } + if execReq.Timeout != 2500*time.Millisecond { + t.Fatalf("unexpected timeout: %s", execReq.Timeout) + } + if execReq.Uid == nil || *execReq.Uid != 1001 { + t.Fatalf("unexpected uid: %#v", execReq.Uid) + } + if execReq.Gid == nil || *execReq.Gid != 1002 { + t.Fatalf("unexpected gid: %#v", execReq.Gid) + } +} diff --git a/components/execd/pkg/web/model/codeinterpreting.go b/components/execd/pkg/web/model/codeinterpreting.go index adc6452c..dab6607d 100644 --- a/components/execd/pkg/web/model/codeinterpreting.go +++ b/components/execd/pkg/web/model/codeinterpreting.go @@ -53,6 +53,10 @@ type RunCommandRequest struct { Background bool `json:"background,omitempty"` // TimeoutMs caps execution duration; 0 uses server default. TimeoutMs int64 `json:"timeout,omitempty" validate:"omitempty,gte=1"` + // Uid selects the POSIX user id for command execution. + Uid *int64 `json:"uid,omitempty" validate:"omitempty,gte=0,lte=4294967295"` + // Gid selects the POSIX group id for command execution. + Gid *int64 `json:"gid,omitempty" validate:"omitempty,gte=0,lte=4294967295"` } func (r *RunCommandRequest) Validate() error { diff --git a/components/execd/pkg/web/model/codeinterpreting_test.go b/components/execd/pkg/web/model/codeinterpreting_test.go index 64eee35d..c76efce5 100644 --- a/components/execd/pkg/web/model/codeinterpreting_test.go +++ b/components/execd/pkg/web/model/codeinterpreting_test.go @@ -58,6 +58,23 @@ func TestRunCommandRequestValidate(t *testing.T) { if err := req.Validate(); err == nil { t.Fatalf("expected validation error when command is empty") } + + req.Command = "ls" + req.Uid = int64Ptr(-1) + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error when uid is negative") + } + + req.Uid = int64Ptr(42) + req.Gid = int64Ptr(4_294_967_296) + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error when gid exceeds uint32 range") + } + + req.Gid = int64Ptr(0) + if err := req.Validate(); err != nil { + t.Fatalf("expected success with explicit uid/gid: %v", err) + } } func TestServerStreamEventToJSON(t *testing.T) { @@ -77,6 +94,10 @@ func TestServerStreamEventToJSON(t *testing.T) { } } +func int64Ptr(value int64) *int64 { + return &value +} + func TestServerStreamEventSummary(t *testing.T) { longText := strings.Repeat("a", 120) tests := []struct { diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs index d9cbf9f1..50f209a9 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs @@ -66,7 +66,9 @@ 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, }; 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..2714d69c 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs @@ -133,6 +133,18 @@ public class RunCommandRequest /// [JsonPropertyName("timeout")] public long? Timeout { get; set; } + + /// + /// Gets or sets the POSIX user id for command execution. Only supported on POSIX platforms. + /// + [JsonPropertyName("uid")] + public long? Uid { get; set; } + + /// + /// Gets or sets the POSIX group id for command execution. Only supported on POSIX platforms. + /// + [JsonPropertyName("gid")] + public long? Gid { get; set; } } /// @@ -155,6 +167,16 @@ public class RunCommandOptions /// The server terminates the command when this duration is reached. /// public int? TimeoutSeconds { get; set; } + + /// + /// Gets or sets the POSIX user id for command execution. Only supported on POSIX platforms. + /// + public long? Uid { get; set; } + + /// + /// Gets or sets the POSIX group id for command execution. Only supported on POSIX platforms. + /// + public long? Gid { get; set; } } /// diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs index 087fd1f0..2a89ea16 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs @@ -97,6 +97,8 @@ public async Task RunStreamAsync_ShouldSendTimeoutInMilliseconds() var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false); using var doc = JsonDocument.Parse(body); doc.RootElement.GetProperty("timeout").GetInt64().Should().Be(2000); + doc.RootElement.GetProperty("uid").GetInt64().Should().Be(1001); + doc.RootElement.GetProperty("gid").GetInt64().Should().Be(1002); return new HttpResponseMessage(HttpStatusCode.OK) { @@ -107,7 +109,9 @@ public async Task RunStreamAsync_ShouldSendTimeoutInMilliseconds() var options = new RunCommandOptions { - TimeoutSeconds = 2 + TimeoutSeconds = 2, + Uid = 1001, + Gid = 1002 }; await foreach (var _ in adapter.RunStreamAsync("sleep 1", options)) diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs index 70c81696..a5454bed 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs @@ -307,13 +307,17 @@ public void RunCommandOptions_ShouldStoreProperties() { WorkingDirectory = "/home/user", Background = true, - TimeoutSeconds = 30 + TimeoutSeconds = 30, + Uid = 1001, + Gid = 1002, }; // Assert options.WorkingDirectory.Should().Be("/home/user"); options.Background.Should().BeTrue(); options.TimeoutSeconds.Should().Be(30); + options.Uid.Should().Be(1001); + options.Gid.Should().Be(1002); } [Fact] diff --git a/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts b/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts index 6a457d3f..34796466 100644 --- a/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts @@ -45,6 +45,8 @@ function toRunCommandRequest(command: string, opts?: RunCommandOpts): ApiRunComm command, cwd: opts?.workingDirectory, background: !!opts?.background, + uid: opts?.uid, + gid: opts?.gid, }; if (opts?.timeoutSeconds != null) { body.timeout = Math.round(opts.timeoutSeconds * 1000); @@ -174,4 +176,4 @@ export class CommandsAdapter implements ExecdCommands { return execution; } -} \ No newline at end of file +} diff --git a/sdks/sandbox/javascript/src/api/execd.ts b/sdks/sandbox/javascript/src/api/execd.ts index 3c42a494..fc68e14b 100644 --- a/sdks/sandbox/javascript/src/api/execd.ts +++ b/sdks/sandbox/javascript/src/api/execd.ts @@ -527,6 +527,18 @@ export interface components { * @example 60000 */ timeout?: number; + /** + * Format: int64 + * @description Optional POSIX user id for command execution. When omitted, the current execd process uid is used. Only supported on POSIX platforms. + * @example 1000 + */ + uid?: number; + /** + * Format: int64 + * @description Optional POSIX group id for command execution. When omitted, the current execd process gid is used. Only supported on POSIX platforms. + * @example 1000 + */ + gid?: number; }; /** @description Command execution status (foreground or background) */ CommandStatusResponse: { diff --git a/sdks/sandbox/javascript/src/models/execd.ts b/sdks/sandbox/javascript/src/models/execd.ts index 6ffd59ef..fbcb6406 100644 --- a/sdks/sandbox/javascript/src/models/execd.ts +++ b/sdks/sandbox/javascript/src/models/execd.ts @@ -63,6 +63,14 @@ export interface RunCommandOpts { * If omitted, the server will not enforce any timeout. */ timeoutSeconds?: number; + /** + * Optional POSIX user id for command execution. Only supported on POSIX platforms. + */ + uid?: number; + /** + * Optional POSIX group id for command execution. Only supported on POSIX platforms. + */ + gid?: number; } export interface CommandStatus { @@ -101,4 +109,4 @@ export interface SandboxMetrics { timestamp: number; } -export type PingResponse = Record; \ No newline at end of file +export type PingResponse = Record; 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..af48b625 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,8 @@ 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 Optional POSIX user id for command execution. Only supported on POSIX platforms. + * @property gid Optional POSIX group id for command execution. Only supported on POSIX platforms. * @property handlers Optional execution handlers */ class RunCommandRequest private constructor( @@ -32,6 +34,8 @@ class RunCommandRequest private constructor( val background: Boolean, val workingDirectory: String?, val timeout: Duration?, + val uid: Long?, + val gid: Long?, val handlers: ExecutionHandlers?, ) { companion object { @@ -44,6 +48,8 @@ class RunCommandRequest private constructor( private var background: Boolean = false private var workingDirectory: String? = null private var timeout: Duration? = null + private var uid: Long? = null + private var gid: Long? = null private var handlers: ExecutionHandlers? = null fun command(command: String): Builder { @@ -71,6 +77,16 @@ class RunCommandRequest private constructor( return this } + fun uid(uid: Long?): Builder { + this.uid = uid + return this + } + + fun gid(gid: Long?): Builder { + this.gid = gid + return this + } + fun handlers(handlers: ExecutionHandlers?): Builder { this.handlers = handlers return this @@ -83,6 +99,8 @@ class RunCommandRequest private constructor( background = background, workingDirectory = workingDirectory, timeout = timeout, + uid = uid, + gid = gid, 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..3196a76b 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,8 @@ object ExecutionConverter { background = background, cwd = workingDirectory, timeout = timeout?.inWholeMilliseconds, + uid = uid, + gid = gid, ) } 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..4ec1f0c6 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 @@ -93,6 +93,8 @@ class CommandsAdapterTest { val request = RunCommandRequest.builder() .command("echo Hello") + .uid(1001) + .gid(1002) .handlers(handlers) .build() @@ -105,5 +107,8 @@ class CommandsAdapterTest { val recordedRequest = mockWebServer.takeRequest() assertEquals("/command", recordedRequest.path) assertEquals("POST", recordedRequest.method) + val requestBody = recordedRequest.body.readUtf8() + assertTrue(requestBody.contains("\"uid\":1001")) + assertTrue(requestBody.contains("\"gid\":1002")) } } 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..7f1f69b9 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py @@ -58,11 +58,21 @@ 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 + return ApiRunCommandRequest( command=command, background=background, cwd=cwd, # Domain uses 'working_directory', API uses 'cwd' timeout=timeout_milliseconds, + uid=uid, + gid=gid, # Note: handlers are not included in API request as they are for local processing ) 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..d64f284f 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 @@ -37,12 +37,18 @@ 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): Optional POSIX user id for command execution. When omitted, the current execd process uid is + used. Only supported on POSIX platforms. Example: 1000. + gid (int | Unset): Optional POSIX group id for command execution. When omitted, the current execd process gid is + used. Only supported on POSIX platforms. Example: 1000. """ command: str cwd: str | Unset = UNSET background: bool | Unset = False timeout: int | Unset = UNSET + uid: int | Unset = UNSET + gid: int | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -54,6 +60,10 @@ def to_dict(self) -> dict[str, Any]: timeout = self.timeout + uid = self.uid + + gid = self.gid + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -67,6 +77,10 @@ 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 return field_dict @@ -81,11 +95,17 @@ 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) + run_command_request = cls( command=command, cwd=cwd, background=background, timeout=timeout, + uid=uid, + gid=gid, ) run_command_request.additional_properties = d diff --git a/sdks/sandbox/python/src/opensandbox/models/execd.py b/sdks/sandbox/python/src/opensandbox/models/execd.py index 31e944b9..66bc6da0 100644 --- a/sdks/sandbox/python/src/opensandbox/models/execd.py +++ b/sdks/sandbox/python/src/opensandbox/models/execd.py @@ -234,6 +234,14 @@ 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, + description="Optional POSIX user id for command execution. Only supported on POSIX platforms.", + ) + gid: int | None = Field( + default=None, + description="Optional POSIX group id for command execution. Only supported on POSIX platforms.", + ) 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 54c42790..d27e18bf 100644 --- a/sdks/sandbox/python/tests/test_converters_and_error_handling.py +++ b/sdks/sandbox/python/tests/test_converters_and_error_handling.py @@ -117,6 +117,14 @@ def test_execution_converter_to_api_run_command_request() -> None: # timeout omitted when not set (backward compat) assert "timeout" not in ExecutionConverter.to_api_run_command_request("x", RunCommandOpts()).to_dict() + api4 = ExecutionConverter.to_api_run_command_request( + "id", + RunCommandOpts(uid=1001, gid=1002), + ) + d4 = api4.to_dict() + assert d4["uid"] == 1001 + assert d4["gid"] == 1002 + 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..599bb5b4 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -938,6 +938,20 @@ 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: int64 + minimum: 0 + maximum: 4294967295 + description: Optional POSIX user id for command execution. When omitted, the current execd process uid is used. Only supported on POSIX platforms. + example: 1000 + gid: + type: integer + format: int64 + minimum: 0 + maximum: 4294967295 + description: Optional POSIX group id for command execution. When omitted, the current execd process gid is used. Only supported on POSIX platforms. + example: 1000 CommandStatusResponse: type: object