Merge branch 'sipeed:main' into feat/audio-call

This commit is contained in:
zhangmikoto 2026-03-21 16:14:44 +01:00 committed by GitHub
commit 7f9e4d1021
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2110 additions and 42 deletions

View file

@ -46,3 +46,7 @@ Feishu (nom international : Lark) est une plateforme de collaboration d'entrepri
> `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production. > `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production.
> >
> Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) > Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Limitations de plateforme
> ⚠️ **Le canal Feishu ne prend pas en charge les appareils 32 bits.** Le SDK Feishu ne fournit que des builds 64 bits. Les architectures 32 bits (armv6, armv7, mipsle, etc.) ne peuvent pas utiliser le canal Feishu. Pour la messagerie sur des appareils 32 bits, utilisez Telegram, Discord ou OneBot.

View file

@ -46,3 +46,7 @@
> `encrypt_key``verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。 > `encrypt_key``verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。
> >
> カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) > カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## プラットフォーム制限
> ⚠️ **飛書チャネルは 32 ビットデバイスをサポートしていません。** 飛書 SDK は 64 ビットビルドのみ提供しています。armv6 / armv7 / mipsle などの 32 ビットアーキテクチャでは飛書チャネルを使用できません。32 ビットデバイスでのメッセージングには、Telegram、Discord、または OneBot をご利用ください。

View file

@ -46,3 +46,7 @@ Feishu (international name: Lark) is an enterprise collaboration platform by Byt
> `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments. > `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments.
> >
> For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) > For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Platform Limitations
> ⚠️ **Feishu channel does not support 32-bit devices.** The Feishu SDK only provides 64-bit builds. Devices running armv6, armv7, mipsle, or other 32-bit architectures cannot use the Feishu channel. For messaging on 32-bit devices, use Telegram, Discord, or OneBot instead.

View file

@ -46,3 +46,7 @@ Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial
> `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção. > `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção.
> >
> Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) > Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Limitações de Plataforma
> ⚠️ **O canal Feishu não suporta dispositivos 32 bits.** O SDK do Feishu fornece apenas builds 64 bits. Arquiteturas 32 bits (armv6, armv7, mipsle, etc.) não podem usar o canal Feishu. Para mensagens em dispositivos 32 bits, use Telegram, Discord ou OneBot.

View file

@ -46,3 +46,7 @@ Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp củ
> `encrypt_key``verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất. > `encrypt_key``verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất.
> >
> Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) > Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Giới hạn nền tảng
> ⚠️ **Kênh Feishu không hỗ trợ thiết bị 32 bit.** SDK Feishu chỉ cung cấp bản build 64 bit. Các kiến trúc 32 bit (armv6, armv7, mipsle, v.v.) không thể sử dụng kênh Feishu. Để nhắn tin trên thiết bị 32 bit, hãy dùng Telegram, Discord hoặc OneBot.

View file

@ -35,16 +35,13 @@
## 设置流程 ## 设置流程
1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用 1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用程序
2. 在应用设置中启用**机器人**能力 2. 获取 App ID 和 App Secret
3. 创建版本并发布应用(应用发布后配置才会生效) 3. 配置事件订阅和Webhook URL
4. 获取 **App ID**(以 `cli_` 开头)和 **App Secret** 4. 设置加密(可选,生产环境建议启用)
5. 将 App ID 和 App Secret 填入 PicoClaw 配置文件 5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
6. 运行 `picoclaw gateway` 启动服务 6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
7. 在飞书中搜索机器人名称,开始对话
> PicoClaw 使用 WebSocket/SDK 模式连接飞书,无需配置公网回调地址或 Webhook URL。 ## 平台限制
>
> `encrypt_key``verification_token` 为可选项,生产环境建议启用事件加密。 > ⚠️ **飞书通道不支持 32 位设备。** 飞书官方 SDK 仅提供 64 位构建armv6 / armv7 / mipsle 等 32 位架构无法使用飞书通道。如需在 32 位设备上接入即时通讯,请改用 Telegram、Discord 或 OneBot 等通道。
>
> 自定义表情参考:[飞书表情列表](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)

1
go.mod
View file

@ -9,6 +9,7 @@ require (
github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/anthropics/anthropic-sdk-go v1.26.0
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
View file

@ -37,6 +37,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=

View file

@ -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{
"action": "run",
"command": "cat " + filepath.Base(mediaPath), "command": "cat " + filepath.Base(mediaPath),
"working_dir": mediaDir, "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
View file

@ -0,0 +1,252 @@
package tools
import (
"bytes"
"errors"
"io"
"os"
"sync"
"time"
"github.com/google/uuid"
)
const maxOutputBufferSize = 100 * 1024 * 1024 // 100MB
const outputTruncateMarker = "\n... [output truncated, exceeded 100MB]\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"`
}

View 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
}

View 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
View 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)
}

View file

@ -3,20 +3,37 @@ 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"
"syscall"
"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 +43,7 @@ type ExecTool struct {
allowedPathPatterns []*regexp.Regexp allowedPathPatterns []*regexp.Regexp
restrictToWorkspace bool restrictToWorkspace bool
allowRemote bool allowRemote bool
sessionManager *SessionManager
} }
var ( var (
@ -145,7 +163,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 +177,7 @@ func NewExecToolWithConfig(
allowedPathPatterns: allowedPathPatterns, allowedPathPatterns: allowedPathPatterns,
restrictToWorkspace: restrict, restrictToWorkspace: restrict,
allowRemote: allowRemote, allowRemote: allowRemote,
sessionManager: getSessionManager(),
}, nil }, nil
} }
@ -167,27 +186,146 @@ 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: 100MB.`
} }
func (t *ExecTool) Parameters() map[string]any { func (t *ExecTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"oneOf": []map[string]any{
{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"command": map[string]any{ "action": map[string]any{"const": "run", "description": "Execute a shell command"},
"command": map[string]any{"type": "string", "description": "Shell command to execute"},
"background": map[string]any{
"type": "string", "type": "string",
"description": "The shell command to execute", "description": "Run in background immediately",
}, },
"working_dir": map[string]any{ "pty": map[string]any{
"type": "string", "type": "string",
"description": "Optional working directory for the command", "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 (default: 0 = no timeout, kills process on expiry)",
},
},
"required": []string{"action", "command"},
},
{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"const": "list", "description": "List all active sessions"},
},
"required": []string{"action"},
},
{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"const": "poll",
"description": "Check session status. Returns: {sessionId, status: running|done, exitCode}. exitCode only meaningful when status=done",
},
"sessionId": map[string]any{
"type": "string",
"description": "Session ID returned from background command",
},
},
"required": []string{"action", "sessionId"},
},
{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"const": "read",
"description": "Read output from session. Returns: {sessionId, output, status: running|done}",
},
"sessionId": map[string]any{
"type": "string",
"description": "Session ID returned from background command",
},
},
"required": []string{"action", "sessionId"},
},
{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"const": "write",
"description": "Send input to session stdin (only when status=running)",
},
"sessionId": map[string]any{
"type": "string",
"description": "Session ID returned from background command",
},
"data": map[string]any{"type": "string", "description": "Data to write to session stdin."},
},
"required": []string{"action", "sessionId", "data"},
},
{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"const": "kill", "description": "Terminate session"},
"sessionId": map[string]any{
"type": "string",
"description": "Session ID returned from background command",
},
},
"required": []string{"action", "sessionId"},
},
{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"const": "send-keys",
"description": "Send special keys to PTY session. Keys: down/up/left/right/enter/escape/tab/backspace/ctrl-c/ctrl-d/ctrl-z. Multiple keys separated by comma",
},
"sessionId": map[string]any{
"type": "string",
"description": "Session ID returned from background command",
},
"keys": map[string]any{
"type": "string",
"description": "Comma-separated key names (optional spaces around comma). Valid keys: up, down, left, right, enter, tab, escape, backspace, ctrl-c, ctrl-d, home, end, pageup, pagedown, f1-f12.",
},
},
"required": []string{"action", "sessionId", "keys"},
}, },
}, },
"required": []string{"command"},
} }
} }
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 +344,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 +409,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 +525,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.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
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

View file

@ -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",
} }

View file

@ -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"`
}