diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go new file mode 100644 index 000000000..4cdf86542 --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -0,0 +1,233 @@ +package zellij + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + runtimeName = "zellij" + agentPaneName = "agent" + defaultChunkBytes = 16 * 1024 +) + +func versionArgs() []string { + return []string{"--version"} +} + +func createSessionArgs(id, layoutPath string) []string { + return []string{ + "attach", "--create-background", id, + "options", + "--default-layout", layoutPath, + "--pane-frames", "false", + "--session-serialization", "false", + "--show-startup-tips", "false", + "--show-release-notes", "false", + } +} + +func listPanesArgs(id string) []string { + return []string{"--session", id, "action", "list-panes", "--all", "--json"} +} + +func pasteArgs(id, paneID, chunk string) []string { + return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk} +} + +func sendEnterArgs(id, paneID string) []string { + return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"} +} + +func dumpScreenArgs(id, paneID string) []string { + return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"} +} + +func listSessionsArgs() []string { + return []string{"list-sessions", "--no-formatting"} +} + +func killSessionArgs(id string) []string { + return []string{"kill-session", id} +} + +func attachArgs(id string) []string { + return []string{"attach", id} +} + +func handleIDValue(sessionID, paneID string) string { + return sessionID + "/" + paneID +} + +func terminalPaneID(id int) string { + return fmt.Sprintf("terminal_%d", id) +} + +func buildLayout(cfg ports.RuntimeConfig, shellPath string) string { + spec := shellLaunchSpecFor(shellPath) + shellCommand := shellLaunchCommand(cfg, shellPath, spec) + return layoutString(cfg.WorkspacePath, shellPath, spec.args, shellCommand) +} + +type shellLaunchSpec struct { + args []string +} + +func shellLaunchSpecFor(shellPath string) shellLaunchSpec { + base := strings.ToLower(filepathBase(shellPath)) + if strings.Contains(base, "cmd") { + return shellLaunchSpec{args: []string{"/D", "/S", "/K"}} + } + if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { + return shellLaunchSpec{args: []string{"-NoLogo", "-NoProfile", "-NoExit", "-Command"}} + } + return shellLaunchSpec{args: []string{"-lc"}} +} + +func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string { + return "layout {\n" + + " cwd " + kdlQuote(workspacePath) + "\n" + + " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " {\n" + + " args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" + + " }\n" + + "}\n" +} + +func shellLaunchCommand(cfg ports.RuntimeConfig, shellPath string, spec shellLaunchSpec) string { + if len(spec.args) > 0 && spec.args[0] == "-NoLogo" { + return wrapLaunchCommandPowerShell(cfg, shellPath) + } + if len(spec.args) > 0 && spec.args[0] == "/D" { + return wrapLaunchCommandCmd(cfg) + } + return wrapLaunchCommandUnix(cfg, shellPath) +} + +func wrapLaunchCommandUnix(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 wrapLaunchCommandPowerShell(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("$env:") + b.WriteString(key) + b.WriteString(" = ") + b.WriteString(psQuote(cfg.Env[key])) + b.WriteString("; ") + } + if path != "" { + b.WriteString("$env:PATH = ") + b.WriteString(psQuote(path)) + b.WriteString("; ") + } + b.WriteString(cfg.LaunchCommand) + return b.String() +} + +func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) 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("set \"") + b.WriteString(key) + b.WriteString("=") + b.WriteString(cmdQuote(cfg.Env[key])) + b.WriteString("\" && ") + } + if path != "" { + b.WriteString("set \"PATH=") + b.WriteString(cmdQuote(path)) + b.WriteString("\" && ") + } + b.WriteString(cfg.LaunchCommand) + 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, "'", "'\\''") + "'" +} + +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +func cmdQuote(s string) string { + return strings.ReplaceAll(s, "\"", "\"\"") +} + +func kdlQuote(s string) string { + return strconv.Quote(s) +} + +func kdlJoin(args []string) string { + parts := make([]string, 0, len(args)) + for _, arg := range args { + parts = append(parts, kdlQuote(arg)) + } + return strings.Join(parts, " ") +} + +func filepathBase(path string) string { + if path == "" { + return "" + } + i := strings.LastIndexAny(path, `/\`) + if i < 0 { + return path + } + return path[i+1:] +} diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go new file mode 100644 index 000000000..b98df8491 --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -0,0 +1,539 @@ +// Package zellij implements ports.Runtime using Zellij sessions. +package zellij + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultTimeout = 5 * time.Second + minMajor = 0 + minMinor = 44 + minPatch = 3 +) + +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +var paneIDPattern = regexp.MustCompile(`^terminal_[0-9]+$`) + +var getenv = os.Getenv + +type Options struct { + Binary string + Timeout time.Duration + Shell string + SocketDir string + ConfigDir string + ChunkSize int +} + +type Runtime struct { + binary string + timeout time.Duration + shell string + socketDir string + configDir string + chunkSize int + runner runner +} + +var _ ports.Runtime = (*Runtime)(nil) + +type runner interface { + Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + return cmd.CombinedOutput() +} + +func New(opts Options) *Runtime { + binary := opts.Binary + if binary == "" { + binary = "zellij" + } + timeout := opts.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + shellPath := opts.Shell + if shellPath == "" { + shellPath = os.Getenv("SHELL") + } + if shellPath == "" { + if runtime.GOOS == "windows" { + shellPath = "powershell.exe" + } else { + shellPath = "/bin/sh" + } + } + chunkSize := opts.ChunkSize + if chunkSize <= 0 { + chunkSize = defaultChunkBytes + } + return &Runtime{binary: binary, timeout: timeout, shell: shellPath, socketDir: opts.SocketDir, configDir: opts.ConfigDir, chunkSize: chunkSize, runner: execRunner{}} +} + +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + id, err := zellijSessionName(cfg.SessionID) + if err != nil { + return ports.RuntimeHandle{}, err + } + if cfg.WorkspacePath == "" { + return ports.RuntimeHandle{}, errors.New("zellij runtime: workspace path is required") + } + if cfg.LaunchCommand == "" { + return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required") + } + if err := r.ensureSupportedVersion(ctx); err != nil { + return ports.RuntimeHandle{}, err + } + + layoutPath, err := r.writeLayout(cfg) + if err != nil { + return ports.RuntimeHandle{}, err + } + defer os.Remove(layoutPath) + + if _, err := r.run(ctx, createSessionArgs(id, layoutPath)...); err != nil { + return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err) + } + paneID, err := r.findAgentPane(ctx, id) + if err != nil { + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + return ports.RuntimeHandle{}, err + } + return ports.RuntimeHandle{ID: handleIDValue(id, paneID), 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("zellij runtime: destroy session %s: %w", id, err) + } + return nil +} + +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { + id, paneID, err := handleID(handle) + if err != nil { + return err + } + for _, chunk := range chunks(message, r.chunkSize) { + if _, err := r.run(ctx, pasteArgs(id, paneID, chunk)...); err != nil { + return fmt.Errorf("zellij runtime: paste message %s/%s: %w", id, paneID, err) + } + } + if _, err := r.run(ctx, sendEnterArgs(id, paneID)...); err != nil { + return fmt.Errorf("zellij runtime: send enter %s/%s: %w", id, paneID, err) + } + return nil +} + +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { + id, paneID, err := handleID(handle) + if err != nil { + return "", err + } + if lines <= 0 { + return "", errors.New("zellij runtime: lines must be positive") + } + out, err := r.run(ctx, dumpScreenArgs(id, paneID)...) + if err != nil { + return "", fmt.Errorf("zellij runtime: capture output %s/%s: %w", id, paneID, err) + } + return tailLines(string(out), lines), nil +} + +func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { + id, _, err := handleID(handle) + if err != nil { + return false, err + } + out, err := r.run(ctx, listSessionsArgs()...) + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, fmt.Errorf("zellij runtime: probe session %s: %w", id, err) + } + return sessionListedAlive(string(out), id), nil +} + +func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { + id, _, err := handleID(handle) + if err != nil { + return nil, err + } + args := append([]string{}, r.baseArgs()...) + args = append(args, attachArgs(id)...) + if r.socketDir == "" { + return append([]string{r.binary}, args...), nil + } + return attachCommandWithEnv(r.binary, r.socketDir, args...), nil +} + +func (r *Runtime) ensureSupportedVersion(ctx context.Context) error { + out, err := r.run(ctx, versionArgs()...) + if err != nil { + return fmt.Errorf("zellij runtime: check version: %w", err) + } + version, err := parseVersion(string(out)) + if err != nil { + return fmt.Errorf("zellij runtime: check version: %w", err) + } + if compareVersion(version, semver{minMajor, minMinor, minPatch}) < 0 { + return fmt.Errorf("zellij runtime: unsupported zellij version %s; require >= %d.%d.%d", version, minMajor, minMinor, minPatch) + } + return nil +} + +func (r *Runtime) writeLayout(cfg ports.RuntimeConfig) (string, error) { + file, err := os.CreateTemp(os.TempDir(), "ao-zellij-layout-*.kdl") + if err != nil { + return "", fmt.Errorf("zellij runtime: create layout temp file: %w", err) + } + path := file.Name() + if _, err := file.WriteString(buildLayout(cfg, r.shell)); err != nil { + _ = file.Close() + _ = os.Remove(path) + return "", fmt.Errorf("zellij runtime: write layout temp file: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return "", fmt.Errorf("zellij runtime: close layout temp file: %w", err) + } + return path, nil +} + +func (r *Runtime) findAgentPane(ctx context.Context, id string) (string, error) { + deadline := time.Now().Add(r.timeout) + var lastErr error + for { + out, err := r.run(ctx, listPanesArgs(id)...) + if err == nil { + paneID, parseErr := agentPaneID(out) + if parseErr == nil { + return paneID, nil + } + lastErr = parseErr + } else { + lastErr = err + } + if time.Now().After(deadline) { + return "", fmt.Errorf("zellij runtime: list panes %s: %w", id, lastErr) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { + cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + fullArgs := append(r.baseArgs(), args...) + out, err := r.runner.Run(cmdCtx, r.env(), r.binary, fullArgs...) + 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 (r *Runtime) baseArgs() []string { + args := []string{} + if r.configDir != "" { + args = append(args, "--config-dir", r.configDir) + } + return args +} + +func (r *Runtime) env() []string { + if r.socketDir == "" { + return nil + } + return []string{"ZELLIJ_SOCKET_DIR=" + r.socketDir} +} + +func attachCommandWithEnv(binary, socketDir string, args ...string) []string { + if socketDir == "" { + return append([]string{binary}, args...) + } + if runtime.GOOS == "windows" { + command := strings.Builder{} + command.WriteString("$env:ZELLIJ_SOCKET_DIR = ") + command.WriteString(psQuote(socketDir)) + command.WriteString("; & ") + command.WriteString(psQuote(binary)) + for _, arg := range args { + command.WriteByte(' ') + command.WriteString(psQuote(arg)) + } + return []string{"powershell.exe", "-NoLogo", "-NoProfile", "-Command", command.String()} + } + return append([]string{"env", "ZELLIJ_SOCKET_DIR=" + socketDir, binary}, args...) +} + +func zellijSessionName(id domain.SessionID) (string, error) { + raw := string(id) + if raw == "" { + return "", errors.New("zellij runtime: session id is required") + } + if sessionIDPattern.MatchString(raw) && len(raw) <= 48 { + 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) > 32 { + base = strings.TrimRight(base[:32], "-") + } + sum := sha256.Sum256([]byte(raw)) + return base + "-" + hex.EncodeToString(sum[:4]) +} + +func validateSessionID(id string) error { + if id == "" { + return errors.New("zellij runtime: session id is required") + } + if !sessionIDPattern.MatchString(id) { + return fmt.Errorf("zellij runtime: invalid session id %q", id) + } + return nil +} + +func validatePaneID(id string) error { + if id == "" { + return errors.New("zellij runtime: pane id is required") + } + if !paneIDPattern.MatchString(id) { + return fmt.Errorf("zellij runtime: invalid pane id %q", id) + } + return nil +} + +func handleID(handle ports.RuntimeHandle) (string, string, error) { + if handle.RuntimeName != "" && handle.RuntimeName != runtimeName { + return "", "", fmt.Errorf("zellij runtime: wrong runtime %q", handle.RuntimeName) + } + parts := strings.Split(handle.ID, "/") + if len(parts) == 1 { + if err := validateSessionID(parts[0]); err != nil { + return "", "", err + } + return parts[0], terminalPaneID(0), nil + } + if len(parts) != 2 { + return "", "", fmt.Errorf("zellij runtime: invalid handle id %q", handle.ID) + } + if err := validateSessionID(parts[0]); err != nil { + return "", "", err + } + if err := validatePaneID(parts[1]); err != nil { + return "", "", err + } + return parts[0], parts[1], nil +} + +type paneInfo struct { + ID int `json:"id"` + IsPlugin bool `json:"is_plugin"` + Title string `json:"title"` +} + +func agentPaneID(out []byte) (string, error) { + var panes []paneInfo + if err := json.Unmarshal(out, &panes); err != nil { + return "", fmt.Errorf("parse panes: %w", err) + } + for _, pane := range panes { + if !pane.IsPlugin && pane.Title == agentPaneName { + return terminalPaneID(pane.ID), nil + } + } + for _, pane := range panes { + if !pane.IsPlugin { + return terminalPaneID(pane.ID), nil + } + } + return "", errors.New("agent pane not found") +} + +func chunks(s string, maxBytes int) []string { + if s == "" { + return []string{""} + } + if maxBytes <= 0 || len(s) <= maxBytes { + return []string{s} + } + parts := []string{} + for len(s) > 0 { + if len(s) <= maxBytes { + parts = append(parts, s) + break + } + end := maxBytes + for end > 0 && !utf8.ValidString(s[:end]) { + end-- + } + if end == 0 { + _, size := utf8.DecodeRuneInString(s) + end = size + } + parts = append(parts, s[:end]) + s = s[end:] + } + return parts +} + +func tailLines(s string, n int) string { + if n <= 0 || s == "" { + return "" + } + lines := strings.SplitAfter(s, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) <= n { + return s + } + return strings.Join(lines[len(lines)-n:], "") +} + +type semver struct { + major int + minor int + patch int +} + +func (v semver) String() string { + return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) +} + +func parseVersion(out string) (semver, error) { + fields := strings.Fields(strings.TrimSpace(out)) + if len(fields) == 0 { + return semver{}, errors.New("empty version output") + } + raw := strings.TrimPrefix(fields[len(fields)-1], "v") + parts := strings.Split(raw, ".") + if len(parts) < 3 { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + major, err := parseVersionPart(parts[0]) + if err != nil { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + minor, err := parseVersionPart(parts[1]) + if err != nil { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + patch, err := parseVersionPart(parts[2]) + if err != nil { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + return semver{major: major, minor: minor, patch: patch}, nil +} + +func parseVersionPart(s string) (int, error) { + end := 0 + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + if end == 0 { + return 0, errors.New("missing version number") + } + return strconv.Atoi(s[:end]) +} + +func compareVersion(a, b semver) int { + if a.major != b.major { + return a.major - b.major + } + if a.minor != b.minor { + return a.minor - b.minor + } + return a.patch - b.patch +} + +func sessionListedAlive(out, id string) bool { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != id { + continue + } + return !strings.Contains(line, "(EXITED") + } + return false +} + +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/zellij/zellij_integration_test.go b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go new file mode 100644 index 000000000..6729cc3b5 --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go @@ -0,0 +1,113 @@ +package zellij + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestRuntimeIntegration(t *testing.T) { + if _, err := exec.LookPath("zellij"); err != nil { + t.Skip("zellij unavailable") + } + + ctx := context.Background() + id := "ao_itest_zj" + socketDir := filepath.Join(os.TempDir(), "ao-zj-itest") + if err := os.MkdirAll(socketDir, 0o755); err != nil { + t.Fatalf("mkdir socket dir: %v", err) + } + configDir := t.TempDir() + r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + + h, err := r.Create(ctx, ports.RuntimeConfig{ + SessionID: "ao_itest_zj", + WorkspacePath: t.TempDir(), + LaunchCommand: "printf ready-$AO_SESSION_ID\\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-zellij"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + deadline := time.Now().Add(3 * time.Second) + var out string + for time.Now().Before(deadline) { + out, err = r.GetOutput(ctx, h, 30) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if strings.Contains(out, "hello-from-zellij") { + break + } + time.Sleep(100 * time.Millisecond) + } + if !strings.Contains(out, "hello-from-zellij") { + 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 TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { + if _, err := exec.LookPath("zellij"); err != nil { + t.Skip("zellij unavailable") + } + + ctx := context.Background() + socketDir := filepath.Join(os.TempDir(), "ao-zj-exact-itest") + if err := os.MkdirAll(socketDir, 0o755); err != nil { + t.Fatalf("mkdir socket dir: %v", err) + } + r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()}) + longID := "ao_zj_exact_long" + prefixID := "ao_zj_exact" + _ = 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_zj_exact_long", + WorkspacePath: t.TempDir(), + LaunchCommand: "printf ready\\n", + }) + 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; zellij session matching is not exact") + } +} diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go new file mode 100644 index 000000000..a690af038 --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -0,0 +1,403 @@ +package zellij + +import ( + "context" + "errors" + "os/exec" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestNewDefaultsToPortableShell(t *testing.T) { + t.Setenv("SHELL", "") + r := New(Options{}) + want := "/bin/sh" + if runtime.GOOS == "windows" { + want = "powershell.exe" + } + if got := r.shell; got != want { + t.Fatalf("default shell = %q, want %q", got, want) + } +} + +func TestCommandBuilders(t *testing.T) { + if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) { + t.Fatalf("versionArgs = %#v, want %#v", got, want) + } + if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), []string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl", "--pane-frames", "false", "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false"}; !reflect.DeepEqual(got, want) { + t.Fatalf("createSessionArgs = %#v, want %#v", got, want) + } + if got, want := listPanesArgs("sess-1"), []string{"--session", "sess-1", "action", "list-panes", "--all", "--json"}; !reflect.DeepEqual(got, want) { + t.Fatalf("listPanesArgs = %#v, want %#v", got, want) + } + if got, want := pasteArgs("sess-1", "terminal_0", "hello"), []string{"--session", "sess-1", "action", "paste", "--pane-id", "terminal_0", "hello"}; !reflect.DeepEqual(got, want) { + t.Fatalf("pasteArgs = %#v, want %#v", got, want) + } + if got, want := dumpScreenArgs("sess-1", "terminal_0"), []string{"--session", "sess-1", "action", "dump-screen", "--pane-id", "terminal_0", "--full"}; !reflect.DeepEqual(got, want) { + t.Fatalf("dumpScreenArgs = %#v, want %#v", got, want) + } +} + +func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) { + got, err := zellijSessionName("repo/issue#42.1") + if err != nil { + t.Fatalf("zellijSessionName: %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 TestValidateSessionAndPaneID(t *testing.T) { + for _, id := range []string{"sess-1", "S_2", "abc123"} { + if err := validateSessionID(id); err != nil { + t.Fatalf("validateSessionID(%q): %v", id, err) + } + } + for _, id := range []string{"", "sess.1", "sess/1", "$(boom)", "with space"} { + if err := validateSessionID(id); err == nil { + t.Fatalf("validateSessionID(%q): got nil, want error", id) + } + } + for _, id := range []string{"terminal_0", "terminal_42"} { + if err := validatePaneID(id); err != nil { + t.Fatalf("validatePaneID(%q): %v", id, err) + } + } + for _, id := range []string{"", "0", "plugin_0", "terminal_x", "terminal_1/2"} { + if err := validatePaneID(id); err == nil { + t.Fatalf("validatePaneID(%q): got nil, want error", id) + } + } +} + +func TestHandleID(t *testing.T) { + session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("handleID: %v", err) + } + if session != "sess-1" || pane != "terminal_7" { + t.Fatalf("handleID = %q/%q", session, pane) + } + _, _, err = handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: "tmux"}) + if err == nil { + t.Fatal("wrong runtime: got nil, want error") + } +} + +func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + if key == "PATH" { + return "/usr/bin:/bin" + } + return "" + } + defer func() { getenv = oldGetenv }() + + got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", 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{ + `cwd "/tmp/ws"`, + `pane command="/bin/zsh" name="agent"`, + "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("layout missing %q in %q", want, got) + } + } +} + +func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + if key == "PATH" { + return `C:\custom\bin` + } + return "" + } + defer func() { getenv = oldGetenv }() + + got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "Write-Host ready", Env: map[string]string{ + "AO_SESSION_ID": "sess-1", + }}, `C:\Program Files\PowerShell\7\pwsh.exe`) + + for _, want := range []string{ + `pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent"`, + `args "-NoLogo" "-NoProfile" "-NoExit" "-Command"`, + "$env:AO_SESSION_ID = 'sess-1';", + "$env:PATH = ", + "Write-Host ready", + } { + if !strings.Contains(got, want) { + t.Fatalf("powershell layout missing %q in %q", want, got) + } + } +} + +func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + return "" + } + defer func() { getenv = oldGetenv }() + + got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "echo ready", Env: map[string]string{ + "AO_SESSION_ID": "sess-1", + }}, `C:\Windows\System32\cmd.exe`) + + for _, want := range []string{ + `pane command="C:\\Windows\\System32\\cmd.exe" name="agent"`, + `args "/D" "/S" "/K"`, + `AO_SESSION_ID=sess-1`, + "echo ready", + } { + if !strings.Contains(got, want) { + t.Fatalf("cmd layout missing %q in %q", want, got) + } + } +} + +func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}} + r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) + 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/terminal_3", RuntimeName: runtimeName}) { + t.Fatalf("handle = %+v, want zellij handle", handle) + } + if len(fr.calls) != 3 { + t.Fatalf("calls = %d, want 3", len(fr.calls)) + } + if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) { + t.Fatalf("version args = %#v, want %#v", got, want) + } + if got := fr.calls[1].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) { + t.Fatalf("create args prefix = %#v", got) + } + if got := fr.calls[2].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { + t.Fatalf("list panes args = %#v", got) + } + if got, want := fr.calls[0].env, []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { + t.Fatalf("env = %#v, want %#v", got, want) + } +} + +func TestAttachCommandUsesSocketDir(t *testing.T) { + r := New(Options{SocketDir: "/tmp/zj"}) + args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("AttachCommand: %v", err) + } + if runtime.GOOS == "windows" { + if len(args) < 4 || args[0] != "powershell.exe" { + t.Fatalf("attach command = %#v, want powershell wrapper", args) + } + if !strings.Contains(strings.Join(args, " "), "ZELLIJ_SOCKET_DIR") { + t.Fatalf("attach command missing socket dir env: %#v", args) + } + return + } + if got, want := args[:3], []string{"env", "ZELLIJ_SOCKET_DIR=/tmp/zj", r.binary}; !reflect.DeepEqual(got, want) { + t.Fatalf("attach prefix = %#v, want %#v", got, want) + } +} + +func TestFindAgentPaneRetriesTransientErrors(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("boom"), []byte(`[{"id":0,"is_plugin":false,"title":"agent"}]`)}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + got, err := r.findAgentPane(context.Background(), "sess-1") + if err != nil { + t.Fatalf("findAgentPane: %v", err) + } + if got != "terminal_0" { + t.Fatalf("findAgentPane = %q, want terminal_0", got) + } + if len(fr.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(fr.calls)) + } +} + +func TestParseVersion(t *testing.T) { + for _, tc := range []struct { + in string + want semver + }{ + {in: "zellij 0.44.3", want: semver{0, 44, 3}}, + {in: "zellij v1.2.3\n", want: semver{1, 2, 3}}, + {in: "zellij 0.44.3-dev", want: semver{0, 44, 3}}, + } { + got, err := parseVersion(tc.in) + if err != nil { + t.Fatalf("parseVersion(%q): %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("parseVersion(%q) = %v, want %v", tc.in, got, tc.want) + } + } + if _, err := parseVersion("zellij nope"); err == nil { + t.Fatal("parseVersion invalid: got nil, want error") + } + if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 { + t.Fatal("compareVersion should order 0.44.2 before 0.44.3") + } +} + +func TestSendMessageChunksAndSendsEnter(t *testing.T) { + fr := &fakeRunner{} + r := New(Options{Timeout: time.Second, ChunkSize: 5}) + r.runner = fr + + if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, "hello世界"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + if len(fr.calls) != 4 { + t.Fatalf("calls = %d, want 4", len(fr.calls)) + } + if got, want := fr.calls[0].args, pasteArgs("sess-1", "terminal_0", "hello"); !reflect.DeepEqual(got, want) { + t.Fatalf("paste 1 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[1].args, pasteArgs("sess-1", "terminal_0", "世"); !reflect.DeepEqual(got, want) { + t.Fatalf("paste 2 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[2].args, pasteArgs("sess-1", "terminal_0", "界"); !reflect.DeepEqual(got, want) { + t.Fatalf("paste 3 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[3].args, sendEnterArgs("sess-1", "terminal_0"); !reflect.DeepEqual(got, want) { + t.Fatalf("enter args = %#v, want %#v", got, want) + } +} + +func TestGetOutputTrimsLines(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("one\ntwo\nthree\n")}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 2) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if out != "two\nthree\n" { + t.Fatalf("output = %q, want last two lines", out) + } +} + +func TestIsAliveParsesNoFormattingOutput(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("sess-1 [Created 1s ago] \nold [Created 2s ago] (EXITED - attach to resurrect)\n")}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if !alive { + t.Fatal("alive = false, want true") + } + if sessionListedAlive("sess-1-long [Created 1s ago]", "sess-1") { + t.Fatal("prefix matched as alive") + } + if sessionListedAlive("sess-1 [Created 1s ago] (EXITED - attach to resurrect)", "sess-1") { + t.Fatal("exited session matched as alive") + } +} + +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/terminal_0", 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/terminal_0", 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/terminal_0", RuntimeName: runtimeName}, 0) + if err == nil { + t.Fatal("GetOutput lines=0: got nil, want error") + } +} + +type fakeRunner struct { + calls []runnerCall + outputs [][]byte + err error +} + +type runnerCall struct { + env []string + name string + args []string +} + +func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + var out []byte + if len(f.outputs) > 0 { + out = f.outputs[0] + f.outputs = f.outputs[1:] + } + if f.err != nil { + return out, f.err + } + return 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()) + } +}