승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
431 lines
14 KiB
Go
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)
|
|
}
|