e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
480 lines
11 KiB
Go
480 lines
11 KiB
Go
// Copyright 2026 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//go:build !windows
|
|
// +build !windows
|
|
|
|
package runtime
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute"
|
|
"github.com/alibaba/opensandbox/execd/pkg/log"
|
|
"github.com/alibaba/opensandbox/execd/pkg/util/pathutil"
|
|
)
|
|
|
|
const (
|
|
envDumpStartMarker = "__ENV_DUMP_START__"
|
|
envDumpEndMarker = "__ENV_DUMP_END__"
|
|
exitMarkerPrefix = "__EXIT_CODE__:"
|
|
pwdMarkerPrefix = "__PWD__:"
|
|
)
|
|
|
|
func (c *Controller) createBashSession(req *CreateContextRequest) (string, error) {
|
|
resolvedCwd, err := pathutil.ExpandPath(req.Cwd)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve request cwd %s: %w", req.Cwd, err)
|
|
}
|
|
if resolvedCwd != "" {
|
|
err := os.MkdirAll(resolvedCwd, os.ModePerm)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
session := newBashSession(resolvedCwd)
|
|
if err := session.start(); err != nil {
|
|
return "", fmt.Errorf("failed to start bash session: %w", err)
|
|
}
|
|
|
|
c.bashSessionClientMap.Store(session.config.Session, session)
|
|
log.Info("created bash session %s", session.config.Session)
|
|
return session.config.Session, nil
|
|
}
|
|
|
|
func (c *Controller) runBashSession(ctx context.Context, request *ExecuteCodeRequest) error {
|
|
session := c.getBashSession(request.Context)
|
|
if session == nil {
|
|
return ErrContextNotFound
|
|
}
|
|
|
|
return session.run(ctx, request)
|
|
}
|
|
|
|
func (c *Controller) getBashSession(sessionId string) *bashSession {
|
|
if v, ok := c.bashSessionClientMap.Load(sessionId); ok {
|
|
if s, ok := v.(*bashSession); ok {
|
|
return s
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Controller) closeBashSession(sessionId string) error {
|
|
session := c.getBashSession(sessionId)
|
|
if session == nil {
|
|
return ErrContextNotFound
|
|
}
|
|
|
|
err := session.close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.bashSessionClientMap.Delete(sessionId)
|
|
return nil
|
|
}
|
|
|
|
func (c *Controller) CreateBashSession(req *CreateContextRequest) (string, error) {
|
|
return c.createBashSession(req)
|
|
}
|
|
|
|
func (c *Controller) RunInBashSession(ctx context.Context, req *ExecuteCodeRequest) error {
|
|
return c.runBashSession(ctx, req)
|
|
}
|
|
|
|
func (c *Controller) DeleteBashSession(sessionID string) error {
|
|
return c.closeBashSession(sessionID)
|
|
}
|
|
|
|
func newBashSession(cwd string) *bashSession {
|
|
config := &bashSessionConfig{
|
|
Session: uuidString(),
|
|
StartupTimeout: 5 * time.Second,
|
|
}
|
|
|
|
env := make(map[string]string)
|
|
for _, kv := range os.Environ() {
|
|
if k, v, ok := splitEnvPair(kv); ok {
|
|
env[k] = v
|
|
}
|
|
}
|
|
|
|
return &bashSession{
|
|
config: config,
|
|
env: env,
|
|
cwd: cwd,
|
|
}
|
|
}
|
|
|
|
func (s *bashSession) start() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.started {
|
|
return errors.New("session already started")
|
|
}
|
|
|
|
s.started = true
|
|
return nil
|
|
}
|
|
|
|
func (s *bashSession) trackCurrentProcess(pid int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.currentProcessPid = pid
|
|
}
|
|
|
|
func (s *bashSession) untrackCurrentProcess() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.currentProcessPid = 0
|
|
}
|
|
|
|
//nolint:gocognit
|
|
func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) error {
|
|
s.mu.Lock()
|
|
if !s.started {
|
|
s.mu.Unlock()
|
|
return errors.New("session not started")
|
|
}
|
|
|
|
envSnapshot := copyEnvMap(s.env)
|
|
|
|
cwd := s.cwd
|
|
// override original cwd if specified
|
|
if request.Cwd != "" {
|
|
expandedCwd, err := pathutil.ExpandPath(request.Cwd)
|
|
if err != nil {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("resolve cwd: %w", err)
|
|
}
|
|
cwd = expandedCwd
|
|
}
|
|
sessionID := s.config.Session
|
|
s.mu.Unlock()
|
|
|
|
startAt := time.Now()
|
|
if request.Hooks.OnExecuteInit != nil {
|
|
request.Hooks.OnExecuteInit(sessionID)
|
|
}
|
|
|
|
wait := request.Timeout
|
|
if wait <= 0 {
|
|
wait = 24 * 3600 * time.Second // max to 24 hours
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, wait)
|
|
defer cancel()
|
|
|
|
script := buildWrappedScript(request.Code, envSnapshot, cwd)
|
|
scriptFile, err := os.CreateTemp("", "execd_bash_*.sh")
|
|
if err != nil {
|
|
return fmt.Errorf("create script file: %w", err)
|
|
}
|
|
scriptPath := scriptFile.Name()
|
|
if _, err := scriptFile.WriteString(script); err != nil {
|
|
_ = scriptFile.Close()
|
|
return fmt.Errorf("write script file: %w", err)
|
|
}
|
|
if err := scriptFile.Close(); err != nil {
|
|
return fmt.Errorf("close script file: %w", err)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", scriptPath)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
// Do not pass envSnapshot via cmd.Env to avoid "argument list too long" when session env is large.
|
|
// Child inherits parent env (nil => default in Go). The script file already has "export K=V" for
|
|
// all session vars at the top, so the session environment is applied when the script runs.
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
cmd.Stderr = cmd.Stdout
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
log.Error("start bash session failed: %v (command: %q)", err, log.SanitizeCommand(request.Code))
|
|
return fmt.Errorf("start bash: %w", err)
|
|
}
|
|
defer s.untrackCurrentProcess()
|
|
s.trackCurrentProcess(cmd.Process.Pid)
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
|
|
var (
|
|
envLines []string
|
|
pwdLine string
|
|
exitCode *int
|
|
inEnv bool
|
|
)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
switch {
|
|
case line == envDumpStartMarker:
|
|
inEnv = true
|
|
case line == envDumpEndMarker:
|
|
inEnv = false
|
|
case strings.HasPrefix(line, exitMarkerPrefix):
|
|
if code, err := strconv.Atoi(strings.TrimPrefix(line, exitMarkerPrefix)); err == nil {
|
|
exitCode = &code //nolint:ineffassign
|
|
}
|
|
case strings.HasPrefix(line, pwdMarkerPrefix):
|
|
pwdLine = strings.TrimPrefix(line, pwdMarkerPrefix)
|
|
default:
|
|
if inEnv {
|
|
envLines = append(envLines, line)
|
|
continue
|
|
}
|
|
if request.Hooks.OnExecuteStdout != nil {
|
|
request.Hooks.OnExecuteStdout(line)
|
|
}
|
|
}
|
|
}
|
|
|
|
scanErr := scanner.Err()
|
|
waitErr := cmd.Wait()
|
|
|
|
if scanErr != nil {
|
|
log.Error("read stdout failed: %v (command: %q)", scanErr, log.SanitizeCommand(request.Code))
|
|
return fmt.Errorf("read stdout: %w", scanErr)
|
|
}
|
|
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
log.Error("timeout after %s while running command: %q", wait, log.SanitizeCommand(request.Code))
|
|
return fmt.Errorf("timeout after %s", wait)
|
|
}
|
|
|
|
if exitCode == nil && cmd.ProcessState != nil {
|
|
code := cmd.ProcessState.ExitCode() //nolint:staticcheck
|
|
exitCode = &code //nolint:ineffassign
|
|
}
|
|
|
|
updatedEnv := parseExportDump(envLines)
|
|
s.mu.Lock()
|
|
if len(updatedEnv) > 0 {
|
|
s.env = updatedEnv
|
|
}
|
|
if pwdLine != "" {
|
|
s.cwd = pwdLine
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
var exitErr *exec.ExitError
|
|
if waitErr != nil && !errors.As(waitErr, &exitErr) {
|
|
log.Error("command wait failed: %v (command: %q)", waitErr, log.SanitizeCommand(request.Code))
|
|
return waitErr
|
|
}
|
|
|
|
userExitCode := 0
|
|
if exitCode != nil {
|
|
userExitCode = *exitCode
|
|
}
|
|
|
|
if userExitCode != 0 {
|
|
errMsg := fmt.Sprintf("command exited with code %d", userExitCode)
|
|
if waitErr != nil {
|
|
errMsg = waitErr.Error()
|
|
}
|
|
if request.Hooks.OnExecuteError != nil {
|
|
request.Hooks.OnExecuteError(&execute.ErrorOutput{
|
|
EName: "CommandExecError",
|
|
EValue: strconv.Itoa(userExitCode),
|
|
Traceback: []string{errMsg},
|
|
})
|
|
}
|
|
log.Error("CommandExecError: %s (command: %q)", errMsg, log.SanitizeCommand(request.Code))
|
|
return nil
|
|
}
|
|
|
|
if request.Hooks.OnExecuteComplete != nil {
|
|
request.Hooks.OnExecuteComplete(time.Since(startAt))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func buildWrappedScript(command string, env map[string]string, cwd string) string {
|
|
var b strings.Builder
|
|
|
|
keys := make([]string, 0, len(env))
|
|
for k := range env {
|
|
v := env[k]
|
|
if isValidEnvKey(k) && !envKeysNotPersisted[k] && len(v) <= maxPersistedEnvValueSize {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
b.WriteString("export ")
|
|
b.WriteString(k)
|
|
b.WriteString("=")
|
|
b.WriteString(shellEscape(env[k]))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
if cwd != "" {
|
|
b.WriteString("cd ")
|
|
b.WriteString(shellEscape(cwd))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString(command)
|
|
if !strings.HasSuffix(command, "\n") {
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("__USER_EXIT_CODE__=$?\n")
|
|
b.WriteString("printf \"\\n%s\\n\" \"" + envDumpStartMarker + "\"\n")
|
|
b.WriteString("export -p\n")
|
|
b.WriteString("printf \"%s\\n\" \"" + envDumpEndMarker + "\"\n")
|
|
b.WriteString("printf \"" + pwdMarkerPrefix + "%s\\n\" \"$(pwd)\"\n")
|
|
b.WriteString("printf \"" + exitMarkerPrefix + "%s\\n\" \"$__USER_EXIT_CODE__\"\n")
|
|
b.WriteString("exit \"$__USER_EXIT_CODE__\"\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// envKeysNotPersisted are not carried across runs (prompt/display vars).
|
|
var envKeysNotPersisted = map[string]bool{
|
|
"PS1": true, "PS2": true, "PS3": true, "PS4": true,
|
|
"PROMPT_COMMAND": true,
|
|
}
|
|
|
|
// maxPersistedEnvValueSize caps single env value length as a safeguard.
|
|
const maxPersistedEnvValueSize = 8 * 1024
|
|
|
|
func parseExportDump(lines []string) map[string]string {
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
env := make(map[string]string, len(lines))
|
|
for _, line := range lines {
|
|
k, v, ok := parseExportLine(line)
|
|
if !ok || envKeysNotPersisted[k] || len(v) > maxPersistedEnvValueSize {
|
|
continue
|
|
}
|
|
env[k] = v
|
|
}
|
|
return env
|
|
}
|
|
|
|
func parseExportLine(line string) (string, string, bool) {
|
|
const prefix = "declare -x "
|
|
if !strings.HasPrefix(line, prefix) {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
|
if rest == "" {
|
|
return "", "", false
|
|
}
|
|
name, value := rest, ""
|
|
if eq := strings.Index(rest, "="); eq >= 0 {
|
|
name = rest[:eq]
|
|
raw := rest[eq+1:]
|
|
if unquoted, err := strconv.Unquote(raw); err == nil {
|
|
value = unquoted
|
|
} else {
|
|
value = strings.Trim(raw, `"`)
|
|
}
|
|
}
|
|
if !isValidEnvKey(name) {
|
|
return "", "", false
|
|
}
|
|
return name, value, true
|
|
}
|
|
|
|
func shellEscape(value string) string {
|
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
|
}
|
|
|
|
func isValidEnvKey(key string) bool {
|
|
if key == "" {
|
|
return false
|
|
}
|
|
|
|
for i, r := range key {
|
|
if i == 0 {
|
|
if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && r != '_' {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && (r < '0' || r > '9') && r != '_' {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func copyEnvMap(src map[string]string) map[string]string {
|
|
if src == nil {
|
|
return map[string]string{}
|
|
}
|
|
|
|
dst := make(map[string]string, len(src))
|
|
for k, v := range src {
|
|
dst[k] = v
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func splitEnvPair(kv string) (string, string, bool) {
|
|
parts := strings.SplitN(kv, "=", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", false
|
|
}
|
|
if !isValidEnvKey(parts[0]) {
|
|
return "", "", false
|
|
}
|
|
return parts[0], parts[1], true
|
|
}
|
|
|
|
func (s *bashSession) close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
pid := s.currentProcessPid
|
|
s.currentProcessPid = 0
|
|
s.started = false
|
|
s.env = nil
|
|
s.cwd = ""
|
|
|
|
if pid != 0 {
|
|
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
|
log.Warn("kill session process group %d: %v (process may have already exited)", pid, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uuidString() string {
|
|
return uuid.New().String()
|
|
}
|