Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions docs/opencode-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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.<name>.tools` or `tools` entries, CAO's MCP wiring can be silently overridden for that agent.
Expand Down
21 changes: 21 additions & 0 deletions src/cli_agent_orchestrator/cli/commands/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
65 changes: 57 additions & 8 deletions src/cli_agent_orchestrator/providers/kiro_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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
Expand Down
150 changes: 147 additions & 3 deletions test/providers/test_kiro_cli_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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"]
Expand Down
Loading