execute: add process detach mechanism

Allows the user to detach a running process from the agent mid-execution.
The process continues running; output flows to a 64KB ring buffer.
Detached processes can be listed, signaled (TERM/KILL), and dismissed.

- DetachedProcess struct with ring buffer, signal, output methods
- Server.Detach() signals the currently executing process to detach
- Execute() rewritten: cmd.Start() + select on wait/ctx/detach
- cleanupDetached() on Close() sends SIGTERM to all
- OnDetach/OnExit hooks for integration with D-Bus layer
This commit is contained in:
Levi Neely 2026-07-17 16:43:07 +02:00
parent aca0250b65
commit 16d057741c
4 changed files with 344 additions and 11 deletions

View File

@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
"sync"
"ollie/internal/sandbox"
"ollie/pkg/paths"
@ -158,6 +159,7 @@ func loadSandboxConfig(name string) (*sandbox.Config, error) {
}
type limitedWriter struct {
mu sync.Mutex
w io.Writer
written int
limit int
@ -165,6 +167,8 @@ type limitedWriter struct {
}
func (lw *limitedWriter) Write(p []byte) (n int, err error) {
lw.mu.Lock()
defer lw.mu.Unlock()
if lw.written >= lw.limit {
lw.truncated = true
return len(p), nil

View File

@ -0,0 +1,96 @@
package execute
import (
"fmt"
"os"
"sync"
"syscall"
"time"
)
const ringBufSize = 64 * 1024 // 64KB ring buffer per detached process
// DetachedProcess represents a process that the agent has detached from
// but which continues running. The user can view its output and signal it.
type DetachedProcess struct {
PID int
Command string
Started time.Time
Exited bool
ExitCode int
ring *ringBuffer
cmd *os.Process
done chan struct{}
mu sync.Mutex
}
// Output returns the current contents of the ring buffer.
func (p *DetachedProcess) Output() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.ring.String()
}
// Signal sends a signal to the detached process.
func (p *DetachedProcess) Signal(sig syscall.Signal) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.Exited {
return fmt.Errorf("process %d already exited", p.PID)
}
if p.cmd == nil {
return fmt.Errorf("process %d has no handle", p.PID)
}
// Send to process group
return syscall.Kill(-p.PID, sig)
}
// ringBuffer is a fixed-size circular byte buffer.
type ringBuffer struct {
buf []byte
size int
pos int
full bool
}
func newRingBuffer(size int) *ringBuffer {
return &ringBuffer{buf: make([]byte, size), size: size}
}
// Write implements io.Writer.
func (r *ringBuffer) Write(p []byte) (int, error) {
n := len(p)
if n >= r.size {
// Data larger than buffer: just keep the tail
copy(r.buf, p[n-r.size:])
r.pos = 0
r.full = true
return n, nil
}
if r.pos+n <= r.size {
copy(r.buf[r.pos:], p)
} else {
first := r.size - r.pos
copy(r.buf[r.pos:], p[:first])
copy(r.buf, p[first:])
r.full = true
}
r.pos = (r.pos + n) % r.size
if r.pos == 0 && n > 0 {
r.full = true
}
return n, nil
}
// String returns the buffer contents in order.
func (r *ringBuffer) String() string {
if !r.full {
return string(r.buf[:r.pos])
}
// Buffer has wrapped: data from pos..end + 0..pos
out := make([]byte, r.size)
copy(out, r.buf[r.pos:])
copy(out[r.size-r.pos:], r.buf[:r.pos])
return string(out)
}

View File

@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
// TestMain points OLLIE_CFG_PATH at testdata so loadSandboxConfig uses the
@ -515,3 +516,61 @@ func TestRunReadBatch_WithLockDir(t *testing.T) {
t.Errorf("output = %q; want 'locked'", out)
}
}
// ---- detach ----
func TestDetach(t *testing.T) {
s := newServer(t)
ctx := context.Background()
// Start a long-running process, then detach it
var detachedPID int
s.OnDetach = func(pid int, cmd string) {
detachedPID = pid
}
done := make(chan string, 1)
go func() {
out, _ := s.Execute(ctx, "echo started; sleep 30", "bash", 60, "default", true)
done <- out
}()
// Give the process time to start and reach the select
for i := 0; i < 50; i++ {
if s.Detach() {
break
}
time.Sleep(10 * time.Millisecond)
}
out := <-done
if !strings.Contains(out, "[detached: pid") {
t.Fatalf("expected detach marker in output, got: %q", out)
}
if detachedPID == 0 {
t.Fatal("OnDetach not called")
}
// Verify process is in detached list
procs := s.ListDetached()
if len(procs) != 1 {
t.Fatalf("expected 1 detached process, got %d", len(procs))
}
if procs[0].PID != detachedPID {
t.Errorf("PID mismatch: got %d, want %d", procs[0].PID, detachedPID)
}
if procs[0].Exited {
t.Error("process should still be running")
}
// Kill it
if err := s.SignalDetached(detachedPID, 15); err != nil { // SIGTERM
t.Fatalf("SignalDetached: %v", err)
}
// Wait for exit
<-procs[0].done
if !procs[0].Exited {
t.Error("process should have exited after SIGTERM")
}
}

View File

@ -65,6 +65,13 @@ type Server struct {
validationFailures int
lastFailure time.Time
blockedUntil time.Time
// Detached process management
detachMu sync.Mutex
detachCh chan struct{} // signal to detach the currently running process
detached []*DetachedProcess
OnDetach func(pid int, cmd string) // hook: called when a process is detached
OnExit func(pid int, exitCode int) // hook: called when a detached process exits
}
// Option configures a Server.
@ -386,6 +393,7 @@ func (e *Server) SetEnv(key, value string) {
}
// Close is called when the session ends. Calls OnClose hook if registered.
func (e *Server) Close() {
e.cleanupDetached()
if e.OnClose != nil {
e.OnClose()
}
@ -505,7 +513,9 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
// NOTE: do NOT defer cancel() here — the detach path must avoid cancelling
// the context (which would kill the detached process via cmd.Cancel).
// Each exit path calls cancel() explicitly except detach.
var cmd *exec.Cmd
var interpreter []string
@ -606,19 +616,106 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
cmd.Stdout = lw
cmd.Stderr = lw
err = cmd.Run()
output := outputBuf.Bytes()
if lw.truncated {
output = append(output, []byte("\n[output truncated at 10MB]")...)
// Set up detach channel for this execution
detachCh := make(chan struct{}, 1)
e.detachMu.Lock()
e.detachCh = detachCh
e.detachMu.Unlock()
defer func() {
e.detachMu.Lock()
e.detachCh = nil
e.detachMu.Unlock()
}()
if err = cmd.Start(); err != nil {
return "", fmt.Errorf("execution failed: %v", err)
}
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("execution timeout after %d seconds", timeout)
// Wait for completion, context cancellation, or detach signal
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
select {
case err = <-waitCh:
// Normal completion
cancel()
output := outputBuf.Bytes()
if lw.truncated {
output = append(output, []byte("\n[output truncated at 10MB]")...)
}
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("execution timeout after %d seconds", timeout)
}
if err != nil {
return string(output), fmt.Errorf("execution failed: %v\nOutput: %s", err, string(output))
}
return string(output), nil
case <-ctx.Done():
// Context cancelled (interrupt or timeout) — cancel() already fired via ctx
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-waitCh // reap
output := outputBuf.Bytes()
if ctx.Err() == context.DeadlineExceeded {
return string(output), fmt.Errorf("execution timeout after %d seconds", timeout)
}
return string(output), fmt.Errorf("execution interrupted")
case <-detachCh:
// Detach: move process to registry, continue running
// Neutralize cmd.Cancel so context cancellation won't SIGKILL the process
cmd.Cancel = nil
// Cancel the timeout context (harmless now that cmd.Cancel is nil)
cancel()
cmdStr := strings.Join(interpreter, " ")
if len(cmdStr) > 80 {
cmdStr = cmdStr[:77] + "..."
}
ring := newRingBuffer(ringBufSize)
// Splice: future output goes to ring buffer instead of outputBuf.
lw.mu.Lock()
lw.w = ring
lw.mu.Unlock()
proc := &DetachedProcess{
PID: cmd.Process.Pid,
Command: cmdStr,
Started: time.Now(),
ring: ring,
cmd: cmd.Process,
done: make(chan struct{}),
}
e.detachMu.Lock()
e.detached = append(e.detached, proc)
e.detachMu.Unlock()
// Monitor for exit in background
go func() {
waitErr := <-waitCh
proc.mu.Lock()
proc.Exited = true
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
proc.ExitCode = exitErr.ExitCode()
} else {
proc.ExitCode = -1
}
}
proc.mu.Unlock()
close(proc.done)
if e.OnExit != nil {
e.OnExit(proc.PID, proc.ExitCode)
}
}()
if e.OnDetach != nil {
e.OnDetach(proc.PID, cmdStr)
}
partial := outputBuf.String()
return partial + fmt.Sprintf("\n[detached: pid %d]", proc.PID), nil
}
if err != nil {
return string(output), fmt.Errorf("execution failed: %v\nOutput: %s", err, string(output))
}
return string(output), nil
}
// prependOlliePath returns env with $OLLIE_CFG_PATH/scripts/x/ prepended to PATH so wrapper
@ -662,4 +759,81 @@ func plan9Namespace(env map[string]string) string {
return "/tmp/ns." + user + "." + disp
}
// Detach signals the currently running process to be detached from the agent.
// The process continues running; its output is captured in a ring buffer.
// Returns false if no process is currently running.
func (e *Server) Detach() bool {
e.detachMu.Lock()
ch := e.detachCh
e.detachMu.Unlock()
if ch == nil {
return false
}
select {
case ch <- struct{}{}:
return true
default:
return false
}
}
// ListDetached returns all detached processes (running and exited).
func (e *Server) ListDetached() []*DetachedProcess {
e.detachMu.Lock()
defer e.detachMu.Unlock()
out := make([]*DetachedProcess, len(e.detached))
copy(out, e.detached)
return out
}
// SignalDetached sends a signal to a detached process by PID.
func (e *Server) SignalDetached(pid int, sig syscall.Signal) error {
e.detachMu.Lock()
defer e.detachMu.Unlock()
for _, p := range e.detached {
if p.PID == pid {
return p.Signal(sig)
}
}
return fmt.Errorf("no detached process with pid %d", pid)
}
// GetDetachedOutput returns the ring buffer contents for a detached process.
func (e *Server) GetDetachedOutput(pid int) (string, error) {
e.detachMu.Lock()
defer e.detachMu.Unlock()
for _, p := range e.detached {
if p.PID == pid {
return p.Output(), nil
}
}
return "", fmt.Errorf("no detached process with pid %d", pid)
}
// DismissDetached removes an exited process from the list.
func (e *Server) DismissDetached(pid int) bool {
e.detachMu.Lock()
defer e.detachMu.Unlock()
for i, p := range e.detached {
if p.PID == pid && p.Exited {
e.detached = append(e.detached[:i], e.detached[i+1:]...)
return true
}
}
return false
}
// cleanupDetached sends SIGTERM to all running detached processes.
func (e *Server) cleanupDetached() {
e.detachMu.Lock()
defer e.detachMu.Unlock()
for _, p := range e.detached {
p.mu.Lock()
if !p.Exited && p.cmd != nil {
syscall.Kill(-p.PID, syscall.SIGTERM)
}
p.mu.Unlock()
}
}
var _ tools.Server = (*Server)(nil) // compile-time interface check