Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
9eda4ab
fix(demo): stabilize Electron Windows packaging and build flow
xuelin-cell Mar 20, 2026
56466c2
merge: integrate feat/agent-loop into feat/demo-electron-packaging-fixes
xuelin-cell Mar 23, 2026
4fb80a3
merge: resolve feat/agent-loop into feat/demo-electron-packaging-fixes
xuelin-cell Mar 23, 2026
f3f358d
mod: windows wsl and vm install GUI and fix bind user folder.
xuelin-cell Mar 24, 2026
bb80362
Merge remote-tracking branch 'origin/feat/agent-loop' into feat/demo-…
xuelin-cell Mar 24, 2026
a269b8c
fix(demo): remove duplicate VM backend declarations in settings
xuelin-cell Mar 24, 2026
107e4a5
mod:fix windows sandbox problem
xuelin-cell Mar 24, 2026
9471123
fix(windows): harden WSL cowork readiness and source distro detection
Mar 24, 2026
ed05118
Fix cowork remount latency and add immediate request loading feedback
xuelin-cell Mar 24, 2026
44684e4
fix(vm): harden WSL setup flow and rebuild prebuilt pipeline
xuelin-cell Mar 25, 2026
47501c9
fix(vm-win): harden WSL startup/user creation and add regressions
xuelin-cell Mar 26, 2026
efeaead
fix(backend): preserve actionable cowork errors and improve WSL setup…
xuelin-cell Mar 26, 2026
cf2b545
feat(electron): add WSL prerequisite precheck IPC and typed bridge
xuelin-cell Mar 26, 2026
163fda7
feat(frontend): improve VM setup flow and add global reboot-required …
xuelin-cell Mar 26, 2026
3b4c892
docs: add windows regression checklist and vm prebuilt inventory
xuelin-cell Mar 26, 2026
3f1a6f9
feat: add uninstallation script to clean up user data
Mar 27, 2026
d16536e
chore(repo): track wsl prebuilt tar with LFS and ignore dist zip
xuelin-cell Mar 26, 2026
e71c991
Merge branch 'main' into feat/demo-electron-packaging-fixes
xuelin-cell Mar 27, 2026
5c0c878
fix(windows-vm): improve onboarding recovery and setup status
xuelin-cell Mar 27, 2026
077da7d
review(pr13): address admin feedback, add setup_lite, fix CI failures
an7tang Mar 27, 2026
019d321
merge: integrate latest main into feat/demo-electron-packaging-fixes
an7tang Mar 27, 2026
c7b9224
fix(tests): update environment tests to expect ValueError on empty da…
an7tang Mar 27, 2026
02d8b25
security: fix CodeQL alerts for clear-text key storage and exception …
an7tang Mar 27, 2026
3b6d55a
fix(wsl): eliminate redundant wsl.exe resolution and preserve excepti…
an7tang Mar 27, 2026
d17cf8d
security(codeql): avoid exposing exception details in setup routes
xuelin-cell Mar 27, 2026
55957d9
Merge remote-tracking branch 'origin/main' into feat/demo-electron-pa…
xuelin-cell Mar 27, 2026
d826126
fix(cowork): harden WSL shell decoding and environment probing
xuelin-cell Mar 27, 2026
32b2fd0
feat(setup): improve WSL instance/provision reliability on Windows
xuelin-cell Mar 27, 2026
34d8d44
feat(electron): bundle offline WSL assets and build pipeline
xuelin-cell Mar 27, 2026
7817b95
Merge origin/main into feat/demo-electron-packaging-fixes
xuelin-cell Mar 27, 2026
d4ede0c
fix(build): reuse hexagent-prebuilt tar during offline packaging
xuelin-cell Mar 28, 2026
a3e0ad5
chore(branding): rename desktop app to ClawWork and update Windows icon
xuelin-cell Mar 28, 2026
4c483a5
fix(branding): point builder icon path to buildResources and regenera…
xuelin-cell Mar 28, 2026
089bd4a
fix(windows-branding): force ClawWork icon for desktop shortcut and w…
xuelin-cell Mar 28, 2026
9137f49
style(lint): apply ruff formatting for environment probe changes
xuelin-cell Mar 28, 2026
100a5b5
Merge remote-tracking branch 'origin/main' into feat/demo-electron-pa…
xuelin-cell Mar 28, 2026
d90d3eb
fix mcp httpx timeout
xuelin-cell Mar 28, 2026
48155ab
fix wsl io decoding and vm file staging
xuelin-cell Mar 28, 2026
6c17a92
improve windows setup flow and desktop restart UX
xuelin-cell Mar 28, 2026
61f0196
add email and pptx skill bundles
xuelin-cell Mar 28, 2026
0f809ff
fix: resolve lint and CodeQL issues in PR checks
xuelin-cell Mar 28, 2026
6d2765f
fix: harden html script/style tag filtering regex
xuelin-cell Mar 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions libs/hexagent/hexagent/computer/local/_wsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,37 @@


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")
"""Decode WSL output that may mix UTF-16-LE and UTF-8 bytes.

Some Windows builds emit UTF-16-LE diagnostics from ``wsl.exe`` and then
append plain UTF-8 stderr from the invoked shell in the same stream.
"""
if not raw:
return ""

# Handle BOM-prefixed UTF-16-LE while preserving the remaining bytes for
# mixed-stream recovery below.
if raw.startswith(b"\xff\xfe"):
raw = raw[2:]

# Fast path: regular UTF-8 output.
if b"\x00" not in raw:
return raw.decode("utf-8", errors="replace")

# Mixed-path: decode the UTF-16-LE prefix up to the last NUL byte, then
# decode any trailing bytes as UTF-8 (common bash stderr tail).
last_nul = raw.rfind(b"\x00")
split = last_nul + 1
if split % 2 != 0:
split += 1

head = raw[:split]
tail = raw[split:]

text = head.decode("utf-16-le", errors="replace").replace("\x00", "")
if tail:
text += tail.decode("utf-8", errors="replace")
return text


def _resolve_wsl_exe() -> str | None:
Expand Down
36 changes: 26 additions & 10 deletions libs/hexagent/hexagent/computer/local/vm.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import shlex
import sys
import uuid
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING

import petname
Expand Down Expand Up @@ -127,20 +127,33 @@ async def upload(self, src: str, dst: str) -> None:
msg = f"Source is not a file: {src}"
raise CLIError(msg)

dst_parent = str(Path(dst).parent)
# Destination path is always POSIX inside the guest.
dst_parent = str(PurePosixPath(dst).parent)
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
sudo_prefix = ""
try:
await self._vm.shell(f"sudo mkdir -p {shlex.quote(dst_parent)}")
sudo_probe = await self._vm.shell("command -v sudo >/dev/null 2>&1")
sudo_prefix = "sudo " if sudo_probe.exit_code == 0 else ""
mk_result = await self._vm.shell(f"{sudo_prefix}mkdir -p {shlex.quote(dst_parent)}")
if mk_result.exit_code != 0:
msg = mk_result.stderr or mk_result.stdout or f"Failed to create upload directory: {dst_parent}"
raise CLIError(msg)
# Copy to /tmp first (always writable), then sudo mv into place.
# This works regardless of destination directory ownership.
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
await self._vm.copy(src, tmp, host_to_guest=True)
await self._vm.shell(
f"sudo mv {tmp} {shlex.quote(dst)} && "
f"sudo chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
f"sudo chmod 644 {shlex.quote(dst)}"
stage_result = await self._vm.shell(
f"{sudo_prefix}mv {tmp} {shlex.quote(dst)} && "
f"{sudo_prefix}chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
f"{sudo_prefix}chmod 644 {shlex.quote(dst)}"
)
if stage_result.exit_code != 0:
msg = stage_result.stderr or stage_result.stdout or f"Failed to stage uploaded file: {dst}"
raise CLIError(msg)
except VMError as e:
raise CLIError(str(e)) from e
finally:
# Best-effort cleanup when stage command failed before move.
await self._vm.shell(f"{sudo_prefix}rm -f {tmp}")

async def download(self, src: str, dst: str) -> None:
"""Transfer a file from the VM session to the host.
Expand All @@ -154,8 +167,11 @@ async def download(self, src: str, dst: str) -> None:
self._check_active()
Path(dst).parent.mkdir(parents=True, exist_ok=True)
tmp = f"/tmp/.download-{uuid.uuid4().hex}" # noqa: S108
sudo_prefix = ""
try:
result = await self._vm.shell(f"sudo cp {shlex.quote(src)} {tmp} && sudo chmod 644 {tmp}")
sudo_probe = await self._vm.shell("command -v sudo >/dev/null 2>&1")
sudo_prefix = "sudo " if sudo_probe.exit_code == 0 else ""
result = await self._vm.shell(f"{sudo_prefix}cp {shlex.quote(src)} {tmp} && {sudo_prefix}chmod 644 {tmp}")
if result.exit_code != 0:
msg = result.stderr or result.stdout or f"Failed to stage {src} for download"
raise CLIError(msg)
Expand All @@ -164,7 +180,7 @@ async def download(self, src: str, dst: str) -> None:
raise CLIError(str(e)) from e
finally:
# Best-effort cleanup of the temp file inside the guest.
await self._vm.shell(f"sudo rm -f {tmp}")
await self._vm.shell(f"{sudo_prefix}rm -f {tmp}")

def _check_active(self) -> None:
"""Raise if handle is inactive."""
Expand Down
36 changes: 26 additions & 10 deletions libs/hexagent/hexagent/computer/local/vm_win.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import os
import shlex
import uuid
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING

import petname
Expand Down Expand Up @@ -154,20 +154,33 @@ async def upload(self, src: str, dst: str) -> None:
msg = f"Source is not a file: {src}"
raise CLIError(msg)

dst_parent = str(Path(dst).parent)
# Destination path is always a Linux path; keep POSIX semantics on Windows.
dst_parent = str(PurePosixPath(dst).parent)
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
sudo_prefix = ""
try:
await self._vm.shell(f"sudo mkdir -p {shlex.quote(dst_parent)}")
sudo_probe = await self._vm.shell("command -v sudo >/dev/null 2>&1")
sudo_prefix = "sudo " if sudo_probe.exit_code == 0 else ""
mk_result = await self._vm.shell(f"{sudo_prefix}mkdir -p {shlex.quote(dst_parent)}")
if mk_result.exit_code != 0:
msg = mk_result.stderr or mk_result.stdout or f"Failed to create upload directory: {dst_parent}"
raise CLIError(msg)
# Copy to /tmp first (always writable), then sudo mv into place.
# This works regardless of destination directory ownership.
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
await self._vm.copy(src, tmp, host_to_guest=True)
await self._vm.shell(
f"sudo mv {tmp} {shlex.quote(dst)} && "
f"sudo chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
f"sudo chmod 644 {shlex.quote(dst)}"
stage_result = await self._vm.shell(
f"{sudo_prefix}mv {tmp} {shlex.quote(dst)} && "
f"{sudo_prefix}chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
f"{sudo_prefix}chmod 644 {shlex.quote(dst)}"
)
if stage_result.exit_code != 0:
msg = stage_result.stderr or stage_result.stdout or f"Failed to stage uploaded file: {dst}"
raise CLIError(msg)
except VMError as e:
raise CLIError(str(e)) from e
finally:
# Best-effort cleanup when stage command failed before move.
await self._vm.shell(f"{sudo_prefix}rm -f {tmp}")

async def download(self, src: str, dst: str) -> None:
"""Transfer a file from the WSL session to the host.
Expand All @@ -179,8 +192,11 @@ async def download(self, src: str, dst: str) -> None:
self._check_active()
Path(dst).parent.mkdir(parents=True, exist_ok=True)
tmp = f"/tmp/.download-{uuid.uuid4().hex}" # noqa: S108
sudo_prefix = ""
try:
result = await self._vm.shell(f"sudo cp {shlex.quote(src)} {tmp} && sudo chmod 644 {tmp}")
sudo_probe = await self._vm.shell("command -v sudo >/dev/null 2>&1")
sudo_prefix = "sudo " if sudo_probe.exit_code == 0 else ""
result = await self._vm.shell(f"{sudo_prefix}cp {shlex.quote(src)} {tmp} && {sudo_prefix}chmod 644 {tmp}")
if result.exit_code != 0:
msg = result.stderr or result.stdout or f"Failed to stage {src} for download"
raise CLIError(msg)
Expand All @@ -189,7 +205,7 @@ async def download(self, src: str, dst: str) -> None:
raise CLIError(str(e)) from e
finally:
# Best-effort cleanup of the temp file inside the guest.
await self._vm.shell(f"sudo rm -f {tmp}")
await self._vm.shell(f"{sudo_prefix}rm -f {tmp}")

def _check_active(self) -> None:
"""Raise if handle is inactive."""
Expand Down
5 changes: 4 additions & 1 deletion libs/hexagent/hexagent/mcp/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ async def _open_transport(self) -> tuple[Any, Any]:
if transport_type == "http":
http_cfg = cast("McpHttpServerConfig", config)
http_client = await self._exit_stack.enter_async_context(
httpx.AsyncClient(headers=dict(http_cfg.get("headers", {}))),
httpx.AsyncClient(
headers=dict(http_cfg.get("headers", {})),
timeout=httpx.Timeout(300, connect=10),
),
)
read_stream, write_stream, _ = await self._exit_stack.enter_async_context(
streamable_http_client(http_cfg["url"], http_client=http_client),
Expand Down
17 changes: 11 additions & 6 deletions libs/hexagent/hexagent/tools/ui/present_to_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,10 @@ def _build_case_block() -> str:
return "\n".join(arms)


# The bash script body template. ``{case_arms}`` is replaced at import
# time with the generated case block. $1 is the output directory;
# $2.. are file paths.
# The bash script body template. ``{case_arms}`` is replaced at import
# time with the generated case block. ``OUTPUT_DIR`` is injected by
# ``_build_command`` and file paths are passed via ``$@``.
_SCRIPT_BODY = r"""
OUTPUT_DIR="$1"; shift
mkdir -p "$OUTPUT_DIR"
REAL_OUT="$(realpath "$OUTPUT_DIR")"

Expand Down Expand Up @@ -204,8 +203,14 @@ def _build_command(filepaths: list[str], output_dir: str) -> str:
Returns:
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_LF)} _ {quoted_args}"
quoted_file_args = " ".join(shlex.quote(p) for p in filepaths)
set_args = f"set -- {quoted_file_args}" if quoted_file_args else "set --"
script = f"OUTPUT_DIR={shlex.quote(output_dir)}\n{set_args}\n{_SCRIPT_BODY_LF}"
# WSL can evaluate one outer shell layer before the intended ``bash -c``
# command, which would eagerly expand ``$...`` and break the script.
# Pre-escape dollars so expansion happens only in the inner bash.
script_for_outer = script.replace("$", r"\$")
return f"bash -c {shlex.quote(script_for_outer)}"


class PresentToUserTool(BaseAgentTool[PresentToUserToolParams]):
Expand Down
4 changes: 2 additions & 2 deletions libs/hexagent/tests/unit_tests/computer/test_vm.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ async def test_upload_copies_via_tmp_then_moves(self, tmp_path: Path) -> None:
assert copy_call.kwargs.get("host_to_guest") is True

# Should sudo mv from tmp to destination, chown to session user, and chmod 644
mv_call = vm.shell.call_args_list[1]
mv_call = next(c for c in vm.shell.call_args_list if " mv " in c.args[0])
assert "sudo mv" in mv_call.args[0]
assert "/remote/file.txt" in mv_call.args[0]
assert "chown test-session:test-session" in mv_call.args[0]
Expand All @@ -232,7 +232,7 @@ async def test_upload_creates_parent_dir_on_guest(self, tmp_path: Path) -> None:

await computer.upload(str(src), "/remote/deep/file.txt")

mkdir_call = vm.shell.call_args_list[0]
mkdir_call = next(c for c in vm.shell.call_args_list if "mkdir -p" in c.args[0])
assert "sudo mkdir -p" in mkdir_call.args[0]
assert "/remote/deep" in mkdir_call.args[0]

Expand Down
47 changes: 43 additions & 4 deletions libs/hexagent/tests/unit_tests/computer/test_wsl.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ruff: noqa: PLR2004 S108 ARG005 UP012
"""Tests for WslVM and _VMSessionComputer (Windows variant).

All tests mock the WSL backend no wsl.exe or WSL2 required.
All tests mock the WSL backend - no wsl.exe or WSL2 required.
"""

from __future__ import annotations
Expand All @@ -19,6 +19,7 @@
from hexagent.computer.local._types import ResolvedMount
from hexagent.computer.local._wsl import (
WslVM,
_decode_wsl_output,
_parse_status_output,
_session_user_from_guest_mount_path,
_win_path_to_wsl,
Expand Down Expand Up @@ -243,12 +244,26 @@ async def test_upload_copies_via_tmp_then_moves(self, tmp_path: Path) -> None:
assert copy_call.args[1].startswith("/tmp/.upload-")
assert copy_call.kwargs.get("host_to_guest") is True

mv_call = vm.shell.call_args_list[1]
mv_call = next(c for c in vm.shell.call_args_list if " mv " in c.args[0])
assert "sudo mv" in mv_call.args[0]
assert "/remote/file.txt" in mv_call.args[0]
assert "chown test-session:test-session" in mv_call.args[0]
assert "chmod 644" in mv_call.args[0]

async def test_upload_uses_posix_parent_for_session_paths(self, tmp_path: Path) -> None:
vm = _mock_vm()
vm.copy = AsyncMock()
computer = _make_computer(vm)

src = tmp_path / "file.txt"
src.write_text("data")

await computer.upload(str(src), "/sessions/alice/mnt/uploads/file.txt")

mkdir_call = next(c for c in vm.shell.call_args_list if "mkdir -p" in c.args[0])
assert "/sessions/alice/mnt/uploads" in mkdir_call.args[0]
assert "\\sessions\\alice\\mnt\\uploads" not in mkdir_call.args[0]

async def test_upload_missing_src_raises_file_not_found(self, tmp_path: Path) -> None:
vm = _mock_vm()
computer = _make_computer(vm)
Expand Down Expand Up @@ -288,7 +303,7 @@ async def test_download_stages_via_tmp(self, tmp_path: Path) -> None:
await computer.download("/remote/file.txt", str(dst))

# First shell call: sudo cp to tmp + chmod
stage_call = vm.shell.call_args_list[0]
stage_call = next(c for c in vm.shell.call_args_list if " cp " in c.args[0])
assert "sudo cp" in stage_call.args[0]
assert "chmod 644" in stage_call.args[0]

Expand Down Expand Up @@ -340,7 +355,7 @@ def test_satisfies_computer_protocol(self) -> None:


# ===========================================================================
# WslVM pure logic only (no subprocess)
# WslVM - pure logic only (no subprocess)
# ===========================================================================


Expand Down Expand Up @@ -419,6 +434,30 @@ async def test_start_does_not_retry_on_non_transient_failure(self) -> None:
mock_apply.assert_not_awaited()


# ===========================================================================
# WSL output decoding
# ===========================================================================


class TestDecodeWslOutput:
"""Tests for mixed-encoding stderr decoding."""

def test_utf8_plain(self) -> None:
assert _decode_wsl_output("hello".encode("utf-8")) == "hello"

def test_utf16le_with_bom(self) -> None:
raw = b"\xff\xfe" + "warning: test".encode("utf-16-le")
assert "warning: test" in _decode_wsl_output(raw)

def test_mixed_utf16le_prefix_and_utf8_tail(self) -> None:
prefix = "wsl: localhost proxy config detected but not mirrored to WSL.\r\n".encode("utf-16-le")
tail = b"/bin/bash: line 1: _mime_by_ext: command not found\n"
text = _decode_wsl_output(prefix + tail)

assert "localhost proxy config detected" in text
assert "_mime_by_ext: command not found" in text


# ===========================================================================
# Status output parsing
# ===========================================================================
Expand Down
10 changes: 10 additions & 0 deletions libs/hexagent/tests/unit_tests/tools/ui/test_present_to_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,16 @@ def test_embedded_script_normalized_to_lf(self) -> None:
cmd = _build_command(["/a.txt"], "/out")
assert "\r" not in cmd

def test_uses_inner_bash_c_without_positional_arg_shim(self) -> None:
cmd = _build_command(["/a.txt"], "/out")
assert "bash -c" in cmd
assert " _ " not in cmd
assert "OUTPUT_DIR=/out" in cmd

def test_escapes_dollar_for_wsl_outer_shell(self) -> None:
cmd = _build_command(["/a.txt"], "/out")
assert r"\$OUTPUT_DIR" in cmd


# ---------------------------------------------------------------------------
# _EXT_MIME_MAP / generated script tests
Expand Down
Loading
Loading