feat(tools): add exec tool enhancement with background execution and PTY support
Merge #1869: Unified exec tool with actions (run/list/poll/read/write/send-keys/kill), PTY support, background execution, process session management.
This commit is contained in:
commit
70c4714988
14 changed files with 2041 additions and 25 deletions
8
Makefile
8
Makefile
|
|
@ -47,6 +47,13 @@ define PATCH_MIPS_FLAGS
|
||||||
fi
|
fi
|
||||||
endef
|
endef
|
||||||
|
|
||||||
|
# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go)
|
||||||
|
PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
|
||||||
|
if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \
|
||||||
|
chmod +w "$$pty_dir" 2>/dev/null || true; \
|
||||||
|
printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
GOLANGCI_LINT?=golangci-lint
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
|
|
@ -190,6 +197,7 @@ build-all: generate
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
@$(PTY_PATCH_LOONG64)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -12,6 +12,7 @@ require (
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
|
github.com/creack/pty v1.1.9
|
||||||
github.com/ergochat/irc-go v0.6.0
|
github.com/ergochat/irc-go v0.6.0
|
||||||
github.com/ergochat/readline v0.1.3
|
github.com/ergochat/readline v0.1.3
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
|
|
|
||||||
1
go.sum
1
go.sum
|
|
@ -69,6 +69,7 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
|
|
||||||
|
|
@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||||
t.Fatal("exec tool not registered")
|
t.Fatal("exec tool not registered")
|
||||||
}
|
}
|
||||||
execResult := execTool.Execute(context.Background(), map[string]any{
|
execResult := execTool.Execute(context.Background(), map[string]any{
|
||||||
"command": "cat " + filepath.Base(mediaPath),
|
"action": "run",
|
||||||
"working_dir": mediaDir,
|
"command": "cat " + filepath.Base(mediaPath),
|
||||||
|
"cwd": mediaDir,
|
||||||
})
|
})
|
||||||
if execResult.IsError {
|
if execResult.IsError {
|
||||||
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
|
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
|
||||||
|
|
|
||||||
252
pkg/tools/session.go
Normal file
252
pkg/tools/session.go
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxOutputBufferSize = 1 * 1024 * 1024 // 1MB
|
||||||
|
|
||||||
|
const outputTruncateMarker = "\n... [output truncated, exceeded 1MB]\n"
|
||||||
|
|
||||||
|
// PtyKeyMode represents arrow key encoding mode for PTY sessions.
|
||||||
|
// Programs send smkx/rmkx sequences to switch between CSI and SS3 modes.
|
||||||
|
type PtyKeyMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
PtyKeyModeCSI PtyKeyMode = iota // triggered by rmkx (\x1b[?1l)
|
||||||
|
PtyKeyModeSS3 // triggered by smkx (\x1b[?1h)
|
||||||
|
)
|
||||||
|
|
||||||
|
const PtyKeyModeNotFound PtyKeyMode = 255
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSessionNotFound = errors.New("session not found")
|
||||||
|
ErrSessionDone = errors.New("session already completed")
|
||||||
|
ErrPTYNotSupported = errors.New("PTY is not supported on this platform")
|
||||||
|
ErrNoStdin = errors.New("no stdin available")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessSession struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ID string
|
||||||
|
PID int
|
||||||
|
Command string
|
||||||
|
PTY bool
|
||||||
|
Background bool
|
||||||
|
StartTime int64
|
||||||
|
ExitCode int
|
||||||
|
Status string
|
||||||
|
stdinWriter io.Writer
|
||||||
|
stdoutPipe io.Reader
|
||||||
|
outputBuffer *bytes.Buffer
|
||||||
|
outputTruncated bool
|
||||||
|
ptyMaster *os.File
|
||||||
|
|
||||||
|
// ptyKeyMode tracks arrow key encoding mode (CSI vs SS3)
|
||||||
|
ptyKeyMode PtyKeyMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) IsDone() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.Status == "done" || s.Status == "exited"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) GetPtyKeyMode() PtyKeyMode {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.ptyKeyMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) SetPtyKeyMode(mode PtyKeyMode) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.ptyKeyMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) GetStatus() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) SetStatus(status string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.Status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) GetExitCode() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.ExitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) SetExitCode(code int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.ExitCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) killProcess() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.Status != "running" {
|
||||||
|
return ErrSessionDone
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := s.PID
|
||||||
|
if pid <= 0 {
|
||||||
|
return ErrSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := killProcessGroup(pid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Status = "done"
|
||||||
|
s.ExitCode = -1
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) Kill() error {
|
||||||
|
return s.killProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) Write(data string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.Status != "running" {
|
||||||
|
return ErrSessionDone
|
||||||
|
}
|
||||||
|
|
||||||
|
var writer io.Writer
|
||||||
|
if s.PTY && s.ptyMaster != nil {
|
||||||
|
writer = s.ptyMaster
|
||||||
|
} else if s.stdinWriter != nil {
|
||||||
|
writer = s.stdinWriter
|
||||||
|
} else {
|
||||||
|
return ErrNoStdin
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := writer.Write([]byte(data))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) Read() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.outputBuffer.Len() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
data := s.outputBuffer.String()
|
||||||
|
s.outputBuffer.Reset()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSession) ToSessionInfo() SessionInfo {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
return SessionInfo{
|
||||||
|
ID: s.ID,
|
||||||
|
Command: s.Command,
|
||||||
|
Status: s.Status,
|
||||||
|
PID: s.PID,
|
||||||
|
StartedAt: s.StartTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*ProcessSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionManager() *SessionManager {
|
||||||
|
sm := &SessionManager{
|
||||||
|
sessions: make(map[string]*ProcessSession),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cleaner goroutine - runs every 5 minutes, cleans up sessions done for >30 minutes
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
sm.cleanupOldSessions()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return sm
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldSessions removes sessions that are done and older than 30 minutes
|
||||||
|
func (sm *SessionManager) cleanupOldSessions() {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-30 * time.Minute)
|
||||||
|
for id, session := range sm.sessions {
|
||||||
|
if session.IsDone() && session.StartTime < cutoff.Unix() {
|
||||||
|
delete(sm.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) Add(session *ProcessSession) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
sm.sessions[session.ID] = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) Get(sessionID string) (*ProcessSession, error) {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
session, ok := sm.sessions[sessionID]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) Remove(sessionID string) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
delete(sm.sessions, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) List() []SessionInfo {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]SessionInfo, 0, len(sm.sessions))
|
||||||
|
for _, session := range sm.sessions {
|
||||||
|
result = append(result, session.ToSessionInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSessionID() string {
|
||||||
|
return uuid.New().String()[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
StartedAt int64 `json:"startedAt"`
|
||||||
|
}
|
||||||
14
pkg/tools/session_process_unix.go
Normal file
14
pkg/tools/session_process_unix.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func killProcessGroup(pid int) error {
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
13
pkg/tools/session_process_windows.go
Normal file
13
pkg/tools/session_process_windows.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func killProcessGroup(pid int) error {
|
||||||
|
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
99
pkg/tools/session_test.go
Normal file
99
pkg/tools/session_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionManager_AddGet(t *testing.T) {
|
||||||
|
sm := NewSessionManager()
|
||||||
|
session := &ProcessSession{
|
||||||
|
ID: "test-1",
|
||||||
|
Command: "echo hello",
|
||||||
|
Status: "running",
|
||||||
|
StartTime: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.Add(session)
|
||||||
|
|
||||||
|
got, err := sm.Get("test-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "test-1", got.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionManager_Remove(t *testing.T) {
|
||||||
|
sm := NewSessionManager()
|
||||||
|
session := &ProcessSession{
|
||||||
|
ID: "test-1",
|
||||||
|
Command: "echo hello",
|
||||||
|
Status: "running",
|
||||||
|
StartTime: 1000,
|
||||||
|
}
|
||||||
|
sm.Add(session)
|
||||||
|
sm.Remove("test-1")
|
||||||
|
|
||||||
|
_, err := sm.Get("test-1")
|
||||||
|
require.ErrorIs(t, err, ErrSessionNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionManager_List(t *testing.T) {
|
||||||
|
sm := NewSessionManager()
|
||||||
|
sm.Add(&ProcessSession{
|
||||||
|
ID: "test-1",
|
||||||
|
Command: "echo hello",
|
||||||
|
Status: "running",
|
||||||
|
StartTime: 1000,
|
||||||
|
})
|
||||||
|
sm.Add(&ProcessSession{
|
||||||
|
ID: "test-2",
|
||||||
|
Command: "echo world",
|
||||||
|
Status: "running",
|
||||||
|
StartTime: 1001,
|
||||||
|
})
|
||||||
|
sm.Add(&ProcessSession{
|
||||||
|
ID: "test-3",
|
||||||
|
Command: "echo done",
|
||||||
|
Status: "done",
|
||||||
|
StartTime: 1002,
|
||||||
|
})
|
||||||
|
|
||||||
|
sessions := sm.List()
|
||||||
|
require.Len(t, sessions, 3)
|
||||||
|
|
||||||
|
ids := make(map[string]bool)
|
||||||
|
for _, s := range sessions {
|
||||||
|
ids[s.ID] = true
|
||||||
|
}
|
||||||
|
require.True(t, ids["test-1"])
|
||||||
|
require.True(t, ids["test-2"])
|
||||||
|
require.True(t, ids["test-3"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSession_IsDone(t *testing.T) {
|
||||||
|
session := &ProcessSession{Status: "running"}
|
||||||
|
require.False(t, session.IsDone())
|
||||||
|
|
||||||
|
session.Status = "done"
|
||||||
|
require.True(t, session.IsDone())
|
||||||
|
|
||||||
|
session.Status = "exited"
|
||||||
|
require.True(t, session.IsDone())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSession_ToSessionInfo(t *testing.T) {
|
||||||
|
session := &ProcessSession{
|
||||||
|
ID: "test-1",
|
||||||
|
PID: 12345,
|
||||||
|
Command: "echo hello",
|
||||||
|
Status: "running",
|
||||||
|
StartTime: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
info := session.ToSessionInfo()
|
||||||
|
require.Equal(t, "test-1", info.ID)
|
||||||
|
require.Equal(t, "echo hello", info.Command)
|
||||||
|
require.Equal(t, "running", info.Status)
|
||||||
|
require.Equal(t, 12345, info.PID)
|
||||||
|
require.Equal(t, int64(1000), info.StartedAt)
|
||||||
|
}
|
||||||
|
|
@ -3,20 +3,36 @@ package tools
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalSessionManager = NewSessionManager()
|
||||||
|
sessionManagerMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func getSessionManager() *SessionManager {
|
||||||
|
sessionManagerMu.RLock()
|
||||||
|
defer sessionManagerMu.RUnlock()
|
||||||
|
return globalSessionManager
|
||||||
|
}
|
||||||
|
|
||||||
type ExecTool struct {
|
type ExecTool struct {
|
||||||
workingDir string
|
workingDir string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
|
|
@ -26,6 +42,7 @@ type ExecTool struct {
|
||||||
allowedPathPatterns []*regexp.Regexp
|
allowedPathPatterns []*regexp.Regexp
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
allowRemote bool
|
allowRemote bool
|
||||||
|
sessionManager *SessionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -145,7 +162,7 @@ func NewExecToolWithConfig(
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout := 60 * time.Second
|
var timeout time.Duration
|
||||||
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
|
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
|
||||||
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
|
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +176,7 @@ func NewExecToolWithConfig(
|
||||||
allowedPathPatterns: allowedPathPatterns,
|
allowedPathPatterns: allowedPathPatterns,
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
allowRemote: allowRemote,
|
allowRemote: allowRemote,
|
||||||
|
sessionManager: getSessionManager(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,27 +185,82 @@ func (t *ExecTool) Name() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) Description() string {
|
func (t *ExecTool) Description() string {
|
||||||
return "Execute a shell command and return its output. Use with caution."
|
return `Execute shell commands. Use background=true for long-running commands (returns sessionId). Use pty=true for interactive commands (can combine with background=true). Use poll/read/write/send-keys/kill with sessionId to manage background sessions. Sessions auto-cleanup 30 minutes after process exits; use kill to terminate early. Output buffer limit: 1MB.`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) Parameters() map[string]any {
|
func (t *ExecTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"run", "list", "poll", "read", "write", "kill", "send-keys"},
|
||||||
|
"description": "Action: run (execute command), list (show sessions), poll (check status), read (get output), write (send input), kill (terminate), send-keys (send keys to PTY)",
|
||||||
|
},
|
||||||
"command": map[string]any{
|
"command": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The shell command to execute",
|
"description": "Shell command to execute (required for run)",
|
||||||
},
|
},
|
||||||
"working_dir": map[string]any{
|
"sessionId": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional working directory for the command",
|
"description": "Session ID (required for poll/read/write/kill/send-keys)",
|
||||||
|
},
|
||||||
|
"keys": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key names for send-keys: up, down, left, right, enter, tab, escape, backspace, ctrl-c, ctrl-d, home, end, pageup, pagedown, f1-f12",
|
||||||
|
},
|
||||||
|
"data": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Data to write to stdin (required for write)",
|
||||||
|
},
|
||||||
|
"background": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Run in background immediately",
|
||||||
|
},
|
||||||
|
"pty": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Run in a pseudo-terminal (PTY) when available",
|
||||||
|
},
|
||||||
|
"cwd": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Working directory for the command",
|
||||||
|
},
|
||||||
|
"timeout": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Timeout in seconds (0 = no timeout)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"command"},
|
"required": []string{"action"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
if action == "" {
|
||||||
|
return ErrorResult("action is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "run":
|
||||||
|
return t.executeRun(ctx, args)
|
||||||
|
case "list":
|
||||||
|
return t.executeList()
|
||||||
|
case "poll":
|
||||||
|
return t.executePoll(args)
|
||||||
|
case "read":
|
||||||
|
return t.executeRead(args)
|
||||||
|
case "write":
|
||||||
|
return t.executeWrite(args)
|
||||||
|
case "kill":
|
||||||
|
return t.executeKill(args)
|
||||||
|
case "send-keys":
|
||||||
|
return t.executeSendKeys(args)
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
command, ok := args["command"].(string)
|
command, ok := args["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("command is required")
|
return ErrorResult("command is required")
|
||||||
|
|
@ -206,8 +279,26 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBoolArg := func(key string) bool {
|
||||||
|
switch v := args[key].(type) {
|
||||||
|
case bool:
|
||||||
|
return v
|
||||||
|
case string:
|
||||||
|
return v == "true"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
isPty := getBoolArg("pty")
|
||||||
|
isBackground := getBoolArg("background")
|
||||||
|
|
||||||
|
if isPty {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return ErrorResult("PTY is not supported on Windows. Use background=true without pty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cwd := t.workingDir
|
cwd := t.workingDir
|
||||||
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
if wd, ok := args["cwd"].(string); ok && wd != "" {
|
||||||
if t.restrictToWorkspace && t.workingDir != "" {
|
if t.restrictToWorkspace && t.workingDir != "" {
|
||||||
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
|
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -253,6 +344,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isBackground {
|
||||||
|
return t.runBackground(ctx, command, cwd, isPty)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.runSync(ctx, command, cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult {
|
||||||
// timeout == 0 means no timeout
|
// timeout == 0 means no timeout
|
||||||
var cmdCtx context.Context
|
var cmdCtx context.Context
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
|
|
@ -361,6 +460,560 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult {
|
||||||
|
sessionID := generateSessionID()
|
||||||
|
session := &ProcessSession{
|
||||||
|
ID: sessionID,
|
||||||
|
Command: command,
|
||||||
|
PTY: ptyEnabled,
|
||||||
|
Background: true,
|
||||||
|
StartTime: time.Now().Unix(),
|
||||||
|
Status: "running",
|
||||||
|
ptyKeyMode: PtyKeyModeCSI,
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("sh", "-c", command)
|
||||||
|
}
|
||||||
|
if cwd != "" {
|
||||||
|
cmd.Dir = cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareCommandForTermination(cmd)
|
||||||
|
|
||||||
|
var stdoutReader io.ReadCloser
|
||||||
|
var stderrReader io.ReadCloser
|
||||||
|
var stdinWriter io.WriteCloser
|
||||||
|
|
||||||
|
if ptyEnabled {
|
||||||
|
ptmx, tty, err := pty.Open()
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to create PTY: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Stdin = tty
|
||||||
|
cmd.Stdout = tty
|
||||||
|
cmd.Stderr = tty
|
||||||
|
|
||||||
|
// For PTY, we need Setsid to create a new session.
|
||||||
|
// Note: Setsid and Setpgid conflict, so we must replace SysProcAttr entirely.
|
||||||
|
setSysProcAttrForPty(cmd)
|
||||||
|
|
||||||
|
session.ptyMaster = ptmx
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
stdoutReader, err = cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to create stdout pipe: %v", err))
|
||||||
|
}
|
||||||
|
stderrReader, err = cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to create stderr pipe: %v", err))
|
||||||
|
}
|
||||||
|
stdinWriter, err = cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to create stdin pipe: %v", err))
|
||||||
|
}
|
||||||
|
session.stdoutPipe = io.MultiReader(stdoutReader, stderrReader)
|
||||||
|
session.stdinWriter = stdinWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
if session.ptyMaster != nil {
|
||||||
|
session.ptyMaster.Close()
|
||||||
|
}
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
session.PID = cmd.Process.Pid
|
||||||
|
t.sessionManager.Add(session)
|
||||||
|
|
||||||
|
session.outputBuffer = &bytes.Buffer{}
|
||||||
|
|
||||||
|
// PTY mode: read from ptyMaster and wait for process
|
||||||
|
// Note: On Linux, closing ptyMaster doesn't interrupt blocking Read() calls,
|
||||||
|
// so we need cmd.Wait() in a separate goroutine to detect process exit.
|
||||||
|
if session.PTY && session.ptyMaster != nil {
|
||||||
|
go func() {
|
||||||
|
cmd.Wait() // Wait for process to exit
|
||||||
|
session.mu.Lock()
|
||||||
|
if cmd.ProcessState != nil {
|
||||||
|
session.ExitCode = cmd.ProcessState.ExitCode()
|
||||||
|
}
|
||||||
|
session.Status = "done"
|
||||||
|
session.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := session.ptyMaster.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
raw := string(buf[:n])
|
||||||
|
if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() {
|
||||||
|
session.SetPtyKeyMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.mu.Lock()
|
||||||
|
if session.outputBuffer.Len() >= maxOutputBufferSize {
|
||||||
|
if !session.outputTruncated {
|
||||||
|
session.outputBuffer.WriteString(outputTruncateMarker)
|
||||||
|
session.outputTruncated = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.outputBuffer.Write(buf[:n])
|
||||||
|
}
|
||||||
|
session.mu.Unlock()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
// Non-PTY mode: single goroutine reads pipes.
|
||||||
|
// When Read() returns EOF (pipe closed), we break.
|
||||||
|
// When process exits, OS closes pipe write end → Read() returns EOF → we exit.
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
|
||||||
|
// Read stdout
|
||||||
|
for {
|
||||||
|
n, err := stdoutReader.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
session.mu.Lock()
|
||||||
|
if session.outputBuffer.Len() >= maxOutputBufferSize {
|
||||||
|
if !session.outputTruncated {
|
||||||
|
session.outputBuffer.WriteString(outputTruncateMarker)
|
||||||
|
session.outputTruncated = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.outputBuffer.Write(buf[:n])
|
||||||
|
}
|
||||||
|
session.mu.Unlock()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read stderr
|
||||||
|
for {
|
||||||
|
n, err := stderrReader.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
session.mu.Lock()
|
||||||
|
if session.outputBuffer.Len() >= maxOutputBufferSize {
|
||||||
|
if !session.outputTruncated {
|
||||||
|
session.outputBuffer.WriteString(outputTruncateMarker)
|
||||||
|
session.outputTruncated = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.outputBuffer.Write(buf[:n])
|
||||||
|
}
|
||||||
|
session.mu.Unlock()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All pipes closed, get exit status
|
||||||
|
if stdinWriter != nil {
|
||||||
|
stdinWriter.Close()
|
||||||
|
}
|
||||||
|
cmd.Wait()
|
||||||
|
|
||||||
|
session.mu.Lock()
|
||||||
|
if cmd.ProcessState != nil {
|
||||||
|
session.ExitCode = cmd.ProcessState.ExitCode()
|
||||||
|
}
|
||||||
|
session.Status = "done"
|
||||||
|
session.mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ExecResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "running",
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: fmt.Sprintf("Session %s started", sessionID),
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) executeList() *ToolResult {
|
||||||
|
sessions := t.sessionManager.List()
|
||||||
|
resp := ExecResponse{
|
||||||
|
Sessions: sessions,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: fmt.Sprintf("%d active sessions", len(sessions)),
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) executePoll(args map[string]any) *ToolResult {
|
||||||
|
sessionID, ok := args["sessionId"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("sessionId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := t.sessionManager.Get(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSessionNotFound) {
|
||||||
|
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||||
|
}
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ExecResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: session.GetStatus(),
|
||||||
|
ExitCode: session.GetExitCode(),
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) executeRead(args map[string]any) *ToolResult {
|
||||||
|
sessionID, ok := args["sessionId"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("sessionId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := t.sessionManager.Get(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSessionNotFound) {
|
||||||
|
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||||
|
}
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
output := session.Read()
|
||||||
|
|
||||||
|
resp := ExecResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Output: output,
|
||||||
|
Status: session.GetStatus(),
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) executeWrite(args map[string]any) *ToolResult {
|
||||||
|
sessionID, ok := args["sessionId"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("sessionId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := args["data"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("data is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := t.sessionManager.Get(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSessionNotFound) {
|
||||||
|
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||||
|
}
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.IsDone() {
|
||||||
|
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Write(data); err != nil {
|
||||||
|
if errors.Is(err, ErrSessionDone) {
|
||||||
|
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||||
|
}
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to write to session: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ExecResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: session.GetStatus(),
|
||||||
|
}
|
||||||
|
respData, _ := json.Marshal(resp)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(respData),
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) executeKill(args map[string]any) *ToolResult {
|
||||||
|
sessionID, ok := args["sessionId"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("sessionId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := t.sessionManager.Get(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSessionNotFound) {
|
||||||
|
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||||
|
}
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.IsDone() {
|
||||||
|
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Kill(); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to kill session: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.sessionManager.Remove(sessionID)
|
||||||
|
|
||||||
|
resp := ExecResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "done",
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: fmt.Sprintf("Session %s killed", sessionID),
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyMap maps key names to their escape sequences.
|
||||||
|
var keyMap = map[string]string{
|
||||||
|
"enter": "\r",
|
||||||
|
"return": "\r",
|
||||||
|
"tab": "\t",
|
||||||
|
"escape": "\x1b",
|
||||||
|
"esc": "\x1b",
|
||||||
|
"space": " ",
|
||||||
|
"backspace": "\x7f",
|
||||||
|
"bspace": "\x7f",
|
||||||
|
"up": "\x1b[A",
|
||||||
|
"down": "\x1b[B",
|
||||||
|
"right": "\x1b[C",
|
||||||
|
"left": "\x1b[D",
|
||||||
|
"home": "\x1b[1~",
|
||||||
|
"end": "\x1b[4~",
|
||||||
|
"pageup": "\x1b[5~",
|
||||||
|
"pagedown": "\x1b[6~",
|
||||||
|
"pgup": "\x1b[5~",
|
||||||
|
"pgdn": "\x1b[6~",
|
||||||
|
"insert": "\x1b[2~",
|
||||||
|
"ic": "\x1b[2~",
|
||||||
|
"delete": "\x1b[3~",
|
||||||
|
"del": "\x1b[3~",
|
||||||
|
"dc": "\x1b[3~",
|
||||||
|
"btab": "\x1b[Z",
|
||||||
|
"f1": "\x1bOP",
|
||||||
|
"f2": "\x1bOQ",
|
||||||
|
"f3": "\x1bOR",
|
||||||
|
"f4": "\x1bOS",
|
||||||
|
"f5": "\x1b[15~",
|
||||||
|
"f6": "\x1b[17~",
|
||||||
|
"f7": "\x1b[18~",
|
||||||
|
"f8": "\x1b[19~",
|
||||||
|
"f9": "\x1b[20~",
|
||||||
|
"f10": "\x1b[21~",
|
||||||
|
"f11": "\x1b[23~",
|
||||||
|
"f12": "\x1b[24~",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ss3KeysMap maps key names to SS3 escape sequences
|
||||||
|
var ss3KeysMap = map[string]string{
|
||||||
|
"up": "\x1bOA",
|
||||||
|
"down": "\x1bOB",
|
||||||
|
"right": "\x1bOC",
|
||||||
|
"left": "\x1bOD",
|
||||||
|
"home": "\x1bOH",
|
||||||
|
"end": "\x1bOF",
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPtyKeyMode(raw string) PtyKeyMode {
|
||||||
|
const SMKX = "\x1b[?1h"
|
||||||
|
const RMKX = "\x1b[?1l"
|
||||||
|
|
||||||
|
lastSmkx := strings.LastIndex(raw, SMKX)
|
||||||
|
lastRmkx := strings.LastIndex(raw, RMKX)
|
||||||
|
|
||||||
|
if lastSmkx == -1 && lastRmkx == -1 {
|
||||||
|
return PtyKeyModeNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastSmkx > lastRmkx {
|
||||||
|
return PtyKeyModeSS3
|
||||||
|
}
|
||||||
|
return PtyKeyModeCSI
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeKeyToken encodes a single key token into its escape sequence.
|
||||||
|
// Supports:
|
||||||
|
// - Named keys: "enter", "tab", "up", "ctrl-c", "alt-x", etc.
|
||||||
|
// - Ctrl modifier: "ctrl-c" or "c-c" (sends Ctrl+char)
|
||||||
|
// - Alt modifier: "alt-x" or "m-x" (sends ESC+char)
|
||||||
|
func encodeKeyToken(token string, ptyKeyMode PtyKeyMode) (string, error) {
|
||||||
|
token = strings.ToLower(strings.TrimSpace(token))
|
||||||
|
if token == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ctrl-X format (c-x)
|
||||||
|
if strings.HasPrefix(token, "c-") {
|
||||||
|
char := token[2]
|
||||||
|
if char >= 'a' && char <= 'z' {
|
||||||
|
return string(rune(char) & 0x1f), nil // ctrl-a through ctrl-z
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("invalid ctrl key: %s", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ctrl-X format (ctrl-x)
|
||||||
|
if strings.HasPrefix(token, "ctrl-") {
|
||||||
|
char := token[5]
|
||||||
|
if char >= 'a' && char <= 'z' {
|
||||||
|
return string(rune(char) & 0x1f), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("invalid ctrl key: %s", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle alt-X format (m-x or alt-x)
|
||||||
|
if strings.HasPrefix(token, "m-") || strings.HasPrefix(token, "alt-") {
|
||||||
|
var char string
|
||||||
|
if strings.HasPrefix(token, "m-") {
|
||||||
|
char = token[2:]
|
||||||
|
} else {
|
||||||
|
char = token[4:]
|
||||||
|
}
|
||||||
|
if len(char) == 1 {
|
||||||
|
return "\x1b" + char, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("invalid alt key: %s", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shift modifier for special keys (shift-up, shift-down, etc.)
|
||||||
|
if strings.HasPrefix(token, "s-") || strings.HasPrefix(token, "shift-") {
|
||||||
|
var key string
|
||||||
|
if strings.HasPrefix(token, "s-") {
|
||||||
|
key = token[2:]
|
||||||
|
} else {
|
||||||
|
key = token[6:]
|
||||||
|
}
|
||||||
|
// Apply shift modifier: for single-char keys, return uppercase
|
||||||
|
if seq, ok := keyMap[key]; ok {
|
||||||
|
// For escape sequences, we can't easily add shift
|
||||||
|
// For single-char keys (letters), return uppercase
|
||||||
|
if len(seq) == 1 {
|
||||||
|
return strings.ToUpper(seq), nil
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown key with shift: %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ptyKeyMode == PtyKeyModeSS3 {
|
||||||
|
if seq, ok := ss3KeysMap[token]; ok {
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if seq, ok := keyMap[token]; ok {
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unknown key: %s (use write action for text input)", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeKeySequence encodes a slice of key tokens into a single string.
|
||||||
|
func encodeKeySequence(tokens []string, ptyKeyMode PtyKeyMode) (string, error) {
|
||||||
|
var result string
|
||||||
|
for _, token := range tokens {
|
||||||
|
seq, err := encodeKeyToken(token, ptyKeyMode)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
result += seq
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult {
|
||||||
|
sessionID, ok := args["sessionId"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("sessionId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
keysStr, ok := args["keys"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("keys must be a string")
|
||||||
|
}
|
||||||
|
|
||||||
|
if keysStr == "" {
|
||||||
|
return ErrorResult("keys cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse comma-separated key names
|
||||||
|
keyNames := strings.Split(keysStr, ",")
|
||||||
|
var keys []string
|
||||||
|
for _, k := range keyNames {
|
||||||
|
k = strings.TrimSpace(k)
|
||||||
|
if k != "" {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return ErrorResult("keys cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := t.sessionManager.Get(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSessionNotFound) {
|
||||||
|
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||||
|
}
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
ptyKeyMode := session.GetPtyKeyMode()
|
||||||
|
|
||||||
|
data, err := encodeKeySequence(keys, ptyKeyMode)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid key: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.IsDone() {
|
||||||
|
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Write(data); err != nil {
|
||||||
|
if errors.Is(err, ErrSessionDone) {
|
||||||
|
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||||
|
}
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to send keys: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ExecResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "running",
|
||||||
|
Output: fmt.Sprintf("Sent keys: %v", keys),
|
||||||
|
}
|
||||||
|
respData, _ := json.Marshal(resp)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(respData),
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ExecTool) guardCommand(command, cwd string) string {
|
func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
cmd := strings.TrimSpace(command)
|
cmd := strings.TrimSpace(command)
|
||||||
lower := strings.ToLower(cmd)
|
lower := strings.ToLower(cmd)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
|
||||||
tool.SetTimeout(500 * time.Millisecond)
|
tool.SetTimeout(500 * time.Millisecond)
|
||||||
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
|
"action": "run",
|
||||||
// Spawn a child process that would outlive the shell unless process-group kill is used.
|
// Spawn a child process that would outlive the shell unless process-group kill is used.
|
||||||
"command": "sleep 60 & echo $! > child.pid; wait",
|
"command": "sleep 60 & echo $! > child.pid; wait",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
pkg/tools/sysproc_unix.go
Normal file
12
pkg/tools/sysproc_unix.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setSysProcAttrForPty(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
}
|
||||||
10
pkg/tools/sysproc_windows.go
Normal file
10
pkg/tools/sysproc_windows.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func setSysProcAttrForPty(cmd *exec.Cmd) {
|
||||||
|
// Windows doesn't support Setsid, and PTY is not available on Windows anyway.
|
||||||
|
// This function is a no-op for Windows builds.
|
||||||
|
}
|
||||||
|
|
@ -56,3 +56,24 @@ type ToolFunctionDefinition struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Parameters map[string]any `json:"parameters"`
|
Parameters map[string]any `json:"parameters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExecRequest struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
PTY bool `json:"pty,omitempty"`
|
||||||
|
Background bool `json:"background,omitempty"`
|
||||||
|
Timeout int `json:"timeout,omitempty"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecResponse struct {
|
||||||
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
ExitCode int `json:"exitCode,omitempty"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Sessions []SessionInfo `json:"sessions,omitempty"`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue