package execute import ( "context" "encoding/binary" "bytes" "encoding/json" "fmt" "io" "net" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "syscall" "time" "ollie/sandbox" "ollie/paths" "ollie/tools" "ollie/skills" "ollie/detach" ) const ( failureWindow = 1 * time.Minute maxFailures = 5 blockDuration = 5 * time.Minute ) // Server runs code in a sandboxed environment. type Server struct { // cwd is the working directory for sandboxed commands. If empty, // the process working directory is used. wdMu sync.RWMutex cwd string // envExtra holds per-session environment variables injected via SetEnv. envMu sync.RWMutex envExtra map[string]string // Hooks for lifecycle events (harnesses like 9P can inject mount logic here) OnPreDispatch func() OnClose func() OnEnvSet func(key, value string) // Strict rejects inline {code} steps; only {tool} steps are allowed. Strict bool // Yolo skips the landrun sandbox for all execution. Yolo bool // allowTools restricts which named tool scripts can be invoked via call_tool/pipe. // Empty means all are allowed. allowTools map[string]bool toolRegistry *tools.Registry skillsRegistry *skills.Registry sessionID string // OnInjection is called when a skill is loaded and its content // should be injected into the agent's context. OnInjection func(content string) // rate limiting state (per-Server) rateLimitMu sync.Mutex 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 []*detach.Process 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. type Option func(*Server) // WithStrict rejects inline {code} steps; only {tool} steps are allowed. func WithStrict() Option { return func(s *Server) { s.Strict = true } } // WithYolo skips the landrun sandbox. func WithYolo() Option { return func(s *Server) { s.Yolo = true } } // WithOnPreDispatch registers a hook called before each Dispatch. func WithOnPreDispatch(fn func()) Option { return func(s *Server) { s.OnPreDispatch = fn } } // WithOnClose registers a hook called during Close. func WithOnClose(fn func()) Option { return func(s *Server) { s.OnClose = fn } } // SetOnExit sets the hook called when a detached process exits. func (e *Server) SetOnExit(fn func(pid, exitCode int)) { e.OnExit = fn } // WithOnEnvSet registers a hook called each time SetEnv is called. func WithOnEnvSet(fn func(key, value string)) Option { return func(s *Server) { s.OnEnvSet = fn } } // WithAllowTools restricts which tool scripts can be invoked. func WithAllowTools(names []string) Option { return func(s *Server) { if len(names) > 0 { s.allowTools = make(map[string]bool, len(names)) for _, n := range names { s.allowTools[n] = true } } } } // AllowTools returns the set of allowed tool names, or nil if unrestricted. func (e *Server) AllowTools() []string { if len(e.allowTools) == 0 { return nil } out := make([]string, 0, len(e.allowTools)) for k := range e.allowTools { out = append(out, k) } return out } // WithToolRegistry attaches a tool registry and session ID to the Server. func WithToolRegistry(r *tools.Registry, sessionID string) Option { return func(s *Server) { s.toolRegistry = r s.sessionID = sessionID } } // WithSkillsRegistry attaches a skills registry to the Server. func WithSkillsRegistry(r *skills.Registry) Option { return func(s *Server) { s.skillsRegistry = r } } // Decl returns a factory for an execute Server with the given working directory. func Decl(cwd string, opts ...Option) func() tools.Server { return func() tools.Server { s := New(cwd) for _, o := range opts { o(s) } return s } } // ListTools implements tools.Server, returning shell plus any // tools promoted in the session's tool registry. func (e *Server) ListTools() ([]tools.ToolInfo, error) { all := []tools.ToolInfo{ { Name: "shell", Description: `Execute a single bash command in a sandboxed environment. Usage: {"cmd": "your command here"} Sandbox prevents dangerous operations. Use for computation, builds, scripting. timeout applies to each call (default: 30s). A non-zero exit is an error.`, InputSchema: json.RawMessage(`{ "type": "object", "required": ["cmd"], "properties": { "cmd": {"type": "string", "description": "Bash command to execute."}, "timeout": {"type": "integer", "description": "Timeout in seconds (default: 30). Use 0 for no timeout."}, "sandbox": {"type": "string", "description": "Sandbox profile name (default: default)."}, "elevated": {"type": "boolean", "description": "Run outside the sandbox via elevation broker."} } }`), }, { Name: "tool_list", Description: `List all available tools that can be loaded. Usage: {"name": "toolname"} — if name is provided, loads that tool. Otherwise lists all tools with descriptions.`, InputSchema: json.RawMessage(`{ "type": "object", "properties": {} }`), }, { Name: "tool_load", Description: `Load a tool by name into the current session. Usage: {"name": "toolname"} After loading, the tool becomes a native callable function.`, InputSchema: json.RawMessage(`{ "type": "object", "required": ["name"], "properties": { "name": {"type": "string", "description": "Tool name to load."} } }`), }, { Name: "tool_active", Description: `List tools currently loaded (promoted) in this session. Usage: (no arguments) Returns tools with descriptions, one per line.`, InputSchema: json.RawMessage(`{ "type": "object", "properties": {} }`), }, } if e.toolRegistry != nil && e.sessionID != "" { all = append(all, e.toolRegistry.Loaded(e.sessionID)...) } // Append skill tools if skills registry is available skillTools := ListSkillsTools(e.skillsRegistry, e.sessionID) all = append(all, skillTools...) return all, nil } // CallTool implements tools.Server. func (e *Server) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { if e.toolRegistry != nil && e.sessionID != "" { if _, promoted := e.toolRegistry.Lookup(e.sessionID, tool); promoted { return e.callPromotedTool(ctx, tool, args) } } result, err := e.Dispatch(ctx, tool, args) if err != nil { return json.Marshal(map[string]any{ "isError": true, "content": []map[string]string{{"type": "text", "text": err.Error()}}, }) } // If the tool output is already structured content-block JSON, pass it through. var structured map[string]json.RawMessage if json.Unmarshal([]byte(result), &structured) == nil { if _, ok := structured["content"]; ok { return []byte(result), nil } } return json.Marshal(map[string]any{ "content": []map[string]string{{"type": "text", "text": result}}, }) } // New creates a new Server with the given working directory. func New(cwd string) *Server { return &Server{cwd: paths.ExpandHome(cwd)} } // SetCWD updates the working directory used for subsequent command executions. func (e *Server) SetCWD(dir string) { e.wdMu.Lock() e.cwd = paths.ExpandHome(dir) e.wdMu.Unlock() } // SetAllowTools restricts which tool scripts can be invoked. func (e *Server) SetAllowTools(names []string) { if len(names) > 0 { e.allowTools = make(map[string]bool, len(names)) for _, n := range names { e.allowTools[n] = true } } else { e.allowTools = nil } } // SetEnv adds a session-scoped environment variable injected into all // subsequent subprocess invocations for this session. func (e *Server) SetEnv(key, value string) { e.envMu.Lock() if e.envExtra == nil { e.envExtra = make(map[string]string) } e.envExtra[key] = value e.envMu.Unlock() if e.OnEnvSet != nil { e.OnEnvSet(key, value) } } // SetToolRegistry attaches a session-local tool registry. func (e *Server) SetToolRegistry(r *tools.Registry, sessionID string) { e.toolRegistry = r e.sessionID = sessionID } // callPromotedTool executes a tool promoted via the registry by running the // script file inside the sandbox, piping the JSON args to stdin. func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { // Resolve script path. if strings.Contains(tool, "/") || strings.Contains(tool, "..") { return nil, fmt.Errorf("invalid tool name") } path := filepath.Join(tools.ToolsPath(), tool) // Extract elevated flag (dispatch-level concern, not passed to tool). elevated := false var argMap map[string]interface{} if err := json.Unmarshal(args, &argMap); err == nil { if e, ok := argMap["elevated"]; ok { switch v := e.(type) { case bool: elevated = v case string: elevated = v == "true" || v == "1" } // Remove elevated from the args passed to the tool. delete(argMap, "elevated") args, _ = json.Marshal(argMap) } } // The tool script receives JSON args on stdin. code := path stdinData := string(args) var result string var err error if elevated { e.wdMu.RLock() workDir := e.cwd e.wdMu.RUnlock() // Broker protocol has no stdin support; pipe JSON via heredoc. elevatedCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", code, stdinData) result, err = e.executeElevated(ctx, elevatedCode, workDir, 30) } else { result, err = e.executeWithStdin(ctx, code, "bash", 30, "default", false, stdinData) } if err != nil { return json.Marshal(map[string]interface{}{ "isError": true, "content": []map[string]string{{"type": "text", "text": result + ": " + err.Error()}}, }) } return json.Marshal(map[string]interface{}{ "content": []map[string]string{{"type": "text", "text": result}}, }) } // Close is called when the session ends. Calls OnClose hook if registered. func (e *Server) Close() { e.cleanupDetached() if e.OnClose != nil { e.OnClose() } } // executeElevated runs cmd outside the sandbox via the integrated elevation broker. // Connects to the broker socket, sends the request with the current env, // and streams the framed response back. func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) { sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET") if sockPath == "" { xdg := os.Getenv("XDG_RUNTIME_DIR") if xdg == "" { return "", fmt.Errorf("elevation not available: no XDG_RUNTIME_DIR") } sockPath = filepath.Join(xdg, "ollie", "elevate.sock") } wantDetach := len(doDetach) > 0 && doDetach[0] var cancel context.CancelFunc if wantDetach || timeout <= 0 { ctx, cancel = context.WithCancel(ctx) } else { ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second) } // Connect to broker conn, err := net.DialTimeout("unix", sockPath, 5*time.Second) if err != nil { cancel() return "", fmt.Errorf("elevation not available: %w", err) } // Send request envMap := make(map[string]string, len(os.Environ())) for _, kv := range os.Environ() { if k, v, ok := strings.Cut(kv, "="); ok { envMap[k] = v } } reqJSON, _ := json.Marshal(struct { Cmd string `json:"cmd"` Cwd string `json:"cwd"` Env map[string]string `json:"env"` Session string `json:"session,omitempty"` }{Cmd: cmd, Cwd: dir, Env: envMap, Session: os.Getenv("OLLIE_SESSION_ID")}) reqJSON = append(reqJSON, '\n') if _, err := conn.Write(reqJSON); err != nil { conn.Close() cancel() return "", fmt.Errorf("elevated execution failed: write: %w", err) } // Set up detach channel 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 wantDetach { close(detachCh) } // readFrames reads from the connection, writing to w and streaming. // Returns exit code when 'x' frame arrives or -1 on error. readFrames := func(w io.Writer, stream func(string)) int { header := make([]byte, 5) for { conn.SetReadDeadline(time.Now().Add(1 * time.Second)) //nolint:errcheck _, err := io.ReadFull(conn, header) if err != nil { if ne, ok := err.(net.Error); ok && ne.Timeout() { // Check for context cancellation select { case <-ctx.Done(): return -1 default: continue } } return -1 } frameType := header[0] length := binary.BigEndian.Uint32(header[1:5]) payload := make([]byte, length) if length > 0 { conn.SetReadDeadline(time.Now().Add(30 * time.Second)) //nolint:errcheck if _, err := io.ReadFull(conn, payload); err != nil { return -1 } } switch frameType { case 'd': w.Write(payload) //nolint:errcheck if stream != nil { stream(string(payload)) } case 'x': var code int fmt.Sscanf(string(payload), "%d", &code) return code } } } // Check if detach was requested select { case <-detachCh: // Detach: background the socket read into a goroutine cmdStr := "elevated: " + cmd if len(cmdStr) > 80 { cmdStr = cmdStr[:77] + "..." } ring := detach.NewRingBuffer(detach.RingBufSize) pid := int(time.Now().UnixNano() & 0x7FFFFFFF) // synthetic PID proc := &detach.Process{ PID: pid, Command: cmdStr, Started: time.Now(), Ring: ring, Done: make(chan struct{}), } e.detachMu.Lock() e.detached = append(e.detached, proc) e.detachMu.Unlock() go func() { defer conn.Close() defer cancel() exitCode := readFrames(ring, nil) proc.Mu.Lock() proc.Exited = true proc.ExitCode = exitCode 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) } return fmt.Sprintf("[detached: pid %d]", pid), nil default: // Normal (foreground) execution — must also handle manual detach. var outputBuf bytes.Buffer streamFn := tools.StreamFunc(ctx) lw := &limitedWriter{ w: &outputBuf, limit: 10 * 1024 * 1024, stream: streamFn, } // Run readFrames in a goroutine so we can select on detach. type frameResult struct{ exitCode int } frameCh := make(chan frameResult, 1) go func() { code := readFrames(lw, nil) frameCh <- frameResult{code} }() select { case fr := <-frameCh: // Normal completion conn.Close() cancel() combined := outputBuf.String() if fr.exitCode != 0 { if combined == "" { return "", fmt.Errorf("elevated execution failed (exit %d)", fr.exitCode) } errOutput := combined const maxErrOutput = 8192 if len(errOutput) > maxErrOutput { errOutput = errOutput[:maxErrOutput] + fmt.Sprintf("\n[...truncated %d bytes in error]", len(combined)-maxErrOutput) } return combined, fmt.Errorf("elevated execution failed (exit %d)\nOutput: %s", fr.exitCode, errOutput) } return combined, nil case <-ctx.Done(): // Timeout or external cancellation conn.Close() cancel() combined := outputBuf.String() if ctx.Err() == context.DeadlineExceeded { return combined, fmt.Errorf("elevated execution timeout after %d seconds", timeout) } return combined, fmt.Errorf("elevated execution interrupted") case <-detachCh: // Manual detach: background the connection read cmdStr := "elevated: " + cmd if len(cmdStr) > 80 { cmdStr = cmdStr[:77] + "..." } ring := detach.NewRingBuffer(detach.RingBufSize) pid := int(time.Now().UnixNano() & 0x7FFFFFFF) // Splice: future output goes to ring buffer, stop streaming lw.mu.Lock() lw.w = ring lw.stream = nil lw.mu.Unlock() proc := &detach.Process{ PID: pid, Command: cmdStr, Started: time.Now(), Ring: ring, Done: make(chan struct{}), } e.detachMu.Lock() e.detached = append(e.detached, proc) e.detachMu.Unlock() go func() { fr := <-frameCh conn.Close() cancel() proc.Mu.Lock() proc.Exited = true proc.ExitCode = fr.exitCode 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]", pid), nil } } } var whitespacePattern = regexp.MustCompile(`\s+`) func (e *Server) checkRateLimit() error { e.rateLimitMu.Lock() defer e.rateLimitMu.Unlock() now := time.Now() if now.Before(e.blockedUntil) { remaining := e.blockedUntil.Sub(now).Round(time.Second) return fmt.Errorf("rate limited: too many validation failures, blocked for %v", remaining) } return nil } func (e *Server) recordValidationFailure() { e.rateLimitMu.Lock() defer e.rateLimitMu.Unlock() now := time.Now() if now.Sub(e.lastFailure) > failureWindow { e.validationFailures = 0 } e.validationFailures++ e.lastFailure = now if e.validationFailures >= maxFailures { e.blockedUntil = now.Add(blockDuration) e.validationFailures = 0 } } // Execute runs code in a sandbox and returns combined stdout+stderr. func (e *Server) Execute(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool) (string, error) { return e.executeWithStdin(ctx, code, language, timeout, sandboxName, trusted, "") } // executeWithStdin is like Execute but feeds stdinData to the command's stdin. // For languages where code is itself passed via stdin (ed, expect, bc), stdinData is ignored. func (e *Server) executeWithStdin(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool, stdinData string, doDetach ...bool) (string, error) { if timeout < 0 { timeout = 30 } if !trusted { if err := e.ValidateCode(code, language); err != nil { return "", err } } var cfg *sandbox.Config var err error if !e.Yolo { cfg, err = loadSandboxConfig(sandboxName) if err != nil { return "", err } } e.wdMu.RLock() workDir := e.cwd e.wdMu.RUnlock() if workDir == "" { workDir, _ = os.Getwd() } var cancel context.CancelFunc if timeout > 0 { ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second) } else { // timeout=0 means no timeout (run indefinitely, e.g. for daemons) ctx, cancel = context.WithCancel(ctx) } // 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 // codeStdin: non-empty means the code itself is fed via stdin (ed, expect, bc). // In these cases stdinData cannot be used simultaneously. var codeStdin string switch language { case "bash", "": interpreter = []string{"bash", "-c", code} default: cancel() return "", fmt.Errorf("unsupported language: %s (only bash is supported)", language) } e.envMu.RLock() envMap := make(map[string]string, len(e.envExtra)) for k, v := range e.envExtra { envMap[k] = v } e.envMu.RUnlock() for _, ev := range os.Environ() { k, v, _ := strings.Cut(ev, "=") if _, ok := envMap[k]; !ok { envMap[k] = v } } if _, ok := envMap["NAMESPACE"]; !ok { if ns := plan9Namespace(envMap); ns != "" { envMap["NAMESPACE"] = ns } } getenv := func(key string) string { return envMap[key] } if e.Yolo { cmd = exec.CommandContext(ctx, interpreter[0], interpreter[1:]...) } else { wrapped, wrapErr := sandbox.WrapCommand(cfg, interpreter, workDir, getenv) if wrapErr != nil { cancel() return "", wrapErr } cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...) } cmd.Dir = workDir switch { case codeStdin != "": cmd.Stdin = strings.NewReader(codeStdin) case stdinData != "": cmd.Stdin = strings.NewReader(stdinData) } e.envMu.RLock() baseEnv := os.Environ() // Remove keys that envExtra overrides so duplicates don't leak through. filtered := baseEnv[:0] for _, ev := range baseEnv { k, _, _ := strings.Cut(ev, "=") if _, overridden := e.envExtra[k]; !overridden { filtered = append(filtered, ev) } } cmd.Env = prependOlliePath(filtered, paths.CfgDir()) cmd.Env = append(cmd.Env, "OLLIE_TOOLS_PATH="+tools.ToolsPath()) for k, v := range e.envExtra { cmd.Env = append(cmd.Env, k+"="+v) } e.envMu.RUnlock() cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { if cmd.Process != nil { syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } return nil } cmd.WaitDelay = time.Second var outputBuf bytes.Buffer lw := &limitedWriter{ w: &outputBuf, limit: 10 * 1024 * 1024, stream: tools.StreamFunc(ctx), } cmd.Stdout = lw cmd.Stderr = lw // 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 { cancel() return "", fmt.Errorf("execution failed: %v", err) } // If detach requested, immediately signal the detach channel if len(doDetach) > 0 && doDetach[0] { close(detachCh) } // 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 { // Cap output in error message to avoid flooding the agent context. // The full output is still returned as the first return value. errOutput := string(output) const maxErrOutput = 8192 if len(errOutput) > maxErrOutput { errOutput = errOutput[:maxErrOutput] + fmt.Sprintf("\n[...truncated %d bytes in error]", len(output)-maxErrOutput) } return string(output), fmt.Errorf("execution failed: %v\nOutput: %s", err, errOutput) } return string(output), nil case <-ctx.Done(): // Context cancelled (interrupt or timeout) cancel() 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 and WaitDelay so context cancellation // won't kill the process after the timeout fires. cmd.Cancel = nil cmd.WaitDelay = 0 cancel() cmdStr := strings.Join(interpreter, " ") if len(cmdStr) > 80 { cmdStr = cmdStr[:77] + "..." } ring := detach.NewRingBuffer(detach.RingBufSize) // Splice: future output goes to ring buffer instead of outputBuf. lw.mu.Lock() lw.w = ring lw.stream = nil lw.mu.Unlock() proc := &detach.Process{ 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 } } // prependOlliePath returns env with $OLLIE_CFG_PATH/scripts/x/ prepended to PATH so wrapper // scripts placed there shadow system binaries. func prependOlliePath(env []string, cfgDir string) []string { if cfgDir == "" { return env } xdir := filepath.Join(cfgDir, "scripts", "x") result := make([]string, 0, len(env)) for _, e := range env { if strings.HasPrefix(e, "PATH=") { e = "PATH=" + xdir + string(filepath.ListSeparator) + e[5:] } result = append(result, e) } return result } // plan9Namespace computes the plan9port namespace directory from the environment. // Convention: $NAMESPACE if set, else /tmp/ns.. where display is // $DISPLAY with trailing .0 stripped and / replaced by _. func plan9Namespace(env map[string]string) string { if ns := env["NAMESPACE"]; ns != "" { return ns } user := env["USER"] if user == "" { return "" } disp := env["DISPLAY"] if disp == "" { return "" } // Canonicalize: strip trailing .0 if strings.HasSuffix(disp, ".0") { disp = disp[:len(disp)-2] } // Replace / with _ disp = strings.ReplaceAll(disp, "/", "_") 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() []*detach.Process { e.detachMu.Lock() defer e.detachMu.Unlock() out := make([]*detach.Process, len(e.detached)) copy(out, e.detached) return out } // ListDetachedInfo returns plain-data snapshots of all detached processes. func (e *Server) ListDetachedInfo() []detach.InfoData { e.detachMu.Lock() defer e.detachMu.Unlock() out := make([]detach.InfoData, len(e.detached)) for i, p := range e.detached { out[i] = p.Info() } return out } // ListDetachedRaw returns detached process info as []any (each element is map[string]any) // for consumption by packages that can't import this package directly. func (e *Server) ListDetachedRaw() []any { e.detachMu.Lock() defer e.detachMu.Unlock() out := make([]any, len(e.detached)) for i, p := range e.detached { info := p.Info() out[i] = map[string]any{ "pid": info.PID, "command": info.Command, "started": info.Started, "exited": info.Exited, "exit_code": info.ExitCode, } } 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