diff --git a/.gitattributes b/.gitattributes index c3482579..245dd5cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,4 +2,11 @@ libs/openagent/sandbox/vm/setup/*.sh text eol=lf libs/openagent/sandbox/vm/setup/steps/*.sh text eol=lf libs/hexagent/sandbox/vm/setup/*.sh text eol=lf libs/hexagent/sandbox/vm/setup/steps/*.sh text eol=lf +libs/openagent/sandbox/vm/setup_lite/*.sh text eol=lf +libs/openagent/sandbox/vm/setup_lite/steps/*.sh text eol=lf +libs/hexagent/sandbox/vm/setup_lite/*.sh text eol=lf +libs/hexagent/sandbox/vm/setup_lite/steps/*.sh text eol=lf libs/openagent/sandbox/vm/wsl/prebuilt/openagent-prebuilt.tar filter=lfs diff=lfs merge=lfs -text +libs/hexagent_demo/electron/resources/wsl/*.msi filter=lfs diff=lfs merge=lfs -text +libs/hexagent_demo/electron/*.msi filter=lfs diff=lfs merge=lfs -text +libs/hexagent_demo/electron/resources/wsl/ubuntu-base-24.04-amd64.tar.gz filter=lfs diff=lfs merge=lfs -text diff --git a/libs/hexagent/hexagent/computer/local/_wsl.py b/libs/hexagent/hexagent/computer/local/_wsl.py index b62909a3..4f2dc500 100644 --- a/libs/hexagent/hexagent/computer/local/_wsl.py +++ b/libs/hexagent/hexagent/computer/local/_wsl.py @@ -45,6 +45,13 @@ _PLATFORM = sys.platform +def _decode_wsl_output(raw: bytes) -> str: + """Decode WSL output that may be UTF-16-LE on some Windows builds.""" + if raw[:2] == b"\xff\xfe" or b"\x00" in raw: + return raw.decode("utf-16-le", errors="replace").replace("\x00", "") + return raw.decode("utf-8", errors="replace") + + def _resolve_wsl_exe() -> str | None: """Return a usable ``wsl.exe`` path. @@ -453,8 +460,8 @@ async def shell( msg = f"timed out after {timeout}s" raise WslError(msg) from None - stdout = stdout_bytes.decode("utf-8", errors="replace").removesuffix("\n") - stderr = stderr_bytes.decode("utf-8", errors="replace").removesuffix("\n") + stdout = _decode_wsl_output(stdout_bytes).removesuffix("\n") + stderr = _decode_wsl_output(stderr_bytes).removesuffix("\n") rc: int = process.returncode if process.returncode is not None else -1 return CLIResult( @@ -530,11 +537,11 @@ async def _run_wsl(self, *cmd: str, timeout: float = 300) -> str: # noqa: ASYNC raise WslError(msg) from None if proc.returncode != 0: - stderr = stderr_bytes.decode("utf-8", errors="replace").strip() + stderr = _decode_wsl_output(stderr_bytes).strip() msg = f"wsl.exe failed (exit {proc.returncode}): {stderr}" raise WslError(msg) - return stdout_bytes.decode("utf-8", errors="replace") + return _decode_wsl_output(stdout_bytes) async def _apply_bind_mounts(self) -> None: """Apply all bind mounts from ``mounts.json`` inside the distro. diff --git a/libs/hexagent/hexagent/harness/environment.py b/libs/hexagent/hexagent/harness/environment.py index 9aa63886..b6f6f578 100644 --- a/libs/hexagent/hexagent/harness/environment.py +++ b/libs/hexagent/hexagent/harness/environment.py @@ -6,6 +6,8 @@ from __future__ import annotations +import logging +import shlex from datetime import datetime from typing import TYPE_CHECKING @@ -14,6 +16,8 @@ if TYPE_CHECKING: from hexagent.computer.base import Computer +logger = logging.getLogger(__name__) + class EnvironmentResolver: """Detects runtime environment properties via a Computer. @@ -38,6 +42,47 @@ def __init__(self, computer: Computer) -> None: """ self._computer = computer + async def _probe_datetime(self) -> datetime: + """Best-effort datetime probe that never raises. + + Returns: + Timezone-aware datetime when possible; falls back to UTC now. + """ + # Primary probe: timezone-aware ISO-8601 from shell date. + probe = await self._computer.run("date '+%Y-%m-%dT%H:%M:%S%z'") + raw = (probe.stdout or "").strip() + if raw: + try: + return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + try: + return datetime.strptime(raw[:19], "%Y-%m-%dT%H:%M:%S") # noqa: DTZ007 + except ValueError: + logger.warning("Unparseable environment datetime probe: %r", raw) + + # Secondary probe: Python inside guest (if available). + py_probe = await self._computer.run( + "python3 -c \"from datetime import datetime as d; print(d.now().astimezone().strftime('%Y-%m-%dT%H:%M:%S%z'))\"" + ) + py_raw = (py_probe.stdout or "").strip() + if py_raw: + try: + return datetime.strptime(py_raw, "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + try: + return datetime.strptime(py_raw[:19], "%Y-%m-%dT%H:%M:%S") # noqa: DTZ007 + except ValueError: + logger.warning("Unparseable python datetime probe: %r", py_raw) + + logger.warning( + "Environment datetime probes failed; falling back to UTC now. date.stdout=%r date.stderr=%r python3.stdout=%r python3.stderr=%r", + probe.stdout, + probe.stderr, + py_probe.stdout, + py_probe.stderr, + ) + return datetime.now().astimezone() + async def resolve(self) -> EnvironmentContext: """Detect environment properties from the computer. @@ -46,19 +91,19 @@ async def resolve(self) -> EnvironmentContext: """ # Single batched command: 6 values separated by a unique delimiter. delimiter = "___ENV___" + qd = shlex.quote(delimiter) cmd = ( - f'printf "%s\\n" ' - f'"$(pwd)" ' - f'"{delimiter}" ' - f'"$(git rev-parse --is-inside-work-tree 2>/dev/null || echo false)" ' - f'"{delimiter}" ' - f'"$(uname -s | tr "[:upper:]" "[:lower:]")" ' - f'"{delimiter}" ' - f'"$(basename "$SHELL")" ' - f'"{delimiter}" ' - f'"$(uname -sr)" ' - f'"{delimiter}" ' - f"\"$(date '+%Y-%m-%dT%H:%M:%S%z')\"" + "pwd; " + f"printf '%s\\n' {qd}; " + "(git rev-parse --is-inside-work-tree 2>/dev/null || echo false); " + f"printf '%s\\n' {qd}; " + "uname -s | tr '[:upper:]' '[:lower:]'; " + f"printf '%s\\n' {qd}; " + 'basename "${SHELL:-bash}"; ' + f"printf '%s\\n' {qd}; " + "uname -sr; " + f"printf '%s\\n' {qd}; " + "date '+%Y-%m-%dT%H:%M:%S%z'" ) result = await self._computer.run(cmd) parts = result.stdout.strip().split(delimiter) @@ -69,16 +114,22 @@ async def resolve(self) -> EnvironmentContext: while len(values) < _EXPECTED_PARTS: values.append("") - # Parse into a timezone-aware datetime. - # Shell outputs ISO 8601 with numeric offset, e.g. "2026-02-14T10:30:00-0800". + # Parse into a datetime. Shell usually outputs timezone-aware ISO 8601, + # e.g. "2026-02-14T10:30:00-0800". If missing, probe separately. raw_dt = values[5] - if not raw_dt: - msg = f"Environment shell returned empty datetime (raw output: {result.stdout!r})" - raise ValueError(msg) - try: - now = datetime.strptime(raw_dt, "%Y-%m-%dT%H:%M:%S%z") - except ValueError: - now = datetime.strptime(raw_dt[:19], "%Y-%m-%dT%H:%M:%S") # noqa: DTZ007 + if raw_dt: + try: + now = datetime.strptime(raw_dt, "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + now = datetime.strptime(raw_dt[:19], "%Y-%m-%dT%H:%M:%S") # noqa: DTZ007 + else: + logger.warning( + "Environment shell returned empty datetime; falling back probe. stdout=%r stderr=%r exit=%s", + result.stdout, + result.stderr, + result.exit_code, + ) + now = await self._probe_datetime() return EnvironmentContext( working_dir=values[0], diff --git a/libs/hexagent/hexagent/tools/ui/present_to_user.py b/libs/hexagent/hexagent/tools/ui/present_to_user.py index b9b5df39..7efeb826 100644 --- a/libs/hexagent/hexagent/tools/ui/present_to_user.py +++ b/libs/hexagent/hexagent/tools/ui/present_to_user.py @@ -184,6 +184,11 @@ def _build_case_block() -> str: done """.format(case_arms=_build_case_block()) # noqa: UP032 — can't use f-string; bash ${} conflicts +# WSL/bash is sensitive to CRLF in inline scripts (can break function +# definitions/quoting with opaque parse errors). Normalize to LF at runtime so +# behavior is stable regardless of host checkout EOL settings. +_SCRIPT_BODY_LF = _SCRIPT_BODY.replace("\r\n", "\n").replace("\r", "\n") + def _build_command(filepaths: list[str], output_dir: str) -> str: """Build a bash command that processes all file paths. @@ -200,7 +205,7 @@ def _build_command(filepaths: list[str], output_dir: str) -> str: A shell command string safe for ``Computer.run()``. """ quoted_args = " ".join(shlex.quote(p) for p in [output_dir, *filepaths]) - return f"bash -c {shlex.quote(_SCRIPT_BODY)} _ {quoted_args}" + return f"bash -c {shlex.quote(_SCRIPT_BODY_LF)} _ {quoted_args}" class PresentToUserTool(BaseAgentTool[PresentToUserToolParams]): diff --git a/libs/hexagent/tests/unit_tests/harness/test_environment.py b/libs/hexagent/tests/unit_tests/harness/test_environment.py index 9681f0a6..f93ac826 100644 --- a/libs/hexagent/tests/unit_tests/harness/test_environment.py +++ b/libs/hexagent/tests/unit_tests/harness/test_environment.py @@ -6,8 +6,6 @@ from datetime import UTC, datetime from unittest.mock import AsyncMock -import pytest - from hexagent.harness.environment import EnvironmentResolver from hexagent.types import CLIResult @@ -36,6 +34,12 @@ def _mock_computer(stdout: str) -> AsyncMock: return computer +def _mock_computer_sequence(results: list[CLIResult]) -> AsyncMock: + computer = AsyncMock() + computer.run = AsyncMock(side_effect=results) + return computer + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -83,20 +87,47 @@ async def test_datetime_without_timezone_fallback(self) -> None: assert env.today_date.year == 2026 assert env.today_date.tzinfo is None - async def test_empty_datetime_raises(self) -> None: - """Empty datetime must raise — it indicates a broken shell probe.""" - computer = _mock_computer(_make_stdout(date="")) - with pytest.raises(ValueError, match="empty datetime"): - await EnvironmentResolver(computer).resolve() + async def test_empty_datetime_falls_back_to_secondary_probe(self) -> None: + """Empty datetime in batched output falls back to a secondary date probe.""" + computer = _mock_computer_sequence( + [ + CLIResult(stdout=_make_stdout(date=""), stderr="", exit_code=0), + CLIResult(stdout="2026-03-13T10:30:00+0000", stderr="", exit_code=0), + ] + ) + env = await EnvironmentResolver(computer).resolve() + + assert env.today_date.tzinfo is not None + assert env.today_date == datetime(2026, 3, 13, 10, 30, 0, tzinfo=UTC) - async def test_pads_missing_parts_raises(self) -> None: - """When stdout has fewer delimiters, missing date field raises.""" - # Only cwd and git — missing platform, shell, os_version, date + async def test_pads_missing_parts_falls_back_to_secondary_probe(self) -> None: + """When stdout has fewer delimiters, resolver still recovers via date probe.""" + # Only cwd and git; missing platform, shell, os_version, date stdout = f"/home/user\n{_DELIM}\ntrue" - computer = _mock_computer(stdout) + computer = _mock_computer_sequence( + [ + CLIResult(stdout=stdout, stderr="", exit_code=0), + CLIResult(stdout="2026-03-13T10:30:00+0000", stderr="", exit_code=0), + ] + ) + env = await EnvironmentResolver(computer).resolve() + + assert env.today_date.tzinfo is not None + assert env.today_date.year == 2026 + + async def test_all_datetime_probes_fail_uses_local_time(self) -> None: + """If both date probes fail, resolver should still return a usable context.""" + computer = _mock_computer_sequence( + [ + CLIResult(stdout=_make_stdout(date=""), stderr="", exit_code=0), + CLIResult(stdout="", stderr="date: command not found", exit_code=127), + CLIResult(stdout="", stderr="python3: command not found", exit_code=127), + ] + ) + env = await EnvironmentResolver(computer).resolve() - with pytest.raises(ValueError, match="empty datetime"): - await EnvironmentResolver(computer).resolve() + assert env.today_date.tzinfo is not None + assert env.today_date.year >= 2020 async def test_darwin_platform(self) -> None: computer = _mock_computer(_make_stdout(platform="darwin", os_version="Darwin 25.3.0")) diff --git a/libs/hexagent/tests/unit_tests/tools/ui/test_present_to_user.py b/libs/hexagent/tests/unit_tests/tools/ui/test_present_to_user.py index b8f21263..3e68db1f 100644 --- a/libs/hexagent/tests/unit_tests/tools/ui/test_present_to_user.py +++ b/libs/hexagent/tests/unit_tests/tools/ui/test_present_to_user.py @@ -160,6 +160,11 @@ def test_quotes_special_characters(self) -> None: cmd = _build_command(["/path/with spaces/file.txt"], "/out") assert "'/path/with spaces/file.txt'" in cmd + def test_embedded_script_normalized_to_lf(self) -> None: + """Command string should not carry CR characters into bash -c payload.""" + cmd = _build_command(["/a.txt"], "/out") + assert "\r" not in cmd + # --------------------------------------------------------------------------- # _EXT_MIME_MAP / generated script tests diff --git a/libs/hexagent_demo/backend/hexagent_api/routes/setup.py b/libs/hexagent_demo/backend/hexagent_api/routes/setup.py index 61d9a89d..4a2137d0 100644 --- a/libs/hexagent_demo/backend/hexagent_api/routes/setup.py +++ b/libs/hexagent_demo/backend/hexagent_api/routes/setup.py @@ -184,6 +184,7 @@ def _lima_status() -> dict[str, object]: _WSL_PREBUILT_CANDIDATES = ( "hexagent-prebuilt.tar", "hexagent.tar", + "ubuntu-base-24.04-amd64.tar.gz", ) @@ -222,6 +223,18 @@ def _combine_wsl_output(stdout_b: bytes | None, stderr_b: bytes | None) -> str: return err or out +def _looks_like_wsl_usage(msg: str) -> bool: + """Return True when output is the generic WSL usage/help banner.""" + text = msg.strip() + low = text.lower() + return ( + "usage: wsl" in low + or "usage: wsl.exe" in low + or "用法: wsl" in text + or "用法: wsl.exe" in text + ) + + def _looks_like_missing_wsl_disk(msg: str) -> bool: text = msg.lower() return ( @@ -333,12 +346,23 @@ async def _wsl_instance_status() -> str | None: def _wsl_prebuilt_tar_path() -> Path | None: - """Return bundled prebuilt WSL rootfs tar if present.""" - prebuilt_dir = vm_setup_dir().parent / "wsl" / "prebuilt" - for name in _WSL_PREBUILT_CANDIDATES: - candidate = prebuilt_dir / name - if candidate.is_file(): - return candidate + """Return an offline WSL rootfs archive if present. + + Search order: + 1. Backend-bundled VM assets (PyInstaller ``sandbox/vm/wsl/prebuilt``) + 2. Electron extraResources path from ``HEXAGENT_WSL_OFFLINE_DIR`` (if set) + """ + candidate_dirs: list[Path] = [vm_setup_dir().parent / "wsl" / "prebuilt"] + + offline_dir = os.environ.get("HEXAGENT_WSL_OFFLINE_DIR", "").strip() + if offline_dir: + candidate_dirs.append(Path(offline_dir)) + + for prebuilt_dir in candidate_dirs: + for name in _WSL_PREBUILT_CANDIDATES: + candidate = prebuilt_dir / name + if candidate.is_file(): + return candidate return None @@ -469,7 +493,9 @@ def _wsl_status() -> dict[str, object]: "installed": False, "path": wsl, "managed": False, - "reason": last_err or "WSL runtime is not available", + # Some Windows builds print only usage text for unsupported probes. + # Treat that as "not installed yet" (pending) instead of hard error. + **({} if _looks_like_wsl_usage(last_err) else {"reason": last_err or "WSL runtime is not available"}), } @@ -664,6 +690,22 @@ def _vm_status() -> dict[str, object]: return {"supported": False, "backend": None, "installed": False, "reason": f"No VM backend for {sys.platform}"} +def _runtime_vm_backend() -> str: + """Resolve the active VM backend for branch dispatch. + + Prefer the backend reported by ``_vm_status()`` so behavior stays aligned + with the setup API surface. Fall back to platform defaults defensively. + """ + backend = str(_vm_status().get("backend") or "") + if backend in {"wsl", "lima"}: + return backend + if sys.platform == "win32": + return "wsl" + if sys.platform == "darwin": + return "lima" + return "" + + # --------------------------------------------------------------------------- # Endpoints — generic /vm, frontend doesn't need to know Lima vs WSL # --------------------------------------------------------------------------- @@ -947,10 +989,59 @@ async def _communicate_with_heartbeat( self._emit("progress", {"step": step, "message": f"{message} (elapsed {elapsed}s){extra}"}) async def _run(self, **kwargs: object) -> None: - if sys.platform == "win32": + backend = _runtime_vm_backend() + if backend == "wsl": await self._run_wsl() return - await self._run_lima() + if backend == "lima": + await self._run_lima() + return + self._emit("error", {"message": f"VM build is not supported on backend: {backend or sys.platform}"}) + self._status = "error" + self._error = "Unsupported backend" + + async def _start_wsl_instance( + self, + wsl_exe: str, + *, + step: str, + message: str, + retries_on_missing_disk: int = 0, + ) -> tuple[bool, str]: + """Start hexagent distro and optionally retry transient missing-disk errors.""" + attempts = max(1, retries_on_missing_disk + 1) + for attempt in range(1, attempts + 1): + proc = await asyncio.create_subprocess_exec( + wsl_exe, "-d", _WSL_INSTANCE, "--", "echo", "ok", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + self._process = proc + out_b, err_b = await self._communicate_with_heartbeat( + proc, + step=step, + message=message, + ) + if proc.returncode == 0: + return True, "" + + err = _combine_wsl_output(out_b, err_b) + is_missing_disk = _looks_like_missing_wsl_disk(err) + if is_missing_disk and attempt < attempts: + wait_s = min(2 * attempt, 5) + self._emit( + "progress", + { + "step": step, + "message": f"WSL disk not ready yet, retrying start in {wait_s}s " + f"({attempt}/{attempts - 1})...", + }, + ) + await asyncio.sleep(wait_s) + continue + + return False, err or f"WSL start failed (exit {proc.returncode})" + return False, "WSL start failed" async def _run_lima(self) -> None: # Ensure limactl has the virtualization entitlement before any VM @@ -1033,23 +1124,17 @@ async def _run_wsl(self) -> None: if _wsl_state_equals(status, _WSL_STOPPED_STATES): self._emit("progress", {"step": "starting", "message": "Starting existing WSL distro..."}) - proc = await asyncio.create_subprocess_exec( - wsl_exe, "-d", _WSL_INSTANCE, "--", "echo", "ok", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - self._process = proc - stdout_b, stderr_b = await self._communicate_with_heartbeat( - proc, + ok, err = await self._start_wsl_instance( + wsl_exe, step="starting", message="Starting existing WSL distro...", + retries_on_missing_disk=1, ) - if proc.returncode == 0: + if ok: self._emit("done", {"message": "WSL distro started successfully"}) self._status = "done" return else: - err = _combine_wsl_output(stdout_b, stderr_b) if _looks_like_missing_wsl_disk(err): self._emit("progress", {"step": "creating", "message": "Detected broken WSL distro disk. Recreating HexAgent distro..."}) proc_unreg = await asyncio.create_subprocess_exec( @@ -1106,25 +1191,19 @@ async def _run_wsl(self) -> None: return self._emit("progress", {"step": "starting", "message": "Starting imported HexAgent WSL distro..."}) - proc_start = await asyncio.create_subprocess_exec( - wsl_exe, "-d", _WSL_INSTANCE, "--", "echo", "ok", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - self._process = proc_start - out_b, err_b = await self._communicate_with_heartbeat( - proc_start, + ok, err = await self._start_wsl_instance( + wsl_exe, step="starting", message="Starting imported HexAgent WSL distro...", + retries_on_missing_disk=3, ) - if proc_start.returncode == 0: + if ok: self._emit("done", {"message": "WSL distro imported from bundled image and started successfully"}) self._status = "done" else: - err = _combine_wsl_output(out_b, err_b) - self._emit("error", {"message": err or f"WSL start failed (exit {proc_start.returncode})"}) + self._emit("error", {"message": err}) self._status = "error" - self._error = f"exit {proc_start.returncode}" + self._error = err return # Fallback: bootstrap from Ubuntu export. @@ -1209,25 +1288,19 @@ async def _run_wsl(self) -> None: return self._emit("progress", {"step": "starting", "message": "Starting HexAgent WSL distro..."}) - proc_start = await asyncio.create_subprocess_exec( - wsl_exe, "-d", _WSL_INSTANCE, "--", "echo", "ok", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - self._process = proc_start - out_b, err_b = await self._communicate_with_heartbeat( - proc_start, + ok, err = await self._start_wsl_instance( + wsl_exe, step="starting", message="Starting HexAgent WSL distro...", + retries_on_missing_disk=3, ) - if proc_start.returncode == 0: + if ok: self._emit("done", {"message": "WSL distro created and started successfully"}) self._status = "done" else: - err = _combine_wsl_output(out_b, err_b) - self._emit("error", {"message": err or f"WSL start failed (exit {proc_start.returncode})"}) + self._emit("error", {"message": err}) self._status = "error" - self._error = f"exit {proc_start.returncode}" + self._error = err async def _stream_stderr(self, proc: asyncio.subprocess.Process) -> str: """Read limactl stderr line-by-line and emit progress events. @@ -1260,8 +1333,8 @@ async def _stream_stderr(self, proc: asyncio.subprocess.Process) -> str: # Provision Manager — runs setup.sh inside the VM # --------------------------------------------------------------------------- -_SETUP_MARKER_DIR = "/var/lib/hexagent/setup" -_SETUP_LOG_DIR = "/var/log/hexagent/setup" +_SETUP_MARKER_DIRS = ("/var/lib/hexagent/setup", "/var/lib/openagent/setup") +_SETUP_LOG_DIRS = ("/var/log/hexagent/setup", "/var/log/openagent/setup") _SETUP_VM_DIR = "/tmp/hexagent-setup" # Step IDs that setup.sh discovers (must match filenames in steps/) @@ -1281,10 +1354,16 @@ class _ProvisionManager(_ProcessManager): """Manages setup.sh execution inside the Lima VM.""" async def _run(self, **kwargs: object) -> None: - if sys.platform == "win32": + backend = _runtime_vm_backend() + if backend == "wsl": await self._run_wsl(**kwargs) return - await self._run_lima(**kwargs) + if backend == "lima": + await self._run_lima(**kwargs) + return + self._emit("error", {"message": f"VM provisioning is not supported on backend: {backend or sys.platform}"}) + self._status = "error" + self._error = "Unsupported backend" async def _run_lima(self, **kwargs: object) -> None: force = bool(kwargs.get("force", False)) @@ -1418,7 +1497,9 @@ async def _run_wsl(self, **kwargs: object) -> None: setup_vm_dir_quoted = shlex.quote(_SETUP_VM_DIR) rc, _, err = await _wsl_shell( f"rm -rf {setup_vm_dir_quoted} && mkdir -p {setup_vm_dir_quoted} && " - f"cp -r {setup_wsl_quoted}/. {setup_vm_dir_quoted}/", + f"cp -r {setup_wsl_quoted}/. {setup_vm_dir_quoted}/ && " + f"find {setup_vm_dir_quoted} -type f -name '*.sh' -exec sed -i 's/\\r$//' {{}} + && " + f"find {setup_vm_dir_quoted} -type f -name '*.sh' -exec chmod +x {{}} +", timeout=60, user="root", ) @@ -1488,19 +1569,27 @@ def _handle_setup_line(self, line: str) -> None: async def check_markers(self) -> dict[str, object]: """Read VM-side marker files to determine provision state.""" - if sys.platform == "win32": + backend = _runtime_vm_backend() + if backend == "wsl": instance_status = await _wsl_instance_status() shell = lambda cmd: _wsl_shell(cmd, user="root") if not _wsl_distro_ready_for_cowork(instance_status): return {"provisioned": False, "steps_done": [], "total_steps": len(_PROVISION_STEPS)} - else: + elif backend == "lima": instance_status = await _lima_instance_status() shell = _lima_shell if instance_status != "Running": return {"provisioned": False, "steps_done": [], "total_steps": len(_PROVISION_STEPS)} + else: + return {"provisioned": False, "steps_done": [], "total_steps": len(_PROVISION_STEPS)} - rc, stdout, _ = await shell(f"ls {_SETUP_MARKER_DIR}/*.done 2>/dev/null || true") - if rc != 0 or not stdout.strip(): + stdout = "" + for marker_dir in _SETUP_MARKER_DIRS: + rc, out, _ = await shell(f"ls {marker_dir}/*.done 2>/dev/null || true") + if rc == 0 and out.strip(): + stdout = out + break + if not stdout.strip(): return {"provisioned": False, "steps_done": [], "total_steps": len(_PROVISION_STEPS)} done_files = stdout.strip().splitlines() @@ -1518,12 +1607,21 @@ async def check_markers(self) -> dict[str, object]: async def get_log(self) -> str: """Fetch the latest setup log from the VM.""" - shell = (lambda cmd, timeout=15: _wsl_shell(cmd, timeout=timeout, user="root")) if sys.platform == "win32" else _lima_shell - rc, stdout, _ = await shell( - f"ls -t {_SETUP_LOG_DIR}/setup-*.log 2>/dev/null | head -1 | xargs cat 2>/dev/null | tail -500", - timeout=15, - ) - return stdout if rc == 0 else "" + backend = _runtime_vm_backend() + if backend == "wsl": + shell = lambda cmd, timeout=15: _wsl_shell(cmd, timeout=timeout, user="root") + elif backend == "lima": + shell = _lima_shell + else: + return "" + for log_dir in _SETUP_LOG_DIRS: + rc, stdout, _ = await shell( + f"ls -t {log_dir}/setup-*.log 2>/dev/null | head -1 | xargs cat 2>/dev/null | tail -500", + timeout=15, + ) + if rc == 0 and stdout.strip(): + return stdout + return "" # --------------------------------------------------------------------------- @@ -1573,10 +1671,13 @@ async def get_build_status() -> dict[str, object]: mgr = _get_build_manager() result = dict(mgr.status_dict()) if mgr._status in ("idle", "done", "error"): - if sys.platform == "win32": + backend = _runtime_vm_backend() + if backend == "wsl": result["vm_state"] = await _wsl_instance_status() - else: + elif backend == "lima": result["vm_state"] = await _lima_instance_status() + else: + result["vm_state"] = None return result diff --git a/libs/hexagent_demo/electron/main.js b/libs/hexagent_demo/electron/main.js index f7a5cf5e..c31b4f6a 100644 --- a/libs/hexagent_demo/electron/main.js +++ b/libs/hexagent_demo/electron/main.js @@ -107,6 +107,9 @@ function waitForHealth(port, retries = 30, interval = 500) { async function spawnBackend() { const port = IS_DEV ? 8000 : await findFreePort(); backendPort = port; + const wslOfflineDir = IS_DEV + ? path.join(__dirname, "resources", "wsl") + : path.join(process.resourcesPath, "wsl"); if (IS_DEV) { const backendDir = path.join(__dirname, "..", "backend"); @@ -167,6 +170,7 @@ async function spawnBackend() { HOST: "127.0.0.1", PORT: String(port), HEXAGENT_DATA_DIR: userDataDir, + HEXAGENT_WSL_OFFLINE_DIR: wslOfflineDir, }, }); } @@ -415,9 +419,14 @@ try { // ── Window ─────────────────────────────────────────────────────────────────── function createWindow() { + const winIconPath = IS_DEV + ? path.join(__dirname, "resources", "icon.ico") + : path.join(process.resourcesPath, "app-icon.ico"); + mainWindow = new BrowserWindow({ width: 1200, height: 800, + icon: fs.existsSync(winIconPath) ? winIconPath : undefined, webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, diff --git a/libs/hexagent_demo/electron/package.json b/libs/hexagent_demo/electron/package.json index 75fd69b7..322157a4 100644 --- a/libs/hexagent_demo/electron/package.json +++ b/libs/hexagent_demo/electron/package.json @@ -1,7 +1,7 @@ { "name": "hexagent", "version": "0.0.1", - "description": "HexAgent Desktop App", + "description": "ClawWork Desktop App", "main": "main.js", "scripts": { "dev": "ELECTRON_DEV=1 electron .", @@ -22,7 +22,7 @@ }, "build": { "appId": "com.hexagent.app", - "productName": "HexAgent", + "productName": "ClawWork", "directories": { "buildResources": "resources", "output": "dist" @@ -57,13 +57,23 @@ ] } ], - "icon": "resources/icon.icns", + "icon": "icon.icns", "category": "public.app-category.developer-tools", "identity": null, "signIgnore": [] }, "afterPack": "./scripts/afterPack.js", "win": { + "extraResources": [ + { + "from": "resources/wsl/", + "to": "wsl" + }, + { + "from": "resources/icon.ico", + "to": "app-icon.ico" + } + ], "target": [ { "target": "nsis", @@ -72,7 +82,7 @@ ] } ], - "icon": "resources/icon.ico", + "icon": "icon.ico", "signAndEditExecutable": false }, "nsis": { diff --git a/libs/hexagent_demo/electron/resources/icon.ico b/libs/hexagent_demo/electron/resources/icon.ico index af153efb..2b3b0647 100644 Binary files a/libs/hexagent_demo/electron/resources/icon.ico and b/libs/hexagent_demo/electron/resources/icon.ico differ diff --git a/libs/hexagent_demo/electron/resources/installer.nsh b/libs/hexagent_demo/electron/resources/installer.nsh index 7609d096..6a228a6f 100644 --- a/libs/hexagent_demo/electron/resources/installer.nsh +++ b/libs/hexagent_demo/electron/resources/installer.nsh @@ -1,3 +1,9 @@ +!macro customInstall + ; Force desktop shortcut to use bundled ClawWork icon, independent of EXE icon resource. + Delete "$DESKTOP\ClawWork.lnk" + CreateShortCut "$DESKTOP\ClawWork.lnk" "$INSTDIR\ClawWork.exe" "" "$INSTDIR\resources\app-icon.ico" 0 +!macroend + !macro customUnInstall RMDir /r "$PROFILE\.hexagent" !macroend diff --git a/libs/hexagent_demo/electron/resources/wsl/.gitkeep b/libs/hexagent_demo/electron/resources/wsl/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/libs/hexagent_demo/electron/resources/wsl/.gitkeep @@ -0,0 +1 @@ + diff --git a/libs/hexagent_demo/electron/resources/wsl/ubuntu-base-24.04-amd64.tar.gz b/libs/hexagent_demo/electron/resources/wsl/ubuntu-base-24.04-amd64.tar.gz new file mode 100644 index 00000000..24e69628 --- /dev/null +++ b/libs/hexagent_demo/electron/resources/wsl/ubuntu-base-24.04-amd64.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1e67ef7b17a6300e136118bd1dc04725009cb376c1aad10abcf8cd453628d58 +size 29989394 diff --git a/libs/hexagent_demo/electron/resources/wsl/wsl.2.6.3.0.x64.msi b/libs/hexagent_demo/electron/resources/wsl/wsl.2.6.3.0.x64.msi new file mode 100644 index 00000000..3a090704 --- /dev/null +++ b/libs/hexagent_demo/electron/resources/wsl/wsl.2.6.3.0.x64.msi @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:562c79aba6ce9b6e9170f069d31e3717f10d76dd8bfbee39b07eae0ca4a02ca0 +size 247123968 diff --git a/libs/hexagent_demo/electron/scripts/build-all.ps1 b/libs/hexagent_demo/electron/scripts/build-all.ps1 index d26a5905..3c3f9e01 100644 --- a/libs/hexagent_demo/electron/scripts/build-all.ps1 +++ b/libs/hexagent_demo/electron/scripts/build-all.ps1 @@ -3,7 +3,8 @@ $ErrorActionPreference = 'Stop' $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ElectronDir = Resolve-Path "$ScriptDir\.." $Target = if ($args.Count -gt 0) { $args[0] } else { 'win' } -$EmbedWslPrebuilt = ($env:OPENAGENT_EMBED_WSL_PREBUILT -eq "1") +$EmbedWslPrebuilt = ($env:HEXAGENT_EMBED_WSL_PREBUILT -eq "1" -or $env:OPENAGENT_EMBED_WSL_PREBUILT -eq "1") +$PrepareOfflineWsl = ($env:HEXAGENT_PREPARE_OFFLINE_WSL -ne "0") Write-Host '=========================================' Write-Host ' HexAgent Desktop - Build ('$Target')' @@ -25,10 +26,18 @@ Write-Host '' Write-Host '[2/3] Skipping electron dependencies (already installed)...' Set-Location $ElectronDir -if ($Target -eq 'win' -and $EmbedWslPrebuilt) { - Write-Host '' - Write-Host '[2.2/3] Exporting prebuilt WSL VM image for offline-ready package...' - & "$ScriptDir\prepare-wsl-prebuilt.ps1" +if ($Target -eq 'win') { + if ($PrepareOfflineWsl) { + Write-Host '' + Write-Host '[2.1/3] Preparing offline WSL installer assets...' + & "$ScriptDir\prepare-wsl-offline-assets.ps1" + } + + if ($EmbedWslPrebuilt) { + Write-Host '' + Write-Host '[2.2/3] Exporting prebuilt WSL VM image for offline-ready package...' + & "$ScriptDir\prepare-wsl-prebuilt.ps1" + } } Write-Host '' diff --git a/libs/hexagent_demo/electron/scripts/prepare-wsl-offline-assets.ps1 b/libs/hexagent_demo/electron/scripts/prepare-wsl-offline-assets.ps1 new file mode 100644 index 00000000..1346a101 --- /dev/null +++ b/libs/hexagent_demo/electron/scripts/prepare-wsl-offline-assets.ps1 @@ -0,0 +1,127 @@ +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ElectronDir = Resolve-Path "$ScriptDir\.." +$OfflineDir = Join-Path $ElectronDir "resources\wsl" + +$WslMsiName = "wsl.2.6.3.0.x64.msi" +$UbuntuRootfsName = "ubuntu-base-24.04-amd64.tar.gz" +$UseCnMirrors = ($env:HEXAGENT_USE_CN_MIRRORS -ne "0") + +if ($env:OS -ne "Windows_NT") { + Write-Host "Skipping offline WSL asset preparation: non-Windows environment." + exit 0 +} + +New-Item -ItemType Directory -Force -Path $OfflineDir | Out-Null + +function Ensure-DownloadedFile { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string[]]$Urls, + [long]$MinBytes = 1024, + [long]$MaxBytes = 0, + [string]$Kind = "generic" + ) + + $target = Join-Path $OfflineDir $Name + function Test-AssetValidity { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$AssetKind, + [long]$AssetMinBytes = 1024, + [long]$AssetMaxBytes = 0 + ) + if (-not (Test-Path $Path)) { return $false } + $item = Get-Item $Path -ErrorAction SilentlyContinue + if (-not $item) { return $false } + if ($item.Length -lt $AssetMinBytes) { return $false } + if ($AssetMaxBytes -gt 0 -and $item.Length -gt $AssetMaxBytes) { return $false } + + try { + $fs = [System.IO.File]::OpenRead($Path) + try { + $header = New-Object byte[] 4 + [void]$fs.Read($header, 0, 4) + } finally { + $fs.Dispose() + } + + if ($AssetKind -eq "msi") { + # MSI is a CFB container: D0 CF 11 E0 + return ($header[0] -eq 0xD0 -and $header[1] -eq 0xCF -and $header[2] -eq 0x11 -and $header[3] -eq 0xE0) + } + if ($AssetKind -eq "tar_gz") { + # Gzip magic: 1F 8B + return ($header[0] -eq 0x1F -and $header[1] -eq 0x8B) + } + return $true + } catch { + return $false + } + } + + if (Test-Path $target) { + if (Test-AssetValidity -Path $target -AssetKind $Kind -AssetMinBytes $MinBytes -AssetMaxBytes $MaxBytes) { + $sizeMb = [math]::Round(((Get-Item $target).Length / 1MB), 1) + Write-Host "==> Reusing cached offline asset: $Name (${sizeMb} MB)" + return + } + Write-Host "==> Cached file is invalid, redownloading: $Name" + Remove-Item -Force $target + } + + $lastError = $null + foreach ($url in $Urls) { + if (-not $url) { continue } + Write-Host "==> Downloading $Name from $url ..." + try { + Invoke-WebRequest -Uri $url -OutFile $target + if (Test-AssetValidity -Path $target -AssetKind $Kind -AssetMinBytes $MinBytes -AssetMaxBytes $MaxBytes) { + $sizeMb = [math]::Round(((Get-Item $target).Length / 1MB), 1) + Write-Host "==> Ready: $Name (${sizeMb} MB)" + return + } + Write-Host "==> Downloaded file failed validation, trying next mirror..." + if (Test-Path $target) { Remove-Item -Force $target } + } catch { + $lastError = $_ + Write-Host "==> Download failed from $url, trying next mirror..." + if (Test-Path $target) { Remove-Item -Force $target } + } + } + + if (-not (Test-Path $target)) { + if ($lastError) { + throw $lastError + } + throw "All download URLs failed for $Name" + } +} + +$wslMsiUrls = @() +$rootfsUrls = @() + +if ($env:HEXAGENT_WSL_MSI_URL) { + $wslMsiUrls += $env:HEXAGENT_WSL_MSI_URL +} +if ($env:HEXAGENT_UBUNTU_ROOTFS_URL) { + $rootfsUrls += $env:HEXAGENT_UBUNTU_ROOTFS_URL +} + +if ($UseCnMirrors) { + # Optional acceleration mirror for GitHub download. + $wslMsiUrls += "https://gh.llkk.cc/https://github.com/microsoft/WSL/releases/download/2.6.3/$WslMsiName" + # Smaller Ubuntu base rootfs (~28MB) for offline package size control. + $rootfsUrls += "https://mirrors.ustc.edu.cn/ubuntu-cdimage/ubuntu-base/releases/24.04/release/ubuntu-base-24.04.4-base-amd64.tar.gz" + $rootfsUrls += "https://mirror.sjtu.edu.cn/ubuntu-cdimage/ubuntu-base/releases/24.04/release/ubuntu-base-24.04.4-base-amd64.tar.gz" +} + +# Official fallback URLs. +$wslMsiUrls += "https://github.com/microsoft/WSL/releases/download/2.6.3/$WslMsiName" +$rootfsUrls += "https://cdimage.ubuntu.com/ubuntu-base/releases/24.04/release/ubuntu-base-24.04.4-base-amd64.tar.gz" + +Ensure-DownloadedFile -Name $WslMsiName -Urls $wslMsiUrls -Kind "msi" -MinBytes 10485760 +Ensure-DownloadedFile -Name $UbuntuRootfsName -Urls $rootfsUrls -Kind "tar_gz" -MinBytes 20971520 -MaxBytes 83886080 + +Write-Host "==> Offline WSL assets are ready in: $OfflineDir" diff --git a/libs/hexagent_demo/electron/scripts/prepare-wsl-prebuilt.ps1 b/libs/hexagent_demo/electron/scripts/prepare-wsl-prebuilt.ps1 index 33396e6e..011d7395 100644 --- a/libs/hexagent_demo/electron/scripts/prepare-wsl-prebuilt.ps1 +++ b/libs/hexagent_demo/electron/scripts/prepare-wsl-prebuilt.ps1 @@ -3,14 +3,29 @@ $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $HexagentRoot = Resolve-Path "$ScriptDir\..\..\.." $PrebuiltDir = Join-Path $HexagentRoot "hexagent\sandbox\vm\wsl\prebuilt" -$PrebuiltTar = Join-Path $PrebuiltDir "openagent-prebuilt.tar" -$DistroName = "openagent" +$PrebuiltTar = Join-Path $PrebuiltDir "hexagent-prebuilt.tar" +$LegacyPrebuiltTar = Join-Path $PrebuiltDir "openagent-prebuilt.tar" +$DistroName = if ($env:HEXAGENT_WSL_DISTRO) { $env:HEXAGENT_WSL_DISTRO } else { "hexagent" } +$ForceRebuild = ($env:HEXAGENT_FORCE_REBUILD_WSL_PREBUILT -eq "1") if ($env:OS -ne "Windows_NT") { Write-Host "Skipping WSL prebuilt export: non-Windows environment." exit 0 } +New-Item -ItemType Directory -Force -Path $PrebuiltDir | Out-Null + +if ((-not (Test-Path $PrebuiltTar)) -and (Test-Path $LegacyPrebuiltTar)) { + Write-Host "==> Found legacy prebuilt tar name, renaming to hexagent-prebuilt.tar ..." + Move-Item -Force $LegacyPrebuiltTar $PrebuiltTar +} + +if ((Test-Path $PrebuiltTar) -and (-not $ForceRebuild)) { + $sizeMb = [math]::Round(((Get-Item $PrebuiltTar).Length / 1MB), 1) + Write-Host "==> Reusing existing WSL prebuilt image: $PrebuiltTar (${sizeMb} MB)" + exit 0 +} + if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { throw "wsl command not found. Install WSL first." } @@ -18,10 +33,9 @@ if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { Write-Host "==> Ensuring distro '$DistroName' can start..." & wsl -d $DistroName -- echo ok | Out-Null if ($LASTEXITCODE -ne 0) { - throw "WSL distro '$DistroName' is not available/runnable. Please initialize VM Instance first." + throw "WSL distro '$DistroName' is not available/runnable. Please initialize VM Instance first, or provide an existing hexagent-prebuilt.tar." } -New-Item -ItemType Directory -Force -Path $PrebuiltDir | Out-Null if (Test-Path $PrebuiltTar) { Remove-Item -Force $PrebuiltTar } diff --git a/libs/hexagent_demo/electron/wsl.2.6.3.0.x64.msi b/libs/hexagent_demo/electron/wsl.2.6.3.0.x64.msi new file mode 100644 index 00000000..3a090704 --- /dev/null +++ b/libs/hexagent_demo/electron/wsl.2.6.3.0.x64.msi @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:562c79aba6ce9b6e9170f069d31e3717f10d76dd8bfbee39b07eae0ca4a02ca0 +size 247123968 diff --git a/libs/hexagent_demo/frontend/src/vmSetup.tsx b/libs/hexagent_demo/frontend/src/vmSetup.tsx index 4b31321d..4b9d95f9 100644 --- a/libs/hexagent_demo/frontend/src/vmSetup.tsx +++ b/libs/hexagent_demo/frontend/src/vmSetup.tsx @@ -109,6 +109,7 @@ export function VMSetupProvider({ children }: { children: ReactNode }) { const [provStepMsg, setProvStepMsg] = useState>({}); const [provLog, setProvLog] = useState(null); const autoBootstrapTriggeredRef = useRef(false); + const autoProvisionTriggeredRef = useRef(false); const [autoBootstrapping, setAutoBootstrapping] = useState(false); // SSE abort controllers (kept alive across renders, never aborted on unmount) @@ -550,6 +551,18 @@ export function VMSetupProvider({ children }: { children: ReactNode }) { } }, [autoBootstrapping, phase1, phase2]); + // Windows-first run: once VM instance is ready, auto start dependency provision + // in background so users don't need to click "Install in background" manually. + useEffect(() => { + if (!IS_WINDOWS) return; + if (autoProvisionTriggeredRef.current) return; + if (phase1 !== "done" || phase2 !== "done") return; + if (phase3 !== "pending") return; + autoProvisionTriggeredRef.current = true; + notify("VM instance is ready. Starting system dependency installation in background...", "info"); + doStartProvision(false); + }, [phase1, phase2, phase3]); // eslint-disable-line react-hooks/exhaustive-deps + const value: VMSetupContextValue = { vmStatus, autoBootstrapping,