Merge pull request #11 from aoagents/aa-8-tmux-worktree-adapters

feat: add tmux runtime adapter
This commit is contained in:
Harshit Singh Bhandari 2026-05-27 18:26:39 +05:30 committed by GitHub
commit f9bb4ce90d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 746 additions and 0 deletions

View File

@ -0,0 +1,97 @@
package tmux
import (
"fmt"
"sort"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const runtimeName = "tmux"
func newSessionArgs(id, workspacePath, shellPath, script string) []string {
return []string{"new-session", "-d", "-s", id, "-c", workspacePath, shellPath, "-lc", script}
}
func setStatusOffArgs(id string) []string {
return []string{"set-option", "-t", exactSessionTarget(id), "status", "off"}
}
func hasSessionArgs(id string) []string {
return []string{"has-session", "-t", exactSessionTarget(id)}
}
func killSessionArgs(id string) []string {
return []string{"kill-session", "-t", exactSessionTarget(id)}
}
func capturePaneArgs(id string, lines int) []string {
return []string{"capture-pane", "-p", "-t", exactPaneTarget(id), "-S", fmt.Sprintf("-%d", lines)}
}
func sendLiteralArgs(id, message string) []string {
return []string{"send-keys", "-t", exactPaneTarget(id), "-l", message}
}
func sendEnterArgs(id string) []string {
return []string{"send-keys", "-t", exactPaneTarget(id), "C-m"}
}
func loadBufferArgs(bufferName, path string) []string {
return []string{"load-buffer", "-b", bufferName, path}
}
func pasteBufferArgs(id, bufferName string) []string {
return []string{"paste-buffer", "-d", "-t", exactPaneTarget(id), "-b", bufferName}
}
func exactSessionTarget(id string) string {
return "=" + id + ":"
}
func exactPaneTarget(id string) string {
return "=" + id + ":0.0"
}
func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string {
path := cfg.Env["PATH"]
if path == "" {
path = getenv("PATH")
}
var b strings.Builder
for _, key := range sortedKeys(cfg.Env) {
if key == "PATH" {
continue
}
b.WriteString("export ")
b.WriteString(key)
b.WriteString("=")
b.WriteString(shellQuote(cfg.Env[key]))
b.WriteString("; ")
}
if path != "" {
b.WriteString("export PATH=")
b.WriteString(shellQuote(path))
b.WriteString("; ")
}
b.WriteString(cfg.LaunchCommand)
b.WriteString("; exec ")
b.WriteString(shellQuote(shellPath))
b.WriteString(" -i")
return b.String()
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

View File

@ -0,0 +1,281 @@
// Package tmux implements ports.Runtime using tmux sessions.
package tmux
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const defaultTimeout = 5 * time.Second
const longMessageThreshold = 512
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
var getenv = os.Getenv
type Options struct {
Binary string
Timeout time.Duration
Shell string
}
type Runtime struct {
binary string
timeout time.Duration
shell string
runner runner
}
var _ ports.Runtime = (*Runtime)(nil)
type runner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
func New(opts Options) *Runtime {
binary := opts.Binary
if binary == "" {
binary = "tmux"
}
timeout := opts.Timeout
if timeout == 0 {
timeout = defaultTimeout
}
shellPath := opts.Shell
if shellPath == "" {
shellPath = os.Getenv("SHELL")
}
if shellPath == "" {
shellPath = "/bin/sh"
}
return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}}
}
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
id, err := tmuxSessionName(cfg.SessionID)
if err != nil {
return ports.RuntimeHandle{}, err
}
if cfg.WorkspacePath == "" {
return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required")
}
if cfg.LaunchCommand == "" {
return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required")
}
script := wrapLaunchCommand(cfg, r.shell)
if _, err := r.run(ctx, newSessionArgs(id, cfg.WorkspacePath, r.shell, script)...); err != nil {
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err)
}
if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil {
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: disable status %s: %w", id, err)
}
return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil
}
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
id, err := handleID(handle)
if err != nil {
return err
}
if _, err := r.run(ctx, killSessionArgs(id)...); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil
}
return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err)
}
return nil
}
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
id, err := handleID(handle)
if err != nil {
return err
}
if useBuffer(message) {
return r.sendViaBuffer(ctx, id, message)
}
if _, err := r.run(ctx, sendLiteralArgs(id, message)...); err != nil {
return fmt.Errorf("tmux runtime: send message %s: %w", id, err)
}
if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil {
return fmt.Errorf("tmux runtime: send enter %s: %w", id, err)
}
return nil
}
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
id, err := handleID(handle)
if err != nil {
return "", err
}
if lines <= 0 {
return "", errors.New("tmux runtime: lines must be positive")
}
out, err := r.run(ctx, capturePaneArgs(id, lines)...)
if err != nil {
return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err)
}
return string(out), nil
}
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
id, err := handleID(handle)
if err != nil {
return false, err
}
_, err = r.run(ctx, hasSessionArgs(id)...)
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return false, nil
}
return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err)
}
func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) {
id, err := handleID(handle)
if err != nil {
return nil, err
}
return append([]string{r.binary}, "attach", "-t", exactSessionTarget(id)), nil
}
func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error {
dir := os.TempDir()
file, err := os.CreateTemp(dir, "ao-tmux-message-*")
if err != nil {
return fmt.Errorf("tmux runtime: create message temp file: %w", err)
}
path := file.Name()
defer os.Remove(path)
if _, err := file.WriteString(message); err != nil {
_ = file.Close()
return fmt.Errorf("tmux runtime: write message temp file: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("tmux runtime: close message temp file: %w", err)
}
bufferName := "ao-" + filepath.Base(path)
if _, err := r.run(ctx, loadBufferArgs(bufferName, path)...); err != nil {
return fmt.Errorf("tmux runtime: load buffer %s: %w", id, err)
}
if _, err := r.run(ctx, pasteBufferArgs(id, bufferName)...); err != nil {
return fmt.Errorf("tmux runtime: paste buffer %s: %w", id, err)
}
if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil {
return fmt.Errorf("tmux runtime: send enter %s: %w", id, err)
}
return nil
}
func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
cmdCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
out, err := r.runner.Run(cmdCtx, r.binary, args...)
if cmdCtx.Err() != nil {
return out, cmdCtx.Err()
}
if err != nil {
return out, commandError{err: err, output: strings.TrimSpace(string(out))}
}
return out, nil
}
func tmuxSessionName(id domain.SessionID) (string, error) {
raw := string(id)
if raw == "" {
return "", errors.New("tmux runtime: session id is required")
}
if sessionIDPattern.MatchString(raw) {
return raw, nil
}
return sanitizedSessionName(raw), nil
}
func sanitizedSessionName(raw string) string {
var b strings.Builder
lastDash := false
for _, r := range raw {
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
if valid {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
base := strings.Trim(b.String(), "-")
if base == "" {
base = "session"
}
if len(base) > 40 {
base = strings.TrimRight(base[:40], "-")
}
sum := sha256.Sum256([]byte(raw))
return base + "-" + hex.EncodeToString(sum[:4])
}
func validateSessionID(id string) error {
if id == "" {
return errors.New("tmux runtime: session id is required")
}
if !sessionIDPattern.MatchString(id) {
return fmt.Errorf("tmux runtime: invalid session id %q", id)
}
return nil
}
func handleID(handle ports.RuntimeHandle) (string, error) {
if handle.RuntimeName != "" && handle.RuntimeName != runtimeName {
return "", fmt.Errorf("tmux runtime: wrong runtime %q", handle.RuntimeName)
}
if err := validateSessionID(handle.ID); err != nil {
return "", err
}
return handle.ID, nil
}
func useBuffer(message string) bool {
return strings.Contains(message, "\n") || len(message) > longMessageThreshold
}
type commandError struct {
err error
output string
}
func (e commandError) Error() string {
if e.output == "" {
return e.err.Error()
}
return e.err.Error() + ": " + e.output
}
func (e commandError) Unwrap() error { return e.err }

View File

@ -0,0 +1,112 @@
package tmux
import (
"context"
"os/exec"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestRuntimeIntegration(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux unavailable")
}
r := New(Options{Timeout: 5 * time.Second})
ctx := context.Background()
id := "ao_itest_tmux"
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_itest_tmux",
WorkspacePath: t.TempDir(),
LaunchCommand: "printf ready\\n",
Env: map[string]string{"AO_SESSION_ID": id},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer r.Destroy(ctx, h)
alive, err := r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
if !alive {
t.Fatal("alive = false, want true")
}
if err := r.SendMessage(ctx, h, "printf hello-from-tmux"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
var out string
for time.Now().Before(deadline) {
out, err = r.GetOutput(ctx, h, 20)
if err != nil {
t.Fatalf("GetOutput: %v", err)
}
if strings.Contains(out, "hello-from-tmux") {
break
}
time.Sleep(100 * time.Millisecond)
}
if !strings.Contains(out, "hello-from-tmux") {
t.Fatalf("output = %q, want sent command output", out)
}
if err := r.Destroy(ctx, h); err != nil {
t.Fatalf("Destroy: %v", err)
}
alive, err = r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive after destroy: %v", err)
}
if alive {
t.Fatal("alive after destroy = true, want false")
}
}
func TestRuntimeIntegrationUsesExactTargets(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux unavailable")
}
r := New(Options{Timeout: 5 * time.Second})
ctx := context.Background()
longID := "ao_exact_target_long"
prefixID := "ao_exact_target"
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_exact_target_long",
WorkspacePath: t.TempDir(),
LaunchCommand: "cat",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer r.Destroy(ctx, h)
alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
if err != nil {
t.Fatalf("IsAlive prefix: %v", err)
}
if alive {
t.Fatal("prefix handle reported alive; tmux target matching is not exact")
}
if err := r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}); err != nil {
t.Fatalf("Destroy prefix: %v", err)
}
alive, err = r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive long after prefix destroy: %v", err)
}
if !alive {
t.Fatal("destroying prefix handle killed longer session")
}
}

View File

@ -0,0 +1,256 @@
package tmux
import (
"context"
"errors"
"os/exec"
"reflect"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestNewDefaultsToPortableShell(t *testing.T) {
t.Setenv("SHELL", "")
r := New(Options{})
if got, want := r.shell, "/bin/sh"; got != want {
t.Fatalf("default shell = %q, want %q", got, want)
}
}
func TestCommandBuilders(t *testing.T) {
if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/zsh", "echo hi"), []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws", "/bin/zsh", "-lc", "echo hi"}; !reflect.DeepEqual(got, want) {
t.Fatalf("newSessionArgs = %#v, want %#v", got, want)
}
if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "=sess-1:", "status", "off"}; !reflect.DeepEqual(got, want) {
t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want)
}
if got, want := capturePaneArgs("sess-1", 42), []string{"capture-pane", "-p", "-t", "=sess-1:0.0", "-S", "-42"}; !reflect.DeepEqual(got, want) {
t.Fatalf("capturePaneArgs = %#v, want %#v", got, want)
}
}
func TestExactTargets(t *testing.T) {
if got, want := exactSessionTarget("abc"), "=abc:"; got != want {
t.Fatalf("exactSessionTarget = %q, want %q", got, want)
}
if got, want := exactPaneTarget("abc"), "=abc:0.0"; got != want {
t.Fatalf("exactPaneTarget = %q, want %q", got, want)
}
}
func TestTmuxSessionNameSanitizesIssueRefs(t *testing.T) {
got, err := tmuxSessionName("repo/issue#42.1")
if err != nil {
t.Fatalf("tmuxSessionName: %v", err)
}
if err := validateSessionID(got); err != nil {
t.Fatalf("sanitized id %q is invalid: %v", got, err)
}
if !strings.HasPrefix(got, "repo-issue-42-1-") {
t.Fatalf("sanitized id = %q, want readable prefix", got)
}
if got == "repo/issue#42.1" {
t.Fatal("sanitized id still contains raw unsafe characters")
}
}
func TestValidateSessionID(t *testing.T) {
valid := []string{"sess-1", "S_2", "abc123"}
for _, id := range valid {
if err := validateSessionID(id); err != nil {
t.Fatalf("validateSessionID(%q): %v", id, err)
}
}
invalid := []string{"", "sess.1", "sess/1", "$(boom)", "with space"}
for _, id := range invalid {
if err := validateSessionID(id); err == nil {
t.Fatalf("validateSessionID(%q): got nil, want error", id)
}
}
}
func TestWrapLaunchCommandExportsEnvAndKeepsPaneAlive(t *testing.T) {
oldGetenv := getenv
getenv = func(key string) string {
if key == "PATH" {
return "/usr/bin:/bin"
}
return ""
}
defer func() { getenv = oldGetenv }()
got := wrapLaunchCommand(ports.RuntimeConfig{LaunchCommand: "ao run", Env: map[string]string{
"AO_SESSION_ID": "sess-1",
"ODD": "can't",
"PATH": "/custom/bin:/usr/bin",
}}, "/bin/zsh")
for _, want := range []string{
"export AO_SESSION_ID='sess-1';",
"export ODD='can'\\''t';",
"export PATH='/custom/bin:/usr/bin';",
"ao run; exec '/bin/zsh' -i",
} {
if !strings.Contains(got, want) {
t.Fatalf("wrapped command missing %q in %q", want, got)
}
}
}
func TestCreateRunsNewSessionAndDisablesStatus(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/zsh"})
r.runner = fr
handle, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
Env: map[string]string{"AO_SESSION_ID": "sess-1"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if handle != (ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}) {
t.Fatalf("handle = %+v, want tmux handle", handle)
}
if len(fr.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(fr.calls))
}
if got, want := fr.calls[0].args[:6], []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws"}; !reflect.DeepEqual(got, want) {
t.Fatalf("create args prefix = %#v, want %#v", got, want)
}
if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("status args = %#v, want %#v", got, want)
}
}
func TestCreateNormalizesUnsafeSessionID(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh"})
r.runner = fr
handle, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "repo/issue#42",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := validateSessionID(handle.ID); err != nil {
t.Fatalf("handle id %q invalid: %v", handle.ID, err)
}
if handle.ID == "repo/issue#42" {
t.Fatal("handle kept unsafe raw session id")
}
if got := fr.calls[0].args[3]; got != handle.ID {
t.Fatalf("tmux session arg = %q, want handle id %q", got, handle.ID)
}
}
func TestSendMessageUsesLiteralForShortInput(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
if got, want := fr.calls[0].args, sendLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) {
t.Fatalf("literal args = %#v, want %#v", got, want)
}
if got, want := fr.calls[1].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("enter args = %#v, want %#v", got, want)
}
}
func TestSendMessageUsesBufferForMultilineInput(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello\nworld"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
if len(fr.calls) != 3 {
t.Fatalf("calls = %d, want 3", len(fr.calls))
}
if fr.calls[0].args[0] != "load-buffer" {
t.Fatalf("first command = %#v, want load-buffer", fr.calls[0].args)
}
if got := fr.calls[1].args; !reflect.DeepEqual(got[:4], []string{"paste-buffer", "-d", "-t", "=sess-1:0.0"}) {
t.Fatalf("paste args = %#v", got)
}
if got, want := fr.calls[2].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("enter args = %#v, want %#v", got, want)
}
}
func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) {
fr := &fakeRunner{err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName})
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
if alive {
t.Fatal("alive = true, want false")
}
}
func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
fr := &fakeRunner{err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}); err != nil {
t.Fatalf("Destroy: %v", err)
}
if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" {
t.Fatalf("calls = %#v, want only kill-session", fr.calls)
}
}
func TestGetOutputValidatesLines(t *testing.T) {
r := New(Options{Timeout: time.Second})
_, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, 0)
if err == nil {
t.Fatal("GetOutput lines=0: got nil, want error")
}
}
type fakeRunner struct {
calls []runnerCall
out []byte
err error
}
type runnerCall struct {
name string
args []string
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
f.calls = append(f.calls, runnerCall{name: name, args: append([]string(nil), args...)})
if f.err != nil {
return f.out, f.err
}
return f.out, nil
}
func TestCommandErrorUnwraps(t *testing.T) {
base := errors.New("base")
err := commandError{err: base, output: "details"}
if !errors.Is(err, base) {
t.Fatal("commandError should unwrap base error")
}
if !strings.Contains(err.Error(), "details") {
t.Fatalf("error = %q, want output details", err.Error())
}
}