diff --git a/clawteam/spawn/subprocess_backend.py b/clawteam/spawn/subprocess_backend.py index 13b61093..683ccdc1 100644 --- a/clawteam/spawn/subprocess_backend.py +++ b/clawteam/spawn/subprocess_backend.py @@ -66,6 +66,8 @@ def spawn( final_command.append("--dangerously-skip-permissions") elif _is_codex_command(normalized_command): final_command.append("--dangerously-bypass-approvals-and-sandbox") + elif _is_gemini_command(normalized_command): + final_command.append("--yolo") if _is_nanobot_command(normalized_command): if cwd and not _command_has_workspace_arg(normalized_command): final_command.extend(["-w", cwd]) @@ -143,6 +145,14 @@ def _is_nanobot_command(command: list[str]) -> bool: return cmd == "nanobot" +def _is_gemini_command(command: list[str]) -> bool: + """Check if the command is a Gemini CLI invocation.""" + if not command: + return False + cmd = command[0].rsplit("/", 1)[-1] + return cmd == "gemini" + + def _command_has_workspace_arg(command: list[str]) -> bool: """Return True when a command already specifies a nanobot workspace.""" return "-w" in command or "--workspace" in command diff --git a/clawteam/spawn/tmux_backend.py b/clawteam/spawn/tmux_backend.py index 9ffd42cf..4f85864e 100644 --- a/clawteam/spawn/tmux_backend.py +++ b/clawteam/spawn/tmux_backend.py @@ -79,6 +79,8 @@ def spawn( final_command.append("--dangerously-skip-permissions") elif _is_codex_command(normalized_command): final_command.append("--dangerously-bypass-approvals-and-sandbox") + elif _is_gemini_command(normalized_command): + final_command.append("--yolo") if _is_nanobot_command(normalized_command): if cwd and not _command_has_workspace_arg(normalized_command): @@ -87,6 +89,8 @@ def spawn( final_command.extend(["-m", prompt]) elif prompt and _is_codex_command(normalized_command): final_command.append(prompt) + elif prompt and _is_gemini_command(normalized_command): + final_command.extend(["-p", prompt]) cmd_str = " ".join(shlex.quote(c) for c in final_command) # Append on-exit hook: runs immediately when agent process exits @@ -187,7 +191,7 @@ def spawn( stderr=subprocess.PIPE, ) os.unlink(tmp_path) - elif prompt and not _is_codex_command(normalized_command) and not _is_nanobot_command(normalized_command): + elif prompt and not _is_codex_command(normalized_command) and not _is_nanobot_command(normalized_command) and not _is_gemini_command(normalized_command): time.sleep(1) subprocess.run( ["tmux", "send-keys", "-t", target, prompt, "Enter"], @@ -325,6 +329,14 @@ def _is_nanobot_command(command: list[str]) -> bool: return cmd == "nanobot" +def _is_gemini_command(command: list[str]) -> bool: + """Check if the command is a Gemini CLI invocation.""" + if not command: + return False + cmd = command[0].rsplit("/", 1)[-1] + return cmd == "gemini" + + def _command_has_workspace_arg(command: list[str]) -> bool: """Return True when a command already specifies a nanobot workspace.""" return "-w" in command or "--workspace" in command @@ -343,7 +355,7 @@ def _confirm_workspace_trust_if_prompted( injection and accept it with a single Enter so the interactive TUI remains intact. """ - if not (_is_claude_command(command) or _is_codex_command(command)): + if not (_is_claude_command(command) or _is_codex_command(command) or _is_gemini_command(command)): return False deadline = time.monotonic() + timeout_seconds @@ -384,12 +396,20 @@ def _looks_like_workspace_trust_prompt(command: list[str], pane_text: str) -> bo and "press enter to continue" in pane_text ) + if _is_gemini_command(command): + return "trust folder" in pane_text or "trust parent folder" in pane_text + return False def _is_interactive_cli(command: list[str]) -> bool: """Check if the command is an interactive AI CLI.""" - return _is_claude_command(command) or _is_codex_command(command) or _is_nanobot_command(command) + return ( + _is_claude_command(command) + or _is_codex_command(command) + or _is_nanobot_command(command) + or _is_gemini_command(command) + ) def _wait_for_claude_ready( diff --git a/tests/test_spawn_backends.py b/tests/test_spawn_backends.py index 37a30ee1..f2ba0950 100644 --- a/tests/test_spawn_backends.py +++ b/tests/test_spawn_backends.py @@ -340,6 +340,125 @@ def fake_popen(cmd, **kwargs): 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_resolve_clawteam_executable_ignores_unrelated_argv0(monkeypatch, tmp_path): unrelated = tmp_path / "not-clawteam-review" unrelated.write_text("#!/bin/sh\n")