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

322 lines
9.4 KiB
Go

package workspace
import (
"container/heap"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path"
"sort"
"time"
iop "iop/proto/gen/iop"
)
const maxListEntries = 1024
const listBatchSize = 128
type listMaxHeap []string
func (h listMaxHeap) Len() int { return len(h) }
func (h listMaxHeap) Less(i, j int) bool { return h[i] > h[j] }
func (h listMaxHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *listMaxHeap) Push(value any) { *h = append(*h, value.(string)) }
func (h *listMaxHeap) Pop() any {
old := *h
last := old[len(old)-1]
*h = old[:len(old)-1]
return last
}
// Result is intentionally content-free on failure. The Node handler maps it to
// the typed wire response without returning filesystem paths or OS errors.
type Result struct {
Status iop.WorkspaceStatus
Code iop.WorkspaceErrorCode
Content []byte
Entries []string
Stdout []byte
Stderr []byte
ExitCode int32
Truncated bool
DurationMS int64
}
func (r *Runtime) Read(requestID, relativePath string) (result Result) {
startedAt := time.Now()
var correlation string
defer func() {
result.DurationMS = time.Since(startedAt).Milliseconds()
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_READ, result)
}()
return r.withRequest(requestID, func(req *Request) Result {
correlation = req.correlation
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_READ); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
return result
}
name, err := userPath(relativePath)
if err != nil {
return failureFor(err)
}
info, err := checkedExisting(req.entry, name, false, false)
if err != nil || !info.Mode().IsRegular() {
if err == nil {
err = errUnsafePath
}
return failureFor(err)
}
file, err := req.entry.root.Open(name)
if err != nil {
return failureFor(err)
}
defer file.Close()
opened, err := file.Stat()
if err != nil || !opened.Mode().IsRegular() {
return failureFor(errUnsafePath)
}
if device, _, ok := fileIdentity(opened); !ok || device != req.entry.device {
return failureFor(errUnsafePath)
}
data, err := io.ReadAll(io.LimitReader(file, req.maxRead+1))
if err != nil {
return failureFor(err)
}
if int64(len(data)) > req.maxRead {
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Content: data[:req.maxRead], Truncated: true}
}
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Content: data}
})
}
func (r *Runtime) List(requestID, relativePath string) (result Result) {
startedAt := time.Now()
var correlation string
defer func() {
result.DurationMS = time.Since(startedAt).Milliseconds()
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST, result)
}()
return r.withRequest(requestID, func(req *Request) Result {
correlation = req.correlation
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
return result
}
name, err := userPath(relativePath)
if err != nil {
return failureFor(err)
}
if _, err := checkedExisting(req.entry, name, true, false); err != nil {
return failureFor(err)
}
file, err := req.entry.root.Open(name)
if err != nil {
return failureFor(err)
}
defer file.Close()
opened, err := file.Stat()
if err != nil || !opened.IsDir() {
return failureFor(errUnsafePath)
}
if device, _, ok := fileIdentity(opened); !ok || device != req.entry.device {
return failureFor(errUnsafePath)
}
retained := make(listMaxHeap, 0, maxListEntries)
heap.Init(&retained)
truncated := false
for {
batch, readErr := file.ReadDir(listBatchSize)
for _, item := range batch {
candidate := item.Name()
if name == "." && candidate == ".iop" {
continue
}
if retained.Len() < maxListEntries {
heap.Push(&retained, candidate)
continue
}
truncated = true
if candidate < retained[0] {
retained[0] = candidate
heap.Fix(&retained, 0)
}
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return failureFor(readErr)
}
}
entries := []string(retained)
sort.Strings(entries)
result := Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Truncated: truncated}
var bytes int64
for _, item := range entries {
child := item
if name != "." {
child = path.Join(name, child)
}
info, err := checkedExisting(req.entry, child, false, false)
if err != nil {
return failureFor(err)
}
if !info.IsDir() && !info.Mode().IsRegular() {
return failureFor(errUnsafePath)
}
encoded := item + "\t" + entryType(info)
if bytes+int64(len(encoded)) > req.maxOutput {
result.Truncated = true
break
}
bytes += int64(len(encoded))
result.Entries = append(result.Entries, encoded)
}
return result
})
}
func (r *Runtime) Write(requestID, relativePath string, content []byte) (result Result) {
startedAt := time.Now()
var correlation string
defer func() {
result.DurationMS = time.Since(startedAt).Milliseconds()
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE, result)
}()
return r.withRequest(requestID, func(req *Request) Result {
correlation = req.correlation
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
return result
}
if int64(len(content)) > req.maxWrite {
return failureFor(errInvalidPath)
}
name, err := userPath(relativePath)
if err != nil || name == "." {
if err == nil {
err = errInvalidPath
}
return failureFor(err)
}
parent, base, err := openOrCreateParentNoFollow(req.entry, name)
if err != nil {
return failureFor(err)
}
defer parent.close()
initialTarget, err := parent.targetIdentity(base)
if err != nil || initialTarget.exists && (!initialTarget.mode.IsRegular() || initialTarget.device != req.entry.device) {
return failureFor(errUnsafePath)
}
tmpBase, err := randomTempBase()
if err != nil {
return failureFor(err)
}
file, err := parent.createTemp(tmpBase)
if err != nil {
return failureFor(err)
}
ok := false
defer func() {
if !ok {
_ = parent.remove(tmpBase)
}
}()
if _, err := file.Write(content); err != nil {
_ = file.Close()
return failureFor(err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return failureFor(err)
}
if err := file.Close(); err != nil {
return failureFor(err)
}
if req.entry.beforeRename != nil {
if err := req.entry.beforeRename(); err != nil {
return failureFor(err)
}
}
if err := parent.revalidate(); err != nil {
return failureFor(err)
}
currentTarget, err := parent.targetIdentity(base)
if err != nil || currentTarget != initialTarget {
return failureFor(errUnsafePath)
}
if err := parent.rename(tmpBase, base); err != nil {
return failureFor(err)
}
ok = true
// The atomic replacement is already committed. Directory sync is best
// effort because reporting a post-effect failure would violate the
// executor's failure-preserves-target contract.
_ = parent.sync()
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}
})
}
func (r *Runtime) Delete(requestID, relativePath string) (result Result) {
startedAt := time.Now()
var correlation string
defer func() {
result.DurationMS = time.Since(startedAt).Milliseconds()
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE, result)
}()
return r.withRequest(requestID, func(req *Request) Result {
correlation = req.correlation
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
return result
}
name, err := userPath(relativePath)
if err != nil || name == "." {
if err == nil {
err = errInvalidPath
}
return failureFor(err)
}
info, err := checkedExisting(req.entry, name, false, true)
if err != nil {
return failureFor(err)
}
if info.Mode()&os.ModeSymlink == 0 && !info.Mode().IsRegular() && !info.IsDir() {
return failureFor(errUnsafePath)
}
if err := req.entry.root.Remove(name); err != nil {
return failureFor(err)
}
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}
})
}
func entryType(info os.FileInfo) string {
switch {
case info.IsDir():
return "dir"
case info.Mode().IsRegular():
return "file"
default:
return "other"
}
}
func randomTempBase() (string, error) {
var token [12]byte
if _, err := rand.Read(token[:]); err != nil {
return "", err
}
return fmt.Sprintf(".iop-write-%x", token), nil
}
func failureFor(err error) Result {
if errors.Is(err, errNotFound) || errors.Is(err, os.ErrNotExist) {
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_FOUND}
}
if errors.Is(err, ErrClosed) {
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_UNSUPPORTED, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_READY}
}
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrRequestConflict) || errors.Is(err, ErrUnknownWorkspace) || errors.Is(err, errInvalidPath) || errors.Is(err, errReservedPath) {
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST}
}
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL}
}