Skip to content
Merged
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
71 changes: 67 additions & 4 deletions verifiers/v1/harnesses/codex/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
"""

import base64
import hashlib
import json
import logging
import re
import shlex
from collections import Counter

from verifiers.v1.clients import ModelContext
from verifiers.v1.configs.harness import HarnessConfig
Expand Down Expand Up @@ -47,7 +50,7 @@ class CodexHarnessConfig(HarnessConfig):

class CodexHarness(Harness[CodexHarnessConfig]):
APPENDS_SYSTEM_PROMPT = False # TODO
SUPPORTS_MCP = False # TODO
SUPPORTS_MCP = True
SUPPORTS_RESUME = True
SUPPORTS_SKILLS = True

Expand Down Expand Up @@ -128,7 +131,7 @@ async def launch(
argv = [
CODEX_BIN,
"exec",
*self._config_args(ctx, endpoint),
*self._config_args(ctx, endpoint, mcp_urls),
*image_args,
"--",
prompt,
Expand Down Expand Up @@ -196,7 +199,7 @@ async def resume(
"exec",
"resume",
"--last",
*self._config_args(ctx, endpoint),
*self._config_args(ctx, endpoint, mcp_urls),
"--",
"\n\n".join(texts),
]
Expand Down Expand Up @@ -228,14 +231,62 @@ async def _env(self, trace: Trace, runtime: Runtime, secret: str) -> dict[str, s
"CODEX_HOME": home,
}

def _config_args(self, ctx: ModelContext, endpoint: str) -> list[str]:
def _config_args(
self,
ctx: ModelContext,
endpoint: str,
mcp_urls: dict[str, str],
) -> list[str]:
# Values are Codex feature names such as `shell_tool`; Codex owns validation.
# https://developers.openai.com/codex/config-reference#features
tool_config = [
arg
for tool in self.config.disabled_tools or []
for arg in ("--disable", tool)
]
# Set the whole table so dots in quoted server IDs are not interpreted as
# `-c` path separators.
mcp_config = (
[
"-c",
"mcp_servers={"
+ ",".join(
(
f"{json.dumps(name, ensure_ascii=False)}="
f"{{url={json.dumps(url, ensure_ascii=False)},required=true,"
"startup_timeout_sec=60.0,tool_timeout_sec=600.0}"
)
for name, url in mcp_urls.items()
)
+ "}",
]
if mcp_urls
else []
)

# Codex keeps raw server IDs for MCP calls but normalizes the namespaces
# exposed to the model. Mirror rust-v0.144.5's per-character replacement,
# optional prefix, collision hash, and possible 49-character truncation.
namespace_bases: dict[str, str] = {}
for name in mcp_urls:
namespace = re.sub(r"[^a-zA-Z0-9_]", "_", name) or "_"
namespace_bases[name] = (
namespace if namespace.startswith("mcp__") else f"mcp__{namespace}"
)
namespace_counts = Counter(namespace_bases.values())
direct_mcp_namespaces: list[str] = []
for name, namespace in namespace_bases.items():
if namespace_counts[namespace] > 1:
suffix = hashlib.sha1(f"{name}\0{name}\0".encode()).hexdigest()[:12]
namespace = (
f"{namespace[:-2]}_{suffix}__"
if namespace.endswith("__")
else f"{namespace}_{suffix}"
)
direct_mcp_namespaces.append(namespace)
if len(namespace) > 49:
direct_mcp_namespaces.append(namespace[:49])
direct_mcp_namespaces = list(dict.fromkeys(direct_mcp_namespaces))
# `-c` values parse as TOML, falling back to a raw string (so the url / `responses`
# come through literally); `requires_openai_auth=false` parses as a bool.
return [
Expand Down Expand Up @@ -266,4 +317,16 @@ def _config_args(self, ctx: ModelContext, endpoint: str) -> list[str]:
"-c",
f"model_providers.{PROVIDER}.requires_openai_auth=false",
*tool_config,
*mcp_config,
*(
[
"-c",
(
"features.code_mode.direct_only_tool_namespaces="
f"{json.dumps(direct_mcp_namespaces)}"
),
]
if direct_mcp_namespaces
else []
),
]
Loading