From 606161025d48cbc25c7852b45d39e097df3fca12 Mon Sep 17 00:00:00 2001 From: nono <97236799+Peanut-Puff@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:37:02 +0800 Subject: [PATCH 1/6] Support Bitfun CLI agent for windows tasks --- .gitignore | 5 + src/harbor/agents/installed/base.py | 6 +- src/harbor/agents/installed/bitfun_cli.py | 391 ++++++++++++++++-- src/harbor/environments/docker/__init__.py | 14 +- src/harbor/environments/docker/docker.py | 15 +- .../unit/agents/installed/test_bitfun_cli.py | 89 +++- tests/unit/test_agent_os_compat.py | 4 +- 7 files changed, 476 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 565f6390d75..90009b7be4c 100644 --- a/.gitignore +++ b/.gitignore @@ -243,3 +243,8 @@ apps/* BitFun/ astropy__astropy-12907/ swe-bench-verified/ + + +jobs-bitfun-hello-world-bat/ +.bitfun-user-hello-world-bat/ +.tmp/ diff --git a/src/harbor/agents/installed/base.py b/src/harbor/agents/installed/base.py index 8e36851d3dd..7dfd663bd04 100644 --- a/src/harbor/agents/installed/base.py +++ b/src/harbor/agents/installed/base.py @@ -10,6 +10,7 @@ from harbor.agents.base import BaseAgent from harbor.environments.base import BaseEnvironment +from harbor.models.task.config import TaskOS from harbor.utils.env import parse_bool_env_value from harbor.utils.templating import render_prompt_template @@ -383,7 +384,10 @@ async def install(self, environment: BaseEnvironment) -> None: pass async def setup(self, environment: BaseEnvironment) -> None: - await environment.exec(command="mkdir -p /installed-agent", user="root") + if environment.os == TaskOS.WINDOWS: + await environment.ensure_dirs(["C:/installed-agent"], chmod=False) + else: + await environment.exec(command="mkdir -p /installed-agent", user="root") setup_dir = self.logs_dir / "setup" setup_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/harbor/agents/installed/bitfun_cli.py b/src/harbor/agents/installed/bitfun_cli.py index 792aabbed26..76a4a7175b5 100644 --- a/src/harbor/agents/installed/bitfun_cli.py +++ b/src/harbor/agents/installed/bitfun_cli.py @@ -8,7 +8,7 @@ import shlex import tempfile from datetime import datetime, timezone -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Any from harbor.agents.installed.base import ( @@ -30,11 +30,15 @@ ToolCall, Trajectory, ) +from harbor.models.task.config import TaskOS from harbor.models.trial.paths import EnvironmentPaths +from harbor.utils.scripts import quote_shell_arg from harbor.utils.trajectory_utils import format_trajectory_json _DEFAULT_BINARY = "/usr/local/bin/bitfun-cli" +_WINDOWS_DEFAULT_BINARY = "C:/bitfun/bitfun-cli.exe" _AGENT_LOG = "/logs/agent/bitfun.txt" +_WINDOWS_AGENT_LOG_NAME = "bitfun.txt" _FAILURE_LOG_MAX_BYTES = 512 * 1024 _FAILURE_LOG_HEAD_BYTES = 8 * 1024 _FAILURE_LOG_TAIL_BYTES = 32 * 1024 @@ -42,10 +46,15 @@ _ATIF_SCHEMA_VERSION = "ATIF-v1.7" _BITFUN_DATA_SUBDIR = "bitfun" # under self.logs_dir PATCH_ARTIFACTS_SUBDIR = "patch" +_DEFAULT_OUTPUT_PATCH_PATH = "/logs/agent/bitfun.patch" _REMOTE_BITFUN_CONFIG_DIR = "/logs/agent/bitfun/config" _REMOTE_APP_CONFIG_REDACTED_PATH = f"{_REMOTE_BITFUN_CONFIG_DIR}/app.redacted.json" _APP_CONFIG_REDACTED_ARTIFACT_PATH = "agent/bitfun/config/app.redacted.json" _REMOTE_CP_BACK_MANIFEST_PATH = "/logs/agent/bitfun/cp-back-manifest.json" +_WINDOWS_PROMPT_FILE_NAME = "bitfun-prompt.txt" +_WINDOWS_RUN_SCRIPT_NAME = "bitfun-run.bat" +_WINDOWS_BITFUN_USER_ROOT = "C:/bitfun-user" +_WINDOWS_BITFUN_HOME = "C:/bitfun-home" _REDACTED_CONFIG_VALUE = "[REDACTED]" _SENSITIVE_CONFIG_KEYS = frozenset( { @@ -246,13 +255,14 @@ class BitfunCli(BaseInstalledAgent): """Run BitFun CLI in non-interactive `exec` mode (binary supplied via bind mount).""" SUPPORTS_ATIF: bool = True + SUPPORTS_WINDOWS: bool = True def __init__( self, logs_dir: Path, binary_path: str = _DEFAULT_BINARY, exec_agent: str = "agentic", - output_patch_path: str | None = "/logs/agent/bitfun.patch", + output_patch_path: str | None = _DEFAULT_OUTPUT_PATCH_PATH, bitfun_config: dict[str, Any] | None = None, *args, **kwargs, @@ -269,19 +279,109 @@ def __init__( def _patch_logs_dir(self) -> Path: return self.logs_dir / PATCH_ARTIFACTS_SUBDIR - @property - def _patch_logs_dir_in_env(self) -> PurePosixPath: - return EnvironmentPaths.agent_dir / PATCH_ARTIFACTS_SUBDIR + @staticmethod + def _task_os(environment: BaseEnvironment) -> TaskOS: + return getattr(environment, "os", TaskOS.LINUX) + + @classmethod + def _env_paths(cls, environment: BaseEnvironment) -> EnvironmentPaths: + return EnvironmentPaths.for_os(cls._task_os(environment)) + + @classmethod + def _agent_log_path(cls, environment: BaseEnvironment) -> str: + env_paths = cls._env_paths(environment) + if cls._task_os(environment) == TaskOS.WINDOWS: + return str(env_paths.agent_dir / _WINDOWS_AGENT_LOG_NAME) + return _AGENT_LOG + + @classmethod + def _prompt_path(cls, environment: BaseEnvironment) -> str: + return str(cls._env_paths(environment).agent_dir / _WINDOWS_PROMPT_FILE_NAME) + + @classmethod + def _run_script_path(cls, environment: BaseEnvironment) -> str: + return str(cls._env_paths(environment).agent_dir / _WINDOWS_RUN_SCRIPT_NAME) + + @classmethod + def _patch_logs_dir_in_env(cls, environment: BaseEnvironment) -> str: + return str(cls._env_paths(environment).agent_dir / PATCH_ARTIFACTS_SUBDIR) + + @classmethod + def _remote_bitfun_config_dir(cls, environment: BaseEnvironment) -> str: + if cls._task_os(environment) == TaskOS.WINDOWS: + return str(cls._env_paths(environment).agent_dir / "bitfun/config") + return _REMOTE_BITFUN_CONFIG_DIR + + @classmethod + def _remote_app_config_redacted_path(cls, environment: BaseEnvironment) -> str: + if cls._task_os(environment) == TaskOS.WINDOWS: + return f"{cls._remote_bitfun_config_dir(environment)}/app.redacted.json" + return _REMOTE_APP_CONFIG_REDACTED_PATH + + @classmethod + def _remote_cp_back_manifest_path(cls, environment: BaseEnvironment) -> str: + if cls._task_os(environment) == TaskOS.WINDOWS: + return str( + cls._env_paths(environment).agent_dir / "bitfun/cp-back-manifest.json" + ) + return _REMOTE_CP_BACK_MANIFEST_PATH + + def _output_patch_path_for(self, environment: BaseEnvironment) -> str | None: + if self._output_patch_path is None: + return None + if ( + self._task_os(environment) == TaskOS.WINDOWS + and self._output_patch_path == _DEFAULT_OUTPUT_PATCH_PATH + ): + return str(self._env_paths(environment).agent_dir / "bitfun.patch") + return self._output_patch_path + + @staticmethod + def _windows_cmd_path(path: str) -> str: + return path.replace("/", "\\").rstrip("\\") + + def _windows_user_root_for(self, environment: BaseEnvironment) -> str: + user_root = self._env_for_run(environment).get( + "BITFUN_USER_ROOT", _WINDOWS_BITFUN_USER_ROOT + ) + return self._windows_cmd_path(user_root) + + def _windows_home_for(self, environment: BaseEnvironment) -> str: + home_root = self._env_for_run(environment).get( + "BITFUN_HOME", _WINDOWS_BITFUN_HOME + ) + return self._windows_cmd_path(home_root) @staticmethod def name() -> str: return AgentName.BITFUN_CLI.value + def _binary_path_for(self, environment: BaseEnvironment) -> str: + if ( + self._task_os(environment) == TaskOS.WINDOWS + and self._binary_path == _DEFAULT_BINARY + ): + return _WINDOWS_DEFAULT_BINARY + return self._binary_path + def get_version_command(self) -> str | None: return f"{shlex.quote(self._binary_path)} --version" async def install(self, environment: BaseEnvironment) -> None: - quoted = shlex.quote(self._binary_path) + binary_path = self._binary_path_for(environment) + if environment.os == TaskOS.WINDOWS: + quoted = quote_shell_arg(binary_path, environment.os) + await self.exec_as_agent( + environment, + command=( + f"if not exist {quoted} " + f'(echo BitFun CLI binary not found: {quoted} & exit /b 1) ' + f"& {quoted} --version" + ), + ) + return + + quoted = shlex.quote(binary_path) await self.exec_as_agent( environment, command=( @@ -1909,8 +2009,14 @@ async def _exec( extra={"user": str(user), "env": merged_env or {}}, ) + exec_command = ( + command + if self._task_os(environment) == TaskOS.WINDOWS + else f"set -o pipefail; {command}" + ) + result = await environment.exec( - command=f"set -o pipefail; {command}", + command=exec_command, user=user, env=merged_env, cwd=cwd, @@ -1950,7 +2056,14 @@ async def _exec( ) return result - def _build_run_shell(self, instruction: str) -> str: + def _build_run_shell( + self, instruction: str, environment: BaseEnvironment | None = None + ) -> str: + _ = instruction + if environment is not None and self._task_os(environment) == TaskOS.WINDOWS: + task_os = self._task_os(environment) + return quote_shell_arg(self._run_script_path(environment), task_os) + bp = shlex.quote(self._binary_path) msg = shlex.quote(instruction) agent_flag = shlex.quote(self._exec_agent) @@ -1978,9 +2091,13 @@ def _build_run_shell(self, instruction: str) -> str: "exit $rc" ) - def _build_register_config_command(self) -> str | None: + def _build_register_config_command( + self, environment: BaseEnvironment | None = None + ) -> str | None: if self._bitfun_config is None: return None + if environment is not None and self._task_os(environment) == TaskOS.WINDOWS: + return None config_json = json.dumps(self._bitfun_config, indent=2) escaped = shlex.quote(config_json) @@ -1990,7 +2107,18 @@ def _build_register_config_command(self) -> str | None: + f"printf '%s\\n' {escaped} > \"$BITFUN_CONFIG_ROOT/config/app.json\"" ) - def _build_app_config_probe_command(self) -> str: + def _build_app_config_probe_command( + self, environment: BaseEnvironment | None = None + ) -> str: + if environment is not None and self._task_os(environment) == TaskOS.WINDOWS: + configured_path = self._windows_user_root_for(environment) + "\\config\\app.json" + return ( + f"echo source={configured_path}& " + f'if exist "{configured_path}" ' + f'(echo exists=true& for %I in ("{configured_path}") do echo size_bytes=%~zI) ' + "else (echo exists=false & echo size_bytes=0)" + ) + return ( _bitfun_config_root_shell() + 'APP_CONFIG_SRC="$BITFUN_CONFIG_ROOT/config/app.json"\n' @@ -2050,6 +2178,73 @@ def _new_app_config_capture_temp_path(self, suffix: str) -> Path: os.close(fd) return Path(path) + def _windows_config_path(self, environment: BaseEnvironment) -> str: + return self._windows_user_root_for(environment) + "\\config\\app.json" + + async def _upload_windows_prompt( + self, instruction: str, environment: BaseEnvironment + ) -> None: + prompt_path = self._new_app_config_capture_temp_path(".prompt.txt") + try: + prompt_path.write_text(instruction, encoding="utf-8") + await environment.upload_file(prompt_path, self._prompt_path(environment)) + finally: + prompt_path.unlink(missing_ok=True) + + def _windows_run_script(self, environment: BaseEnvironment) -> str: + task_os = self._task_os(environment) + binary_path = quote_shell_arg(self._binary_path_for(environment), task_os) + agent_flag = quote_shell_arg(self._exec_agent, task_os) + prompt_path = quote_shell_arg(self._prompt_path(environment), task_os) + agent_log_path = quote_shell_arg(self._agent_log_path(environment), task_os) + patch_path = self._output_patch_path_for(environment) + patch_part = "" + if patch_path: + patch_part = f" --output-patch {quote_shell_arg(patch_path, task_os)}" + + return ( + "@echo off\r\n" + "setlocal EnableExtensions\r\n" + f"echo Harbor BitFun command started> {agent_log_path}\r\n" + f"echo BITFUN_USER_ROOT=%BITFUN_USER_ROOT%>> {agent_log_path}\r\n" + f"echo BITFUN_HOME=%BITFUN_HOME%>> {agent_log_path}\r\n" + f"type {prompt_path} | " + f"{binary_path} exec --agent {agent_flag}{patch_part} --no-title " + f">> {agent_log_path} 2>&1\r\n" + "set \"BITFUN_RC=%ERRORLEVEL%\"\r\n" + f"echo BITFUN_RC=%BITFUN_RC%>> {agent_log_path}\r\n" + "exit /b %BITFUN_RC%\r\n" + ) + + async def _upload_windows_run_script(self, environment: BaseEnvironment) -> None: + script_path = self._new_app_config_capture_temp_path(".bitfun-run.bat") + try: + script_path.write_text( + self._windows_run_script(environment), + encoding="utf-8", + newline="", + ) + await environment.upload_file(script_path, self._run_script_path(environment)) + finally: + script_path.unlink(missing_ok=True) + + async def _register_windows_config(self, environment: BaseEnvironment) -> None: + if self._bitfun_config is None: + return + + config_path = self._new_app_config_capture_temp_path(".app.json") + try: + config_path.write_text( + json.dumps(self._bitfun_config, indent=2) + "\n", + encoding="utf-8", + ) + await environment.upload_file( + config_path, + self._windows_config_path(environment), + ) + finally: + config_path.unlink(missing_ok=True) + async def _upload_app_config_capture_manifest( self, environment: BaseEnvironment, @@ -2065,7 +2260,7 @@ async def _upload_app_config_capture_manifest( manifest: dict[str, Any] = {} try: await environment.download_file( - _REMOTE_CP_BACK_MANIFEST_PATH, + self._remote_cp_back_manifest_path(environment), current_manifest, ) loaded = json.loads(current_manifest.read_text()) @@ -2079,7 +2274,9 @@ async def _upload_app_config_capture_manifest( manifest["app_config"] = app_config updated_manifest.write_text(json.dumps(manifest, indent=2) + "\n") - await environment.upload_file(updated_manifest, _REMOTE_CP_BACK_MANIFEST_PATH) + await environment.upload_file( + updated_manifest, self._remote_cp_back_manifest_path(environment) + ) async def _capture_final_app_config(self, environment: BaseEnvironment) -> None: app_config: dict[str, Any] = { @@ -2094,9 +2291,14 @@ async def _capture_final_app_config(self, environment: BaseEnvironment) -> None: temp_paths: list[Path] = [] try: + probe_prefix = ( + "" + if self._task_os(environment) == TaskOS.WINDOWS + else "set -o pipefail; " + ) probe_result = await environment.exec( - command=f"set -o pipefail; {self._build_app_config_probe_command()}", - env=self._env_for_run(), + command=f"{probe_prefix}{self._build_app_config_probe_command(environment)}", + env=self._env_for_run(environment), ) if probe_result.return_code != 0: raise RuntimeError(f"probe failed with exit {probe_result.return_code}") @@ -2130,17 +2332,27 @@ async def _capture_final_app_config(self, environment: BaseEnvironment) -> None: redacted_path.write_text( json.dumps(redacted_config, indent=2) + "\n" ) - mkdir_result = await environment.exec( - command=f"mkdir -p {shlex.quote(_REMOTE_BITFUN_CONFIG_DIR)}", - env=self._env_for_run(), - ) - if mkdir_result.return_code != 0: - raise RuntimeError( - f"mkdir failed with exit {mkdir_result.return_code}" + remote_config_dir = self._remote_bitfun_config_dir(environment) + if self._task_os(environment) == TaskOS.WINDOWS: + mkdir_result = await environment.ensure_dirs( + [remote_config_dir], chmod=False ) + if mkdir_result is not None and mkdir_result.return_code != 0: + raise RuntimeError( + f"mkdir failed with exit {mkdir_result.return_code}" + ) + else: + mkdir_result = await environment.exec( + command=f"mkdir -p {shlex.quote(remote_config_dir)}", + env=self._env_for_run(environment), + ) + if mkdir_result.return_code != 0: + raise RuntimeError( + f"mkdir failed with exit {mkdir_result.return_code}" + ) await environment.upload_file( redacted_path, - _REMOTE_APP_CONFIG_REDACTED_PATH, + self._remote_app_config_redacted_path(environment), ) app_config.update( { @@ -2221,7 +2433,89 @@ def _log_cp_back_gaps(self) -> None: sessions_root, ) - def _cp_back_command(self) -> str: + def _cp_back_command(self, environment: BaseEnvironment | None = None) -> str: + if environment is not None and self._task_os(environment) == TaskOS.WINDOWS: + task_os = self._task_os(environment) + env_paths = self._env_paths(environment) + bitfun_dir = quote_shell_arg( + str(env_paths.agent_dir / "bitfun"), task_os + ) + bitfun_dir_probe = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/"), task_os + ) + sessions_dir = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/sessions"), task_os + ) + sessions_dir_probe = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/sessions/"), task_os + ) + request_traces_dir = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/request-traces"), task_os + ) + request_traces_dir_probe = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/request-traces/"), task_os + ) + token_usage_dir = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/token_usage"), task_os + ) + cli_logs_dir = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/cli-logs"), task_os + ) + cli_log_path = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/cli.log"), task_os + ) + audit_log_path = quote_shell_arg( + str(env_paths.agent_dir / "bitfun/ai-request-audit.jsonl"), + task_os, + ) + manifest_path = quote_shell_arg( + self._remote_cp_back_manifest_path(environment), task_os + ) + user_root = self._windows_user_root_for(environment) + home_root = self._windows_home_for(environment) + commands = [ + f"if not exist {bitfun_dir_probe} mkdir {bitfun_dir}", + f"if not exist {sessions_dir_probe} mkdir {sessions_dir}", + ( + f'for /d %P in ("{home_root}\\projects\\*") do ' + f'if exist "%P\\sessions" xcopy /E /I /Y "%P\\sessions" {sessions_dir} >nul 2>nul' + ), + ( + f'if exist "{user_root}\\data\\token_usage" ' + f'xcopy /E /I /Y "{user_root}\\data\\token_usage" {token_usage_dir} >nul 2>nul' + ), + ( + f'if exist "{user_root}\\cli-logs" ' + f'xcopy /E /I /Y "{user_root}\\cli-logs" {cli_logs_dir} >nul 2>nul' + ), + ( + f'if exist "{user_root}\\logs\\bitfun-cli.log" ' + f'copy /Y "{user_root}\\logs\\bitfun-cli.log" {cli_log_path} >nul 2>nul' + ), + ( + f'if exist "{user_root}\\logs\\ai-request-audit.jsonl" ' + f'copy /Y "{user_root}\\logs\\ai-request-audit.jsonl" {audit_log_path} >nul 2>nul' + ), + f"if not exist {request_traces_dir_probe} mkdir {request_traces_dir}", + ( + f'for /d %P in ("{home_root}\\projects\\*") do ' + f'if exist "%P\\request-traces" xcopy /E /I /Y "%P\\request-traces" {request_traces_dir} >nul 2>nul' + ), + f'echo {{"windows_cp_back":true}} > {manifest_path}', + ] + patch_path = self._output_patch_path_for(environment) + if patch_path: + patch_q = quote_shell_arg(patch_path, task_os) + meta_q = quote_shell_arg(f"{patch_path}.meta.json", task_os) + commands.append( + f"if exist {patch_q} " + f'(echo {{"present":true,"created_empty_placeholder":false}} > {meta_q}) ' + f"else (type nul > {patch_q} & " + f'echo {{"present":false,"created_empty_placeholder":true}} > {meta_q})' + ) + commands.append("exit /b 0") + return " & ".join(commands) + command = _CP_BACK_COMMAND if self._output_patch_path: patch_path = shlex.quote(self._output_patch_path) @@ -2239,7 +2533,7 @@ def _cp_back_command(self) -> str: """ return command + "exit 0\n" - def _env_for_run(self) -> dict[str, str]: + def _env_for_run(self, environment: BaseEnvironment | None = None) -> dict[str, str]: env: dict[str, str] = {} for key in _ENV_PASSTHROUGH: val = os.environ.get(key) @@ -2248,26 +2542,36 @@ def _env_for_run(self) -> dict[str, str]: for key, val in os.environ.items(): if key.startswith("BITFUN_") and val: env[key] = val + if environment is not None and self._task_os(environment) == TaskOS.WINDOWS: + env.setdefault( + "BITFUN_USER_ROOT", + _WINDOWS_BITFUN_USER_ROOT.replace("/", "\\"), + ) + env.setdefault("BITFUN_HOME", _WINDOWS_BITFUN_HOME.replace("/", "\\")) env.update(self._extra_env) return env async def _capture_repo_baseline(self, environment: BaseEnvironment) -> None: + if self._task_os(environment) == TaskOS.WINDOWS: + return await self.exec_as_root( environment, - command=f"mkdir -p {shlex.quote(self._patch_logs_dir_in_env.as_posix())}", + command=f"mkdir -p {shlex.quote(self._patch_logs_dir_in_env(environment))}", ) await self.exec_as_agent( environment, command=build_repo_baseline_capture_script( - self._patch_logs_dir_in_env.as_posix() + self._patch_logs_dir_in_env(environment) ), ) async def _capture_repo_final_state(self, environment: BaseEnvironment) -> None: + if self._task_os(environment) == TaskOS.WINDOWS: + return await self.exec_as_agent( environment, command=build_repo_final_capture_script( - self._patch_logs_dir_in_env.as_posix() + self._patch_logs_dir_in_env(environment) ), ) @@ -2281,19 +2585,26 @@ async def run( _ = context baseline_captured = False try: - config_command = self._build_register_config_command() - if config_command: - await self.exec_as_agent( - environment, - command=config_command, - env=self._env_for_run(), - ) - await self._capture_repo_baseline(environment) - baseline_captured = True + task_os = self._task_os(environment) + if task_os == TaskOS.WINDOWS: + await self._register_windows_config(environment) + await self._upload_windows_prompt(instruction, environment) + await self._upload_windows_run_script(environment) + else: + config_command = self._build_register_config_command(environment) + if config_command: + await self.exec_as_agent( + environment, + command=config_command, + env=self._env_for_run(environment), + ) + if task_os != TaskOS.WINDOWS: + await self._capture_repo_baseline(environment) + baseline_captured = True await self.exec_as_agent( environment, - command=self._build_run_shell(instruction), - env=self._env_for_run(), + command=self._build_run_shell(instruction, environment), + env=self._env_for_run(environment), ) finally: if baseline_captured: @@ -2306,8 +2617,8 @@ async def run( try: await self.exec_as_agent( environment, - command=self._cp_back_command(), - env=self._env_for_run(), + command=self._cp_back_command(environment), + env=self._env_for_run(environment), ) self._log_cp_back_gaps() except Exception as exc: diff --git a/src/harbor/environments/docker/__init__.py b/src/harbor/environments/docker/__init__.py index b35d115546a..af4955e5685 100644 --- a/src/harbor/environments/docker/__init__.py +++ b/src/harbor/environments/docker/__init__.py @@ -12,9 +12,17 @@ COMPOSE_WINDOWS_KEEPALIVE_PATH = COMPOSE_DIR / "docker-compose-windows-keepalive.yaml" -def write_mounts_compose_file(path: Path, mounts: list[ServiceVolumeConfig]) -> Path: - """Write a compose override that declares services.main.volumes.""" - compose = {"services": {"main": {"volumes": list(mounts)}}} +def write_mounts_compose_file( + path: Path, + mounts: list[ServiceVolumeConfig], + *, + dns: list[str] | None = None, +) -> Path: + """Write a compose override that declares runtime service settings.""" + main: dict[str, object] = {"volumes": list(mounts)} + if dns: + main["dns"] = dns + compose = {"services": {"main": main}} path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(compose, indent=2)) return path diff --git a/src/harbor/environments/docker/docker.py b/src/harbor/environments/docker/docker.py index c6b6c07e6e7..13956a9b59c 100644 --- a/src/harbor/environments/docker/docker.py +++ b/src/harbor/environments/docker/docker.py @@ -128,6 +128,7 @@ def __init__( trial_paths: TrialPaths, task_env_config: EnvironmentConfig, keep_containers: bool = False, + dns: str | list[str] | tuple[str, ...] | None = None, *args, **kwargs, ): @@ -141,6 +142,7 @@ def __init__( ) self._keep_containers = keep_containers + self._dns = self._normalize_dns(dns) self._is_windows_container = task_env_config.os == TaskOS.WINDOWS self._mounts_compose_temp_dir: tempfile.TemporaryDirectory | None = None self._mounts_compose_path: Path | None = None @@ -176,6 +178,17 @@ def __init__( def type() -> EnvironmentType: return EnvironmentType.DOCKER + @staticmethod + def _normalize_dns(dns: str | list[str] | tuple[str, ...] | None) -> list[str] | None: + if dns is None: + return None + if isinstance(dns, str): + servers = [part.strip() for part in dns.split(",")] + else: + servers = [str(part).strip() for part in dns] + servers = [server for server in servers if server] + return servers or None + @property def _uses_compose(self) -> bool: return self._environment_docker_compose_path.exists() @@ -251,7 +264,7 @@ def _write_mounts_compose_file(self) -> Path: self._cleanup_mounts_compose_file() self._mounts_compose_temp_dir = tempfile.TemporaryDirectory() path = Path(self._mounts_compose_temp_dir.name) / "docker-compose-mounts.json" - return write_mounts_compose_file(path, list(self._mounts)) + return write_mounts_compose_file(path, list(self._mounts), dns=self._dns) def _cleanup_mounts_compose_file(self) -> None: if self._mounts_compose_temp_dir is None: diff --git a/tests/unit/agents/installed/test_bitfun_cli.py b/tests/unit/agents/installed/test_bitfun_cli.py index 9976dfb6b91..7dbe2d413a8 100644 --- a/tests/unit/agents/installed/test_bitfun_cli.py +++ b/tests/unit/agents/installed/test_bitfun_cli.py @@ -21,6 +21,7 @@ ) from harbor.models.agent.context import AgentContext from harbor.models.agent.name import AgentName +from harbor.models.task.config import TaskOS from harbor.models.trajectories.agent import Agent from harbor.models.trajectories.final_metrics import FinalMetrics from harbor.models.trajectories.trajectory import Trajectory @@ -585,8 +586,39 @@ def _first_command_containing(commands: list[str], text: str) -> str: return next(command for command in commands if text in command) +def _usable_bash_command() -> list[str] | None: + candidates = [ + shutil.which("bash"), + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + ] + seen: set[str] = set() + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + probe = subprocess.run( + [ + candidate, + "-lc", + "command -v git >/dev/null && command -v mktemp >/dev/null", + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if probe.returncode == 0: + return [candidate, "-lc"] + return None + + def _run_shell(command: str, *, cwd: _Path) -> None: - subprocess.run(["bash", "-lc", command], cwd=cwd, check=True) + if os.name == "nt": + pytest.skip("POSIX repo capture script is exercised on POSIX hosts") + bash_command = _usable_bash_command() + if bash_command is None: + pytest.skip("POSIX bash with git and mktemp is required for repo capture") + subprocess.run([*bash_command, command], cwd=cwd, check=True) class TestRepoPatchCapture: @@ -804,6 +836,16 @@ def test_omits_patch_when_disabled(self, temp_dir): assert "PATCH_PATH=" not in shell assert "--output-patch" not in shell + def test_windows_runs_uploaded_bat_script(self, temp_dir): + agent = BitfunCli(logs_dir=temp_dir) + env = SimpleNamespace(os=TaskOS.WINDOWS) + + shell = agent._build_run_shell("Hi", env) + + assert shell == "C:\\logs\\agent\\bitfun-run.bat" + assert "set -o pipefail" not in shell + assert "tee /logs/agent/bitfun.txt" not in shell + class TestRegisterConfigCommand: def _parse_written_config(self, command: str) -> dict: @@ -1085,6 +1127,7 @@ def test_registered_in_factory(self): async def test_install_verifies_binary(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir, binary_path="/usr/local/bin/bitfun-cli") mock_env = AsyncMock() + mock_env.os = TaskOS.LINUX mock_env.exec.return_value = AsyncMock( return_code=0, stdout="bitfun 0.0.1\n", stderr="" ) @@ -1095,6 +1138,23 @@ async def test_install_verifies_binary(self, temp_dir): assert "chmod a+x" in cmd assert "--version" in cmd + @pytest.mark.asyncio + async def test_install_verifies_windows_binary(self, temp_dir): + agent = BitfunCli(logs_dir=temp_dir) + mock_env = AsyncMock() + mock_env.os = TaskOS.WINDOWS + mock_env.exec.return_value = AsyncMock( + return_code=0, stdout="bitfun 0.0.1\n", stderr="" + ) + + await agent.install(mock_env) + + cmd = mock_env.exec.call_args.kwargs["command"] + assert "C:\\bitfun\\bitfun-cli.exe" in cmd + assert "--version" in cmd + assert "chmod" not in cmd + assert "set -euo pipefail" not in cmd + @pytest.mark.asyncio async def test_run_uses_container_workdir_and_exec(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir, binary_path="/opt/bitfun-cli") @@ -1199,6 +1259,33 @@ async def test_run_writes_bitfun_config_before_exec(self, temp_dir): assert "/logs/agent/bitfun" in cp_cmd assert "APP_CONFIG_SRC" in probe_cmd + @pytest.mark.asyncio + async def test_windows_run_uploads_prompt_and_uses_windows_paths(self, temp_dir): + agent = BitfunCli(logs_dir=temp_dir) + mock_env = AsyncMock() + mock_env.os = TaskOS.WINDOWS + mock_env.exec.return_value = SimpleNamespace( + return_code=0, stdout="", stderr="" + ) + mock_env.upload_file.return_value = None + + await agent.run("Write C:\\app\\greet.bat", mock_env, AgentContext()) + + commands = _exec_commands(mock_env) + run_cmd = _first_command_containing(commands, "bitfun-run.bat") + cp_cmd = _first_command_containing(commands, "windows_cp_back") + probe_cmd = _first_command_containing(commands, "source=") + assert "set -o pipefail" not in run_cmd + assert run_cmd == "C:\\logs\\agent\\bitfun-run.bat" + assert "C:\\logs\\agent\\bitfun" in cp_cmd + assert "C:\\bitfun-user\\config\\app.json" in probe_cmd + assert "%BITFUN_USER_ROOT%" not in probe_cmd + uploaded_targets = [ + call.args[1] for call in mock_env.upload_file.call_args_list if call.args + ] + assert "C:/logs/agent/bitfun-prompt.txt" in uploaded_targets + assert "C:/logs/agent/bitfun-run.bat" in uploaded_targets + @pytest.mark.asyncio async def test_run_attempts_final_app_config_capture_after_cp_back(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir) diff --git a/tests/unit/test_agent_os_compat.py b/tests/unit/test_agent_os_compat.py index 5b6f84c4799..9a98a66434f 100644 --- a/tests/unit/test_agent_os_compat.py +++ b/tests/unit/test_agent_os_compat.py @@ -37,8 +37,8 @@ def installed_agents(self): return agents def test_installed_agents_default_linux_only(self, installed_agents): - # These are the only agents that should support Windows. - windows_agents = {"oracle", "nop"} + # These are the agents that should support Windows. + windows_agents = {"oracle", "nop", "bitfun-cli"} for name, cls in installed_agents.items(): if name.value in windows_agents: assert cls.SUPPORTS_WINDOWS is True, ( From 88d4641b690d017a2ab427a4c3588f7fd60c708d Mon Sep 17 00:00:00 2001 From: nono <97236799+Peanut-Puff@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:37:44 +0800 Subject: [PATCH 2/6] remove dns parameter --- .gitignore | 2 +- src/harbor/environments/docker/__init__.py | 14 +++----------- src/harbor/environments/docker/docker.py | 15 +-------------- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 90009b7be4c..9c8a3a82c67 100644 --- a/.gitignore +++ b/.gitignore @@ -245,6 +245,6 @@ astropy__astropy-12907/ swe-bench-verified/ -jobs-bitfun-hello-world-bat/ +jobs*/ .bitfun-user-hello-world-bat/ .tmp/ diff --git a/src/harbor/environments/docker/__init__.py b/src/harbor/environments/docker/__init__.py index af4955e5685..b35d115546a 100644 --- a/src/harbor/environments/docker/__init__.py +++ b/src/harbor/environments/docker/__init__.py @@ -12,17 +12,9 @@ COMPOSE_WINDOWS_KEEPALIVE_PATH = COMPOSE_DIR / "docker-compose-windows-keepalive.yaml" -def write_mounts_compose_file( - path: Path, - mounts: list[ServiceVolumeConfig], - *, - dns: list[str] | None = None, -) -> Path: - """Write a compose override that declares runtime service settings.""" - main: dict[str, object] = {"volumes": list(mounts)} - if dns: - main["dns"] = dns - compose = {"services": {"main": main}} +def write_mounts_compose_file(path: Path, mounts: list[ServiceVolumeConfig]) -> Path: + """Write a compose override that declares services.main.volumes.""" + compose = {"services": {"main": {"volumes": list(mounts)}}} path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(compose, indent=2)) return path diff --git a/src/harbor/environments/docker/docker.py b/src/harbor/environments/docker/docker.py index 13956a9b59c..c6b6c07e6e7 100644 --- a/src/harbor/environments/docker/docker.py +++ b/src/harbor/environments/docker/docker.py @@ -128,7 +128,6 @@ def __init__( trial_paths: TrialPaths, task_env_config: EnvironmentConfig, keep_containers: bool = False, - dns: str | list[str] | tuple[str, ...] | None = None, *args, **kwargs, ): @@ -142,7 +141,6 @@ def __init__( ) self._keep_containers = keep_containers - self._dns = self._normalize_dns(dns) self._is_windows_container = task_env_config.os == TaskOS.WINDOWS self._mounts_compose_temp_dir: tempfile.TemporaryDirectory | None = None self._mounts_compose_path: Path | None = None @@ -178,17 +176,6 @@ def __init__( def type() -> EnvironmentType: return EnvironmentType.DOCKER - @staticmethod - def _normalize_dns(dns: str | list[str] | tuple[str, ...] | None) -> list[str] | None: - if dns is None: - return None - if isinstance(dns, str): - servers = [part.strip() for part in dns.split(",")] - else: - servers = [str(part).strip() for part in dns] - servers = [server for server in servers if server] - return servers or None - @property def _uses_compose(self) -> bool: return self._environment_docker_compose_path.exists() @@ -264,7 +251,7 @@ def _write_mounts_compose_file(self) -> Path: self._cleanup_mounts_compose_file() self._mounts_compose_temp_dir = tempfile.TemporaryDirectory() path = Path(self._mounts_compose_temp_dir.name) / "docker-compose-mounts.json" - return write_mounts_compose_file(path, list(self._mounts), dns=self._dns) + return write_mounts_compose_file(path, list(self._mounts)) def _cleanup_mounts_compose_file(self) -> None: if self._mounts_compose_temp_dir is None: From 02b60be5c75e6bcb19dc4c64470f5d287eeecdc0 Mon Sep 17 00:00:00 2001 From: nono <97236799+Peanut-Puff@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:54:19 +0800 Subject: [PATCH 3/6] Revert base.py --- src/harbor/agents/installed/base.py | 6 +---- src/harbor/agents/installed/bitfun_cli.py | 33 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/harbor/agents/installed/base.py b/src/harbor/agents/installed/base.py index 7dfd663bd04..8e36851d3dd 100644 --- a/src/harbor/agents/installed/base.py +++ b/src/harbor/agents/installed/base.py @@ -10,7 +10,6 @@ from harbor.agents.base import BaseAgent from harbor.environments.base import BaseEnvironment -from harbor.models.task.config import TaskOS from harbor.utils.env import parse_bool_env_value from harbor.utils.templating import render_prompt_template @@ -384,10 +383,7 @@ async def install(self, environment: BaseEnvironment) -> None: pass async def setup(self, environment: BaseEnvironment) -> None: - if environment.os == TaskOS.WINDOWS: - await environment.ensure_dirs(["C:/installed-agent"], chmod=False) - else: - await environment.exec(command="mkdir -p /installed-agent", user="root") + await environment.exec(command="mkdir -p /installed-agent", user="root") setup_dir = self.logs_dir / "setup" setup_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/harbor/agents/installed/bitfun_cli.py b/src/harbor/agents/installed/bitfun_cli.py index 76a4a7175b5..aee94cfb372 100644 --- a/src/harbor/agents/installed/bitfun_cli.py +++ b/src/harbor/agents/installed/bitfun_cli.py @@ -367,6 +367,39 @@ def _binary_path_for(self, environment: BaseEnvironment) -> str: def get_version_command(self) -> str | None: return f"{shlex.quote(self._binary_path)} --version" + def _version_command_for(self, environment: BaseEnvironment) -> str | None: + binary_path = self._binary_path_for(environment) + if self._task_os(environment) == TaskOS.WINDOWS: + return f"{quote_shell_arg(binary_path, environment.os)} --version" + return self.get_version_command() + + async def setup(self, environment: BaseEnvironment) -> None: + if self._task_os(environment) != TaskOS.WINDOWS: + await super().setup(environment) + return + + await environment.ensure_dirs(["C:/installed-agent"], chmod=False) + + setup_dir = self.logs_dir / "setup" + setup_dir.mkdir(parents=True, exist_ok=True) + + try: + await self.install(environment) + except RuntimeError: + raise + except Exception as exc: + raise RuntimeError(f"Agent install failed: {exc}") from exc + + if self._version is None: + version_cmd = self._version_command_for(environment) + if version_cmd: + try: + version_result = await environment.exec(command=version_cmd) + if version_result.return_code == 0 and version_result.stdout: + self._version = self.parse_version(version_result.stdout) + except Exception: + pass # Version detection is best-effort + async def install(self, environment: BaseEnvironment) -> None: binary_path = self._binary_path_for(environment) if environment.os == TaskOS.WINDOWS: From 221031913bf8f7f12dded7d7b2d31e03cde2d553 Mon Sep 17 00:00:00 2001 From: nono <97236799+Peanut-Puff@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:54:50 +0800 Subject: [PATCH 4/6] revert tests --- .../unit/agents/installed/test_bitfun_cli.py | 89 +------------------ tests/unit/test_agent_os_compat.py | 4 +- 2 files changed, 3 insertions(+), 90 deletions(-) diff --git a/tests/unit/agents/installed/test_bitfun_cli.py b/tests/unit/agents/installed/test_bitfun_cli.py index 7dbe2d413a8..9976dfb6b91 100644 --- a/tests/unit/agents/installed/test_bitfun_cli.py +++ b/tests/unit/agents/installed/test_bitfun_cli.py @@ -21,7 +21,6 @@ ) from harbor.models.agent.context import AgentContext from harbor.models.agent.name import AgentName -from harbor.models.task.config import TaskOS from harbor.models.trajectories.agent import Agent from harbor.models.trajectories.final_metrics import FinalMetrics from harbor.models.trajectories.trajectory import Trajectory @@ -586,39 +585,8 @@ def _first_command_containing(commands: list[str], text: str) -> str: return next(command for command in commands if text in command) -def _usable_bash_command() -> list[str] | None: - candidates = [ - shutil.which("bash"), - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files\Git\usr\bin\bash.exe", - ] - seen: set[str] = set() - for candidate in candidates: - if not candidate or candidate in seen: - continue - seen.add(candidate) - probe = subprocess.run( - [ - candidate, - "-lc", - "command -v git >/dev/null && command -v mktemp >/dev/null", - ], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - if probe.returncode == 0: - return [candidate, "-lc"] - return None - - def _run_shell(command: str, *, cwd: _Path) -> None: - if os.name == "nt": - pytest.skip("POSIX repo capture script is exercised on POSIX hosts") - bash_command = _usable_bash_command() - if bash_command is None: - pytest.skip("POSIX bash with git and mktemp is required for repo capture") - subprocess.run([*bash_command, command], cwd=cwd, check=True) + subprocess.run(["bash", "-lc", command], cwd=cwd, check=True) class TestRepoPatchCapture: @@ -836,16 +804,6 @@ def test_omits_patch_when_disabled(self, temp_dir): assert "PATCH_PATH=" not in shell assert "--output-patch" not in shell - def test_windows_runs_uploaded_bat_script(self, temp_dir): - agent = BitfunCli(logs_dir=temp_dir) - env = SimpleNamespace(os=TaskOS.WINDOWS) - - shell = agent._build_run_shell("Hi", env) - - assert shell == "C:\\logs\\agent\\bitfun-run.bat" - assert "set -o pipefail" not in shell - assert "tee /logs/agent/bitfun.txt" not in shell - class TestRegisterConfigCommand: def _parse_written_config(self, command: str) -> dict: @@ -1127,7 +1085,6 @@ def test_registered_in_factory(self): async def test_install_verifies_binary(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir, binary_path="/usr/local/bin/bitfun-cli") mock_env = AsyncMock() - mock_env.os = TaskOS.LINUX mock_env.exec.return_value = AsyncMock( return_code=0, stdout="bitfun 0.0.1\n", stderr="" ) @@ -1138,23 +1095,6 @@ async def test_install_verifies_binary(self, temp_dir): assert "chmod a+x" in cmd assert "--version" in cmd - @pytest.mark.asyncio - async def test_install_verifies_windows_binary(self, temp_dir): - agent = BitfunCli(logs_dir=temp_dir) - mock_env = AsyncMock() - mock_env.os = TaskOS.WINDOWS - mock_env.exec.return_value = AsyncMock( - return_code=0, stdout="bitfun 0.0.1\n", stderr="" - ) - - await agent.install(mock_env) - - cmd = mock_env.exec.call_args.kwargs["command"] - assert "C:\\bitfun\\bitfun-cli.exe" in cmd - assert "--version" in cmd - assert "chmod" not in cmd - assert "set -euo pipefail" not in cmd - @pytest.mark.asyncio async def test_run_uses_container_workdir_and_exec(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir, binary_path="/opt/bitfun-cli") @@ -1259,33 +1199,6 @@ async def test_run_writes_bitfun_config_before_exec(self, temp_dir): assert "/logs/agent/bitfun" in cp_cmd assert "APP_CONFIG_SRC" in probe_cmd - @pytest.mark.asyncio - async def test_windows_run_uploads_prompt_and_uses_windows_paths(self, temp_dir): - agent = BitfunCli(logs_dir=temp_dir) - mock_env = AsyncMock() - mock_env.os = TaskOS.WINDOWS - mock_env.exec.return_value = SimpleNamespace( - return_code=0, stdout="", stderr="" - ) - mock_env.upload_file.return_value = None - - await agent.run("Write C:\\app\\greet.bat", mock_env, AgentContext()) - - commands = _exec_commands(mock_env) - run_cmd = _first_command_containing(commands, "bitfun-run.bat") - cp_cmd = _first_command_containing(commands, "windows_cp_back") - probe_cmd = _first_command_containing(commands, "source=") - assert "set -o pipefail" not in run_cmd - assert run_cmd == "C:\\logs\\agent\\bitfun-run.bat" - assert "C:\\logs\\agent\\bitfun" in cp_cmd - assert "C:\\bitfun-user\\config\\app.json" in probe_cmd - assert "%BITFUN_USER_ROOT%" not in probe_cmd - uploaded_targets = [ - call.args[1] for call in mock_env.upload_file.call_args_list if call.args - ] - assert "C:/logs/agent/bitfun-prompt.txt" in uploaded_targets - assert "C:/logs/agent/bitfun-run.bat" in uploaded_targets - @pytest.mark.asyncio async def test_run_attempts_final_app_config_capture_after_cp_back(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir) diff --git a/tests/unit/test_agent_os_compat.py b/tests/unit/test_agent_os_compat.py index 9a98a66434f..5b6f84c4799 100644 --- a/tests/unit/test_agent_os_compat.py +++ b/tests/unit/test_agent_os_compat.py @@ -37,8 +37,8 @@ def installed_agents(self): return agents def test_installed_agents_default_linux_only(self, installed_agents): - # These are the agents that should support Windows. - windows_agents = {"oracle", "nop", "bitfun-cli"} + # These are the only agents that should support Windows. + windows_agents = {"oracle", "nop"} for name, cls in installed_agents.items(): if name.value in windows_agents: assert cls.SUPPORTS_WINDOWS is True, ( From b81c16a91335d8f0bab970ee910107841e693d9b Mon Sep 17 00:00:00 2001 From: nono <97236799+Peanut-Puff@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:32:15 +0800 Subject: [PATCH 5/6] Revert "revert tests" This reverts commit 221031913bf8f7f12dded7d7b2d31e03cde2d553. --- .../unit/agents/installed/test_bitfun_cli.py | 89 ++++++++++++++++++- tests/unit/test_agent_os_compat.py | 4 +- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/tests/unit/agents/installed/test_bitfun_cli.py b/tests/unit/agents/installed/test_bitfun_cli.py index 9976dfb6b91..7dbe2d413a8 100644 --- a/tests/unit/agents/installed/test_bitfun_cli.py +++ b/tests/unit/agents/installed/test_bitfun_cli.py @@ -21,6 +21,7 @@ ) from harbor.models.agent.context import AgentContext from harbor.models.agent.name import AgentName +from harbor.models.task.config import TaskOS from harbor.models.trajectories.agent import Agent from harbor.models.trajectories.final_metrics import FinalMetrics from harbor.models.trajectories.trajectory import Trajectory @@ -585,8 +586,39 @@ def _first_command_containing(commands: list[str], text: str) -> str: return next(command for command in commands if text in command) +def _usable_bash_command() -> list[str] | None: + candidates = [ + shutil.which("bash"), + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + ] + seen: set[str] = set() + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + probe = subprocess.run( + [ + candidate, + "-lc", + "command -v git >/dev/null && command -v mktemp >/dev/null", + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if probe.returncode == 0: + return [candidate, "-lc"] + return None + + def _run_shell(command: str, *, cwd: _Path) -> None: - subprocess.run(["bash", "-lc", command], cwd=cwd, check=True) + if os.name == "nt": + pytest.skip("POSIX repo capture script is exercised on POSIX hosts") + bash_command = _usable_bash_command() + if bash_command is None: + pytest.skip("POSIX bash with git and mktemp is required for repo capture") + subprocess.run([*bash_command, command], cwd=cwd, check=True) class TestRepoPatchCapture: @@ -804,6 +836,16 @@ def test_omits_patch_when_disabled(self, temp_dir): assert "PATCH_PATH=" not in shell assert "--output-patch" not in shell + def test_windows_runs_uploaded_bat_script(self, temp_dir): + agent = BitfunCli(logs_dir=temp_dir) + env = SimpleNamespace(os=TaskOS.WINDOWS) + + shell = agent._build_run_shell("Hi", env) + + assert shell == "C:\\logs\\agent\\bitfun-run.bat" + assert "set -o pipefail" not in shell + assert "tee /logs/agent/bitfun.txt" not in shell + class TestRegisterConfigCommand: def _parse_written_config(self, command: str) -> dict: @@ -1085,6 +1127,7 @@ def test_registered_in_factory(self): async def test_install_verifies_binary(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir, binary_path="/usr/local/bin/bitfun-cli") mock_env = AsyncMock() + mock_env.os = TaskOS.LINUX mock_env.exec.return_value = AsyncMock( return_code=0, stdout="bitfun 0.0.1\n", stderr="" ) @@ -1095,6 +1138,23 @@ async def test_install_verifies_binary(self, temp_dir): assert "chmod a+x" in cmd assert "--version" in cmd + @pytest.mark.asyncio + async def test_install_verifies_windows_binary(self, temp_dir): + agent = BitfunCli(logs_dir=temp_dir) + mock_env = AsyncMock() + mock_env.os = TaskOS.WINDOWS + mock_env.exec.return_value = AsyncMock( + return_code=0, stdout="bitfun 0.0.1\n", stderr="" + ) + + await agent.install(mock_env) + + cmd = mock_env.exec.call_args.kwargs["command"] + assert "C:\\bitfun\\bitfun-cli.exe" in cmd + assert "--version" in cmd + assert "chmod" not in cmd + assert "set -euo pipefail" not in cmd + @pytest.mark.asyncio async def test_run_uses_container_workdir_and_exec(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir, binary_path="/opt/bitfun-cli") @@ -1199,6 +1259,33 @@ async def test_run_writes_bitfun_config_before_exec(self, temp_dir): assert "/logs/agent/bitfun" in cp_cmd assert "APP_CONFIG_SRC" in probe_cmd + @pytest.mark.asyncio + async def test_windows_run_uploads_prompt_and_uses_windows_paths(self, temp_dir): + agent = BitfunCli(logs_dir=temp_dir) + mock_env = AsyncMock() + mock_env.os = TaskOS.WINDOWS + mock_env.exec.return_value = SimpleNamespace( + return_code=0, stdout="", stderr="" + ) + mock_env.upload_file.return_value = None + + await agent.run("Write C:\\app\\greet.bat", mock_env, AgentContext()) + + commands = _exec_commands(mock_env) + run_cmd = _first_command_containing(commands, "bitfun-run.bat") + cp_cmd = _first_command_containing(commands, "windows_cp_back") + probe_cmd = _first_command_containing(commands, "source=") + assert "set -o pipefail" not in run_cmd + assert run_cmd == "C:\\logs\\agent\\bitfun-run.bat" + assert "C:\\logs\\agent\\bitfun" in cp_cmd + assert "C:\\bitfun-user\\config\\app.json" in probe_cmd + assert "%BITFUN_USER_ROOT%" not in probe_cmd + uploaded_targets = [ + call.args[1] for call in mock_env.upload_file.call_args_list if call.args + ] + assert "C:/logs/agent/bitfun-prompt.txt" in uploaded_targets + assert "C:/logs/agent/bitfun-run.bat" in uploaded_targets + @pytest.mark.asyncio async def test_run_attempts_final_app_config_capture_after_cp_back(self, temp_dir): agent = BitfunCli(logs_dir=temp_dir) diff --git a/tests/unit/test_agent_os_compat.py b/tests/unit/test_agent_os_compat.py index 5b6f84c4799..9a98a66434f 100644 --- a/tests/unit/test_agent_os_compat.py +++ b/tests/unit/test_agent_os_compat.py @@ -37,8 +37,8 @@ def installed_agents(self): return agents def test_installed_agents_default_linux_only(self, installed_agents): - # These are the only agents that should support Windows. - windows_agents = {"oracle", "nop"} + # These are the agents that should support Windows. + windows_agents = {"oracle", "nop", "bitfun-cli"} for name, cls in installed_agents.items(): if name.value in windows_agents: assert cls.SUPPORTS_WINDOWS is True, ( From 50efc62fc977f3db05b94f297506cdadaa1b3e37 Mon Sep 17 00:00:00 2001 From: nono <97236799+Peanut-Puff@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:49:13 +0800 Subject: [PATCH 6/6] fix(format) fix(test): skip when windows container is not ready --- src/harbor/agents/installed/bitfun_cli.py | 20 +++++++++++-------- src/harbor/viewer/server.py | 4 +++- tests/integration/conftest.py | 10 +++++++--- tests/integration/test_windows_hello_world.py | 6 +++++- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/harbor/agents/installed/bitfun_cli.py b/src/harbor/agents/installed/bitfun_cli.py index aee94cfb372..5acc949942e 100644 --- a/src/harbor/agents/installed/bitfun_cli.py +++ b/src/harbor/agents/installed/bitfun_cli.py @@ -408,7 +408,7 @@ async def install(self, environment: BaseEnvironment) -> None: environment, command=( f"if not exist {quoted} " - f'(echo BitFun CLI binary not found: {quoted} & exit /b 1) ' + f"(echo BitFun CLI binary not found: {quoted} & exit /b 1) " f"& {quoted} --version" ), ) @@ -2144,7 +2144,9 @@ def _build_app_config_probe_command( self, environment: BaseEnvironment | None = None ) -> str: if environment is not None and self._task_os(environment) == TaskOS.WINDOWS: - configured_path = self._windows_user_root_for(environment) + "\\config\\app.json" + configured_path = ( + self._windows_user_root_for(environment) + "\\config\\app.json" + ) return ( f"echo source={configured_path}& " f'if exist "{configured_path}" ' @@ -2244,7 +2246,7 @@ def _windows_run_script(self, environment: BaseEnvironment) -> str: f"type {prompt_path} | " f"{binary_path} exec --agent {agent_flag}{patch_part} --no-title " f">> {agent_log_path} 2>&1\r\n" - "set \"BITFUN_RC=%ERRORLEVEL%\"\r\n" + 'set "BITFUN_RC=%ERRORLEVEL%"\r\n' f"echo BITFUN_RC=%BITFUN_RC%>> {agent_log_path}\r\n" "exit /b %BITFUN_RC%\r\n" ) @@ -2257,7 +2259,9 @@ async def _upload_windows_run_script(self, environment: BaseEnvironment) -> None encoding="utf-8", newline="", ) - await environment.upload_file(script_path, self._run_script_path(environment)) + await environment.upload_file( + script_path, self._run_script_path(environment) + ) finally: script_path.unlink(missing_ok=True) @@ -2470,9 +2474,7 @@ def _cp_back_command(self, environment: BaseEnvironment | None = None) -> str: if environment is not None and self._task_os(environment) == TaskOS.WINDOWS: task_os = self._task_os(environment) env_paths = self._env_paths(environment) - bitfun_dir = quote_shell_arg( - str(env_paths.agent_dir / "bitfun"), task_os - ) + bitfun_dir = quote_shell_arg(str(env_paths.agent_dir / "bitfun"), task_os) bitfun_dir_probe = quote_shell_arg( str(env_paths.agent_dir / "bitfun/"), task_os ) @@ -2566,7 +2568,9 @@ def _cp_back_command(self, environment: BaseEnvironment | None = None) -> str: """ return command + "exit 0\n" - def _env_for_run(self, environment: BaseEnvironment | None = None) -> dict[str, str]: + def _env_for_run( + self, environment: BaseEnvironment | None = None + ) -> dict[str, str]: env: dict[str, str] = {} for key in _ENV_PASSTHROUGH: val = os.environ.get(key) diff --git a/src/harbor/viewer/server.py b/src/harbor/viewer/server.py index 7510c291a89..7cb2d635ade 100644 --- a/src/harbor/viewer/server.py +++ b/src/harbor/viewer/server.py @@ -1813,7 +1813,9 @@ def get_trajectory_stats(job_name: str) -> dict[str, Any]: result["avg_model_calls"] = round(total_model_calls / n_trajectories, 1) if has_token_data and total_input_tokens > 0: - result["cache_hit_rate"] = round(total_cached_tokens / total_input_tokens, 4) + result["cache_hit_rate"] = round( + total_cached_tokens / total_input_tokens, 4 + ) return result diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index cba0e1a4009..e68d344c3ad 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,8 @@ def docker_ready(): On CI runners the Docker service may still be starting when tests begin. This fixture polls ``docker info`` for up to two minutes and skips the - requesting test when Docker never becomes available. + requesting test when Docker never becomes available or is not running in + Windows containers mode. Tests that need Docker should request this fixture explicitly (or apply it via ``pytestmark``). It is intentionally **not** ``autouse`` so that @@ -28,11 +29,14 @@ def docker_ready(): deadline = time.monotonic() + _DOCKER_WAIT_TIMEOUT_SEC while True: result = subprocess.run( - ["docker", "info"], + ["docker", "info", "--format", "{{.OSType}}"], capture_output=True, + text=True, ) - if result.returncode == 0: + if result.returncode == 0 and result.stdout.strip() == "windows": return + if result.returncode == 0: + pytest.skip("Docker daemon is not running in Windows containers mode") if time.monotonic() >= deadline: pytest.skip(f"Docker daemon not ready after {_DOCKER_WAIT_TIMEOUT_SEC}s") time.sleep(_DOCKER_POLL_INTERVAL_SEC) diff --git a/tests/integration/test_windows_hello_world.py b/tests/integration/test_windows_hello_world.py index 07035934e2b..0d57bc0b78b 100644 --- a/tests/integration/test_windows_hello_world.py +++ b/tests/integration/test_windows_hello_world.py @@ -41,7 +41,11 @@ ], ids=["bat"], ) -async def test_windows_hello_world_oracle(task_path: str, tmp_path: Path): +async def test_windows_hello_world_oracle( + task_path: str, + tmp_path: Path, + docker_ready, +): """Run oracle agent on a Windows hello-world task and verify reward=1.0.""" config = TrialConfig( task=TaskConfig(path=Path(task_path)),