diff --git a/codeframe/core/adapters/kilocode.py b/codeframe/core/adapters/kilocode.py index eb84a3f6..3a8c6fcc 100644 --- a/codeframe/core/adapters/kilocode.py +++ b/codeframe/core/adapters/kilocode.py @@ -2,36 +2,119 @@ from __future__ import annotations +import logging import os import shlex import shutil +import subprocess from pathlib import Path from codeframe.core.adapters.agent_adapter import AgentResult from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter +logger = logging.getLogger(__name__) + # Exit code used by kilo when the timeout is exceeded _KILO_TIMEOUT_EXIT_CODE = 124 +#: The two incompatible CLIs that both answer to ``kilo``. +_MODERN = "modern" # 7.x: `kilo run --dir ` +_LEGACY = "legacy" # 0.22.0: `kilo --auto --workspace ` + +#: Substring that appears in ``kilo --help`` only once ``run`` exists. Detection +#: reads the CLI's own help rather than parsing ``--version``: #1015 requires +#: that the invocation never be guessed from a version string, and this repo has +#: been bitten three times by adapters that assumed a CLI surface (#913/#914/ +#: #1012). Help text is what the binary actually offers. +_RUN_SUBCOMMAND_MARKER = "kilo run" + +#: Linux caps a *single* argv entry at MAX_ARG_STRLEN — 128 KiB — independently +#: of the much larger total ARG_MAX. Same constraint opencode hit in #913. +_MAX_ARG_BYTES = 128 * 1024 + +#: Detection runs a subprocess, so it is cached per binary path for the process. +_SURFACE_CACHE: dict[str, str] = {} + + +def _prompt_exceeds_argv(prompt: str) -> bool: + return len(prompt.encode("utf-8")) >= _MAX_ARG_BYTES + + +def _detect_surface(binary_path: str) -> str: + """Which kilo CLI is installed, according to its own ``--help``. + + ``@kilocode/cli`` was rewritten between 0.22.0 (2026-01-15) and 7.x + (2026-07-29) — 213 releases apart. The invocations share nothing: + + ============== ============================ ========================== + \\ 0.22.0 7.4.17 + ============== ============================ ========================== + usage ``kilocode [options] [prompt]`` ``kilo run [message..]`` + workspace ``--workspace `` ``--dir `` + ``--auto`` non-interactive **auto-approve ALL perms** + ============== ============================ ========================== + + That last row is why this cannot be a blind rename: on 7.x ``--auto`` is the + old ``--yolo``, the permission bypass #916 established must stay off. + + An unreadable or failing ``--help`` falls back to modern — the version any + new install gets, and the one whose ``run`` subcommand fails loudly rather + than opening a TUI that hangs until the timeout (#1012). + """ + if binary_path in _SURFACE_CACHE: + return _SURFACE_CACHE[binary_path] + + try: + # Bytes, decoded permissively. `text=True` decodes with the locale + # encoding and no error handler, so under a non-UTF-8 locale + # (LC_ALL=C with UTF-8 coercion disabled — verified: encoding becomes + # ANSI_X3.4-1968) kilo 7.x's box-drawing banner raises + # UnicodeDecodeError. That is a ValueError, so it sailed straight past + # the handler below and crashed build_command. (#1015 review) + proc = subprocess.run( + [binary_path, "--help"], capture_output=True, timeout=90 + ) + help_text = (proc.stdout + proc.stderr).decode("utf-8", errors="replace") + except (OSError, subprocess.SubprocessError, ValueError): + logger.warning( + "Could not read `%s --help`; assuming the modern kilo surface.", + binary_path, + ) + help_text = "" + + surface = _MODERN if (not help_text or _RUN_SUBCOMMAND_MARKER in help_text) else _LEGACY + _SURFACE_CACHE[binary_path] = surface + return surface + + class KilocodeAdapter(SubprocessAdapter): """Adapter that delegates code execution to Kilocode CLI. - Invokes ``kilo --auto --workspace `` for headless - non-interactive execution. The prompt is the CLI's leading positional - (not stdin, and **not** behind a subcommand). + **Supported version floor: ``@kilocode/cli`` 7.x**, the surface any new + install gets. 0.22.0 is still driven correctly when that is what is + installed, because #1012 verified it end-to-end and an unupgraded machine + should not silently break — but it is not the target, and support for it + can be dropped once nobody is on it. + + Which invocation to use is **detected from the CLI's own ``--help``**, never + inferred from a version string (#1015). ``_detect_surface`` has the table: - There is no ``run`` subcommand: verified against kilocode 0.22.0, whose - usage is ``kilocode [options] [command] [prompt]`` with commands - ``auth``/``config``/``debug``/``models`` only. The adapter used to prepend - ``run``, which was then swallowed as the prompt — ``--auto`` never took - effect, the interactive TUI opened, and the delegated run hung until the - timeout having written nothing (#1012). + * 7.x — ``kilo run --dir ``. No ``--auto``: on this CLI that + flag means "auto-approve all permissions", i.e. the old ``--yolo`` the + adapter has always withheld (#916). ``run`` is non-interactive on its own, + exactly like ``opencode run``. + * 0.22.0 — ``kilo --auto --workspace ``, where ``--auto`` is + merely "non-interactive". There is no ``run`` subcommand; prepending one + got it swallowed as the prompt, opening the TUI to hang until the timeout + having written nothing (#1012). - Note on prompt length: the prompt is passed as a single positional argument. - Linux supports up to ~2 MB per argument, but macOS caps individual arguments - at 256 KB. Very large task contexts assembled by TaskContextPackager may fail - on macOS. If Kilocode adds stdin support in a future release, prefer that path. + Prompt length: the prompt is a single argv entry, and Linux caps one entry + at 128 KiB (macOS at 256 KB) — well under CodeFrame's ~100K-token budget. On + 7.x an oversized prompt goes to stdin instead, which ``kilo run`` accepts + when given no positional (verified: ``echo "say ok" | kilo run --dir /tmp`` + reaches the model call). 0.22.0 has no stdin path, so there it still goes + positionally and fails loudly. Exit codes: 0 — success @@ -89,26 +172,44 @@ def check_ready(cls) -> dict[str, bool]: """Check if the kilo binary is available on PATH.""" return {"kilo_binary": shutil.which(cls._resolve_binary()) is not None} + def _surface(self) -> str: + """Which kilo CLI this adapter is talking to.""" + return _detect_surface(self._binary_path) + def build_command(self, prompt: str, workspace_path: Path) -> list[str]: - """Build the kilo CLI command. + """Build the kilo CLI command for whichever CLI is actually installed. - Kilocode takes the prompt as a positional argument, with ``--auto`` - for non-interactive execution and ``--workspace`` for the repo root. + The two eras take completely different invocations (see + ``_detect_surface``). Modern is the documented target; legacy is kept + because it is what #1012 verified end-to-end and what an unupgraded + install still speaks. Args: - prompt: The task prompt passed as a positional argument. - workspace_path: Workspace root passed as ``--workspace``. + prompt: The task prompt. + workspace_path: Workspace root. Returns: Command list for subprocess.Popen. """ - cmd = [ - self._binary_path, - prompt, - "--auto", - "--workspace", - str(workspace_path), - ] + if self._surface() == _MODERN: + # `run` is non-interactive by itself, exactly like `opencode run`. + # --auto is deliberately NOT passed: in 7.x it means "auto-approve + # all permissions", the 0.22 `--yolo` that #916 established must + # stay off. Renaming --workspace to --dir while keeping --auto + # would have silently upgraded the adapter into a permission bypass. + cmd = [self._binary_path, "run", "--dir", str(workspace_path)] + if not _prompt_exceeds_argv(prompt): + cmd.append(prompt) + else: + # 0.22.0: bare positional prompt, --auto is merely non-interactive + # and --yolo (never passed) is the bypass. Verified in #1012/#916. + cmd = [ + self._binary_path, + prompt, + "--auto", + "--workspace", + str(workspace_path), + ] model = os.environ.get("KILOCODE_MODEL") if model: @@ -121,7 +222,20 @@ def build_command(self, prompt: str, workspace_path: Path) -> list[str]: return cmd def get_stdin(self, prompt: str) -> str | None: - """Return None — prompt is passed as a positional CLI argument, not stdin.""" + """The prompt, only when it is too large to survive as an argv entry. + + Normally None — both eras take the prompt positionally. But Linux caps a + *single* argv entry at 128 KiB while CodeFrame budgets ~100K tokens of + prompt, so a large task would raise OSError(E2BIG) before kilo starts. + + ``kilo run`` with no positional reads the message from stdin: verified + against 7.4.17, where `echo "say ok" | kilo run --dir /tmp` gets past + message validation to the model call. The legacy CLI has no such path, + so an oversized prompt still goes positionally there and fails loudly + rather than silently doing nothing. + """ + if self._surface() == _MODERN and _prompt_exceeds_argv(prompt): + return prompt return None def _map_result( diff --git a/tests/core/adapters/fixtures/kilocode_help/README.md b/tests/core/adapters/fixtures/kilocode_help/README.md new file mode 100644 index 00000000..084fd0ce --- /dev/null +++ b/tests/core/adapters/fixtures/kilocode_help/README.md @@ -0,0 +1,21 @@ +# kilocode CLI help fixtures + +Verbatim `kilo --help` output from the two incompatible CLIs that both answer to +`kilo`. `_detect_surface` is matched against these rather than against text +invented for the test — the same reason #914 checks in the codex app-server +schema. + +| file | version | captured | +|---|---|---| +| `help-0.22.0.txt` | `@kilocode/cli@0.22.0` (2026-01-15) | 2026-08-01 | +| `help-7.4.17.txt` | `@kilocode/cli@7.4.17` (2026-07-29) | 2026-08-01 | + +Regenerate with: + +``` +npm install -g @kilocode/cli@ +kilo --help > help-.txt 2>&1 +``` + +The 7.x file contains ANSI/box-drawing characters from the banner; that is +deliberate, since the real output does too and the detector must cope with it. diff --git a/tests/core/adapters/fixtures/kilocode_help/help-0.22.0.txt b/tests/core/adapters/fixtures/kilocode_help/help-0.22.0.txt new file mode 100644 index 00000000..ea927266 --- /dev/null +++ b/tests/core/adapters/fixtures/kilocode_help/help-0.22.0.txt @@ -0,0 +1,53 @@ +Usage: kilocode [options] [command] [prompt] + +Kilo Code Terminal User Interface - AI-powered coding assistant + +Arguments: + prompt The prompt or command to execute + +Options: + -V, --version output the version number + -m, --mode Set the mode of operation (architect, code, + ask, debug, orchestrator) + -w, --workspace Path to the workspace directory (default: + "/home/frankbria/projects/codeframe") + -a, --auto Run in autonomous mode (non-interactive) + (default: false) + --yolo Auto-approve all tool permissions (default: + false) + -j, --json Output messages as JSON (requires --auto) + (default: false) + -i, --json-io Bidirectional JSON mode (no TUI, + stdin/stdout enabled) (default: false) + -c, --continue Resume the last conversation from this + workspace (default: false) + -t, --timeout Timeout in seconds for autonomous mode + (requires --auto) + -p, --parallel Run in parallel mode - the agent will create + a separate git branch, unless you provide + the --existing-branch option + -eb, --existing-branch (Parallel mode only) Instructs the agent to + work on an existing branch + -pv, --provider Select provider by ID (e.g., 'kilocode-1') + -mo, --model Override model for the selected provider + -s, --session Restore a session by ID + -f, --fork Fork a session by ID + --nosplash Disable the welcome message and update + notifications (default: false) + --append-system-prompt Append custom instructions to the system + prompt + --on-task-completed Send a custom prompt to the agent when the + task completes + --attach Attach a file to the prompt (can be + repeated). Currently supports images: png, + jpg, jpeg, webp, gif, tiff (default: []) + -h, --help display help for command + +Commands: + auth Manage authentication for the Kilo Code CLI + config Open the configuration file in your default + editor + debug [mode] Run a system compatibility check for the + Kilo Code CLI + models [options] List available models for the current + provider as JSON diff --git a/tests/core/adapters/fixtures/kilocode_help/help-7.4.17.txt b/tests/core/adapters/fixtures/kilocode_help/help-7.4.17.txt new file mode 100644 index 00000000..d7123124 --- /dev/null +++ b/tests/core/adapters/fixtures/kilocode_help/help-7.4.17.txt @@ -0,0 +1,60 @@ +██ ██ ██🬺🬏 ██ ██ ██🬺🬏 ████ ██ ██🬺🬏 +████🬺🬏 ~~██ ██ ~~ ██~~██ ██~~~~ ██ ~~██ +██ ██ ██████ 🬁🬬████ 🬁🬬██~~ 🬁🬬████ 🬁🬬████ ██████ +~~ ~~ ~~~~~~ ~~~~ ~~ ~~~~ ~~~~ ~~~~~~ + +Commands: + kilo completion generate shell completion script + kilo acp start ACP (Agent Client Protocol) server + kilo mcp manage MCP (Model Context Protocol) servers + kilo [project] start kilo tui [default] + kilo attach attach to a running kilo server + kilo run [message..] run kilo with a message + kilo debug debugging and troubleshooting tools + kilo auth manage AI providers and credentials [aliases: providers] + kilo agent manage agents + kilo upgrade [target] upgrade kilo to the latest or a specific version + kilo uninstall uninstall kilo and remove all related files + kilo serve starts a headless kilo server + kilo web start kilo server and open web interface + kilo models [provider] list all available models + kilo stats show token usage and cost statistics + kilo export [sessionID] export session data as JSON + kilo import import session data from JSON file or URL + kilo github manage GitHub agent + kilo pr fetch and checkout a GitHub PR branch, then run kilo + kilo session manage sessions + kilo plugin install plugin and update config [aliases: plug] + kilo db database tools + kilo console open or stop the local Kilo Console + kilo cloud run Cloud Agent tasks + kilo roll-call batch-test text models matching a filter for connectivity and latency + kilo profile show Kilo account profile + kilo remote enable remote connection for real-time session relay + kilo daemon manage the local kilo daemon + kilo config configuration tools + kilo help [command] show full CLI reference + +Positionals: + project path to start kilo in [string] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) + [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) + [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] + -m, --model model to use in the format of provider/model [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] + --prompt prompt to use [string] + --agent agent to use [string] diff --git a/tests/core/adapters/test_engine_smoke_tier.py b/tests/core/adapters/test_engine_smoke_tier.py index 85d7059a..3dec8aed 100644 --- a/tests/core/adapters/test_engine_smoke_tier.py +++ b/tests/core/adapters/test_engine_smoke_tier.py @@ -103,6 +103,9 @@ class Engine: #: ``opencode run --help``. Without this the tier would silently under-cover #: exactly the flags most likely to drift. help_subcommand: str | None = None + #: Currently unused — #1015 was the last standing drift. Kept because three + #: of four engines drifted in a single week, so the next one is a matter of + #: time, and re-deriving this mechanism then would be wasted work. #: Set when the adapter is known to target a different CLI version than the #: one likely installed. The contract check then xfails instead of blocking #: every adapter PR — but it is `strict=False`, so it flips to a pass the @@ -155,19 +158,17 @@ def _kilo_binary() -> str: "opencode", lambda: "opencode", _opencode, ("opencode run", "--dir"), help_subcommand="run", ), - # kilocode 0.22.0 takes a bare positional prompt plus these flags and has no - # `run` subcommand (#1012). kilocode 7.x reinstated `run` and renamed - # --workspace to --dir, so the adapter is stale against a current install — - # tracked in #1015. This tier caught that drift on its own first CI run. + # kilocode 7.x is the supported floor: `kilo run --dir `. + # The adapter still drives 0.22.0 when that is what is installed, detecting + # the surface from --help rather than assuming one (#1015) — so the tier + # pins the *modern* entry point, which is what a fresh CI install gets. + # This tier caught the rewrite on its own first CI run, which is its purpose. Engine( "kilocode", _kilo_binary, _kilocode, - ("--auto", "--workspace"), - known_drift=( - "adapter targets @kilocode/cli 0.22.0; 7.x renamed --workspace to " - "--dir and reinstated `run` (#1015)" - ), + ("kilo run", "--dir"), + help_subcommand="run", ), ) @@ -243,7 +244,8 @@ def test_the_cli_still_documents_the_adapters_entry_point(engine: Engine) -> Non pytest.xfail(f"{engine.name}: known drift — {engine.known_drift}; missing {missing}") assert not missing, ( f"{engine.name}: {missing} absent from `{binary} --help` — the adapter's " - f"invocation may no longer be valid" + f"invocation may no longer be valid, or the installed CLI predates the " + f"version floor documented on the adapter class" ) diff --git a/tests/core/adapters/test_kilocode.py b/tests/core/adapters/test_kilocode.py index 19900d8d..19ab114a 100644 --- a/tests/core/adapters/test_kilocode.py +++ b/tests/core/adapters/test_kilocode.py @@ -11,6 +11,18 @@ pytestmark = pytest.mark.v2 _WHICH = "codeframe.core.adapters.subprocess_adapter.shutil.which" + + +def _pin_legacy_surface(binary_path: str) -> None: + """Drive the 0.22.0 invocation regardless of what is installed. + + Since #1015 `build_command` asks the CLI's own `--help` which era it is + talking to. These tests use a fake binary path, so detection would fail and + fall back to modern — pin it instead of asserting whatever the fallback is. + """ + from codeframe.core.adapters import kilocode as kilo_mod + + kilo_mod._SURFACE_CACHE[binary_path] = kilo_mod._LEGACY _WHICH_KILOCODE = "codeframe.core.adapters.kilocode.shutil.which" @@ -48,8 +60,11 @@ def test_raises_if_kilo_not_installed(self) -> None: KilocodeAdapter() def test_build_command_includes_prompt_and_auto_flag(self) -> None: + """The 0.22.0 form. Pinned to that surface explicitly since #1015: which + invocation is built now depends on the installed CLI's own --help.""" with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() + _pin_legacy_surface("/usr/bin/kilo") cmd = adapter.build_command("do the thing", Path("/tmp/repo")) assert cmd[0] == "/usr/bin/kilo" assert "do the thing" in cmd @@ -64,9 +79,13 @@ def test_the_prompt_is_the_first_positional_not_a_run_subcommand(self) -> None: [prompt]`, commands are auth/config/debug/models only. With a bogus `run` in front, `run` is consumed as the prompt, `--auto` never takes effect, and the CLI opens the TUI and hangs until the timeout (#1012). + + Still true *for that CLI*. 7.x reinstated `run`, which is why the + adapter now detects the surface rather than assuming one (#1015). """ with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() + _pin_legacy_surface("/usr/bin/kilo") cmd = adapter.build_command("do the thing", Path("/tmp/repo")) assert "run" not in cmd, "`kilo run` opens the TUI and does no work" diff --git a/tests/core/adapters/test_kilocode_7x_1015.py b/tests/core/adapters/test_kilocode_7x_1015.py new file mode 100644 index 00000000..a4a9b91c --- /dev/null +++ b/tests/core/adapters/test_kilocode_7x_1015.py @@ -0,0 +1,280 @@ +"""The kilocode adapter must match the CLI that is actually installed (#1015 / P0.30). + +`@kilocode/cli` was rewritten between 0.22.0 (2026-01-15) and 7.x (2026-07-29) — +213 releases apart — and the two invocations share nothing: + +| | 0.22.0 | 7.4.17 | +|------------|---------------------------------|----------------------------| +| usage | `kilocode [options] [prompt]` | `kilo run [message..]` | +| workspace | `--workspace ` | `--dir ` | +| `--auto` | "run in autonomous mode" | **"auto-approve ALL perms"**| + +That last row is why this could not be a rename. On 7.x `--auto` *is* the old +`--yolo` — the permission bypass #916 established must stay off — so carrying +the flag across would have silently upgraded the adapter into a bypass while +looking like a cosmetic fix. + +Detection reads the CLI's own `--help`, never a version string (the issue's AC3), +and is matched against **verbatim captured help from both real CLIs** in +`fixtures/kilocode_help/` rather than text invented here. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from codeframe.core.adapters import kilocode as kilo_mod +from codeframe.core.adapters.kilocode import KilocodeAdapter + +pytestmark = pytest.mark.v2 + +_FIXTURES = Path(__file__).parent / "fixtures" / "kilocode_help" +_LEGACY_HELP = (_FIXTURES / "help-0.22.0.txt").read_text() +_MODERN_HELP = (_FIXTURES / "help-7.4.17.txt").read_text() + + +@pytest.fixture(autouse=True) +def _clear_surface_cache(): + kilo_mod._SURFACE_CACHE.clear() + yield + kilo_mod._SURFACE_CACHE.clear() + + +@pytest.fixture +def adapter(): + a = KilocodeAdapter.__new__(KilocodeAdapter) + a._binary_path = "/usr/local/bin/kilo" + a._cli_args = [] + return a + + +def _with_help(monkeypatch, help_text: str) -> None: + class _Proc: + stdout = help_text.encode("utf-8") + stderr = b"" + + monkeypatch.setattr(kilo_mod.subprocess, "run", lambda *a, **k: _Proc()) + + +# --------------------------------------------------------------------------- +# Detection, against the real help text of both CLIs +# --------------------------------------------------------------------------- + + +def test_the_modern_cli_is_detected(monkeypatch): + _with_help(monkeypatch, _MODERN_HELP) + assert kilo_mod._detect_surface("/usr/local/bin/kilo") == kilo_mod._MODERN + + +def test_the_legacy_cli_is_detected(monkeypatch): + _with_help(monkeypatch, _LEGACY_HELP) + assert kilo_mod._detect_surface("/usr/local/bin/kilo") == kilo_mod._LEGACY + + +def test_detection_does_not_read_a_version_string(monkeypatch): + """AC3: never guessed from a version. A legacy CLI reporting a high version + number must still be driven the legacy way.""" + _with_help(monkeypatch, "kilocode 7.9.9\n" + _LEGACY_HELP) + assert kilo_mod._detect_surface("/usr/local/bin/kilo") == kilo_mod._LEGACY + + +def test_an_unreadable_help_falls_back_to_modern(monkeypatch): + """Modern is what a new install gets, and its `run` fails loudly rather than + opening the TUI that hung until timeout in #1012.""" + def _boom(*a, **k): + raise OSError("cannot exec") + + monkeypatch.setattr(kilo_mod.subprocess, "run", _boom) + assert kilo_mod._detect_surface("/usr/local/bin/kilo") == kilo_mod._MODERN + + +def test_detection_is_cached(monkeypatch): + """build_command runs per task; re-execing `--help` each time would add a + subprocess to every run.""" + calls: list[int] = [] + + class _Proc: + stdout = _MODERN_HELP.encode("utf-8") + stderr = b"" + + def _counted(*a, **k): + calls.append(1) + return _Proc() + + monkeypatch.setattr(kilo_mod.subprocess, "run", _counted) + for _ in range(5): + kilo_mod._detect_surface("/usr/local/bin/kilo") + + assert len(calls) == 1 + + +# --------------------------------------------------------------------------- +# The command each surface gets +# --------------------------------------------------------------------------- + + +def test_modern_uses_run_and_dir(adapter, monkeypatch, tmp_path): + _with_help(monkeypatch, _MODERN_HELP) + + cmd = adapter.build_command("do a thing", tmp_path) + + assert cmd == ["/usr/local/bin/kilo", "run", "--dir", str(tmp_path), "do a thing"] + + +def test_modern_does_not_pass_auto(adapter, monkeypatch, tmp_path): + """The security half of this issue: on 7.x `--auto` auto-approves ALL + permissions — the 0.22 `--yolo` the adapter has always withheld (#916).""" + _with_help(monkeypatch, _MODERN_HELP) + + cmd = adapter.build_command("do a thing", tmp_path) + + assert "--auto" not in cmd + assert "--yolo" not in cmd + assert "--dangerously-skip-permissions" not in cmd + + +def test_legacy_keeps_the_invocation_1012_verified(adapter, monkeypatch, tmp_path): + _with_help(monkeypatch, _LEGACY_HELP) + + cmd = adapter.build_command("do a thing", tmp_path) + + assert cmd == [ + "/usr/local/bin/kilo", "do a thing", "--auto", "--workspace", str(tmp_path), + ] + assert "run" not in cmd, "0.22.0 has no `run`; it gets swallowed as the prompt" + assert "--yolo" not in cmd + + +def test_legacy_auto_is_not_the_bypass(adapter, monkeypatch, tmp_path): + """Both eras must end up without a permission bypass, by different means.""" + _with_help(monkeypatch, _LEGACY_HELP) + + assert "--yolo" not in adapter.build_command("x", tmp_path) + # …and the fixture proves the two flags really are distinct on 0.22.0. + assert "--yolo" in _LEGACY_HELP + assert "Run in autonomous mode" in _LEGACY_HELP + + +def test_env_overrides_still_apply_on_both(adapter, monkeypatch, tmp_path): + monkeypatch.setenv("KILOCODE_MODEL", "some-model") + monkeypatch.setenv("KILOCODE_FLAGS", '--flag "val"') + + for help_text in (_MODERN_HELP, _LEGACY_HELP): + kilo_mod._SURFACE_CACHE.clear() + _with_help(monkeypatch, help_text) + cmd = adapter.build_command("x", tmp_path) + assert "--model" in cmd and "some-model" in cmd + assert "--flag" in cmd and "val" in cmd + + +# --------------------------------------------------------------------------- +# Oversized prompts +# --------------------------------------------------------------------------- + + +def test_modern_moves_an_oversized_prompt_to_stdin(adapter, monkeypatch, tmp_path): + """Linux caps one argv entry at 128 KiB, under CodeFrame's ~100K-token + budget. `kilo run` with no positional reads stdin — verified against 7.4.17, + where `echo "say ok" | kilo run --dir /tmp` reaches the model call.""" + _with_help(monkeypatch, _MODERN_HELP) + big = "x" * 200_000 + + cmd = adapter.build_command(big, tmp_path) + + assert cmd == ["/usr/local/bin/kilo", "run", "--dir", str(tmp_path)] + assert adapter.get_stdin(big) == big + + +def test_a_normal_prompt_stays_positional(adapter, monkeypatch, tmp_path): + _with_help(monkeypatch, _MODERN_HELP) + + assert adapter.get_stdin("small") is None + + +def test_legacy_never_uses_stdin(adapter, monkeypatch, tmp_path): + """0.22.0 has no stdin path; sending it there would silently do nothing.""" + _with_help(monkeypatch, _LEGACY_HELP) + + assert adapter.get_stdin("x" * 200_000) is None + + +# --------------------------------------------------------------------------- +# Against the installed binary +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(shutil.which("kilo") is None, reason="kilo not installed") +def test_the_installed_cli_matches_one_of_the_two_known_surfaces(): + """Pins the next rewrite: a third surface must fail here, not in production.""" + proc = subprocess.run( + ["kilo", "--help"], capture_output=True, text=True, timeout=90 + ) + help_text = proc.stdout + proc.stderr + + modern = kilo_mod._RUN_SUBCOMMAND_MARKER in help_text + legacy = "--workspace" in help_text and "--yolo" in help_text + + assert modern or legacy, ( + "installed kilo matches neither known surface — the CLI was rewritten " + f"again. Help begins:\n{help_text[:600]}" + ) + + +@pytest.mark.skipif(shutil.which("kilo") is None, reason="kilo not installed") +def test_the_detected_surface_documents_the_flags_the_adapter_uses(tmp_path): + """Whatever is installed, every flag the adapter emits must exist on it.""" + adapter = KilocodeAdapter() + cmd = adapter.build_command("x", tmp_path) + + if adapter._surface() == kilo_mod._MODERN: + proc = subprocess.run( + ["kilo", "run", "--help"], capture_output=True, text=True, timeout=90 + ) + help_text = proc.stdout + proc.stderr + else: + proc = subprocess.run( + ["kilo", "--help"], capture_output=True, text=True, timeout=90 + ) + help_text = proc.stdout + proc.stderr + + for flag in (f for f in cmd if f.startswith("--")): + assert flag in help_text, f"adapter emits {flag}, absent from the CLI's help" + + +def test_detection_survives_a_non_utf8_locale(monkeypatch): + """Review finding (bot, [major]): `text=True` decodes with the locale + encoding and no error handler, so under LC_ALL=C with UTF-8 coercion + disabled — verified: encoding becomes ANSI_X3.4-1968 — kilo 7.x's + box-drawing banner raises UnicodeDecodeError. That is a ValueError, so it + sailed past `(OSError, SubprocessError)` and crashed build_command. + """ + banner = "██ ██ ██🬺🬏\n".encode("utf-8") + + class _Proc: + stdout = banner + b" kilo run [message..] run kilo with a message\n" + stderr = b"" + + def _bytes_mode(args, **kwargs): + assert not kwargs.get("text"), "help must be read as bytes and decoded leniently" + return _Proc() + + monkeypatch.setattr(kilo_mod.subprocess, "run", _bytes_mode) + + assert kilo_mod._detect_surface("/usr/local/bin/kilo") == kilo_mod._MODERN + + +def test_undecodable_help_does_not_crash(monkeypatch): + """Even genuinely invalid bytes must degrade, not raise.""" + class _Proc: + stdout = b"\xff\xfe\x00garbage" + stderr = b"" + + monkeypatch.setattr(kilo_mod.subprocess, "run", lambda *a, **k: _Proc()) + + assert kilo_mod._detect_surface("/usr/local/bin/kilo") in ( + kilo_mod._MODERN, kilo_mod._LEGACY, + )