| 1 | """Run Codewhale through a Verifiers v0.2 interception endpoint. |
| 2 | |
| 3 | The harness owns an isolated Codewhale home for every rollout, forwards only |
| 4 | the interception secret supplied by Verifiers, and retains a bounded terminal |
| 5 | receipt rather than copying raw program output into trace metadata. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import hashlib |
| 11 | import json |
| 12 | import logging |
| 13 | import re |
| 14 | import shlex |
| 15 | from collections import Counter |
| 16 | from typing import Any, Literal |
| 17 | |
| 18 | from verifiers.v1.clients import ModelContext |
| 19 | from verifiers.v1.harness import Harness, HarnessConfig |
| 20 | from verifiers.v1.runtimes import ProgramResult, Runtime |
| 21 | from verifiers.v1.trace import Trace |
| 22 | |
| 23 | logger = logging.getLogger(__name__) |
| 24 | |
| 25 | INSTALL_DIR = "/tmp/vf-codewhale" |
| 26 | DEFAULT_BINARY = f"{INSTALL_DIR}/bin/codewhale" |
| 27 | RELEASE_ROOT = "https://github.com/Hmbown/CodeWhale/releases/download" |
| 28 | STREAM_SCHEMA = "codewhale.exec-stream" |
| 29 | STREAM_SCHEMA_VERSION = 1 |
| 30 | MAX_TERMINAL_RECEIPT_BYTES = 8_192 |
| 31 | MAX_TERMINAL_STRING_CHARS = 512 |
| 32 | _VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") |
| 33 | _TOOL = re.compile(r"^[a-z0-9][a-z0-9_-]*$") |
| 34 | _SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") |
| 35 | _EVENT_TYPES = { |
| 36 | "content", |
| 37 | "tool_use", |
| 38 | "tool_result", |
| 39 | "sandbox_denied", |
| 40 | "workflow_event", |
| 41 | "session_capture", |
| 42 | "turn_usage", |
| 43 | "metadata", |
| 44 | "done", |
| 45 | "error", |
| 46 | } |
| 47 | _TERMINAL_FIELDS = { |
| 48 | "receipt_kind", |
| 49 | "provider", |
| 50 | "provider_id", |
| 51 | "model", |
| 52 | "route_source", |
| 53 | "input_tokens", |
| 54 | "output_tokens", |
| 55 | "prompt_cache_hit_tokens", |
| 56 | "prompt_cache_miss_tokens", |
| 57 | "prompt_cache_write_tokens", |
| 58 | "reasoning_tokens", |
| 59 | "duration_ms", |
| 60 | "retry_count", |
| 61 | "approval_posture", |
| 62 | "sandbox_posture", |
| 63 | "binary_sha256", |
| 64 | "config_sha256", |
| 65 | "prompt_sha256", |
| 66 | "tool_catalog_sha256", |
| 67 | "visible_final_answer_chars", |
| 68 | "message_count", |
| 69 | "status", |
| 70 | "termination_reason", |
| 71 | "error_category", |
| 72 | } |
| 73 | |
| 74 | |
| 75 | class CodewhaleHarnessConfig(HarnessConfig): |
| 76 | version: str = "0.9.1" |
| 77 | """Codewhale release to install, pinned for reproducible rollouts.""" |
| 78 | |
| 79 | binary_path: str | None = None |
| 80 | """Preinstalled facade inside the runtime; useful for local candidate testing.""" |
| 81 | |
| 82 | max_turns: int = 100 |
| 83 | """Maximum Codewhale model steps in one rollout.""" |
| 84 | |
| 85 | sandbox: Literal["auto", "read-only", "workspace-write", "external-sandbox"] = ( |
| 86 | "auto" |
| 87 | ) |
| 88 | """`auto` keeps subprocess runs workspace-bound and trusts isolated runtimes.""" |
| 89 | |
| 90 | |
| 91 | class CodewhaleHarness(Harness[CodewhaleHarnessConfig]): |
| 92 | APPENDS_SYSTEM_PROMPT = True |
| 93 | SUPPORTS_MCP = True |
| 94 | SUPPORTS_USER_SIM = False |
| 95 | |
| 96 | def _validate_config(self) -> None: |
| 97 | if not _VERSION.fullmatch(self.config.version): |
| 98 | raise ValueError("version must be a semantic release identifier") |
| 99 | if not 1 <= self.config.max_turns <= 10_000: |
| 100 | raise ValueError("max_turns must be between 1 and 10000") |
| 101 | invalid = [ |
| 102 | tool |
| 103 | for tool in self.config.disabled_tools or [] |
| 104 | if not _TOOL.fullmatch(tool) |
| 105 | ] |
| 106 | if invalid: |
| 107 | raise ValueError( |
| 108 | "disabled_tools must use Codewhale catalog identifiers: " |
| 109 | + ", ".join(repr(tool) for tool in invalid) |
| 110 | ) |
| 111 | |
| 112 | @property |
| 113 | def binary(self) -> str: |
| 114 | configured = (self.config.binary_path or "").strip() |
| 115 | return configured or DEFAULT_BINARY |
| 116 | |
| 117 | async def setup(self, runtime: Runtime) -> None: |
| 118 | self._validate_config() |
| 119 | if self.config.binary_path: |
| 120 | logger.info("codewhale: verifying preinstalled %s", self.binary) |
| 121 | result = await runtime.run([self.binary, "--version"], {}) |
| 122 | version_text = f"{result.stdout}\n{result.stderr}" |
| 123 | if result.exit_code != 0 or not _has_version( |
| 124 | version_text, self.config.version |
| 125 | ): |
| 126 | raise RuntimeError( |
| 127 | "configured Codewhale binary is unavailable or does not report " |
| 128 | f"version {self.config.version}" |
| 129 | ) |
| 130 | return |
| 131 | |
| 132 | logger.info("codewhale: ensuring Codewhale %s is installed", self.config.version) |
| 133 | script = _install_script(self.config.version) |
| 134 | result = await runtime.run(["sh", "-c", script], {}) |
| 135 | if result.exit_code != 0: |
| 136 | raise RuntimeError( |
| 137 | "Codewhale install failed: " |
| 138 | + (result.stderr or result.stdout).strip()[-500:] |
| 139 | ) |
| 140 | |
| 141 | async def launch( |
| 142 | self, |
| 143 | ctx: ModelContext, |
| 144 | trace: Trace, |
| 145 | runtime: Runtime, |
| 146 | endpoint: str, |
| 147 | secret: str, |
| 148 | mcp_urls: dict[str, str], |
| 149 | ) -> ProgramResult: |
| 150 | self._validate_config() |
| 151 | system, prompt = self.resolve_prompt(trace.task.data) |
| 152 | trace_key = hashlib.sha256(str(trace.id).encode()).hexdigest()[:32] |
| 153 | home = f".vf-codewhale/{trace_key}" |
| 154 | mcp_path = f"{home}/mcp.json" |
| 155 | mcp = {"servers": {name: {"url": url} for name, url in mcp_urls.items()}} |
| 156 | await runtime.write( |
| 157 | mcp_path, |
| 158 | json.dumps(mcp, sort_keys=True, separators=(",", ":")).encode(), |
| 159 | ) |
| 160 | |
| 161 | endpoint = endpoint.rstrip("/") |
| 162 | env = { |
| 163 | **self.config.resolved_env, |
| 164 | "CODEWHALE_HOME": home, |
| 165 | "CODEWHALE_PROVIDER": "openai", |
| 166 | "DEEPSEEK_PROVIDER": "openai", |
| 167 | "CODEWHALE_MODEL": ctx.model, |
| 168 | "DEEPSEEK_MODEL": ctx.model, |
| 169 | "OPENAI_MODEL": ctx.model, |
| 170 | "OPENAI_BASE_URL": endpoint, |
| 171 | "OPENAI_API_KEY": secret, |
| 172 | "CODEWHALE_MCP_CONFIG": mcp_path, |
| 173 | "DEEPSEEK_MCP_CONFIG": mcp_path, |
| 174 | "CODEWHALE_TELEMETRY": "false", |
| 175 | "DEEPSEEK_TELEMETRY": "false", |
| 176 | "CODEWHALE_MEMORY": "false", |
| 177 | "NO_COLOR": "1", |
| 178 | } |
| 179 | if endpoint.startswith("http://") or any( |
| 180 | url.startswith("http://") for url in mcp_urls.values() |
| 181 | ): |
| 182 | # Verifiers' interception and colocated MCP endpoints are often |
| 183 | # ephemeral HTTP services inside an already-isolated runtime. |
| 184 | env["CODEWHALE_ALLOW_INSECURE_HTTP"] = "1" |
| 185 | |
| 186 | sandbox = self.config.sandbox |
| 187 | if sandbox == "auto": |
| 188 | sandbox = ( |
| 189 | "workspace-write" |
| 190 | if runtime.type == "subprocess" |
| 191 | else "external-sandbox" |
| 192 | ) |
| 193 | argv = [ |
| 194 | self.binary, |
| 195 | "--provider", |
| 196 | "openai", |
| 197 | "--model", |
| 198 | ctx.model, |
| 199 | "--telemetry", |
| 200 | "false", |
| 201 | "--workspace", |
| 202 | ".", |
| 203 | "--skip-onboarding", |
| 204 | "--no-project-config", |
| 205 | "exec", |
| 206 | "--auto", |
| 207 | "--sandbox", |
| 208 | sandbox, |
| 209 | "--output-format", |
| 210 | "stream-json", |
| 211 | "--max-turns", |
| 212 | str(self.config.max_turns), |
| 213 | ] |
| 214 | if self.config.disabled_tools: |
| 215 | argv.extend(["--disallowed-tools", ",".join(self.config.disabled_tools)]) |
| 216 | if system: |
| 217 | argv.extend(["--append-system-prompt", system]) |
| 218 | argv.extend(["--", str(prompt or "")]) |
| 219 | |
| 220 | result = await runtime.run_program(argv, env) |
| 221 | if result.exit_code == 0: |
| 222 | receipt = _parse_stream_receipt(result.stdout) |
| 223 | terminal = receipt["terminal"] |
| 224 | if terminal.get("provider") != "openai": |
| 225 | raise RuntimeError("Codewhale terminal receipt did not use provider openai") |
| 226 | if terminal.get("model") != ctx.model: |
| 227 | raise RuntimeError("Codewhale terminal receipt model did not match rollout") |
| 228 | if terminal.get("approval_posture") != "auto_tools": |
| 229 | raise RuntimeError("Codewhale terminal receipt did not confirm auto tools") |
| 230 | if terminal.get("sandbox_posture") != sandbox: |
| 231 | raise RuntimeError("Codewhale terminal receipt sandbox did not match launch") |
| 232 | if receipt["events"].get("error", 0) != 0: |
| 233 | raise RuntimeError("Codewhale successful run contained an error event") |
| 234 | if terminal.get("status") != "completed": |
| 235 | raise RuntimeError("Codewhale terminal receipt did not report completion") |
| 236 | if terminal.get("termination_reason") != "resolved": |
| 237 | raise RuntimeError("Codewhale terminal receipt was not resolved") |
| 238 | trace.info["codewhale"] = receipt |
| 239 | return result |
| 240 | |
| 241 | |
| 242 | def _has_version(output: str, version: str) -> bool: |
| 243 | return ( |
| 244 | re.search( |
| 245 | rf"(?<![0-9A-Za-z.+-]){re.escape(version)}(?![0-9A-Za-z.+-])", |
| 246 | output, |
| 247 | ) |
| 248 | is not None |
| 249 | ) |
| 250 | |
| 251 | |
| 252 | def _bounded_terminal(meta: dict[str, Any]) -> dict[str, Any]: |
| 253 | terminal: dict[str, Any] = {} |
| 254 | for key in _TERMINAL_FIELDS: |
| 255 | if key not in meta or meta[key] is None: |
| 256 | continue |
| 257 | value = meta[key] |
| 258 | if isinstance(value, str): |
| 259 | if len(value) > MAX_TERMINAL_STRING_CHARS: |
| 260 | raise RuntimeError("Codewhale terminal receipt exceeded its string bound") |
| 261 | elif isinstance(value, int) and not isinstance(value, bool): |
| 262 | if value < 0 or value > 2**63 - 1: |
| 263 | raise RuntimeError("Codewhale terminal receipt contained an invalid count") |
| 264 | else: |
| 265 | raise RuntimeError("Codewhale terminal receipt contained a non-scalar field") |
| 266 | terminal[key] = value |
| 267 | encoded = json.dumps(terminal, sort_keys=True, separators=(",", ":")).encode() |
| 268 | if len(encoded) > MAX_TERMINAL_RECEIPT_BYTES: |
| 269 | raise RuntimeError("Codewhale terminal receipt exceeded its total bound") |
| 270 | return terminal |
| 271 | |
| 272 | |
| 273 | def _install_script(version: str) -> str: |
| 274 | version_q = shlex.quote(version) |
| 275 | install_q = shlex.quote(INSTALL_DIR) |
| 276 | release_q = shlex.quote(RELEASE_ROOT) |
| 277 | return f""" |
| 278 | set -eu |
| 279 | version={version_q} |
| 280 | install_dir={install_q} |
| 281 | release_root={release_q} |
| 282 | if [ "$(uname -s)" != Linux ]; then |
| 283 | echo "automatic Codewhale installation supports Linux runtimes; set binary_path" >&2 |
| 284 | exit 1 |
| 285 | fi |
| 286 | case "$(uname -m)" in |
| 287 | x86_64|amd64) platform=linux-x64 ;; |
| 288 | aarch64|arm64) platform=linux-arm64 ;; |
| 289 | *) echo "unsupported Codewhale runtime architecture: $(uname -m)" >&2; exit 1 ;; |
| 290 | esac |
| 291 | if ! command -v curl >/dev/null 2>&1 \ |
| 292 | || ! command -v sha256sum >/dev/null 2>&1 \ |
| 293 | || ! command -v flock >/dev/null 2>&1; then |
| 294 | if command -v apt-get >/dev/null 2>&1; then |
| 295 | apt-get update -qq |
| 296 | apt-get install -y -qq curl ca-certificates coreutils util-linux >/dev/null |
| 297 | elif command -v apk >/dev/null 2>&1; then |
| 298 | apk add --no-cache curl ca-certificates coreutils util-linux >/dev/null |
| 299 | else |
| 300 | echo "Codewhale install needs curl, sha256sum, and flock" >&2 |
| 301 | exit 1 |
| 302 | fi |
| 303 | fi |
| 304 | mkdir -p "$install_dir" |
| 305 | exec 9>"$install_dir/install.lock" |
| 306 | flock 9 |
| 307 | mkdir -p "$install_dir/bin" |
| 308 | if [ -f "$install_dir/bin/.version" ] \ |
| 309 | && [ "$(cat "$install_dir/bin/.version")" = "$version" ] \ |
| 310 | && (cd "$install_dir/bin" && sha256sum -c .sha256 >/dev/null 2>&1); then |
| 311 | exit 0 |
| 312 | fi |
| 313 | tmp="$(mktemp -d "$install_dir/install.XXXXXX")" |
| 314 | trap 'rm -rf "$tmp"' EXIT HUP INT TERM |
| 315 | base="$release_root/v$version" |
| 316 | curl -fsSL "$base/codewhale-artifacts-sha256.txt" -o "$tmp/manifest" |
| 317 | for pair in \ |
| 318 | "codewhale-$platform:codewhale" \ |
| 319 | "codew-$platform:codew" \ |
| 320 | "codewhale-tui-$platform:codewhale-tui" |
| 321 | do |
| 322 | asset="${{pair%%:*}}" |
| 323 | target="${{pair#*:}}" |
| 324 | curl -fsSL "$base/$asset" -o "$tmp/$asset" |
| 325 | expected="$(awk -v asset="$asset" '$2 == asset {{ print $1; exit }}' "$tmp/manifest")" |
| 326 | actual="$(sha256sum "$tmp/$asset" | awk '{{print $1}}')" |
| 327 | if [ -z "$expected" ] || [ "$actual" != "$expected" ]; then |
| 328 | echo "Codewhale checksum verification failed for $asset" >&2 |
| 329 | exit 1 |
| 330 | fi |
| 331 | cp "$tmp/$asset" "$install_dir/bin/$target.tmp.$$" |
| 332 | chmod 0755 "$install_dir/bin/$target.tmp.$$" |
| 333 | mv -f "$install_dir/bin/$target.tmp.$$" "$install_dir/bin/$target" |
| 334 | done |
| 335 | (cd "$install_dir/bin" && sha256sum codewhale codew codewhale-tui > .sha256.tmp) |
| 336 | mv -f "$install_dir/bin/.sha256.tmp" "$install_dir/bin/.sha256" |
| 337 | printf '%s' "$version" > "$install_dir/bin/.version.tmp" |
| 338 | mv -f "$install_dir/bin/.version.tmp" "$install_dir/bin/.version" |
| 339 | """ |
| 340 | |
| 341 | |
| 342 | def _parse_stream_receipt(stdout: str) -> dict[str, Any]: |
| 343 | counts: Counter[str] = Counter() |
| 344 | terminal: dict[str, Any] | None = None |
| 345 | ordered_types: list[str] = [] |
| 346 | for line_number, line in enumerate(stdout.splitlines(), start=1): |
| 347 | if not line.strip(): |
| 348 | continue |
| 349 | try: |
| 350 | event = json.loads(line) |
| 351 | except json.JSONDecodeError as error: |
| 352 | raise RuntimeError( |
| 353 | f"Codewhale stream-json line {line_number} was not valid JSON" |
| 354 | ) from error |
| 355 | if not isinstance(event, dict): |
| 356 | raise RuntimeError( |
| 357 | f"Codewhale stream-json line {line_number} was not an object" |
| 358 | ) |
| 359 | if event.get("schema") != STREAM_SCHEMA or event.get( |
| 360 | "schema_version" |
| 361 | ) != STREAM_SCHEMA_VERSION: |
| 362 | raise RuntimeError("Codewhale stream-json schema did not match v0.9.1") |
| 363 | event_type = event.get("type") |
| 364 | if event_type not in _EVENT_TYPES: |
| 365 | raise RuntimeError("Codewhale stream-json contained an unknown event type") |
| 366 | counts[event_type] += 1 |
| 367 | ordered_types.append(event_type) |
| 368 | if event_type == "metadata": |
| 369 | if terminal is not None: |
| 370 | raise RuntimeError("Codewhale emitted more than one terminal metadata receipt") |
| 371 | meta = event.get("meta") |
| 372 | if not isinstance(meta, dict) or meta.get("receipt_kind") != "terminal": |
| 373 | raise RuntimeError("Codewhale metadata event was not a terminal receipt") |
| 374 | terminal = _bounded_terminal(meta) |
| 375 | |
| 376 | if terminal is None: |
| 377 | raise RuntimeError("Codewhale stream-json omitted terminal metadata") |
| 378 | if counts["done"] != 1 or not ordered_types or ordered_types[-1] != "done": |
| 379 | raise RuntimeError("Codewhale stream-json did not end with exactly one done event") |
| 380 | if ordered_types[-2:-1] != ["metadata"]: |
| 381 | raise RuntimeError("Codewhale terminal metadata did not immediately precede done") |
| 382 | for field in ["binary_sha256", "prompt_sha256"]: |
| 383 | if not isinstance(terminal.get(field), str) or not _SHA256.fullmatch( |
| 384 | terminal[field] |
| 385 | ): |
| 386 | raise RuntimeError(f"Codewhale terminal receipt omitted a valid {field}") |
| 387 | return { |
| 388 | "schema": STREAM_SCHEMA, |
| 389 | "schema_version": STREAM_SCHEMA_VERSION, |
| 390 | "events": dict(sorted(counts.items())), |
| 391 | "terminal": terminal, |
| 392 | } |
| 393 |