返回 CodeWhale
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
393 lines PYTHON