Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
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
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 11 additions & 4 deletions libs/hexagent/hexagent/computer/local/_wsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
93 changes: 72 additions & 21 deletions libs/hexagent/hexagent/harness/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

import logging
import shlex
from datetime import datetime
from typing import TYPE_CHECKING

Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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)
Expand All @@ -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],
Expand Down
7 changes: 6 additions & 1 deletion libs/hexagent/hexagent/tools/ui/present_to_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]):
Expand Down
57 changes: 44 additions & 13 deletions libs/hexagent/tests/unit_tests/harness/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading