Skip to content

Commit b37ab3d

Browse files
committed
codex: allow-list --profile by subcommand; omit it where codex rejects it
ucode's codex launch always ran `codex --profile ucode <args>`, but codex only accepts the global `--profile` on runtime subcommands (the interactive TUI, exec/review/resume/archive/delete/unarchive/fork/mcp/sandbox). Server-family subcommands (app-server, mcp-server, exec-server, remote-control) and utilities reject it up front: Error: --profile only applies to runtime commands and `codex mcp`: ... so e.g. `ucode codex app-server` was functionally broken. Allow-list the subcommands that accept `--profile` (a deny-list would break the next codex server subcommand by default) and attach it only for those. For the rest, omit `--profile` and pass the caller's args through untouched — those subcommands are caller-configured (e.g. omnigent runs `codex app-server` with its own CODEX_HOME), so ucode just stays out of the way. Co-authored-by: Isaac
1 parent a0a0d1c commit b37ab3d

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

src/ucode/agents/codex.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,12 +391,84 @@ def _gpt_version_key(entry: tuple[str, tuple[int, int | None, int | None, str]])
391391
return max(parsed, key=_gpt_version_key)[0]
392392

393393

394+
# codex global options that consume the following token as their value. Used to
395+
# skip past a leading global (e.g. ``--model X``) when locating the subcommand,
396+
# so it isn't mistaken for the subcommand itself. Boolean flags take no value;
397+
# ``--flag=value`` forms are self-contained and handled inline.
398+
_CODEX_GLOBAL_VALUE_FLAGS = frozenset(
399+
{
400+
"-c",
401+
"--config",
402+
"-i",
403+
"--image",
404+
"-m",
405+
"--model",
406+
"-p",
407+
"--profile",
408+
"-s",
409+
"--sandbox",
410+
"-C",
411+
"--cd",
412+
"-a",
413+
"--ask-for-approval",
414+
"--enable",
415+
"--disable",
416+
"--remote",
417+
"--remote-auth-token-env",
418+
"--local-provider",
419+
"--add-dir",
420+
}
421+
)
422+
423+
424+
def _codex_subcommand(tool_args: list[str]) -> str | None:
425+
"""Return the codex subcommand in *tool_args*, skipping global options.
426+
427+
``None`` when there is no subcommand (the interactive TUI).
428+
"""
429+
i = 0
430+
while i < len(tool_args):
431+
arg = tool_args[i]
432+
if not arg.startswith("-"):
433+
return arg
434+
# A bare value flag (``--model X``) consumes the next token too; a
435+
# ``--flag=value`` form or a boolean flag consumes only itself.
436+
if "=" not in arg and arg in _CODEX_GLOBAL_VALUE_FLAGS:
437+
i += 1
438+
i += 1
439+
return None
440+
441+
442+
# codex accepts the global --profile ONLY on these runtime subcommands (plus the
443+
# bare interactive TUI). Server-family subcommands (app-server, mcp-server,
444+
# exec-server, remote-control) and utilities reject it: "--profile only applies
445+
# to runtime commands and codex mcp". Verified against codex 0.137 + 0.141.
446+
# ALLOW-LIST (not deny-list) so a future codex server subcommand defaults to the
447+
# safe no-profile path instead of breaking.
448+
_CODEX_PROFILE_SUBCOMMANDS = frozenset(
449+
{"exec", "review", "resume", "archive", "delete", "unarchive", "fork", "mcp", "sandbox"}
450+
)
451+
452+
453+
def _codex_accepts_profile(tool_args: list[str]) -> bool:
454+
sub = _codex_subcommand(tool_args)
455+
return sub is None or sub in _CODEX_PROFILE_SUBCOMMANDS
456+
457+
394458
def launch(state: dict, tool_args: list[str]) -> None:
395459
binary = SPEC["binary"]
396460
workspace = state.get("workspace")
397461
if workspace:
398462
os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile"))
399-
exec_or_spawn([binary, "--profile", CODEX_PROFILE_NAME, *tool_args])
463+
if _codex_accepts_profile(tool_args):
464+
exec_or_spawn([binary, "--profile", CODEX_PROFILE_NAME, *tool_args])
465+
else:
466+
# codex rejects the global --profile on these subcommands (app-server,
467+
# mcp-server, exec-server, remote-control, ...). They are caller-
468+
# configured — e.g. omnigent runs `codex app-server` with its own
469+
# CODEX_HOME + config — so ucode simply omits --profile and stays out of
470+
# the way instead of injecting a named profile codex won't accept.
471+
exec_or_spawn([binary, *tool_args])
400472

401473

402474
def validate_cmd(binary: str) -> list[str]:

tests/test_agent_codex.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,70 @@ def fake_execvp(binary: str, args: list[str]) -> None:
436436

437437
assert os.environ["OAUTH_TOKEN"] == "fresh-token"
438438
assert exec_calls == [("codex", ["codex", "--profile", "ucode", "--search"])]
439+
440+
441+
class TestCodexSubcommand:
442+
def test_none_for_interactive(self):
443+
assert codex._codex_subcommand([]) is None
444+
assert codex._codex_subcommand(["--search"]) is None
445+
446+
def test_finds_bare_subcommand(self):
447+
assert codex._codex_subcommand(["exec", "hi"]) == "exec"
448+
assert codex._codex_subcommand(["app-server"]) == "app-server"
449+
450+
def test_skips_leading_value_flags(self):
451+
assert codex._codex_subcommand(["--model", "gpt-5", "app-server"]) == "app-server"
452+
assert codex._codex_subcommand(["-c", "k=v", "exec"]) == "exec"
453+
454+
def test_handles_equals_form(self):
455+
assert codex._codex_subcommand(["--model=gpt-5", "app-server"]) == "app-server"
456+
457+
458+
class TestCodexAcceptsProfile:
459+
def test_bare_tui_accepts_profile(self):
460+
assert codex._codex_accepts_profile([])
461+
assert codex._codex_accepts_profile(["--search"])
462+
463+
def test_runtime_subcommands_accept_profile(self):
464+
for sub in codex._CODEX_PROFILE_SUBCOMMANDS:
465+
assert codex._codex_accepts_profile([sub])
466+
467+
def test_server_family_and_utilities_reject_profile(self):
468+
for sub in ("app-server", "mcp-server", "exec-server", "remote-control", "login", "debug"):
469+
assert not codex._codex_accepts_profile([sub])
470+
471+
472+
class TestCodexLaunchProfileRouting:
473+
"""launch() attaches --profile only where codex accepts it. Subcommands that
474+
reject it (the server family) get the caller's args through untouched, with
475+
no --profile — the caller configures those (e.g. omnigent via CODEX_HOME)."""
476+
477+
@staticmethod
478+
def _capture(monkeypatch):
479+
calls: list[list[str]] = []
480+
monkeypatch.setattr(codex, "exec_or_spawn", lambda argv: calls.append(argv))
481+
monkeypatch.setattr(codex, "get_databricks_token", lambda workspace, profile=None: "tok")
482+
return calls
483+
484+
def test_bare_keeps_profile(self, monkeypatch):
485+
calls = self._capture(monkeypatch)
486+
codex.launch({"workspace": WS}, [])
487+
assert calls == [["codex", "--profile", "ucode"]]
488+
489+
def test_exec_keeps_profile(self, monkeypatch):
490+
calls = self._capture(monkeypatch)
491+
codex.launch({"workspace": WS}, ["exec", "say hi"])
492+
assert calls == [["codex", "--profile", "ucode", "exec", "say hi"]]
493+
494+
def test_runtime_subcommands_keep_profile(self, monkeypatch):
495+
for sub in ("mcp", "resume"):
496+
calls = self._capture(monkeypatch)
497+
codex.launch({"workspace": WS}, [sub])
498+
assert calls[0] == ["codex", "--profile", "ucode", sub]
499+
500+
def test_server_family_omits_profile(self, monkeypatch):
501+
for sub in ("app-server", "mcp-server", "exec-server", "remote-control"):
502+
calls = self._capture(monkeypatch)
503+
codex.launch({"workspace": WS}, [sub, "--listen", "u"])
504+
# No --profile injected; the caller's args pass through untouched.
505+
assert calls[0] == ["codex", sub, "--listen", "u"]

0 commit comments

Comments
 (0)