diff --git a/.gitignore b/.gitignore index 50469f8..41e0685 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ tmp/ # External SDK checkouts should be installed as dependencies instead. /wt-data-gateway/ + +# +wheels/ \ No newline at end of file diff --git a/=10.1 b/=10.1 new file mode 100644 index 0000000..e69de29 diff --git a/clusters/docker_clusters.py b/clusters/docker_clusters.py index bd538c9..8cee2a3 100644 --- a/clusters/docker_clusters.py +++ b/clusters/docker_clusters.py @@ -527,18 +527,29 @@ async def _install_runner_script_if_needed( runner_host_path = self._resolve_runner_host_path(docker_cfg.get("runner_script_host_path")) if runner_host_path is None: runner_host_path = self._runner_host_path + runner_container_path = str( + docker_cfg.get("runner_container_path", self._runner_container_path) or self._runner_container_path + ).strip() if runner_host_path is None or not runner_host_path.is_file(): - raise RuntimeError(f"OpenClaw runner script not found: {runner_host_path}") + raise RuntimeError(f"Safactory runner script not found: {runner_host_path}") + if not runner_container_path: + raise RuntimeError("Safactory runner container path is empty") + runner_parent = str(Path(runner_container_path).parent) await self._run_required( - [self.docker_bin, "cp", str(runner_host_path), f"{record.container_id}:{self._runner_container_path}"], - action=f"install Safactory OpenClaw runner in {record.container_name}", + [self.docker_bin, "exec", record.container_id, "mkdir", "-p", runner_parent], + action=f"prepare runner directory in {record.container_name}", + timeout_s=self._start_timeout_s, + ) + await self._run_required( + [self.docker_bin, "cp", str(runner_host_path), f"{record.container_id}:{runner_container_path}"], + action=f"install Safactory runner in {record.container_name}", timeout_s=self._start_timeout_s, ) log.debug( - "installed Safactory OpenClaw runner into %s:%s", + "installed Safactory runner into %s:%s", record.container_name, - self._runner_container_path, + runner_container_path, ) async def _start_container(self, *, env_name: str, image: str) -> DockerContainerRecord: @@ -562,9 +573,12 @@ async def _start_container(self, *, env_name: str, image: str) -> DockerContaine ) resolved_max_runs = int(docker_cfg.get("max_runs_per_container", self._default_max_runs) or 0) reuse_container = self._resolve_reuse(docker_cfg) + runner_container_path = str( + docker_cfg.get("runner_container_path", self._runner_container_path) or self._runner_container_path + ).strip() install_runner = docker_cfg.get("install_runner_script") if install_runner is None: - install_runner = self._runner_container_path in resolved_run_command + install_runner = runner_container_path in resolved_run_command run_cmd = self._build_run_command( image=image, name=container_name, @@ -610,7 +624,7 @@ async def _start_container(self, *, env_name: str, image: str) -> DockerContaine "remove_timeout_s": self._remove_timeout_s, "remove_retries": self._remove_retries, "remove_retry_delay_s": self._remove_retry_delay_s, - "runner_container_path": self._runner_container_path, + "runner_container_path": runner_container_path, "install_runner_script": bool(install_runner), } ), diff --git a/env/geo3k/geo3k_config.yaml b/env/geo3k/geo3k_config.yaml index a5c14ce..f1d4f85 100644 --- a/env/geo3k/geo3k_config.yaml +++ b/env/geo3k/geo3k_config.yaml @@ -3,7 +3,7 @@ environments: env_image: safactory-geo3k:py311 env_num: 1 # chenhegu/geo3k_imgurl train split at the pinned Hugging Face snapshot. - dataset: /mnt/shared-storage-user/yinzhenyun/datasets--chenhegu--geo3k_imgurl/snapshots/a924f868cefdf3b8306196b04edaa88d930978e5/train.parquet + dataset: /mnt/shared-storage-user/leishanzhe/dataset/chenhegu/geo3k_imgurl/train.parquet dataset_load_mode: parquet_row_ref # Exclude preprocessed_images: it contains binary image bytes and the runner # only consumes the data URLs already stored in images. diff --git a/env/patcheval/PatchEval b/env/patcheval/PatchEval new file mode 160000 index 0000000..ae9b3a7 --- /dev/null +++ b/env/patcheval/PatchEval @@ -0,0 +1 @@ +Subproject commit ae9b3a7233e9e2fe86dcba8baba24beff9bcbba8 diff --git a/env/patcheval/datasets/cve-2020-25459.jsonl b/env/patcheval/datasets/cve-2020-25459.jsonl new file mode 100644 index 0000000..3510039 --- /dev/null +++ b/env/patcheval/datasets/cve-2020-25459.jsonl @@ -0,0 +1 @@ +{"cve_id":"CVE-2020-25459","work_dir":"/workspace/FATE","problem_statement":"Fix CVE-2020-25459 in FATE. sync_tree sends sensitive decision-tree information to hosts during federated training. Remove sensitive information before transfer while preserving training behavior.\nVulnerability Location\n[{\"file_path\":\"federatedml/tree/hetero/hetero_decision_tree_guest.py\",\"start_line\":532,\"end_line\":544}]"} diff --git a/env/patcheval/datasets/cve-2020-26215.jsonl b/env/patcheval/datasets/cve-2020-26215.jsonl new file mode 100644 index 0000000..ca2b78b --- /dev/null +++ b/env/patcheval/datasets/cve-2020-26215.jsonl @@ -0,0 +1 @@ +{"cve_id":"CVE-2020-26215","work_dir":"/workspace/notebook","problem_statement":"Fix CVE-2020-26215 in Jupyter Notebook. Trailing-slash redirects can interpret repeated leading slashes as a protocol-relative external URL. Preserve the query string and normalize the redirect to a local path.\nVulnerability Location\n[{\"file_path\":\"notebook/base/handlers.py\",\"start_line\":861,\"end_line\":863}]"} diff --git a/env/patcheval/datasets/cve-2021-23376.jsonl b/env/patcheval/datasets/cve-2021-23376.jsonl new file mode 100644 index 0000000..06d8b4d --- /dev/null +++ b/env/patcheval/datasets/cve-2021-23376.jsonl @@ -0,0 +1 @@ +{"cve_id":"CVE-2021-23376","work_dir":"/workspace/ffmpegdotjs","problem_statement":"Fix CVE-2021-23376 in ffmpegdotjs. Attacker-controlled arguments to trimvideo reach child_process.exec and permit OS command injection. Preserve normal video trimming behavior.\nVulnerability Location\n[{\"file_path\":\"index.js\",\"start_line\":216,\"end_line\":233}]"} diff --git a/env/patcheval/datasets/cve-2022-1986.jsonl b/env/patcheval/datasets/cve-2022-1986.jsonl new file mode 100644 index 0000000..710aa35 --- /dev/null +++ b/env/patcheval/datasets/cve-2022-1986.jsonl @@ -0,0 +1 @@ +{"cve_id":"CVE-2022-1986","work_dir":"/workspace/gogs","problem_statement":"Fix CVE-2022-1986 in Gogs. Repository path validation is vulnerable to OS command injection because Git path detection is platform-separator dependent. Handle both slash styles safely.\nVulnerability Location\n[{\"file_path\":\"internal/db/repo_editor.go\",\"start_line\":468,\"end_line\":474}]"} diff --git a/env/patcheval/datasets/cve-2024-25620.jsonl b/env/patcheval/datasets/cve-2024-25620.jsonl new file mode 100644 index 0000000..9fef452 --- /dev/null +++ b/env/patcheval/datasets/cve-2024-25620.jsonl @@ -0,0 +1 @@ +{"cve_id":"CVE-2024-25620","work_dir":"/workspace/helm","problem_statement":"Fix CVE-2024-25620 in Helm. A chart name containing relative path changes can cause a chart to be saved outside the expected directory. Validate chart metadata names without breaking valid charts.\nVulnerability Location\n[{\"file_path\":\"pkg/chart/metadata.go\",\"start_line\":87,\"end_line\":146}]"} diff --git a/env/patcheval/patcheval_config.yaml b/env/patcheval/patcheval_config.yaml new file mode 100644 index 0000000..79c7b44 --- /dev/null +++ b/env/patcheval/patcheval_config.yaml @@ -0,0 +1,40 @@ +environments: + - env_name: patcheval_cve_2021_23376 + env_image: ghcr.io/anonymous2578-data/cve-2021-23376:latest + env_num: 1 + dataset: ./datasets/cve-2021-23376.jsonl + dataset_load_mode: eager + env_params: + task_family: patcheval + + - env_name: patcheval_cve_2020_25459 + env_image: ghcr.io/anonymous2578-data/cve-2020-25459:latest + env_num: 1 + dataset: ./datasets/cve-2020-25459.jsonl + dataset_load_mode: eager + env_params: + task_family: patcheval + + - env_name: patcheval_cve_2022_1986 + env_image: ghcr.io/anonymous2578-data/cve-2022-1986:latest + env_num: 1 + dataset: ./datasets/cve-2022-1986.jsonl + dataset_load_mode: eager + env_params: + task_family: patcheval + + - env_name: patcheval_cve_2024_25620 + env_image: ghcr.io/anonymous2578-data/cve-2024-25620:latest + env_num: 1 + dataset: ./datasets/cve-2024-25620.jsonl + dataset_load_mode: eager + env_params: + task_family: patcheval + + - env_name: patcheval_cve_2020_26215 + env_image: ghcr.io/anonymous2578-data/cve-2020-26215:latest + env_num: 1 + dataset: ./datasets/cve-2020-26215.jsonl + dataset_load_mode: eager + env_params: + task_family: patcheval diff --git a/env/patcheval/patcheval_start.yaml b/env/patcheval/patcheval_start.yaml new file mode 100644 index 0000000..a8a5c6c --- /dev/null +++ b/env/patcheval/patcheval_start.yaml @@ -0,0 +1,21 @@ +agents: + patcheval_cve_2021_23376: &patcheval + container: + workdir: /workspace + runner_entrypoint: + source: ./runner.py + target: /tmp/safactory-patcheval-runner.py + command: "python /tmp/safactory-patcheval-runner.py" + install_runner_script: true + env: + PYTHONDONTWRITEBYTECODE: "1" + NO_PROXY: host.docker.internal,localhost,127.0.0.1,::1 + no_proxy: host.docker.internal,localhost,127.0.0.1,::1 + extra_args: + - --add-host=host.docker.internal:host-gateway + idle_command: "tail -f /dev/null" + + patcheval_cve_2020_25459: *patcheval + patcheval_cve_2022_1986: *patcheval + patcheval_cve_2024_25620: *patcheval + patcheval_cve_2020_26215: *patcheval diff --git a/env/patcheval/rule_evaluator.py b/env/patcheval/rule_evaluator.py new file mode 100644 index 0000000..4756d2b --- /dev/null +++ b/env/patcheval/rule_evaluator.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any + +from evaluator.eval_types import EvalRequest, EvalResult, EvalSpec, EvalStatus, Trajectory + + +async def evaluate_rule( + *, + request: EvalRequest, + spec: EvalSpec, + trajectory: Trajectory, +) -> EvalResult: + """Commit the runner's staged PatchEval score as a 0-10 reward.""" + metrics = _start_metrics(request) + score = _float_or_none(metrics.get("score")) + if score is None: + return EvalResult.failed( + session_id=request.session_id, + eval_id=spec.eval_id, + method=spec.method.value, + reason="patcheval metrics did not contain a numeric score", + artifacts={"bench": "patcheval", "metrics": metrics}, + ) + + normalized_score = max(0.0, min(10.0, score)) + return EvalResult( + session_id=request.session_id, + eval_id=spec.eval_id, + method=spec.method.value, + status=EvalStatus.SUCCEEDED.value, + raw_score=float(score), + normalized_score_10=round(normalized_score, 6), + reason="patcheval staged reward: apply=1, PoC=7, strict=10", + artifacts={ + "bench": "patcheval", + "cve_id": metrics.get("cve_id"), + "strict_success": metrics.get("strict_success"), + "patch_applied": metrics.get("patch_applied"), + "poc_passed": metrics.get("poc_passed"), + "unit_tests_passed": metrics.get("unit_tests_passed"), + "failure_stage": metrics.get("failure_stage"), + "poc_log": metrics.get("poc_log"), + "unit_test_log": metrics.get("unit_test_log"), + }, + ) + + +def _start_metrics(request: EvalRequest) -> dict[str, Any]: + start_result = getattr(request, "start_result", None) + metrics = getattr(start_result, "metrics", None) + return dict(metrics) if isinstance(metrics, dict) else {} + + +def _float_or_none(value: Any) -> float | None: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None diff --git a/env/patcheval/runner.py b/env/patcheval/runner.py new file mode 100755 index 0000000..e9e85f4 --- /dev/null +++ b/env/patcheval/runner.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +"""Single-turn PatchEval runner for the Safactory v2 runtime contract.""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit +from urllib.request import Request, urlopen + + +DEFAULT_TIMEOUT_S = 600.0 +MAX_SOURCE_CHARS = 24_000 +MAX_LOG_CHARS = 8_000 + + +def main() -> int: + started_at = time.perf_counter() + request = _read_request() + session_id = _required_text(request.get("session_id"), "session_id") + + try: + env_params = request.get("env_params") if isinstance(request.get("env_params"), dict) else {} + dataset = env_params.get("dataset") if isinstance(env_params.get("dataset"), dict) else {} + cve_id = _required_text(dataset.get("cve_id") or env_params.get("cve_id"), "cve_id") + work_dir = Path(_required_text(dataset.get("work_dir") or env_params.get("work_dir"), "work_dir")) + problem = _required_text(dataset.get("problem_statement"), "problem_statement") + timeout_s = _float(request.get("agent_start_timeout_s"), DEFAULT_TIMEOUT_S) + + if not work_dir.is_dir(): + raise RuntimeError(f"PatchEval work directory does not exist: {work_dir}") + + _remove_answer_leaks() + source_context = _collect_source_context(problem, work_dir) + prompt = _build_prompt(cve_id, problem, source_context) + response_text = _call_gateway( + base_url=_resolve_base_url(request, session_id), + model=_required_text( + request.get("model") or env_params.get("route_model") or os.environ.get("SAFACTORY_ROUTE_MODEL"), + "model", + ), + prompt=prompt, + temperature=_float(request.get("temperature"), 1.0), + timeout_s=timeout_s, + ) + patch = _extract_patch(response_text) + if patch and not _patch_is_allowed(patch): + patch = "" + result = _evaluate_patch(patch, work_dir, timeout_s) + + _write_result( + { + "session_id": session_id, + "status": "succeeded", + "total_reward": result["score"], + "step_count": 1, + "terminated": True, + "truncated": False, + "error_text": None, + "metrics": { + "bench": "patcheval", + "cve_id": cve_id, + "score": result["score"], + "strict_success": result["strict_success"], + "patch_extracted": bool(patch), + "patch_applied": result["patch_applied"], + "poc_passed": result["poc_passed"], + "unit_test_present": result["unit_test_present"], + "unit_tests_passed": result["unit_tests_passed"], + "failure_stage": result["failure_stage"], + "patch": patch, + "poc_log": result["poc_log"], + "unit_test_log": result["unit_test_log"], + "duration_ms": round((time.perf_counter() - started_at) * 1000, 3), + }, + } + ) + return 0 + except Exception as exc: # runtime must always emit a result object + _write_result( + { + "session_id": session_id, + "status": "failed", + "total_reward": 0.0, + "step_count": 0, + "terminated": True, + "truncated": isinstance(exc, subprocess.TimeoutExpired), + "error_text": str(exc), + "metrics": { + "bench": "patcheval", + "infrastructure_error": True, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 3), + }, + } + ) + return 0 + + +def _evaluate_patch(patch: str, work_dir: Path, timeout_s: float) -> dict[str, Any]: + empty = { + "score": 0.0, + "strict_success": False, + "patch_applied": False, + "poc_passed": False, + "unit_test_present": Path("/workspace/unit_test.sh").is_file(), + "unit_tests_passed": False, + "failure_stage": "patch_extraction", + "poc_log": "", + "unit_test_log": "", + } + if not patch: + return empty + + patch_path = Path("/workspace/fix.patch") + patch_path.write_text(patch.rstrip() + "\n", encoding="utf-8") + _run(["bash", "/workspace/prepare.sh"], cwd=Path("/workspace"), timeout_s=timeout_s) + + apply_check = _run( + ["git", "-C", str(work_dir), "apply", "--check", str(patch_path)], + cwd=work_dir, + timeout_s=timeout_s, + check=False, + ) + if apply_check.returncode != 0: + empty["failure_stage"] = "patch_apply" + empty["poc_log"] = _trim_log(apply_check.stdout + apply_check.stderr) + return empty + + result = dict(empty) + result["score"] = 1.0 + result["patch_applied"] = True + result["failure_stage"] = "poc" + + poc = _run( + ["bash", "/workspace/fix-run.sh"], + cwd=Path("/workspace"), + timeout_s=timeout_s, + check=False, + ) + result["poc_log"] = _trim_log(poc.stdout + poc.stderr) + if poc.returncode != 0: + return result + + result["score"] = 7.0 + result["poc_passed"] = True + unit_script = Path("/workspace/unit_test.sh") + if not unit_script.is_file(): + result.update(score=10.0, strict_success=True, unit_tests_passed=True, failure_stage=None) + return result + + result["failure_stage"] = "unit_tests" + unit = _run( + ["bash", str(unit_script)], + cwd=Path("/workspace"), + timeout_s=timeout_s, + check=False, + ) + result["unit_test_log"] = _trim_log(unit.stdout + unit.stderr) + if unit.returncode == 0: + result.update(score=10.0, strict_success=True, unit_tests_passed=True, failure_stage=None) + return result + + +def _build_prompt(cve_id: str, problem: str, source_context: str) -> str: + return f"""You are repairing {cve_id} in a local source repository. + +{problem.strip()} + +Relevant source excerpts: +{source_context or "(No source excerpt could be loaded.)"} + +Return only a git-compatible unified diff beginning with `diff --git`. +Modify only files inside the vulnerable repository. Do not modify tests, shell scripts, +CI configuration, dependency lock files, or generated files. Make the smallest secure +change that preserves existing behavior. Do not include Markdown fences or explanation.""" + + +def _collect_source_context(problem: str, work_dir: Path) -> str: + locations = re.findall( + r'"file_path"\s*:\s*"([^"]+)".*?"start_line"\s*:\s*(\d+).*?"end_line"\s*:\s*(\d+)', + problem, + flags=re.DOTALL, + ) + chunks: list[str] = [] + used = 0 + for rel_path, start_raw, end_raw in locations: + path = (work_dir / rel_path).resolve() + try: + path.relative_to(work_dir.resolve()) + except ValueError: + continue + if not path.is_file(): + continue + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + start = max(1, int(start_raw) - 20) + end = min(len(lines), int(end_raw) + 20) + body = "\n".join(f"{idx:6d} {lines[idx - 1]}" for idx in range(start, end + 1)) + chunk = f"\n--- {rel_path}:{start}-{end} ---\n{body}\n" + if used + len(chunk) > MAX_SOURCE_CHARS: + break + chunks.append(chunk) + used += len(chunk) + return "".join(chunks) + + +def _extract_patch(text: str) -> str: + text = str(text or "").strip() + fenced = re.search(r"```(?:diff|patch)?\s*(diff --git .*?)```", text, flags=re.DOTALL) + if fenced: + text = fenced.group(1).strip() + else: + marker = text.find("diff --git ") + if marker < 0: + return "" + text = text[marker:].strip() + if not text.startswith("diff --git "): + return "" + return text + + +def _patch_is_allowed(patch: str) -> bool: + """Reject direct edits to validation infrastructure and test suites.""" + paths = re.findall(r"^\+\+\+ [ab]/(.+)$", patch, flags=re.MULTILINE) + if not paths: + return False + forbidden_names = { + "prepare.sh", + "fix-run.sh", + "vul-run.sh", + "unit_test.sh", + "llm.patch", + "fix.patch", + } + for raw_path in paths: + path = raw_path.strip() + name = Path(path).name + parts = {part.lower() for part in Path(path).parts} + if name in forbidden_names or "test" in name.lower() or {"test", "tests", ".github"} & parts: + return False + return True + + +def _call_gateway(*, base_url: str, model: str, prompt: str, temperature: float, timeout_s: float) -> str: + payload = json.dumps( + { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": temperature, + } + ).encode("utf-8") + request = Request( + f"{base_url}/chat/completions", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=timeout_s) as response: + body = json.loads(response.read().decode("utf-8")) + return str(body["choices"][0]["message"].get("content") or "") + + +def _resolve_base_url(request: dict[str, Any], session_id: str) -> str: + base_url = str(os.environ.get("SAFACTORY_GATEWAY_SESSION_URL_CONTAINER") or "").strip() + if base_url: + return base_url.rstrip("/") + gateway = str(request.get("gateway_base_url") or "").rstrip("/") + if not gateway: + raise RuntimeError("PatchEval runner could not resolve the gateway session URL") + return _containerize_local_url(f"{gateway}/{session_id}") + + +def _containerize_local_url(url: str) -> str: + parts = urlsplit(url) + if parts.hostname not in {"127.0.0.1", "localhost", "::1"}: + return url + netloc = "host.docker.internal" + if parts.port is not None: + netloc = f"{netloc}:{parts.port}" + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + +def _remove_answer_leaks() -> None: + for raw in ("/workspace/llm.patch", "/workspace/test.patch"): + path = Path(raw) + if path.exists(): + path.unlink() + + +def _run( + args: list[str], + *, + cwd: Path, + timeout_s: float, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=str(cwd), + capture_output=True, + text=True, + timeout=max(1.0, timeout_s), + check=check, + ) + + +def _trim_log(value: str) -> str: + value = str(value or "") + return value[-MAX_LOG_CHARS:] + + +def _read_request() -> dict[str, Any]: + raw = sys.stdin.read().strip() or os.environ.get("SAFACTORY_START_REQUEST_JSON", "").strip() + if not raw: + raise RuntimeError("SimulationStartRequest JSON was not provided") + data = json.loads(raw) + if not isinstance(data, dict): + raise RuntimeError("SimulationStartRequest must be a JSON object") + return data + + +def _required_text(value: Any, name: str) -> str: + text = str(value or "").strip() + if not text: + raise RuntimeError(f"PatchEval request missing {name}") + return text + + +def _float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _write_result(result: dict[str, Any]) -> None: + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evaluator/configs/patcheval_rule_eval.yaml b/evaluator/configs/patcheval_rule_eval.yaml new file mode 100644 index 0000000..ee9827e --- /dev/null +++ b/evaluator/configs/patcheval_rule_eval.yaml @@ -0,0 +1,8 @@ +evaluation: + max_concurrency: 5 + fail_policy: zero_reward + default_specs: + - eval_id: patcheval_rule + method: rule_evaluator + rule_evaluator: env/patcheval/rule_evaluator.py + timeout_s: 30 diff --git a/manager/simulation_config.py b/manager/simulation_config.py index 21c386a..9582f5b 100644 --- a/manager/simulation_config.py +++ b/manager/simulation_config.py @@ -465,6 +465,7 @@ def _normalize_agent_start_docker(agent_name: Any, spec: Any, cfg_path: Path) -> ) docker["run_command"] = entrypoint_command + install_runner_script = bool(container.get("install_runner_script", False)) mounts = container.get("mounts", container.get("volumes", [])) or [] if isinstance(mounts, (str, dict)): mounts = [mounts] @@ -472,7 +473,7 @@ def _normalize_agent_start_docker(agent_name: Any, spec: Any, cfg_path: Path) -> raise ValueError(f"container.mounts for {agent_name!r} must be a list in {cfg_path}") normalized_mounts = [_normalize_mount(mount, cfg_path) for mount in mounts] runner_mount = _mount_from_runner_entrypoint(runner_entrypoint) - if runner_mount: + if runner_mount and not install_runner_script: _append_mount_if_missing(normalized_mounts, runner_mount, agent_name=agent_name, cfg_path=cfg_path) docker["volumes"] = normalized_mounts @@ -484,10 +485,22 @@ def _normalize_agent_start_docker(agent_name: Any, spec: Any, cfg_path: Path) -> else: raise ValueError(f"container.extra_args for {agent_name!r} must be a string or list in {cfg_path}") - if "install_runner_script" in container: - docker["install_runner_script"] = bool(container.get("install_runner_script")) - else: - docker["install_runner_script"] = False + docker["install_runner_script"] = install_runner_script + if install_runner_script and runner_entrypoint: + runner_source = str(runner_entrypoint.get("source") or "").strip() + runner_target = str(runner_entrypoint.get("target") or "").strip() + if not runner_source or not Path(runner_source).is_file(): + raise ValueError( + f"container.install_runner_script requires runner_entrypoint.source to be a file " + f"for {agent_name!r} in {cfg_path}, got {runner_source!r}" + ) + if not runner_target: + raise ValueError( + f"container.install_runner_script requires runner_entrypoint.target " + f"for {agent_name!r} in {cfg_path}" + ) + docker["runner_script_host_path"] = runner_source + docker["runner_container_path"] = runner_target return docker diff --git a/rl/examples/geo3k_vl/=10.1 b/rl/examples/geo3k_vl/=10.1 new file mode 100644 index 0000000..e69de29 diff --git a/rl/examples/geo3k_vl/env.sh b/rl/examples/geo3k_vl/env.sh index cbb6d13..9612ed6 100644 --- a/rl/examples/geo3k_vl/env.sh +++ b/rl/examples/geo3k_vl/env.sh @@ -1,7 +1,7 @@ # ------------------------------------------- # AIEvobox (rollout) Settings # ------------------------------------------- -export AIEVOBOX_ROOT=/root/Safactory +export AIEVOBOX_ROOT=/mnt/shared-storage-user/leishanzhe/repo/SAfactory export STORAGE_TYPE=sqlite export AIEVOBOX_DB_URL=sqlite:///${AIEVOBOX_ROOT}/rl/examples/geo3k_vl/geo3k_vl.db export AIEVOBOX_MAX_STEPS=10 @@ -31,7 +31,7 @@ export AIEVOBOX_SQLITE_BULK_INSERT_PAUSE_S=0.01 # RL Settings # ------------------------------------------- export RL_GROUP_SIZE=8 -export RL_EPOCH=1000 +export RL_EPOCH=100 export RL_OFF_BY_N=0 # no use, will be removed @@ -68,7 +68,8 @@ export LLM_PROXY_ENABLE_CONSOLE_LOG=0 # buffer_server 会自动拉起 gateway(生成配置:route 指向 llm_proxy、共用 RL DB、 # max_steps=-1),并等 /readyz。runner 只打 gateway 的 session 端点。 # 想用外部/手动 gateway 时设 AIEVOBOX_GATEWAY_AUTOSTART=0 关掉自动拉起。 -export AIEVOBOX_GATEWAY_HOST=127.0.0.1 +# Docker 容器通过当前训练节点 IP 访问宿主进程。 +export AIEVOBOX_GATEWAY_HOST=100.99.221.45 export AIEVOBOX_GATEWAY_PORT=8000 export AIEVOBOX_GATEWAY_BASE_URL=http://${AIEVOBOX_GATEWAY_HOST}:${AIEVOBOX_GATEWAY_PORT}/v1/sessions # export AIEVOBOX_GATEWAY_AUTOSTART=0 diff --git a/rl/examples/geo3k_vl/run_slime_generator.sh b/rl/examples/geo3k_vl/run_slime_generator.sh index fb4afbc..4bfc976 100755 --- a/rl/examples/geo3k_vl/run_slime_generator.sh +++ b/rl/examples/geo3k_vl/run_slime_generator.sh @@ -96,6 +96,8 @@ OPTIMIZER_ARGS=( --adam-beta2 0.98 ) +# Store W&B runs locally without uploading them. +export WANDB_MODE=offline WANDB_ARGS=( --use-wandb --wandb-project slime diff --git a/rl/examples/patcheval/README.md b/rl/examples/patcheval/README.md new file mode 100644 index 0000000..bb24541 --- /dev/null +++ b/rl/examples/patcheval/README.md @@ -0,0 +1,171 @@ +# PatchEval Environment + +当前 SAfactory PatchEval 是一个单轮代码修复环境:LLM 根据漏洞描述和局部 +源码生成 Git 补丁,环境在隔离容器中验证补丁并给出 reward。 + +原版 PatchEval 提供两类 baseline: + +- **LLM baseline**:将漏洞知识和相关代码包装进 prompt,由 LLM 直接生成补丁; + LLM 不能使用仓库浏览或编辑工具。 +- **Agent baseline**:SWE-agent、OpenHands 或 Claude Code 等 agent 在容器仓库 + 中运行,可以在有限工具调用次数内搜索、读取和修改完整 codebase。 + +当前 `env/patcheval/runner.py` 只实现了简化的 **LLM baseline** 交互: +一次 prompt、一次模型回答、一次补丁验证。它没有实现 Agent baseline 的工具 +调用、多轮仓库探索和测试反馈。完整仓库虽然存在于 Docker 容器中,但 LLM +只能看到 Runner 放入 prompt 的局部源码。 + +## 1. Environment Data + +`env/patcheval/patcheval_config.yaml` 为每个任务指定 Docker 镜像和 JSONL: + +```yaml +- env_name: patcheval_ + env_image: + env_num: 1 + dataset: ./datasets/.jsonl +``` + +JSONL 每行需要: + +```json +{ + "cve_id": "CVE-YYYY-NNNN", + "work_dir": "/workspace/", + "problem_statement": "漏洞描述和修复目标\nVulnerability Location\n[{\"file_path\":\"path/to/file\",\"start_line\":10,\"end_line\":20}]" +} +``` + +Runner 从 `Vulnerability Location` 解析文件和行号,读取前后各 20 行源码, +总上下文最多 24,000 字符。镜像必须包含漏洞源码、`prepare.sh`、 +`fix-run.sh` 和可选的 `unit_test.sh`。 + +### 能否直接使用原版 PatchEval 数据 + +原始 `patcheval_dataset.json` schema 不能直接使用;但 Agent baseline 的 +`exp_agent/*/dataset.jsonl` 已包含 `cve_id`、`image_name`、`work_dir` +和 `problem_statement`,任务内容可以直接复用。Env 作者只需写一次 adapter, +将 `image_name` 转成 SAfactory YAML 中的 `env_image`,无需重写 Runner 或 +逐条改写任务。 + +## 2. Environment–LLM Interaction + +### Environment 输入 + +Runner 接收 `SimulationStartRequest` JSON,包含 session、任务数据、Gateway +地址、模型名、temperature 和 timeout。 + +### LLM 输入 + +Runner 通过 Gateway 发送一个 user message: + +```text +You are repairing in a local source repository. + +<漏洞描述和漏洞位置> + +Relevant source excerpts: +<带行号的源码片段> + +Return only a git-compatible unified diff beginning with `diff --git`. +Make the smallest secure change that preserves existing behavior. +``` + +### LLM 输出 + +LLM 应只返回以 `diff --git` 开头的 Git unified diff。修改测试、脚本、CI、 +lock 文件及评测基础设施的补丁会被拒绝。 + +### Environment 输出 + +Runner 输出 JSON,包含状态、reward、补丁、失败阶段、PoC/单测结果及日志。 +测试日志最多保留末尾 8,000 字符。 + +## 3. How the Environment Validates a Patch + +LLM 返回的是 unified diff 文本。Runner 从回答中提取 `diff --git ...`, +检查修改路径后,将它写入容器: + +```text +/workspace/fix.patch +``` + +随后环境执行以下步骤。 + +### 3.1 检查补丁能否应用 + +```bash +bash /workspace/prepare.sh +git -C apply --check /workspace/fix.patch +``` + +`git apply --check` 只检查补丁格式和上下文是否匹配,不会修改工作树。检查失败 +表示这个补丁无法用于当前源码,Reward 为 `0`。 + +### 3.2 验证漏洞是否修复 + +```bash +bash /workspace/fix-run.sh +``` + +`fix-run.sh` 由每个 PatchEval Docker 镜像提供。它会应用候选补丁和安全测试, +然后运行该 CVE 对应的 PoC 回归测试。例如 Gogs 的验证逻辑是: + +```bash +cd /workspace/gogs +git apply /workspace/test.patch /workspace/fix.patch +go test -run Test_isRepositoryGitPath +``` + +- 返回码非 0:安全测试失败,Reward 为 `1`。 +- 返回码为 0:漏洞攻击已被阻止,继续运行普通单元测试。 + +因此 `poc_passed=true` 表示安全验证通过,不表示攻击成功。 + +### 3.3 检查原有功能 + +如果镜像存在 `/workspace/unit_test.sh`,环境继续执行: + +```bash +bash /workspace/unit_test.sh +``` + +- 安全测试通过、单元测试失败:Reward 为 `7`。 +- 安全测试和单元测试都通过:Reward 为 `10`。 +- 没有 `unit_test.sh`:安全测试通过后直接给 `10`。 + +Runner 将补丁、失败阶段、PoC/单测结果和命令日志转换成结构化 metrics。 +Gateway 保存 prompt、模型回答、token 和延迟;Evaluator 再把 reward 和测试 +日志合并到 SQLite 的 terminal trajectory 中,供后续分析。 + +## 4. Running + +独立外部 API 评测: + +```bash +export PATCH_EVAL_API_KEY="" +export PATCH_EVAL_API_BASE="http:///v1" +export PATCH_EVAL_MODEL="" +./rl/examples/patcheval/run_external_eval.sh +``` + +训练需要两个进程,并按以下顺序启动。 + +终端 1:先启动 Rollout Buffer Server: + +```bash +cd +./rl/examples/patcheval/run_buffer_server.sh +``` + +终端 2:Buffer Server 就绪后启动 Slime: + +```bash +cd +./rl/examples/patcheval/run_slime_generator.sh +``` + +Slime 启动训练模型和 in-process LLM proxy,然后向 Buffer Server 的 +`/start_rollout` 发起请求。Buffer Server 随后启动 Gateway 和 PatchEval +Launcher;Launcher 创建 Docker 环境并将生成的 trajectory 写入 Buffer 和 +SQLite。两个进程中的任意一个缺失,训练都无法正常生成 rollout。 diff --git a/rl/examples/patcheval/env.sh b/rl/examples/patcheval/env.sh new file mode 100644 index 0000000..f68ce0e --- /dev/null +++ b/rl/examples/patcheval/env.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +export AIEVOBOX_ROOT=/mnt/shared-storage-user/leishanzhe/repo/SAfactory +export STORAGE_TYPE=sqlite +export AIEVOBOX_DB_URL=sqlite:///${AIEVOBOX_ROOT}/rl/examples/patcheval/patcheval.db +export AIEVOBOX_AGENT_CONFIG=${AIEVOBOX_ROOT}/env/patcheval/patcheval_config.yaml +export AIEVOBOX_AGENT_START_CONFIG=${AIEVOBOX_ROOT}/env/patcheval/patcheval_start.yaml +export AIEVOBOX_ENABLE_EVALUATION=1 +export AIEVOBOX_EVALUATION_CONFIG=${AIEVOBOX_ROOT}/evaluator/configs/patcheval_rule_eval.yaml + +# One warm container per locally downloaded CVE image. +export AIEVOBOX_POOL_SIZE=${AIEVOBOX_POOL_SIZE:-5} +export AIEVOBOX_LLM_MAX_CONCURRENCY=${AIEVOBOX_LLM_MAX_CONCURRENCY:-5} +export AIEVOBOX_LLM_PROXY_WORKERS=${AIEVOBOX_LLM_PROXY_WORKERS:-5} +export AIEVOBOX_MAX_STEPS=1 + +export RL_GROUP_SIZE=${RL_GROUP_SIZE:-4} +export RL_EPOCH=${RL_EPOCH:-100} +export RL_OFF_BY_N=${RL_OFF_BY_N:-0} + +export BUFFER_SERVER_HOST=${BUFFER_SERVER_HOST:-127.0.0.1} +export BUFFER_SERVER_PORT=${BUFFER_SERVER_PORT:-18889} +export LLM_PROXY_HOST=${LLM_PROXY_HOST:-127.0.0.1} +export LLM_PROXY_PORT=${LLM_PROXY_PORT:-18890} +export LLM_MAX_LENGTH=${LLM_MAX_LENGTH:-4096} +export LLM_TEMPERATURE=${LLM_TEMPERATURE:-1.0} + +# Docker environments reach the gateway through the current training node IP. +export AIEVOBOX_GATEWAY_HOST="${AIEVOBOX_GATEWAY_HOST:-$(hostname -I | awk '{print $1}')}" +export AIEVOBOX_GATEWAY_PORT=${AIEVOBOX_GATEWAY_PORT:-8000} +export AIEVOBOX_GATEWAY_BASE_URL=http://${AIEVOBOX_GATEWAY_HOST}:${AIEVOBOX_GATEWAY_PORT}/v1/sessions + +export SLIME_ROLLBUF_RESTART_TRAINING=${SLIME_ROLLBUF_RESTART_TRAINING:-True} +export SLIME_N_SAMPLES_PER_PROMPT=$RL_GROUP_SIZE +export SLIME_GLOBAL_BATCH_SIZE=${SLIME_GLOBAL_BATCH_SIZE:-64} +export SLIME_ROLLOUT_BATCH_SIZE=$((SLIME_GLOBAL_BATCH_SIZE / RL_GROUP_SIZE)) diff --git a/rl/examples/patcheval/reward_1_fix_patches.txt b/rl/examples/patcheval/reward_1_fix_patches.txt new file mode 100644 index 0000000..132cec9 --- /dev/null +++ b/rl/examples/patcheval/reward_1_fix_patches.txt @@ -0,0 +1,131 @@ +Reward-1 PatchEval trajectories +Generated from: rl/examples/patcheval/patcheval.db +Meaning: patch passed git apply --check, but fix-run.sh did not pass. + +================================================================================ +session_id: d5747973-5197-4736-9437-08790fbdcd33 +environment: patcheval_cve_2024_25620 +================================================================================ +diff --git a/pkg/chart/metadata.go b/pkg/chart/metadata.go +--- a/pkg/chart/metadata.go ++++ b/pkg/chart/metadata.go +@@ -92,6 +92,11 @@ func (md *Metadata) Validate() error { + + md.Name = sanitizeString(md.Name) + md.Description = sanitizeString(md.Description) ++ // Validate chart name to prevent directory traversal via relative paths ++ if strings.Contains(md.Name, "..") { ++ return ValidationError("chart.metadata.name contains invalid path component '..'") ++ } ++ + md.Home = sanitizeString(md.Home) + md.Icon = sanitizeString(md.Icon) + md.Condition = sanitizeString(md.Condition) + +================================================================================ +session_id: 39aec0ca-24a5-4509-a70f-47bf3b1e7369 +environment: patcheval_cve_2024_25620 +================================================================================ +diff --git a/pkg/chart/metadata.go b/pkg/chart/metadata.go +index 1234567..89abcde 100644 +--- a/pkg/chart/metadata.go ++++ b/pkg/chart/metadata.go +@@ -92,6 +92,12 @@ func (md *Metadata) Validate() error { + + md.Name = sanitizeString(md.Name) + md.Description = sanitizeString(md.Description) ++ ++ // Validate chart name to prevent directory traversal via relative paths ++ if strings.Contains(md.Name, "/") || strings.Contains(md.Name, "..") { ++ return ValidationError("chart.metadata.name contains invalid characters") ++ } ++ + md.Home = sanitizeString(md.Home) + md.Icon = sanitizeString(md.Icon) + md.Condition = sanitizeString(md.Condition) + +================================================================================ +session_id: 2964d831-a5f9-4408-884f-c47500c09e7b +environment: patcheval_cve_2024_25620 +================================================================================ +diff --git a/pkg/chart/metadata.go b/pkg/chart/metadata.go +--- a/pkg/chart/metadata.go ++++ b/pkg/chart/metadata.go +@@ -92,6 +92,10 @@ func (md *Metadata) Validate() error { + + md.Name = sanitizeString(md.Name) + md.Description = sanitizeString(md.Description) ++ // Prevent directory traversal by ensuring name does not contain invalid characters ++ if strings.Contains(md.Name, "/") || strings.Contains(md.Name, "..") { ++ return ValidationError("chart.metadata.name contains invalid characters") ++ } + md.Home = sanitizeString(md.Home) + md.Icon = sanitizeString(md.Icon) + md.Condition = sanitizeString(md.Condition) + +================================================================================ +session_id: 385f57ff-d55d-4bae-b291-6f1366e3e990 +environment: patcheval_cve_2022_1986 +================================================================================ +diff --git a/internal/db/repo_editor.go b/internal/db/repo_editor.go +index 1234567..89abcde 100644 +--- a/internal/db/repo_editor.go ++++ b/internal/db/repo_editor.go +@@ -468,14 +468,14 @@ func isRepositoryGitPath(path string) bool { + return strings.HasSuffix(path, ".git") || + strings.Contains(path, ".git"+string(os.PathSeparator)) || + // Windows treats ".git." the same as ".git" +- strings.HasSuffix(path, ".git.") || +- strings.Contains(path, ".git."+string(os.PathSeparator)) ++ strings.HasSuffix(path, ".git.") || ++ strings.Contains(path, ".git."+string(os.PathSeparator)) + } + + func (repo *Repository) UploadRepoFiles(doer *User, opts UploadRepoFileOptions) error { + if len(opts.Files) == 0 { + return nil + } + +- // 🚨 SECURITY: Prevent uploading files into the ".git" directory ++ // 🚨 SECURITY: Prevent uploading files into the ".git" directory + if isRepositoryGitPath(opts.TreePath) { + return errors.Errorf("bad tree path %q", opts.TreePath) + } + +================================================================================ +session_id: 7424f264-3366-4faf-9246-ac83c450edb4 +environment: patcheval_cve_2024_25620 +================================================================================ +diff --git a/pkg/chart/metadata.go b/pkg/chart/metadata.go +index 1234567..89abcde 100644 +--- a/pkg/chart/metadata.go ++++ b/pkg/chart/metadata.go +@@ -92,6 +92,10 @@ func (md *Metadata) Validate() error { + + md.Name = sanitizeString(md.Name) + md.Description = sanitizeString(md.Description) ++ // Prevent directory traversal by ensuring the chart name does not contain relative paths ++ if strings.Contains(md.Name, "..") { ++ return ValidationError("chart.metadata.name contains invalid characters") ++ } + md.Home = sanitizeString(md.Home) + md.Icon = sanitizeString(md.Icon) + md.Condition = sanitizeString(md.Condition) + +================================================================================ +session_id: 81251c63-95da-47cb-bc43-c8114cd0b899 +environment: patcheval_cve_2021_23376 +================================================================================ +diff --git a/index.js b/index.js +index abc1234..def5678 100644 +--- a/index.js ++++ b/index.js +@@ -219,7 +219,7 @@ trimvideo: (input, start, duration, output) => { + exec( + `ffmpeg -hide_banner -loglevel quiet -ss ${start} -i ${input} -t ${duration} -c copy -y ${output}.mp4`, + (error, stdout, stderr) => { +- if (error) { ++ if (error) { + reject(error); + return; + } diff --git a/rl/examples/patcheval/run_buffer_server.sh b/rl/examples/patcheval/run_buffer_server.sh new file mode 100755 index 0000000..275af64 --- /dev/null +++ b/rl/examples/patcheval/run_buffer_server.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +source "${SCRIPT_DIR}/env.sh" + +export PYTHONPATH="${PYTHONPATH:-}:${AIEVOBOX_ROOT}" +python3 "${AIEVOBOX_ROOT}/rl/buffer_server.py" diff --git a/rl/examples/patcheval/run_external_eval.sh b/rl/examples/patcheval/run_external_eval.sh new file mode 100755 index 0000000..0c35abc --- /dev/null +++ b/rl/examples/patcheval/run_external_eval.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +AIEVOBOX_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." &>/dev/null && pwd)" + +PYTHON_BIN=${PYTHON_BIN:-python3} +PATCH_EVAL_API_BASE=${PATCH_EVAL_API_BASE:-http://35.220.164.252:3888/v1} +PATCH_EVAL_MODEL=${PATCH_EVAL_MODEL:-claude-sonnet-4-5-20250929} +PATCH_EVAL_API_KEY=${PATCH_EVAL_API_KEY:-} +PATCH_EVAL_DB=${PATCH_EVAL_DB:-${SCRIPT_DIR}/patcheval_claude_sonnet_4_5_eval.db} +PATCH_EVAL_POOL_SIZE=${PATCH_EVAL_POOL_SIZE:-5} +PATCH_EVAL_TEMPERATURE=${PATCH_EVAL_TEMPERATURE:-0.7} +GATEWAY_HOST=${GATEWAY_HOST:-$(hostname -I | awk '{print $1}')} +GATEWAY_PORT=${GATEWAY_PORT:-18000} + +if [[ -z "${PATCH_EVAL_API_KEY}" ]]; then + echo "PATCH_EVAL_API_KEY is not set." >&2 + echo "Run: export PATCH_EVAL_API_KEY='your token'" >&2 + exit 1 +fi + +echo "Checking external API credentials and model..." +PATCH_EVAL_API_BASE="${PATCH_EVAL_API_BASE}" \ +PATCH_EVAL_MODEL="${PATCH_EVAL_MODEL}" \ +PATCH_EVAL_API_KEY="${PATCH_EVAL_API_KEY}" \ +"${PYTHON_BIN}" - <<'PY' +import json +import os +import sys +import urllib.error +import urllib.request + +url = os.environ["PATCH_EVAL_API_BASE"].rstrip("/") + "/chat/completions" +payload = json.dumps( + { + "model": os.environ["PATCH_EVAL_MODEL"], + "messages": [{"role": "user", "content": "Reply with OK."}], + "temperature": 0, + "max_tokens": 4, + } +).encode() +request = urllib.request.Request( + url, + data=payload, + headers={ + "Authorization": f"Bearer {os.environ['PATCH_EVAL_API_KEY']}", + "Content-Type": "application/json", + }, +) + +try: + with urllib.request.urlopen(request, timeout=60) as response: + body = json.load(response) +except urllib.error.HTTPError as exc: + message = exc.read().decode("utf-8", errors="replace") + raise SystemExit(f"External API rejected the request ({exc.code}): {message}") +except Exception as exc: + raise SystemExit(f"External API preflight failed: {exc}") + +if not body.get("choices"): + raise SystemExit(f"External API returned no choices: {body}") +print("External API preflight passed.") +PY + +mkdir -p "${AIEVOBOX_ROOT}/logs" +mkdir -p "$(dirname -- "${PATCH_EVAL_DB}")" +# Rebuild before Gateway opens SQLite. Removing an open SQLite DB on shared +# storage leaves its WAL/SHM handles invalid and causes "disk I/O error". +rm -f -- "${PATCH_EVAL_DB}" "${PATCH_EVAL_DB}-wal" "${PATCH_EVAL_DB}-shm" + +GATEWAY_CONFIG="$(mktemp "${TMPDIR:-/tmp}/patcheval-gateway.XXXXXX.json")" +chmod 600 "${GATEWAY_CONFIG}" +GATEWAY_PID="" + +cleanup() { + if [[ -n "${GATEWAY_PID}" ]] && kill -0 "${GATEWAY_PID}" 2>/dev/null; then + kill "${GATEWAY_PID}" 2>/dev/null || true + wait "${GATEWAY_PID}" 2>/dev/null || true + fi + rm -f "${GATEWAY_CONFIG}" +} +trap cleanup EXIT INT TERM + +PATCH_EVAL_API_BASE="${PATCH_EVAL_API_BASE}" \ +PATCH_EVAL_MODEL="${PATCH_EVAL_MODEL}" \ +PATCH_EVAL_API_KEY="${PATCH_EVAL_API_KEY}" \ +PATCH_EVAL_DB="${PATCH_EVAL_DB}" \ +GATEWAY_PORT="${GATEWAY_PORT}" \ +PATCH_EVAL_POOL_SIZE="${PATCH_EVAL_POOL_SIZE}" \ +"${PYTHON_BIN}" - "${GATEWAY_CONFIG}" <<'PY' +import json +import os +import sys + +config = { + "listen_host": "0.0.0.0", + "listen_port": int(os.environ["GATEWAY_PORT"]), + "base_session_path": "/v1/sessions", + "max_steps": -1, + "storage_type": "sqlite", + "storage_config": { + "db_url": f"sqlite:///{os.path.abspath(os.environ['PATCH_EVAL_DB'])}", + }, + "telemetry": { + "mode": "strict", + "loss_policy": "fail_closed", + "capture_payload": "full", + "payload_sample_rate": 1.0, + "redact_sensitive_fields": True, + "batch_size": 20, + "flush_interval_ms": 100, + }, + "request_log": { + "enabled": True, + "path": "logs/patcheval_external_gateway_requests.jsonl", + "max_bytes": 104857600, + "backup_count": 3, + "body_limit_bytes": 0, + }, + "llm_routes": { + os.environ["PATCH_EVAL_MODEL"]: { + "base_url": os.environ["PATCH_EVAL_API_BASE"].rstrip("/"), + "api_key": os.environ["PATCH_EVAL_API_KEY"], + "supports_stream": False, + "max_concurrency": int(os.environ["PATCH_EVAL_POOL_SIZE"]), + }, + }, +} + +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(config, handle) +PY + +cd "${AIEVOBOX_ROOT}" + +SAFACTORY_GATEWAY_LOG_PATH="${AIEVOBOX_ROOT}/logs/patcheval_external_gateway.log" \ + "${PYTHON_BIN}" -m gateway --config "${GATEWAY_CONFIG}" & +GATEWAY_PID=$! + +for _ in $(seq 1 60); do + if ! kill -0 "${GATEWAY_PID}" 2>/dev/null; then + echo "Gateway exited before becoming ready." >&2 + wait "${GATEWAY_PID}" + fi + if curl -fsS --max-time 2 "http://127.0.0.1:${GATEWAY_PORT}/readyz" >/dev/null; then + break + fi + sleep 1 +done + +if ! curl -fsS --max-time 2 "http://127.0.0.1:${GATEWAY_PORT}/readyz" >/dev/null; then + echo "Gateway did not become ready on port ${GATEWAY_PORT}." >&2 + exit 1 +fi + +echo "Starting PatchEval: 5 CVEs, one episode each" +echo "Model: ${PATCH_EVAL_MODEL}" +echo "Results DB: ${PATCH_EVAL_DB}" + +export AIEVOBOX_GATEWAY_CONFIG="${GATEWAY_CONFIG}" +"${PYTHON_BIN}" launcher.py \ + --mode docker \ + --docker-pull-policy always \ + --agent-config env/patcheval/patcheval_config.yaml \ + --agent-start-config env/patcheval/patcheval_start.yaml \ + --gateway-base-url "http://${GATEWAY_HOST}:${GATEWAY_PORT}/v1/sessions" \ + --llm-model "${PATCH_EVAL_MODEL}" \ + --llm-temperature "${PATCH_EVAL_TEMPERATURE}" \ + --db-path "sqlite:///${PATCH_EVAL_DB}" \ + --storage-type sqlite \ + --pool-size "${PATCH_EVAL_POOL_SIZE}" \ + --max-workers "${PATCH_EVAL_POOL_SIZE}" \ + --max-steps 1 \ + --enable-evaluation \ + --evaluation-config evaluator/configs/patcheval_rule_eval.yaml \ + --rl-group-size 0 \ + --rl-epoch 1 diff --git a/rl/examples/patcheval/run_slime_generator.sh b/rl/examples/patcheval/run_slime_generator.sh new file mode 100755 index 0000000..77ace62 --- /dev/null +++ b/rl/examples/patcheval/run_slime_generator.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +source "${SCRIPT_DIR}/env.sh" + +SLIME_HOME=${SLIME_HOME:-/mnt/shared-storage-user/evobox-share/yinzhenyun/slime-env-0.2.3/slime} +PYTHON_BIN=${PYTHON_BIN:-/mnt/shared-storage-user/evobox-share/yinzhenyun/slime-env-0.2.3/bin/python3.12} +RAY_BIN=${RAY_BIN:-/mnt/shared-storage-user/evobox-share/yinzhenyun/slime-env-0.2.3/bin/ray} +HF_CKPT_DIR=${HF_CKPT_DIR:-/mnt/shared-storage-gpfs2/gpfs2-shared-public/huggingface/hub/models--Qwen--Qwen3-8B/snapshots/2069b3fae1114555f3c020c81410e51fa0f656f2} +SAVE_DIR=${SAVE_DIR:-${SCRIPT_DIR}/checkpoints/qwen3-8B} +NUM_GPUS=${NUM_GPUS:-4} + +if [[ ! -d "${HF_CKPT_DIR}" ]]; then + echo "Qwen3-8B checkpoint does not exist: ${HF_CKPT_DIR}" >&2 + exit 1 +fi +mkdir -p "${SAVE_DIR}" + +ROLLOUT_BUFFER_URL="http://${BUFFER_SERVER_HOST}:${BUFFER_SERVER_PORT}" +LLM_PROXY_URL="http://${LLM_PROXY_HOST}:${LLM_PROXY_PORT}" + +source "${SLIME_HOME}/scripts/models/qwen3-8B.sh" + +"${RAY_BIN}" stop --force || true +"${RAY_BIN}" start --head --node-ip-address "${MASTER_ADDR:-127.0.0.1}" --num-gpus "${NUM_GPUS}" --disable-usage-stats + +RUNTIME_ENV_JSON="{\"env_vars\":{\"PYTHONPATH\":\"${SLIME_HOME}:${AIEVOBOX_ROOT}/rl:${AIEVOBOX_ROOT}:/root/Megatron-LM\",\"CUDA_DEVICE_MAX_CONNECTIONS\":\"1\",\"LLM_PROXY_URL\":\"${LLM_PROXY_URL}\",\"ROLLOUT_BUFFER_URL\":\"${ROLLOUT_BUFFER_URL}\"}}" + +"${RAY_BIN}" job submit --address="http://127.0.0.1:8265" \ + --runtime-env-json="${RUNTIME_ENV_JSON}" \ + -- "${PYTHON_BIN}" "${SLIME_HOME}/train.py" \ + --actor-num-nodes 1 \ + --actor-num-gpus-per-node "${NUM_GPUS}" \ + --rollout-num-gpus "${NUM_GPUS}" \ + --colocate \ + "${MODEL_ARGS[@]}" \ + --hf-checkpoint "${HF_CKPT_DIR}" \ + --load "${HF_CKPT_DIR}" \ + --save "${SAVE_DIR}" \ + --save-interval 20 \ + --rollout-function-path rl.slime_generator.generate_rollout \ + --rollout-buffer-url "${ROLLOUT_BUFFER_URL}" \ + --disable-rollout-global-dataset \ + --num-rollout 300 \ + --rollout-batch-size "${SLIME_ROLLOUT_BATCH_SIZE}" \ + --n-samples-per-prompt "${SLIME_N_SAMPLES_PER_PROMPT}" \ + --rollout-max-response-len "${LLM_MAX_LENGTH}" \ + --rollout-temperature "${LLM_TEMPERATURE}" \ + --global-batch-size "${SLIME_GLOBAL_BATCH_SIZE}" \ + --loss-mask-type qwen3 \ + --train-backend megatron \ + --megatron-to-hf-mode bridge \ + --tensor-model-parallel-size "${NUM_GPUS}" \ + --sequence-parallel \ + --pipeline-model-parallel-size 1 \ + --context-parallel-size 1 \ + --recompute-granularity full \ + --recompute-method uniform \ + --recompute-num-layers 1 \ + --use-dynamic-batch-size \ + --max-tokens-per-gpu "${MAX_TOKENS_PER_GPU:-4096}" \ + --calculate-per-token-loss \ + --advantage-estimator grpo \ + --entropy-coef 0.0 \ + --eps-clip 0.2 \ + --eps-clip-high 0.2 \ + --optimizer adam \ + --lr "${LR:-1e-6}" \ + --lr-decay-style constant \ + --weight-decay 0.1 \ + --adam-beta1 0.9 \ + --adam-beta2 0.98 \ + --optimizer-cpu-offload \ + --overlap-cpu-optimizer-d2h-h2d \ + --use-precision-aware-optimizer \ + --rollout-num-gpus-per-engine "${NUM_GPUS}" \ + --sglang-mem-fraction-static 0.75 \ + --sglang-log-level error \ + --sglang-log-level-http error