Skip to content

Commit a00e5bc

Browse files
committed
Merge origin/main into fix/919-api-key-lifecycle
2 parents 8cc888c + 7f79bfe commit a00e5bc

11 files changed

Lines changed: 921 additions & 42 deletions

File tree

codeframe/cli/app.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2543,8 +2543,10 @@ def work_start(
25432543
if execute:
25442544
from codeframe.core.engine_registry import is_external_engine
25452545
if engine == "codex":
2546-
from codeframe.cli.validators import require_openai_api_key
2547-
require_openai_api_key()
2546+
# Not the OpenAI key check: `codex login` is the common way in
2547+
# and sets no env var at all (#1010).
2548+
from codeframe.cli.validators import require_codex_auth
2549+
require_codex_auth()
25482550
elif engine == "cloud":
25492551
from codeframe.cli.validators import require_e2b_api_key
25502552
require_e2b_api_key()
@@ -3997,8 +3999,10 @@ def batch_run(
39973999
# Validate API key before batch execution
39984000
from codeframe.core.engine_registry import is_external_engine
39994001
if engine == "codex":
4000-
from codeframe.cli.validators import require_openai_api_key
4001-
require_openai_api_key()
4002+
# Not the OpenAI key check: `codex login` is the common way in
4003+
# and sets no env var at all (#1010).
4004+
from codeframe.cli.validators import require_codex_auth
4005+
require_codex_auth()
40024006
elif engine == "cloud":
40034007
from codeframe.cli.validators import require_e2b_api_key
40044008
require_e2b_api_key()

codeframe/cli/validators.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,32 @@ def require_e2b_api_key() -> str:
122122
"Get your key at https://e2b.dev"
123123
)
124124
raise typer.Exit(1)
125+
126+
127+
def require_codex_auth() -> None:
128+
"""Ensure the codex CLI can reach a model, by either route (#1010).
129+
130+
Not ``require_openai_api_key``: ``codex login`` stores ChatGPT-plan
131+
credentials in ``auth.json`` and writes ``"OPENAI_API_KEY": null`` in that
132+
same file, so gating on the environment variable refused the common case —
133+
``--engine codex`` was unusable for anyone who had simply logged in, even
134+
though the adapter and the binary both worked.
135+
136+
Raises:
137+
typer.Exit: If codex is authenticated by neither route.
138+
"""
139+
from codeframe.core.adapters.codex import CodexAdapter
140+
141+
if CodexAdapter.is_authenticated():
142+
return
143+
144+
load_env_files()
145+
if CodexAdapter.is_authenticated():
146+
return
147+
148+
console.print(
149+
"[red]Error:[/red] codex is not authenticated. "
150+
"Run [bold]codex login[/bold], or set OPENAI_API_KEY in your "
151+
"environment or a .env file."
152+
)
153+
raise typer.Exit(1)

codeframe/core/adapters/codex.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
from __future__ import annotations
2424

25+
import os
2526
import json
2627
import logging
2728
import queue
@@ -148,8 +149,61 @@ def name(self) -> str:
148149

149150
@classmethod
150151
def requirements(cls) -> dict[str, str]:
151-
"""Return required environment variables for ``cf engines check``."""
152-
return {"OPENAI_API_KEY": "OpenAI API key"}
152+
"""No *required* environment variables (#1010).
153+
154+
``OPENAI_API_KEY`` used to be listed here, and ``cf engines check`` marks
155+
any unsatisfied entry as an unmet requirement and exits 1 — so a codex
156+
authenticated by ``codex login`` (which sets no variable at all, and
157+
records ``"OPENAI_API_KEY": null`` in its own auth.json) was reported as
158+
broken while working perfectly. ``check_ready`` below answers the real
159+
question, by either route.
160+
"""
161+
return {}
162+
163+
@classmethod
164+
def codex_home(cls) -> Path:
165+
"""Where codex keeps its state. ``$CODEX_HOME`` is documented by the CLI."""
166+
override = os.environ.get("CODEX_HOME")
167+
return Path(override) if override else Path.home() / ".codex"
168+
169+
@classmethod
170+
def is_authenticated(cls) -> bool:
171+
"""True when codex can actually talk to a model (#1010).
172+
173+
``OPENAI_API_KEY`` is *not* the test. ``codex login`` writes ChatGPT-plan
174+
credentials to ``auth.json`` and records ``"OPENAI_API_KEY": null`` in
175+
the very same file — so gating on the environment variable refuses the
176+
common case, where the CLI works perfectly well.
177+
178+
Presence of the file is not the test either: ``codex logout`` can leave
179+
it behind with empty tokens.
180+
"""
181+
if os.environ.get("OPENAI_API_KEY"):
182+
return True
183+
184+
try:
185+
auth = json.loads((cls.codex_home() / "auth.json").read_text())
186+
except (OSError, json.JSONDecodeError, ValueError):
187+
# ValueError also covers UnicodeDecodeError from read_text() — a
188+
# genuinely corrupt auth.json must read as "not authenticated",
189+
# not crash the caller. JSONDecodeError is itself a ValueError, so
190+
# this is the whole family. (#1010 review)
191+
return False
192+
193+
if not isinstance(auth, dict):
194+
return False
195+
if auth.get("OPENAI_API_KEY"):
196+
return True
197+
tokens = auth.get("tokens")
198+
return bool(isinstance(tokens, dict) and tokens.get("access_token"))
199+
200+
@classmethod
201+
def check_ready(cls) -> dict[str, bool]:
202+
"""What ``cf engines check`` reports for codex."""
203+
return {
204+
"codex_binary": shutil.which("codex") is not None,
205+
"authenticated": cls.is_authenticated(),
206+
}
153207

154208
@classmethod
155209
def credential_env_vars(cls) -> tuple[str, ...]:

codeframe/core/adapters/kilocode.py

Lines changed: 140 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,119 @@
22

33
from __future__ import annotations
44

5+
import logging
56
import os
67
import shlex
78
import shutil
9+
import subprocess
810
from pathlib import Path
911

1012
from codeframe.core.adapters.agent_adapter import AgentResult
1113
from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter
1214

15+
logger = logging.getLogger(__name__)
16+
1317
# Exit code used by kilo when the timeout is exceeded
1418
_KILO_TIMEOUT_EXIT_CODE = 124
1519

1620

21+
#: The two incompatible CLIs that both answer to ``kilo``.
22+
_MODERN = "modern" # 7.x: `kilo run <message> --dir <path>`
23+
_LEGACY = "legacy" # 0.22.0: `kilo <prompt> --auto --workspace <path>`
24+
25+
#: Substring that appears in ``kilo --help`` only once ``run`` exists. Detection
26+
#: reads the CLI's own help rather than parsing ``--version``: #1015 requires
27+
#: that the invocation never be guessed from a version string, and this repo has
28+
#: been bitten three times by adapters that assumed a CLI surface (#913/#914/
29+
#: #1012). Help text is what the binary actually offers.
30+
_RUN_SUBCOMMAND_MARKER = "kilo run"
31+
32+
#: Linux caps a *single* argv entry at MAX_ARG_STRLEN — 128 KiB — independently
33+
#: of the much larger total ARG_MAX. Same constraint opencode hit in #913.
34+
_MAX_ARG_BYTES = 128 * 1024
35+
36+
#: Detection runs a subprocess, so it is cached per binary path for the process.
37+
_SURFACE_CACHE: dict[str, str] = {}
38+
39+
40+
def _prompt_exceeds_argv(prompt: str) -> bool:
41+
return len(prompt.encode("utf-8")) >= _MAX_ARG_BYTES
42+
43+
44+
def _detect_surface(binary_path: str) -> str:
45+
"""Which kilo CLI is installed, according to its own ``--help``.
46+
47+
``@kilocode/cli`` was rewritten between 0.22.0 (2026-01-15) and 7.x
48+
(2026-07-29) — 213 releases apart. The invocations share nothing:
49+
50+
============== ============================ ==========================
51+
\\ 0.22.0 7.4.17
52+
============== ============================ ==========================
53+
usage ``kilocode [options] [prompt]`` ``kilo run [message..]``
54+
workspace ``--workspace <path>`` ``--dir <path>``
55+
``--auto`` non-interactive **auto-approve ALL perms**
56+
============== ============================ ==========================
57+
58+
That last row is why this cannot be a blind rename: on 7.x ``--auto`` is the
59+
old ``--yolo``, the permission bypass #916 established must stay off.
60+
61+
An unreadable or failing ``--help`` falls back to modern — the version any
62+
new install gets, and the one whose ``run`` subcommand fails loudly rather
63+
than opening a TUI that hangs until the timeout (#1012).
64+
"""
65+
if binary_path in _SURFACE_CACHE:
66+
return _SURFACE_CACHE[binary_path]
67+
68+
try:
69+
# Bytes, decoded permissively. `text=True` decodes with the locale
70+
# encoding and no error handler, so under a non-UTF-8 locale
71+
# (LC_ALL=C with UTF-8 coercion disabled — verified: encoding becomes
72+
# ANSI_X3.4-1968) kilo 7.x's box-drawing banner raises
73+
# UnicodeDecodeError. That is a ValueError, so it sailed straight past
74+
# the handler below and crashed build_command. (#1015 review)
75+
proc = subprocess.run(
76+
[binary_path, "--help"], capture_output=True, timeout=90
77+
)
78+
help_text = (proc.stdout + proc.stderr).decode("utf-8", errors="replace")
79+
except (OSError, subprocess.SubprocessError, ValueError):
80+
logger.warning(
81+
"Could not read `%s --help`; assuming the modern kilo surface.",
82+
binary_path,
83+
)
84+
help_text = ""
85+
86+
surface = _MODERN if (not help_text or _RUN_SUBCOMMAND_MARKER in help_text) else _LEGACY
87+
_SURFACE_CACHE[binary_path] = surface
88+
return surface
89+
90+
1791
class KilocodeAdapter(SubprocessAdapter):
1892
"""Adapter that delegates code execution to Kilocode CLI.
1993
20-
Invokes ``kilo <prompt> --auto --workspace <path>`` for headless
21-
non-interactive execution. The prompt is the CLI's leading positional
22-
(not stdin, and **not** behind a subcommand).
94+
**Supported version floor: ``@kilocode/cli`` 7.x**, the surface any new
95+
install gets. 0.22.0 is still driven correctly when that is what is
96+
installed, because #1012 verified it end-to-end and an unupgraded machine
97+
should not silently break — but it is not the target, and support for it
98+
can be dropped once nobody is on it.
99+
100+
Which invocation to use is **detected from the CLI's own ``--help``**, never
101+
inferred from a version string (#1015). ``_detect_surface`` has the table:
23102
24-
There is no ``run`` subcommand: verified against kilocode 0.22.0, whose
25-
usage is ``kilocode [options] [command] [prompt]`` with commands
26-
``auth``/``config``/``debug``/``models`` only. The adapter used to prepend
27-
``run``, which was then swallowed as the prompt — ``--auto`` never took
28-
effect, the interactive TUI opened, and the delegated run hung until the
29-
timeout having written nothing (#1012).
103+
* 7.x — ``kilo run --dir <path> <message>``. No ``--auto``: on this CLI that
104+
flag means "auto-approve all permissions", i.e. the old ``--yolo`` the
105+
adapter has always withheld (#916). ``run`` is non-interactive on its own,
106+
exactly like ``opencode run``.
107+
* 0.22.0 — ``kilo <prompt> --auto --workspace <path>``, where ``--auto`` is
108+
merely "non-interactive". There is no ``run`` subcommand; prepending one
109+
got it swallowed as the prompt, opening the TUI to hang until the timeout
110+
having written nothing (#1012).
30111
31-
Note on prompt length: the prompt is passed as a single positional argument.
32-
Linux supports up to ~2 MB per argument, but macOS caps individual arguments
33-
at 256 KB. Very large task contexts assembled by TaskContextPackager may fail
34-
on macOS. If Kilocode adds stdin support in a future release, prefer that path.
112+
Prompt length: the prompt is a single argv entry, and Linux caps one entry
113+
at 128 KiB (macOS at 256 KB) — well under CodeFrame's ~100K-token budget. On
114+
7.x an oversized prompt goes to stdin instead, which ``kilo run`` accepts
115+
when given no positional (verified: ``echo "say ok" | kilo run --dir /tmp``
116+
reaches the model call). 0.22.0 has no stdin path, so there it still goes
117+
positionally and fails loudly.
35118
36119
Exit codes:
37120
0 — success
@@ -89,26 +172,44 @@ def check_ready(cls) -> dict[str, bool]:
89172
"""Check if the kilo binary is available on PATH."""
90173
return {"kilo_binary": shutil.which(cls._resolve_binary()) is not None}
91174

175+
def _surface(self) -> str:
176+
"""Which kilo CLI this adapter is talking to."""
177+
return _detect_surface(self._binary_path)
178+
92179
def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
93-
"""Build the kilo CLI command.
180+
"""Build the kilo CLI command for whichever CLI is actually installed.
94181
95-
Kilocode takes the prompt as a positional argument, with ``--auto``
96-
for non-interactive execution and ``--workspace`` for the repo root.
182+
The two eras take completely different invocations (see
183+
``_detect_surface``). Modern is the documented target; legacy is kept
184+
because it is what #1012 verified end-to-end and what an unupgraded
185+
install still speaks.
97186
98187
Args:
99-
prompt: The task prompt passed as a positional argument.
100-
workspace_path: Workspace root passed as ``--workspace``.
188+
prompt: The task prompt.
189+
workspace_path: Workspace root.
101190
102191
Returns:
103192
Command list for subprocess.Popen.
104193
"""
105-
cmd = [
106-
self._binary_path,
107-
prompt,
108-
"--auto",
109-
"--workspace",
110-
str(workspace_path),
111-
]
194+
if self._surface() == _MODERN:
195+
# `run` is non-interactive by itself, exactly like `opencode run`.
196+
# --auto is deliberately NOT passed: in 7.x it means "auto-approve
197+
# all permissions", the 0.22 `--yolo` that #916 established must
198+
# stay off. Renaming --workspace to --dir while keeping --auto
199+
# would have silently upgraded the adapter into a permission bypass.
200+
cmd = [self._binary_path, "run", "--dir", str(workspace_path)]
201+
if not _prompt_exceeds_argv(prompt):
202+
cmd.append(prompt)
203+
else:
204+
# 0.22.0: bare positional prompt, --auto is merely non-interactive
205+
# and --yolo (never passed) is the bypass. Verified in #1012/#916.
206+
cmd = [
207+
self._binary_path,
208+
prompt,
209+
"--auto",
210+
"--workspace",
211+
str(workspace_path),
212+
]
112213

113214
model = os.environ.get("KILOCODE_MODEL")
114215
if model:
@@ -121,7 +222,20 @@ def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
121222
return cmd
122223

123224
def get_stdin(self, prompt: str) -> str | None:
124-
"""Return None — prompt is passed as a positional CLI argument, not stdin."""
225+
"""The prompt, only when it is too large to survive as an argv entry.
226+
227+
Normally None — both eras take the prompt positionally. But Linux caps a
228+
*single* argv entry at 128 KiB while CodeFrame budgets ~100K tokens of
229+
prompt, so a large task would raise OSError(E2BIG) before kilo starts.
230+
231+
``kilo run`` with no positional reads the message from stdin: verified
232+
against 7.4.17, where `echo "say ok" | kilo run --dir /tmp` gets past
233+
message validation to the model call. The legacy CLI has no such path,
234+
so an oversized prompt still goes positionally there and fails loudly
235+
rather than silently doing nothing.
236+
"""
237+
if self._surface() == _MODERN and _prompt_exceeds_argv(prompt):
238+
return prompt
125239
return None
126240

127241
def _map_result(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# kilocode CLI help fixtures
2+
3+
Verbatim `kilo --help` output from the two incompatible CLIs that both answer to
4+
`kilo`. `_detect_surface` is matched against these rather than against text
5+
invented for the test — the same reason #914 checks in the codex app-server
6+
schema.
7+
8+
| file | version | captured |
9+
|---|---|---|
10+
| `help-0.22.0.txt` | `@kilocode/cli@0.22.0` (2026-01-15) | 2026-08-01 |
11+
| `help-7.4.17.txt` | `@kilocode/cli@7.4.17` (2026-07-29) | 2026-08-01 |
12+
13+
Regenerate with:
14+
15+
```
16+
npm install -g @kilocode/cli@<version>
17+
kilo --help > help-<version>.txt 2>&1
18+
```
19+
20+
The 7.x file contains ANSI/box-drawing characters from the banner; that is
21+
deliberate, since the real output does too and the detector must cope with it.

0 commit comments

Comments
 (0)