From 3cdade0b08dca7f7a5b630e30fb8c4289bd923de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 5 Feb 2026 13:27:57 +0800 Subject: [PATCH 1/9] feat(specs): allow runCommand to specify username or uid --- specs/execd-api.yaml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml index 3763a0a3..1ac04fee 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -288,7 +288,8 @@ paths: description: | Executes a shell command and streams the output in real-time using SSE (Server-Sent Events). The command can run in foreground or background mode. The response includes stdout, stderr, - execution status, and completion events. + execution status, and completion events. Use the optional `user` field to run the command as + a specific system user when supported by the sandbox. operationId: runCommand tags: - Command @@ -304,12 +305,14 @@ paths: value: command: ls -la /workspace cwd: /workspace + user: sandbox background: false background: summary: Background command value: command: python server.py cwd: /app + user: 1001 background: true responses: "200": @@ -929,6 +932,16 @@ components: description: Whether to run command in detached mode default: false example: false + user: + oneOf: + - type: string + description: POSIX username + example: sandbox + - type: integer + format: int64 + description: Numeric UID + example: 1001 + description: Username or numeric UID to run the command as; defaults to the execd process user (current OS user) if omitted CommandStatusResponse: type: object @@ -942,6 +955,15 @@ components: type: string description: Original command content example: ls -la + user: + oneOf: + - type: string + description: Effective username that executed the command + example: sandbox + - type: integer + format: int64 + description: Effective UID that executed the command + example: 1001 running: type: boolean description: Whether the command is still running From 9092247fc1e47897ddb4d3e2ce4e9fc02737630a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 5 Feb 2026 14:02:49 +0800 Subject: [PATCH 2/9] feat(components/execd): add a user object to the run_command request and corresponding serialization/deserialization and validation rules. --- .../execd/pkg/runtime/command_status.go | 15 +- components/execd/pkg/runtime/types.go | 1 + components/execd/pkg/runtime/user.go | 22 +++ .../execd/pkg/web/controller/command.go | 3 + .../execd/pkg/web/model/codeinterpreting.go | 12 -- .../pkg/web/model/codeinterpreting_test.go | 12 -- components/execd/pkg/web/model/command.go | 43 ++++- .../execd/pkg/web/model/command_test.go | 29 +++ .../execd/pkg/web/model/user_identity.go | 171 ++++++++++++++++++ .../execd/pkg/web/model/user_identity_test.go | 132 ++++++++++++++ 10 files changed, 401 insertions(+), 39 deletions(-) create mode 100644 components/execd/pkg/runtime/user.go create mode 100644 components/execd/pkg/web/model/command_test.go create mode 100644 components/execd/pkg/web/model/user_identity.go create mode 100644 components/execd/pkg/web/model/user_identity_test.go diff --git a/components/execd/pkg/runtime/command_status.go b/components/execd/pkg/runtime/command_status.go index 97f112b1..0f40515c 100644 --- a/components/execd/pkg/runtime/command_status.go +++ b/components/execd/pkg/runtime/command_status.go @@ -23,13 +23,14 @@ import ( // CommandStatus describes the lifecycle state of a command. type CommandStatus struct { - Session string `json:"session"` - Running bool `json:"running"` - ExitCode *int `json:"exit_code,omitempty"` - Error string `json:"error,omitempty"` - StartedAt time.Time `json:"started_at,omitempty"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - Content string `json:"content,omitempty"` + Session string `json:"session"` + Running bool `json:"running"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Content string `json:"content,omitempty"` + User *CommandUser `json:"user,omitempty"` } // CommandOutput contains non-streamed stdout/stderr plus status. diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index cb82a11b..2b595001 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -40,6 +40,7 @@ type ExecuteCodeRequest struct { Timeout time.Duration `json:"timeout"` Cwd string `json:"cwd"` Envs map[string]string `json:"envs"` + User *CommandUser `json:"user,omitempty"` Hooks ExecuteResultHook } diff --git a/components/execd/pkg/runtime/user.go b/components/execd/pkg/runtime/user.go new file mode 100644 index 00000000..afb7a7b4 --- /dev/null +++ b/components/execd/pkg/runtime/user.go @@ -0,0 +1,22 @@ +// 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. + +package runtime + +// CommandUser captures the identity to run a command as. +// Actual user-switching is implemented elsewhere. +type CommandUser struct { + Username *string `json:"username,omitempty"` + UID *int64 `json:"uid,omitempty"` +} diff --git a/components/execd/pkg/web/controller/command.go b/components/execd/pkg/web/controller/command.go index 4031da71..3359b1d6 100644 --- a/components/execd/pkg/web/controller/command.go +++ b/components/execd/pkg/web/controller/command.go @@ -90,6 +90,7 @@ func (c *CodeInterpretingController) GetCommandStatus() { resp := model.CommandStatusResponse{ ID: status.Session, + User: model.UserIdentityFromRuntime(status.User), Running: status.Running, ExitCode: status.ExitCode, Error: status.Error, @@ -131,12 +132,14 @@ func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.Ru Language: runtime.BackgroundCommand, Code: request.Command, Cwd: request.Cwd, + User: request.User.ToRuntime(), } } else { return &runtime.ExecuteCodeRequest{ Language: runtime.Command, Code: request.Command, Cwd: request.Cwd, + User: request.User.ToRuntime(), } } } diff --git a/components/execd/pkg/web/model/codeinterpreting.go b/components/execd/pkg/web/model/codeinterpreting.go index e66976a4..0b6e863c 100644 --- a/components/execd/pkg/web/model/codeinterpreting.go +++ b/components/execd/pkg/web/model/codeinterpreting.go @@ -44,18 +44,6 @@ type CodeContextRequest struct { Cwd string `json:"cwd,omitempty"` } -// RunCommandRequest represents a shell command execution request. -type RunCommandRequest struct { - Command string `json:"command" validate:"required"` - Cwd string `json:"cwd,omitempty"` - Background bool `json:"background,omitempty"` -} - -func (r *RunCommandRequest) Validate() error { - validate := validator.New() - return validate.Struct(r) -} - type ServerStreamEventType string const ( diff --git a/components/execd/pkg/web/model/codeinterpreting_test.go b/components/execd/pkg/web/model/codeinterpreting_test.go index c94f2175..6250f190 100644 --- a/components/execd/pkg/web/model/codeinterpreting_test.go +++ b/components/execd/pkg/web/model/codeinterpreting_test.go @@ -33,18 +33,6 @@ func TestRunCodeRequestValidate(t *testing.T) { } } -func TestRunCommandRequestValidate(t *testing.T) { - req := RunCommandRequest{Command: "ls"} - if err := req.Validate(); err != nil { - t.Fatalf("expected command validation success: %v", err) - } - - req.Command = "" - if err := req.Validate(); err == nil { - t.Fatalf("expected validation error when command is empty") - } -} - func TestServerStreamEventToJSON(t *testing.T) { event := ServerStreamEvent{ Type: StreamEventTypeStdout, diff --git a/components/execd/pkg/web/model/command.go b/components/execd/pkg/web/model/command.go index 0d35aa82..eb628187 100644 --- a/components/execd/pkg/web/model/command.go +++ b/components/execd/pkg/web/model/command.go @@ -14,15 +14,42 @@ package model -import "time" +import ( + "time" + + "github.com/go-playground/validator/v10" +) // CommandStatusResponse represents command status for REST APIs. type CommandStatusResponse struct { - ID string `json:"id"` - Content string `json:"content,omitempty"` - Running bool `json:"running"` - ExitCode *int `json:"exit_code,omitempty"` - Error string `json:"error,omitempty"` - StartedAt time.Time `json:"started_at,omitempty"` - FinishedAt *time.Time `json:"finished_at,omitempty"` + ID string `json:"id"` + Content string `json:"content,omitempty"` + User *UserIdentity `json:"user,omitempty"` + Running bool `json:"running"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// RunCommandRequest represents a shell command execution request. +type RunCommandRequest struct { + Command string `json:"command" validate:"required"` + Cwd string `json:"cwd,omitempty"` + Background bool `json:"background,omitempty"` + User *UserIdentity `json:"user,omitempty"` +} + +func (r *RunCommandRequest) Validate() error { + validate := validator.New() + if err := validate.Struct(r); err != nil { + return err + } + if err := r.User.validate(); err != nil { + return err + } + if err := r.User.validateExists(); err != nil { + return err + } + return nil } diff --git a/components/execd/pkg/web/model/command_test.go b/components/execd/pkg/web/model/command_test.go new file mode 100644 index 00000000..f2867b68 --- /dev/null +++ b/components/execd/pkg/web/model/command_test.go @@ -0,0 +1,29 @@ +// 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. + +package model + +import "testing" + +func TestRunCommandRequestValidate(t *testing.T) { + req := RunCommandRequest{Command: "ls"} + if err := req.Validate(); err != nil { + t.Fatalf("expected command validation success: %v", err) + } + + req.Command = "" + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error when command is empty") + } +} diff --git a/components/execd/pkg/web/model/user_identity.go b/components/execd/pkg/web/model/user_identity.go new file mode 100644 index 00000000..2bfa14aa --- /dev/null +++ b/components/execd/pkg/web/model/user_identity.go @@ -0,0 +1,171 @@ +// 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. + +package model + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os/user" + "strconv" + "strings" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// UserIdentity represents a POSIX username or numeric UID. +type UserIdentity struct { + username *string + uid *int64 +} + +func newUserIdentityFromUsername(username string) *UserIdentity { + return &UserIdentity{username: &username} +} + +func newUserIdentityFromUID(uid int64) *UserIdentity { + return &UserIdentity{uid: &uid} +} + +func (u *UserIdentity) Username() (string, bool) { + if u == nil || u.username == nil { + return "", false + } + return *u.username, true +} + +func (u *UserIdentity) UID() (int64, bool) { + if u == nil || u.uid == nil { + return 0, false + } + return *u.uid, true +} + +// validate ensures the identity contains either username or uid with valid values. +func (u *UserIdentity) validate() error { + if u == nil { + return nil + } + if u.username != nil && u.uid != nil { + return errors.New("user must not set both username and uid") + } + if u.username != nil { + if strings.TrimSpace(*u.username) == "" { + return errors.New("username cannot be empty") + } + return nil + } + if u.uid != nil { + if *u.uid < 0 { + return errors.New("uid must be non-negative") + } + return nil + } + return errors.New("user must be a username or uid") +} + +// MarshalJSON renders the identity as either a JSON string (username) or number (uid). +func (u *UserIdentity) MarshalJSON() ([]byte, error) { + if u == nil { + return []byte("null"), nil + } + if u.username != nil { + return json.Marshal(*u.username) + } + if u.uid != nil { + return json.Marshal(*u.uid) + } + return []byte("null"), nil +} + +// UnmarshalJSON accepts either a string username or numeric UID. +func (u *UserIdentity) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil + } + u.username = nil + u.uid = nil + + // Try username (string) + if trimmed[0] == '"' { + var username string + if err := json.Unmarshal(trimmed, &username); err != nil { + return err + } + u.username = &username + u.uid = nil + return nil + } + + // Try UID (number) + var uid int64 + if err := json.Unmarshal(trimmed, &uid); err == nil { + u.uid = &uid + u.username = nil + return nil + } + + return errors.New("user must be string username or integer uid") +} + +// ToRuntime converts the identity to runtime.CommandUser. +func (u *UserIdentity) ToRuntime() *runtime.CommandUser { + if u == nil { + return nil + } + if username, ok := u.Username(); ok { + return &runtime.CommandUser{Username: &username} + } + if uid, ok := u.UID(); ok { + return &runtime.CommandUser{UID: &uid} + } + return nil +} + +// UserIdentityFromRuntime converts runtime.CommandUser to UserIdentity. +func UserIdentityFromRuntime(user *runtime.CommandUser) *UserIdentity { + if user == nil { + return nil + } + if user.Username != nil { + return newUserIdentityFromUsername(*user.Username) + } + if user.UID != nil { + return newUserIdentityFromUID(*user.UID) + } + return nil +} + +// validateExists ensures the referenced user/uid is present on the system. +func (u *UserIdentity) validateExists() error { + if u == nil { + return nil + } + if username, ok := u.Username(); ok { + if _, err := user.Lookup(username); err != nil { + return fmt.Errorf("user %s not found: %w", username, err) + } + return nil + } + if uid, ok := u.UID(); ok { + if _, err := user.LookupId(strconv.FormatInt(uid, 10)); err != nil { + return fmt.Errorf("uid %d not found: %w", uid, err) + } + return nil + } + return nil +} diff --git a/components/execd/pkg/web/model/user_identity_test.go b/components/execd/pkg/web/model/user_identity_test.go new file mode 100644 index 00000000..9bb9fa36 --- /dev/null +++ b/components/execd/pkg/web/model/user_identity_test.go @@ -0,0 +1,132 @@ +// 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. + +package model + +import ( + "encoding/json" + "os/user" + "strconv" + "testing" +) + +func TestRunCommand_requestValidateWithUser(t *testing.T) { + cur, err := user.Current() + if err != nil { + t.Skipf("cannot get current user: %v", err) + } + + req := RunCommandRequest{ + Command: "ls", + User: newUserIdentityFromUsername(cur.Username), + } + if err := req.Validate(); err != nil { + t.Fatalf("expected validation success with uid user: %v", err) + } + + if uid, parseErr := strconv.ParseInt(cur.Uid, 10, 64); parseErr == nil { + req.User = newUserIdentityFromUID(uid) + if err := req.Validate(); err != nil { + t.Fatalf("expected validation success with existing uid: %v", err) + } + } + + req.User = newUserIdentityFromUID(-1) + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error for negative uid") + } + + req.User = newUserIdentityFromUsername("") + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error for empty username") + } +} + +func TestRunCommand_requestValidateUserExists(t *testing.T) { + cur, err := user.Current() + if err != nil { + t.Skipf("cannot get current user: %v", err) + } + + req := RunCommandRequest{ + Command: "echo ok", + User: newUserIdentityFromUsername(cur.Username), + } + if err := req.Validate(); err != nil { + t.Fatalf("expected validation success with existing username: %v", err) + } + + if uid, parseErr := strconv.ParseInt(cur.Uid, 10, 64); parseErr == nil { + req.User = newUserIdentityFromUID(uid) + if err := req.Validate(); err != nil { + t.Fatalf("expected validation success with existing uid: %v", err) + } + } + + req.User = newUserIdentityFromUsername("user-does-not-exist-123456789") + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error for missing username") + } +} + +func TestUserIdentity_jsonRoundTrip(t *testing.T) { + var user UserIdentity + if err := json.Unmarshal([]byte(`"sandbox"`), &user); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if name, ok := user.Username(); !ok || name != "sandbox" { + t.Fatalf("expected username=sandbox, got %q", name) + } + if _, ok := user.UID(); ok { + t.Fatalf("expected uid to be unset") + } + if err := user.validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + b, err := json.Marshal(&user) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + if string(b) != `"sandbox"` { + t.Fatalf("expected marshaled username, got %s", string(b)) + } + + var uidUser UserIdentity + if err := json.Unmarshal([]byte(`1001`), &uidUser); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if uid, ok := uidUser.UID(); !ok || uid != 1001 { + t.Fatalf("expected uid=1001, got %d", uid) + } + if _, ok := uidUser.Username(); ok { + t.Fatalf("expected username to be unset") + } + if err := uidUser.validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + b, err = json.Marshal(&uidUser) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + if string(b) != "1001" { + t.Fatalf("expected marshaled uid, got %s", string(b)) + } +} + +func TestUserIdentity_unmarshalInvalid(t *testing.T) { + var user UserIdentity + if err := json.Unmarshal([]byte(`{"name":"bad"}`), &user); err == nil { + t.Fatalf("expected error for object input") + } +} From 42896b00a1001307c63306e4f65805338a375abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 5 Feb 2026 14:31:38 +0800 Subject: [PATCH 3/9] feat(components/execd): allow runCommand to switch user/uid --- components/execd/README.md | 8 + components/execd/README_zh.md | 8 + components/execd/pkg/runtime/command.go | 43 ++++- .../execd/pkg/runtime/command_status.go | 1 + components/execd/pkg/runtime/command_test.go | 163 ++++++++++++------ components/execd/pkg/runtime/ctrl.go | 1 + components/execd/pkg/runtime/user_unix.go | 79 +++++++++ .../execd/pkg/runtime/user_unix_test.go | 74 ++++++++ components/execd/pkg/web/model/command.go | 11 +- .../execd/pkg/web/model/command_test.go | 14 +- .../execd/pkg/web/model/user_identity_test.go | 88 +++------- 11 files changed, 365 insertions(+), 125 deletions(-) create mode 100644 components/execd/pkg/runtime/user_unix.go create mode 100644 components/execd/pkg/runtime/user_unix_test.go diff --git a/components/execd/README.md b/components/execd/README.md index dce1fd73..94f6467a 100644 --- a/components/execd/README.md +++ b/components/execd/README.md @@ -52,6 +52,7 @@ English | [中文](README_zh.md) - Proper signal forwarding with process groups - Real-time stdout/stderr streaming - Context-aware interruption +- Optional user/UID switch per request (requires container/user namespace permissions; see below) ### Filesystem @@ -169,6 +170,13 @@ export JUPYTER_TOKEN=your-token Environment variables override defaults but are superseded by explicit CLI flags. +### User switching (runCommand `user` field) + +- The `user` field in the command API supports username or UID. +- Effective switching requires the execd process to have **root** or at least **CAP_SETUID** and **CAP_SETGID**; in a user namespace, the target UID/GID must be mapped. +- If these capabilities/mappings are missing, command start will fail with a permission error. +- Ensure the target user exists in the container’s `/etc/passwd` (or NSS) before enabling. + ## API Reference [API Spec](../../specs/execd-api.yaml). diff --git a/components/execd/README_zh.md b/components/execd/README_zh.md index 35c3e4aa..4758ddf1 100644 --- a/components/execd/README_zh.md +++ b/components/execd/README_zh.md @@ -50,6 +50,7 @@ - 通过进程组管理正确转发信号 - 实时 stdout/stderr 流式输出 - 支持上下文感知的中断 +- 可选按请求切换用户/UID(需容器/namespace 权限,见下文) ### 文件系统 @@ -167,6 +168,13 @@ export JUPYTER_TOKEN=your-token 环境变量优先于默认值,但会被显式的 CLI 标志覆盖。 +### 用户切换(runCommand `user` 字段) + +- 命令 API 的 `user` 字段支持用户名或 UID。 +- 生效条件:进程需具备 **root** 或至少 **CAP_SETUID**、**CAP_SETGID**;在 user namespace 下还需有目标 UID/GID 的映射。 +- 若缺少上述能力/映射,启动命令会因权限不足失败。 +- 启用前请确保目标用户已在容器的 `/etc/passwd`(或 NSS)中存在。 + ## API 参考 [API Spec](../../specs/execd-api.yaml)。 diff --git a/components/execd/pkg/runtime/command.go b/components/execd/pkg/runtime/command.go index 5afc37d3..a88ae077 100644 --- a/components/execd/pkg/runtime/command.go +++ b/components/execd/pkg/runtime/command.go @@ -52,6 +52,17 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest startAt := time.Now() log.Info("received command: %v", request.Code) + cred, resolvedUser, err := resolveUserCredential(request.User) + if err != nil { + request.Hooks.OnExecuteInit(session) + request.Hooks.OnExecuteError(&execute.ErrorOutput{ + EName: "CommandExecError", + EValue: err.Error(), + Traceback: []string{err.Error()}, + }) + log.Error("CommandExecError: error preparing command user: %v", err) + return nil + } cmd := exec.CommandContext(ctx, "bash", "-c", request.Code) cmd.Stdout = stdout @@ -72,7 +83,11 @@ 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} + sysProcAttr := &syscall.SysProcAttr{Setpgid: true} + if cred != nil { + sysProcAttr.Credential = cred + } + cmd.SysProcAttr = sysProcAttr err = cmd.Start() if err != nil { @@ -90,6 +105,7 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest running: true, content: request.Code, isBackground: false, + user: resolvedUser, } c.storeCommandKernel(session, kernel) request.Hooks.OnExecuteInit(session) @@ -169,8 +185,30 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod log.Info("received command: %v", request.Code) cmd := exec.CommandContext(context.Background(), "bash", "-c", request.Code) + cred, resolvedUser, err := resolveUserCredential(request.User) + if err != nil { + log.Error("CommandExecError: error preparing command user: %v", err) + kernel := &commandKernel{ + pid: -1, + stdoutPath: stdoutPath, + stderrPath: stderrPath, + startedAt: startAt, + running: false, + content: request.Code, + isBackground: true, + user: nil, + } + c.storeCommandKernel(session, kernel) + c.markCommandFinished(session, 255, err.Error()) + return nil + } + cmd.Dir = request.Cwd - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + sysProcAttr := &syscall.SysProcAttr{Setpgid: true} + if cred != nil { + sysProcAttr.Credential = cred + } + cmd.SysProcAttr = sysProcAttr cmd.Stdout = pipe cmd.Stderr = pipe cmd.Env = mergeEnvs(os.Environ(), loadExtraEnvFromFile()) @@ -190,6 +228,7 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod running: true, content: request.Code, isBackground: true, + user: resolvedUser, } if err != nil { diff --git a/components/execd/pkg/runtime/command_status.go b/components/execd/pkg/runtime/command_status.go index 0f40515c..ae850f29 100644 --- a/components/execd/pkg/runtime/command_status.go +++ b/components/execd/pkg/runtime/command_status.go @@ -68,6 +68,7 @@ func (c *Controller) GetCommandStatus(session string) (*CommandStatus, error) { StartedAt: kernel.startedAt, FinishedAt: kernel.finishedAt, Content: kernel.content, + User: kernel.user, } return status, nil } diff --git a/components/execd/pkg/runtime/command_test.go b/components/execd/pkg/runtime/command_test.go index 1e201330..ecd40565 100644 --- a/components/execd/pkg/runtime/command_test.go +++ b/components/execd/pkg/runtime/command_test.go @@ -18,7 +18,9 @@ import ( "context" "os" "os/exec" + "os/user" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -37,8 +39,8 @@ func TestReadFromPos_SplitsOnCRAndLF(t *testing.T) { mutex := &sync.Mutex{} initial := "line1\nprog 10%\rprog 20%\rprog 30%\nlast\n" - if err := os.WriteFile(logFile, []byte(initial), 0o644); err != nil { - t.Fatalf("write initial file: %v", err) + if err := os.WriteFile(logFile, []byte(initial), 0o644); !assert.NoError(t, err, "write initial file") { + return } var got []string @@ -46,37 +48,30 @@ func TestReadFromPos_SplitsOnCRAndLF(t *testing.T) { nextPos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false) want := []string{"line1", "prog 10%", "prog 20%", "prog 30%", "last"} - if len(got) != len(want) { - t.Fatalf("unexpected token count: got %d want %d", len(got), len(want)) - } + assert.Equal(t, len(want), len(got), "unexpected token count") for i := range want { - if got[i] != want[i] { - t.Fatalf("token[%d]: got %q want %q", i, got[i], want[i]) - } + assert.Equalf(t, want[i], got[i], "token[%d]", i) } // append more content and ensure incremental read only yields the new part appendPart := "tail1\r\ntail2\n" f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - t.Fatalf("open append: %v", err) + if !assert.NoError(t, err, "open append") { + return } if _, err := f.WriteString(appendPart); err != nil { - f.Close() - t.Fatalf("append write: %v", err) + _ = f.Close() + assert.NoError(t, err, "append write") + return } _ = f.Close() got = got[:0] c.readFromPos(mutex, logFile, nextPos, func(s string) { got = append(got, s) }, false) want = []string{"tail1", "tail2"} - if len(got) != len(want) { - t.Fatalf("incremental token count: got %d want %d", len(got), len(want)) - } + assert.Equal(t, len(want), len(got), "incremental token count") for i := range want { - if got[i] != want[i] { - t.Fatalf("incremental token[%d]: got %q want %q", i, got[i], want[i]) - } + assert.Equalf(t, want[i], got[i], "incremental token[%d]", i) } } @@ -86,20 +81,16 @@ func TestReadFromPos_LongLine(t *testing.T) { // construct a single line larger than the default 64KB, but under 5MB longLine := strings.Repeat("x", 256*1024) + "\n" // 256KB - if err := os.WriteFile(logFile, []byte(longLine), 0o644); err != nil { - t.Fatalf("write long line: %v", err) + if err := os.WriteFile(logFile, []byte(longLine), 0o644); !assert.NoError(t, err, "write long line") { + return } var got []string c := &Controller{} c.readFromPos(&sync.Mutex{}, logFile, 0, func(s string) { got = append(got, s) }, false) - if len(got) != 1 { - t.Fatalf("expected one token, got %d", len(got)) - } - if got[0] != strings.TrimSuffix(longLine, "\n") { - t.Fatalf("long line mismatch: got %d chars want %d chars", len(got[0]), len(longLine)-1) - } + assert.Equal(t, 1, len(got), "expected one token") + assert.Equal(t, strings.TrimSuffix(longLine, "\n"), got[0], "long line mismatch") } func TestReadFromPos_FlushesTrailingLine(t *testing.T) { @@ -159,7 +150,7 @@ func TestRunCommand_Echo(t *testing.T) { stderrLines = append(stderrLines, s) }, OnExecuteError: func(err *execute.ErrorOutput) { - t.Fatalf("unexpected error hook: %+v", err) + assert.Fail(t, "unexpected error hook", "%+v", err) }, OnExecuteComplete: func(_ time.Duration) { completeCh <- struct{}{} @@ -167,25 +158,20 @@ func TestRunCommand_Echo(t *testing.T) { }, } - if err := c.runCommand(ctx, req); err != nil { - t.Fatalf("runCommand returned error: %v", err) + if !assert.NoError(t, c.runCommand(ctx, req)) { + return } select { case <-completeCh: case <-time.After(2 * time.Second): - t.Fatalf("timeout waiting for completion hook") + assert.Fail(t, "timeout waiting for completion hook") + return } - if sessionID == "" { - t.Fatalf("expected session id to be set") - } - if len(stdoutLines) != 1 || stdoutLines[0] != "hello" { - t.Fatalf("unexpected stdout: %#v", stdoutLines) - } - if len(stderrLines) != 1 || stderrLines[0] != "errline" { - t.Fatalf("unexpected stderr: %#v", stderrLines) - } + assert.NotEmpty(t, sessionID, "expected session id to be set") + assert.Equal(t, []string{"hello"}, stdoutLines) + assert.Equal(t, []string{"errline"}, stderrLines) } func TestRunCommand_Error(t *testing.T) { @@ -227,29 +213,102 @@ func TestRunCommand_Error(t *testing.T) { }, } - if err := c.runCommand(ctx, req); err != nil { - t.Fatalf("runCommand returned error: %v", err) + if !assert.NoError(t, c.runCommand(ctx, req)) { + return } select { case <-completeCh: case <-time.After(2 * time.Second): - t.Fatalf("timeout waiting for completion hook") + assert.Fail(t, "timeout waiting for completion hook") + return } - if sessionID == "" { - t.Fatalf("expected session id to be set") + assert.NotEmpty(t, sessionID, "expected session id to be set") + if assert.NotEmpty(t, stdoutLines) { + assert.Equal(t, "before", stdoutLines[0]) } - if len(stdoutLines) == 0 || stdoutLines[0] != "before" { - t.Fatalf("unexpected stdout: %#v", stdoutLines) + assert.Empty(t, stderrLines) + if assert.NotNil(t, gotErr, "expected error hook to be called") { + assert.Equal(t, "CommandExecError", gotErr.EName) + assert.Equal(t, "3", gotErr.EValue) } - if len(stderrLines) != 0 { - t.Fatalf("expected no stderr, got %#v", stderrLines) +} + +func TestRunCommand_WithUser(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("bash not available on windows") } - if gotErr == nil { - t.Fatalf("expected error hook to be called") + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + cur, err := user.Current() + if err != nil { + t.Skipf("cannot get current user: %v", err) + } + + c := NewController("", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + sessionID string + gotErr *execute.ErrorOutput + completeCh = make(chan struct{}, 2) + ) + + req := &ExecuteCodeRequest{ + Code: `echo "user-test"`, + Cwd: t.TempDir(), + Timeout: 5 * time.Second, + User: &CommandUser{Username: &cur.Username}, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(s string) { sessionID = s }, + OnExecuteError: func(err *execute.ErrorOutput) { gotErr = err; completeCh <- struct{}{} }, + OnExecuteComplete: func(_ time.Duration) { + completeCh <- struct{}{} + }, + }, + } + + if !assert.NoError(t, c.runCommand(ctx, req)) { + return + } + + select { + case <-completeCh: + case <-time.After(2 * time.Second): + assert.Fail(t, "timeout waiting for completion hook") + return + } + + if gotErr != nil { + if strings.Contains(gotErr.EValue, "operation not permitted") { + t.Skipf("skipping user credential test: %s", gotErr.EValue) + } + assert.Fail(t, "unexpected error hook", "%+v", gotErr) + return + } + + assert.NotEmpty(t, sessionID, "expected session id to be set") + + status, err := c.GetCommandStatus(sessionID) + if !assert.NoError(t, err) { + return + } + if assert.NotNil(t, status.User, "expected status user") { + assert.NotNil(t, status.User.Username, "expected status username") + if status.User.Username != nil { + assert.Equal(t, cur.Username, *status.User.Username) + } + } + if uidVal, parseErr := strconv.ParseInt(cur.Uid, 10, 64); parseErr == nil { + if assert.NotNil(t, status.User) && assert.NotNil(t, status.User.UID) { + assert.Equal(t, uidVal, *status.User.UID) + } } - if gotErr.EName != "CommandExecError" || gotErr.EValue != "3" { - t.Fatalf("unexpected error payload: %+v", gotErr) + if assert.NotNil(t, status.ExitCode) { + assert.Equal(t, 0, *status.ExitCode) } } diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index 20bbecc6..0fe279eb 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -63,6 +63,7 @@ type commandKernel struct { running bool isBackground bool content string + user *CommandUser } // NewController creates a runtime controller. diff --git a/components/execd/pkg/runtime/user_unix.go b/components/execd/pkg/runtime/user_unix.go new file mode 100644 index 00000000..e26f0d0c --- /dev/null +++ b/components/execd/pkg/runtime/user_unix.go @@ -0,0 +1,79 @@ +//go:build !windows +// +build !windows + +// 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. + +package runtime + +import ( + "errors" + "fmt" + "os/user" + "strconv" + "syscall" +) + +// resolveUserCredential converts CommandUser to syscall.Credential and a resolved identity. +// Caller is responsible for handling permission errors when switching users. +func resolveUserCredential(u *CommandUser) (*syscall.Credential, *CommandUser, error) { + if u == nil { + return nil, nil, nil + } + + var ( + usr *user.User + err error + ) + + switch { + case u.Username != nil: + usr, err = user.Lookup(*u.Username) + if err != nil { + return nil, nil, fmt.Errorf("lookup user %s: %w", *u.Username, err) + } + case u.UID != nil: + usr, err = user.LookupId(strconv.FormatInt(*u.UID, 10)) + if err != nil { + return nil, nil, fmt.Errorf("lookup uid %d: %w", *u.UID, err) + } + default: + return nil, nil, errors.New("user must provide username or uid") + } + + uid, err := strconv.ParseUint(usr.Uid, 10, 32) + if err != nil { + return nil, nil, fmt.Errorf("parse uid %s: %w", usr.Uid, err) + } + gid, err := strconv.ParseUint(usr.Gid, 10, 32) + if err != nil { + return nil, nil, fmt.Errorf("parse gid %s: %w", usr.Gid, err) + } + + cred := &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + } + + resolvedUID := int64(uid) + resolved := &CommandUser{ + UID: &resolvedUID, + } + if usr.Username != "" { + username := usr.Username + resolved.Username = &username + } + + return cred, resolved, nil +} diff --git a/components/execd/pkg/runtime/user_unix_test.go b/components/execd/pkg/runtime/user_unix_test.go new file mode 100644 index 00000000..9b81d5b6 --- /dev/null +++ b/components/execd/pkg/runtime/user_unix_test.go @@ -0,0 +1,74 @@ +//go:build !windows +// +build !windows + +// 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. + +package runtime + +import ( + "os/user" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveUserCredentialWithUsername(t *testing.T) { + cur, err := user.Current() + if err != nil { + t.Skipf("cannot get current user: %v", err) + } + + u := &CommandUser{Username: &cur.Username} + cred, resolved, err := resolveUserCredential(u) + if !assert.NoError(t, err) { + return + } + if !assert.NotNil(t, cred) || !assert.NotNil(t, resolved) { + return + } + if assert.NotNil(t, resolved.Username) { + assert.Equal(t, cur.Username, *resolved.Username) + } + uid, _ := strconv.ParseUint(cur.Uid, 10, 32) + if assert.NotNil(t, resolved.UID) { + assert.Equal(t, int64(uid), *resolved.UID) + } + assert.Equal(t, uint32(uid), cred.Uid) +} + +func TestResolveUserCredentialWithUID(t *testing.T) { + cur, err := user.Current() + if err != nil { + t.Skipf("cannot get current user: %v", err) + } + uidVal, parseErr := strconv.ParseInt(cur.Uid, 10, 64) + if parseErr != nil { + t.Skipf("cannot parse uid: %v", parseErr) + } + + u := &CommandUser{UID: &uidVal} + cred, resolved, err := resolveUserCredential(u) + if !assert.NoError(t, err) { + return + } + if assert.NotNil(t, resolved.UID) { + assert.Equal(t, uidVal, *resolved.UID) + } + assert.NotNil(t, resolved.Username) + if assert.NotNil(t, cred) { + assert.Equal(t, uint32(uidVal), cred.Uid) + } +} diff --git a/components/execd/pkg/web/model/command.go b/components/execd/pkg/web/model/command.go index eb628187..10ead3d3 100644 --- a/components/execd/pkg/web/model/command.go +++ b/components/execd/pkg/web/model/command.go @@ -34,10 +34,13 @@ type CommandStatusResponse struct { // RunCommandRequest represents a shell command execution request. type RunCommandRequest struct { - Command string `json:"command" validate:"required"` - Cwd string `json:"cwd,omitempty"` - Background bool `json:"background,omitempty"` - User *UserIdentity `json:"user,omitempty"` + Command string `json:"command" validate:"required"` + Cwd string `json:"cwd,omitempty"` + Background bool `json:"background,omitempty"` + // User specifies the username or UID to run the command as. + // Effective switching requires root or CAP_SETUID/CAP_SETGID (and valid UID/GID + // mappings when using user namespaces); otherwise it will fail with a permission error. + User *UserIdentity `json:"user,omitempty"` } func (r *RunCommandRequest) Validate() error { diff --git a/components/execd/pkg/web/model/command_test.go b/components/execd/pkg/web/model/command_test.go index f2867b68..b8a93dc2 100644 --- a/components/execd/pkg/web/model/command_test.go +++ b/components/execd/pkg/web/model/command_test.go @@ -14,16 +14,16 @@ package model -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestRunCommandRequestValidate(t *testing.T) { req := RunCommandRequest{Command: "ls"} - if err := req.Validate(); err != nil { - t.Fatalf("expected command validation success: %v", err) - } + assert.NoError(t, req.Validate()) req.Command = "" - if err := req.Validate(); err == nil { - t.Fatalf("expected validation error when command is empty") - } + assert.Error(t, req.Validate()) } diff --git a/components/execd/pkg/web/model/user_identity_test.go b/components/execd/pkg/web/model/user_identity_test.go index 9bb9fa36..81fd6676 100644 --- a/components/execd/pkg/web/model/user_identity_test.go +++ b/components/execd/pkg/web/model/user_identity_test.go @@ -19,6 +19,8 @@ import ( "os/user" "strconv" "testing" + + "github.com/stretchr/testify/assert" ) func TestRunCommand_requestValidateWithUser(t *testing.T) { @@ -31,26 +33,18 @@ func TestRunCommand_requestValidateWithUser(t *testing.T) { Command: "ls", User: newUserIdentityFromUsername(cur.Username), } - if err := req.Validate(); err != nil { - t.Fatalf("expected validation success with uid user: %v", err) - } + assert.NoError(t, req.Validate(), "expected validation success with uid user") if uid, parseErr := strconv.ParseInt(cur.Uid, 10, 64); parseErr == nil { req.User = newUserIdentityFromUID(uid) - if err := req.Validate(); err != nil { - t.Fatalf("expected validation success with existing uid: %v", err) - } + assert.NoError(t, req.Validate(), "expected validation success with existing uid") } req.User = newUserIdentityFromUID(-1) - if err := req.Validate(); err == nil { - t.Fatalf("expected validation error for negative uid") - } + assert.Error(t, req.Validate(), "expected validation error for negative uid") req.User = newUserIdentityFromUsername("") - if err := req.Validate(); err == nil { - t.Fatalf("expected validation error for empty username") - } + assert.Error(t, req.Validate(), "expected validation error for empty username") } func TestRunCommand_requestValidateUserExists(t *testing.T) { @@ -63,70 +57,44 @@ func TestRunCommand_requestValidateUserExists(t *testing.T) { Command: "echo ok", User: newUserIdentityFromUsername(cur.Username), } - if err := req.Validate(); err != nil { - t.Fatalf("expected validation success with existing username: %v", err) - } + assert.NoError(t, req.Validate(), "expected validation success with existing username") if uid, parseErr := strconv.ParseInt(cur.Uid, 10, 64); parseErr == nil { req.User = newUserIdentityFromUID(uid) - if err := req.Validate(); err != nil { - t.Fatalf("expected validation success with existing uid: %v", err) - } + assert.NoError(t, req.Validate(), "expected validation success with existing uid") } req.User = newUserIdentityFromUsername("user-does-not-exist-123456789") - if err := req.Validate(); err == nil { - t.Fatalf("expected validation error for missing username") - } + assert.Error(t, req.Validate(), "expected validation error for missing username") } func TestUserIdentity_jsonRoundTrip(t *testing.T) { var user UserIdentity - if err := json.Unmarshal([]byte(`"sandbox"`), &user); err != nil { - t.Fatalf("unexpected unmarshal error: %v", err) - } - if name, ok := user.Username(); !ok || name != "sandbox" { - t.Fatalf("expected username=sandbox, got %q", name) - } - if _, ok := user.UID(); ok { - t.Fatalf("expected uid to be unset") - } - if err := user.validate(); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } + assert.NoError(t, json.Unmarshal([]byte(`"sandbox"`), &user)) + name, ok := user.Username() + assert.True(t, ok) + assert.Equal(t, "sandbox", name) + _, ok = user.UID() + assert.False(t, ok) + assert.NoError(t, user.validate()) b, err := json.Marshal(&user) - if err != nil { - t.Fatalf("marshal error: %v", err) - } - if string(b) != `"sandbox"` { - t.Fatalf("expected marshaled username, got %s", string(b)) - } + assert.NoError(t, err) + assert.Equal(t, `"sandbox"`, string(b)) var uidUser UserIdentity - if err := json.Unmarshal([]byte(`1001`), &uidUser); err != nil { - t.Fatalf("unexpected unmarshal error: %v", err) - } - if uid, ok := uidUser.UID(); !ok || uid != 1001 { - t.Fatalf("expected uid=1001, got %d", uid) - } - if _, ok := uidUser.Username(); ok { - t.Fatalf("expected username to be unset") - } - if err := uidUser.validate(); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } + assert.NoError(t, json.Unmarshal([]byte(`1001`), &uidUser)) + uid, ok := uidUser.UID() + assert.True(t, ok) + assert.Equal(t, int64(1001), uid) + _, ok = uidUser.Username() + assert.False(t, ok) + assert.NoError(t, uidUser.validate()) b, err = json.Marshal(&uidUser) - if err != nil { - t.Fatalf("marshal error: %v", err) - } - if string(b) != "1001" { - t.Fatalf("expected marshaled uid, got %s", string(b)) - } + assert.NoError(t, err) + assert.Equal(t, "1001", string(b)) } func TestUserIdentity_unmarshalInvalid(t *testing.T) { var user UserIdentity - if err := json.Unmarshal([]byte(`{"name":"bad"}`), &user); err == nil { - t.Fatalf("expected error for object input") - } + assert.Error(t, json.Unmarshal([]byte(`{"name":"bad"}`), &user)) } From d39cb51ac83c8c0ca1c9327372846e205cbee689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Thu, 5 Feb 2026 16:22:02 +0800 Subject: [PATCH 4/9] chore(specs): simplify user identity schema to name/uid object --- .../execd/pkg/web/model/command_test.go | 33 ++++++ .../execd/pkg/web/model/user_identity.go | 102 ++++-------------- .../execd/pkg/web/model/user_identity_test.go | 29 +++-- specs/execd-api.yaml | 34 ++++-- 4 files changed, 91 insertions(+), 107 deletions(-) diff --git a/components/execd/pkg/web/model/command_test.go b/components/execd/pkg/web/model/command_test.go index b8a93dc2..248f4af5 100644 --- a/components/execd/pkg/web/model/command_test.go +++ b/components/execd/pkg/web/model/command_test.go @@ -15,6 +15,8 @@ package model import ( + "os/user" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -27,3 +29,34 @@ func TestRunCommandRequestValidate(t *testing.T) { req.Command = "" assert.Error(t, req.Validate()) } + +func TestRunCommandRequestValidate_UserObject(t *testing.T) { + cur, err := user.Current() + if err != nil { + t.Skipf("cannot get current user: %v", err) + } + + req := RunCommandRequest{ + Command: "ls", + User: &UserIdentity{ + Username: &cur.Username, + }, + } + assert.NoError(t, req.Validate()) + + if uid, parseErr := strconv.ParseInt(cur.Uid, 10, 64); parseErr == nil { + req.User = &UserIdentity{ + UID: &uid, + } + assert.NoError(t, req.Validate()) + } + + req.User = &UserIdentity{ + Username: ptrString("sandbox"), + UID: ptrInt64(1001), + } + assert.Error(t, req.Validate()) +} + +func ptrString(s string) *string { return &s } +func ptrInt64(i int64) *int64 { return &i } diff --git a/components/execd/pkg/web/model/user_identity.go b/components/execd/pkg/web/model/user_identity.go index 2bfa14aa..9f69c20a 100644 --- a/components/execd/pkg/web/model/user_identity.go +++ b/components/execd/pkg/web/model/user_identity.go @@ -15,8 +15,6 @@ package model import ( - "bytes" - "encoding/json" "errors" "fmt" "os/user" @@ -27,31 +25,18 @@ import ( ) // UserIdentity represents a POSIX username or numeric UID. +// Prefer specifying exactly one of Username/UID. type UserIdentity struct { - username *string - uid *int64 + Username *string `json:"name,omitempty"` + UID *int64 `json:"uid,omitempty"` } func newUserIdentityFromUsername(username string) *UserIdentity { - return &UserIdentity{username: &username} + return &UserIdentity{Username: &username} } func newUserIdentityFromUID(uid int64) *UserIdentity { - return &UserIdentity{uid: &uid} -} - -func (u *UserIdentity) Username() (string, bool) { - if u == nil || u.username == nil { - return "", false - } - return *u.username, true -} - -func (u *UserIdentity) UID() (int64, bool) { - if u == nil || u.uid == nil { - return 0, false - } - return *u.uid, true + return &UserIdentity{UID: &uid} } // validate ensures the identity contains either username or uid with valid values. @@ -59,17 +44,17 @@ func (u *UserIdentity) validate() error { if u == nil { return nil } - if u.username != nil && u.uid != nil { + if u.Username != nil && u.UID != nil { return errors.New("user must not set both username and uid") } - if u.username != nil { - if strings.TrimSpace(*u.username) == "" { + if u.Username != nil { + if strings.TrimSpace(*u.Username) == "" { return errors.New("username cannot be empty") } return nil } - if u.uid != nil { - if *u.uid < 0 { + if u.UID != nil { + if *u.UID < 0 { return errors.New("uid must be non-negative") } return nil @@ -77,61 +62,16 @@ func (u *UserIdentity) validate() error { return errors.New("user must be a username or uid") } -// MarshalJSON renders the identity as either a JSON string (username) or number (uid). -func (u *UserIdentity) MarshalJSON() ([]byte, error) { - if u == nil { - return []byte("null"), nil - } - if u.username != nil { - return json.Marshal(*u.username) - } - if u.uid != nil { - return json.Marshal(*u.uid) - } - return []byte("null"), nil -} - -// UnmarshalJSON accepts either a string username or numeric UID. -func (u *UserIdentity) UnmarshalJSON(data []byte) error { - trimmed := bytes.TrimSpace(data) - if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { - return nil - } - u.username = nil - u.uid = nil - - // Try username (string) - if trimmed[0] == '"' { - var username string - if err := json.Unmarshal(trimmed, &username); err != nil { - return err - } - u.username = &username - u.uid = nil - return nil - } - - // Try UID (number) - var uid int64 - if err := json.Unmarshal(trimmed, &uid); err == nil { - u.uid = &uid - u.username = nil - return nil - } - - return errors.New("user must be string username or integer uid") -} - // ToRuntime converts the identity to runtime.CommandUser. func (u *UserIdentity) ToRuntime() *runtime.CommandUser { if u == nil { return nil } - if username, ok := u.Username(); ok { - return &runtime.CommandUser{Username: &username} + if u.Username != nil { + return &runtime.CommandUser{Username: u.Username} } - if uid, ok := u.UID(); ok { - return &runtime.CommandUser{UID: &uid} + if u.UID != nil { + return &runtime.CommandUser{UID: u.UID} } return nil } @@ -155,17 +95,17 @@ func (u *UserIdentity) validateExists() error { if u == nil { return nil } - if username, ok := u.Username(); ok { - if _, err := user.Lookup(username); err != nil { - return fmt.Errorf("user %s not found: %w", username, err) + if u.Username != nil && *u.Username != "" { + if _, err := user.Lookup(*u.Username); err != nil { + return fmt.Errorf("user %s not found: %w", *u.Username, err) } return nil } - if uid, ok := u.UID(); ok { - if _, err := user.LookupId(strconv.FormatInt(uid, 10)); err != nil { - return fmt.Errorf("uid %d not found: %w", uid, err) + if u.UID != nil { + if _, err := user.LookupId(strconv.FormatInt(*u.UID, 10)); err != nil { + return fmt.Errorf("uid %d not found: %w", *u.UID, err) } return nil } - return nil + return errors.New("user must contain name or uid") } diff --git a/components/execd/pkg/web/model/user_identity_test.go b/components/execd/pkg/web/model/user_identity_test.go index 81fd6676..082d57de 100644 --- a/components/execd/pkg/web/model/user_identity_test.go +++ b/components/execd/pkg/web/model/user_identity_test.go @@ -70,31 +70,30 @@ func TestRunCommand_requestValidateUserExists(t *testing.T) { func TestUserIdentity_jsonRoundTrip(t *testing.T) { var user UserIdentity - assert.NoError(t, json.Unmarshal([]byte(`"sandbox"`), &user)) - name, ok := user.Username() - assert.True(t, ok) - assert.Equal(t, "sandbox", name) - _, ok = user.UID() - assert.False(t, ok) + assert.NoError(t, json.Unmarshal([]byte(`{"name":"sandbox"}`), &user)) + if assert.NotNil(t, user.Username) { + assert.Equal(t, "sandbox", *user.Username) + } + assert.Nil(t, user.UID) assert.NoError(t, user.validate()) b, err := json.Marshal(&user) assert.NoError(t, err) - assert.Equal(t, `"sandbox"`, string(b)) + assert.JSONEq(t, `{"name":"sandbox"}`, string(b)) var uidUser UserIdentity - assert.NoError(t, json.Unmarshal([]byte(`1001`), &uidUser)) - uid, ok := uidUser.UID() - assert.True(t, ok) - assert.Equal(t, int64(1001), uid) - _, ok = uidUser.Username() - assert.False(t, ok) + assert.NoError(t, json.Unmarshal([]byte(`{"uid":1001}`), &uidUser)) + if assert.NotNil(t, uidUser.UID) { + assert.Equal(t, int64(1001), *uidUser.UID) + } + assert.Nil(t, uidUser.Username) assert.NoError(t, uidUser.validate()) b, err = json.Marshal(&uidUser) assert.NoError(t, err) - assert.Equal(t, "1001", string(b)) + assert.JSONEq(t, `{"uid":1001}`, string(b)) } func TestUserIdentity_unmarshalInvalid(t *testing.T) { var user UserIdentity - assert.Error(t, json.Unmarshal([]byte(`{"name":"bad"}`), &user)) + assert.NoError(t, json.Unmarshal([]byte(`{"other":"bad"}`), &user)) + assert.Error(t, user.validateExists()) } diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml index 1ac04fee..c7ae7488 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -305,14 +305,16 @@ paths: value: command: ls -la /workspace cwd: /workspace - user: sandbox + user: + name: sandbox background: false background: summary: Background command value: command: python server.py cwd: /app - user: 1001 + user: + uid: 1001 background: true responses: "200": @@ -933,15 +935,21 @@ components: default: false example: false user: - oneOf: - - type: string - description: POSIX username + type: object + properties: + name: + type: string + description: POSIX username to drop privileges to example: sandbox - - type: integer + uid: + type: integer format: int64 - description: Numeric UID + description: Numeric UID to drop privileges to example: 1001 - description: Username or numeric UID to run the command as; defaults to the execd process user (current OS user) if omitted + additionalProperties: false + description: | + Target user identity. Specify either `name` or `uid` (but not both). + Effective switching requires root or CAP_SETUID/CAP_SETGID (and valid UID/GID mappings when using user namespaces); otherwise the command will fail with a permission error. CommandStatusResponse: type: object @@ -956,14 +964,18 @@ components: description: Original command content example: ls -la user: - oneOf: - - type: string + type: object + properties: + name: + type: string description: Effective username that executed the command example: sandbox - - type: integer + uid: + type: integer format: int64 description: Effective UID that executed the command example: 1001 + additionalProperties: false running: type: boolean description: Whether the command is still running From 5e895be6e4f160b6e0a038da333b3a6c26fd5d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Tue, 10 Feb 2026 12:18:01 +0800 Subject: [PATCH 5/9] chore: update execd-api.yaml --- specs/execd-api.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml index 1437e47a..fcff1474 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -289,6 +289,8 @@ paths: Executes a shell command and streams the output in real-time using SSE (Server-Sent Events). 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. operationId: runCommand tags: - Command @@ -305,12 +307,14 @@ paths: command: ls -la /workspace cwd: /workspace background: false + timeout: 30000 background: summary: Background command value: command: python server.py cwd: /app background: true + timeout: 120000 responses: "200": description: Stream of command execution events @@ -929,6 +933,11 @@ components: description: Whether to run command in detached mode default: false example: false + timeout: + type: integer + format: int64 + description: Maximum allowed execution time in milliseconds before the command is forcefully terminated. If omitted, the server default is used. + example: 60000 user: type: object properties: From 1b32e8391dad1a63148a154572e39f6682fb6fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sat, 28 Feb 2026 16:33:01 +0800 Subject: [PATCH 6/9] fix(execd): fix unreachable uid/gid setting --- components/execd/pkg/runtime/command.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/components/execd/pkg/runtime/command.go b/components/execd/pkg/runtime/command.go index 80c47bea..b5c23420 100644 --- a/components/execd/pkg/runtime/command.go +++ b/components/execd/pkg/runtime/command.go @@ -52,6 +52,13 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest startAt := time.Now() log.Info("received command: %v", request.Code) + if request.User != nil { + if request.User.UID != nil { + log.Info("run_command request.User.UID=%d", *request.User.UID) + } else if request.User.Username != nil { + log.Info("run_command request.User.Username=%s", *request.User.Username) + } + } cred, resolvedUser, err := resolveUserCredential(request.User) if err != nil { request.Hooks.OnExecuteInit(session) @@ -86,6 +93,9 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest sysProcAttr := &syscall.SysProcAttr{Setpgid: true} if cred != nil { sysProcAttr.Credential = cred + log.Info("run_command setting Credential Uid=%d Gid=%d", cred.Uid, cred.Gid) + } else { + log.Info("run_command cred is nil, not switching user") } cmd.SysProcAttr = sysProcAttr From f483a988cf2242f16fbfd692e9d67d760e7c6bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 1 Mar 2026 12:08:46 +0800 Subject: [PATCH 7/9] fix(execd): set supplementary groups in syscall.Credential when switching user --- components/execd/pkg/runtime/user_unix.go | 24 ++++++++++++-- .../execd/pkg/runtime/user_unix_test.go | 32 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/components/execd/pkg/runtime/user_unix.go b/components/execd/pkg/runtime/user_unix.go index e26f0d0c..f62a1f43 100644 --- a/components/execd/pkg/runtime/user_unix.go +++ b/components/execd/pkg/runtime/user_unix.go @@ -61,9 +61,29 @@ func resolveUserCredential(u *CommandUser) (*syscall.Credential, *CommandUser, e return nil, nil, fmt.Errorf("parse gid %s: %w", usr.Gid, err) } + // Supplementary groups: required so the process has all permissions the user is entitled to + // (e.g. docker group for socket access, device access). + groupIds, err := usr.GroupIds() + if err != nil { + return nil, nil, fmt.Errorf("lookup supplementary groups for user %s: %w", usr.Username, err) + } + groups := make([]uint32, 0, len(groupIds)) + for _, gidStr := range groupIds { + g, parseErr := strconv.ParseUint(gidStr, 10, 32) + if parseErr != nil { + continue + } + // Skip primary group; it is already set in Credential.Gid. + if g == gid { + continue + } + groups = append(groups, uint32(g)) + } + cred := &syscall.Credential{ - Uid: uint32(uid), - Gid: uint32(gid), + Uid: uint32(uid), + Gid: uint32(gid), + Groups: groups, } resolvedUID := int64(uid) diff --git a/components/execd/pkg/runtime/user_unix_test.go b/components/execd/pkg/runtime/user_unix_test.go index 9b81d5b6..1633aa13 100644 --- a/components/execd/pkg/runtime/user_unix_test.go +++ b/components/execd/pkg/runtime/user_unix_test.go @@ -43,10 +43,13 @@ func TestResolveUserCredentialWithUsername(t *testing.T) { assert.Equal(t, cur.Username, *resolved.Username) } uid, _ := strconv.ParseUint(cur.Uid, 10, 32) + gid, _ := strconv.ParseUint(cur.Gid, 10, 32) if assert.NotNil(t, resolved.UID) { assert.Equal(t, int64(uid), *resolved.UID) } assert.Equal(t, uint32(uid), cred.Uid) + assert.Equal(t, uint32(gid), cred.Gid) + expectSupplementaryGroups(t, cur, cred.Groups) } func TestResolveUserCredentialWithUID(t *testing.T) { @@ -70,5 +73,34 @@ func TestResolveUserCredentialWithUID(t *testing.T) { assert.NotNil(t, resolved.Username) if assert.NotNil(t, cred) { assert.Equal(t, uint32(uidVal), cred.Uid) + gid, _ := strconv.ParseUint(cur.Gid, 10, 32) + assert.Equal(t, uint32(gid), cred.Gid) + expectSupplementaryGroups(t, cur, cred.Groups) } } + +// expectSupplementaryGroups asserts that credGroups matches the current user's +// supplementary groups (all groups from usr.GroupIds() except the primary Gid). +func expectSupplementaryGroups(t *testing.T, usr *user.User, credGroups []uint32) { + t.Helper() + primaryGid, err := strconv.ParseUint(usr.Gid, 10, 32) + if err != nil { + t.Skipf("cannot parse primary gid: %v", err) + } + allGids, err := usr.GroupIds() + if err != nil { + t.Skipf("cannot get group ids: %v", err) + } + var expected []uint32 + for _, gidStr := range allGids { + g, parseErr := strconv.ParseUint(gidStr, 10, 32) + if parseErr != nil { + continue + } + if g == primaryGid { + continue + } + expected = append(expected, uint32(g)) + } + assert.ElementsMatch(t, expected, credGroups, "supplementary groups should match user's groups excluding primary") +} From d6a46c0f2e3d6ed2bf23ef9c2fc13d10c4e685b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 1 Mar 2026 12:18:46 +0800 Subject: [PATCH 8/9] fix(execd): avoid ghost sessions on exec failure, extract error handling --- components/execd/pkg/runtime/command.go | 54 +++++++++++++------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/components/execd/pkg/runtime/command.go b/components/execd/pkg/runtime/command.go index b5c23420..6256b385 100644 --- a/components/execd/pkg/runtime/command.go +++ b/components/execd/pkg/runtime/command.go @@ -34,6 +34,24 @@ import ( "github.com/alibaba/opensandbox/execd/pkg/util/safego" ) +// storeFailedCommandKernel records a session with an error-state kernel so the client +// can query GetCommandStatus instead of getting 404. Call when execution fails before +// or at Start (e.g. resolveUserCredential or cmd.Start error). +func (c *Controller) storeFailedCommandKernel(session, stdoutPath, stderrPath, content string, startAt time.Time, isBackground bool, user *CommandUser, err error) { + kernel := &commandKernel{ + pid: -1, + stdoutPath: stdoutPath, + stderrPath: stderrPath, + startedAt: startAt, + running: false, + content: content, + isBackground: isBackground, + user: user, + } + c.storeCommandKernel(session, kernel) + c.markCommandFinished(session, 255, err.Error()) +} + // runCommand executes shell commands and streams their output. func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest) error { session := c.newContextID() @@ -68,6 +86,7 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest Traceback: []string{err.Error()}, }) log.Error("CommandExecError: error preparing command user: %v", err) + c.storeFailedCommandKernel(session, stdoutPath, stderrPath, request.Code, startAt, false, nil, err) return nil } cmd := exec.CommandContext(ctx, "bash", "-c", request.Code) @@ -104,6 +123,7 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest request.Hooks.OnExecuteInit(session) request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "CommandExecError", EValue: err.Error()}) log.Error("CommandExecError: error starting commands: %v", err) + c.storeFailedCommandKernel(session, stdoutPath, stderrPath, request.Code, startAt, false, resolvedUser, err) return nil } @@ -199,18 +219,7 @@ func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.Ca cred, resolvedUser, err := resolveUserCredential(request.User) if err != nil { log.Error("CommandExecError: error preparing command user: %v", err) - kernel := &commandKernel{ - pid: -1, - stdoutPath: stdoutPath, - stderrPath: stderrPath, - startedAt: startAt, - running: false, - content: request.Code, - isBackground: true, - user: nil, - } - c.storeCommandKernel(session, kernel) - c.markCommandFinished(session, 255, err.Error()) + c.storeFailedCommandKernel(session, stdoutPath, stderrPath, request.Code, startAt, true, nil, err) return nil } @@ -228,8 +237,14 @@ func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.Ca cmd.Stdin = os.NewFile(uintptr(syscall.Stdin), os.DevNull) err = cmd.Start() + if err != nil { + cancel() + log.Error("CommandExecError: error starting commands: %v", err) + c.storeFailedCommandKernel(session, stdoutPath, stderrPath, request.Code, startAt, true, resolvedUser, err) + return fmt.Errorf("failed to start commands: %w", err) + } kernel := &commandKernel{ - pid: -1, + pid: cmd.Process.Pid, stdoutPath: stdoutPath, stderrPath: stderrPath, startedAt: startAt, @@ -238,22 +253,11 @@ func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.Ca isBackground: true, user: resolvedUser, } - if err != nil { - cancel() - log.Error("CommandExecError: error starting commands: %v", err) - kernel.running = false - c.storeCommandKernel(session, kernel) - c.markCommandFinished(session, 255, err.Error()) - return fmt.Errorf("failed to start commands: %w", err) - } + c.storeCommandKernel(session, kernel) safego.Go(func() { defer pipe.Close() - kernel.running = true - kernel.pid = cmd.Process.Pid - c.storeCommandKernel(session, kernel) - err = cmd.Wait() cancel() if err != nil { From c6e9a065b69609fb1efb243fb90517406b86a0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sun, 1 Mar 2026 12:21:52 +0800 Subject: [PATCH 9/9] chore(execd): drop redundant request.User logging in runCommand --- components/execd/pkg/runtime/command.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/components/execd/pkg/runtime/command.go b/components/execd/pkg/runtime/command.go index 6256b385..70d584e1 100644 --- a/components/execd/pkg/runtime/command.go +++ b/components/execd/pkg/runtime/command.go @@ -70,13 +70,6 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest startAt := time.Now() log.Info("received command: %v", request.Code) - if request.User != nil { - if request.User.UID != nil { - log.Info("run_command request.User.UID=%d", *request.User.UID) - } else if request.User.Username != nil { - log.Info("run_command request.User.Username=%s", *request.User.Username) - } - } cred, resolvedUser, err := resolveUserCredential(request.User) if err != nil { request.Hooks.OnExecuteInit(session)