diff --git a/docs/opencode-cli.md b/docs/opencode-cli.md index e06341b35..736b1bdc7 100644 --- a/docs/opencode-cli.md +++ b/docs/opencode-cli.md @@ -57,8 +57,10 @@ cao launch --agents developer --provider opencode_cli --auto-approve # Specify model override cao launch --agents developer --provider opencode_cli --model anthropic/claude-sonnet-4-6 -# Unrestricted (DANGEROUS) — agent can run any command -cao launch --agents developer --provider opencode_cli --yolo +# Unrestricted access — install the profile with allowedTools: ["*"] first. +# `cao launch --yolo` is a no-op on opencode_cli (see Known Limitations). +cao install developer --provider opencode_cli # profile must have allowedTools: ["*"] +cao launch --agents developer --provider opencode_cli ``` Via HTTP API: @@ -114,7 +116,7 @@ Tools not in any enabled category default to `deny`. The following tools have ha | `webfetch`, `websearch`, `codesearch` | deny | Network egress — opt-in only | | `todowrite`, `skill` | allow | In-memory / additive, no side-effects | -Pass `--yolo` (or set `allowedTools: ["*"]` in the profile) to allow all 13 tools including the above. +To allow all 13 tools including the above, set `allowedTools: ["*"]` in the profile and re-run `cao install`. Unlike the other providers, `cao launch --yolo` does **not** widen permissions at runtime on `opencode_cli` — see [`cao launch --yolo` is install-time only](#cao-launch---yolo-is-install-time-only) below. ### `cao launch --auto-approve` @@ -188,6 +190,27 @@ The `test_assign_with_callback` test validates all four orchestration modes: ## Known Limitations +### `cao launch --yolo` is install-time only + +Unlike every other CAO provider, `opencode_cli` does **not** honour `cao launch --yolo` at runtime. Permissions are baked into the installed agent's frontmatter `permission:` block at `cao install` time and cannot be loosened by a launch flag. + +Root cause: OpenCode's TUI (the mode CAO drives) has no equivalent to `--dangerously-skip-permissions` / `--yolo` / `--trust-all-tools`. The flag exists only on the `opencode run` headless one-shot command, which CAO does not use. Tracked upstream in [sst/opencode#8463](https://github.com/sst/opencode/issues/8463) and sibling issues. + +**To get unrestricted access on `opencode_cli`:** + +```bash +# 1. Edit the profile's frontmatter so it contains: +# allowedTools: ["*"] + +# 2. Re-run cao install — this rewrites the permission: block with all tools set to allow. +cao install my_agent --provider opencode_cli + +# 3. Launch normally (omit --yolo — it would emit a warning and still only honour what's installed). +cao launch --agents my_agent --provider opencode_cli +``` + +CAO's tracking issue for providing a runtime bypass (either via the temp-agent workaround or by consuming an upstream TUI flag once it ships): see the README's *experimental — single-agent only* notice. + ### Project-local `opencode.json` override OpenCode's config merge precedence places a project-local `opencode.json` in the current working directory **above** `OPENCODE_CONFIG` (the CAO-managed file). If you `cao launch` in a directory that has its own `opencode.json` with conflicting `agent..tools` or `tools` entries, CAO's MCP wiring can be silently overridden for that agent. diff --git a/src/cli_agent_orchestrator/cli/commands/launch.py b/src/cli_agent_orchestrator/cli/commands/launch.py index 82e1cf957..0a8623daa 100644 --- a/src/cli_agent_orchestrator/cli/commands/launch.py +++ b/src/cli_agent_orchestrator/cli/commands/launch.py @@ -135,6 +135,27 @@ def launch( f" Agent can execute ANY command (aws, rm, curl, read credentials).\n" f" Directory: {display_dir}\n" ) + if provider == "kiro_cli": + # kiro-cli 2.0.1 TUI blocks on an interactive "Yes, I accept" + # consent dialog when --trust-all-tools is set. CAO cannot + # answer it headlessly, so yolo launches use --legacy-ui. + click.echo( + " Note: kiro_cli will launch in --legacy-ui mode so " + "--trust-all-tools can be applied non-interactively.\n" + ) + elif provider == "opencode_cli": + # opencode's TUI has no runtime skip-permissions flag + # (tracked upstream in sst/opencode#8463). Permissions are + # install-time only, so --yolo cannot loosen them here. + click.echo( + click.style( + " Note: --yolo has no runtime effect on opencode_cli.\n" + " Permissions are set at cao install time. To get unrestricted\n" + " access, set 'allowedTools: [\"*\"]' in the profile and re-run\n" + " 'cao install'. See docs/opencode-cli.md for details.\n", + fg="yellow", + ) + ) else: # Normal launch: show tool summary and confirm tool_summary = format_tool_summary(resolved_allowed_tools) diff --git a/src/cli_agent_orchestrator/providers/kiro_cli.py b/src/cli_agent_orchestrator/providers/kiro_cli.py index 159e072a2..7206b3e3f 100644 --- a/src/cli_agent_orchestrator/providers/kiro_cli.py +++ b/src/cli_agent_orchestrator/providers/kiro_cli.py @@ -25,6 +25,7 @@ from cli_agent_orchestrator.clients.tmux import tmux_client from cli_agent_orchestrator.models.terminal import TerminalStatus from cli_agent_orchestrator.providers.base import BaseProvider +from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status logger = logging.getLogger(__name__) @@ -141,6 +142,24 @@ def paste_enter_count(self) -> int: """Kiro CLI submits on single Enter after bracketed paste.""" return 1 + def _get_profile_model(self) -> Optional[str]: + """Return profile.model if the agent profile can be loaded, else None. + + Best-effort: historically the Kiro CLI provider has not required the + CAO agent profile to be loadable at runtime (kiro-cli has its own + agent store). A missing or unparseable profile must not block launch. + """ + try: + profile = load_agent_profile(self._agent_profile) + except (FileNotFoundError, RuntimeError) as exc: + logger.debug( + "Profile '%s' not loadable by CAO; skipping --model resolution: %s", + self._agent_profile, + exc, + ) + return None + return profile.model or None + def initialize(self) -> bool: """Initialize Kiro CLI provider by starting kiro-cli chat command. @@ -160,10 +179,35 @@ def initialize(self) -> bool: if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): raise TimeoutError("Shell initialization timed out after 10 seconds") - # Step 2: Start the Kiro CLI chat session using kiro-cli's default UI. - # Detection code handles both legacy and TUI patterns (stateless). - # If initialization fails, fall back to --legacy-ui. - command = shlex.join(["kiro-cli", "chat", "--agent", self._agent_profile]) + # Step 2: Start the Kiro CLI chat session. + # + # --trust-all-tools: bypass Kiro CLI's permission prompts when CAO + # launches with --yolo (allowed_tools=['*']). Without this, every + # tool invocation re-prompts, blocking assign/handoff flows. + # --model: honor profile.model so workflows can pin a specific model. + # + # UI mode selection: + # - Yolo (--trust-all-tools): kiro-cli 2.0.1 TUI blocks on an + # interactive "Yes, I accept" consent dialog before the chat is + # ready; only --legacy-ui/--classic/--no-interactive bypass it. + # CAO drives kiro-cli headlessly, so we force --legacy-ui for yolo. + # - Non-yolo: use the default TUI (fall back to --legacy-ui on + # timeout, preserving prior behavior for older kiro-cli versions). + yolo = bool(self._allowed_tools and "*" in self._allowed_tools) + model = self._get_profile_model() + + if yolo: + logger.info( + "kiro_cli yolo mode: forcing --legacy-ui (kiro-cli 2.0.1 TUI " + "shows a non-bypassable trust-all-tools consent dialog)" + ) + base_args = ["kiro-cli", "chat", "--legacy-ui", "--trust-all-tools"] + else: + base_args = ["kiro-cli", "chat"] + if model: + base_args.extend(["--model", model]) + base_args.extend(["--agent", self._agent_profile]) + command = shlex.join(base_args) tmux_client.send_keys(self.session_name, self.window_name, command) # Step 3: Wait for Kiro CLI to fully initialize and show the agent prompt. @@ -172,15 +216,20 @@ def initialize(self) -> bool: if not wait_until_status( self, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=30.0 ): - # TUI mode failed — fall back to --legacy-ui + if yolo: + # Yolo already launched with --legacy-ui; no further fallback. + raise TimeoutError("Kiro CLI initialization timed out with --legacy-ui (yolo mode)") + # Non-yolo TUI mode failed — fall back to --legacy-ui logger.warning("Kiro CLI TUI initialization timed out, retrying with --legacy-ui") # Exit the current session and start fresh with --legacy-ui tmux_client.send_keys(self.session_name, self.window_name, "/exit") if not wait_for_shell(tmux_client, self.session_name, self.window_name, timeout=10.0): raise TimeoutError("Shell recovery timed out after --legacy-ui fallback") - legacy_command = shlex.join( - ["kiro-cli", "chat", "--legacy-ui", "--agent", self._agent_profile] - ) + legacy_args = ["kiro-cli", "chat", "--legacy-ui"] + if model: + legacy_args.extend(["--model", model]) + legacy_args.extend(["--agent", self._agent_profile]) + legacy_command = shlex.join(legacy_args) tmux_client.send_keys(self.session_name, self.window_name, legacy_command) if not wait_until_status( self, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, timeout=30.0 diff --git a/test/providers/test_kiro_cli_unit.py b/test/providers/test_kiro_cli_unit.py index 34068f95e..73e7ab762 100644 --- a/test/providers/test_kiro_cli_unit.py +++ b/test/providers/test_kiro_cli_unit.py @@ -22,13 +22,17 @@ def load_fixture(filename: str) -> str: class TestKiroCliProviderInitialization: """Test Kiro CLI provider initialization.""" + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell): + def test_initialize_success( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): """Test successful initialization.""" mock_wait_shell.return_value = True mock_wait_status.return_value = True + mock_load_profile.side_effect = FileNotFoundError("no profile") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") result = provider.initialize() @@ -51,27 +55,35 @@ def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell): with pytest.raises(TimeoutError, match="Shell initialization timed out"): provider.initialize() + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_initialize_kiro_cli_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell): + def test_initialize_kiro_cli_timeout( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): """Test initialization fails when both TUI and --legacy-ui timeout.""" mock_wait_shell.return_value = True mock_wait_status.return_value = False + mock_load_profile.side_effect = FileNotFoundError("no profile") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") with pytest.raises(TimeoutError, match="timed out with TUI and `--legacy-ui`"): provider.initialize() + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") - def test_initialize_legacy_ui_fallback(self, mock_tmux, mock_wait_status, mock_wait_shell): + def test_initialize_legacy_ui_fallback( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): """Test fallback to --legacy-ui when TUI initialization fails.""" mock_wait_shell.return_value = True # First call (TUI) fails, second call (--legacy-ui) succeeds mock_wait_status.side_effect = [False, True] + mock_load_profile.side_effect = FileNotFoundError("no profile") provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") result = provider.initialize() @@ -92,6 +104,138 @@ def test_initialize_legacy_ui_fallback(self, mock_tmux, mock_wait_status, mock_w "kiro-cli chat --legacy-ui --agent developer", ) + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") + def test_initialize_yolo_forces_legacy_ui_with_trust_all_tools( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): + """--yolo (allowed_tools=['*']) must launch directly with --legacy-ui + --trust-all-tools. + + kiro-cli 2.0.1 TUI shows a non-bypassable trust-all-tools consent + dialog; yolo headless launches must skip the TUI attempt entirely. + """ + mock_wait_shell.return_value = True + mock_wait_status.return_value = True + mock_load_profile.side_effect = FileNotFoundError("no profile") + + provider = KiroCliProvider( + "test1234", "test-session", "window-0", "developer", allowed_tools=["*"] + ) + provider.initialize() + + mock_tmux.send_keys.assert_called_once_with( + "test-session", + "window-0", + "kiro-cli chat --legacy-ui --trust-all-tools --agent developer", + ) + + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") + def test_initialize_passes_profile_model( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): + """profile.model must be forwarded to kiro-cli via --model.""" + mock_wait_shell.return_value = True + mock_wait_status.return_value = True + profile = Mock() + profile.model = "claude-opus-4-6" + mock_load_profile.return_value = profile + + provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") + provider.initialize() + + mock_tmux.send_keys.assert_called_once_with( + "test-session", + "window-0", + "kiro-cli chat --model claude-opus-4-6 --agent developer", + ) + + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") + def test_initialize_yolo_and_model_combine( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): + """--yolo + profile.model: --legacy-ui + --trust-all-tools + --model, all in one launch.""" + mock_wait_shell.return_value = True + mock_wait_status.return_value = True + profile = Mock() + profile.model = "claude-opus-4.6" + mock_load_profile.return_value = profile + + provider = KiroCliProvider( + "test1234", "test-session", "window-0", "developer", allowed_tools=["*"] + ) + provider.initialize() + + mock_tmux.send_keys.assert_called_once_with( + "test-session", + "window-0", + "kiro-cli chat --legacy-ui --trust-all-tools --model claude-opus-4.6 --agent developer", + ) + + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") + def test_initialize_yolo_no_fallback_on_timeout( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): + """Yolo launch is already --legacy-ui; on timeout, raise — do not re-fall-back. + + Prevents the old double-timeout behavior (TUI timeout → legacy fallback → + legacy timeout) which added ~30 seconds before returning the 500 to the caller. + """ + mock_wait_shell.return_value = True + mock_wait_status.return_value = False + mock_load_profile.side_effect = FileNotFoundError("no profile") + + provider = KiroCliProvider( + "test1234", "test-session", "window-0", "developer", allowed_tools=["*"] + ) + + with pytest.raises(TimeoutError, match="timed out with --legacy-ui"): + provider.initialize() + + # Only one launch attempt — no /exit, no second launch. + assert mock_tmux.send_keys.call_count == 1 + + @patch("cli_agent_orchestrator.providers.kiro_cli.load_agent_profile") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_for_shell") + @patch("cli_agent_orchestrator.providers.kiro_cli.wait_until_status") + @patch("cli_agent_orchestrator.providers.kiro_cli.tmux_client") + def test_initialize_non_yolo_legacy_ui_fallback_preserves_model( + self, mock_tmux, mock_wait_status, mock_wait_shell, mock_load_profile + ): + """Non-yolo TUI timeout → --legacy-ui fallback must preserve --model.""" + mock_wait_shell.return_value = True + mock_wait_status.side_effect = [False, True] + profile = Mock() + profile.model = "claude-opus-4.6" + mock_load_profile.return_value = profile + + provider = KiroCliProvider("test1234", "test-session", "window-0", "developer") + provider.initialize() + + calls = mock_tmux.send_keys.call_args_list + assert len(calls) == 3 + assert calls[0].args == ( + "test-session", + "window-0", + "kiro-cli chat --model claude-opus-4.6 --agent developer", + ) + assert calls[1].args == ("test-session", "window-0", "/exit") + assert calls[2].args == ( + "test-session", + "window-0", + "kiro-cli chat --legacy-ui --model claude-opus-4.6 --agent developer", + ) + def test_initialization_with_different_agent_profiles(self): """Test initialization with various agent profile names.""" test_profiles = ["developer", "code-reviewer", "test_agent", "agent123"]