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 11f2cf09..70d584e1 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() @@ -52,6 +70,18 @@ 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) + c.storeFailedCommandKernel(session, stdoutPath, stderrPath, request.Code, startAt, false, nil, err) + return nil + } cmd := exec.CommandContext(ctx, "bash", "-c", request.Code) cmd.Stdout = stdout @@ -72,13 +102,21 @@ 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 + 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 err = cmd.Start() if err != nil { 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 } @@ -90,6 +128,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) @@ -170,8 +209,19 @@ func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.Ca log.Info("received command: %v", request.Code) cmd := exec.CommandContext(ctx, "bash", "-c", request.Code) + cred, resolvedUser, err := resolveUserCredential(request.User) + if err != nil { + log.Error("CommandExecError: error preparing command user: %v", err) + c.storeFailedCommandKernel(session, stdoutPath, stderrPath, request.Code, startAt, true, nil, err) + 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()) @@ -180,31 +230,27 @@ 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, running: true, content: request.Code, isBackground: true, - } - 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) + user: resolvedUser, } + 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 { diff --git a/components/execd/pkg/runtime/command_status.go b/components/execd/pkg/runtime/command_status.go index 97f112b1..ae850f29 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. @@ -67,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 36c325b4..e7905e16 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/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/runtime/user_unix.go b/components/execd/pkg/runtime/user_unix.go new file mode 100644 index 00000000..f62a1f43 --- /dev/null +++ b/components/execd/pkg/runtime/user_unix.go @@ -0,0 +1,99 @@ +//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) + } + + // 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), + Groups: groups, + } + + 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..1633aa13 --- /dev/null +++ b/components/execd/pkg/runtime/user_unix_test.go @@ -0,0 +1,106 @@ +//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) + 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) { + 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) + 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") +} diff --git a/components/execd/pkg/web/controller/command.go b/components/execd/pkg/web/controller/command.go index 9d61308b..72f343a2 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, @@ -132,6 +133,7 @@ func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.Ru Language: runtime.BackgroundCommand, Code: request.Command, Cwd: request.Cwd, + User: request.User.ToRuntime(), Timeout: timeout, } } else { @@ -139,6 +141,7 @@ func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.Ru Language: runtime.Command, Code: request.Command, Cwd: request.Cwd, + User: request.User.ToRuntime(), Timeout: timeout, } } diff --git a/components/execd/pkg/web/model/codeinterpreting.go b/components/execd/pkg/web/model/codeinterpreting.go index adc6452c..ddd5dcb8 100644 --- a/components/execd/pkg/web/model/codeinterpreting.go +++ b/components/execd/pkg/web/model/codeinterpreting.go @@ -46,20 +46,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"` - // TimeoutMs caps execution duration; 0 uses server default. - TimeoutMs int64 `json:"timeout,omitempty" validate:"omitempty,gte=1"` -} - -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 64eee35d..cbeefd14 100644 --- a/components/execd/pkg/web/model/codeinterpreting_test.go +++ b/components/execd/pkg/web/model/codeinterpreting_test.go @@ -36,30 +36,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.TimeoutMs = -100 - if err := req.Validate(); err == nil { - t.Fatalf("expected validation error when timeout is negative") - } - - req.TimeoutMs = 0 - req.Command = "ls" - if err := req.Validate(); err != nil { - t.Fatalf("expected success when timeout is omitted/zero: %v", err) - } - - req.TimeoutMs = 10 - 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..ea0a35e3 100644 --- a/components/execd/pkg/web/model/command.go +++ b/components/execd/pkg/web/model/command.go @@ -14,15 +14,49 @@ 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 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"` + + // TimeoutMs caps execution duration; 0 uses server default. + TimeoutMs int64 `json:"timeout,omitempty" validate:"omitempty,gte=1"` +} + +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..a7d205f8 --- /dev/null +++ b/components/execd/pkg/web/model/command_test.go @@ -0,0 +1,78 @@ +// 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 ( + "os/user" + "strconv" + "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) + } + + req.TimeoutMs = -100 + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error when timeout is negative") + } + + req.TimeoutMs = 0 + req.Command = "ls" + if err := req.Validate(); err != nil { + t.Fatalf("expected success when timeout is omitted/zero: %v", err) + } + + req.TimeoutMs = 10 + req.Command = "" + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error when command is empty") + } +} + +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 new file mode 100644 index 00000000..9f69c20a --- /dev/null +++ b/components/execd/pkg/web/model/user_identity.go @@ -0,0 +1,111 @@ +// 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 ( + "errors" + "fmt" + "os/user" + "strconv" + "strings" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// UserIdentity represents a POSIX username or numeric UID. +// Prefer specifying exactly one of Username/UID. +type UserIdentity struct { + Username *string `json:"name,omitempty"` + UID *int64 `json:"uid,omitempty"` +} + +func newUserIdentityFromUsername(username string) *UserIdentity { + return &UserIdentity{Username: &username} +} + +func newUserIdentityFromUID(uid int64) *UserIdentity { + return &UserIdentity{UID: &uid} +} + +// 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") +} + +// ToRuntime converts the identity to runtime.CommandUser. +func (u *UserIdentity) ToRuntime() *runtime.CommandUser { + if u == nil { + return nil + } + if u.Username != nil { + return &runtime.CommandUser{Username: u.Username} + } + if u.UID != nil { + return &runtime.CommandUser{UID: u.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 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 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 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 new file mode 100644 index 00000000..082d57de --- /dev/null +++ b/components/execd/pkg/web/model/user_identity_test.go @@ -0,0 +1,99 @@ +// 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" + + "github.com/stretchr/testify/assert" +) + +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), + } + 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) + assert.NoError(t, req.Validate(), "expected validation success with existing uid") + } + + req.User = newUserIdentityFromUID(-1) + assert.Error(t, req.Validate(), "expected validation error for negative uid") + + req.User = newUserIdentityFromUsername("") + assert.Error(t, req.Validate(), "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), + } + 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) + assert.NoError(t, req.Validate(), "expected validation success with existing uid") + } + + req.User = newUserIdentityFromUsername("user-does-not-exist-123456789") + assert.Error(t, req.Validate(), "expected validation error for missing username") +} + +func TestUserIdentity_jsonRoundTrip(t *testing.T) { + var user UserIdentity + 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.JSONEq(t, `{"name":"sandbox"}`, string(b)) + + var uidUser UserIdentity + 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.JSONEq(t, `{"uid":1001}`, string(b)) +} + +func TestUserIdentity_unmarshalInvalid(t *testing.T) { + var user UserIdentity + 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 77f690a2..260cd346 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -938,6 +938,22 @@ 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 + user: + type: object + properties: + name: + type: string + description: POSIX username to drop privileges to + example: sandbox + uid: + type: integer + format: int64 + description: Numeric UID to drop privileges to + example: 1001 + 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 @@ -951,6 +967,19 @@ components: type: string description: Original command content example: ls -la + user: + type: object + properties: + name: + type: string + description: Effective username that executed the command + example: sandbox + 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