From 943eb65ec1a487c1bec47578583689da190166b0 Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:01:25 -0700 Subject: [PATCH 1/3] fix(kilocode): detect the installed CLI instead of assuming one (#1015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 permissions" That last row is why this could not be the rename the issue described. 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. `kilo run` is non-interactive on its own, exactly like `opencode run`, so the flag is not needed at all. Which invocation to build is detected from the CLI's own `--help`, never from a version string (the issue's AC3, and this repo has been bitten three times by adapters that assumed a surface: #913, #914, #1012). Detection is cached per binary for the process, and an unreadable --help falls back to modern — the version a new install gets, and the one whose `run` fails loudly rather than opening the TUI that hung until timeout in #1012. Version floor is 7.x, documented on the class. 0.22.0 is still driven correctly because #1012 verified it end-to-end and an unupgraded machine should not silently break. Oversized prompts: Linux caps one argv entry at 128 KiB, under CodeFrame's ~100K-token budget, so on 7.x the prompt moves to stdin. `kilo run` accepts it there when given no positional — verified, not assumed: `echo "say ok" | kilo run --dir /tmp` reaches the model call. 0.22.0 has no stdin path, so there it stays positional and fails loudly. Both surfaces verified live by installing each version in turn. Detection is tested against verbatim captured `--help` from both real CLIs, checked in under fixtures/kilocode_help/ (the #914 schema-fixture pattern) rather than text invented for the test. The two #1012 tests that assert the legacy form now pin that surface explicitly, since which command is built no longer depends on the adapter alone. --- codeframe/core/adapters/kilocode.py | 164 ++++++++++-- .../adapters/fixtures/kilocode_help/README.md | 21 ++ .../fixtures/kilocode_help/help-0.22.0.txt | 53 ++++ .../fixtures/kilocode_help/help-7.4.17.txt | 60 +++++ tests/core/adapters/test_kilocode.py | 19 ++ tests/core/adapters/test_kilocode_7x_1015.py | 245 ++++++++++++++++++ 6 files changed, 534 insertions(+), 28 deletions(-) create mode 100644 tests/core/adapters/fixtures/kilocode_help/README.md create mode 100644 tests/core/adapters/fixtures/kilocode_help/help-0.22.0.txt create mode 100644 tests/core/adapters/fixtures/kilocode_help/help-7.4.17.txt create mode 100644 tests/core/adapters/test_kilocode_7x_1015.py diff --git a/codeframe/core/adapters/kilocode.py b/codeframe/core/adapters/kilocode.py index eb84a3f6..20a451fc 100644 --- a/codeframe/core/adapters/kilocode.py +++ b/codeframe/core/adapters/kilocode.py @@ -2,36 +2,113 @@ 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 -class KilocodeAdapter(SubprocessAdapter): - """Adapter that delegates code execution to Kilocode CLI. +#: 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] = {} + - Invokes ``kilo --auto --workspace `` for headless - non-interactive execution. The prompt is the CLI's leading positional - (not stdin, and **not** behind a subcommand). +def _prompt_exceeds_argv(prompt: str) -> bool: + return len(prompt.encode("utf-8")) >= _MAX_ARG_BYTES - 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). - 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. +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: + proc = subprocess.run( + [binary_path, "--help"], capture_output=True, text=True, timeout=90 + ) + help_text = proc.stdout + proc.stderr + except (OSError, subprocess.SubprocessError): + 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. + + **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: + + * 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). + + 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 +166,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 +216,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_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..2a6b9ae4 --- /dev/null +++ b/tests/core/adapters/test_kilocode_7x_1015.py @@ -0,0 +1,245 @@ +"""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 + stderr = "" + + 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 + stderr = "" + + 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" From 5729221d22df27a73fc54965506a028bef112b8e Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:04:01 -0700 Subject: [PATCH 2/3] test(engines): pin kilocode 7.x in the contract tier and drop its xfail (#1015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC4. The tier now expects `kilo run` + `--dir` via #1007's help_subcommand — kilocode's --dir lives under `run`, exactly like opencode's, so the top-level help the tier used to read could not see it. The xfail added in #1014 is gone: with the adapter migrated it passes on its own, which is what strict=False was there to allow. known_drift is now unused by every engine. Kept, with a note: three of four engines drifted in a single week, so the next one is a matter of time and re-deriving the mechanism then would be wasted work. The contract failure message now also names the version floor, since a machine below it (this one was on 0.22.0) sees the same missing-flag assertion as real upstream drift. --- tests/core/adapters/test_engine_smoke_tier.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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" ) From 3b37d03efff06cd33cdf84bf831b0785ff0ab94d Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:08:32 -0700 Subject: [PATCH 3/3] fix(kilocode): read the CLI help as bytes, not locale-decoded text (#1015 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text=True decodes with the locale encoding and no error handler, so under a non-UTF-8 locale kilo 7.x's box-drawing banner raises UnicodeDecodeError — a ValueError, which sailed straight past (OSError, SubprocessError) and crashed build_command. Reproduced: with LC_ALL=C and UTF-8 coercion disabled (PYTHONCOERCECLOCALE=0, PYTHONUTF8=0) the preferred encoding becomes ANSI_X3.4-1968 and `kilo --help` raises. Modern Python usually coerces C to C.UTF-8, which is why this hides on a normal dev box — but not on a system without C.UTF-8, and not with coercion turned off. Now read as bytes and decoded with errors='replace', and ValueError is caught too. Verified detection returns 'modern' under that same ASCII locale. Two tests: a banner-bearing help read in bytes mode, and undecodable garbage. --- codeframe/core/adapters/kilocode.py | 12 ++++-- tests/core/adapters/test_kilocode_7x_1015.py | 43 ++++++++++++++++++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/codeframe/core/adapters/kilocode.py b/codeframe/core/adapters/kilocode.py index 20a451fc..3a8c6fcc 100644 --- a/codeframe/core/adapters/kilocode.py +++ b/codeframe/core/adapters/kilocode.py @@ -66,11 +66,17 @@ def _detect_surface(binary_path: str) -> str: 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, text=True, timeout=90 + [binary_path, "--help"], capture_output=True, timeout=90 ) - help_text = proc.stdout + proc.stderr - except (OSError, subprocess.SubprocessError): + 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, diff --git a/tests/core/adapters/test_kilocode_7x_1015.py b/tests/core/adapters/test_kilocode_7x_1015.py index 2a6b9ae4..a4a9b91c 100644 --- a/tests/core/adapters/test_kilocode_7x_1015.py +++ b/tests/core/adapters/test_kilocode_7x_1015.py @@ -54,8 +54,8 @@ def adapter(): def _with_help(monkeypatch, help_text: str) -> None: class _Proc: - stdout = help_text - stderr = "" + stdout = help_text.encode("utf-8") + stderr = b"" monkeypatch.setattr(kilo_mod.subprocess, "run", lambda *a, **k: _Proc()) @@ -98,8 +98,8 @@ def test_detection_is_cached(monkeypatch): calls: list[int] = [] class _Proc: - stdout = _MODERN_HELP - stderr = "" + stdout = _MODERN_HELP.encode("utf-8") + stderr = b"" def _counted(*a, **k): calls.append(1) @@ -243,3 +243,38 @@ def test_the_detected_surface_documents_the_flags_the_adapter_uses(tmp_path): 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, + )