iop/apps/node/internal/workspace/command_process_unix.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

196 lines
5 KiB
Go

//go:build darwin || linux
package workspace
import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
const (
commandRootFD = 3
commandRecordFD = 4
commandStatusFD = 5
commandShimExit = 125
)
func startCommandProcess(record commandLaunchRecord, directory *os.File, output *commandOutput) (*commandProcess, error) {
encoded, err := json.Marshal(record)
if err != nil || len(encoded) == 0 || len(encoded) > commandLaunchRecordLimit || directory == nil {
return nil, errCommandLaunchInvalid
}
rootFD, err := unix.Dup(int(directory.Fd()))
if err != nil {
return nil, err
}
root := os.NewFile(uintptr(rootFD), "workspace-root")
recordReader, recordWriter, err := os.Pipe()
if err != nil {
_ = root.Close()
return nil, err
}
statusReader, statusWriter, err := os.Pipe()
if err != nil {
_ = root.Close()
_ = recordReader.Close()
_ = recordWriter.Close()
return nil, err
}
closeAll := func() {
_ = root.Close()
_ = recordReader.Close()
_ = recordWriter.Close()
_ = statusReader.Close()
_ = statusWriter.Close()
}
currentExecutable, err := os.Executable()
if err != nil {
closeAll()
return nil, err
}
if !filepath.IsAbs(currentExecutable) {
closeAll()
return nil, errCommandLaunchInvalid
}
cmd := exec.Command(currentExecutable, commandShimArgument)
cmd.Env = []string{commandShimEnvironment + "=1"}
cmd.ExtraFiles = []*os.File{root, recordReader, statusWriter}
cmd.Stdout = output.writer(false)
cmd.Stderr = output.writer(true)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
closeAll()
return nil, err
}
_ = root.Close()
_ = recordReader.Close()
_ = statusWriter.Close()
go func() {
_, _ = io.Copy(recordWriter, bytes.NewReader(encoded))
_ = recordWriter.Close()
}()
launch := make(chan commandLaunchStatus, 1)
go func() {
data, readErr := io.ReadAll(io.LimitReader(statusReader, 2))
_ = statusReader.Close()
launch <- commandLaunchStatus{started: readErr == nil && len(data) == 0}
}()
wait := make(chan error, 1)
go func() {
wait <- cmd.Wait()
}()
return &commandProcess{
wait: wait, launch: launch, pid: cmd.Process.Pid,
exitCode: func() int32 {
if cmd.ProcessState == nil {
return -1
}
return int32(cmd.ProcessState.ExitCode())
},
}, nil
}
func runCommandShim() int {
status := os.NewFile(commandStatusFD, "workspace-command-status")
fail := func() int {
if status != nil {
_, _ = status.Write([]byte{'F'})
_ = status.Close()
}
return commandShimExit
}
if status == nil {
return commandShimExit
}
unix.CloseOnExec(commandStatusFD)
recordFile := os.NewFile(commandRecordFD, "workspace-command-record")
root := os.NewFile(commandRootFD, "workspace-root")
if recordFile == nil || root == nil {
return fail()
}
defer recordFile.Close()
defer root.Close()
encoded, err := io.ReadAll(io.LimitReader(recordFile, commandLaunchRecordLimit+1))
if err != nil || len(encoded) == 0 || len(encoded) > commandLaunchRecordLimit {
return fail()
}
decoder := json.NewDecoder(bytes.NewReader(encoded))
decoder.DisallowUnknownFields()
var record commandLaunchRecord
if err := decoder.Decode(&record); err != nil {
return fail()
}
if err := ensureJSONEOF(decoder); err != nil || !validLaunchRecord(record) {
return fail()
}
var stat unix.Stat_t
if err := unix.Fstat(commandRootFD, &stat); err != nil || stat.Mode&unix.S_IFMT != unix.S_IFDIR || uint64(stat.Dev) != record.Device || uint64(stat.Ino) != record.Inode {
return fail()
}
if err := unix.Fchdir(commandRootFD); err != nil {
return fail()
}
_ = recordFile.Close()
_ = root.Close()
argv := make([]string, 1, len(record.Args)+1)
argv[0] = record.Executable
argv = append(argv, record.Args...)
if err := unix.Exec(record.Executable, argv, record.Environment); err != nil {
return fail()
}
return commandShimExit
}
func ensureJSONEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return errors.New("workspace command record has trailing data")
}
return err
}
return nil
}
func validLaunchRecord(record commandLaunchRecord) bool {
if record.Version != commandLaunchVersion || !filepath.IsAbs(record.Executable) || filepath.Clean(record.Executable) != record.Executable || strings.IndexByte(record.Executable, 0) >= 0 {
return false
}
for _, arg := range record.Args {
if strings.IndexByte(arg, 0) >= 0 {
return false
}
}
seen := make(map[string]struct{}, len(record.Environment))
for _, item := range record.Environment {
name, value, ok := strings.Cut(item, "=")
if !ok || !validEnvironmentName(name) || name == commandShimEnvironment || strings.IndexByte(value, 0) >= 0 {
return false
}
if _, duplicate := seen[name]; duplicate {
return false
}
seen[name] = struct{}{}
}
return true
}
func terminateProcessGroup(pid int) {
if pid <= 0 {
return
}
_ = syscall.Kill(-pid, syscall.SIGTERM)
_ = syscall.Kill(-pid, syscall.SIGKILL)
}