"""Tests for spawn backend environment propagation.""" from __future__ import annotations import subprocess import sys from clawteam.spawn.cli_env import build_spawn_path, resolve_clawteam_executable from clawteam.spawn.subprocess_backend import SubprocessBackend from clawteam.spawn.tmux_backend import ( TmuxBackend, _confirm_workspace_trust_if_prompted, _dismiss_codex_update_prompt_if_present, _inject_prompt_via_buffer, _wait_for_cli_ready, ) class DummyProcess: def __init__(self, pid: int = 4321): self.pid = pid def poll(self): return None def test_subprocess_backend_prepends_current_clawteam_bin_to_path(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) captured: dict[str, object] = {} def fake_popen(cmd, **kwargs): captured["cmd"] = cmd captured["env"] = kwargs["env"] return DummyProcess() monkeypatch.setattr( "clawteam.spawn.command_validation.shutil.which", lambda name, path=None: "/usr/bin/codex" if name == "codex" else None, ) monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = SubprocessBackend() backend.spawn( command=["codex"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) env = captured["env"] assert env["PATH"].startswith(f"{clawteam_bin.parent}:") assert env["CLAWTEAM_BIN"] == str(clawteam_bin) def test_subprocess_backend_discards_output_and_preserves_exit_hook_and_registry( monkeypatch, tmp_path ): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) captured: dict[str, object] = {} registered: dict[str, object] = {} def fake_popen(cmd, **kwargs): captured["cmd"] = cmd captured["stdout"] = kwargs["stdout"] captured["stderr"] = kwargs["stderr"] captured["cwd"] = kwargs["cwd"] return DummyProcess(pid=9876) def fake_register_agent(**kwargs): registered.update(kwargs) monkeypatch.setattr( "clawteam.spawn.command_validation.shutil.which", lambda name, path=None: "/usr/bin/codex" if name == "codex" else None, ) monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) monkeypatch.setattr("clawteam.spawn.registry.register_agent", fake_register_agent) backend = SubprocessBackend() result = backend.spawn( command=["codex"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) assert result == "Agent 'worker1' spawned as subprocess (pid=9876)" assert captured["stdout"] is subprocess.DEVNULL assert captured["stderr"] is subprocess.DEVNULL assert captured["cwd"] == "/tmp/demo" assert ( f"{clawteam_bin} lifecycle on-exit --team demo-team --agent worker1" in captured["cmd"] ) assert registered == { "team_name": "demo-team", "agent_name": "worker1", "backend": "subprocess", "pid": 9876, "command": ["codex", "--dangerously-bypass-approvals-and-sandbox", "do work"], } def test_tmux_backend_exports_spawn_path_for_agent_commands(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") monkeypatch.setenv("CLAWTEAM_DATA_DIR", "/tmp/clawteam-data") monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "demo-project") monkeypatch.setenv("PROGRAMFILES(X86)", "should-not-be-exported") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) run_calls: list[list[str]] = [] class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:3] == ["tmux", "has-session", "-t"]: return Result(returncode=1) if args[:3] == ["tmux", "list-panes", "-t"]: return Result(returncode=0, stdout="9876\n") return Result(returncode=0) original_which = __import__("shutil").which def fake_which(name, path=None): if name == "tmux": return "/opt/homebrew/bin/tmux" if name == "codex": return "/usr/bin/codex" return original_which(name, path=path) monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr( "clawteam.spawn.tmux_backend._confirm_workspace_trust_if_prompted", lambda *_, **__: False, ) monkeypatch.setattr( "clawteam.spawn.tmux_backend._dismiss_codex_update_prompt_if_present", lambda *_, **__: False, ) monkeypatch.setattr( "clawteam.spawn.tmux_backend._wait_for_cli_ready", lambda *_, **__: True, ) monkeypatch.setattr("clawteam.spawn.tmux_backend._inject_prompt_via_buffer", lambda *_, **__: None) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = TmuxBackend() backend.spawn( command=["codex"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) new_session = next(call for call in run_calls if call[:3] == ["tmux", "new-session", "-d"]) full_cmd = new_session[-1] assert f"export PATH={clawteam_bin.parent}:/usr/bin:/bin" in full_cmd assert f"export CLAWTEAM_BIN={clawteam_bin}" in full_cmd assert "export CLAWTEAM_DATA_DIR=/tmp/clawteam-data" in full_cmd assert "export GOOGLE_CLOUD_PROJECT=demo-project" in full_cmd assert "cd /tmp/demo &&" in full_cmd assert "PROGRAMFILES(X86)" not in full_cmd assert f"{clawteam_bin} lifecycle on-exit --team demo-team --agent worker1" in full_cmd def test_tmux_backend_uses_configured_timeout_for_workspace_trust_prompt(monkeypatch, tmp_path): from clawteam.config import ClawTeamConfig monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): if args[:3] == ["tmux", "has-session", "-t"]: return Result(returncode=1) if args[:3] == ["tmux", "list-panes", "-t"]: return Result(returncode=0, stdout="9876\n") return Result(returncode=0) captured: dict[str, object] = {} def fake_confirm(target, command, timeout_seconds=0.0, poll_interval_seconds=0.2): captured["target"] = target captured["command"] = command captured["timeout_seconds"] = timeout_seconds captured["poll_interval_seconds"] = poll_interval_seconds return False original_which = __import__("shutil").which def fake_which(name, path=None): if name == "tmux": return "/usr/bin/tmux" if name == "codex": return "/usr/bin/codex" return original_which(name, path=path) monkeypatch.setattr("clawteam.config.load_config", lambda: ClawTeamConfig(spawn_ready_timeout=42.0)) monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr( "clawteam.spawn.tmux_backend._confirm_workspace_trust_if_prompted", fake_confirm, ) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = TmuxBackend() backend.spawn( command=["codex"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) assert captured["target"] == "clawteam-demo-team:worker1" assert captured["command"] == ["codex"] assert captured["timeout_seconds"] == 42.0 assert captured["poll_interval_seconds"] == 0.2 def test_tmux_backend_returns_error_when_command_missing(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) run_calls: list[list[str]] = [] def fake_which(name, path=None): if name == "tmux": return "/usr/bin/tmux" return None def fake_run(args, **kwargs): run_calls.append(args) raise AssertionError("tmux should not be invoked when the command is missing") monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) backend = TmuxBackend() result = backend.spawn( command=["nanobot"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) assert result == ( "Error: command 'nanobot' not found in PATH. " "Install the agent CLI first or pass an executable path." ) assert run_calls == [] def test_subprocess_backend_returns_error_when_command_missing(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) popen_called = False def fake_popen(*args, **kwargs): nonlocal popen_called popen_called = True raise AssertionError("Popen should not be called when the command is missing") monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) backend = SubprocessBackend() result = backend.spawn( command=["nanobot"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) assert result == ( "Error: command 'nanobot' not found in PATH. " "Install the agent CLI first or pass an executable path." ) assert popen_called is False def test_tmux_backend_normalizes_bare_nanobot_to_agent(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) run_calls: list[list[str]] = [] class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:3] == ["tmux", "has-session", "-t"]: return Result(returncode=1) if args[:3] == ["tmux", "list-panes", "-t"]: return Result(returncode=0, stdout="9876\n") return Result(returncode=0) def fake_which(name, path=None): if name == "tmux": return "/usr/bin/tmux" if name == "nanobot": return "/usr/bin/nanobot" return None monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = TmuxBackend() backend.spawn( command=["nanobot"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) new_session = next(call for call in run_calls if call[:3] == ["tmux", "new-session", "-d"]) full_cmd = new_session[-1] assert " nanobot agent -w /tmp/demo -m 'do work';" in full_cmd def test_tmux_backend_confirms_claude_workspace_trust_prompt(monkeypatch): run_calls: list[list[str]] = [] capture_count = 0 class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): nonlocal capture_count run_calls.append(args) if args[:4] == ["tmux", "capture-pane", "-p", "-t"]: capture_count += 1 if capture_count == 1: return Result( stdout=( "Quick safety check\n" "Yes, I trust this folder\n" "Enter to confirm\n" ) ) return Result(stdout="") return Result() monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) confirmed = _confirm_workspace_trust_if_prompted("demo:agent", ["claude"]) assert confirmed is True assert ["tmux", "send-keys", "-t", "demo:agent", "Enter"] in run_calls def test_tmux_backend_confirms_claude_skip_permissions_prompt(monkeypatch): run_calls: list[list[str]] = [] class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:4] == ["tmux", "capture-pane", "-p", "-t"]: return Result( stdout=( "Dangerous permission mode\n" "Using --dangerously-skip-permissions\n" "Yes, I accept\n" ) ) return Result() monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) confirmed = _confirm_workspace_trust_if_prompted("demo:agent", ["claude"]) assert confirmed is True assert ["tmux", "send-keys", "-t", "demo:agent", "-l", "\x1b[B"] in run_calls assert ["tmux", "send-keys", "-t", "demo:agent", "Enter"] in run_calls def test_tmux_backend_confirms_codex_workspace_trust_prompt(monkeypatch): run_calls: list[list[str]] = [] class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:4] == ["tmux", "capture-pane", "-p", "-t"]: return Result( stdout=( "Do you trust the contents of this directory?\n" "Press enter to continue\n" ) ) return Result() monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) confirmed = _confirm_workspace_trust_if_prompted("demo:agent", ["codex"]) assert confirmed is True assert ["tmux", "send-keys", "-t", "demo:agent", "Enter"] in run_calls def test_tmux_backend_waits_for_pane_before_declaring_failure(monkeypatch, tmp_path): from clawteam.config import ClawTeamConfig monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) run_calls: list[list[str]] = [] pane_calls = 0 class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): nonlocal pane_calls run_calls.append(args) if args[:3] == ["tmux", "has-session", "-t"]: return Result(returncode=1) if args[:3] == ["tmux", "new-session", "-d"]: return Result(returncode=0) if args[:3] == ["tmux", "list-panes", "-t"]: pane_calls += 1 if pane_calls < 3: return Result(returncode=0, stdout="") return Result(returncode=0, stdout="9876\n") return Result(returncode=0) def fake_which(name, path=None): if name == "tmux": return "/usr/bin/tmux" if name == "claude": return "/usr/bin/claude" return None monkeypatch.setattr("clawteam.config.load_config", lambda: ClawTeamConfig()) monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.monotonic", iter(range(100)).__next__) monkeypatch.setattr( "clawteam.spawn.tmux_backend._confirm_workspace_trust_if_prompted", lambda *_, **__: False, ) monkeypatch.setattr( "clawteam.spawn.tmux_backend._wait_for_cli_ready", lambda *_, **__: True, ) monkeypatch.setattr("clawteam.spawn.tmux_backend._inject_prompt_via_buffer", lambda *_, **__: None) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = TmuxBackend() result = backend.spawn( command=["claude"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) assert "spawned" in result assert pane_calls >= 3 assert any(call[:3] == ["tmux", "list-panes", "-t"] for call in run_calls) def test_dismiss_codex_update_prompt_sends_enter(monkeypatch): run_calls: list[list[str]] = [] capture_count = 0 class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): nonlocal capture_count run_calls.append(args) if args[:4] == ["tmux", "capture-pane", "-p", "-t"]: capture_count += 1 if capture_count == 1: return Result( stdout=( "✨ Update available! 0.113.0 -> 0.116.0\n" "1 Update now\n" "2 Skip\n" "3 Skip until next version\n" "Press enter to continue\n" ) ) return Result(stdout=">_ OpenAI Codex (v0.113.0)\n") return Result() monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.monotonic", iter(range(100)).__next__) dismissed = _dismiss_codex_update_prompt_if_present( "demo:agent", ["codex"], timeout_seconds=2.0, poll_interval_seconds=0.1, ) assert dismissed is True assert ["tmux", "send-keys", "-t", "demo:agent", "Enter"] in run_calls def test_subprocess_backend_normalizes_nanobot_and_uses_message_flag(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) captured: dict[str, object] = {} def fake_popen(cmd, **kwargs): captured["cmd"] = cmd captured["env"] = kwargs["env"] return DummyProcess() monkeypatch.setattr( "clawteam.spawn.command_validation.shutil.which", lambda name, path=None: "/usr/bin/nanobot" if name == "nanobot" else None, ) monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = SubprocessBackend() backend.spawn( command=["nanobot"], agent_name="worker1", agent_id="agent-1", agent_type="general-purpose", team_name="demo-team", prompt="do work", cwd="/tmp/demo", skip_permissions=True, ) assert "nanobot agent -w /tmp/demo -m 'do work'" in captured["cmd"] def test_tmux_backend_gemini_skip_permissions_and_prompt(monkeypatch, tmp_path): """Gemini gets --yolo for permissions and -p for prompt.""" monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) run_calls: list[list[str]] = [] class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:3] == ["tmux", "has-session", "-t"]: return Result(returncode=1) if args[:3] == ["tmux", "list-panes", "-t"]: return Result(returncode=0, stdout="9876\n") return Result(returncode=0) def fake_which(name, path=None): if name == "tmux": return "/usr/bin/tmux" if name == "gemini": return "/usr/bin/gemini" return None monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = TmuxBackend() backend.spawn( command=["gemini"], agent_name="researcher", agent_id="agent-2", agent_type="general-purpose", team_name="demo-team", prompt="analyze this repo", cwd="/tmp/demo", skip_permissions=True, ) new_session = next(call for call in run_calls if call[:3] == ["tmux", "new-session", "-d"]) full_cmd = new_session[-1] assert " gemini --yolo -p 'analyze this repo';" in full_cmd def test_subprocess_backend_gemini_skip_permissions_and_prompt(monkeypatch, tmp_path): """Gemini subprocess uses --yolo and -p flags.""" monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) captured: dict[str, object] = {} def fake_popen(cmd, **kwargs): captured["cmd"] = cmd return DummyProcess() monkeypatch.setattr( "clawteam.spawn.command_validation.shutil.which", lambda name, path=None: "/usr/bin/gemini" if name == "gemini" else None, ) monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = SubprocessBackend() backend.spawn( command=["gemini"], agent_name="researcher", agent_id="agent-2", agent_type="general-purpose", team_name="demo-team", prompt="analyze this repo", cwd="/tmp/demo", skip_permissions=True, ) assert "gemini --yolo -p 'analyze this repo'" in captured["cmd"] def test_tmux_backend_confirms_gemini_workspace_trust_prompt(monkeypatch): run_calls: list[list[str]] = [] class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:4] == ["tmux", "capture-pane", "-p", "-t"]: return Result( stdout=( "Gemini CLI\n" "Trust folder: /tmp/demo\n" ) ) return Result() monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) confirmed = _confirm_workspace_trust_if_prompted("demo:agent", ["gemini"]) assert confirmed is True assert ["tmux", "send-keys", "-t", "demo:agent", "Enter"] in run_calls def test_tmux_backend_kimi_skip_permissions_workspace_and_prompt(monkeypatch, tmp_path): """Kimi gets --yolo, -w for workspace, and --print -p for prompt.""" monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) run_calls: list[list[str]] = [] class Result: def __init__(self, returncode: int = 0, stdout: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:3] == ["tmux", "has-session", "-t"]: return Result(returncode=1) if args[:3] == ["tmux", "list-panes", "-t"]: return Result(returncode=0, stdout="9876\n") return Result(returncode=0) def fake_which(name, path=None): if name == "tmux": return "/usr/bin/tmux" if name == "kimi": return "/usr/bin/kimi" return None monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = TmuxBackend() backend.spawn( command=["kimi"], agent_name="coder", agent_id="agent-3", agent_type="general-purpose", team_name="demo-team", prompt="fix the bug", cwd="/tmp/demo", skip_permissions=True, ) new_session = next(call for call in run_calls if call[:3] == ["tmux", "new-session", "-d"]) full_cmd = new_session[-1] assert " kimi --yolo -w /tmp/demo --print -p 'fix the bug';" in full_cmd def test_subprocess_backend_kimi_skip_permissions_workspace_and_prompt(monkeypatch, tmp_path): """Kimi subprocess uses --yolo, -w, and --print -p flags.""" monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) captured: dict[str, object] = {} def fake_popen(cmd, **kwargs): captured["cmd"] = cmd return DummyProcess() monkeypatch.setattr( "clawteam.spawn.command_validation.shutil.which", lambda name, path=None: "/usr/bin/kimi" if name == "kimi" else None, ) monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = SubprocessBackend() backend.spawn( command=["kimi"], agent_name="coder", agent_id="agent-3", agent_type="general-purpose", team_name="demo-team", prompt="fix the bug", cwd="/tmp/demo", skip_permissions=True, ) assert "kimi --yolo -w /tmp/demo --print -p 'fix the bug'" in captured["cmd"] def test_resolve_clawteam_executable_ignores_unrelated_argv0(monkeypatch, tmp_path): unrelated = tmp_path / "not-clawteam-review" unrelated.write_text("#!/bin/sh\n") resolved_bin = tmp_path / "bin" / "clawteam" resolved_bin.parent.mkdir(parents=True) resolved_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(unrelated)]) monkeypatch.setattr("clawteam.spawn.cli_env.shutil.which", lambda name: str(resolved_bin)) assert resolve_clawteam_executable() == str(resolved_bin) assert build_spawn_path("/usr/bin:/bin").startswith(f"{resolved_bin.parent}:") def test_resolve_clawteam_executable_ignores_relative_argv0_even_if_local_file_exists( monkeypatch, tmp_path ): local_shadow = tmp_path / "clawteam" local_shadow.write_text("#!/bin/sh\n") resolved_bin = tmp_path / "venv" / "bin" / "clawteam" resolved_bin.parent.mkdir(parents=True) resolved_bin.write_text("#!/bin/sh\n") monkeypatch.chdir(tmp_path) monkeypatch.setattr(sys, "argv", ["clawteam"]) monkeypatch.setattr("clawteam.spawn.cli_env.shutil.which", lambda name: str(resolved_bin)) assert resolve_clawteam_executable() == str(resolved_bin) assert build_spawn_path("/usr/bin:/bin").startswith(f"{resolved_bin.parent}:") def test_resolve_clawteam_executable_accepts_relative_path_with_explicit_directory( monkeypatch, tmp_path ): relative_bin = tmp_path / ".venv" / "bin" / "clawteam" relative_bin.parent.mkdir(parents=True) relative_bin.write_text("#!/bin/sh\n") fallback_bin = tmp_path / "fallback" / "clawteam" fallback_bin.parent.mkdir(parents=True) fallback_bin.write_text("#!/bin/sh\n") monkeypatch.chdir(tmp_path) monkeypatch.setattr(sys, "argv", ["./.venv/bin/clawteam"]) monkeypatch.setattr("clawteam.spawn.cli_env.shutil.which", lambda name: str(fallback_bin)) assert resolve_clawteam_executable() == str(relative_bin.resolve()) assert build_spawn_path("/usr/bin:/bin").startswith(f"{relative_bin.parent.resolve()}:") # --------------------------------------------------------------------------- # _wait_for_cli_ready tests # --------------------------------------------------------------------------- class TestWaitForCliReady: """Tests for the generic readiness poller.""" @staticmethod def _fake_run_factory(outputs): """Return a fake subprocess.run that yields successive pane contents.""" idx = {"n": 0} class Result: def __init__(self, stdout): self.returncode = 0 self.stdout = stdout def fake_run(args, **kwargs): if args[:4] == ["tmux", "capture-pane", "-p", "-t"]: text = outputs[min(idx["n"], len(outputs) - 1)] idx["n"] += 1 return Result(stdout=text) return Result(stdout="") return fake_run def test_detects_prompt_indicator(self, monkeypatch): fake = self._fake_run_factory(["Loading...\n", "❯ \n"]) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda _: None) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.monotonic", iter(range(100)).__next__) assert _wait_for_cli_ready("t:a", timeout_seconds=10) is True def test_detects_content_stabilisation(self, monkeypatch): stable = "Welcome to MyAgent v1\nReady.\n" fake = self._fake_run_factory(["Booting...\n", stable, stable, stable]) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda _: None) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.monotonic", iter(range(100)).__next__) assert _wait_for_cli_ready("t:a", timeout_seconds=10) is True def test_times_out_on_empty_pane(self, monkeypatch): fake = self._fake_run_factory(["", "", ""]) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda _: None) counter = iter([0, 0.5, 1.0, 1.5, 2.0, 999]) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.monotonic", lambda: next(counter)) assert _wait_for_cli_ready("t:a", timeout_seconds=2) is False # --------------------------------------------------------------------------- # _inject_prompt_via_buffer tests # --------------------------------------------------------------------------- def test_inject_prompt_via_buffer_uses_load_and_paste(monkeypatch, tmp_path): run_calls: list[list[str]] = [] class Result: returncode = 0 stdout = "" stderr = "" def fake_run(args, **kwargs): run_calls.append(args) return Result() monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda _: None) monkeypatch.setattr("clawteam.spawn.tmux_backend.tempfile.NamedTemporaryFile", lambda **kw: open(tmp_path / "prompt.txt", kw.get("mode", "w"))) # NamedTemporaryFile mock won't have .name → use real tempfile monkeypatch.undo() # just use real functions monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda _: None) _inject_prompt_via_buffer("sess:win", "worker1", "hello world") cmds = [c[:3] for c in run_calls] assert ["tmux", "load-buffer", "-b"] in cmds assert ["tmux", "paste-buffer", "-b"] in cmds assert ["tmux", "send-keys", "-t"] in cmds assert ["tmux", "delete-buffer", "-b"] in cmds # --------------------------------------------------------------------------- # End-to-end: qwen & opencode spawn via tmux backend # --------------------------------------------------------------------------- def _make_tmux_spawn_harness(monkeypatch, tmp_path, cli_name): """Shared harness for tmux spawn tests of new CLIs.""" monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) run_calls: list[list[str]] = [] class Result: def __init__(self, returncode=0, stdout=""): self.returncode = returncode self.stdout = stdout self.stderr = "" def fake_run(args, **kwargs): run_calls.append(args) if args[:3] == ["tmux", "has-session", "-t"]: return Result(returncode=1) if args[:3] == ["tmux", "list-panes", "-t"]: return Result(returncode=0, stdout="9876\n") return Result(returncode=0) def fake_which(name, path=None): if name == "tmux": return "/usr/bin/tmux" if name == cli_name: return f"/usr/bin/{cli_name}" return None monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which) monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None) monkeypatch.setattr("clawteam.spawn.tmux_backend.time.monotonic", lambda: 0) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) return run_calls def test_tmux_backend_qwen_skip_permissions_and_prompt(monkeypatch, tmp_path): run_calls = _make_tmux_spawn_harness(monkeypatch, tmp_path, "qwen") backend = TmuxBackend() result = backend.spawn( command=["qwen"], agent_name="coder", agent_id="agent-q", agent_type="general-purpose", team_name="demo-team", prompt="refactor this", cwd="/tmp/demo", skip_permissions=True, ) assert "spawned" in result new_session = next(c for c in run_calls if c[:3] == ["tmux", "new-session", "-d"]) full_cmd = new_session[-1] assert " qwen --dangerously-skip-permissions -p 'refactor this';" in full_cmd def test_tmux_backend_opencode_skip_permissions_and_prompt(monkeypatch, tmp_path): run_calls = _make_tmux_spawn_harness(monkeypatch, tmp_path, "opencode") backend = TmuxBackend() result = backend.spawn( command=["opencode"], agent_name="coder", agent_id="agent-o", agent_type="general-purpose", team_name="demo-team", prompt="fix the bug", cwd="/tmp/demo", skip_permissions=True, ) assert "spawned" in result new_session = next(c for c in run_calls if c[:3] == ["tmux", "new-session", "-d"]) full_cmd = new_session[-1] assert " opencode --yolo -p 'fix the bug';" in full_cmd def test_subprocess_backend_qwen_skip_permissions_and_prompt(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) captured: dict[str, object] = {} def fake_popen(cmd, **kwargs): captured["cmd"] = cmd return DummyProcess() monkeypatch.setattr( "clawteam.spawn.command_validation.shutil.which", lambda name, path=None: "/usr/bin/qwen" if name == "qwen" else None, ) monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = SubprocessBackend() backend.spawn( command=["qwen"], agent_name="coder", agent_id="agent-q", agent_type="general-purpose", team_name="demo-team", prompt="refactor this", cwd="/tmp/demo", skip_permissions=True, ) assert "qwen --dangerously-skip-permissions -p 'refactor this'" in captured["cmd"] def test_subprocess_backend_opencode_skip_permissions_and_prompt(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") monkeypatch.setattr(sys, "argv", [str(clawteam_bin)]) captured: dict[str, object] = {} def fake_popen(cmd, **kwargs): captured["cmd"] = cmd return DummyProcess() monkeypatch.setattr( "clawteam.spawn.command_validation.shutil.which", lambda name, path=None: "/usr/bin/opencode" if name == "opencode" else None, ) monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen) monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None) backend = SubprocessBackend() backend.spawn( command=["opencode"], agent_name="coder", agent_id="agent-o", agent_type="general-purpose", team_name="demo-team", prompt="fix the bug", cwd="/tmp/demo", skip_permissions=True, ) assert "opencode --yolo -p 'fix the bug'" in captured["cmd"]