Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions components/execd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions components/execd/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
- 通过进程组管理正确转发信号
- 实时 stdout/stderr 流式输出
- 支持上下文感知的中断
- 可选按请求切换用户/UID(需容器/namespace 权限,见下文)

### 文件系统

Expand Down Expand Up @@ -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)。
Expand Down
76 changes: 61 additions & 15 deletions components/execd/pkg/runtime/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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 {
Expand Down
16 changes: 9 additions & 7 deletions components/execd/pkg/runtime/command_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading