mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 09:38:32 +08:00
286 lines
7.4 KiB
Go
286 lines
7.4 KiB
Go
// shellctl-runner is the Go replacement for the previously generated bash+python
|
|
// runner script. It is invoked by tmux as:
|
|
//
|
|
// shellctl-runner <job_dir> <job_id> <cwd>
|
|
//
|
|
// The binary operates in two modes:
|
|
//
|
|
// 1. Parent mode (default): waits for start-gate, loads env, forks child,
|
|
// waits for exit, writes exit artifacts.
|
|
// 2. Child mode (--exec flag): applies Landlock restrictions, then exec's
|
|
// the user script.
|
|
//
|
|
// This eliminates the bash/python dependency chain and integrates Landlock
|
|
// directly.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/langgenius/dify/dify-agent-runtime/internal/cmdutil"
|
|
"github.com/langgenius/dify/dify-agent-runtime/internal/envvar"
|
|
"github.com/langgenius/dify/dify-agent-runtime/internal/landlock"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) > 1 && os.Args[1] == "--exec" {
|
|
childMode()
|
|
return
|
|
}
|
|
parentMode()
|
|
}
|
|
|
|
// parentMode is the entry point when called by tmux.
|
|
// Args: shellctl-runner <job_dir> <job_id> <cwd>
|
|
func parentMode() {
|
|
if len(os.Args) < 4 {
|
|
cmdutil.HandleError(fmt.Errorf("bad args"), 125, "usage: shellctl-runner <job_dir> <job_id> <cwd>")
|
|
}
|
|
|
|
jobDir := os.Args[1]
|
|
// jobID := os.Args[2] // unused in parent but passed for compat
|
|
cwd := os.Args[3]
|
|
|
|
scriptPath := filepath.Join(jobDir, "script")
|
|
envPath := filepath.Join(jobDir, ".job-env.json")
|
|
startGate := filepath.Join(jobDir, "start-gate")
|
|
exitCodePath := filepath.Join(jobDir, "runner-exit-code")
|
|
endedAtPath := filepath.Join(jobDir, "runner-ended-at")
|
|
|
|
// Wait for start-gate.
|
|
for {
|
|
if _, err := os.Stat(startGate); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
// Load environment overlay from JSON.
|
|
env := os.Environ()
|
|
// Remove internal shellctl vars from inherited env.
|
|
env = filterEnv(env, []string{
|
|
"TMUX",
|
|
"SHELLCTL_STATE_DIR",
|
|
"SHELLCTL_RUNTIME_DIR",
|
|
"SHELLCTL_TMUX_SOCKET",
|
|
"SHELLCTL_RUNNER",
|
|
"SHELLCTL_AUTH_TOKEN",
|
|
})
|
|
|
|
envOverlay := loadEnvJSON(envPath)
|
|
env = mergeEnv(env, envOverlay)
|
|
|
|
// Ensure HOME exists.
|
|
home := envGet(env, "HOME")
|
|
if home != "" {
|
|
cmdutil.HandleError(os.MkdirAll(home, 0755), 125, "mkdir HOME %s", home)
|
|
}
|
|
|
|
// Create a per-workspace temp directory under cwd and inject TMPDIR.
|
|
// This avoids granting RW access to the shared /tmp.
|
|
agentTmp := filepath.Join(cwd, ".tmp")
|
|
cmdutil.HandleError(os.MkdirAll(agentTmp, 0755), 125, "mkdir TMPDIR %s", agentTmp)
|
|
env = setEnvIfEmpty(env, "TMPDIR", agentTmp)
|
|
env = setEnvIfEmpty(env, "TMP", agentTmp)
|
|
env = setEnvIfEmpty(env, "TEMP", agentTmp)
|
|
|
|
// Determine if path isolation is enabled.
|
|
enableIsolation := envvar.PathIsolationEnabled()
|
|
|
|
// Build child command: re-exec self in --exec mode.
|
|
self, _ := os.Executable()
|
|
childArgs := []string{"--exec", scriptPath, cwd}
|
|
if enableIsolation {
|
|
childArgs = append([]string{"--exec", "--landlock"}, childArgs[1:]...)
|
|
}
|
|
|
|
cmd := exec.Command(self, childArgs...)
|
|
cmd.Env = env
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Dir = cwd
|
|
|
|
// Forward signals to child.
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
|
go func() {
|
|
for sig := range sigCh {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Signal(sig)
|
|
}
|
|
}
|
|
}()
|
|
|
|
err := cmd.Run()
|
|
|
|
exitCode := 0
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
exitCode = 125
|
|
}
|
|
}
|
|
|
|
endedAt := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
|
|
writeAtomic(exitCodePath, fmt.Sprintf("%d", exitCode))
|
|
writeAtomic(endedAtPath, endedAt)
|
|
|
|
os.Exit(exitCode)
|
|
}
|
|
|
|
// childMode applies Landlock (if --landlock flag) and exec's the user script.
|
|
// Args: shellctl-runner --exec [--landlock] <script_path> <cwd>
|
|
func childMode() {
|
|
args := os.Args[2:] // skip program name and "--exec"
|
|
applyLandlockFlag := false
|
|
|
|
if len(args) > 0 && args[0] == "--landlock" {
|
|
applyLandlockFlag = true
|
|
args = args[1:]
|
|
}
|
|
|
|
if len(args) < 2 {
|
|
cmdutil.HandleError(fmt.Errorf("bad args"), 125, "--exec: missing script_path and cwd")
|
|
}
|
|
|
|
scriptPath := args[0]
|
|
cwd := args[1]
|
|
|
|
// chdir
|
|
cmdutil.HandleError(os.Chdir(cwd), 111, "chdir %s", cwd)
|
|
|
|
// Determine argv for exec.
|
|
argv := buildExecArgv(scriptPath)
|
|
|
|
// Resolve binary before Landlock (PATH search).
|
|
binary, err := exec.LookPath(argv[0])
|
|
cmdutil.HandleError(err, 127, "%s: not found", argv[0])
|
|
|
|
// Apply Landlock if requested.
|
|
if applyLandlockFlag {
|
|
home := os.Getenv("HOME")
|
|
jobDir := filepath.Dir(scriptPath)
|
|
cfg := landlock.ConfigFromEnv(home, cwd, jobDir)
|
|
if err := landlock.Restrict(cfg); err != nil {
|
|
// the landlock is best-effort, so we just log the error whatever it is
|
|
fmt.Fprintf(os.Stderr, "shellctl-runner: WARNING: %v — running without filesystem isolation\n", err)
|
|
}
|
|
}
|
|
|
|
// exec replaces the current process.
|
|
cmdutil.HandleError(syscall.Exec(binary, argv, os.Environ()), 126, "exec %s", binary)
|
|
}
|
|
|
|
// buildExecArgv determines whether the script has a shebang or needs sh.
|
|
func buildExecArgv(scriptPath string) []string {
|
|
f, err := os.Open(scriptPath)
|
|
if err != nil {
|
|
return []string{"sh", scriptPath}
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
buf := make([]byte, 2)
|
|
n, _ := f.Read(buf)
|
|
if n == 2 && string(buf) == "#!" {
|
|
// Has shebang — make executable and run directly.
|
|
_ = os.Chmod(scriptPath, 0755)
|
|
return []string{scriptPath}
|
|
}
|
|
return []string{"sh", scriptPath}
|
|
}
|
|
|
|
// loadEnvJSON reads a JSON object from path and returns key=value pairs.
|
|
func loadEnvJSON(path string) map[string]string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
cmdutil.HandleError(err, 125, "read env json %s", path)
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
cmdutil.HandleError(err, 125, "parse env json %s", path)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// filterEnv removes entries with the given key prefixes from env.
|
|
func filterEnv(env []string, remove []string) []string {
|
|
result := env[:0]
|
|
for _, e := range env {
|
|
skip := false
|
|
for _, r := range remove {
|
|
if strings.HasPrefix(e, r+"=") {
|
|
skip = true
|
|
break
|
|
}
|
|
}
|
|
if !skip {
|
|
result = append(result, e)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// mergeEnv applies overlay key=value pairs onto the env slice.
|
|
func mergeEnv(env []string, overlay map[string]string) []string {
|
|
for k, v := range overlay {
|
|
found := false
|
|
prefix := k + "="
|
|
for i, e := range env {
|
|
if strings.HasPrefix(e, prefix) {
|
|
env[i] = prefix + v
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
env = append(env, prefix+v)
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
// envGet retrieves a value from a []string env slice.
|
|
func envGet(env []string, key string) string {
|
|
prefix := key + "="
|
|
for _, e := range env {
|
|
if strings.HasPrefix(e, prefix) {
|
|
return e[len(prefix):]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// setEnvIfEmpty sets key=value in the env slice only if the key is not already present.
|
|
func setEnvIfEmpty(env []string, key, value string) []string {
|
|
if envGet(env, key) != "" {
|
|
return env
|
|
}
|
|
return append(env, key+"="+value)
|
|
}
|
|
|
|
// writeAtomic writes value to dest via a temp file + rename.
|
|
func writeAtomic(dest, value string) {
|
|
tmp := fmt.Sprintf("%s.tmp.%d", dest, os.Getpid())
|
|
if err := os.WriteFile(tmp, []byte(value+"\n"), 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "shellctl-runner: write %s: %v\n", tmp, err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmp, dest); err != nil {
|
|
fmt.Fprintf(os.Stderr, "shellctl-runner: rename %s -> %s: %v\n", tmp, dest, err)
|
|
}
|
|
}
|