Merge branch 'sipeed:main' into feat/audio-call
This commit is contained in:
commit
7f9e4d1021
17 changed files with 2110 additions and 42 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 をご利用ください。
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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` và `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` và `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.
|
||||||
|
|
|
||||||
|
|
@ -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
1
go.mod
|
|
@ -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
1
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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 = 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"`
|
||||||
|
}
|
||||||
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,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{
|
||||||
"type": "object",
|
"oneOf": []map[string]any{
|
||||||
"properties": map[string]any{
|
{
|
||||||
"command": map[string]any{
|
"type": "object",
|
||||||
"type": "string",
|
"properties": map[string]any{
|
||||||
"description": "The shell command to execute",
|
"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",
|
||||||
|
"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 (default: 0 = no timeout, kills process on expiry)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action", "command"},
|
||||||
},
|
},
|
||||||
"working_dir": map[string]any{
|
{
|
||||||
"type": "string",
|
"type": "object",
|
||||||
"description": "Optional working directory for the command",
|
"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
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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