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

431 lines
14 KiB
Go

package workspace
import (
"bytes"
"context"
"errors"
"io"
"math"
"os"
"slices"
"sort"
"strings"
"sync"
"time"
iop "iop/proto/gen/iop"
)
const (
commandLaunchVersion = 1
commandLaunchRecordLimit = 64 << 10
commandLaunchPayloadLimit = 4 << 10
commandShimArgument = "__iop_workspace_command_shim"
commandShimEnvironment = "IOP_WORKSPACE_COMMAND_SHIM"
)
var (
errCommandPlatformUnsupported = errors.New("workspace command execution is unsupported on this platform")
errCommandLaunchInvalid = errors.New("workspace command launch is invalid")
)
type commandTemplate struct {
executable string
args []string
}
type commandKey struct {
requestID string
toolCallID string
}
type commandExecution struct {
mu sync.Mutex
cancelRequested bool
finished bool
cancel chan struct{}
done chan struct{}
doneOnce sync.Once
}
func newCommandExecution() *commandExecution {
return &commandExecution{cancel: make(chan struct{}), done: make(chan struct{})}
}
func (e *commandExecution) requestCancel() bool {
e.mu.Lock()
defer e.mu.Unlock()
if e.finished {
return false
}
if !e.cancelRequested {
e.cancelRequested = true
close(e.cancel)
}
return true
}
func (e *commandExecution) finish() {
e.mu.Lock()
e.finished = true
e.mu.Unlock()
e.doneOnce.Do(func() { close(e.done) })
}
// CommandInput contains only request identity and caller-selectable fields
// already closed by the workspace wire. Executable and argv never enter it.
type CommandInput struct {
RequestID string
ToolCallID string
CommandID string
Environment map[string]string
TimeoutMS int64
}
// CancelResult is the stable result of addressing one active command by its
// exact request and tool-call identity.
type CancelResult struct {
Status iop.WorkspaceStatus
Code iop.WorkspaceErrorCode
}
type commandLaunchRecord struct {
Version int `json:"version"`
Executable string `json:"executable"`
Args []string `json:"args,omitempty"`
Environment []string `json:"environment,omitempty"`
Device uint64 `json:"device"`
Inode uint64 `json:"inode"`
}
type commandLaunchStatus struct {
started bool
}
type commandProcess struct {
wait <-chan error
launch <-chan commandLaunchStatus
pid int
exitCode func() int32
}
// ExecuteCommand resolves an admitted command id to one immutable operator
// template and owns its complete process/result lifecycle.
func (r *Runtime) ExecuteCommand(ctx context.Context, input CommandInput) (result Result) {
observationStartedAt := time.Now()
var correlation string
defer func() {
if result.DurationMS == 0 {
result.DurationMS = time.Since(observationStartedAt).Milliseconds()
}
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_COMMAND, result)
}()
if ctx == nil {
ctx = context.Background()
}
r.lifetime.RLock()
defer r.lifetime.RUnlock()
req, template, environment, result := r.prepareCommand(input)
if result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
return result
}
correlation = req.correlation
if ctx.Err() != nil {
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED, ExitCode: -1}
}
key := commandKey{requestID: input.RequestID, toolCallID: input.ToolCallID}
execution := newCommandExecution()
if !r.registerCommand(req, key, execution) {
return invalidCommandResult()
}
defer func() {
execution.finish()
r.commandsMu.Lock()
if r.activeCommands[key] == execution {
delete(r.activeCommands, key)
}
r.commandsMu.Unlock()
}()
output := newCommandOutput(req.maxOutput)
record := commandLaunchRecord{
Version: commandLaunchVersion, Executable: template.executable,
Args: append([]string(nil), template.args...), Environment: environment,
Device: req.entry.device, Inode: req.entry.inode,
}
execution.mu.Lock()
if execution.cancelRequested {
execution.mu.Unlock()
return terminalCommandResult(iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED, iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED, -1, 0, output)
}
startedAt := time.Now()
process, err := startCommandProcess(record, req.entry.directory, output)
execution.mu.Unlock()
if err != nil {
if errors.Is(err, errCommandPlatformUnsupported) {
return terminalCommandResult(iop.WorkspaceStatus_WORKSPACE_STATUS_UNSUPPORTED, iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_UNSUPPORTED, -1, time.Since(startedAt), output)
}
return terminalCommandResult(iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL, -1, time.Since(startedAt), output)
}
return awaitCommand(ctx, execution, process, time.Duration(input.TimeoutMS)*time.Millisecond, startedAt, output)
}
func (r *Runtime) prepareCommand(input CommandInput) (*Request, commandTemplate, []string, Result) {
if !validRequestID(input.RequestID) || !validRequestID(input.ToolCallID) || strings.TrimSpace(input.CommandID) == "" || input.CommandID != strings.TrimSpace(input.CommandID) {
return nil, commandTemplate{}, nil, invalidCommandResult()
}
req, err := r.Request(input.RequestID)
if err != nil {
return nil, commandTemplate{}, nil, failureFor(err)
}
if _, allowed := req.operations[iop.WorkspaceOperation_WORKSPACE_OPERATION_COMMAND]; !allowed {
return nil, commandTemplate{}, nil, Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_UNSUPPORTED, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_UNSUPPORTED}
}
if _, allowed := slices.BinarySearch(req.commandIDs, input.CommandID); !allowed {
return nil, commandTemplate{}, nil, invalidCommandResult()
}
if input.TimeoutMS <= 0 || input.TimeoutMS > req.maxCommandTimeout || input.TimeoutMS > math.MaxInt64/int64(time.Millisecond) {
return nil, commandTemplate{}, nil, invalidCommandResult()
}
template, ok := req.entry.commands[input.CommandID]
if !ok {
return nil, commandTemplate{}, nil, invalidCommandResult()
}
environment, ok := buildMinimalEnvironment(input.Environment, req.entry.environment)
if !ok {
return nil, commandTemplate{}, nil, invalidCommandResult()
}
return req, template, environment, Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}
}
func (r *Runtime) registerCommand(req *Request, key commandKey, execution *commandExecution) bool {
if req == nil {
return false
}
req.mu.Lock()
defer req.mu.Unlock()
if req.cleaning {
return false
}
r.commandsMu.Lock()
defer r.commandsMu.Unlock()
if _, duplicate := r.activeCommands[key]; duplicate {
return false
}
if _, cancelled := r.cancelledCommands[key]; cancelled {
return false
}
r.activeCommands[key] = execution
return true
}
// Cancel requests process-group cancellation for one exact active command.
// Repeated requests remain idempotent for the open request lifecycle.
func (r *Runtime) Cancel(requestID, toolCallID string) CancelResult {
if !validRequestID(requestID) || !validRequestID(toolCallID) {
return CancelResult{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST}
}
key := commandKey{requestID: requestID, toolCallID: toolCallID}
r.commandsMu.Lock()
execution := r.activeCommands[key]
_, alreadyCancelled := r.cancelledCommands[key]
if alreadyCancelled {
r.commandsMu.Unlock()
return CancelResult{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED}
}
if execution == nil || !execution.requestCancel() {
r.commandsMu.Unlock()
return CancelResult{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_FOUND}
}
r.cancelledCommands[key] = struct{}{}
r.commandsMu.Unlock()
return CancelResult{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED}
}
func awaitCommand(ctx context.Context, execution *commandExecution, process *commandProcess, timeout time.Duration, startedAt time.Time, output *commandOutput) Result {
timer := time.NewTimer(timeout)
defer timer.Stop()
var (
launchKnown bool
launched bool
waitDone bool
waitErr error
terminalStatus iop.WorkspaceStatus
terminalCode iop.WorkspaceErrorCode
terminated bool
)
for {
if terminalStatus != iop.WorkspaceStatus_WORKSPACE_STATUS_UNSPECIFIED && !terminated {
terminateProcessGroup(process.pid)
terminated = true
}
if waitDone && terminalStatus != iop.WorkspaceStatus_WORKSPACE_STATUS_UNSPECIFIED {
return terminalCommandResult(terminalStatus, terminalCode, -1, time.Since(startedAt), output)
}
if waitDone && launchKnown {
if !launched {
return terminalCommandResult(iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL, -1, time.Since(startedAt), output)
}
exitCode := process.exitCode()
if waitErr == nil && exitCode == 0 {
return terminalCommandResult(iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_UNSPECIFIED, 0, time.Since(startedAt), output)
}
return terminalCommandResult(iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL, exitCode, time.Since(startedAt), output)
}
select {
case status := <-process.launch:
launchKnown = true
launched = status.started
process.launch = nil
if !launched && terminalStatus == iop.WorkspaceStatus_WORKSPACE_STATUS_UNSPECIFIED {
terminalStatus = iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR
terminalCode = iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL
}
case waitErr = <-process.wait:
waitDone = true
process.wait = nil
case <-timer.C:
if terminalStatus == iop.WorkspaceStatus_WORKSPACE_STATUS_UNSPECIFIED {
terminalStatus = iop.WorkspaceStatus_WORKSPACE_STATUS_TIMEOUT
terminalCode = iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_TIMEOUT
}
timer.Stop()
case <-execution.cancel:
if terminalStatus == iop.WorkspaceStatus_WORKSPACE_STATUS_UNSPECIFIED {
terminalStatus = iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED
terminalCode = iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED
}
execution.cancel = nil
case <-ctx.Done():
if terminalStatus == iop.WorkspaceStatus_WORKSPACE_STATUS_UNSPECIFIED {
terminalStatus = iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED
terminalCode = iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED
}
ctx = context.Background()
}
}
}
func terminalCommandResult(status iop.WorkspaceStatus, code iop.WorkspaceErrorCode, exitCode int32, duration time.Duration, output *commandOutput) Result {
stdout, stderr, truncated := output.snapshot()
return Result{
Status: status, Code: code, Stdout: stdout, Stderr: stderr,
ExitCode: exitCode, Truncated: truncated, DurationMS: duration.Milliseconds(),
}
}
func invalidCommandResult() Result {
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST, ExitCode: -1}
}
func buildMinimalEnvironment(input map[string]string, allowlist map[string]struct{}) ([]string, bool) {
if len(input) == 0 {
return []string{}, true
}
names := make([]string, 0, len(input))
total := 0
for name, value := range input {
if !validEnvironmentName(name) || name == commandShimEnvironment || strings.IndexByte(value, 0) >= 0 {
return nil, false
}
if _, allowed := allowlist[name]; !allowed {
return nil, false
}
total += len(name) + len(value) + 1
if total > commandLaunchPayloadLimit {
return nil, false
}
names = append(names, name)
}
sort.Strings(names)
environment := make([]string, 0, len(names))
for _, name := range names {
environment = append(environment, name+"="+input[name])
}
return environment, true
}
func validEnvironmentName(name string) bool {
if name == "" {
return false
}
for index := 0; index < len(name); index++ {
value := name[index]
if index == 0 {
if (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || value == '_' {
continue
}
return false
}
if (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || (value >= '0' && value <= '9') || value == '_' {
continue
}
return false
}
return true
}
type commandOutput struct {
mu sync.Mutex
remaining int64
truncated bool
stdout bytes.Buffer
stderr bytes.Buffer
}
type commandOutputWriter struct {
output *commandOutput
stderr bool
}
func newCommandOutput(limit int64) *commandOutput {
return &commandOutput{remaining: limit}
}
func (o *commandOutput) writer(stderr bool) io.Writer {
return commandOutputWriter{output: o, stderr: stderr}
}
func (w commandOutputWriter) Write(data []byte) (int, error) {
w.output.mu.Lock()
defer w.output.mu.Unlock()
retained := int64(len(data))
if retained > w.output.remaining {
retained = w.output.remaining
w.output.truncated = true
}
if retained < int64(len(data)) {
w.output.truncated = true
}
if retained > 0 {
if w.stderr {
_, _ = w.output.stderr.Write(data[:retained])
} else {
_, _ = w.output.stdout.Write(data[:retained])
}
w.output.remaining -= retained
}
return len(data), nil
}
func (o *commandOutput) snapshot() ([]byte, []byte, bool) {
o.mu.Lock()
defer o.mu.Unlock()
return bytes.Clone(o.stdout.Bytes()), bytes.Clone(o.stderr.Bytes()), o.truncated
}
// RunCommandShim must run before Cobra parsing. It recognizes only the exact
// internal invocation and otherwise leaves normal CLI behavior untouched.
func RunCommandShim(args []string) (bool, int) {
if len(args) != 2 || args[1] != commandShimArgument || commandShimMarker() != "1" {
return false, 0
}
return true, runCommandShim()
}
func commandShimMarker() string {
return os.Getenv(commandShimEnvironment)
}