From ee81193ccb129166d481a0862c594e627c554d16 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 20 Aug 2026 10:01:52 +0530 Subject: [PATCH 1/2] feat(terminal): add persistent PTY terminal store and tools (DSH 2.9) Port of DSH terminal/pty, terminal/terminal-bash, and terminal/tool-terminal into Hawk. - terminal: Implemented TerminalStore managing persistent interactive PTY sessions with branded IDs (terminal-). - terminal: Enforced exact session ownership and isolation (cross-session access denied). - terminal: Implemented zero-CGO PTY backend on Unix/Darwin/Linux via creack/pty with bounded buffering (64 KiB read cap / 512 KiB buffer cap). - terminal: Integrated sandbox policies (SecurityWorkspace, SecurityStrict) and process-tree disposal. - tools: Added TerminalCreate, TerminalSend, TerminalRead, TerminalList, TerminalResize, and TerminalKill tools to essentialTools. - tests: Full test coverage for lifecycle, session authorization, bounded reads, resize, and session teardown. --- cmd/chat_tools.go | 6 + go.mod | 1 + internal/terminal/proc_unix.go | 15 + internal/terminal/proc_windows.go | 14 + internal/terminal/pty_unix.go | 55 ++++ internal/terminal/pty_windows.go | 69 +++++ internal/terminal/store.go | 377 +++++++++++++++++++++++++ internal/terminal/terminal_test.go | 144 ++++++++++ internal/tool/terminal.go | 437 +++++++++++++++++++++++++++++ internal/tool/terminal_test.go | 146 ++++++++++ 10 files changed, 1264 insertions(+) create mode 100644 internal/terminal/proc_unix.go create mode 100644 internal/terminal/proc_windows.go create mode 100644 internal/terminal/pty_unix.go create mode 100644 internal/terminal/pty_windows.go create mode 100644 internal/terminal/store.go create mode 100644 internal/terminal/terminal_test.go create mode 100644 internal/tool/terminal.go create mode 100644 internal/tool/terminal_test.go diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index 32bfbb22..3297ae9d 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -56,6 +56,12 @@ func essentialTools() []tool.Tool { tool.ScheduleCreateTool{}, tool.ScheduleListTool{}, tool.ScheduleDeleteTool{}, + tool.TerminalCreateTool{}, + tool.TerminalSendTool{}, + tool.TerminalReadTool{}, + tool.TerminalListTool{}, + tool.TerminalResizeTool{}, + tool.TerminalKillTool{}, tool.AgentTool{}, tool.AskUserQuestionTool{}, tool.TodoWriteTool{}, diff --git a/go.mod b/go.mod index ce414e41..b31e4c0b 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect github.com/chromedp/sysutil v1.1.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect github.com/fatih/color v1.19.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect diff --git a/internal/terminal/proc_unix.go b/internal/terminal/proc_unix.go new file mode 100644 index 00000000..363e1cf5 --- /dev/null +++ b/internal/terminal/proc_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +package terminal + +import ( + "os" + "syscall" +) + +func killProcessGroup(proc *os.Process) error { + if proc == nil { + return nil + } + return syscall.Kill(-proc.Pid, syscall.SIGKILL) +} diff --git a/internal/terminal/proc_windows.go b/internal/terminal/proc_windows.go new file mode 100644 index 00000000..93723be8 --- /dev/null +++ b/internal/terminal/proc_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package terminal + +import ( + "os" +) + +func killProcessGroup(proc *os.Process) error { + if proc == nil { + return nil + } + return proc.Kill() +} diff --git a/internal/terminal/pty_unix.go b/internal/terminal/pty_unix.go new file mode 100644 index 00000000..15c27952 --- /dev/null +++ b/internal/terminal/pty_unix.go @@ -0,0 +1,55 @@ +//go:build !windows + +package terminal + +import ( + "os" + "os/exec" + + "github.com/creack/pty" +) + +type ptyDevice struct { + file *os.File +} + +func startPTY(cmd *exec.Cmd, rows, cols int) (*ptyDevice, error) { + var sz *pty.Winsize + if rows > 0 && cols > 0 { + sz = &pty.Winsize{ + Rows: uint16(rows), + Cols: uint16(cols), + } + } + + ptmx, err := pty.StartWithSize(cmd, sz) + if err != nil { + return nil, err + } + return &ptyDevice{file: ptmx}, nil +} + +func (p *ptyDevice) Resize(rows, cols int) error { + if p == nil || p.file == nil { + return nil + } + return pty.Setsize(p.file, &pty.Winsize{ + Rows: uint16(rows), + Cols: uint16(cols), + }) +} + +func (p *ptyDevice) Read(b []byte) (int, error) { + return p.file.Read(b) +} + +func (p *ptyDevice) Write(b []byte) (int, error) { + return p.file.Write(b) +} + +func (p *ptyDevice) Close() error { + if p == nil || p.file == nil { + return nil + } + return p.file.Close() +} diff --git a/internal/terminal/pty_windows.go b/internal/terminal/pty_windows.go new file mode 100644 index 00000000..3b9933bb --- /dev/null +++ b/internal/terminal/pty_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +package terminal + +import ( + "errors" + "io" + "os/exec" +) + +type ptyDevice struct { + stdin io.WriteCloser + stdout io.ReadCloser +} + +func startPTY(cmd *exec.Cmd, rows, cols int) (*ptyDevice, error) { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + _ = stdin.Close() + return nil, err + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + _ = stdin.Close() + _ = stdout.Close() + return nil, err + } + + return &ptyDevice{ + stdin: stdin, + stdout: stdout, + }, nil +} + +func (p *ptyDevice) Resize(rows, cols int) error { + return nil +} + +func (p *ptyDevice) Read(b []byte) (int, error) { + if p == nil || p.stdout == nil { + return 0, errors.New("terminal closed") + } + return p.stdout.Read(b) +} + +func (p *ptyDevice) Write(b []byte) (int, error) { + if p == nil || p.stdin == nil { + return 0, errors.New("terminal closed") + } + return p.stdin.Write(b) +} + +func (p *ptyDevice) Close() error { + if p == nil { + return nil + } + if p.stdin != nil { + _ = p.stdin.Close() + } + if p.stdout != nil { + _ = p.stdout.Close() + } + return nil +} diff --git a/internal/terminal/store.go b/internal/terminal/store.go new file mode 100644 index 00000000..2e00047c --- /dev/null +++ b/internal/terminal/store.go @@ -0,0 +1,377 @@ +package terminal + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/GrayCodeAI/hawk/internal/sandbox" +) + +var ( + // ErrUnauthorizedSession is returned when a session attempts to operate on a terminal owned by another session. + ErrUnauthorizedSession = errors.New("terminal: unauthorized access (terminal belongs to another session)") + // ErrTerminalNotFound is returned when a requested terminal ID does not exist. + ErrTerminalNotFound = errors.New("terminal: terminal not found") + // ErrTerminalClosed is returned when an operation is performed on a terminated terminal. + ErrTerminalClosed = errors.New("terminal: terminal is closed") +) + +// DefaultReadCap is the maximum number of bytes returned by a single read call (64 KiB). +const DefaultReadCap = 64 * 1024 + +// TerminalInfo provides metadata about an active or terminated terminal. +type TerminalInfo struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Command string `json:"command"` + CWD string `json:"cwd"` + CreatedAt time.Time `json:"created_at"` + Alive bool `json:"alive"` + ExitCode int `json:"exit_code,omitempty"` +} + +// Terminal represents an active persistent PTY session. +type Terminal struct { + ID string + SessionID string + Command string + CWD string + CreatedAt time.Time + + cmd *exec.Cmd + device *ptyDevice + + mu sync.Mutex + cond *sync.Cond + buf bytes.Buffer + closed bool + alive bool + exitCode int +} + +// Send writes user input to the terminal PTY. +func (t *Terminal) Send(input string, enter bool) error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed || !t.alive { + return ErrTerminalClosed + } + + data := []byte(input) + if enter && !bytes.HasSuffix(data, []byte("\n")) { + data = append(data, '\n') + } + + _, err := t.device.Write(data) + return err +} + +// Read reads up to maxBytes from the buffered terminal output. +// If timeout > 0, it blocks until new output is available or the timeout expires. +func (t *Terminal) Read(maxBytes int, timeout time.Duration) (string, bool, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if maxBytes <= 0 || maxBytes > DefaultReadCap { + maxBytes = DefaultReadCap + } + + // If no data and timeout specified, wait on cond + if t.buf.Len() == 0 && timeout > 0 && t.alive { + timer := time.AfterFunc(timeout, func() { + t.mu.Lock() + t.cond.Broadcast() + t.mu.Unlock() + }) + defer timer.Stop() + + for t.buf.Len() == 0 && t.alive && !t.closed { + t.cond.Wait() + break + } + } + + if t.buf.Len() == 0 { + return "", t.alive, nil + } + + toRead := maxBytes + if t.buf.Len() < toRead { + toRead = t.buf.Len() + } + + out := make([]byte, toRead) + _, _ = t.buf.Read(out) + + return string(out), t.alive, nil +} + +// Resize resizes the terminal PTY window. +func (t *Terminal) Resize(rows, cols int) error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed || !t.alive { + return ErrTerminalClosed + } + return t.device.Resize(rows, cols) +} + +// Kill terminates the terminal process and releases associated resources. +func (t *Terminal) Kill() error { + t.mu.Lock() + if t.closed { + t.mu.Unlock() + return nil + } + t.closed = true + t.alive = false + t.mu.Unlock() + + if t.device != nil { + _ = t.device.Close() + } + if t.cmd != nil && t.cmd.Process != nil { + _ = killProcessTree(t.cmd.Process) + } + + t.mu.Lock() + t.cond.Broadcast() + t.mu.Unlock() + + return nil +} + +// Info returns a snapshot of the terminal's status. +func (t *Terminal) Info() *TerminalInfo { + t.mu.Lock() + defer t.mu.Unlock() + + return &TerminalInfo{ + ID: t.ID, + SessionID: t.SessionID, + Command: t.Command, + CWD: t.CWD, + CreatedAt: t.CreatedAt, + Alive: t.alive, + ExitCode: t.exitCode, + } +} + +// Store coordinates and persists active PTY terminals. +type Store struct { + mu sync.RWMutex + terminals map[string]*Terminal + nextSeq int +} + +// NewStore creates a new TerminalStore. +func NewStore() *Store { + return &Store{ + terminals: make(map[string]*Terminal), + } +} + +// Global default store instance +var ( + defaultStore *Store + defaultStoreOnce sync.Once +) + +// DefaultStore returns the global shared TerminalStore. +func DefaultStore() *Store { + defaultStoreOnce.Do(func() { + defaultStore = NewStore() + }) + return defaultStore +} + +// Create spawns a new persistent PTY terminal under session ownership. +func (s *Store) Create(ctx context.Context, sessionID, cwd, command string, rows, cols int, sbCfg sandbox.SandboxConfig) (*Terminal, error) { + if sessionID == "" { + return nil, errors.New("terminal: sessionID cannot be empty") + } + + if cwd == "" { + if wd, err := os.Getwd(); err == nil { + cwd = wd + } + } + cwd = filepath.Clean(cwd) + + if command == "" { + if shell := os.Getenv("SHELL"); shell != "" { + command = shell + } else if runtime.GOOS == "windows" { + command = "powershell.exe" + } else { + command = "/bin/bash" + } + } + + var cmd *exec.Cmd + if sbCfg.Security != "" && sbCfg.Security != sandbox.SecurityOff { + bin, args, err := sandbox.WrapCommand(command, sbCfg) + if err != nil { + return nil, fmt.Errorf("terminal sandbox wrap failed: %w", err) + } + cmd = exec.CommandContext(ctx, bin, args...) // #nosec G204 -- subprocess execution of shell or sandboxed command is the primary responsibility of terminal package + } else { + // Normal shell command + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "powershell.exe", "-Command", command) // #nosec G204 -- subprocess execution of shell or sandboxed command is the primary responsibility of terminal package + } else { + cmd = exec.CommandContext(ctx, "/bin/sh", "-c", command) // #nosec G204 -- subprocess execution of shell or sandboxed command is the primary responsibility of terminal package + } + } + cmd.Dir = cwd + + device, err := startPTY(cmd, rows, cols) + if err != nil { + return nil, fmt.Errorf("terminal: pty spawn failed: %w", err) + } + + s.mu.Lock() + s.nextSeq++ + termID := fmt.Sprintf("terminal-%d", s.nextSeq) + + t := &Terminal{ + ID: termID, + SessionID: sessionID, + Command: command, + CWD: cwd, + CreatedAt: time.Now(), + cmd: cmd, + device: device, + alive: true, + } + t.cond = sync.NewCond(&t.mu) + s.terminals[termID] = t + s.mu.Unlock() + + // 1. Background output reader + go func() { + buf := make([]byte, 4096) + for { + n, rErr := device.Read(buf) + if n > 0 { + t.mu.Lock() + // Cap internal buffer at 512 KiB to prevent unbounded memory growth + if t.buf.Len()+n > 512*1024 { + drop := (t.buf.Len() + n) - 512*1024 + _ = t.buf.Next(drop) + } + t.buf.Write(buf[:n]) + t.cond.Broadcast() + t.mu.Unlock() + } + if rErr != nil { + break + } + } + }() + + // 2. Background process waiter + go func() { + wErr := cmd.Wait() + t.mu.Lock() + t.alive = false + if wErr != nil { + var exitErr *exec.ExitError + if errors.As(wErr, &exitErr) { + t.exitCode = exitErr.ExitCode() + } else { + t.exitCode = 1 + } + } else { + t.exitCode = 0 + } + t.cond.Broadcast() + t.mu.Unlock() + }() + + return t, nil +} + +// Get fetches a terminal verifying exact session ownership. +func (s *Store) Get(sessionID, id string) (*Terminal, error) { + s.mu.RLock() + t, ok := s.terminals[id] + s.mu.RUnlock() + + if !ok { + return nil, ErrTerminalNotFound + } + if sessionID != "" && t.SessionID != sessionID { + return nil, fmt.Errorf("%w (session %s cannot access terminal owned by %s)", + ErrUnauthorizedSession, sessionID, t.SessionID) + } + return t, nil +} + +// List returns info for all terminals owned by the calling session. +func (s *Store) List(sessionID string) []*TerminalInfo { + s.mu.RLock() + defer s.mu.RUnlock() + + var list []*TerminalInfo + for _, t := range s.terminals { + if sessionID == "" || t.SessionID == sessionID { + list = append(list, t.Info()) + } + } + return list +} + +// Delete removes and terminates a specific terminal. +func (s *Store) Delete(sessionID, id string) error { + t, err := s.Get(sessionID, id) + if err != nil { + return err + } + + _ = t.Kill() + + s.mu.Lock() + delete(s.terminals, id) + s.mu.Unlock() + + return nil +} + +// CloseSession gracefully disposes all terminals owned by sessionID. +func (s *Store) CloseSession(sessionID string) { + if sessionID == "" { + return + } + s.mu.Lock() + var toClose []*Terminal + for id, t := range s.terminals { + if t.SessionID == sessionID { + toClose = append(toClose, t) + delete(s.terminals, id) + } + } + s.mu.Unlock() + + for _, t := range toClose { + _ = t.Kill() + } +} + +// killProcessTree terminates the process and any descendants. +func killProcessTree(p *os.Process) error { + if p == nil { + return nil + } + return killProcessGroup(p) +} diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go new file mode 100644 index 00000000..028d11a9 --- /dev/null +++ b/internal/terminal/terminal_test.go @@ -0,0 +1,144 @@ +package terminal + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/sandbox" +) + +func TestTerminal_LifecycleAndRead(t *testing.T) { + store := NewStore() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Spawn echo / interactive shell + term, err := store.Create(ctx, "session-1", "", "echo hello_hawk", 24, 80, sandbox.SandboxConfig{}) + if err != nil { + t.Fatalf("Create terminal failed: %v", err) + } + defer func() { _ = term.Kill() }() + + if !strings.HasPrefix(term.ID, "terminal-") { + t.Errorf("expected branded terminal ID (terminal-), got %s", term.ID) + } + + // Read output + out, _, err := term.Read(1024, 2*time.Second) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if !strings.Contains(out, "hello_hawk") { + t.Errorf("expected output to contain hello_hawk, got %q", out) + } +} + +func TestTerminal_OwnershipEnforcement(t *testing.T) { + store := NewStore() + ctx := context.Background() + + term, err := store.Create(ctx, "session-alpha", "", "echo isolation", 24, 80, sandbox.SandboxConfig{}) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + defer func() { _ = term.Kill() }() + + // Access with authorized session + got, err := store.Get("session-alpha", term.ID) + if err != nil || got == nil { + t.Fatalf("expected session-alpha to access terminal, got err: %v", err) + } + + // Access with unauthorized session must fail + _, err = store.Get("session-beta", term.ID) + if !errors.Is(err, ErrUnauthorizedSession) { + t.Fatalf("expected ErrUnauthorizedSession for session-beta, got %v", err) + } + + // Delete from unauthorized session must fail + err = store.Delete("session-beta", term.ID) + if !errors.Is(err, ErrUnauthorizedSession) { + t.Fatalf("expected ErrUnauthorizedSession on Delete, got %v", err) + } + + // Delete from authorized session succeeds + err = store.Delete("session-alpha", term.ID) + if err != nil { + t.Fatalf("expected Delete to succeed for session-alpha, got %v", err) + } +} + +func TestTerminal_ListAndCloseSession(t *testing.T) { + store := NewStore() + ctx := context.Background() + + t1, err := store.Create(ctx, "sess-x", "", "cat", 24, 80, sandbox.SandboxConfig{}) + if err != nil { + t.Fatalf("Create t1 failed: %v", err) + } + defer func() { _ = t1.Kill() }() + + t2, err := store.Create(ctx, "sess-x", "", "cat", 24, 80, sandbox.SandboxConfig{}) + if err != nil { + t.Fatalf("Create t2 failed: %v", err) + } + defer func() { _ = t2.Kill() }() + + t3, err := store.Create(ctx, "sess-y", "", "cat", 24, 80, sandbox.SandboxConfig{}) + if err != nil { + t.Fatalf("Create t3 failed: %v", err) + } + defer func() { _ = t3.Kill() }() + + listX := store.List("sess-x") + if len(listX) != 2 { + t.Errorf("expected 2 terminals for sess-x, got %d", len(listX)) + } + + listY := store.List("sess-y") + if len(listY) != 1 { + t.Errorf("expected 1 terminal for sess-y, got %d", len(listY)) + } + + // Close session x disposes both t1 and t2 + store.CloseSession("sess-x") + + if len(store.List("sess-x")) != 0 { + t.Errorf("expected 0 terminals for sess-x after CloseSession, got %d", len(store.List("sess-x"))) + } + + // sess-y is untouched + if len(store.List("sess-y")) != 1 { + t.Errorf("expected 1 terminal for sess-y to remain, got %d", len(store.List("sess-y"))) + } +} + +func TestTerminal_ResizeAndSend(t *testing.T) { + store := NewStore() + ctx := context.Background() + + term, err := store.Create(ctx, "session-cmd", "", "cat", 24, 80, sandbox.SandboxConfig{}) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + defer func() { _ = term.Kill() }() + + if err := term.Resize(40, 120); err != nil { + t.Fatalf("Resize failed: %v", err) + } + + if err := term.Send("ping_term", true); err != nil { + t.Fatalf("Send failed: %v", err) + } + + out, _, err := term.Read(1024, 2*time.Second) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if !strings.Contains(out, "ping_term") { + t.Errorf("expected echoed input ping_term, got %q", out) + } +} diff --git a/internal/tool/terminal.go b/internal/tool/terminal.go new file mode 100644 index 00000000..c85f4a3b --- /dev/null +++ b/internal/tool/terminal.go @@ -0,0 +1,437 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/GrayCodeAI/hawk/internal/sandbox" + "github.com/GrayCodeAI/hawk/internal/terminal" +) + +func resolveStore(custom *terminal.Store) *terminal.Store { + if custom != nil { + return custom + } + return terminal.DefaultStore() +} + +// TerminalCreateTool spawns a persistent interactive PTY terminal. +type TerminalCreateTool struct { + Store *terminal.Store +} + +func (TerminalCreateTool) Name() string { return "TerminalCreate" } +func (TerminalCreateTool) Aliases() []string { return []string{"terminal_create", "pty_create"} } +func (TerminalCreateTool) Description() string { + return "Spawn a persistent interactive PTY terminal session whose state persists across tool calls." +} + +func (TerminalCreateTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + "description": "Shell or command to run (defaults to system shell e.g. /bin/bash or powershell)", + }, + "cwd": map[string]interface{}{ + "type": "string", + "description": "Working directory for the terminal session", + }, + "rows": map[string]interface{}{ + "type": "integer", + "description": "Initial terminal rows (default 24)", + }, + "cols": map[string]interface{}{ + "type": "integer", + "description": "Initial terminal columns (default 80)", + }, + "session_id": map[string]interface{}{ + "type": "string", + "description": "Session ID establishing ownership for this terminal", + }, + }, + } +} + +func (t TerminalCreateTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { + var p struct { + Command string `json:"command"` + CWD string `json:"cwd"` + Rows int `json:"rows"` + Cols int `json:"cols"` + SessionID string `json:"session_id"` + } + if len(input) > 0 { + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + } + + sessionID := strings.TrimSpace(p.SessionID) + if sessionID == "" { + sessionID = "default" + } + rows := p.Rows + if rows <= 0 { + rows = 24 + } + cols := p.Cols + if cols <= 0 { + cols = 80 + } + + sbCfg := sandbox.SandboxConfig{} + if sbMode := sandbox.ModeFromContext(ctx); sbMode == sandbox.ModeWorkspace { + sbCfg.Security = sandbox.SecurityWorkspace + } else if sbMode == sandbox.ModeStrict { + sbCfg.Security = sandbox.SecurityStrict + } + + term, err := resolveStore(t.Store).Create(ctx, sessionID, p.CWD, p.Command, rows, cols, sbCfg) + if err != nil { + return "", fmt.Errorf("failed to create terminal: %w", err) + } + + res, _ := json.Marshal(map[string]any{ + "terminal_id": term.ID, + "session_id": term.SessionID, + "cwd": term.CWD, + "message": fmt.Sprintf("Terminal %s created successfully.", term.ID), + }) + return string(res), nil +} + +// TerminalSendTool writes user input to an active terminal. +type TerminalSendTool struct { + Store *terminal.Store +} + +func (TerminalSendTool) Name() string { return "TerminalSend" } +func (TerminalSendTool) Aliases() []string { return []string{"terminal_send", "pty_send"} } +func (TerminalSendTool) Description() string { + return "Send input characters or commands to an active persistent terminal." +} + +func (TerminalSendTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "terminal_id": map[string]interface{}{ + "type": "string", + "description": "Branded terminal identifier (e.g. 'terminal-1')", + }, + "input": map[string]interface{}{ + "type": "string", + "description": "Characters, keystrokes, or command string to send to stdin", + }, + "send_enter": map[string]interface{}{ + "type": "boolean", + "description": "Whether to append a newline (Enter) at the end of input (default true)", + }, + "session_id": map[string]interface{}{ + "type": "string", + "description": "Owner session ID for authorization", + }, + }, + "required": []string{"terminal_id", "input"}, + } +} + +func (t TerminalSendTool) Execute(_ context.Context, input json.RawMessage) (string, error) { + var p struct { + TerminalID string `json:"terminal_id"` + Input string `json:"input"` + SendEnter *bool `json:"send_enter"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + if p.TerminalID == "" { + return "", fmt.Errorf("terminal_id is required") + } + + sessionID := strings.TrimSpace(p.SessionID) + if sessionID == "" { + sessionID = "default" + } + + term, err := resolveStore(t.Store).Get(sessionID, p.TerminalID) + if err != nil { + return "", err + } + + enter := true + if p.SendEnter != nil { + enter = *p.SendEnter + } + + if err := term.Send(p.Input, enter); err != nil { + return "", fmt.Errorf("failed to send input: %w", err) + } + + res, _ := json.Marshal(map[string]any{ + "terminal_id": p.TerminalID, + "status": "ok", + }) + return string(res), nil +} + +// TerminalReadTool reads bounded output from an active terminal. +type TerminalReadTool struct { + Store *terminal.Store +} + +func (TerminalReadTool) Name() string { return "TerminalRead" } +func (TerminalReadTool) Aliases() []string { return []string{"terminal_read", "pty_read"} } +func (TerminalReadTool) Description() string { + return "Read newly emitted output from an active persistent terminal with an optional timeout." +} + +func (TerminalReadTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "terminal_id": map[string]interface{}{ + "type": "string", + "description": "Branded terminal identifier (e.g. 'terminal-1')", + }, + "max_bytes": map[string]interface{}{ + "type": "integer", + "description": "Maximum bytes to read (default 65536)", + }, + "timeout_ms": map[string]interface{}{ + "type": "integer", + "description": "Milliseconds to wait for output if buffer is empty (default 500ms)", + }, + "session_id": map[string]interface{}{ + "type": "string", + "description": "Owner session ID for authorization", + }, + }, + "required": []string{"terminal_id"}, + } +} + +func (t TerminalReadTool) Execute(_ context.Context, input json.RawMessage) (string, error) { + var p struct { + TerminalID string `json:"terminal_id"` + MaxBytes int `json:"max_bytes"` + TimeoutMS int `json:"timeout_ms"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + if p.TerminalID == "" { + return "", fmt.Errorf("terminal_id is required") + } + + sessionID := strings.TrimSpace(p.SessionID) + if sessionID == "" { + sessionID = "default" + } + + term, err := resolveStore(t.Store).Get(sessionID, p.TerminalID) + if err != nil { + return "", err + } + + timeout := 500 * time.Millisecond + if p.TimeoutMS > 0 { + timeout = time.Duration(p.TimeoutMS) * time.Millisecond + } + + out, alive, err := term.Read(p.MaxBytes, timeout) + if err != nil { + return "", fmt.Errorf("failed to read terminal: %w", err) + } + + res, _ := json.Marshal(map[string]any{ + "terminal_id": p.TerminalID, + "output": out, + "alive": alive, + }) + return string(res), nil +} + +// TerminalListTool lists active persistent terminals for the calling session. +type TerminalListTool struct { + Store *terminal.Store +} + +func (TerminalListTool) Name() string { return "TerminalList" } +func (TerminalListTool) Aliases() []string { return []string{"terminal_list", "pty_list"} } +func (TerminalListTool) Description() string { + return "List active persistent PTY terminals owned by the current session." +} + +func (TerminalListTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "session_id": map[string]interface{}{ + "type": "string", + "description": "Session ID to filter terminals by", + }, + }, + } +} + +func (t TerminalListTool) Execute(_ context.Context, input json.RawMessage) (string, error) { + var p struct { + SessionID string `json:"session_id"` + } + if len(input) > 0 { + _ = json.Unmarshal(input, &p) + } + + sessionID := strings.TrimSpace(p.SessionID) + if sessionID == "" { + sessionID = "default" + } + + terms := resolveStore(t.Store).List(sessionID) + res, _ := json.Marshal(map[string]any{ + "terminals": terms, + "count": len(terms), + }) + return string(res), nil +} + +// TerminalResizeTool resizes an active terminal PTY window. +type TerminalResizeTool struct { + Store *terminal.Store +} + +func (TerminalResizeTool) Name() string { return "TerminalResize" } +func (TerminalResizeTool) Aliases() []string { return []string{"terminal_resize", "pty_resize"} } +func (TerminalResizeTool) Description() string { + return "Resize the rows and columns of an active persistent PTY terminal." +} + +func (TerminalResizeTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "terminal_id": map[string]interface{}{ + "type": "string", + "description": "Branded terminal identifier", + }, + "rows": map[string]interface{}{ + "type": "integer", + "description": "New terminal row count", + }, + "cols": map[string]interface{}{ + "type": "integer", + "description": "New terminal column count", + }, + "session_id": map[string]interface{}{ + "type": "string", + "description": "Owner session ID for authorization", + }, + }, + "required": []string{"terminal_id", "rows", "cols"}, + } +} + +func (t TerminalResizeTool) Execute(_ context.Context, input json.RawMessage) (string, error) { + var p struct { + TerminalID string `json:"terminal_id"` + Rows int `json:"rows"` + Cols int `json:"cols"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + if p.TerminalID == "" { + return "", fmt.Errorf("terminal_id is required") + } + + sessionID := strings.TrimSpace(p.SessionID) + if sessionID == "" { + sessionID = "default" + } + + term, err := resolveStore(t.Store).Get(sessionID, p.TerminalID) + if err != nil { + return "", err + } + + if err := term.Resize(p.Rows, p.Cols); err != nil { + return "", fmt.Errorf("failed to resize terminal: %w", err) + } + + res, _ := json.Marshal(map[string]any{ + "terminal_id": p.TerminalID, + "rows": p.Rows, + "cols": p.Cols, + "status": "ok", + }) + return string(res), nil +} + +// TerminalKillTool terminates an active terminal and frees resources. +type TerminalKillTool struct { + Store *terminal.Store +} + +func (TerminalKillTool) Name() string { return "TerminalKill" } +func (TerminalKillTool) Aliases() []string { return []string{"terminal_kill", "pty_kill"} } +func (TerminalKillTool) Description() string { + return "Terminate an active persistent terminal session." +} + +func (TerminalKillTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "terminal_id": map[string]interface{}{ + "type": "string", + "description": "Branded terminal identifier to terminate", + }, + "session_id": map[string]interface{}{ + "type": "string", + "description": "Owner session ID for authorization", + }, + }, + "required": []string{"terminal_id"}, + } +} + +func (t TerminalKillTool) Execute(_ context.Context, input json.RawMessage) (string, error) { + var p struct { + TerminalID string `json:"terminal_id"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + if p.TerminalID == "" { + return "", fmt.Errorf("terminal_id is required") + } + + sessionID := strings.TrimSpace(p.SessionID) + if sessionID == "" { + sessionID = "default" + } + + if err := resolveStore(t.Store).Delete(sessionID, p.TerminalID); err != nil { + return "", err + } + + res, _ := json.Marshal(map[string]any{ + "terminal_id": p.TerminalID, + "status": "killed", + }) + return string(res), nil +} diff --git a/internal/tool/terminal_test.go b/internal/tool/terminal_test.go new file mode 100644 index 00000000..4146de99 --- /dev/null +++ b/internal/tool/terminal_test.go @@ -0,0 +1,146 @@ +package tool + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/terminal" +) + +func TestTerminalTools_FullLifecycle(t *testing.T) { + store := terminal.NewStore() + + createTool := TerminalCreateTool{Store: store} + sendTool := TerminalSendTool{Store: store} + readTool := TerminalReadTool{Store: store} + listTool := TerminalListTool{Store: store} + resizeTool := TerminalResizeTool{Store: store} + killTool := TerminalKillTool{Store: store} + + ctx := context.Background() + + // 1. Create Terminal + createInput, _ := json.Marshal(map[string]any{ + "session_id": "test-session", + "command": "cat", + "rows": 24, + "cols": 80, + }) + createRes, err := createTool.Execute(ctx, createInput) + if err != nil { + t.Fatalf("TerminalCreateTool failed: %v", err) + } + + var created struct { + TerminalID string `json:"terminal_id"` + } + if err := json.Unmarshal([]byte(createRes), &created); err != nil { + t.Fatalf("unmarshal createRes failed: %v", err) + } + if created.TerminalID == "" { + t.Fatal("expected non-empty terminal ID") + } + + defer func() { + _, _ = killTool.Execute(ctx, []byte(`{"terminal_id":"`+created.TerminalID+`","session_id":"test-session"}`)) + }() + + // 2. List Terminals + listRes, err := listTool.Execute(ctx, []byte(`{"session_id":"test-session"}`)) + if err != nil { + t.Fatalf("TerminalListTool failed: %v", err) + } + var listed struct { + Count int `json:"count"` + } + _ = json.Unmarshal([]byte(listRes), &listed) + if listed.Count != 1 { + t.Errorf("expected 1 terminal in list, got %d", listed.Count) + } + + // 3. Resize Terminal + resizeInput, _ := json.Marshal(map[string]any{ + "session_id": "test-session", + "terminal_id": created.TerminalID, + "rows": 30, + "cols": 100, + }) + if _, err := resizeTool.Execute(ctx, resizeInput); err != nil { + t.Fatalf("TerminalResizeTool failed: %v", err) + } + + // 4. Send Input + sendInput, _ := json.Marshal(map[string]any{ + "session_id": "test-session", + "terminal_id": created.TerminalID, + "input": "echo_tool_test", + }) + if _, err := sendTool.Execute(ctx, sendInput); err != nil { + t.Fatalf("TerminalSendTool failed: %v", err) + } + + // 5. Read Output + readInput, _ := json.Marshal(map[string]any{ + "session_id": "test-session", + "terminal_id": created.TerminalID, + "timeout_ms": 1000, + }) + time.Sleep(50 * time.Millisecond) + readRes, err := readTool.Execute(ctx, readInput) + if err != nil { + t.Fatalf("TerminalReadTool failed: %v", err) + } + if !strings.Contains(readRes, "echo_tool_test") { + t.Errorf("expected readRes to contain echo_tool_test, got %s", readRes) + } + + // 6. Kill Terminal + killInput, _ := json.Marshal(map[string]any{ + "session_id": "test-session", + "terminal_id": created.TerminalID, + }) + killRes, err := killTool.Execute(ctx, killInput) + if err != nil { + t.Fatalf("TerminalKillTool failed: %v", err) + } + if !strings.Contains(killRes, "killed") { + t.Errorf("expected killed status, got %s", killRes) + } +} + +func TestTerminalTools_CrossSessionRejection(t *testing.T) { + store := terminal.NewStore() + + createTool := TerminalCreateTool{Store: store} + sendTool := TerminalSendTool{Store: store} + + ctx := context.Background() + + createInput, _ := json.Marshal(map[string]any{ + "session_id": "session-1", + "command": "cat", + }) + createRes, err := createTool.Execute(ctx, createInput) + if err != nil { + t.Fatalf("create failed: %v", err) + } + + var created struct { + TerminalID string `json:"terminal_id"` + } + _ = json.Unmarshal([]byte(createRes), &created) + + // Send from session-2 must fail + sendInput, _ := json.Marshal(map[string]any{ + "session_id": "session-2", + "terminal_id": created.TerminalID, + "input": "unauthorized input", + }) + _, err = sendTool.Execute(ctx, sendInput) + if err == nil { + t.Fatal("expected unauthorized session error, got nil") + } +} From 357b1d8869d72da456b5c4b8b09e45d271120100 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 20 Aug 2026 10:25:24 +0530 Subject: [PATCH 2/2] chore(terminal): sync direct dependency in go.mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index b31e4c0b..1d21abbf 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/bwmarrin/discordgo v0.28.1 github.com/charmbracelet/x/ansi v0.11.7 github.com/chromedp/chromedp v0.16.0 + github.com/creack/pty v1.1.24 github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 github.com/google/uuid v1.6.0 @@ -51,7 +52,6 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect github.com/chromedp/sysutil v1.1.0 // indirect - github.com/creack/pty v1.1.24 // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect github.com/fatih/color v1.19.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect