From f975ca24c910dd90344a26c2670173d737b7e2c7 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 16:58:03 +0530 Subject: [PATCH 1/3] feat: add tmux runtime adapter --- .../adapters/runtime/tmux/commands.go | 89 +++++++ .../internal/adapters/runtime/tmux/tmux.go | 244 ++++++++++++++++++ .../runtime/tmux/tmux_integration_test.go | 71 +++++ .../adapters/runtime/tmux/tmux_test.go | 199 ++++++++++++++ 4 files changed, 603 insertions(+) create mode 100644 backend/internal/adapters/runtime/tmux/commands.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux_integration_test.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux_test.go diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go new file mode 100644 index 00000000..700d369c --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/commands.go @@ -0,0 +1,89 @@ +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", id, "status", "off"} +} + +func hasSessionArgs(id string) []string { + return []string{"has-session", "-t", id} +} + +func killSessionArgs(id string) []string { + return []string{"kill-session", "-t", id} +} + +func capturePaneArgs(id string, lines int) []string { + return []string{"capture-pane", "-p", "-t", id, "-S", fmt.Sprintf("-%d", lines)} +} + +func sendLiteralArgs(id, message string) []string { + return []string{"send-keys", "-t", id, "-l", message} +} + +func sendEnterArgs(id string) []string { + return []string{"send-keys", "-t", 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", id, "-b", bufferName} +} + +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, "'", "'\\''") + "'" +} diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go new file mode 100644 index 00000000..ac0fc628 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -0,0 +1,244 @@ +// Package tmux implements ports.Runtime using tmux sessions. +package tmux + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "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/zsh" + } + return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}} +} + +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + id := string(cfg.SessionID) + if err := validateSessionID(id); 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 + } + alive, err := r.IsAlive(ctx, handle) + if err != nil { + return err + } + if !alive { + return nil + } + if _, err := r.run(ctx, killSessionArgs(id)...); err != 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", 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 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 } diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go new file mode 100644 index 00000000..7d6d8137 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go @@ -0,0 +1,71 @@ +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") + } +} diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go new file mode 100644 index 00000000..baa1d7fc --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -0,0 +1,199 @@ +package tmux + +import ( + "context" + "errors" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +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", "-S", "-42"}; !reflect.DeepEqual(got, want) { + t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) + } +} + +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 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"}) { + 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] != "has-session" { + t.Fatalf("calls = %#v, want only has-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()) + } +} From b5a344ac07026c323d67c5cf95863d38f2ce796a Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 17:17:59 +0530 Subject: [PATCH 2/3] fix: use exact tmux targets --- .../adapters/runtime/tmux/commands.go | 22 ++++++---- .../internal/adapters/runtime/tmux/tmux.go | 2 +- .../runtime/tmux/tmux_integration_test.go | 41 +++++++++++++++++++ .../adapters/runtime/tmux/tmux_test.go | 15 +++++-- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go index 700d369c..6cf8739e 100644 --- a/backend/internal/adapters/runtime/tmux/commands.go +++ b/backend/internal/adapters/runtime/tmux/commands.go @@ -15,27 +15,27 @@ func newSessionArgs(id, workspacePath, shellPath, script string) []string { } func setStatusOffArgs(id string) []string { - return []string{"set-option", "-t", id, "status", "off"} + return []string{"set-option", "-t", exactSessionTarget(id), "status", "off"} } func hasSessionArgs(id string) []string { - return []string{"has-session", "-t", id} + return []string{"has-session", "-t", exactSessionTarget(id)} } func killSessionArgs(id string) []string { - return []string{"kill-session", "-t", id} + return []string{"kill-session", "-t", exactSessionTarget(id)} } func capturePaneArgs(id string, lines int) []string { - return []string{"capture-pane", "-p", "-t", id, "-S", fmt.Sprintf("-%d", lines)} + return []string{"capture-pane", "-p", "-t", exactPaneTarget(id), "-S", fmt.Sprintf("-%d", lines)} } func sendLiteralArgs(id, message string) []string { - return []string{"send-keys", "-t", id, "-l", message} + return []string{"send-keys", "-t", exactPaneTarget(id), "-l", message} } func sendEnterArgs(id string) []string { - return []string{"send-keys", "-t", id, "C-m"} + return []string{"send-keys", "-t", exactPaneTarget(id), "C-m"} } func loadBufferArgs(bufferName, path string) []string { @@ -43,7 +43,15 @@ func loadBufferArgs(bufferName, path string) []string { } func pasteBufferArgs(id, bufferName string) []string { - return []string{"paste-buffer", "-d", "-t", id, "-b", bufferName} + 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 { diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index ac0fc628..5fbbafb2 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -160,7 +160,7 @@ func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { if err != nil { return nil, err } - return append([]string{r.binary}, "attach", "-t", id), nil + return append([]string{r.binary}, "attach", "-t", exactSessionTarget(id)), nil } func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error { diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go index 7d6d8137..7e798673 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go @@ -69,3 +69,44 @@ func TestRuntimeIntegration(t *testing.T) { 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") + } +} diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index baa1d7fc..1d461609 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -16,14 +16,23 @@ 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) { + 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", "-S", "-42"}; !reflect.DeepEqual(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 TestValidateSessionID(t *testing.T) { valid := []string{"sess-1", "S_2", "abc123"} for _, id := range valid { @@ -125,7 +134,7 @@ func TestSendMessageUsesBufferForMultilineInput(t *testing.T) { 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"}) { + 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) { From 37f1fe269e5e248692f405d7d7c099555309d7de Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 18:20:03 +0530 Subject: [PATCH 3/3] fix: harden tmux runtime teardown and ids --- .../internal/adapters/runtime/tmux/tmux.go | 57 +++++++++++++++---- .../adapters/runtime/tmux/tmux_test.go | 52 ++++++++++++++++- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 5fbbafb2..ba7524ed 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -3,6 +3,8 @@ package tmux import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "os" @@ -12,6 +14,7 @@ import ( "strings" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -61,14 +64,14 @@ func New(opts Options) *Runtime { shellPath = os.Getenv("SHELL") } if shellPath == "" { - shellPath = "/bin/zsh" + 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 := string(cfg.SessionID) - if err := validateSessionID(id); err != nil { + id, err := tmuxSessionName(cfg.SessionID) + if err != nil { return ports.RuntimeHandle{}, err } if cfg.WorkspacePath == "" { @@ -94,14 +97,11 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error if err != nil { return err } - alive, err := r.IsAlive(ctx, handle) - if err != nil { - return err - } - if !alive { - return nil - } 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 @@ -205,6 +205,43 @@ func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { 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") diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index 1d461609..cb56db35 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -12,6 +12,14 @@ import ( "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) @@ -33,6 +41,22 @@ func TestExactTargets(t *testing.T) { } } +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 { @@ -104,6 +128,30 @@ func TestCreateRunsNewSessionAndDisablesStatus(t *testing.T) { } } +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}) @@ -164,8 +212,8 @@ func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { 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] != "has-session" { - t.Fatalf("calls = %#v, want only has-session", fr.calls) + if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { + t.Fatalf("calls = %#v, want only kill-session", fr.calls) } }