diff --git a/AGENTS.md b/AGENTS.md index c752b094..03b876e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ OpenAgentCore is protocol-first and modular. Core orchestrates operations that p | Runtime and Sandbox I/O service–relay (Link) | `internal/sandboxlink/protocol.go` | [Sandbox link protocol](docs/sandbox-link-protocol.md) | | Provider–Sandbox I/O startup | `internal/sandboxbootstrap/bootstrap.go` | [Sandbox bootstrap](docs/sandbox-bootstrap.md) | | Runtime–file service | `internal/sandboxfs/protocol.go` | [File access protocol](docs/file-access-protocol.md) | +| Runtime–process service | `internal/sandboxprocess/protocol.go` | [Process protocol](docs/process-protocol.md) | | Core–Runtime wire | `internal/agentdaemon/proto/` | [Core–Runtime protocol](docs/runtime-protocol.md) | | Runtime–Harness | `apps/daemon/internal/agent/harness.go` | [Harness onboarding](contracts/agents-api/harness-onboarding.md) | | Harness–Model provider | `internal/modelprovider/config.go` | [Model execution](contracts/agents-api/model-execution.md) | diff --git a/apps/sandboxio/internal/processservice/launch.go b/apps/sandboxio/internal/processservice/launch.go new file mode 100644 index 00000000..1374bfa9 --- /dev/null +++ b/apps/sandboxio/internal/processservice/launch.go @@ -0,0 +1,287 @@ +//go:build linux + +package processservice + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "os" + "syscall" + + "github.com/creack/pty" + "golang.org/x/sys/unix" + + sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// launch spawns the process and emits Started or StartFailed, then starts +// the output readers. The reaper reports the leader's exit. +func (op *operation) launch(spec sp.ProcessSpec) { + l, f := op.spawn(spec) + op.mu.Lock() + if f != nil { + op.state, op.startFailure = sp.StateStartFailed, f + op.scope, op.stdinClosed = sp.ScopeStateClosed, true + op.push(sp.StartFailedEvent{EventHeader: op.header(), Failure: *f}) + op.settleLocked() + op.mu.Unlock() + if op.cu != nil { + op.cu.close() + } + return + } + op.pid, op.pty, op.stdin, op.streams, op.openStreams = l.pid, l.pty, l.stdin, l.streams, len(l.streams) + op.state = sp.StateRunning + op.push(sp.StartedEvent{EventHeader: op.header()}) + if op.leaderGone { + op.exitedLocked() + } + cancel, grace := op.cancelPending, op.pendingGrace + op.mu.Unlock() + // Each stream reads at most its share of the replay limit, so a reader + // blocked on an idle stream never holds the space another one needs. + chunk := min(int(op.s.caps.MaxDataBytes), int(op.s.caps.MaxReplayBytesPerOperation)/len(l.streams)) + for _, st := range l.streams { + go op.read(st, chunk) + } + if cancel { + op.cancel(grace) + } +} + +// launched is what a successful spawn hands to the operation. +type launched struct { + pid int + pty *os.File + stdin *os.File + streams []*stream +} + +// spawn starts the trampoline in a new session and waits until it has +// exec'd the target or reported why it could not. +func (op *operation) spawn(spec sp.ProcessSpec) (l launched, f *sp.Failure) { + ioFail := func(what string, err error) *sp.Failure { + return sp.Fail(sp.CodeIO, sandboxwire.EffectNone, "%s: %v", what, err) + } + // Descriptors the child inherits close after the start; the parent's + // close only when the launch fails. Each is listed as soon as it exists. + var child, parent []*os.File + defer func() { + closeAll(child) + if f != nil { + closeAll(parent) + } + }() + pipe := func(what string, childReads bool) (forChild, forParent *os.File, f *sp.Failure) { + r, w, err := os.Pipe() + if err != nil { + return nil, nil, ioFail(what, err) + } + forChild, forParent = w, r + if childReads { + forChild, forParent = r, w + } + child, parent = append(child, forChild), append(parent, forParent) + return forChild, forParent, nil + } + files := make([]*os.File, statusFD+1) + var launchW, statusR *os.File + if files[launchFD], launchW, f = pipe("launch pipe", true); f != nil { + return l, f + } + if files[statusFD], statusR, f = pipe("status pipe", false); f != nil { + return l, f + } + if spec.PTY != nil { + master, tty, err := openPTY(*spec.PTY) + if err != nil { + return l, ioFail("open terminal", err) + } + child, parent = append(child, tty), append(parent, master) + files[0], files[1], files[2] = tty, tty, tty + l.pty, l.stdin = master, master + l.streams = []*stream{{name: sp.StreamTerminal, f: master}} + } else { + if files[0], l.stdin, f = pipe("stdin pipe", true); f != nil { + return l, f + } + for i, name := range []sp.Stream{sp.StreamStdout, sp.StreamStderr} { + var r *os.File + if files[1+i], r, f = pipe("output pipe", false); f != nil { + return l, f + } + l.streams = append(l.streams, &stream{name: name, f: r}) + } + } + + attr := &os.ProcAttr{ + Env: []string{trampolineEnv}, + Files: files, + // Setctty makes the child's descriptor 0, the terminal, its controlling terminal. + Sys: &syscall.SysProcAttr{Setsid: true, Setctty: spec.PTY != nil}, + } + reaping.RLock() + p, err := os.StartProcess("/proc/self/exe", []string{trampolineArg0}, attr) + var ferr error + if err == nil { + // The reaper cannot reap the child while reaping is held, so its PID + // still names it. + var fd int + if fd, ferr = unix.PidfdOpen(p.Pid, 0); ferr == nil { + op.cu = newCustody(p.Pid, fd, op.s.stat) + } else { + unix.Kill(p.Pid, unix.SIGKILL) // it has not read the launch, so it never execs + } + register(p.Pid, op) + } + reaping.RUnlock() + if err != nil { + return l, ioFail("start trampoline", err) + } + pid := p.Pid + p.Release() // the reaper waits; signals go through killPinned and the custody + if ferr != nil { + return l, ioFail("open process descriptor", ferr) + } + l.pid = pid + op.mu.Lock() + op.pid = l.pid // for killPinned; the rest is published with Started + op.mu.Unlock() + closeAll(child) + child = nil + + _, werr := launchW.Write(encodeLaunch(spec)) + launchW.Close() + status, rerr := io.ReadAll(statusR) + statusR.Close() + switch { + case len(status) == 6: + // The trampoline reports only a failure before exec. + stage, errno := binary.BigEndian.Uint16(status), syscall.Errno(binary.BigEndian.Uint32(status[2:])) + return l, launchFailure(stage, errno) + case rerr != nil: + // Whether exec happened is unknown. + op.signalScope(unix.SIGKILL) + return l, sp.Fail(sp.CodeIO, sandboxwire.EffectPossible, "read launch status: %v", rerr) + case werr != nil || len(status) > 0: + // The trampoline ended before reading the whole launch or while + // reporting a failure, so it never exec'd. + op.killPinned(true, unix.SIGKILL) + return l, ioFail("launch", errors.Join(werr, errors.New("the trampoline ended before exec"))) + } + return l, nil +} + +func launchFailure(stage uint16, errno syscall.Errno) *sp.Failure { + what := map[uint16]string{stageLaunch: "read launch", stageDescriptors: "close inherited descriptors", stageChdir: "change directory", stageExec: "exec"}[stage] + code := sp.CodeIO + switch errno { + case unix.ENOENT, unix.ENOTDIR: + code = sp.CodeNotFound + case unix.EACCES, unix.EPERM: + code = sp.CodeUnauthorized + case unix.ENOEXEC, unix.EINVAL, unix.ELIBBAD, unix.ENAMETOOLONG, unix.ELOOP, unix.EISDIR: + code = sp.CodeInvalidArgument + case unix.E2BIG, unix.ENOMEM, unix.EMFILE, unix.ENFILE, unix.EAGAIN: + code = sp.CodeResourceExhausted + } + return sp.Fail(code, sandboxwire.EffectNone, "%s: %v", what, errno) +} + +func closeAll(files []*os.File) { + for _, f := range files { + f.Close() + } +} + +// openPTY opens a terminal with the requested modes and size. It returns a +// non-blocking master, so closing it interrupts a pending read or write. +func openPTY(spec sp.PTYSpec) (master, tty *os.File, err error) { + ptmx, tty, err := pty.Open() + if err != nil { + return nil, nil, err + } + defer ptmx.Close() + defer func() { + if err != nil { + tty.Close() + } + }() + if err := applyModes(tty, spec.Modes); err != nil { + return nil, nil, err + } + if err := unix.IoctlSetWinsize(int(tty.Fd()), unix.TIOCSWINSZ, winsize(spec.Size)); err != nil { + return nil, nil, err + } + fd, err := unix.FcntlInt(ptmx.Fd(), unix.F_DUPFD_CLOEXEC, 0) + if err != nil { + return nil, nil, err + } + if err := unix.SetNonblock(fd, true); err != nil { + unix.Close(fd) + return nil, nil, err + } + return os.NewFile(uintptr(fd), "/dev/ptmx"), tty, nil +} + +// read captures one stream until end of file, a read error, or CloseOutput. +// Before each read of up to chunk bytes it reserves chunk bytes of the replay +// limit, so the streams together never exceed it, and it pauses until they +// are free. +func (op *operation) read(st *stream, chunk int) { + limit := int(op.s.caps.MaxReplayBytesPerOperation) + buf := make([]byte, chunk) + var disp sp.OutputDisposition + for disp == 0 { + op.mu.Lock() + for op.retained+op.reserved+chunk > limit && !st.abandoned { + op.cond.Wait() + } + if st.abandoned { + op.mu.Unlock() + disp = sp.OutputAbandoned + break + } + op.reserved += chunk + op.mu.Unlock() + n, err := st.f.Read(buf) + op.mu.Lock() + op.reserved -= chunk + if n > 0 { + op.push(sp.OutputEvent{EventHeader: op.header(), Stream: st.name, Offset: st.offset, Data: bytes.Clone(buf[:n])}) + st.offset += uint64(n) + } else { + op.cond.Broadcast() // the unused reservation is free again + } + switch { + case err == nil: + case st.abandoned: + disp = sp.OutputAbandoned + case err == io.EOF, op.pty != nil && errors.Is(err, syscall.EIO): // EIO: every slave descriptor closed + disp = sp.OutputDrained + default: + disp = sp.OutputLost + } + op.mu.Unlock() + } + st.f.Close() + + op.mu.Lock() + defer op.mu.Unlock() + st.closed = true + op.push(sp.StreamClosedEvent{EventHeader: op.header(), Stream: st.name, Offset: st.offset, Disposition: disp}) + op.worst = max(op.worst, disp) + if op.openStreams--; op.openStreams > 0 { + return + } + if op.pty != nil { + op.stdinClosed = true + } + worst := op.worst + op.output = &worst + op.push(sp.OutputClosedEvent{EventHeader: op.header(), Disposition: worst}) + op.settleLocked() +} diff --git a/apps/sandboxio/internal/processservice/operation.go b/apps/sandboxio/internal/processservice/operation.go new file mode 100644 index 00000000..69cd13ed --- /dev/null +++ b/apps/sandboxio/internal/processservice/operation.go @@ -0,0 +1,544 @@ +//go:build linux + +package processservice + +import ( + "context" + "crypto/sha256" + "errors" + "os" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" + + sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +type opKey struct { + attachment sandboxwire.ID + operation sandboxwire.ID +} + +// operation is one operation record. After Release it is the tombstone: +// digest, state and results stay; events and descriptors are gone. +type operation struct { + s *Service + key opKey + digest [sha256.Size]byte + + mu sync.Mutex + cond *sync.Cond + + state sp.OperationState + exit *sp.ExitStatus + startFailure *sp.Failure + output *sp.OutputDisposition + scope sp.ScopeState + released bool + settled bool + + // log holds the retained events first..last; retained counts their + // Output bytes, and reserved the bytes readers may add. acked is the + // highest acknowledged sequence. + log []sp.Event + first, last uint64 + retained int + reserved int + acked uint64 + + // observer is the generation of the stream receiving events, zero when + // none; delivered is the last sequence handed to it. + observer, generation uint64 + delivered uint64 + + // The launch publishes these once, with the Started event. + pid int + pty *os.File // PTY master, nil for pipes + stdin *os.File // pipe write end, or the PTY master + streams []*stream + openStreams int + + stdinMu sync.Mutex + stdinOffset uint64 + stdinClosed bool + worst sp.OutputDisposition + + // leaderGone is set when the reaper reaps the leader, with its status. + leaderGone bool + status unix.WaitStatus + // cu holds the processes proven to be in the session; the spawn sets it. + cu *custody + + // A Cancel that arrives while the operation is starting waits for the + // launch. + cancelPending bool + pendingGrace time.Duration + + killTimer *time.Timer + killAt time.Time + killing bool +} + +type stream struct { + name sp.Stream + f *os.File + offset uint64 + abandoned bool + closed bool +} + +func newOperation(s *Service, key opKey, digest [sha256.Size]byte) *operation { + op := &operation{s: s, key: key, digest: digest, state: sp.StateStarting, scope: sp.ScopeStateActive, first: 1} + op.cond = sync.NewCond(&op.mu) + return op +} + +func (op *operation) header() sp.EventHeader { + op.last++ + return sp.EventHeader{OperationID: op.key.operation, Sequence: op.last} +} + +// push appends an event whose header came from header() under the same lock. +func (op *operation) push(ev sp.Event) { + op.log = append(op.log, ev) + if o, ok := ev.(sp.OutputEvent); ok { + op.retained += len(o.Data) + } + op.cond.Broadcast() +} + +func (op *operation) settleLocked() { + if op.settled { + return + } + switch op.state { + case sp.StateStartFailed: + case sp.StateExited, sp.StateUnknown: + if op.output == nil || op.scope != sp.ScopeStateClosed { + return + } + default: + return + } + op.settled = true + op.s.active.Add(-1) + // No process remains to read pipe stdin. + if op.pty == nil && op.stdin != nil && !op.stdinClosed { + op.stdinClosed = true + op.stdin.Close() + } +} + +func (op *operation) statusLocked() sp.OperationStatus { + return sp.OperationStatus{ + State: op.state, + Exit: op.exit, + StartFailure: op.startFailure, + StdinOffset: op.stdinOffset, + StdinClosed: op.stdinClosed, + Output: op.output, + Scope: op.scope, + Released: op.released, + FirstRetained: op.first, + LastSequence: op.last, + } +} + +func (op *operation) inspect() sp.OperationStatus { + op.mu.Lock() + defer op.mu.Unlock() + return op.statusLocked() +} + +func (op *operation) sid() int { + op.mu.Lock() + defer op.mu.Unlock() + return op.pid +} + +// reaped records the leader's wait status. The reaper calls it. +func (op *operation) reaped(ws unix.WaitStatus) { + op.mu.Lock() + defer op.mu.Unlock() + op.leaderGone, op.status = true, ws + if op.state == sp.StateRunning { + op.exitedLocked() + } +} + +// exitedLocked reports the reaped leader's exit and starts watching the scope. +func (op *operation) exitedLocked() { + ws := op.status + exit := sp.ExitStatus{Kind: sp.ExitCode, Code: uint8(ws.ExitStatus())} + if ws.Signaled() { + exit = sp.ExitStatus{Kind: sp.ExitSignal, Signal: sp.Signal(ws.Signal()), CoreDumped: ws.CoreDump()} + } + op.state, op.exit = sp.StateExited, &exit + op.push(sp.ExitedEvent{EventHeader: op.header(), Status: exit}) + op.settleLocked() + go op.watchScope() +} + +// trimLocked drops acknowledged events, except those the observer has not +// been sent yet: its accepted Attach promised them. +func (op *operation) trimLocked() { + limit := op.acked + if op.observer != 0 { + limit = min(limit, op.delivered) + } + if op.first > limit { + return + } + for op.first <= limit { + if o, ok := op.log[0].(sp.OutputEvent); ok { + op.retained -= len(o.Data) + } + op.log[0] = nil + op.log = op.log[1:] + op.first++ + } + op.cond.Broadcast() +} + +// observeLocked makes conn the operation's only observer, from the event +// after sequence after. +func (op *operation) observeLocked(conn *sp.Conn, after uint64) { + op.generation++ + op.observer, op.delivered = op.generation, after + op.trimLocked() + op.cond.Broadcast() + go op.pump(conn, op.generation) +} + +// pump sends retained events to one observer until it is replaced or its +// stream ends. conn.Send blocking on a slow peer is the backpressure; the +// log keeps growing only up to the replay limit, where output reading pauses. +func (op *operation) pump(conn *sp.Conn, gen uint64) { + drop := func() { + op.mu.Lock() + if op.observer == gen { + op.observer = 0 + op.trimLocked() + } + op.cond.Broadcast() + op.mu.Unlock() + } + defer context.AfterFunc(conn.Context(), drop)() + for { + op.mu.Lock() + for op.observer == gen && op.delivered == op.last { + op.cond.Wait() + } + if op.observer != gen { + op.mu.Unlock() + return + } + op.delivered++ + ev := op.log[op.delivered-op.first] + op.trimLocked() + op.mu.Unlock() + if conn.Send(ev) != nil { + drop() + return + } + } +} + +func (op *operation) attach(conn *sp.Conn, after uint64) (sp.OperationStatus, error) { + op.mu.Lock() + defer op.mu.Unlock() + switch { + case op.released: + return sp.OperationStatus{}, released() + case after > op.last: + return sp.OperationStatus{}, invalid("sequence %d is after the last event %d", after, op.last) + case after+1 < op.first: + return sp.OperationStatus{}, sp.Fail(sp.CodeReplayGap, sandboxwire.EffectNone, "events before %d were acknowledged", op.first) + } + op.observeLocked(conn, after) + return op.statusLocked(), nil +} + +func (op *operation) ack(seq uint64) error { + op.mu.Lock() + defer op.mu.Unlock() + switch { + case op.released: + return released() + case seq > op.last: + return invalid("sequence %d is after the last event %d", seq, op.last) + } + op.acked = max(op.acked, seq) + op.trimLocked() + return nil +} + +func (op *operation) writeStdin(ctx context.Context, offset uint64, data []byte) (uint32, error) { + op.stdinMu.Lock() + defer op.stdinMu.Unlock() + op.mu.Lock() + switch { + case op.released: + op.mu.Unlock() + return 0, released() + case op.state == sp.StateStarting: + op.mu.Unlock() + return 0, notRunning("the operation is starting") + case offset != op.stdinOffset: + op.mu.Unlock() + return 0, sp.Fail(sp.CodeInputOffsetConflict, sandboxwire.EffectNone, "stdin offset is %d, not %d", op.stdinOffset, offset) + case op.stdinClosed: + op.mu.Unlock() + return 0, sp.Fail(sp.CodeStdinClosed, sandboxwire.EffectNone, "stdin is closed") + } + f := op.stdin + op.mu.Unlock() + + n, err := writeContext(ctx, f, data) + closed := errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.EIO) || errors.Is(err, os.ErrClosed) + op.mu.Lock() + op.stdinOffset += uint64(n) + if closed && !op.stdinClosed { + op.stdinClosed = true + if op.pty == nil { + f.Close() + } + } + op.mu.Unlock() + switch { + case err == nil || n > 0: + return uint32(n), nil + case closed: + return 0, sp.Fail(sp.CodeStdinClosed, sandboxwire.EffectNone, "stdin is closed") + case errors.Is(err, os.ErrDeadlineExceeded): + return 0, sp.Fail(sp.CodeCancelled, sandboxwire.EffectNone, "stream ended during the write") + } + return 0, sp.Fail(sp.CodeIO, sandboxwire.EffectPossible, "write stdin: %v", err) +} + +// writeContext writes to a pollable file, stopping when ctx ends. +func writeContext(ctx context.Context, f *os.File, data []byte) (int, error) { + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + f.SetWriteDeadline(time.Now()) + close(done) + }) + n, err := f.Write(data) + if !stop() { + <-done + f.SetWriteDeadline(time.Time{}) + } + return n, err +} + +func (op *operation) closeStdin(offset uint64) error { + op.stdinMu.Lock() + defer op.stdinMu.Unlock() + op.mu.Lock() + defer op.mu.Unlock() + switch { + case op.released: + return released() + case op.state == sp.StateStarting: + return notRunning("the operation is starting") + case op.pty != nil: + return sp.Fail(sp.CodeUnsupported, sandboxwire.EffectNone, "a PTY has no stdin half-close; write the terminal's EOF character") + case offset != op.stdinOffset: + return sp.Fail(sp.CodeInputOffsetConflict, sandboxwire.EffectNone, "stdin offset is %d, not %d", op.stdinOffset, offset) + case op.stdinClosed: + return nil + } + op.stdinClosed = true + op.stdin.Close() + return nil +} + +func (op *operation) closeOutput(name sp.Stream) error { + op.mu.Lock() + defer op.mu.Unlock() + if op.released { + return released() + } + if op.state == sp.StateStarting || op.state == sp.StateStartFailed { + return notRunning("the operation has no output") + } + for _, st := range op.streams { + if st.name != name { + continue + } + if st.closed || st.abandoned { + return sp.Fail(sp.CodeOutputClosed, sandboxwire.EffectNone, "stream %d is closed", name) + } + st.abandoned = true + op.cond.Broadcast() + st.f.Close() + return nil + } + return invalid("the operation does not capture stream %d", name) +} + +func (op *operation) resize(size sp.WindowSize) error { + master, err := op.terminal() + if err != nil { + return err + } + return control(master, func(fd int) error { return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, winsize(size)) }) +} + +// terminal returns the open PTY master. +func (op *operation) terminal() (*os.File, error) { + op.mu.Lock() + defer op.mu.Unlock() + switch { + case op.released: + return nil, released() + case op.state == sp.StateStarting || op.state == sp.StateStartFailed: + return nil, notRunning("the operation has no terminal") + case op.pty == nil: + return nil, invalid("the operation has no PTY") + case op.streams[0].closed || op.streams[0].abandoned: + return nil, sp.Fail(sp.CodeOutputClosed, sandboxwire.EffectNone, "the terminal is closed") + } + return op.pty, nil +} + +func control(f *os.File, fn func(fd int) error) error { + rc, err := f.SyscallConn() + if err != nil { + return sp.Fail(sp.CodeOutputClosed, sandboxwire.EffectNone, "the terminal is closed") + } + var ferr error + if err := rc.Control(func(fd uintptr) { ferr = fn(int(fd)) }); err != nil { + return sp.Fail(sp.CodeOutputClosed, sandboxwire.EffectNone, "the terminal is closed") + } + if ferr != nil { + return sp.Fail(sp.CodeIO, sandboxwire.EffectNone, "terminal: %v", ferr) + } + return nil +} + +// signal delivers sig to target. scope.go describes how no signal reaches a +// process outside the operation's session. +func (op *operation) signal(sig sp.Signal, target sp.SignalTarget) error { + op.mu.Lock() + switch { + case op.released: + op.mu.Unlock() + return released() + case op.state == sp.StateStarting || op.state == sp.StateStartFailed: + op.mu.Unlock() + return notRunning("the operation has no processes") + case op.scope == sp.ScopeStateClosed: + op.mu.Unlock() + return notRunning("the scope is empty") + } + op.mu.Unlock() + + s := unix.Signal(sig) + switch target { + case sp.TargetLeader: + sent, err := op.killPinned(false, s) + if err != nil { + return sp.Fail(sp.CodeIO, sandboxwire.EffectNone, "signal leader: %v", err) + } + if !sent { + return notRunning("the leader has exited") + } + case sp.TargetInitialProcessGroup: + return op.signalGroup(op.sid(), s) + case sp.TargetPTYForegroundGroup: + master, err := op.terminal() + if err != nil { + return err + } + var pgrp int + if err := control(master, func(fd int) (err error) { + pgrp, err = unix.IoctlGetInt(fd, unix.TIOCGPGRP) + return err + }); err != nil || pgrp <= 0 { + // 0 once the session leader has exited and the terminal lost its session. + return notRunning("the terminal has no foreground process group") + } + return op.signalGroup(pgrp, s) + case sp.TargetScope: + return op.signalScope(s) + } + return nil +} + +// cancel sends TERM to the scope and schedules KILL after grace. An earlier +// deadline from another Cancel stands. A Cancel while the operation is +// starting, including the owner-loss cleanup, takes effect when the launch +// completes. +func (op *operation) cancel(grace time.Duration) error { + op.mu.Lock() + switch { + case op.released: + op.mu.Unlock() + return released() + case op.state == sp.StateStarting: + if !op.cancelPending || grace < op.pendingGrace { + op.cancelPending, op.pendingGrace = true, grace + } + op.mu.Unlock() + return nil + case op.state == sp.StateStartFailed || op.scope == sp.ScopeStateClosed: + op.mu.Unlock() + return notRunning("the scope is empty") + } + if at := time.Now().Add(grace); op.killTimer == nil || at.Before(op.killAt) { + if op.killTimer != nil { + op.killTimer.Stop() + } + op.killAt = at + op.killTimer = time.AfterFunc(grace, op.kill) + } + op.mu.Unlock() + op.signalScope(unix.SIGTERM) + op.signalScope(unix.SIGCONT) // a stopped process acts on TERM only once continued + return nil +} + +func (op *operation) kill() { + op.mu.Lock() + if op.scope == sp.ScopeStateClosed { + op.mu.Unlock() + return + } + op.killing = true + op.mu.Unlock() + op.signalScope(unix.SIGKILL) +} + +func (op *operation) release() error { + op.mu.Lock() + defer op.mu.Unlock() + switch { + case op.released: + return nil + case !op.settled: + return sp.Fail(sp.CodeBusy, sandboxwire.EffectNone, "the operation has not settled") + } + op.released = true + clear(op.log) + op.log, op.first, op.retained = nil, op.last+1, 0 + op.observer = 0 + op.cond.Broadcast() + op.pty, op.stdin, op.streams = nil, nil, nil + return nil +} + +func released() error { + return sp.Fail(sp.CodeReleased, sandboxwire.EffectNone, "the operation was released") +} + +func notRunning(reason string) error { + return sp.Fail(sp.CodeNotRunning, sandboxwire.EffectNone, "%s", reason) +} + +func invalid(format string, args ...any) error { + return sp.Fail(sp.CodeInvalidArgument, sandboxwire.EffectNone, format, args...) +} diff --git a/apps/sandboxio/internal/processservice/reap.go b/apps/sandboxio/internal/processservice/reap.go new file mode 100644 index 00000000..3799e4d1 --- /dev/null +++ b/apps/sandboxio/internal/processservice/reap.go @@ -0,0 +1,118 @@ +//go:build linux + +package processservice + +import ( + "context" + "os" + "os/signal" + "sync" + "unsafe" + + "golang.org/x/sys/unix" +) + +// One loop, Reap, waits for every child of the process; nothing else in the +// process may wait, so no exit status is lost to a competing wait. A launch +// registers its leader's PID before the loop can reap it. Any other child, +// such as an orphan reparented to the binary as a child subreaper, is reaped +// and dropped. +// +// The loop reaps only while holding reaping for writing, so while reaping is +// held for reading an unreaped leader's PID, and the initial process group ID +// equal to it, name the operation's processes. Before reaping a child, the +// loop refreshes every custody holding it (see scope.go). +var ( + reaping sync.RWMutex + regMu sync.Mutex + leaders = map[int]*operation{} + scopes = map[*custody]struct{}{} +) + +// Reap reaps the process's children until ctx ends and delivers each +// operation leader's exit to its operation. The service binary starts it once, +// before serving, and makes itself a child subreaper so orphaned descendants of +// operations are reaped here too. Operations observe no exit while Reap is not +// running. +func Reap(ctx context.Context) { + sigchld := make(chan os.Signal, 1) + signal.Notify(sigchld, unix.SIGCHLD) + defer signal.Stop(sigchld) + for { + for reapOne() { + } + select { + case <-sigchld: + case <-ctx.Done(): + return + } + } +} + +// childInfo is siginfo_t as waitid fills it for a child. +type childInfo struct { + signo, errno, code int32 + _ [unsafe.Sizeof(uintptr(0)) - 4]byte // the union is pointer-aligned + pid int32 + _ [128]byte +} + +// reapOne reaps one waitable child and reports whether there was one. It +// peeks at the child without reaping it, refreshes the custodies holding it +// while it still holds its IDs, then waits for exactly that child. That wait +// also consumes a ptrace stop, which Linux reports to a tracing parent even +// without WUNTRACED. +func reapOne() bool { + reaping.Lock() + defer reaping.Unlock() + var info childInfo + var err error = unix.EINTR + for err == unix.EINTR { + err = unix.Waitid(unix.P_ALL, 0, (*unix.Siginfo)(unsafe.Pointer(&info)), unix.WEXITED|unix.WNOHANG|unix.WNOWAIT, nil) + } + pid := int(info.pid) + if err != nil || pid <= 0 { + return false + } + regMu.Lock() + held := make([]*custody, 0, len(scopes)) + for c := range scopes { + held = append(held, c) + } + regMu.Unlock() + for _, c := range held { + c.exiting(pid) + } + var ws unix.WaitStatus + got, err := unix.Wait4(pid, &ws, unix.WNOHANG, nil) + for err == unix.EINTR { + got, err = unix.Wait4(pid, &ws, unix.WNOHANG, nil) + } + if err == nil && got == pid { + dispatch(pid, ws) + } + return true +} + +// dispatch hands a child's wait status to its operation. Only an exit ends +// the registration; a stop is not an exit. +func dispatch(pid int, ws unix.WaitStatus) { + if !ws.Exited() && !ws.Signaled() { + return + } + regMu.Lock() + op := leaders[pid] + delete(leaders, pid) + regMu.Unlock() + if op != nil { + op.reaped(ws) + } +} + +// register records a started leader. The caller holds reaping for reading +// from before the fork, so the leader cannot be reaped unregistered. +func register(pid int, op *operation) { + regMu.Lock() + leaders[pid] = op + regMu.Unlock() +} diff --git a/apps/sandboxio/internal/processservice/scope.go b/apps/sandboxio/internal/processservice/scope.go new file mode 100644 index 00000000..0955ef0f --- /dev/null +++ b/apps/sandboxio/internal/processservice/scope.go @@ -0,0 +1,428 @@ +//go:build linux + +package processservice + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "strconv" + "sync" + "time" + + "golang.org/x/sys/unix" + + sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// A POSIX session scope is the processes whose session ID is the leader's +// PID. A descendant that calls setsid leaves the scope; that is the scope's +// documented limit. +// +// A session ID is a PID number, so once the session has emptied and its +// leader is reaped, the number can name a new, unrelated session. The service +// signals only processes it can prove are in the operation's session: +// +// - While the leader is unreaped, its PID pins the session and initial group +// IDs, and killPinned signals by ID, holding off the reaper. +// - A custody keeps a pidfd for each process proven to be in the session, +// starting with the leader's. A process whose stat shows the session ID +// joins only when a process in custody was in the session both before and +// after that read: it held the ID throughout, so the ID named this +// session. Before the reaper reaps a child in custody, the leader +// included, it refreshes the custody while the child's zombie still holds +// the ID. A signal goes only to members the same refresh proved still in +// the session; when a refresh fails, nothing is signaled. +// +// When the custody is empty but a live process still shows the session ID, +// the service cannot tell the session from a new one: the scope becomes +// Unknown and nothing is signaled. +const ( + scopePollFirst = 10 * time.Millisecond + scopePollMax = time.Second +) + +// errAmbiguous reports a session ID shown by processes none of which can be +// proven to be in the operation's session. +var errAmbiguous = errors.New("no process proven to be in the session remains, but a live process shows its ID") + +type procStat struct { + state byte + pgrp int + session int +} + +// readStat parses /proc//stat. The command name may contain spaces and +// parentheses, so fields are counted from the last ')'. +func readStat(pid int) (procStat, error) { + b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return procStat{}, err + } + i := bytes.LastIndexByte(b, ')') + if i < 0 { + return procStat{}, fmt.Errorf("malformed /proc/%d/stat", pid) + } + f := bytes.Fields(b[i+1:]) + if len(f) < 4 || len(f[0]) != 1 { + return procStat{}, fmt.Errorf("malformed /proc/%d/stat", pid) + } + pgrp, err1 := strconv.Atoi(string(f[2])) + session, err2 := strconv.Atoi(string(f[3])) + if err := errors.Join(err1, err2); err != nil { + return procStat{}, err + } + return procStat{state: f[0][0], pgrp: pgrp, session: session}, nil +} + +func (s procStat) live() bool { return s.state != 'Z' && s.state != 'X' } + +// gone reports a read of a process that no longer exists. +func gone(err error) bool { return errors.Is(err, fs.ErrNotExist) || errors.Is(err, unix.ESRCH) } + +// unreaped reports whether fd's process is not yet reaped. EPERM means it +// exists but refuses the caller's signals. +func unreaped(fd int) (bool, error) { + switch err := unix.PidfdSendSignal(fd, 0, nil, 0); { + case err == nil, errors.Is(err, unix.EPERM): + return true, nil + case errors.Is(err, unix.ESRCH): + return false, nil + default: + return false, fmt.Errorf("pidfd_send_signal: %w", err) + } +} + +// member is a process in custody, with its stat from the last refresh. +type member struct { + fd int + st procStat +} + +// custody holds the processes proven to be in one operation's session. +type custody struct { + sid int + stat func(pid int) (procStat, error) + + mu sync.Mutex + members map[int]member // by PID + closed bool +} + +// newCustody starts a custody with the leader's pidfd and tracks it for the +// reaper. +func newCustody(sid, leaderFD int, stat func(int) (procStat, error)) *custody { + c := &custody{sid: sid, stat: stat, members: map[int]member{sid: {fd: leaderFD}}} + regMu.Lock() + scopes[c] = struct{}{} + regMu.Unlock() + return c +} + +// prove reads pid's stat, then checks that fd's process is still unreaped, so +// the stat was that process's. in reports that it was in the session. +func (c *custody) prove(pid, fd int) (st procStat, in bool, err error) { + st, err = c.stat(pid) + if gone(err) { + return st, false, nil + } + if err != nil { + return st, false, err + } + ok, err := unreaped(fd) + return st, ok && st.session == c.sid, err +} + +// pruneLocked drops members that are gone or have left the session and +// returns a member still in it, or 0. +func (c *custody) pruneLocked() (holder int, err error) { + for pid, m := range c.members { + st, in, err := c.prove(pid, m.fd) + if err != nil { + return 0, err + } + if !in { + unix.Close(m.fd) + delete(c.members, pid) + continue + } + c.members[pid] = member{fd: m.fd, st: st} + holder = pid + } + return holder, nil +} + +// showing lists the processes whose stat shows the session ID. +func (c *custody) showing() (map[int]procStat, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil, err + } + found := map[int]procStat{} + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil || !e.IsDir() { + continue + } + st, err := c.stat(pid) + if gone(err) { + continue + } + if err != nil { + return nil, err + } + if st.session == c.sid { + found[pid] = st + } + } + return found, nil +} + +// refreshLocked drops members that are gone or left the session and adds the +// processes proven to have joined it. +func (c *custody) refreshLocked() error { + for { + holder, err := c.pruneLocked() + if err != nil { + return err + } + found, err := c.showing() + if err != nil { + return err + } + if holder == 0 { + for _, st := range found { + if st.live() { + return errAmbiguous + } + } + return nil + } + joined := map[int]member{} + release := func() { + for _, m := range joined { + unix.Close(m.fd) + } + } + for pid := range found { + if _, ok := c.members[pid]; ok || pid <= 1 { + continue + } + fd, err := unix.PidfdOpen(pid, 0) + if errors.Is(err, unix.ESRCH) { + continue + } + if err != nil { + release() + return fmt.Errorf("pidfd_open: %w", err) + } + st, in, err := c.prove(pid, fd) + if err != nil || !in { + unix.Close(fd) + if err != nil { + release() + return err + } + continue + } + joined[pid] = member{fd: fd, st: st} + } + // The holder was in the session before the scan. Still in it, it held + // the session ID throughout, so every process read with that ID was + // in this session. Otherwise prune drops it and the scan repeats. + _, in, err := c.prove(holder, c.members[holder].fd) + if err != nil { + release() + return err + } + if in { + for pid, m := range joined { + c.members[pid] = m + } + return nil + } + release() + } +} + +// sweep refreshes the custody, then sends sig, unless it is 0, to each live +// member that match accepts. It returns the members signaled and the live +// members; failed joins the delivery failures, after every delivery was tried. +// lost is the refresh failure. A member is signaled only once this refresh +// proved it still in the session, so after a failure, which can leave a +// member that has since left, nothing is signaled. +func (c *custody) sweep(match func(st procStat) bool, sig unix.Signal) (sent, live int, lost, failed error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return 0, 0, nil, nil + } + if lost = c.refreshLocked(); lost != nil { + return 0, 0, lost, nil + } + var errs []error + for pid, m := range c.members { + if !m.st.live() { + continue + } + live++ + if sig == 0 || !match(m.st) { + continue + } + switch err := unix.PidfdSendSignal(m.fd, sig, nil, 0); { + case err == nil: + sent++ + case errors.Is(err, unix.ESRCH): + live-- + default: + errs = append(errs, fmt.Errorf("signal process %d: %w", pid, err)) + } + } + return sent, live, lost, errors.Join(errs...) +} + +// exiting refreshes the custody if pid is a member, while the reaper holds +// its zombie unreaped. +func (c *custody) exiting(pid int) { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.members[pid]; ok && !c.closed { + c.refreshLocked() // a failure resurfaces at the next sweep + } +} + +// close releases every pidfd once the scope is closed. +func (c *custody) close() { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return + } + c.closed = true + for pid, m := range c.members { + unix.Close(m.fd) + delete(c.members, pid) + } + regMu.Lock() + delete(scopes, c) + regMu.Unlock() +} + +// killPinned sends sig to the leader, or with group to the initial process +// group, while the leader is unreaped. It holds off the reaper, so the +// leader's PID cannot be reused during the kill. It reports false once the +// leader is reaped or nothing received the signal. +func (op *operation) killPinned(group bool, sig unix.Signal) (bool, error) { + reaping.RLock() + defer reaping.RUnlock() + op.mu.Lock() + pid, gone := op.pid, op.leaderGone + op.mu.Unlock() + if gone || pid <= 1 { + return false, nil + } + if group { + pid = -pid + } + switch err := unix.Kill(pid, sig); { + case err == nil: + return true, nil + case errors.Is(err, unix.ESRCH): + return false, nil + default: + return false, err + } +} + +// signalResult maps a delivery to the Signal response: a failure is IO, with +// EffectPossible once anything was signaled. +func signalResult(sent int, lost, failed error, empty string) error { + switch { + case failed != nil || lost != nil && !errors.Is(lost, errAmbiguous): + effect := sandboxwire.EffectNone + if sent > 0 { + effect = sandboxwire.EffectPossible + } + return sp.Fail(sp.CodeIO, effect, "signal: %v", errors.Join(lost, failed)) + case sent > 0: + return nil + case lost != nil: + return notRunning("no process can be proven to be in the session") + } + return notRunning(empty) +} + +// signalGroup signals process group pgrp of the operation's session: the +// initial group by ID while its leader pins it, any other group, or the +// initial group once the leader is reaped, through the custody. +func (op *operation) signalGroup(pgrp int, sig unix.Signal) error { + if pgrp <= 1 { + return notRunning("the process group is empty") + } + if pgrp == op.sid() { + if sent, err := op.killPinned(true, sig); sent { + return nil + } else if err != nil { + return sp.Fail(sp.CodeIO, sandboxwire.EffectNone, "signal process group: %v", err) + } + } + sent, _, lost, failed := op.cu.sweep(func(st procStat) bool { return st.pgrp == pgrp }, sig) + return signalResult(sent, lost, failed, "the process group is empty") +} + +// signalScope signals the initial process group by ID while its leader pins +// it, then every other member in custody. +func (op *operation) signalScope(sig unix.Signal) error { + sid := op.sid() + grouped, gerr := op.killPinned(true, sig) + sent, _, lost, failed := op.cu.sweep(func(st procStat) bool { return !grouped || st.pgrp != sid }, sig) + if grouped { + sent++ + } + return signalResult(sent, lost, errors.Join(gerr, failed), "the scope is empty") +} + +// watchScope polls until the session is confirmed empty, repeating KILL once +// Cancel's grace has passed so members forked meanwhile die too. A failed +// poll makes the scope Unknown and reports ObservationLost; polling and the +// KILL escalation go on, and the operation settles only once a poll confirms +// the session empty. +func (op *operation) watchScope() { + delay := scopePollFirst + for { + op.mu.Lock() + sig := unix.Signal(0) + if op.killing { + sig = unix.SIGKILL + } + op.mu.Unlock() + _, live, lost, _ := op.cu.sweep(func(procStat) bool { return true }, sig) + if lost == nil && live == 0 { + op.cu.close() + op.scopeClosed() + return + } + op.mu.Lock() + if lost != nil && op.scope == sp.ScopeStateActive { + op.scope = sp.ScopeStateUnknown + op.push(sp.ObservationLostEvent{EventHeader: op.header(), Observation: sp.ObservationScope, Failure: *sp.Fail(sp.CodeIO, sandboxwire.EffectPossible, "observe session: %v", lost)}) + } + op.mu.Unlock() + time.Sleep(delay) + delay = min(2*delay, scopePollMax) + } +} + +func (op *operation) scopeClosed() { + op.mu.Lock() + defer op.mu.Unlock() + if op.killTimer != nil { + op.killTimer.Stop() + } + op.scope = sp.ScopeStateClosed + op.push(sp.ScopeClosedEvent{EventHeader: op.header()}) + op.settleLocked() +} diff --git a/apps/sandboxio/internal/processservice/service.go b/apps/sandboxio/internal/processservice/service.go new file mode 100644 index 00000000..394a44ec --- /dev/null +++ b/apps/sandboxio/internal/processservice/service.go @@ -0,0 +1,352 @@ +//go:build linux + +// Package processservice is the Linux process service of the sandbox I/O +// service. It implements the process protocol (internal/sandboxprocess), +// starting each operation in a new POSIX session. +// +// The service binary's main calls Init first and runs Reap for the life of +// the process; see both. +package processservice + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "sync/atomic" + "time" + + "golang.org/x/sys/unix" + + sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// Config holds the service limits it advertises. +type Config struct { + MaxActiveOperations int + MaxOperationRecords int + MaxReplayBytesPerOperation int + OwnerLossGrace time.Duration + CancelGraceLimit time.Duration +} + +// DefaultConfig returns the standard limits. +func DefaultConfig() Config { + return Config{ + MaxActiveOperations: 256, + MaxOperationRecords: 65536, + MaxReplayBytesPerOperation: 8 << 20, + OwnerLossGrace: time.Minute, + CancelGraceLimit: 30 * time.Second, + } +} + +// signals are the signals the service delivers. +var signals = []sp.Signal{1, 2, 3, 9, 10, 12, 14, 15, 18, 19, 20, 21, 22, 28} // HUP INT QUIT KILL USR1 USR2 ALRM TERM CONT STOP TSTP TTIN TTOU WINCH + +// Service is one incarnation of the process service. Its operation records +// live in memory; a new Service has a new ServerInstanceID. +type Service struct { + instance sandboxwire.ID + caps sp.Capabilities + cfg Config + // stat reads a process's /proc stat; tests replace it. + stat func(pid int) (procStat, error) + // onExpire runs after an expired owner-loss grace has decided the + // cleanup, before the operations are cancelled; tests restore there. + onExpire func() + + mu sync.Mutex + ops map[opKey]*operation + owners map[sandboxwire.ID]*time.Timer + // stale holds attachments whose operations were cleaned up; they start + // nothing more until ownership is restored. + stale map[sandboxwire.ID]bool + active atomic.Int64 +} + +var _ sp.Service = (*Service)(nil) + +// ErrPidfdUnsupported reports a kernel without pidfd_open or +// pidfd_send_signal, which Linux 5.3 and later have. Signals, Cancel and +// ownership cleanup depend on them, so the service does not start without. +var ErrPidfdUnsupported = errors.New("processservice: pidfd_open and pidfd_send_signal are required (Linux 5.3 or later)") + +// New returns a service with a fresh incarnation. It fails with +// ErrPidfdUnsupported when the kernel lacks pidfds. +func New(cfg Config) (*Service, error) { + caps := sp.Capabilities{ + Platform: sp.PlatformLinux, + Scopes: []sp.Scope{sp.ScopePOSIXSession}, + IOModes: []sp.IOMode{sp.IOPipes, sp.IOPTY}, + Signals: signals, + SignalTargets: []sp.SignalTarget{sp.TargetLeader, sp.TargetInitialProcessGroup, sp.TargetPTYForegroundGroup, sp.TargetScope}, + PTYModes: supportedModes(), + MaxStartBytes: sandboxwire.MaxPayload, + MaxDataBytes: sandboxwire.MaxChunk, + MaxActiveOperations: uint32(cfg.MaxActiveOperations), + MaxOperationRecords: uint32(cfg.MaxOperationRecords), + MaxReplayBytesPerOperation: uint32(cfg.MaxReplayBytesPerOperation), + OwnerLossGraceMillis: uint32(cfg.OwnerLossGrace.Milliseconds()), + CancelGraceLimitMillis: uint32(cfg.CancelGraceLimit.Milliseconds()), + } + if cfg.MaxActiveOperations <= 0 || cfg.MaxOperationRecords < cfg.MaxActiveOperations || cfg.OwnerLossGrace <= 0 || cfg.CancelGraceLimit < 0 { + return nil, errors.New("processservice: invalid limits") + } + if err := caps.Validate(); err != nil { + return nil, err + } + if err := probePidfd(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrPidfdUnsupported, err) + } + return &Service{ + instance: sandboxwire.NewID(), caps: caps, cfg: cfg, stat: readStat, + ops: map[opKey]*operation{}, owners: map[sandboxwire.ID]*time.Timer{}, stale: map[sandboxwire.ID]bool{}, + }, nil +} + +// probePidfd opens a pidfd for the service's own process and sends it the +// null signal. +func probePidfd() error { + fd, err := unix.PidfdOpen(os.Getpid(), 0) + if err != nil { + return fmt.Errorf("pidfd_open: %w", err) + } + defer unix.Close(fd) + if err := unix.PidfdSendSignal(fd, 0, nil, 0); err != nil { + return fmt.Errorf("pidfd_send_signal: %w", err) + } + return nil +} + +func (s *Service) Describe(context.Context, *sp.Conn, sp.DescribeRequest) (sp.DescribeResponse, error) { + return sp.DescribeResponse{ServerInstanceID: s.instance, Capabilities: s.caps}, nil +} + +func (s *Service) checkInstance(ref sp.OperationRef) error { + if ref.ServerInstanceID != s.instance { + return sp.Fail(sp.CodeInstanceChanged, sandboxwire.EffectNone, "the service instance is %s", s.instance) + } + return nil +} + +func (s *Service) lookup(conn *sp.Conn, ref sp.OperationRef) (*operation, error) { + if err := s.checkInstance(ref); err != nil { + return nil, err + } + s.mu.Lock() + op := s.ops[opKey{conn.Attachment().ID, ref.OperationID}] + s.mu.Unlock() + if op == nil { + return nil, sp.Fail(sp.CodeNotFound, sandboxwire.EffectNone, "no operation %s", ref.OperationID) + } + return op, nil +} + +// Start reserves the ID, then launches once. The record is kept for the +// incarnation, so an ID is never launched twice. An attachment whose +// operations were cleaned up still finds its existing operations but launches +// nothing new until its ownership is restored. +func (s *Service) Start(_ context.Context, conn *sp.Conn, req sp.StartRequest) (sp.StartResponse, error) { + if err := s.checkInstance(req.OperationRef); err != nil { + return sp.StartResponse{}, err + } + key := opKey{conn.Attachment().ID, req.OperationID} + digest := req.Spec.Digest() + s.mu.Lock() + if op := s.ops[key]; op != nil { + s.mu.Unlock() + if op.digest != digest { + return sp.StartResponse{}, sp.Fail(sp.CodeOperationConflict, sandboxwire.EffectNone, "operation %s has a different spec", req.OperationID) + } + if op.inspect().Released { + return sp.StartResponse{}, released() + } + return sp.StartResponse{Disposition: sp.StartExisting}, nil + } + if s.stale[key.attachment] { + s.mu.Unlock() + return sp.StartResponse{}, sp.Fail(sp.CodeStaleAttachment, sandboxwire.EffectNone, "the attachment's ownership ended") + } + if f := s.caps.CheckStart(req.Spec); f != nil { + s.mu.Unlock() + return sp.StartResponse{}, f + } + if len(s.ops) >= s.cfg.MaxOperationRecords || s.active.Load() >= int64(s.cfg.MaxActiveOperations) { + s.mu.Unlock() + return sp.StartResponse{}, sp.Fail(sp.CodeResourceExhausted, sandboxwire.EffectNone, "operation capacity is exhausted") + } + op := newOperation(s, key, digest) + s.ops[key] = op + s.active.Add(1) + op.mu.Lock() + op.observeLocked(conn, 0) + op.mu.Unlock() + s.mu.Unlock() + op.launch(req.Spec) + return sp.StartResponse{Disposition: sp.StartCreated}, nil +} + +func (s *Service) Attach(_ context.Context, conn *sp.Conn, req sp.AttachRequest) (sp.AttachResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.AttachResponse{}, err + } + st, err := op.attach(conn, req.AfterSequence) + return sp.AttachResponse{Status: st}, err +} + +func (s *Service) Inspect(_ context.Context, conn *sp.Conn, req sp.InspectRequest) (sp.InspectResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.InspectResponse{}, err + } + return sp.InspectResponse{Status: op.inspect()}, nil +} + +func (s *Service) WriteStdin(ctx context.Context, conn *sp.Conn, req sp.WriteStdinRequest) (sp.WriteStdinResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.WriteStdinResponse{}, err + } + n, err := op.writeStdin(ctx, req.Offset, req.Data) + return sp.WriteStdinResponse{Accepted: n}, err +} + +func (s *Service) CloseStdin(_ context.Context, conn *sp.Conn, req sp.CloseStdinRequest) (sp.CloseStdinResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.CloseStdinResponse{}, err + } + return sp.CloseStdinResponse{}, op.closeStdin(req.Offset) +} + +func (s *Service) CloseOutput(_ context.Context, conn *sp.Conn, req sp.CloseOutputRequest) (sp.CloseOutputResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.CloseOutputResponse{}, err + } + return sp.CloseOutputResponse{}, op.closeOutput(req.Stream) +} + +func (s *Service) ResizePTY(_ context.Context, conn *sp.Conn, req sp.ResizePTYRequest) (sp.ResizePTYResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.ResizePTYResponse{}, err + } + return sp.ResizePTYResponse{}, op.resize(req.Size) +} + +func (s *Service) Signal(_ context.Context, conn *sp.Conn, req sp.SignalRequest) (sp.SignalResponse, error) { + if f := s.caps.CheckSignal(req.Signal, req.Target); f != nil { + return sp.SignalResponse{}, f + } + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.SignalResponse{}, err + } + return sp.SignalResponse{}, op.signal(req.Signal, req.Target) +} + +// Cancel caps the grace at the advertised limit. +func (s *Service) Cancel(_ context.Context, conn *sp.Conn, req sp.CancelRequest) (sp.CancelResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.CancelResponse{}, err + } + grace := time.Duration(min(req.GraceMillis, s.caps.CancelGraceLimitMillis)) * time.Millisecond + return sp.CancelResponse{}, op.cancel(grace) +} + +func (s *Service) AckEvents(_ context.Context, conn *sp.Conn, req sp.AckEventsRequest) (sp.AckEventsResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.AckEventsResponse{}, err + } + return sp.AckEventsResponse{}, op.ack(req.Sequence) +} + +func (s *Service) Release(_ context.Context, conn *sp.Conn, req sp.ReleaseRequest) (sp.ReleaseResponse, error) { + op, err := s.lookup(conn, req.OperationRef) + if err != nil { + return sp.ReleaseResponse{}, err + } + return sp.ReleaseResponse{}, op.release() +} + +// AttachmentLost starts the owner-loss grace for an attachment whose Link +// ownership lapsed. Losing a stream alone does not call it. +func (s *Service) AttachmentLost(id sandboxwire.ID) { + s.mu.Lock() + defer s.mu.Unlock() + if s.owners[id] != nil { + return + } + var t *time.Timer + t = time.AfterFunc(s.cfg.OwnerLossGrace, func() { + // The grace is current, the attachment is marked stale and its + // operations are collected in one critical section, so a restore + // either stops this grace first or comes after the cleanup. + s.mu.Lock() + var ops []*operation + if s.owners[id] == t { + delete(s.owners, id) + ops = s.staleLocked(id) + } + s.mu.Unlock() + if s.onExpire != nil { + s.onExpire() + } + s.cancelAll(ops) + }) + s.owners[id] = t +} + +// AttachmentRestored ends the grace: the attachment's operations continue, +// and it can start operations again. +func (s *Service) AttachmentRestored(id sandboxwire.ID) { + s.mu.Lock() + defer s.mu.Unlock() + s.stopGraceLocked(id) + delete(s.stale, id) +} + +// AttachmentRevoked cleans up an attachment's operations at once. +func (s *Service) AttachmentRevoked(id sandboxwire.ID) { + s.mu.Lock() + s.stopGraceLocked(id) + ops := s.staleLocked(id) + s.mu.Unlock() + s.cancelAll(ops) +} + +func (s *Service) stopGraceLocked(id sandboxwire.ID) { + if t := s.owners[id]; t != nil { + t.Stop() + delete(s.owners, id) + } +} + +// staleLocked marks the attachment stale and returns its operations for +// cleanup. Doing both in one critical section means every operation it +// started is either returned or never launched; one still starting is +// cancelled when its launch completes. +func (s *Service) staleLocked(id sandboxwire.ID) []*operation { + s.stale[id] = true + var ops []*operation + for key, op := range s.ops { + if key.attachment == id { + ops = append(ops, op) + } + } + return ops +} + +// cancelAll cancels the operations with the grace limit: TERM, then KILL. +func (s *Service) cancelAll(ops []*operation) { + for _, op := range ops { + op.cancel(s.cfg.CancelGraceLimit) + } +} diff --git a/apps/sandboxio/internal/processservice/service_test.go b/apps/sandboxio/internal/processservice/service_test.go new file mode 100644 index 00000000..49f93a20 --- /dev/null +++ b/apps/sandboxio/internal/processservice/service_test.go @@ -0,0 +1,825 @@ +//go:build linux + +package processservice + +import ( + "bytes" + "context" + "errors" + "io/fs" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "golang.org/x/sys/unix" + + sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// TestMain runs like the service binary: the trampoline hook first, then a +// child subreaper whose one reap loop owns every child exit. +func TestMain(m *testing.M) { + Init() + if err := unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0); err != nil { + panic(err) + } + ctx, cancel := context.WithCancel(context.Background()) + go Reap(ctx) + code := m.Run() + cancel() + os.Exit(code) +} + +const testPath = "/usr/bin:/bin" + +type harness struct { + t *testing.T + svc *Service + att sandboxwire.ID +} + +func newHarness(t *testing.T, cfg Config) *harness { + t.Helper() + svc, err := New(cfg) + if err != nil { + t.Fatal(err) + } + return &harness{t: t, svc: svc, att: sandboxwire.NewID()} +} + +// connect opens a stream for the harness attachment over net.Pipe. +func (h *harness) connect() *sp.Client { + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + sp.Serve(context.Background(), server, sp.Attachment{ID: h.att}, h.svc) + }() + c := sp.NewClient(client) + h.t.Cleanup(func() { + c.Close() + <-done + }) + return c +} + +func (h *harness) start(c *sp.Client, spec sp.ProcessSpec) *sp.Operation { + h.t.Helper() + op, disp, err := c.Start(context.Background(), h.svc.instance, sandboxwire.NewID(), spec) + if err != nil || disp != sp.StartCreated { + h.t.Fatalf("start: %v %v", disp, err) + } + return op +} + +func pipeSpec(argv ...string) sp.ProcessSpec { + spec := sp.ProcessSpec{Executable: []byte(argv[0]), Env: []sp.EnvVar{{Name: []byte("PATH"), Value: []byte(testPath)}}, Cwd: []byte("/"), Umask: 0o022, IOMode: sp.IOPipes, Scope: sp.ScopePOSIXSession} + for _, a := range argv { + spec.Argv = append(spec.Argv, []byte(a)) + } + return spec +} + +func ptySpec(argv ...string) sp.ProcessSpec { + spec := pipeSpec(argv...) + spec.IOMode = sp.IOPTY + spec.PTY = &sp.PTYSpec{Size: sp.WindowSize{Rows: 24, Cols: 80}, Term: []byte("xterm")} + return spec +} + +func next(t *testing.T, op *sp.Operation) sp.Event { + t.Helper() + select { + case ev, ok := <-op.Events(): + if !ok { + t.Fatal("events closed") + } + return ev + case <-time.After(10 * time.Second): + t.Fatal("no event") + } + return nil +} + +// events collects events until one of type until arrives. +func events(t *testing.T, op *sp.Operation, until uint16) []sp.Event { + t.Helper() + var got []sp.Event + for len(got) == 0 || got[len(got)-1].MessageType() != until { + got = append(got, next(t, op)) + } + return got +} + +func output(evs []sp.Event, stream sp.Stream) string { + var b strings.Builder + for _, ev := range evs { + if o, ok := ev.(sp.OutputEvent); ok && o.Stream == stream { + b.Write(o.Data) + } + } + return b.String() +} + +func find[E sp.Event](t *testing.T, evs []sp.Event) (E, int) { + t.Helper() + for i, ev := range evs { + if e, ok := ev.(E); ok { + return e, i + } + } + var zero E + t.Fatalf("no %T in %v", zero, evs) + return zero, -1 +} + +func wantCode(t *testing.T, err error, code sp.ErrorCode) { + t.Helper() + var f *sp.Failure + if !errors.As(err, &f) || f.Code != code { + t.Fatalf("got %v, want %v", err, code) + } +} + +// leader returns the operation's session ID, its leader's PID. +func (h *harness) leader(op *sp.Operation) int { + h.svc.mu.Lock() + defer h.svc.mu.Unlock() + return h.svc.ops[opKey{h.att, op.Ref().OperationID}].sid() +} + +// waitMembers waits until the operation's session has n live processes named +// comm. +func (h *harness) waitMembers(op *sp.Operation, comm string, n int) { + h.t.Helper() + sid := h.leader(op) + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + entries, _ := os.ReadDir("/proc") + count := 0 + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + st, err := readStat(pid) + if err != nil || st.session != sid || !st.live() { + continue + } + if b, err := os.ReadFile("/proc/" + e.Name() + "/comm"); err == nil && strings.TrimSpace(string(b)) == comm { + count++ + } + } + if count >= n { + return + } + } + h.t.Fatalf("session %d never had %d %s processes", sid, n, comm) +} + +func TestEnvironmentIsExplicit(t *testing.T) { + h := newHarness(t, DefaultConfig()) + spec := pipeSpec("env") + spec.Env = append(spec.Env, sp.EnvVar{Name: []byte("GREETING"), Value: []byte("hello world")}) + evs := events(t, h.start(h.connect(), spec), sp.EventOutputClosed) + if got, want := output(evs, sp.StreamStdout), "PATH="+testPath+"\nGREETING=hello world\n"; got != want { + t.Fatalf("environment %q, want %q", got, want) + } + if _, i := find[sp.StartedEvent](t, evs); i != 0 { + t.Fatalf("first event %v", evs[0]) + } +} + +func TestCwdUmaskArgv0(t *testing.T) { + h := newHarness(t, DefaultConfig()) + dir := t.TempDir() + spec := pipeSpec("sh", "-c", `umask; pwd; echo "$0"`) + spec.Argv[0] = []byte("renamed") + spec.Cwd, spec.Umask = []byte(dir), 0o027 + evs := events(t, h.start(h.connect(), spec), sp.EventOutputClosed) + if got, want := output(evs, sp.StreamStdout), "0027\n"+dir+"\nrenamed\n"; got != want { + t.Fatalf("output %q, want %q", got, want) + } +} + +func TestExitStatus(t *testing.T) { + h := newHarness(t, DefaultConfig()) + c := h.connect() + exited, _ := find[sp.ExitedEvent](t, events(t, h.start(c, pipeSpec("sh", "-c", "exit 7")), sp.EventExited)) + if exited.Status != (sp.ExitStatus{Kind: sp.ExitCode, Code: 7}) { + t.Fatalf("exit %+v", exited.Status) + } + exited, _ = find[sp.ExitedEvent](t, events(t, h.start(c, pipeSpec("sh", "-c", "kill -KILL $$")), sp.EventExited)) + if exited.Status != (sp.ExitStatus{Kind: sp.ExitSignal, Signal: 9}) { + t.Fatalf("exit %+v", exited.Status) + } +} + +func TestStartFailure(t *testing.T) { + h := newHarness(t, DefaultConfig()) + evs := events(t, h.start(h.connect(), pipeSpec("no-such-command")), sp.EventStartFailed) + if f := evs[0].(sp.StartFailedEvent).Failure; f.Code != sp.CodeNotFound || f.Effect != sandboxwire.EffectNone { + t.Fatalf("failure %+v", f) + } +} + +// A background job holding stderr keeps output open after the leader exits. +func TestExitedBeforeOutputClosed(t *testing.T) { + h := newHarness(t, DefaultConfig()) + evs := events(t, h.start(h.connect(), pipeSpec("sh", "-c", "sleep 1 >/dev/null & echo hi; exec sleep 0")), sp.EventScopeClosed) + _, exited := find[sp.ExitedEvent](t, evs) + closed, outputClosed := find[sp.OutputClosedEvent](t, evs) + if exited > outputClosed || closed.Disposition != sp.OutputDrained || output(evs, sp.StreamStdout) != "hi\n" { + t.Fatalf("events %v", evs) + } +} + +func TestStdinOffsetsAndHalfClose(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + a := h.start(h.connect(), pipeSpec("cat")) + if _, err := a.WriteStdin(ctx, []byte("ab")); err != nil { + t.Fatal(err) + } + b, st, err := h.connect().Attach(ctx, h.svc.instance, a.Ref().OperationID, 0) + if err != nil || st.StdinOffset != 2 { + t.Fatalf("attach: %+v %v", st, err) + } + if _, err := a.WriteStdin(ctx, []byte("cd")); err != nil { + t.Fatal(err) + } + _, err = b.WriteStdin(ctx, []byte("XX")) + wantCode(t, err, sp.CodeInputOffsetConflict) + if _, err := b.Inspect(ctx); err != nil { + t.Fatal(err) + } + if _, err := b.WriteStdin(ctx, []byte("ef")); err != nil { + t.Fatal(err) + } + for range 2 { // idempotent + if err := b.CloseStdin(ctx); err != nil { + t.Fatal(err) + } + } + evs := events(t, b, sp.EventOutputClosed) + if got := output(evs, sp.StreamStdout); got != "abcdef" { + t.Fatalf("output %q", got) + } + _, err = b.WriteStdin(ctx, []byte("g")) + wantCode(t, err, sp.CodeStdinClosed) +} + +func TestPTYResize(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + spec := ptySpec("sh", "-c", `echo "$TERM"; stty size; read line; stty size`) + spec.PTY.Modes = []sp.PTYModeValue{{Mode: sp.ModeECHO, Value: 0}} + op := h.start(h.connect(), spec) + var seen string + for !strings.Contains(seen, "24 80") { + seen += output(events(t, op, sp.EventOutput), sp.StreamTerminal) + } + if err := op.Resize(ctx, sp.WindowSize{Rows: 40, Cols: 100}); err != nil { + t.Fatal(err) + } + if _, err := op.WriteStdin(ctx, []byte("go\n")); err != nil { + t.Fatal(err) + } + seen += output(events(t, op, sp.EventOutputClosed), sp.StreamTerminal) + if !strings.Contains(seen, "xterm\r\n24 80\r\n40 100\r\n") || strings.Contains(seen, "go") { + t.Fatalf("terminal output %q", seen) + } + wantCode(t, op.CloseStdin(ctx), sp.CodeUnsupported) +} + +func TestSignalTargets(t *testing.T) { + h := newHarness(t, DefaultConfig()) + c := h.connect() + ctx := context.Background() + + t.Run("leader", func(t *testing.T) { + op := h.start(c, pipeSpec("sleep", "30")) + if err := op.Signal(ctx, 15, sp.TargetLeader); err != nil { + t.Fatal(err) + } + if e, _ := find[sp.ExitedEvent](t, events(t, op, sp.EventExited)); e.Status.Signal != 15 { + t.Fatalf("exit %+v", e.Status) + } + }) + t.Run("initial process group", func(t *testing.T) { + op := h.start(c, pipeSpec("sh", "-c", "sleep 30 & wait")) + h.waitMembers(op, "sleep", 1) + if err := op.Signal(ctx, 15, sp.TargetInitialProcessGroup); err != nil { + t.Fatal(err) + } + events(t, op, sp.EventScopeClosed) + }) + t.Run("PTY foreground group", func(t *testing.T) { + op := h.start(c, ptySpec("bash", "-c", "set -m; sleep 30; echo after $?")) + h.waitMembers(op, "sleep", 1) + if err := op.Signal(ctx, 15, sp.TargetPTYForegroundGroup); err != nil { + t.Fatal(err) + } + // The shell is outside the foreground job, so it outlives it. + evs := events(t, op, sp.EventOutputClosed) + if out := output(evs, sp.StreamTerminal); !strings.Contains(out, "after 143") { + t.Fatalf("output %q", out) + } + }) + t.Run("scope", func(t *testing.T) { + // Job control puts each sleep in its own process group. + op := h.start(c, ptySpec("bash", "-c", "set -m; sleep 30 & sleep 30")) + h.waitMembers(op, "sleep", 2) + if err := op.Signal(ctx, 15, sp.TargetScope); err != nil { + t.Fatal(err) + } + events(t, op, sp.EventScopeClosed) + }) + t.Run("undeclared", func(t *testing.T) { + op := h.start(c, pipeSpec("true")) + wantCode(t, op.Signal(ctx, 11, sp.TargetLeader), sp.CodeUnsupported) + }) +} + +func TestCancelEscalatesToKill(t *testing.T) { + h := newHarness(t, DefaultConfig()) + op := h.start(h.connect(), pipeSpec("sh", "-c", `trap "" TERM; sleep 30`)) + h.waitMembers(op, "sleep", 1) + if err := op.Cancel(context.Background(), 200); err != nil { + t.Fatal(err) + } + evs := events(t, op, sp.EventScopeClosed) + if e, _ := find[sp.ExitedEvent](t, evs); e.Status.Signal != 9 { + t.Fatalf("exit %+v", e.Status) + } +} + +func TestStartDeduplication(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + id, spec := sandboxwire.NewID(), pipeSpec("echo", "once") + if _, disp, err := h.connect().Start(ctx, h.svc.instance, id, spec); err != nil || disp != sp.StartCreated { + t.Fatalf("start: %v %v", disp, err) + } + op, disp, err := h.connect().Start(ctx, h.svc.instance, id, spec) + if err != nil || disp != sp.StartExisting { + t.Fatalf("repeat: %v %v", disp, err) + } + if got := output(events(t, op, sp.EventOutputClosed), sp.StreamStdout); got != "once\n" { + t.Fatalf("output %q", got) + } + _, _, err = h.connect().Start(ctx, h.svc.instance, id, pipeSpec("echo", "twice")) + wantCode(t, err, sp.CodeOperationConflict) +} + +func TestReleaseKeepsTombstone(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + c := h.connect() + + busy := h.start(c, pipeSpec("sleep", "30")) + wantCode(t, busy.Release(ctx), sp.CodeBusy) + busy.Cancel(ctx, 0) + + spec := pipeSpec("true") + op := h.start(c, spec) + events(t, op, sp.EventScopeClosed) + if err := op.Release(ctx); err != nil { + t.Fatal(err) + } + c2 := h.connect() + _, _, err := c2.Start(ctx, h.svc.instance, op.Ref().OperationID, spec) + wantCode(t, err, sp.CodeReleased) + _, _, err = c2.Attach(ctx, h.svc.instance, op.Ref().OperationID, 0) + wantCode(t, err, sp.CodeReleased) + h.svc.mu.Lock() + status := h.svc.ops[opKey{h.att, op.Ref().OperationID}].inspect() + h.svc.mu.Unlock() + if !status.Released || status.State != sp.StateExited || status.Exit.Code != 0 { + t.Fatalf("tombstone %+v", status) + } +} + +func TestAttachAfterAckIsReplayGap(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + op := h.start(h.connect(), pipeSpec("echo", "hi")) + evs := events(t, op, sp.EventOutputClosed) + last := evs[len(evs)-1].Header().Sequence + if err := op.Ack(ctx, last); err != nil { + t.Fatal(err) + } + c := h.connect() + _, _, err := c.Attach(ctx, h.svc.instance, op.Ref().OperationID, 0) + wantCode(t, err, sp.CodeReplayGap) + if _, st, err := c.Attach(ctx, h.svc.instance, op.Ref().OperationID, last); err != nil || st.FirstRetained != last+1 { + t.Fatalf("attach after ack: %+v %v", st, err) + } +} + +// Unacknowledged output stops reading, so the writer blocks and nothing drops. +func TestOutputWaitsForAcks(t *testing.T) { + cfg := DefaultConfig() + cfg.MaxReplayBytesPerOperation = sandboxwire.MaxChunk + h := newHarness(t, cfg) + ctx := context.Background() + const size = 300000 // more than the replay limit plus a pipe buffer + op := h.start(h.connect(), pipeSpec("head", "-c", strconv.Itoa(size), "/dev/zero")) + time.Sleep(200 * time.Millisecond) + if st, err := op.Inspect(ctx); err != nil || st.State != sp.StateRunning { + t.Fatalf("before acknowledging: %+v %v", st, err) + } + var total uint64 + var exit *sp.ExitStatus + for total < size || exit == nil { + switch ev := next(t, op).(type) { + case sp.OutputEvent: + if ev.Offset != total { + t.Fatalf("offset %d after %d bytes", ev.Offset, total) + } + total += uint64(len(ev.Data)) + if err := op.Ack(ctx, ev.Sequence); err != nil { + t.Fatal(err) + } + case sp.ExitedEvent: + exit = &ev.Status + } + } + if exit.Code != 0 || total != size { + t.Fatalf("exit %+v after %d bytes", exit, total) + } +} + +// Losing the stream alone never cancels; ownership loss does after the grace. +func TestOwnerLoss(t *testing.T) { + cfg := DefaultConfig() + cfg.OwnerLossGrace, cfg.CancelGraceLimit = 200*time.Millisecond, 200*time.Millisecond + h := newHarness(t, cfg) + ctx := context.Background() + + first := h.connect() + id := h.start(first, pipeSpec("sleep", "30")).Ref().OperationID + first.Close() + h.svc.AttachmentLost(h.att) + h.svc.AttachmentRestored(h.att) + time.Sleep(2 * cfg.OwnerLossGrace) + op, st, err := h.connect().Attach(ctx, h.svc.instance, id, 0) + if err != nil || st.State != sp.StateRunning { + t.Fatalf("after restore: %+v %v", st, err) + } + + h.svc.AttachmentLost(h.att) + if e, _ := find[sp.ExitedEvent](t, events(t, op, sp.EventExited)); e.Status.Signal != 15 { + t.Fatalf("exit %+v", e.Status) + } +} + +func TestInstanceChanged(t *testing.T) { + h := newHarness(t, DefaultConfig()) + _, _, err := h.connect().Start(context.Background(), sandboxwire.NewID(), sandboxwire.NewID(), pipeSpec("true")) + wantCode(t, err, sp.CodeInstanceChanged) +} + +func TestCloseOutput(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + op := h.start(h.connect(), pipeSpec("sh", "-c", "echo ready; read x; echo after")) + events(t, op, sp.EventOutput) + if err := op.CloseOutput(ctx, sp.StreamStdout); err != nil { + t.Fatal(err) + } + wantCode(t, op.CloseOutput(ctx, sp.StreamStdout), sp.CodeOutputClosed) + if _, err := op.WriteStdin(ctx, []byte("go\n")); err != nil { + t.Fatal(err) + } + // The writer sees a closed pipe. + evs := events(t, op, sp.EventScopeClosed) + for _, ev := range evs { + if c, ok := ev.(sp.StreamClosedEvent); ok && c.Stream == sp.StreamStdout && c.Disposition != sp.OutputAbandoned { + t.Fatalf("stdout closed %+v", c) + } + } + closed, _ := find[sp.OutputClosedEvent](t, evs) + exited, _ := find[sp.ExitedEvent](t, evs) + if closed.Disposition != sp.OutputAbandoned || exited.Status.Signal != 13 { + t.Fatalf("events %v", evs) + } +} + +func TestCapacity(t *testing.T) { + cfg := DefaultConfig() + cfg.MaxActiveOperations = 1 + h := newHarness(t, cfg) + ctx := context.Background() + c := h.connect() + op := h.start(c, pipeSpec("sleep", "30")) + _, _, err := c.Start(ctx, h.svc.instance, sandboxwire.NewID(), pipeSpec("true")) + wantCode(t, err, sp.CodeResourceExhausted) + op.Cancel(ctx, 0) +} + +// A failed scope poll reports ObservationLost, and the operation stays +// unsettled until a later poll confirms the scope empty. +func TestScopeObservationLost(t *testing.T) { + h := newHarness(t, DefaultConfig()) + var failing atomic.Bool + failing.Store(true) + h.svc.stat = func(pid int) (procStat, error) { + if failing.Load() { + return procStat{}, errors.New("injected") + } + return readStat(pid) + } + ctx := context.Background() + op := h.start(h.connect(), pipeSpec("true")) + for lost, closed := false, false; !lost || !closed; { + switch ev := next(t, op).(type) { + case sp.ObservationLostEvent: + if ev.Observation != sp.ObservationScope || ev.Failure.Effect != sandboxwire.EffectPossible { + t.Fatalf("observation lost %+v", ev) + } + lost = true + case sp.OutputClosedEvent: + closed = true + } + } + wantCode(t, op.Release(ctx), sp.CodeBusy) + failing.Store(false) + events(t, op, sp.EventScopeClosed) + if err := op.Release(ctx); err != nil { + t.Fatal(err) + } +} + +// Once the session leader exits, the terminal has no foreground group, and +// the signal must not reach process group 0, the service's own. +func TestForegroundGroupAfterLeaderExit(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + op := h.start(h.connect(), ptySpec("sh", "-c", `trap "" HUP; sleep 30 & exit 0`)) + events(t, op, sp.EventExited) + wantCode(t, op.Signal(ctx, 28, sp.TargetPTYForegroundGroup), sp.CodeNotRunning) + if err := op.Cancel(ctx, 0); err != nil { + t.Fatal(err) + } + events(t, op, sp.EventScopeClosed) +} + +// Revocation that finds an operation still starting cancels it when the +// launch completes, and the attachment starts nothing more. +func TestRevokeWhileStarting(t *testing.T) { + h := newHarness(t, DefaultConfig()) + spec := pipeSpec("sleep", "30") + key := opKey{h.att, sandboxwire.NewID()} + op := newOperation(h.svc, key, spec.Digest()) + h.svc.mu.Lock() + h.svc.ops[key] = op + h.svc.active.Add(1) + h.svc.mu.Unlock() + h.svc.AttachmentRevoked(h.att) + op.launch(spec) + for deadline := time.Now().Add(10 * time.Second); op.inspect().State != sp.StateExited; time.Sleep(10 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("the launched operation was not cancelled") + } + } + if exit := op.inspect().Exit; exit.Signal != 15 { + t.Fatalf("exit %+v", exit) + } + _, _, err := h.connect().Start(context.Background(), h.svc.instance, sandboxwire.NewID(), pipeSpec("true")) + wantCode(t, err, sp.CodeStaleAttachment) +} + +// An Ack from another stream cannot take events an accepted Attach promised. +func TestAckKeepsPromisedReplay(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + a := h.start(h.connect(), pipeSpec("echo", "hi")) + evs := events(t, a, sp.EventScopeClosed) + last := evs[len(evs)-1].Header().Sequence + + // b attaches from the start and reads only the response, so the service + // still holds its events when a acknowledges them all. + server, b := net.Pipe() + go sp.Serve(ctx, server, sp.Attachment{ID: h.att}, h.svc) + t.Cleanup(func() { b.Close() }) + b.SetDeadline(time.Now().Add(10 * time.Second)) + read := func() sp.Message { + t.Helper() + f, err := sandboxwire.ReadFrame(b, sandboxwire.MaxPayload) + if err != nil { + t.Fatal(err) + } + m, err := sp.Decode(f.Type, f.Payload) + if err != nil { + t.Fatal(err) + } + return m + } + req := sp.AttachRequest{OperationRef: a.Ref()} + if err := sandboxwire.WriteFrame(b, sandboxwire.Frame{Type: sp.OpAttach, RequestID: 1, Payload: sp.Encode(req)}); err != nil { + t.Fatal(err) + } + if m := read(); m.MessageType() != sandboxwire.ResponseType(sp.OpAttach) { + t.Fatalf("attach: %+v", m) + } else if _, ok := m.(sp.AttachResponse); !ok { + t.Fatalf("attach: %+v", m) + } + if err := a.Ack(ctx, last); err != nil { + t.Fatal(err) + } + for seq := uint64(1); seq <= last; seq++ { + if ev, ok := read().(sp.Event); !ok || ev.Header().Sequence != seq { + t.Fatalf("event %d: %v", seq, ev) + } + } +} + +// The reap loop reaps an orphan reparented to the subreaper and still +// delivers the leader's status to its operation. +func TestReapsOrphans(t *testing.T) { + h := newHarness(t, DefaultConfig()) + op := h.start(h.connect(), pipeSpec("sh", "-c", "sleep 1 >/dev/null 2>&1 & echo $!; exit 3")) + evs := events(t, op, sp.EventExited) + if e, _ := find[sp.ExitedEvent](t, evs); e.Status != (sp.ExitStatus{Kind: sp.ExitCode, Code: 3}) { + t.Fatalf("exit %+v", e.Status) + } + for !strings.Contains(output(evs, sp.StreamStdout), "\n") { + evs = append(evs, next(t, op)) + } + pid := strings.TrimSpace(output(evs, sp.StreamStdout)) + stat, err := os.ReadFile("/proc/" + pid + "/stat") + if err != nil { + t.Fatal(err) + } + if ppid := string(bytes.Fields(stat[bytes.LastIndexByte(stat, ')')+1:])[1]); ppid != strconv.Itoa(os.Getpid()) { + t.Fatalf("orphan %s has parent %s", pid, ppid) + } + events(t, op, sp.EventScopeClosed) + // ScopeClosed ignores zombies; the orphan must not stay one. + for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) { + if _, err := os.Stat("/proc/" + pid); errors.Is(err, fs.ErrNotExist) { + return + } + if time.Now().After(deadline) { + t.Fatalf("orphan %s was never reaped", pid) + } + } +} + +// Once the session is empty and its leader reaped, its ID can name a new +// session. A process showing the ID then is not signaled: nothing in custody +// proves it is in the operation's session. +func TestSignalsNeedCustody(t *testing.T) { + h := newHarness(t, DefaultConfig()) + ctx := context.Background() + c := h.connect() + victim := h.start(c, pipeSpec("sleep", "30")) + events(t, victim, sp.EventStarted) + vpid := h.leader(victim) + + // The victim's custody keeps the real stat; the operation's reads + // through the hook: first failing, then showing the victim in its session. + var blind atomic.Bool + var claim atomic.Int64 + blind.Store(true) + h.svc.stat = func(pid int) (procStat, error) { + if blind.Load() { + return procStat{}, errors.New("injected") + } + st, err := readStat(pid) + if sid := claim.Load(); sid != 0 && pid == vpid { + st.session = int(sid) + } + return st, err + } + op := h.start(c, pipeSpec("true")) + sid := h.leader(op) + for lost := false; !lost; { + _, lost = next(t, op).(sp.ObservationLostEvent) + } + claim.Store(int64(sid)) + blind.Store(false) + + wantCode(t, op.Signal(ctx, 9, sp.TargetScope), sp.CodeNotRunning) + wantCode(t, op.Signal(ctx, 9, sp.TargetInitialProcessGroup), sp.CodeNotRunning) + if err := op.Cancel(ctx, 0); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) // the KILL escalation polls meanwhile + if st, err := victim.Inspect(ctx); err != nil || st.State != sp.StateRunning { + t.Fatalf("victim %+v %v", st, err) + } + if st, err := op.Inspect(ctx); err != nil || st.Scope != sp.ScopeStateUnknown { + t.Fatalf("scope %+v %v", st, err) + } + claim.Store(0) + events(t, op, sp.EventScopeClosed) + if err := victim.Cancel(ctx, 0); err != nil { + t.Fatal(err) + } + events(t, victim, sp.EventScopeClosed) +} + +// A held process that leaves the session is signaled only if a refresh +// proved it still there; a failed refresh signals nothing. +func TestFailedRefreshSignalsNothing(t *testing.T) { + h := newHarness(t, DefaultConfig()) + var failing atomic.Bool + h.svc.stat = func(pid int) (procStat, error) { + if failing.Load() { + return procStat{}, errors.New("injected") + } + return readStat(pid) + } + ctx := context.Background() + fifo := filepath.Join(t.TempDir(), "fifo") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + op := h.start(h.connect(), pipeSpec("sh", "-c", `(read x <"$0"; exec setsid sleep 30) & exit 0`, fifo)) + sid := h.leader(op) + events(t, op, sp.EventExited) // the orphan joined custody before the leader was reaped + + orphan := 0 + entries, _ := os.ReadDir("/proc") + for _, e := range entries { + if pid, err := strconv.Atoi(e.Name()); err == nil { + if st, err := readStat(pid); err == nil && st.session == sid && st.live() { + orphan = pid + } + } + } + if orphan == 0 { + t.Fatal("no orphan in the session") + } + t.Cleanup(func() { unix.Kill(orphan, unix.SIGKILL) }) + failing.Store(true) + if err := os.WriteFile(fifo, []byte("\n"), 0); err != nil { + t.Fatal(err) + } + for i := 0; ; i++ { + if st, err := readStat(orphan); err == nil && st.session == orphan { + break + } else if i == 500 { + t.Fatalf("the orphan did not leave the session: %+v %v", st, err) + } + time.Sleep(10 * time.Millisecond) + } + + wantCode(t, op.Signal(ctx, 19, sp.TargetScope), sp.CodeIO) + time.Sleep(50 * time.Millisecond) + if st, err := readStat(orphan); err != nil || st.state == 'T' { + t.Fatalf("the escaped process was signaled: %+v %v", st, err) + } + failing.Store(false) + events(t, op, sp.EventScopeClosed) +} + +// A restore racing the grace expiry either stops it or follows the cleanup; +// it never ends with the restored attachment stale. +func TestRestoreRacesExpiry(t *testing.T) { + cfg := DefaultConfig() + cfg.OwnerLossGrace = time.Millisecond + h := newHarness(t, cfg) + restored := make(chan struct{}) + h.svc.onExpire = func() { + h.svc.AttachmentRestored(h.att) + close(restored) + } + h.svc.AttachmentLost(h.att) + <-restored + time.Sleep(20 * time.Millisecond) // lets a late cleanup land + h.svc.mu.Lock() + stale := h.svc.stale[h.att] + h.svc.mu.Unlock() + if stale { + t.Fatal("the restored attachment is stale") + } +} + +// A traced child's ptrace stop reaches the reaper even without WUNTRACED. It +// is not an exit: the leader stays registered and nothing is reported. +func TestReaperIgnoresStops(t *testing.T) { + op := newOperation(nil, opKey{}, [32]byte{}) + const pid = 1 << 30 // above any pid_max + register(pid, op) + registered := func() bool { + regMu.Lock() + defer regMu.Unlock() + return leaders[pid] == op + } + dispatch(pid, unix.WaitStatus(unix.SIGTRAP<<8|0x7f)) + if !registered() || op.leaderGone { + t.Fatal("a stop ended the registration") + } + dispatch(pid, unix.WaitStatus(3<<8)) + if registered() || !op.leaderGone || op.status.ExitStatus() != 3 { + t.Fatalf("exit not delivered: %v", op.status) + } +} diff --git a/apps/sandboxio/internal/processservice/termios.go b/apps/sandboxio/internal/processservice/termios.go new file mode 100644 index 00000000..7cb1a010 --- /dev/null +++ b/apps/sandboxio/internal/processservice/termios.go @@ -0,0 +1,140 @@ +//go:build linux + +package processservice + +import ( + "os" + "slices" + + "golang.org/x/sys/unix" + + sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" +) + +type termField uint8 + +const ( + fieldCC termField = iota + fieldIflag + fieldLflag + fieldOflag + fieldCsize + fieldCflag +) + +// termMode maps a supported terminal mode to termios: a control-character +// index, a flag bit, or a character size. +type termMode struct { + field termField + value uint32 +} + +// termModes are the RFC 4254 modes Linux implements. VDSUSP, VFLUSH, VSWTCH +// and VSTATUS have no Linux character, and a PTY has no line speed. +var termModes = map[sp.PTYMode]termMode{ + sp.ModeVINTR: {fieldCC, unix.VINTR}, + sp.ModeVQUIT: {fieldCC, unix.VQUIT}, + sp.ModeVERASE: {fieldCC, unix.VERASE}, + sp.ModeVKILL: {fieldCC, unix.VKILL}, + sp.ModeVEOF: {fieldCC, unix.VEOF}, + sp.ModeVEOL: {fieldCC, unix.VEOL}, + sp.ModeVEOL2: {fieldCC, unix.VEOL2}, + sp.ModeVSTART: {fieldCC, unix.VSTART}, + sp.ModeVSTOP: {fieldCC, unix.VSTOP}, + sp.ModeVSUSP: {fieldCC, unix.VSUSP}, + sp.ModeVREPRINT: {fieldCC, unix.VREPRINT}, + sp.ModeVWERASE: {fieldCC, unix.VWERASE}, + sp.ModeVLNEXT: {fieldCC, unix.VLNEXT}, + sp.ModeVDISCARD: {fieldCC, unix.VDISCARD}, + sp.ModeIGNPAR: {fieldIflag, unix.IGNPAR}, + sp.ModePARMRK: {fieldIflag, unix.PARMRK}, + sp.ModeINPCK: {fieldIflag, unix.INPCK}, + sp.ModeISTRIP: {fieldIflag, unix.ISTRIP}, + sp.ModeINLCR: {fieldIflag, unix.INLCR}, + sp.ModeIGNCR: {fieldIflag, unix.IGNCR}, + sp.ModeICRNL: {fieldIflag, unix.ICRNL}, + sp.ModeIUCLC: {fieldIflag, unix.IUCLC}, + sp.ModeIXON: {fieldIflag, unix.IXON}, + sp.ModeIXANY: {fieldIflag, unix.IXANY}, + sp.ModeIXOFF: {fieldIflag, unix.IXOFF}, + sp.ModeIMAXBEL: {fieldIflag, unix.IMAXBEL}, + sp.ModeIUTF8: {fieldIflag, unix.IUTF8}, + sp.ModeISIG: {fieldLflag, unix.ISIG}, + sp.ModeICANON: {fieldLflag, unix.ICANON}, + sp.ModeXCASE: {fieldLflag, unix.XCASE}, + sp.ModeECHO: {fieldLflag, unix.ECHO}, + sp.ModeECHOE: {fieldLflag, unix.ECHOE}, + sp.ModeECHOK: {fieldLflag, unix.ECHOK}, + sp.ModeECHONL: {fieldLflag, unix.ECHONL}, + sp.ModeNOFLSH: {fieldLflag, unix.NOFLSH}, + sp.ModeTOSTOP: {fieldLflag, unix.TOSTOP}, + sp.ModeIEXTEN: {fieldLflag, unix.IEXTEN}, + sp.ModeECHOCTL: {fieldLflag, unix.ECHOCTL}, + sp.ModeECHOKE: {fieldLflag, unix.ECHOKE}, + sp.ModePENDIN: {fieldLflag, unix.PENDIN}, + sp.ModeOPOST: {fieldOflag, unix.OPOST}, + sp.ModeOLCUC: {fieldOflag, unix.OLCUC}, + sp.ModeONLCR: {fieldOflag, unix.ONLCR}, + sp.ModeOCRNL: {fieldOflag, unix.OCRNL}, + sp.ModeONOCR: {fieldOflag, unix.ONOCR}, + sp.ModeONLRET: {fieldOflag, unix.ONLRET}, + sp.ModeCS7: {fieldCsize, unix.CS7}, + sp.ModeCS8: {fieldCsize, unix.CS8}, + sp.ModePARENB: {fieldCflag, unix.PARENB}, + sp.ModePARODD: {fieldCflag, unix.PARODD}, +} + +func supportedModes() []sp.PTYMode { + modes := make([]sp.PTYMode, 0, len(termModes)) + for m := range termModes { + modes = append(modes, m) + } + slices.Sort(modes) + return modes +} + +// applyModes sets the requested modes on the terminal. A character value of +// 255 disables the character. CS7 or CS8 set to 1 selects that character +// size; set to 0 it leaves the size unchanged. +func applyModes(tty *os.File, modes []sp.PTYModeValue) error { + fd := int(tty.Fd()) + t, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return err + } + for _, mv := range modes { + m := termModes[mv.Mode] + switch m.field { + case fieldCC: + c := uint8(mv.Value) + if mv.Value == sp.DisabledChar { + c = 0 // _POSIX_VDISABLE + } + t.Cc[m.value] = c + case fieldIflag: + t.Iflag = setFlag(t.Iflag, m.value, mv.Value) + case fieldLflag: + t.Lflag = setFlag(t.Lflag, m.value, mv.Value) + case fieldOflag: + t.Oflag = setFlag(t.Oflag, m.value, mv.Value) + case fieldCflag: + t.Cflag = setFlag(t.Cflag, m.value, mv.Value) + case fieldCsize: + if mv.Value == 1 { + t.Cflag = t.Cflag&^unix.CSIZE | m.value + } + } + } + return unix.IoctlSetTermios(fd, unix.TCSETS, t) +} + +func setFlag(flags, bit, value uint32) uint32 { + if value == 1 { + return flags | bit + } + return flags &^ bit +} + +func winsize(s sp.WindowSize) *unix.Winsize { + return &unix.Winsize{Row: s.Rows, Col: s.Cols, Xpixel: s.XPixels, Ypixel: s.YPixels} +} diff --git a/apps/sandboxio/internal/processservice/trampoline.go b/apps/sandboxio/internal/processservice/trampoline.go new file mode 100644 index 00000000..ebf5194b --- /dev/null +++ b/apps/sandboxio/internal/processservice/trampoline.go @@ -0,0 +1,229 @@ +//go:build linux + +package processservice + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "os/signal" + "runtime" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/unix" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// os/exec cannot set a child's umask, so every launch re-executes the service +// binary as a trampoline. It reads the launch from an inherited descriptor, +// applies the umask and working directory, and execs the target. The marker +// is its whole environment and argv; it is not a command-line interface. +const ( + trampolineArg0 = "oac-process-trampoline" + trampolineEnv = "OAC_PROCESS_TRAMPOLINE=1" + // Descriptors the trampoline inherits: the launch, and a close-on-exec + // status pipe that reaches EOF without data exactly when exec succeeds. + launchFD = 3 + statusFD = 4 +) + +// Launch failure stages reported on the status pipe. +const ( + stageLaunch uint16 = iota + 1 + stageDescriptors + stageChdir + stageExec +) + +// Init runs the trampoline when the process is one, and never returns then. +// Otherwise it returns at once. The service binary's main calls it first. +func Init() { + if len(os.Args) != 1 || os.Args[0] != trampolineArg0 || len(os.Environ()) != 1 || os.Environ()[0] != trampolineEnv { + return + } + runtime.LockOSThread() + stage, err := trampoline() + status := binary.BigEndian.AppendUint16(nil, stage) + status = binary.BigEndian.AppendUint32(status, uint32(errnoOf(err))) + unix.Write(statusFD, status) + os.Exit(127) +} + +func errnoOf(err error) syscall.Errno { + var errno syscall.Errno + if errors.As(err, &errno) { + return errno + } + return syscall.EINVAL +} + +func trampoline() (uint16, error) { + unix.CloseOnExec(statusFD) + var buf bytes.Buffer + chunk := make([]byte, 64<<10) + for { + n, err := unix.Read(launchFD, chunk) + if err == unix.EINTR { + continue + } + if err != nil { + return stageLaunch, err + } + if n == 0 { + break + } + buf.Write(chunk[:n]) + } + unix.Close(launchFD) + l, err := decodeLaunch(buf.Bytes()) + if err != nil { + return stageLaunch, syscall.EINVAL + } + if err := closeInherited(); err != nil { + return stageDescriptors, err + } + unix.Umask(int(l.umask)) + if err := unix.Chdir(l.cwd); err != nil { + return stageChdir, err + } + // The Go runtime keeps SIGHUP and SIGINT ignored when it starts with them + // ignored. Handling them resets both to the default at exec. + signal.Notify(make(chan os.Signal, 1), syscall.SIGHUP, syscall.SIGINT) + var none unix.Sigset_t + unix.PthreadSigmask(unix.SIG_SETMASK, &none, nil) + return stageExec, execTarget(l) +} + +// closeInherited makes exec close every descriptor from 3 up, so the target +// gets only 0, 1 and 2: fork copies any descriptor the service holds without +// FD_CLOEXEC. Closing them now instead could close the runtime's own +// descriptors under it before exec. +func closeInherited() error { + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return err + } + for _, e := range entries { + if fd, err := strconv.Atoi(e.Name()); err == nil && fd > 2 { + unix.CloseOnExec(fd) + } + } + return nil +} + +// execTarget execs the executable, searching PATH for a name without '/' the +// way execvpe does. +func execTarget(l launch) error { + if strings.Contains(l.executable, "/") { + return unix.Exec(l.executable, l.argv, l.env) + } + if !l.hasPath { + return unix.ENOENT + } + denied := false + for _, dir := range strings.Split(l.path, ":") { + if dir == "" { + dir = "." + } + err := unix.Exec(dir+"/"+l.executable, l.argv, l.env) + switch err { + case unix.EACCES: + denied = true + case unix.ENOENT, unix.ENOTDIR, unix.ESTALE, unix.ENODEV, unix.ETIMEDOUT: + default: + return err + } + } + if denied { + return unix.EACCES + } + return unix.ENOENT +} + +// launch is what the trampoline needs from a ProcessSpec. +type launch struct { + executable string + argv []string + env []string + cwd string + umask uint32 + hasPath bool + path string +} + +func encodeLaunch(spec sandboxprocess.ProcessSpec) []byte { + var e sandboxwire.Encoder + e.Bytes(spec.Executable) + e.Count(len(spec.Argv)) + for _, a := range spec.Argv { + e.Bytes(a) + } + env := make([][]byte, 0, len(spec.Env)+1) + var path []byte + for _, v := range spec.Env { + env = append(env, bytes.Join([][]byte{v.Name, v.Value}, []byte("="))) + if string(v.Name) == "PATH" { + path = v.Value + } + } + if spec.PTY != nil { + env = append(env, append([]byte("TERM="), spec.PTY.Term...)) + } + e.Count(len(env)) + for _, v := range env { + e.Bytes(v) + } + e.Bytes(spec.Cwd) + e.U32(spec.Umask) + e.Present(path != nil) + if path != nil { + e.Bytes(path) + } + return e.Payload() +} + +func decodeLaunch(b []byte) (launch, error) { + d := sandboxwire.NewDecoder(b) + var l launch + var err error + str := func() string { + var v []byte + if err == nil { + v, err = d.Bytes() + } + return string(v) + } + list := func() []string { + var n int + if err == nil { + n, err = d.Count(sandboxwire.MaxPayload) + } + out := make([]string, n) + for i := range out { + out[i] = str() + } + return out + } + l.executable = str() + l.argv = list() + l.env = list() + l.cwd = str() + if err == nil { + l.umask, err = d.U32() + } + if err == nil { + l.hasPath, err = d.Present() + } + if l.hasPath { + l.path = str() + } + if err == nil { + err = d.Finish() + } + return l, err +} diff --git a/docs/development.md b/docs/development.md index c9d86154..db1595bf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,6 +67,7 @@ For frontend development, run `pnpm dev:web` using the fixture or Core connectio | `internal/sandboxlink` | Link protocol, peer libraries and relay core | [Sandbox link protocol](sandbox-link-protocol.md) | | `internal/sandboxbootstrap` | Provider-to-Sandbox I/O service startup input | [Sandbox bootstrap](sandbox-bootstrap.md) | | `internal/sandboxfs` | Runtime–file service wire types, validators, client and server | [File access protocol](file-access-protocol.md) | +| `internal/sandboxprocess` | Runtime–process service wire types, validators, client and server | [Process protocol](process-protocol.md) | | `apps/sandboxio` | Sandbox I/O service and its Linux protocol services | [File access protocol](file-access-protocol.md#the-linux-service) | | `apps/daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](../contracts/agents-api/harness-onboarding.md#required-adapter-interfaces) | | `apps/daemon/internal/agent` | Native harness adapters | [Native references](../contracts/agents-api/harness-onboarding.md#native-references) | diff --git a/docs/process-protocol.md b/docs/process-protocol.md new file mode 100644 index 00000000..1ba06e20 --- /dev/null +++ b/docs/process-protocol.md @@ -0,0 +1,197 @@ +# Process protocol + +The process protocol is how the agent host starts and controls processes in a sandbox. The Sandbox I/O service in the sandbox serves it, and the agent host's broker is its client. It launches a process from an explicit spec, streams its output as ordered events, accepts stdin at exact offsets, and reports the leader's exit, the end of output and the end of the process scope as separate facts. + +[`internal/sandboxprocess/protocol.go`](../internal/sandboxprocess/protocol.go) is the authored definition: message tags, payload layouts, validators and the `Service` interface. The same package has the generic client and server. [`apps/sandboxio/internal/processservice`](../apps/sandboxio/internal/processservice) is the Linux service. Frames use the shared [framing](sandbox-link-protocol.md#framing), and the Link layer supplies the authenticated attachment of each stream. + +## Streams and operations + +- A stream belongs to one attachment. No request names an attachment, an OS user or a credential; the stream's attachment scopes every operation ID it uses. +- A request's RequestID follows the [request ID rule](sandbox-link-protocol.md#framing), and its response carries the same RequestID. Requests on one stream run concurrently, so responses can arrive in any order. A service may refuse a request beyond its concurrency limit with `Busy` and `EffectNone`. +- An operation is one launch, named by an `OperationID` the client chooses. Its record lives in one service incarnation, named by `ServerInstanceID`. +- Events are frames with RequestID 0. Each carries its `OperationID` and a `Sequence` that starts at 1 and grows by one per event of that operation. +- An operation has one observer: the stream that started it, or the stream of its latest `Attach`. A later `Attach` moves the observer. The response to the `Start` or `Attach` that subscribes a stream arrives before any event it subscribes. + +## Implement a client + +The Go client is `sandboxprocess.NewClient(stream)`. `Start` and `Attach` return an `Operation` handle whose `Events()` channel delivers that operation's events once each, in sequence order; when `Start` finds an existing operation, the client attaches from sequence 0. The handle offers `WriteStdin`, `CloseStdin`, `CloseOutput`, `Resize`, `Signal`, `Cancel`, `Ack`, `Inspect` and `Release`. + +1. Call `Describe`. Keep `ServerInstanceID`, and check each spec and signal against the `Capabilities` before sending it. The Go client keeps the capabilities and splits stdin writes into chunks of `MaxDataBytes`. +2. Choose a new `OperationID` for each launch. After a `Start` failure with `EffectPossible`, repeat `Start` with the same ID and spec: `Existing` means the launch already happened. Never choose a new ID for work whose launch is uncertain. +3. Process events, then `Ack` the last processed sequence so the service can reclaim its replay buffer. After a stream loss, `Attach` with the last processed sequence on a new stream. +4. Write stdin at the offset the service has accepted. After a stdin failure with `EffectPossible`, `Inspect` to learn the accepted offset before deciding what to resend. The client never retries a stdin write or a signal on its own. +5. On `InstanceChanged` the service restarted. The operations of the old incarnation are unknown, and their IDs cannot be reused to find out. + +## Implement a service + +Implement `sandboxprocess.Service` and serve each stream with `sandboxprocess.Serve(ctx, stream, attachment, service)`. `Serve` decodes and validates requests, answers a malformed payload with `InvalidArgument`, ends the stream on a framing violation, and returns a method's `*Failure` as the typed failure. It runs up to 64 requests of a stream at once and answers each request beyond that with `Busy` without running it; it never stops reading, and it ends the stream when another 64 refusals are waiting to be written. A method sends events with `Conn.Send`, which blocks while the peer is not reading. + +A service must: + +- generate a new `ServerInstanceID` whenever its operation records are lost, and answer requests for any other incarnation with `InstanceChanged`; +- advertise only what it enforces, and reject anything else in a spec or signal request with `Unsupported`; +- keep every operation record for the whole incarnation, as described in [Deduplication and tombstones](#deduplication-and-tombstones); +- never drop an event it has not been told was delivered, as described in [Output, replay and flow control](#output-replay-and-flow-control); +- treat the loss of a stream as nothing more than the loss of an observer, as described in [Ownership](#ownership). + +The Linux service calls `processservice.Init()` first in the binary's `main`. Go cannot set a child's umask, so each launch re-executes the service binary as a trampoline that reads the launch from an inherited descriptor, marks every inherited descriptor above 2 close-on-exec, applies the umask and working directory, and execs the target. `Init` runs that trampoline and returns at once in a normal start. The Linux service launches every operation in a new session with `setsid`, observes the session through `/proc`, and advertises `ScopePOSIXSession` only. It requires `pidfd_open` and `pidfd_send_signal` (Linux 5.3 or later): without them `processservice.New` fails with `ErrPidfdUnsupported`. + +Before serving, `main` makes the process a child subreaper (`prctl(PR_SET_CHILD_SUBREAPER)`) and runs `processservice.Reap(ctx)` for the life of the process. `Reap` is the process's only `wait`: it reaps every child, delivers each leader's exit to its operation, and reaps the orphaned descendants the subreaper inherits. Nothing else in the binary may wait for children, and no operation observes an exit while `Reap` is not running. + +## Reference + +### Requests + +Every request except `Describe` begins with `ServerInstanceID` and `OperationID`. A response begins with a success or failure discriminator; a failure carries a [`Failure`](#errors). + +| Tag | Request | Fields | Response | Meaning | +| --- | --- | --- | --- | --- | +| 1 | `Describe` | – | `ServerInstanceID`, `Capabilities` | The incarnation, capabilities, limits and cleanup policy | +| 2 | `Start` | `Spec` | `Disposition` (`Created` or `Existing`) | Reserve the ID, validate the spec, then launch once. `Created` subscribes this stream. `Existing` changes nothing; `Attach` observes the operation | +| 3 | `Attach` | `AfterSequence` | `Status` | Observe from the event after `AfterSequence`. Never launches | +| 4 | `Inspect` | – | `Status` | The current record | +| 5 | `WriteStdin` | `Offset`, `Data` | `Accepted` | Write at the current stdin offset; return the bytes accepted | +| 6 | `CloseStdin` | `Offset` | – | Close pipe stdin after `Offset` accepted bytes. Idempotent at the same offset. `Unsupported` for a PTY | +| 7 | `CloseOutput` | `Stream` | – | Close the read side of one stream. The writer sees the native pipe or PTY behavior, and the stream ends `Abandoned` | +| 8 | `ResizePTY` | `Rows`, `Cols`, `XPixels`, `YPixels` | – | Set the terminal size; the foreground job gets `SIGWINCH` | +| 9 | `Signal` | `Signal`, `Target` | – | Deliver a declared signal to a declared target | +| 10 | `Cancel` | `GraceMillis` | – | Send TERM to the scope, then KILL after the grace, capped at `CancelGraceLimitMillis`. Completion arrives as events. During `Starting` the cancel is kept and applied as soon as the process launches | +| 11 | `AckEvents` | `Sequence` | – | Acknowledge events through `Sequence`, releasing them from the replay buffer | +| 12 | `Release` | – | – | Release a settled operation's resources and keep its tombstone. `Busy` for an unsettled operation | + +`Status` holds `State`, `Exit` (present in state `Exited`), `StartFailure` (present in state `StartFailed`), `StdinOffset`, `StdinClosed`, `Output` (the disposition, present once every stream has closed), `Scope`, `Released`, and the retained event range `FirstRetained` through `LastSequence`, empty when `FirstRetained` is greater. + +| Enum | Values | +| --- | --- | +| `OperationState` | `Starting`, `Running`, `Exited`, `StartFailed`, `Unknown` (the exit could not be observed) | +| `ScopeState` | `Active`, `Closed`, `Unknown` (the scope could not be observed; the service keeps trying, and `Closed` can still follow) | +| `OutputDisposition` | `Drained` (end of file), `Abandoned` (after `CloseOutput`), `Lost` (a read failed) | + +An operation is settled when it failed to start, or when its exit was observed or lost, all its output has closed, and its scope is `Closed`. While the scope is `Unknown` the operation is not settled, so `Release` returns `Busy` until the service confirms the scope closed. `Release` never turns an `Unknown` state into a confirmed result. + +### Events + +| Tag | Event | Fields | Meaning | +| --- | --- | --- | --- | +| `0x4001` | `Started` | – | The launch succeeded | +| `0x4002` | `StartFailed` | `Failure` | The launch failed. No other event follows, and the record is kept. The effect is `EffectNone` only when the target was provably never executed | +| `0x4003` | `Output` | `Stream`, `Offset`, `Data` | Output bytes. `Stream` is `Stdout`, `Stderr` or `Terminal` | +| `0x4004` | `StreamClosed` | `Stream`, `Offset`, `Disposition` | One stream ended at its final offset | +| `0x4005` | `Exited` | `ExitCode`, or `ExitSignal` and `CoreDumped` | The leader's wait result | +| `0x4006` | `OutputClosed` | `Disposition` | Every captured stream closed. The disposition is the worst of the streams': `Lost`, then `Abandoned`, then `Drained` | +| `0x4007` | `ScopeClosed` | – | The operation's scope is empty | +| `0x4008` | `ObservationLost` | `Observation` (`Exit` or `Scope`), `Failure` | A required observation became unavailable. The matching state becomes `Unknown`, and the effect is `EffectPossible`: processes may still run. A lost scope observation can recover, and `ScopeClosed` then follows | + +Ordering: + +- `Started` or `StartFailed` comes first. +- `Exited` and `OutputClosed` are independent. A background process holding a stream open keeps `OutputClosed` pending after `Exited`, and output can close before the leader exits. +- `OutputClosed` follows every output byte the service will deliver, and each `StreamClosed`. +- Output of different streams has no order relative to each other or to changes in the file system. + +### Process spec + +A launch uses only the spec; the service's own environment, directory and umask never reach the process. + +| Field | Rule | +| --- | --- | +| `Executable` | NUL-free bytes. A name without `/` is looked up in the spec's `PATH` entry like `execvpe`, and a spec without `PATH` cannot use one. A relative path with `/` resolves against `Cwd`. A missing file fails the launch with `NotFound` | +| `Argv` | The complete argv, including argv[0], unchanged. At least one entry | +| `Env` | The complete environment: unique names without `=` or NUL, values without NUL | +| `Cwd` | An absolute path | +| `Umask` | At most `0o777` | +| `IOMode` | `IOPipes` or `IOPTY`. Only descriptors 0, 1 and 2 are connected | +| `PTY` | Present exactly for `IOPTY`: the size, `Term` and the terminal modes | +| `Scope` | `ScopePOSIXSession` or `ScopeCgroupV2` | + +Launch failures map the OS error: a missing file or directory is `NotFound`, a permission error is `Unauthorized`, an unexecutable file is `InvalidArgument`, a resource limit is `ResourceExhausted`, and anything else is `IO`. + +### Deduplication and tombstones + +- Operation IDs are scoped to `(AttachmentID, ServerInstanceID, OperationID)`. +- `Start` reserves the ID before launching and keeps the SHA-256 digest of the encoded spec. The same ID with the same spec returns `Existing`, including for concurrent requests; a different spec returns `OperationConflict`. +- Records last for the whole incarnation. `Release` keeps a tombstone with the digest, the state and the results; a `Start` for a released ID returns `Released`. +- Records are never evicted. When `MaxOperationRecords` or `MaxActiveOperations` is reached, `Start` fails with `ResourceExhausted`. + +### Stdin offsets + +The stdin offset counts the bytes the service has accepted, from 0. `WriteStdin` and `CloseStdin` succeed only at the current offset; any other offset returns `InputOffsetConflict`, so an old or overlapping write never reinjects bytes. `Accepted` can be less than the data sent. After the process closes its stdin, writes return `StdinClosed`. + +### Output, replay and flow control + +- The service retains each operation's events until they are acknowledged, and delivers them to the observer in order. +- Unacknowledged `Output` data of all the operation's streams together is limited to `MaxReplayBytesPerOperation`. At the limit the service stops reading the process's output, so the process blocks on its own writes. Nothing is discarded. +- A slow observer holds back its stream: the service writes events only as fast as the peer reads them. +- `Attach` resumes after any sequence in the retained range. A request for acknowledged events returns `ReplayGap`, so missing output is never skipped silently. The events an accepted `Attach` promised stay retained until they are sent, even when another stream acknowledges them first. + +### PTY + +`PTY` sets the size in rows, columns and pixels, the `TERM` value (the environment must not set `TERM` itself) and terminal modes as RFC 4254 §8 opcode and value pairs, plus `IUTF8` (42) from RFC 8160. A control-character mode takes the character, and 255 disables it; a flag mode takes 0 or 1. Requested modes must appear in `Capabilities.PTYModes`. Only the terminal semantics come from SSH, not its transport. + +With a PTY, output arrives as the `Terminal` stream, stdin writes are terminal input, and there is no stdin half-close: to signal end of input, write the terminal's EOF character, `^D` by default. The terminal output ends `Drained` when every process has closed the terminal. `CloseOutput` on the `Terminal` stream closes the terminal, which hangs up its session. + +### Scope and signals + +Both scopes start the process in a new POSIX session. `ScopeCgroupV2` also places it in a new cgroup and is advertised only when the service enforces that. A descendant can leave a `ScopePOSIXSession` scope by calling `setsid`; that is the scope's limit. The Linux service observes a session scope by polling `/proc` for live members, so `ScopeClosed` is best-effort within that limit. + +A request never names a process ID. `Signal` takes a signal number from `Capabilities.Signals` and one of these targets: + +| Target | Receives the signal | +| --- | --- | +| `TargetLeader` | The launched process | +| `TargetInitialProcessGroup` | The leader's process group, which the session created | +| `TargetPTYForegroundGroup` | The terminal's foreground process group. `InvalidArgument` without a PTY | +| `TargetScope` | Every process in the scope: the session's members, or the cgroup | + +A target with no process returns `NotRunning`, and so does every target after `ScopeClosed` and a `TargetPTYForegroundGroup` whose terminal has no foreground group. + +A signal reaches only a process the service can prove is in the operation's session, never one that reused a PID or a session ID. While the leader is unreaped, its PID pins the session and initial process group IDs, and the Linux service signals the leader or that group by ID. Otherwise it signals through the pidfds it holds for the session's processes, and only those a refresh in the same request proved still in the session; when that refresh fails, it signals nothing more and the request fails with `IO`. It takes a pidfd for a process showing the session ID only when a process it already holds stayed in the session across that read, and it updates the held set before reaping any held process. When it holds no process but a live process still shows the session ID, it cannot tell the session from a new one with the same ID: it signals nothing, the target returns `NotRunning`, and the scope becomes `Unknown` with `ObservationLost`. + +### Ownership + +Losing a stream only loses the observer; the operation continues and any stream of the same attachment can `Attach` to it. When the Link layer reports that the attachment's ownership lapsed, the service waits `OwnerLossGraceMillis`. If ownership returns within the grace, nothing happens. When the grace expires or ownership is revoked, the service cancels each of the attachment's live operations with TERM, then KILL after `CancelGraceLimitMillis`; an operation still starting is cancelled as soon as it launches. From then on a `Start` of a new operation on that attachment returns `StaleAttachment` until ownership returns. + +### Capabilities + +| Field | Meaning | +| --- | --- | +| `Platform` | `PlatformLinux`: signal numbers and terminal semantics are Linux's | +| `Scopes`, `IOModes`, `Signals`, `SignalTargets`, `PTYModes` | What a spec or signal request may use | +| `MaxStartBytes` | The largest encoded `Start` payload | +| `MaxDataBytes` | The largest stdin write and output chunk, at most 64 KiB | +| `MaxActiveOperations` | Operations not yet settled | +| `MaxOperationRecords` | All records of the incarnation, tombstones included | +| `MaxReplayBytesPerOperation` | Unacknowledged output retained per operation | +| `OwnerLossGraceMillis` | How long operations survive lapsed ownership | +| `CancelGraceLimitMillis` | The longest `Cancel` grace, and the grace of ownership cleanup | + +Deduplication, replay, pipe half-close and separate exit and output completion are protocol semantics that every service implements, not capabilities. + +### Errors + +A failure carries a `Code`, an `Effect` and a message. `EffectNone` means the request had no effect; `EffectPossible` means it may have. The client reports a transport loss after sending a request as `IO` with `EffectPossible`. When the caller's context ends before the Go client writes a request, the failure is `Cancelled` or `DeadlineExceeded` with `EffectNone`; when it ends during the write, the client closes the stream and the failure has `EffectPossible`; when it ends after the write, the stream stays open and the failure has `EffectPossible`. A cancellation that races the end of a write may still close the stream, and the requests in flight on it then fail with `EffectPossible`. + +| Code | Meaning | +| --- | --- | +| `InvalidArgument` | The request breaks a rule, such as an event sequence beyond the last one | +| `Unsupported` | The request uses something the capabilities do not declare | +| `Unauthorized` | The OS denied the launch | +| `StaleAttachment` | The attachment is no longer valid, or its operations were cleaned up and ownership has not returned | +| `InstanceChanged` | The request names another incarnation | +| `NotFound` | No such operation, or the executable or directory is missing | +| `OperationConflict` | The ID is in use with a different spec | +| `Released` | The operation was released | +| `ReplayGap` | The requested events were acknowledged and are gone | +| `InputOffsetConflict` | The stdin offset is not the current one | +| `StdinClosed` | Stdin is closed | +| `OutputClosed` | The stream or terminal is already closed | +| `NotRunning` | The target has no process, or the operation is still starting | +| `Busy` | The operation has not settled, or the stream already runs as many requests as the service allows | +| `ResourceExhausted` | A declared limit or an OS resource is exhausted | +| `DeadlineExceeded`, `Cancelled` | The caller's deadline passed or it gave up | +| `IO` | The service or transport failed | +| `Unknown` | Any other failure | + +## Verification + +`go test ./internal/sandboxprocess` checks the golden frames in `internal/sandboxprocess/testdata`, and `go test -fuzz FuzzDecode ./internal/sandboxprocess` fuzzes the decoder. `go test ./apps/sandboxio/internal/processservice` runs the Linux service over in-memory streams with real processes. diff --git a/go.mod b/go.mod index cec1fb92..083c9460 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.8 require ( github.com/containerd/errdefs v1.0.0 + github.com/creack/pty v1.1.24 github.com/go-chi/chi/v5 v5.3.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 diff --git a/go.sum b/go.sum index 7ad0841d..2c0ae7b2 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/sandboxprocess/client.go b/internal/sandboxprocess/client.go new file mode 100644 index 00000000..072caf76 --- /dev/null +++ b/internal/sandboxprocess/client.go @@ -0,0 +1,572 @@ +package sandboxprocess + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// Client is the caller side of one stream. +type Client struct { + rw io.ReadWriteCloser + // writing holds the right to write a frame; waiting for it honors the + // caller's context. + writing chan struct{} + + // seq allocates request IDs; it is used only while holding writing. + seq sandboxwire.RequestSequence + + mu sync.Mutex + // issued is the last request ID allocated, so a response to an ID never + // sent is told from one whose caller gave up. + issued uint64 + pending map[uint64]*call + ops map[sandboxwire.ID]*Operation + caps *Capabilities + err error + done chan struct{} + + afterWrite func() // test seam: runs once a frame is recorded as written +} + +type call struct { + request uint16 + resp chan Message + // onResponse runs in the read loop before any later frame is read, so a + // handle is subscribed before the operation's first event arrives. + onResponse func(Message) +} + +// ErrClientClosed is the cause after Close. +var ErrClientClosed = errors.New("sandboxprocess: client closed") + +// NewClient starts reading rw. Close, or the end of rw, stops the client. +func NewClient(rw io.ReadWriteCloser) *Client { + c := &Client{rw: rw, writing: make(chan struct{}, 1), pending: map[uint64]*call{}, ops: map[sandboxwire.ID]*Operation{}, done: make(chan struct{})} + go c.readLoop() + return c +} + +// Close ends the stream. Operations keep running on the service. +func (c *Client) Close() error { + c.fail(ErrClientClosed) + <-c.done + return nil +} + +// Done is closed when the stream has ended. +func (c *Client) Done() <-chan struct{} { return c.done } + +// Err returns why the stream ended. +func (c *Client) Err() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.err +} + +func (c *Client) fail(err error) { + c.mu.Lock() + if c.err == nil { + c.err = err + } + c.mu.Unlock() + c.rw.Close() +} + +func (c *Client) readLoop() { + defer func() { + c.mu.Lock() + ops := c.ops + c.ops = map[sandboxwire.ID]*Operation{} + c.mu.Unlock() + for _, op := range ops { + op.finish(false) + } + close(c.done) + }() + for { + f, err := sandboxwire.ReadFrame(c.rw, sandboxwire.MaxPayload) + if err == nil { + err = c.handle(f) + } + if err != nil { + c.fail(err) + return + } + } +} + +func (c *Client) handle(f sandboxwire.Frame) error { + m, err := Decode(f.Type, f.Payload) + if err != nil { + return err + } + if ev, ok := m.(Event); ok { + if f.RequestID != 0 { + return fmt.Errorf("%w: event with request %d", ErrProtocol, f.RequestID) + } + c.mu.Lock() + op := c.ops[ev.Header().OperationID] + c.mu.Unlock() + if op != nil { + op.deliver(ev) + } + return nil + } + if sandboxwire.IsResponse(f.Type) { + c.mu.Lock() + cl, sent := c.pending[f.RequestID], f.RequestID != 0 && f.RequestID <= c.issued + delete(c.pending, f.RequestID) + c.mu.Unlock() + if !sent { + return fmt.Errorf("%w: response to unknown request %d", ErrProtocol, f.RequestID) + } + if cl == nil { + return nil // its caller gave up waiting + } + if f.Type != sandboxwire.ResponseType(cl.request) { + return fmt.Errorf("%w: response %#04x to request %#04x", ErrProtocol, f.Type, cl.request) + } + if cl.onResponse != nil { + cl.onResponse(m) + } + cl.resp <- m + return nil + } + return fmt.Errorf("%w: request frame %#04x from server", ErrProtocol, f.Type) +} + +// do sends req and waits for its response. A failure is returned as *Failure. +// When ctx ends before the request is written, the failure has EffectNone. +// When it ends while the frame is being written, the frame may be partial, so +// the stream is closed and the failure has EffectPossible. When it ends after +// the frame is written, the stream stays open and the failure has +// EffectPossible. +func (c *Client) do(ctx context.Context, req Message, onResponse func(Message)) (Message, error) { + select { + case c.writing <- struct{}{}: + case <-ctx.Done(): + return nil, contextFailure(ctx, sandboxwire.EffectNone) + case <-c.done: + return nil, Fail(CodeIO, sandboxwire.EffectNone, "stream ended: %v", c.Err()) + } + if ctx.Err() != nil { + <-c.writing + return nil, contextFailure(ctx, sandboxwire.EffectNone) + } + // The ID is allocated while holding the right to write, so IDs increase + // in wire order as the request ID rule requires. + cl := &call{request: req.MessageType(), resp: make(chan Message, 1), onResponse: onResponse} + c.mu.Lock() + if c.err != nil { + c.mu.Unlock() + <-c.writing + return nil, Fail(CodeIO, sandboxwire.EffectNone, "stream ended: %v", c.err) + } + id := c.seq.Next() + c.issued = id + c.pending[id] = cl + c.mu.Unlock() + forget := func() { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + } + // Whichever of the write and the cancellation finishes first decides: only + // a cancellation that interrupts the write closes the stream. Any callback + // already running settles before the right to write is released. + const writing, written, interrupted = 0, 1, 2 + var state atomic.Int32 + settled := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + defer close(settled) + if state.CompareAndSwap(writing, interrupted) { + c.fail(fmt.Errorf("sandboxprocess: request %d interrupted while being written: %w", id, context.Cause(ctx))) + } + }) + err := sandboxwire.WriteFrame(c.rw, sandboxwire.Frame{Type: req.MessageType(), RequestID: id, Payload: Encode(req)}) + state.CompareAndSwap(writing, written) + if c.afterWrite != nil { + c.afterWrite() + } + if !stop() { + <-settled + } + <-c.writing + if state.Load() == interrupted { + forget() + return nil, contextFailure(ctx, sandboxwire.EffectPossible) + } + if err != nil { + c.fail(err) + } + + var m Message + select { + case m = <-cl.resp: + case <-ctx.Done(): + case <-c.done: + } + if m == nil { + // A response that arrived with the cancellation still counts. + select { + case m = <-cl.resp: + default: + forget() + if ctx.Err() != nil { + return nil, contextFailure(ctx, sandboxwire.EffectPossible) + } + return nil, Fail(CodeIO, sandboxwire.EffectPossible, "stream ended: %v", c.Err()) + } + } + if rf, ok := m.(ResponseFailure); ok { + f := rf.Failure + return nil, &f + } + return m, nil +} + +func contextFailure(ctx context.Context, effect sandboxwire.Effect) *Failure { + code := CodeCancelled + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + code = CodeDeadlineExceeded + } + return Fail(code, effect, "%v", ctx.Err()) +} + +// Describe returns the service incarnation and capabilities. The client keeps +// the capabilities; WriteStdin chunks by their MaxDataBytes. +func (c *Client) Describe(ctx context.Context) (DescribeResponse, error) { + m, err := c.do(ctx, DescribeRequest{}, nil) + if err != nil { + return DescribeResponse{}, err + } + r := m.(DescribeResponse) + c.mu.Lock() + c.caps = &r.Capabilities + c.mu.Unlock() + return r, nil +} + +// capabilities returns the kept capabilities, describing the service first +// when the client has not. +func (c *Client) capabilities(ctx context.Context) (Capabilities, error) { + c.mu.Lock() + caps := c.caps + c.mu.Unlock() + if caps != nil { + return *caps, nil + } + r, err := c.Describe(ctx) + return r.Capabilities, err +} + +// Start starts the operation, or finds the existing one with the same ID and +// spec. Either way the handle observes events from sequence 1; for an existing +// operation whose early events were acknowledged, Start fails with +// CodeReplayGap and Attach resumes from a later sequence. +func (c *Client) Start(ctx context.Context, instance, id sandboxwire.ID, spec ProcessSpec) (*Operation, StartDisposition, error) { + op, err := c.register(OperationRef{ServerInstanceID: instance, OperationID: id}) + if err != nil { + return nil, 0, err + } + m, err := c.do(ctx, StartRequest{OperationRef: op.ref, Spec: spec}, func(m Message) { + if r, ok := m.(StartResponse); ok && r.Disposition == StartCreated { + op.activate(0) + } + }) + if err != nil { + c.unregister(op) + return nil, 0, err + } + disp := m.(StartResponse).Disposition + if disp == StartExisting { + if _, err := op.attach(ctx, 0); err != nil { + c.unregister(op) + return nil, disp, err + } + } + return op, disp, nil +} + +// Attach observes an existing operation from the event after afterSequence. +func (c *Client) Attach(ctx context.Context, instance, id sandboxwire.ID, afterSequence uint64) (*Operation, OperationStatus, error) { + op, err := c.register(OperationRef{ServerInstanceID: instance, OperationID: id}) + if err != nil { + return nil, OperationStatus{}, err + } + st, err := op.attach(ctx, afterSequence) + if err != nil { + c.unregister(op) + return nil, OperationStatus{}, err + } + return op, st, nil +} + +func (c *Client) register(ref OperationRef) (*Operation, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.err != nil { + return nil, Fail(CodeIO, sandboxwire.EffectNone, "stream ended: %v", c.err) + } + if c.ops[ref.OperationID] != nil { + return nil, Fail(CodeInvalidArgument, sandboxwire.EffectNone, "operation %s already has a handle on this stream", ref.OperationID) + } + op := &Operation{c: c, ref: ref, events: make(chan Event), wake: make(chan struct{}, 1), stop: make(chan struct{}), stdin: make(chan struct{}, 1)} + c.ops[ref.OperationID] = op + go op.forward() + return op, nil +} + +func (c *Client) unregister(op *Operation) { + c.mu.Lock() + if c.ops[op.ref.OperationID] == op { + delete(c.ops, op.ref.OperationID) + } + c.mu.Unlock() + op.finish(true) +} + +// Operation is a handle to one operation observed on a Client's stream. +type Operation struct { + c *Client + ref OperationRef + + events chan Event + wake chan struct{} + stop chan struct{} + + mu sync.Mutex + active bool + next uint64 + queue []Event + finished bool + stopped bool + + // stdin is held while the stdin offset is used or refreshed; waiting for + // it honors the caller's context. The offset changes only under it. + stdin chan struct{} + stdinOffset atomic.Uint64 +} + +// Ref returns the operation's address. +func (op *Operation) Ref() OperationRef { return op.ref } + +// Events delivers the operation's events in sequence order, each once. It is +// closed after Release, Detach or the end of the stream; the queue it drains +// holds at most the service's unacknowledged replay window. +func (op *Operation) Events() <-chan Event { return op.events } + +// StdinOffset is the next stdin offset WriteStdin uses. +func (op *Operation) StdinOffset() uint64 { return op.stdinOffset.Load() } + +// lockStdin takes the stdin offset until the returned release, or fails with +// EffectNone when ctx or the stream ends first. +func (op *Operation) lockStdin(ctx context.Context) (release func(), err error) { + select { + case op.stdin <- struct{}{}: + return func() { <-op.stdin }, nil + case <-ctx.Done(): + return nil, contextFailure(ctx, sandboxwire.EffectNone) + case <-op.c.done: + return nil, Fail(CodeIO, sandboxwire.EffectNone, "stream ended: %v", op.c.Err()) + } +} + +func (op *Operation) activate(after uint64) { + op.mu.Lock() + op.active, op.next = true, after+1 + op.mu.Unlock() +} + +// deliver queues ev if it is the next expected event. Events from an earlier +// subscription on this stream repeat or skip sequences and are dropped. +func (op *Operation) deliver(ev Event) { + op.mu.Lock() + ok := op.active && !op.finished && ev.Header().Sequence == op.next + if ok { + op.next++ + op.queue = append(op.queue, ev) + } + op.mu.Unlock() + if ok { + op.signal() + } +} + +func (op *Operation) signal() { + select { + case op.wake <- struct{}{}: + default: + } +} + +// finish stops delivery. With discard it drops queued events; otherwise they +// drain before Events closes. +func (op *Operation) finish(discard bool) { + op.mu.Lock() + op.finished = true + if discard && !op.stopped { + op.stopped = true + close(op.stop) + } + op.mu.Unlock() + op.signal() +} + +func (op *Operation) forward() { + defer close(op.events) + for { + op.mu.Lock() + var ev Event + if len(op.queue) > 0 { + ev = op.queue[0] + op.queue[0] = nil + op.queue = op.queue[1:] + } + finished := op.finished + op.mu.Unlock() + if ev == nil { + if finished { + return + } + select { + case <-op.wake: + case <-op.stop: + return + } + continue + } + select { + case op.events <- ev: + case <-op.stop: + return + } + } +} + +func (op *Operation) attach(ctx context.Context, after uint64) (OperationStatus, error) { + release, err := op.lockStdin(ctx) + if err != nil { + return OperationStatus{}, err + } + defer release() + m, err := op.c.do(ctx, AttachRequest{OperationRef: op.ref, AfterSequence: after}, func(m Message) { + if _, ok := m.(AttachResponse); ok { + op.activate(after) + } + }) + if err != nil { + return OperationStatus{}, err + } + st := m.(AttachResponse).Status + op.stdinOffset.Store(st.StdinOffset) + return st, nil +} + +// Inspect returns the operation's status and refreshes the stdin offset. It +// waits for a WriteStdin or CloseStdin in progress, so the offset it stores is +// never older than theirs. +func (op *Operation) Inspect(ctx context.Context) (OperationStatus, error) { + release, err := op.lockStdin(ctx) + if err != nil { + return OperationStatus{}, err + } + defer release() + m, err := op.c.do(ctx, InspectRequest{op.ref}, nil) + if err != nil { + return OperationStatus{}, err + } + st := m.(InspectResponse).Status + op.stdinOffset.Store(st.StdinOffset) + return st, nil +} + +// WriteStdin writes data at the tracked offset, in chunks of the service's +// MaxDataBytes, advancing the offset by each accepted count. It returns the +// bytes accepted. After a failure with EffectPossible the offset is uncertain: +// Inspect refreshes it, and the caller decides what to resend. +func (op *Operation) WriteStdin(ctx context.Context, data []byte) (int, error) { + caps, err := op.c.capabilities(ctx) + if err != nil { + return 0, err + } + release, err := op.lockStdin(ctx) + if err != nil { + return 0, err + } + defer release() + written := 0 + for written < len(data) { + chunk := data[written:min(len(data), written+int(caps.MaxDataBytes))] + m, err := op.c.do(ctx, WriteStdinRequest{OperationRef: op.ref, Offset: op.stdinOffset.Load(), Data: chunk}, nil) + if err != nil { + return written, err + } + n := int(m.(WriteStdinResponse).Accepted) + if n > len(chunk) { + return written, Fail(CodeUnknown, sandboxwire.EffectPossible, "service accepted %d of %d bytes", n, len(chunk)) + } + written += n + op.stdinOffset.Add(uint64(n)) + } + return written, nil +} + +// CloseStdin closes pipe stdin after the bytes accepted so far. +func (op *Operation) CloseStdin(ctx context.Context) error { + release, err := op.lockStdin(ctx) + if err != nil { + return err + } + defer release() + _, err = op.c.do(ctx, CloseStdinRequest{OperationRef: op.ref, Offset: op.stdinOffset.Load()}, nil) + return err +} + +// CloseOutput closes the read side of stream. +func (op *Operation) CloseOutput(ctx context.Context, stream Stream) error { + _, err := op.c.do(ctx, CloseOutputRequest{OperationRef: op.ref, Stream: stream}, nil) + return err +} + +// Resize sets the PTY size. +func (op *Operation) Resize(ctx context.Context, size WindowSize) error { + _, err := op.c.do(ctx, ResizePTYRequest{OperationRef: op.ref, Size: size}, nil) + return err +} + +// Signal delivers sig to target. +func (op *Operation) Signal(ctx context.Context, sig Signal, target SignalTarget) error { + _, err := op.c.do(ctx, SignalRequest{OperationRef: op.ref, Signal: sig, Target: target}, nil) + return err +} + +// Cancel sends TERM to the scope, then KILL after grace milliseconds. +func (op *Operation) Cancel(ctx context.Context, graceMillis uint32) error { + _, err := op.c.do(ctx, CancelRequest{OperationRef: op.ref, GraceMillis: graceMillis}, nil) + return err +} + +// Ack acknowledges events through sequence, letting the service reclaim them. +func (op *Operation) Ack(ctx context.Context, sequence uint64) error { + _, err := op.c.do(ctx, AckEventsRequest{OperationRef: op.ref, Sequence: sequence}, nil) + return err +} + +// Release releases the settled operation and closes the handle. +func (op *Operation) Release(ctx context.Context) error { + if _, err := op.c.do(ctx, ReleaseRequest{op.ref}, nil); err != nil { + return err + } + op.c.unregister(op) + return nil +} + +// Detach closes the handle without affecting the operation. +func (op *Operation) Detach() { op.c.unregister(op) } diff --git a/internal/sandboxprocess/client_test.go b/internal/sandboxprocess/client_test.go new file mode 100644 index 00000000..dab3f9a0 --- /dev/null +++ b/internal/sandboxprocess/client_test.go @@ -0,0 +1,44 @@ +package sandboxprocess + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// A cancellation that arrives after the frame is written cancels the request +// and leaves the stream open. +func TestCancelAfterWriteKeepsStream(t *testing.T) { + cc, sc := net.Pipe() + defer sc.Close() + go func() { + caps := Capabilities{Platform: PlatformLinux, MaxStartBytes: 1024, MaxDataBytes: 1024, MaxReplayBytesPerOperation: 1024} + for { + f, err := sandboxwire.ReadFrame(sc, sandboxwire.MaxPayload) + if err != nil { + return + } + resp := DescribeResponse{ServerInstanceID: fixtureInstance, Capabilities: caps} + if sandboxwire.WriteFrame(sc, sandboxwire.Frame{Type: resp.MessageType(), RequestID: f.RequestID, Payload: Encode(resp)}) != nil { + return + } + } + }() + c := NewClient(cc) + defer c.Close() + + ctx, cancel := context.WithCancel(context.Background()) + c.afterWrite = cancel + _, err := c.Describe(ctx) + var f *Failure + if err != nil && !(errors.As(err, &f) && f.Code == CodeCancelled && f.Effect == sandboxwire.EffectPossible) { + t.Fatalf("cancelled describe: %v", err) + } + c.afterWrite = nil + if _, err := c.Describe(context.Background()); err != nil || c.Err() != nil { + t.Fatalf("describe after the cancellation: %v; stream: %v", err, c.Err()) + } +} diff --git a/internal/sandboxprocess/protocol.go b/internal/sandboxprocess/protocol.go new file mode 100644 index 00000000..2274138f --- /dev/null +++ b/internal/sandboxprocess/protocol.go @@ -0,0 +1,1328 @@ +// Package sandboxprocess is the Runtime–process service protocol: the one +// authored definition of its messages, payload layouts, validators and Service +// interface, plus a generic client and server over one stream. +// +// docs/process-protocol.md describes the protocol. Framing and primitive +// encoding come from internal/sandboxwire. +package sandboxprocess + +import ( + "context" + "crypto/sha256" + "fmt" + "math" + "slices" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// Version is the protocol version. Peers match it exactly. +const Version = 1 + +// Request tags. The response to a request uses the request tag | 0x8000. +const ( + OpDescribe uint16 = iota + 1 + OpStart + OpAttach + OpInspect + OpWriteStdin + OpCloseStdin + OpCloseOutput + OpResizePTY + OpSignal + OpCancel + OpAckEvents + OpRelease +) + +// Event tags, numbered from sandboxwire.FirstEvent in this order. +const ( + EventStarted uint16 = sandboxwire.FirstEvent + iota + EventStartFailed + EventOutput + EventStreamClosed + EventExited + EventOutputClosed + EventScopeClosed + EventObservationLost +) + +var tags = sandboxwire.Tags{Requests: OpRelease, Events: EventObservationLost - sandboxwire.FirstEvent + 1} + +const ( + // MaxFailureMessageBytes bounds Failure.Message. + MaxFailureMessageBytes = 4096 + // MaxTermBytes bounds PTYSpec.Term. + MaxTermBytes = 256 + // maxListEntries bounds every capability list. + maxListEntries = 256 + // maxSpecEntries bounds Argv and Env; the payload limit binds first. + maxSpecEntries = sandboxwire.MaxPayload / 4 +) + +// Enums are uint16 on the wire; zero is never valid. + +// Platform is the operating system whose semantics a service implements. +type Platform uint16 + +const PlatformLinux Platform = 1 + +func (v Platform) Valid() bool { return v == PlatformLinux } + +// Scope is the containment an operation's processes are started in. Both +// scopes start a new POSIX session; ScopeCgroupV2 also enforces cgroup +// containment. +type Scope uint16 + +const ( + ScopePOSIXSession Scope = iota + 1 + ScopeCgroupV2 +) + +func (v Scope) Valid() bool { return v >= ScopePOSIXSession && v <= ScopeCgroupV2 } + +// IOMode selects how descriptors 0, 1 and 2 are connected. +type IOMode uint16 + +const ( + IOPipes IOMode = iota + 1 + IOPTY +) + +func (v IOMode) Valid() bool { return v == IOPipes || v == IOPTY } + +// Stream is a captured output stream. A PTY merges output into StreamTerminal. +type Stream uint16 + +const ( + StreamStdout Stream = iota + 1 + StreamStderr + StreamTerminal +) + +func (v Stream) Valid() bool { return v >= StreamStdout && v <= StreamTerminal } + +// SignalTarget selects which processes of an operation receive a signal. A +// request never names a process ID. +type SignalTarget uint16 + +const ( + TargetLeader SignalTarget = iota + 1 + TargetInitialProcessGroup + TargetPTYForegroundGroup + TargetScope +) + +func (v SignalTarget) Valid() bool { return v >= TargetLeader && v <= TargetScope } + +// Signal is a Linux signal number under PlatformLinux. +type Signal uint16 + +func (v Signal) Valid() bool { return v >= 1 && v <= 64 } + +// StartDisposition says whether Start created the operation or found it. +type StartDisposition uint16 + +const ( + StartCreated StartDisposition = iota + 1 + StartExisting +) + +func (v StartDisposition) Valid() bool { return v == StartCreated || v == StartExisting } + +// OperationState is an operation's launch and exit state. +type OperationState uint16 + +const ( + StateStarting OperationState = iota + 1 + StateRunning + StateExited + StateStartFailed + // StateUnknown means the exit could not be observed. + StateUnknown +) + +func (v OperationState) Valid() bool { return v >= StateStarting && v <= StateUnknown } + +// ScopeState says whether an operation's scope still has members. +type ScopeState uint16 + +const ( + ScopeStateActive ScopeState = iota + 1 + ScopeStateClosed + ScopeStateUnknown +) + +func (v ScopeState) Valid() bool { return v >= ScopeStateActive && v <= ScopeStateUnknown } + +// OutputDisposition says how an output stream ended. +type OutputDisposition uint16 + +const ( + // OutputDrained: the stream reached end of file. + OutputDrained OutputDisposition = iota + 1 + // OutputAbandoned: CloseOutput closed the read side. + OutputAbandoned + // OutputLost: reading failed. + OutputLost +) + +func (v OutputDisposition) Valid() bool { return v >= OutputDrained && v <= OutputLost } + +// ExitKind discriminates ExitStatus. +type ExitKind uint16 + +const ( + ExitCode ExitKind = iota + 1 + ExitSignal +) + +func (v ExitKind) Valid() bool { return v == ExitCode || v == ExitSignal } + +// Observation names what ObservationLost reports as unavailable. +type Observation uint16 + +const ( + ObservationExit Observation = iota + 1 + ObservationScope +) + +func (v Observation) Valid() bool { return v == ObservationExit || v == ObservationScope } + +// ErrorCode is the typed outcome of a failed request. +type ErrorCode uint16 + +const ( + CodeInvalidArgument ErrorCode = iota + 1 + CodeUnsupported + CodeUnauthorized + CodeStaleAttachment + CodeInstanceChanged + CodeNotFound + CodeOperationConflict + CodeReleased + CodeReplayGap + CodeInputOffsetConflict + CodeStdinClosed + CodeOutputClosed + CodeNotRunning + CodeBusy + CodeResourceExhausted + CodeDeadlineExceeded + CodeCancelled + CodeIO + CodeUnknown +) + +var codeNames = [...]string{"", "InvalidArgument", "Unsupported", "Unauthorized", "StaleAttachment", "InstanceChanged", "NotFound", "OperationConflict", "Released", "ReplayGap", "InputOffsetConflict", "StdinClosed", "OutputClosed", "NotRunning", "Busy", "ResourceExhausted", "DeadlineExceeded", "Cancelled", "IO", "Unknown"} + +func (v ErrorCode) Valid() bool { return v >= CodeInvalidArgument && v <= CodeUnknown } + +func (v ErrorCode) String() string { + if v.Valid() { + return codeNames[v] + } + return fmt.Sprintf("ErrorCode(%d)", uint16(v)) +} + +// PTYMode is a terminal mode opcode from RFC 4254 §8, plus IUTF8 from RFC +// 8160. Opcodes 1–18 set a control character: the value is the character and +// 255 disables it. Opcodes 30–93 set a flag: the value is 0 or 1. The speed +// opcodes carry a baud rate. +type PTYMode uint16 + +const ( + ModeVINTR PTYMode = 1 + ModeVQUIT PTYMode = 2 + ModeVERASE PTYMode = 3 + ModeVKILL PTYMode = 4 + ModeVEOF PTYMode = 5 + ModeVEOL PTYMode = 6 + ModeVEOL2 PTYMode = 7 + ModeVSTART PTYMode = 8 + ModeVSTOP PTYMode = 9 + ModeVSUSP PTYMode = 10 + ModeVDSUSP PTYMode = 11 + ModeVREPRINT PTYMode = 12 + ModeVWERASE PTYMode = 13 + ModeVLNEXT PTYMode = 14 + ModeVFLUSH PTYMode = 15 + ModeVSWTCH PTYMode = 16 + ModeVSTATUS PTYMode = 17 + ModeVDISCARD PTYMode = 18 + ModeIGNPAR PTYMode = 30 + ModePARMRK PTYMode = 31 + ModeINPCK PTYMode = 32 + ModeISTRIP PTYMode = 33 + ModeINLCR PTYMode = 34 + ModeIGNCR PTYMode = 35 + ModeICRNL PTYMode = 36 + ModeIUCLC PTYMode = 37 + ModeIXON PTYMode = 38 + ModeIXANY PTYMode = 39 + ModeIXOFF PTYMode = 40 + ModeIMAXBEL PTYMode = 41 + ModeIUTF8 PTYMode = 42 + ModeISIG PTYMode = 50 + ModeICANON PTYMode = 51 + ModeXCASE PTYMode = 52 + ModeECHO PTYMode = 53 + ModeECHOE PTYMode = 54 + ModeECHOK PTYMode = 55 + ModeECHONL PTYMode = 56 + ModeNOFLSH PTYMode = 57 + ModeTOSTOP PTYMode = 58 + ModeIEXTEN PTYMode = 59 + ModeECHOCTL PTYMode = 60 + ModeECHOKE PTYMode = 61 + ModePENDIN PTYMode = 62 + ModeOPOST PTYMode = 70 + ModeOLCUC PTYMode = 71 + ModeONLCR PTYMode = 72 + ModeOCRNL PTYMode = 73 + ModeONOCR PTYMode = 74 + ModeONLRET PTYMode = 75 + ModeCS7 PTYMode = 90 + ModeCS8 PTYMode = 91 + ModePARENB PTYMode = 92 + ModePARODD PTYMode = 93 + ModeISPEED PTYMode = 128 + ModeOSPEED PTYMode = 129 +) + +// IsChar reports whether m sets a control character. +func (m PTYMode) IsChar() bool { return m >= ModeVINTR && m <= ModeVDISCARD } + +// IsSpeed reports whether m sets a line speed. +func (m PTYMode) IsSpeed() bool { return m == ModeISPEED || m == ModeOSPEED } + +func (m PTYMode) Valid() bool { + switch { + case m.IsChar(), m.IsSpeed(): + return true + case m >= ModeIGNPAR && m <= ModeIUTF8, m >= ModeISIG && m <= ModePENDIN: + return true + case m >= ModeOPOST && m <= ModeONLRET, m >= ModeCS7 && m <= ModePARODD: + return true + } + return false +} + +// DisabledChar is the control-character value that disables the character. +const DisabledChar = 255 + +// Failure is the typed outcome of a failed request or launch. Effect says +// whether the request may have taken effect. +type Failure struct { + Code ErrorCode + Effect sandboxwire.Effect + Message string +} + +// Fail returns a Failure with a formatted message. +func Fail(code ErrorCode, effect sandboxwire.Effect, format string, args ...any) *Failure { + msg := fmt.Sprintf(format, args...) + if len(msg) > MaxFailureMessageBytes { + msg = msg[:MaxFailureMessageBytes] + } + return &Failure{Code: code, Effect: effect, Message: msg} +} + +func (f *Failure) Error() string { + effect := "no effect" + if f.Effect == sandboxwire.EffectPossible { + effect = "effect possible" + } + return fmt.Sprintf("sandboxprocess: %s (%s): %s", f.Code, effect, f.Message) +} + +// OperationRef addresses an operation in one service incarnation. Every +// request except Describe begins with it. The attachment comes from the +// stream, never from the payload. +type OperationRef struct { + ServerInstanceID sandboxwire.ID + OperationID sandboxwire.ID +} + +// EnvVar is one environment entry. +type EnvVar struct { + Name []byte + Value []byte +} + +// WindowSize is a terminal size in characters and pixels. +type WindowSize struct { + Rows uint16 + Cols uint16 + XPixels uint16 + YPixels uint16 +} + +// PTYModeValue sets one terminal mode. +type PTYModeValue struct { + Mode PTYMode + Value uint32 +} + +// PTYSpec describes the terminal of an IOPTY operation. The service sets TERM +// in the child's environment to Term. +type PTYSpec struct { + Size WindowSize + Term []byte + Modes []PTYModeValue +} + +// ProcessSpec is everything a launch uses; nothing is inherited from the +// service. Executable, Argv, Env and Cwd are bytes, not strings. +type ProcessSpec struct { + // Executable is a name resolved with Env's PATH when it has no '/', else a + // path, relative paths resolving against Cwd. + Executable []byte + // Argv is the complete argv, including argv[0]. + Argv [][]byte + // Env is the complete environment. + Env []EnvVar + Cwd []byte + Umask uint32 + IOMode IOMode + // PTY is present exactly when IOMode is IOPTY. + PTY *PTYSpec + Scope Scope +} + +// Digest identifies a spec for Start deduplication: the SHA-256 of its +// encoding. +func (s ProcessSpec) Digest() [sha256.Size]byte { + var e sandboxwire.Encoder + s.encode(&e) + return sha256.Sum256(e.Payload()) +} + +// Capabilities is what a service supports. Every field is declared; a request +// outside it fails with CodeUnsupported or CodeInvalidArgument. +type Capabilities struct { + Platform Platform + Scopes []Scope + IOModes []IOMode + Signals []Signal + SignalTargets []SignalTarget + PTYModes []PTYMode + // MaxStartBytes bounds an encoded Start request payload. + MaxStartBytes uint32 + // MaxDataBytes bounds WriteStdin data and Output event data. + MaxDataBytes uint32 + MaxActiveOperations uint32 + // MaxOperationRecords bounds live operations plus tombstones. + MaxOperationRecords uint32 + // MaxReplayBytesPerOperation bounds unacknowledged Output data retained + // per operation; output reading pauses at the limit. + MaxReplayBytesPerOperation uint32 + // OwnerLossGraceMillis is how long an operation survives the loss of its + // attachment before cleanup cancels it. + OwnerLossGraceMillis uint32 + // CancelGraceLimitMillis is the largest Cancel grace, and the grace + // cleanup uses. + CancelGraceLimitMillis uint32 +} + +// CheckStart validates a spec against c. +func (c Capabilities) CheckStart(s ProcessSpec) *Failure { + switch { + case !slices.Contains(c.Scopes, s.Scope): + return Fail(CodeUnsupported, sandboxwire.EffectNone, "scope %d is not supported", s.Scope) + case !slices.Contains(c.IOModes, s.IOMode): + return Fail(CodeUnsupported, sandboxwire.EffectNone, "I/O mode %d is not supported", s.IOMode) + } + if s.PTY != nil { + for _, m := range s.PTY.Modes { + if !slices.Contains(c.PTYModes, m.Mode) { + return Fail(CodeUnsupported, sandboxwire.EffectNone, "terminal mode %d is not supported", m.Mode) + } + } + } + return nil +} + +// CheckSignal validates a signal and target against c. +func (c Capabilities) CheckSignal(sig Signal, target SignalTarget) *Failure { + switch { + case !slices.Contains(c.Signals, sig): + return Fail(CodeUnsupported, sandboxwire.EffectNone, "signal %d is not supported", sig) + case !slices.Contains(c.SignalTargets, target): + return Fail(CodeUnsupported, sandboxwire.EffectNone, "signal target %d is not supported", target) + } + return nil +} + +// ExitStatus is the leader's wait result: an exit code, or the terminating +// signal and whether it dumped core. +type ExitStatus struct { + Kind ExitKind + Code uint8 + Signal Signal + CoreDumped bool +} + +// OperationStatus is an operation's current record. The retained event range +// is FirstRetained through LastSequence; it is empty when FirstRetained is +// greater. +type OperationStatus struct { + State OperationState + // Exit is present exactly when State is StateExited. + Exit *ExitStatus + // StartFailure is present exactly when State is StateStartFailed. + StartFailure *Failure + StdinOffset uint64 + StdinClosed bool + // Output is present once every captured stream has closed. + Output *OutputDisposition + Scope ScopeState + Released bool + FirstRetained uint64 + LastSequence uint64 +} + +// Message is a request, response or event. +type Message interface { + // MessageType is the frame tag. + MessageType() uint16 + encode(*sandboxwire.Encoder) +} + +// Requests. + +type DescribeRequest struct{} + +type StartRequest struct { + OperationRef + Spec ProcessSpec +} + +// AttachRequest observes an operation from the event after AfterSequence. +type AttachRequest struct { + OperationRef + AfterSequence uint64 +} + +type InspectRequest struct{ OperationRef } + +// WriteStdinRequest writes Data at stdin offset Offset. +type WriteStdinRequest struct { + OperationRef + Offset uint64 + Data []byte +} + +// CloseStdinRequest closes pipe stdin after Offset accepted bytes. +type CloseStdinRequest struct { + OperationRef + Offset uint64 +} + +type CloseOutputRequest struct { + OperationRef + Stream Stream +} + +type ResizePTYRequest struct { + OperationRef + Size WindowSize +} + +type SignalRequest struct { + OperationRef + Signal Signal + Target SignalTarget +} + +type CancelRequest struct { + OperationRef + GraceMillis uint32 +} + +type AckEventsRequest struct { + OperationRef + Sequence uint64 +} + +type ReleaseRequest struct{ OperationRef } + +// Responses. + +type DescribeResponse struct { + ServerInstanceID sandboxwire.ID + Capabilities Capabilities +} + +type StartResponse struct{ Disposition StartDisposition } + +type AttachResponse struct{ Status OperationStatus } + +type InspectResponse struct{ Status OperationStatus } + +type WriteStdinResponse struct{ Accepted uint32 } + +type CloseStdinResponse struct{} + +type CloseOutputResponse struct{} + +type ResizePTYResponse struct{} + +type SignalResponse struct{} + +type CancelResponse struct{} + +type AckEventsResponse struct{} + +type ReleaseResponse struct{} + +// ResponseFailure is the failed response to request tag Request. +type ResponseFailure struct { + Request uint16 + Failure Failure +} + +// Events. + +// EventHeader begins every event. Sequence starts at 1 and increases by one +// per event of the operation. +type EventHeader struct { + OperationID sandboxwire.ID + Sequence uint64 +} + +func (h EventHeader) Header() EventHeader { return h } + +// Event is an unsolicited message about one operation. +type Event interface { + Message + Header() EventHeader +} + +type StartedEvent struct{ EventHeader } + +type StartFailedEvent struct { + EventHeader + Failure Failure +} + +type OutputEvent struct { + EventHeader + Stream Stream + Offset uint64 + Data []byte +} + +// StreamClosedEvent reports a stream's end at its final Offset. +type StreamClosedEvent struct { + EventHeader + Stream Stream + Offset uint64 + Disposition OutputDisposition +} + +type ExitedEvent struct { + EventHeader + Status ExitStatus +} + +type OutputClosedEvent struct { + EventHeader + Disposition OutputDisposition +} + +type ScopeClosedEvent struct{ EventHeader } + +type ObservationLostEvent struct { + EventHeader + Observation Observation + Failure Failure +} + +// Service is a process service. Serve calls one method per request with the +// Conn it arrived on. A returned *Failure is sent as is; any other error is +// sent as CodeUnknown with EffectPossible. +type Service interface { + Describe(context.Context, *Conn, DescribeRequest) (DescribeResponse, error) + Start(context.Context, *Conn, StartRequest) (StartResponse, error) + Attach(context.Context, *Conn, AttachRequest) (AttachResponse, error) + Inspect(context.Context, *Conn, InspectRequest) (InspectResponse, error) + WriteStdin(context.Context, *Conn, WriteStdinRequest) (WriteStdinResponse, error) + CloseStdin(context.Context, *Conn, CloseStdinRequest) (CloseStdinResponse, error) + CloseOutput(context.Context, *Conn, CloseOutputRequest) (CloseOutputResponse, error) + ResizePTY(context.Context, *Conn, ResizePTYRequest) (ResizePTYResponse, error) + Signal(context.Context, *Conn, SignalRequest) (SignalResponse, error) + Cancel(context.Context, *Conn, CancelRequest) (CancelResponse, error) + AckEvents(context.Context, *Conn, AckEventsRequest) (AckEventsResponse, error) + Release(context.Context, *Conn, ReleaseRequest) (ReleaseResponse, error) +} + +func (DescribeRequest) MessageType() uint16 { return OpDescribe } +func (StartRequest) MessageType() uint16 { return OpStart } +func (AttachRequest) MessageType() uint16 { return OpAttach } +func (InspectRequest) MessageType() uint16 { return OpInspect } +func (WriteStdinRequest) MessageType() uint16 { return OpWriteStdin } +func (CloseStdinRequest) MessageType() uint16 { return OpCloseStdin } +func (CloseOutputRequest) MessageType() uint16 { return OpCloseOutput } +func (ResizePTYRequest) MessageType() uint16 { return OpResizePTY } +func (SignalRequest) MessageType() uint16 { return OpSignal } +func (CancelRequest) MessageType() uint16 { return OpCancel } +func (AckEventsRequest) MessageType() uint16 { return OpAckEvents } +func (ReleaseRequest) MessageType() uint16 { return OpRelease } + +func (DescribeResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpDescribe) } +func (StartResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpStart) } +func (AttachResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpAttach) } +func (InspectResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpInspect) } +func (WriteStdinResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpWriteStdin) } +func (CloseStdinResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpCloseStdin) } +func (CloseOutputResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpCloseOutput) } +func (ResizePTYResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpResizePTY) } +func (SignalResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpSignal) } +func (CancelResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpCancel) } +func (AckEventsResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpAckEvents) } +func (ReleaseResponse) MessageType() uint16 { return sandboxwire.ResponseType(OpRelease) } +func (m ResponseFailure) MessageType() uint16 { return sandboxwire.ResponseType(m.Request) } + +func (StartedEvent) MessageType() uint16 { return EventStarted } +func (StartFailedEvent) MessageType() uint16 { return EventStartFailed } +func (OutputEvent) MessageType() uint16 { return EventOutput } +func (StreamClosedEvent) MessageType() uint16 { return EventStreamClosed } +func (ExitedEvent) MessageType() uint16 { return EventExited } +func (OutputClosedEvent) MessageType() uint16 { return EventOutputClosed } +func (ScopeClosedEvent) MessageType() uint16 { return EventScopeClosed } +func (ObservationLostEvent) MessageType() uint16 { return EventObservationLost } + +// A response payload begins with this discriminator. +type result uint16 + +const ( + resultOK result = iota + 1 + resultFailure +) + +func (v result) Valid() bool { return v == resultOK || v == resultFailure } + +// Encode returns m's payload. It does not validate m. +func Encode(m Message) []byte { + var e sandboxwire.Encoder + if _, failed := m.(ResponseFailure); !failed && sandboxwire.IsResponse(m.MessageType()) { + e.Enum(uint16(resultOK)) + } + m.encode(&e) + return e.Payload() +} + +// Decode decodes and validates the payload of a frame of type t. Every error +// wraps sandboxwire.ErrMalformed. +func Decode(t uint16, payload []byte) (Message, error) { + kind, err := tags.Classify(t) + if err != nil { + return nil, err + } + r := &reader{d: sandboxwire.NewDecoder(payload)} + var m Message + switch kind { + case sandboxwire.KindRequest: + m = readRequest(r, t) + case sandboxwire.KindResponse: + op := t &^ sandboxwire.ResponseType(0) + if enum[result](r) == resultFailure { + m = ResponseFailure{Request: op, Failure: readFailure(r)} + } else { + m = readResponse(r, op) + } + case sandboxwire.KindEvent: + m = readEvent(r, t) + } + if r.err == nil { + r.err = r.d.Finish() + } + if r.err == nil { + r.err = validate(m) + } + if r.err != nil { + return nil, r.err + } + return m, nil +} + +// Encoding. Fields are written in declaration order. + +func (r OperationRef) encode(e *sandboxwire.Encoder) { + e.ID(r.ServerInstanceID) + e.ID(r.OperationID) +} + +func (f Failure) encode(e *sandboxwire.Encoder) { + e.Enum(uint16(f.Code)) + e.Effect(f.Effect) + e.Bytes([]byte(f.Message)) +} + +func (s WindowSize) encode(e *sandboxwire.Encoder) { + e.U16(s.Rows) + e.U16(s.Cols) + e.U16(s.XPixels) + e.U16(s.YPixels) +} + +func (s ProcessSpec) encode(e *sandboxwire.Encoder) { + e.Bytes(s.Executable) + e.Count(len(s.Argv)) + for _, a := range s.Argv { + e.Bytes(a) + } + e.Count(len(s.Env)) + for _, v := range s.Env { + e.Bytes(v.Name) + e.Bytes(v.Value) + } + e.Bytes(s.Cwd) + e.U32(s.Umask) + e.Enum(uint16(s.IOMode)) + e.Present(s.PTY != nil) + if p := s.PTY; p != nil { + p.Size.encode(e) + e.Bytes(p.Term) + e.Count(len(p.Modes)) + for _, m := range p.Modes { + e.Enum(uint16(m.Mode)) + e.U32(m.Value) + } + } + e.Enum(uint16(s.Scope)) +} + +func encodeEnums[T ~uint16](e *sandboxwire.Encoder, list []T) { + e.Count(len(list)) + for _, v := range list { + e.Enum(uint16(v)) + } +} + +func (c Capabilities) encode(e *sandboxwire.Encoder) { + e.Enum(uint16(c.Platform)) + encodeEnums(e, c.Scopes) + encodeEnums(e, c.IOModes) + encodeEnums(e, c.Signals) + encodeEnums(e, c.SignalTargets) + encodeEnums(e, c.PTYModes) + e.U32(c.MaxStartBytes) + e.U32(c.MaxDataBytes) + e.U32(c.MaxActiveOperations) + e.U32(c.MaxOperationRecords) + e.U32(c.MaxReplayBytesPerOperation) + e.U32(c.OwnerLossGraceMillis) + e.U32(c.CancelGraceLimitMillis) +} + +func (s ExitStatus) encode(e *sandboxwire.Encoder) { + e.Enum(uint16(s.Kind)) + if s.Kind == ExitCode { + e.U8(s.Code) + return + } + e.Enum(uint16(s.Signal)) + e.Bool(s.CoreDumped) +} + +func (s OperationStatus) encode(e *sandboxwire.Encoder) { + e.Enum(uint16(s.State)) + e.Present(s.Exit != nil) + if s.Exit != nil { + s.Exit.encode(e) + } + e.Present(s.StartFailure != nil) + if s.StartFailure != nil { + s.StartFailure.encode(e) + } + e.U64(s.StdinOffset) + e.Bool(s.StdinClosed) + e.Present(s.Output != nil) + if s.Output != nil { + e.Enum(uint16(*s.Output)) + } + e.Enum(uint16(s.Scope)) + e.Bool(s.Released) + e.U64(s.FirstRetained) + e.U64(s.LastSequence) +} + +func (h EventHeader) encode(e *sandboxwire.Encoder) { + e.ID(h.OperationID) + e.U64(h.Sequence) +} + +func (DescribeRequest) encode(*sandboxwire.Encoder) {} +func (m StartRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + m.Spec.encode(e) +} +func (m AttachRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + e.U64(m.AfterSequence) +} +func (m InspectRequest) encode(e *sandboxwire.Encoder) { m.OperationRef.encode(e) } +func (m WriteStdinRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + e.U64(m.Offset) + e.Bytes(m.Data) +} +func (m CloseStdinRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + e.U64(m.Offset) +} +func (m CloseOutputRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + e.Enum(uint16(m.Stream)) +} +func (m ResizePTYRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + m.Size.encode(e) +} +func (m SignalRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + e.Enum(uint16(m.Signal)) + e.Enum(uint16(m.Target)) +} +func (m CancelRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + e.U32(m.GraceMillis) +} +func (m AckEventsRequest) encode(e *sandboxwire.Encoder) { + m.OperationRef.encode(e) + e.U64(m.Sequence) +} +func (m ReleaseRequest) encode(e *sandboxwire.Encoder) { m.OperationRef.encode(e) } + +func (m DescribeResponse) encode(e *sandboxwire.Encoder) { + e.ID(m.ServerInstanceID) + m.Capabilities.encode(e) +} +func (m StartResponse) encode(e *sandboxwire.Encoder) { e.Enum(uint16(m.Disposition)) } +func (m AttachResponse) encode(e *sandboxwire.Encoder) { m.Status.encode(e) } +func (m InspectResponse) encode(e *sandboxwire.Encoder) { m.Status.encode(e) } +func (m WriteStdinResponse) encode(e *sandboxwire.Encoder) { e.U32(m.Accepted) } +func (CloseStdinResponse) encode(*sandboxwire.Encoder) {} +func (CloseOutputResponse) encode(*sandboxwire.Encoder) {} +func (ResizePTYResponse) encode(*sandboxwire.Encoder) {} +func (SignalResponse) encode(*sandboxwire.Encoder) {} +func (CancelResponse) encode(*sandboxwire.Encoder) {} +func (AckEventsResponse) encode(*sandboxwire.Encoder) {} +func (ReleaseResponse) encode(*sandboxwire.Encoder) {} +func (m ResponseFailure) encode(e *sandboxwire.Encoder) { + e.Enum(uint16(resultFailure)) + m.Failure.encode(e) +} + +func (m StartedEvent) encode(e *sandboxwire.Encoder) { m.EventHeader.encode(e) } +func (m StartFailedEvent) encode(e *sandboxwire.Encoder) { + m.EventHeader.encode(e) + m.Failure.encode(e) +} +func (m OutputEvent) encode(e *sandboxwire.Encoder) { + m.EventHeader.encode(e) + e.Enum(uint16(m.Stream)) + e.U64(m.Offset) + e.Bytes(m.Data) +} +func (m StreamClosedEvent) encode(e *sandboxwire.Encoder) { + m.EventHeader.encode(e) + e.Enum(uint16(m.Stream)) + e.U64(m.Offset) + e.Enum(uint16(m.Disposition)) +} +func (m ExitedEvent) encode(e *sandboxwire.Encoder) { + m.EventHeader.encode(e) + m.Status.encode(e) +} +func (m OutputClosedEvent) encode(e *sandboxwire.Encoder) { + m.EventHeader.encode(e) + e.Enum(uint16(m.Disposition)) +} +func (m ScopeClosedEvent) encode(e *sandboxwire.Encoder) { m.EventHeader.encode(e) } +func (m ObservationLostEvent) encode(e *sandboxwire.Encoder) { + m.EventHeader.encode(e) + e.Enum(uint16(m.Observation)) + m.Failure.encode(e) +} + +// Decoding. + +// reader keeps the first error, after which every read returns a zero value. +type reader struct { + d *sandboxwire.Decoder + err error +} + +func read[T any](r *reader, f func() (T, error)) T { + var v T + if r.err == nil { + v, r.err = f() + } + return v +} + +func (r *reader) u8() uint8 { return read(r, r.d.U8) } +func (r *reader) u16() uint16 { return read(r, r.d.U16) } +func (r *reader) u32() uint32 { return read(r, r.d.U32) } +func (r *reader) u64() uint64 { return read(r, r.d.U64) } +func (r *reader) boolean() bool { return read(r, r.d.Bool) } +func (r *reader) present() bool { return read(r, r.d.Present) } +func (r *reader) bytes() []byte { return read(r, r.d.Bytes) } +func (r *reader) id() sandboxwire.ID { return read(r, r.d.ID) } +func (r *reader) effect() sandboxwire.Effect { return read(r, r.d.Effect) } +func (r *reader) count(max uint32) int { + return read(r, func() (int, error) { return r.d.Count(max) }) +} + +func enum[T interface { + ~uint16 + Valid() bool +}](r *reader) T { + return T(read(r, func() (uint16, error) { return r.d.Enum(func(v uint16) bool { return T(v).Valid() }) })) +} + +// enums reads a list of distinct enum values. +func enums[T interface { + ~uint16 + Valid() bool +}](r *reader) []T { + n := r.count(maxListEntries) + list := make([]T, 0, n) + for range n { + v := enum[T](r) + if r.err == nil && slices.Contains(list, v) { + r.err = malformed("duplicate list entry %d", v) + } + list = append(list, v) + } + return list +} + +func malformed(format string, args ...any) error { + return fmt.Errorf("%w: "+format, append([]any{sandboxwire.ErrMalformed}, args...)...) +} + +func readRef(r *reader) OperationRef { + return OperationRef{ServerInstanceID: r.id(), OperationID: r.id()} +} + +func readFailure(r *reader) Failure { + return Failure{Code: enum[ErrorCode](r), Effect: r.effect(), Message: string(r.bytes())} +} + +func readWindowSize(r *reader) WindowSize { + return WindowSize{Rows: r.u16(), Cols: r.u16(), XPixels: r.u16(), YPixels: r.u16()} +} + +func readSpec(r *reader) ProcessSpec { + var s ProcessSpec + s.Executable = r.bytes() + s.Argv = make([][]byte, r.count(maxSpecEntries)) + for i := range s.Argv { + s.Argv[i] = r.bytes() + } + s.Env = make([]EnvVar, r.count(maxSpecEntries)) + for i := range s.Env { + s.Env[i] = EnvVar{Name: r.bytes(), Value: r.bytes()} + } + s.Cwd = r.bytes() + s.Umask = r.u32() + s.IOMode = enum[IOMode](r) + if r.present() { + p := &PTYSpec{Size: readWindowSize(r), Term: r.bytes()} + p.Modes = make([]PTYModeValue, r.count(maxListEntries)) + for i := range p.Modes { + p.Modes[i] = PTYModeValue{Mode: enum[PTYMode](r), Value: r.u32()} + } + s.PTY = p + } + s.Scope = enum[Scope](r) + return s +} + +func readCapabilities(r *reader) Capabilities { + return Capabilities{ + Platform: enum[Platform](r), + Scopes: enums[Scope](r), + IOModes: enums[IOMode](r), + Signals: enums[Signal](r), + SignalTargets: enums[SignalTarget](r), + PTYModes: enums[PTYMode](r), + MaxStartBytes: r.u32(), + MaxDataBytes: r.u32(), + MaxActiveOperations: r.u32(), + MaxOperationRecords: r.u32(), + MaxReplayBytesPerOperation: r.u32(), + OwnerLossGraceMillis: r.u32(), + CancelGraceLimitMillis: r.u32(), + } +} + +func readExitStatus(r *reader) ExitStatus { + s := ExitStatus{Kind: enum[ExitKind](r)} + if s.Kind == ExitCode { + s.Code = r.u8() + } else { + s.Signal = enum[Signal](r) + s.CoreDumped = r.boolean() + } + return s +} + +func readStatus(r *reader) OperationStatus { + s := OperationStatus{State: enum[OperationState](r)} + if r.present() { + exit := readExitStatus(r) + s.Exit = &exit + } + if r.present() { + f := readFailure(r) + s.StartFailure = &f + } + s.StdinOffset = r.u64() + s.StdinClosed = r.boolean() + if r.present() { + d := enum[OutputDisposition](r) + s.Output = &d + } + s.Scope = enum[ScopeState](r) + s.Released = r.boolean() + s.FirstRetained = r.u64() + s.LastSequence = r.u64() + return s +} + +func readHeader(r *reader) EventHeader { + return EventHeader{OperationID: r.id(), Sequence: r.u64()} +} + +func readRequest(r *reader, t uint16) Message { + if t == OpDescribe { + return DescribeRequest{} + } + ref := readRef(r) + switch t { + case OpStart: + return StartRequest{ref, readSpec(r)} + case OpAttach: + return AttachRequest{ref, r.u64()} + case OpInspect: + return InspectRequest{ref} + case OpWriteStdin: + return WriteStdinRequest{ref, r.u64(), r.bytes()} + case OpCloseStdin: + return CloseStdinRequest{ref, r.u64()} + case OpCloseOutput: + return CloseOutputRequest{ref, enum[Stream](r)} + case OpResizePTY: + return ResizePTYRequest{ref, readWindowSize(r)} + case OpSignal: + return SignalRequest{ref, enum[Signal](r), enum[SignalTarget](r)} + case OpCancel: + return CancelRequest{ref, r.u32()} + case OpAckEvents: + return AckEventsRequest{ref, r.u64()} + default: + return ReleaseRequest{ref} + } +} + +func readResponse(r *reader, op uint16) Message { + switch op { + case OpDescribe: + return DescribeResponse{r.id(), readCapabilities(r)} + case OpStart: + return StartResponse{enum[StartDisposition](r)} + case OpAttach: + return AttachResponse{readStatus(r)} + case OpInspect: + return InspectResponse{readStatus(r)} + case OpWriteStdin: + return WriteStdinResponse{r.u32()} + case OpCloseStdin: + return CloseStdinResponse{} + case OpCloseOutput: + return CloseOutputResponse{} + case OpResizePTY: + return ResizePTYResponse{} + case OpSignal: + return SignalResponse{} + case OpCancel: + return CancelResponse{} + case OpAckEvents: + return AckEventsResponse{} + default: + return ReleaseResponse{} + } +} + +func readEvent(r *reader, t uint16) Message { + h := readHeader(r) + switch t { + case EventStarted: + return StartedEvent{h} + case EventStartFailed: + return StartFailedEvent{h, readFailure(r)} + case EventOutput: + return OutputEvent{h, enum[Stream](r), r.u64(), r.bytes()} + case EventStreamClosed: + return StreamClosedEvent{h, enum[Stream](r), r.u64(), enum[OutputDisposition](r)} + case EventExited: + return ExitedEvent{h, readExitStatus(r)} + case EventOutputClosed: + return OutputClosedEvent{h, enum[OutputDisposition](r)} + case EventScopeClosed: + return ScopeClosedEvent{h} + default: + return ObservationLostEvent{h, enum[Observation](r), readFailure(r)} + } +} + +// Validation beyond the primitive rules. + +func validate(m Message) error { + switch m := m.(type) { + case StartRequest: + return m.Spec.Validate() + case WriteStdinRequest: + return checkData(m.Offset, m.Data) + case DescribeResponse: + return m.Capabilities.Validate() + case AttachResponse: + return m.Status.Validate() + case InspectResponse: + return m.Status.Validate() + case ResponseFailure: + return m.Failure.validate() + case Event: + return validateEvent(m) + } + return nil +} + +func validateEvent(ev Event) error { + if ev.Header().Sequence == 0 { + return malformed("event sequence 0") + } + switch ev := ev.(type) { + case StartFailedEvent: + return ev.Failure.validate() + case OutputEvent: + return checkData(ev.Offset, ev.Data) + case ObservationLostEvent: + return ev.Failure.validate() + } + return nil +} + +// checkData checks a chunk written or read at offset. +func checkData(offset uint64, b []byte) error { + switch { + case len(b) > sandboxwire.MaxChunk: + return malformed("data chunk of %d bytes exceeds %d", len(b), sandboxwire.MaxChunk) + case offset > math.MaxUint64-uint64(len(b)): + return malformed("data chunk of %d bytes at offset %d overflows", len(b), offset) + } + return nil +} + +func (f Failure) validate() error { + if len(f.Message) > MaxFailureMessageBytes { + return malformed("failure message of %d bytes", len(f.Message)) + } + return nil +} + +func hasNUL(b []byte) bool { return slices.Contains(b, 0) } + +// Validate checks the rules every ProcessSpec follows, whatever the service +// supports. +func (s ProcessSpec) Validate() error { + switch { + case len(s.Executable) == 0 || hasNUL(s.Executable): + return malformed("executable is empty or contains NUL") + case len(s.Argv) == 0: + return malformed("argv is empty") + case len(s.Cwd) == 0 || s.Cwd[0] != '/' || hasNUL(s.Cwd): + return malformed("cwd is not an absolute path") + case s.Umask > 0o777: + return malformed("umask %#o", s.Umask) + case (s.IOMode == IOPTY) != (s.PTY != nil): + return malformed("PTY must be present exactly for IOPTY") + } + for _, a := range s.Argv { + if hasNUL(a) { + return malformed("argv entry contains NUL") + } + } + names := make(map[string]struct{}, len(s.Env)) + for _, v := range s.Env { + if len(v.Name) == 0 || hasNUL(v.Name) || slices.Contains(v.Name, '=') || hasNUL(v.Value) { + return malformed("invalid environment entry") + } + if _, dup := names[string(v.Name)]; dup { + return malformed("duplicate environment entry %q", v.Name) + } + names[string(v.Name)] = struct{}{} + } + if _, ok := names["PATH"]; !ok && !slices.Contains(s.Executable, '/') { + return malformed("executable name needs a PATH entry") + } + if s.PTY == nil { + return nil + } + if _, ok := names["TERM"]; ok { + return malformed("TERM comes from the PTY spec, not the environment") + } + return s.PTY.validate() +} + +func (p PTYSpec) validate() error { + if len(p.Term) == 0 || len(p.Term) > MaxTermBytes || hasNUL(p.Term) { + return malformed("invalid TERM") + } + seen := make([]PTYMode, 0, len(p.Modes)) + for _, m := range p.Modes { + switch { + case slices.Contains(seen, m.Mode): + return malformed("duplicate terminal mode %d", m.Mode) + case m.Mode.IsChar() && m.Value > DisabledChar: + return malformed("terminal character %d value %d", m.Mode, m.Value) + case !m.Mode.IsChar() && !m.Mode.IsSpeed() && m.Value > 1: + return malformed("terminal flag %d value %d", m.Mode, m.Value) + } + seen = append(seen, m.Mode) + } + return nil +} + +// Validate checks declared limits against the frame limits. +func (c Capabilities) Validate() error { + switch { + case c.MaxStartBytes == 0 || c.MaxStartBytes > sandboxwire.MaxPayload: + return malformed("MaxStartBytes %d", c.MaxStartBytes) + case c.MaxDataBytes == 0 || c.MaxDataBytes > sandboxwire.MaxChunk: + return malformed("MaxDataBytes %d", c.MaxDataBytes) + case c.MaxReplayBytesPerOperation < c.MaxDataBytes: + return malformed("MaxReplayBytesPerOperation %d is below MaxDataBytes", c.MaxReplayBytesPerOperation) + } + return nil +} + +// Validate checks the status invariants. +func (s OperationStatus) Validate() error { + switch { + case (s.State == StateExited) != (s.Exit != nil): + return malformed("exit status must be present exactly in state Exited") + case (s.State == StateStartFailed) != (s.StartFailure != nil): + return malformed("start failure must be present exactly in state StartFailed") + case s.FirstRetained == 0 || s.FirstRetained > s.LastSequence+1: + return malformed("retained range %d..%d", s.FirstRetained, s.LastSequence) + case s.StartFailure != nil: + return s.StartFailure.validate() + } + return nil +} diff --git a/internal/sandboxprocess/protocol_test.go b/internal/sandboxprocess/protocol_test.go new file mode 100644 index 00000000..d6598b87 --- /dev/null +++ b/internal/sandboxprocess/protocol_test.go @@ -0,0 +1,129 @@ +package sandboxprocess + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +var ( + fixtureInstance = sandboxwire.ID{0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f} + fixtureOp = sandboxwire.ID{0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f} + fixtureRef = OperationRef{ServerInstanceID: fixtureInstance, OperationID: fixtureOp} +) + +func b(s string) []byte { return []byte(s) } + +var fixtures = []struct { + name string + requestID uint64 + msg Message +}{ + {"start_pipes.hex", 1, StartRequest{fixtureRef, ProcessSpec{ + Executable: b("sh"), + Argv: [][]byte{b("sh"), b("-c"), b("echo hi")}, + Env: []EnvVar{{b("PATH"), b("/usr/bin:/bin")}}, + Cwd: b("/work"), + Umask: 0o022, + IOMode: IOPipes, + Scope: ScopePOSIXSession, + }}}, + {"start_pty.hex", 2, StartRequest{fixtureRef, ProcessSpec{ + Executable: b("/bin/bash"), + Argv: [][]byte{b("-bash")}, + Env: []EnvVar{{b("HOME"), b("/root")}}, + Cwd: b("/"), + Umask: 0o077, + IOMode: IOPTY, + PTY: &PTYSpec{ + Size: WindowSize{Rows: 24, Cols: 80, XPixels: 640, YPixels: 480}, + Term: b("xterm-256color"), + Modes: []PTYModeValue{{ModeVINTR, 3}, {ModeIUTF8, 1}, {ModeECHO, 0}}, + }, + Scope: ScopePOSIXSession, + }}}, + {"output.hex", 0, OutputEvent{EventHeader{fixtureOp, 3}, StreamStdout, 0, b("hi\n")}}, + {"exited_code.hex", 0, ExitedEvent{EventHeader{fixtureOp, 5}, ExitStatus{Kind: ExitCode, Code: 7}}}, + {"exited_signal.hex", 0, ExitedEvent{EventHeader{fixtureOp, 5}, ExitStatus{Kind: ExitSignal, Signal: 11, CoreDumped: true}}}, + {"output_closed.hex", 0, OutputClosedEvent{EventHeader{fixtureOp, 6}, OutputDrained}}, + {"failure.hex", 1, ResponseFailure{OpStart, Failure{CodeOperationConflict, sandboxwire.EffectNone, "operation has a different spec"}}}, +} + +func readHexFixture(t testing.TB, name string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + var digits strings.Builder + for _, line := range strings.Split(string(raw), "\n") { + line, _, _ = strings.Cut(line, "#") + digits.WriteString(strings.Join(strings.Fields(line), "")) + } + frame, err := hex.DecodeString(digits.String()) + if err != nil { + t.Fatal(err) + } + return frame +} + +func TestGoldenFixtures(t *testing.T) { + for _, fx := range fixtures { + t.Run(fx.name, func(t *testing.T) { + want := readHexFixture(t, fx.name) + var got bytes.Buffer + if err := sandboxwire.WriteFrame(&got, sandboxwire.Frame{Type: fx.msg.MessageType(), RequestID: fx.requestID, Payload: Encode(fx.msg)}); err != nil { + t.Fatal(err) + } + if !bytes.Equal(got.Bytes(), want) { + t.Fatalf("encoded\n got %x\nwant %x", got.Bytes(), want) + } + f, err := sandboxwire.ReadFrame(bytes.NewReader(want), sandboxwire.MaxPayload) + if err != nil || f.RequestID != fx.requestID { + t.Fatalf("frame %+v: %v", f, err) + } + m, err := Decode(f.Type, f.Payload) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(m, fx.msg) { + t.Fatalf("decoded\n got %#v\nwant %#v", m, fx.msg) + } + }) + } +} + +// FuzzDecode takes a message type followed by a payload. Whatever decodes +// must re-encode to the same bytes, because every encoding is canonical. +func FuzzDecode(f *testing.F) { + for _, fx := range fixtures { + f.Add(binary.BigEndian.AppendUint16(nil, fx.msg.MessageType()), Encode(fx.msg)) + } + for op := OpDescribe; op <= OpRelease; op++ { + f.Add(binary.BigEndian.AppendUint16(nil, op), []byte{}) + f.Add(binary.BigEndian.AppendUint16(nil, sandboxwire.ResponseType(op)), []byte{0, 1}) + } + f.Fuzz(func(t *testing.T, tag, payload []byte) { + if len(tag) != 2 { + return + } + m, err := Decode(binary.BigEndian.Uint16(tag), payload) + if err != nil { + if !errors.Is(err, sandboxwire.ErrMalformed) { + t.Fatalf("error %v does not wrap ErrMalformed", err) + } + return + } + if got := Encode(m); !bytes.Equal(got, payload) { + t.Fatalf("%T re-encoded as %x, decoded from %x", m, got, payload) + } + }) +} diff --git a/internal/sandboxprocess/server.go b/internal/sandboxprocess/server.go new file mode 100644 index 00000000..b4c78f14 --- /dev/null +++ b/internal/sandboxprocess/server.go @@ -0,0 +1,220 @@ +package sandboxprocess + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// maxInFlight bounds the requests one stream runs concurrently. A request +// beyond it is answered with CodeBusy; reading never pauses, so the end of the +// stream is always seen. +const maxInFlight = 64 + +// Attachment is the authenticated attachment a stream belongs to. The Link +// layer establishes it; no request payload selects it. +type Attachment struct { + ID sandboxwire.ID +} + +// Conn is the server side of one stream: the requests arriving on it and the +// events a Service sends on it. +type Conn struct { + att Attachment + ctx context.Context + cancel context.CancelCauseFunc + rw io.ReadWriteCloser + + wmu sync.Mutex + + gmu sync.Mutex + gates map[sandboxwire.ID]chan struct{} +} + +// Attachment returns the stream's attachment. +func (c *Conn) Attachment() Attachment { return c.att } + +// Context is cancelled when the stream ends. +func (c *Conn) Context() context.Context { return c.ctx } + +// Send writes an event. It blocks while the peer is not reading, which is the +// stream's backpressure, and while the Start or Attach response that +// subscribed the stream to the operation is still unwritten, so a peer always +// sees that response before the operation's events. +func (c *Conn) Send(ev Event) error { + id := ev.Header().OperationID + for { + c.gmu.Lock() + gate := c.gates[id] + c.gmu.Unlock() + if gate == nil { + break + } + select { + case <-gate: + case <-c.ctx.Done(): + return context.Cause(c.ctx) + } + } + return c.write(0, ev) +} + +func (c *Conn) write(requestID uint64, m Message) error { + if err := c.ctx.Err(); err != nil { + return context.Cause(c.ctx) + } + c.wmu.Lock() + defer c.wmu.Unlock() + err := sandboxwire.WriteFrame(c.rw, sandboxwire.Frame{Type: m.MessageType(), RequestID: requestID, Payload: Encode(m)}) + if err != nil { + c.cancel(err) + } + return err +} + +func (c *Conn) openGate(id sandboxwire.ID) chan struct{} { + gate := make(chan struct{}) + c.gmu.Lock() + c.gates[id] = gate + c.gmu.Unlock() + return gate +} + +func (c *Conn) closeGate(id sandboxwire.ID, gate chan struct{}) { + c.gmu.Lock() + if c.gates[id] == gate { + delete(c.gates, id) + } + c.gmu.Unlock() + close(gate) +} + +// ErrProtocol reports a peer that broke the framing or message rules; Serve +// closes the stream. +var ErrProtocol = errors.New("sandboxprocess: protocol violation") + +// Serve runs svc on one stream until the stream ends or ctx is done, then +// closes rw and waits for running requests. It returns nil when the peer +// closed the stream or ctx was cancelled. +// +// Up to maxInFlight requests run at once; a request beyond that is answered +// with CodeBusy and EffectNone without running. A malformed request payload is +// answered with CodeInvalidArgument. A malformed frame, an unknown or +// non-request tag, a RequestID that does not increase, or a peer that keeps +// sending while maxInFlight rejections wait to be written ends the stream with +// ErrProtocol, and the request is not dispatched. +func Serve(ctx context.Context, rw io.ReadWriteCloser, att Attachment, svc Service) error { + ctx, cancel := context.WithCancelCause(ctx) + c := &Conn{att: att, ctx: ctx, cancel: cancel, rw: rw, gates: map[sandboxwire.ID]chan struct{}{}} + stop := context.AfterFunc(ctx, func() { rw.Close() }) + defer stop() + + running := make(chan struct{}, maxInFlight) + rejecting := make(chan struct{}, maxInFlight) + var seq sandboxwire.RequestSequence + var wg sync.WaitGroup + err := func() error { + for { + f, err := sandboxwire.ReadFrame(rw, sandboxwire.MaxPayload) + if err != nil { + return err + } + if kind, err := tags.Classify(f.Type); err != nil || kind != sandboxwire.KindRequest || !seq.Admit(f.RequestID) { + return fmt.Errorf("%w: frame type %#04x request %d", ErrProtocol, f.Type, f.RequestID) + } + select { + case running <- struct{}{}: + default: + select { + case rejecting <- struct{}{}: + default: + return fmt.Errorf("%w: the peer overran %d pending requests", ErrProtocol, 2*maxInFlight) + } + wg.Add(1) + go func() { + defer wg.Done() + c.write(f.RequestID, ResponseFailure{Request: f.Type, Failure: *Fail(CodeBusy, sandboxwire.EffectNone, "%d requests are running on this stream", maxInFlight)}) + <-rejecting + }() + continue + } + m, derr := Decode(f.Type, f.Payload) + var gateID sandboxwire.ID + var gate chan struct{} + switch m := m.(type) { + case StartRequest: + gateID, gate = m.OperationID, c.openGate(m.OperationID) + case AttachRequest: + gateID, gate = m.OperationID, c.openGate(m.OperationID) + } + wg.Add(1) + go func() { + defer wg.Done() + var resp Message + if derr != nil { + resp = ResponseFailure{Request: f.Type, Failure: *Fail(CodeInvalidArgument, sandboxwire.EffectNone, "%v", derr)} + } else { + resp = dispatch(ctx, svc, c, m) + } + c.write(f.RequestID, resp) + if gate != nil { + c.closeGate(gateID, gate) + } + <-running + }() + } + }() + if cause := context.Cause(ctx); cause != nil { + err = cause // a failed write or the caller's cancellation ended the read + } + cancel(err) + rw.Close() + wg.Wait() + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return nil + } + return err +} + +func dispatch(ctx context.Context, svc Service, c *Conn, m Message) Message { + var resp Message + var err error + switch m := m.(type) { + case DescribeRequest: + resp, err = svc.Describe(ctx, c, m) + case StartRequest: + resp, err = svc.Start(ctx, c, m) + case AttachRequest: + resp, err = svc.Attach(ctx, c, m) + case InspectRequest: + resp, err = svc.Inspect(ctx, c, m) + case WriteStdinRequest: + resp, err = svc.WriteStdin(ctx, c, m) + case CloseStdinRequest: + resp, err = svc.CloseStdin(ctx, c, m) + case CloseOutputRequest: + resp, err = svc.CloseOutput(ctx, c, m) + case ResizePTYRequest: + resp, err = svc.ResizePTY(ctx, c, m) + case SignalRequest: + resp, err = svc.Signal(ctx, c, m) + case CancelRequest: + resp, err = svc.Cancel(ctx, c, m) + case AckEventsRequest: + resp, err = svc.AckEvents(ctx, c, m) + case ReleaseRequest: + resp, err = svc.Release(ctx, c, m) + } + if err == nil { + return resp + } + var f *Failure + if !errors.As(err, &f) || !f.Code.Valid() || !f.Effect.Valid() { + f = Fail(CodeUnknown, sandboxwire.EffectPossible, "%v", err) + } + return ResponseFailure{Request: m.MessageType(), Failure: *f} +} diff --git a/internal/sandboxprocess/server_test.go b/internal/sandboxprocess/server_test.go new file mode 100644 index 00000000..55c9eae1 --- /dev/null +++ b/internal/sandboxprocess/server_test.go @@ -0,0 +1,54 @@ +package sandboxprocess + +import ( + "context" + "errors" + "io" + "net" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +type countingService struct { + Service + describes int +} + +func (s *countingService) Describe(context.Context, *Conn, DescribeRequest) (DescribeResponse, error) { + s.describes++ + caps := Capabilities{Platform: PlatformLinux, MaxStartBytes: 1024, MaxDataBytes: 1024, MaxReplayBytesPerOperation: 1024} + return DescribeResponse{ServerInstanceID: fixtureInstance, Capabilities: caps}, nil +} + +// A request whose ID does not increase ends the stream without being +// dispatched. +func TestServeRejectsNonIncreasingRequestID(t *testing.T) { + cc, sc := net.Pipe() + defer cc.Close() + svc := &countingService{} + served := make(chan error, 1) + go func() { served <- Serve(context.Background(), sc, Attachment{ID: fixtureOp}, svc) }() + + describe := sandboxwire.Frame{Type: OpDescribe, RequestID: 2, Payload: Encode(DescribeRequest{})} + if err := sandboxwire.WriteFrame(cc, describe); err != nil { + t.Fatal(err) + } + if _, err := sandboxwire.ReadFrame(cc, sandboxwire.MaxPayload); err != nil { + t.Fatal(err) + } + go io.Copy(io.Discard, cc) + describe.RequestID = 1 + if err := sandboxwire.WriteFrame(cc, describe); err != nil { + t.Fatal(err) + } + select { + case err := <-served: + if !errors.Is(err, ErrProtocol) || svc.describes != 1 { + t.Fatalf("Serve: %v after %d dispatches, want ErrProtocol after 1", err, svc.describes) + } + case <-time.After(5 * time.Second): + t.Fatal("Serve accepted a repeated request ID") + } +} diff --git a/internal/sandboxprocess/testdata/exited_code.hex b/internal/sandboxprocess/testdata/exited_code.hex new file mode 100644 index 00000000..7bef4549 --- /dev/null +++ b/internal/sandboxprocess/testdata/exited_code.hex @@ -0,0 +1,9 @@ +# An Exited event with an exit code. Hex bytes; text after # is a comment. +0000001b # PayloadLength 27 +4005 # MessageType: Exited event +0000 # Flags +0000000000000000 # RequestID 0 +202122232425262728292a2b2c2d2e2f # OperationID +0000000000000005 # Sequence 5 +0001 # Kind ExitCode +07 # Code 7 diff --git a/internal/sandboxprocess/testdata/exited_signal.hex b/internal/sandboxprocess/testdata/exited_signal.hex new file mode 100644 index 00000000..1db8c00e --- /dev/null +++ b/internal/sandboxprocess/testdata/exited_signal.hex @@ -0,0 +1,10 @@ +# An Exited event with a terminating signal. Hex bytes; text after # is a comment. +0000001d # PayloadLength 29 +4005 # MessageType: Exited event +0000 # Flags +0000000000000000 # RequestID 0 +202122232425262728292a2b2c2d2e2f # OperationID +0000000000000005 # Sequence 5 +0002 # Kind ExitSignal +000b # Signal 11 (SEGV) +01 # CoreDumped true diff --git a/internal/sandboxprocess/testdata/failure.hex b/internal/sandboxprocess/testdata/failure.hex new file mode 100644 index 00000000..7dd3ed34 --- /dev/null +++ b/internal/sandboxprocess/testdata/failure.hex @@ -0,0 +1,9 @@ +# A failed response to Start. Hex bytes; text after # is a comment. +00000028 # PayloadLength 40 +8002 # MessageType: response to Start +0000 # Flags +0000000000000001 # RequestID 1 +0002 # Result failure +0007 # Code OperationConflict +0001 # Effect EffectNone +0000001e 6f7065726174696f6e20686173206120646966666572656e742073706563 # Message "operation has a different spec" diff --git a/internal/sandboxprocess/testdata/output.hex b/internal/sandboxprocess/testdata/output.hex new file mode 100644 index 00000000..1d493092 --- /dev/null +++ b/internal/sandboxprocess/testdata/output.hex @@ -0,0 +1,10 @@ +# An Output event. Hex bytes; text after # is a comment. +00000029 # PayloadLength 41 +4003 # MessageType: Output event +0000 # Flags +0000000000000000 # RequestID 0 +202122232425262728292a2b2c2d2e2f # OperationID +0000000000000003 # Sequence 3 +0001 # Stream StreamStdout +0000000000000000 # Offset 0 +00000003 68690a # Data "hi\n" diff --git a/internal/sandboxprocess/testdata/output_closed.hex b/internal/sandboxprocess/testdata/output_closed.hex new file mode 100644 index 00000000..eab52970 --- /dev/null +++ b/internal/sandboxprocess/testdata/output_closed.hex @@ -0,0 +1,8 @@ +# An OutputClosed event. Hex bytes; text after # is a comment. +0000001a # PayloadLength 26 +4006 # MessageType: OutputClosed event +0000 # Flags +0000000000000000 # RequestID 0 +202122232425262728292a2b2c2d2e2f # OperationID +0000000000000006 # Sequence 6 +0001 # Disposition OutputDrained diff --git a/internal/sandboxprocess/testdata/start_pipes.hex b/internal/sandboxprocess/testdata/start_pipes.hex new file mode 100644 index 00000000..fab6a0b3 --- /dev/null +++ b/internal/sandboxprocess/testdata/start_pipes.hex @@ -0,0 +1,20 @@ +# A Start request for a piped process. Hex bytes; text after # is a comment. +00000070 # PayloadLength 112 +0002 # MessageType: Start +0000 # Flags +0000000000000001 # RequestID 1 +101112131415161718191a1b1c1d1e1f # ServerInstanceID +202122232425262728292a2b2c2d2e2f # OperationID +00000002 7368 # Executable "sh" +00000003 # Argv count 3 +00000002 7368 # Argv "sh" +00000002 2d63 # Argv "-c" +00000007 6563686f206869 # Argv "echo hi" +00000001 # Env count 1 +00000004 50415448 # Env name "PATH" +0000000d 2f7573722f62696e3a2f62696e # Env value "/usr/bin:/bin" +00000005 2f776f726b # Cwd "/work" +00000012 # Umask 0o022 +0001 # IOMode IOPipes +00 # PTY absent +0001 # Scope ScopePOSIXSession diff --git a/internal/sandboxprocess/testdata/start_pty.hex b/internal/sandboxprocess/testdata/start_pty.hex new file mode 100644 index 00000000..af452c59 --- /dev/null +++ b/internal/sandboxprocess/testdata/start_pty.hex @@ -0,0 +1,30 @@ +# A Start request for a process on a PTY. Hex bytes; text after # is a comment. +0000008d # PayloadLength 141 +0002 # MessageType: Start +0000 # Flags +0000000000000002 # RequestID 2 +101112131415161718191a1b1c1d1e1f # ServerInstanceID +202122232425262728292a2b2c2d2e2f # OperationID +00000009 2f62696e2f62617368 # Executable "/bin/bash" +00000001 # Argv count 1 +00000005 2d62617368 # Argv "-bash" +00000001 # Env count 1 +00000004 484f4d45 # Env name "HOME" +00000005 2f726f6f74 # Env value "/root" +00000001 2f # Cwd "/" +0000003f # Umask 0o077 +0002 # IOMode IOPTY +01 # PTY present +0018 # Rows 24 +0050 # Cols 80 +0280 # XPixels 640 +01e0 # YPixels 480 +0000000e 787465726d2d323536636f6c6f72 # Term "xterm-256color" +00000003 # Modes count 3 +0001 # Mode VINTR +00000003 # Value 3 (^C) +002a # Mode IUTF8 +00000001 # Value 1 +0035 # Mode ECHO +00000000 # Value 0 +0001 # Scope ScopePOSIXSession