Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 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
774d058
sync changes to dev-0329
Mar 29, 2026
a6e2234
修改了skills,移除了2个不稳定的skills,添加了pptx skills
Mar 29, 2026
4a3c0b6
修复了小的问题
Mar 29, 2026
671c825
提交了版本号的问题,打包
Mar 29, 2026
a120812
feat(demo): split wsl prebuilt package and fix auto setup flow
xuelin-cell Mar 29, 2026
3272382
merge: resolve conflicts with dev-0329
xuelin-cell Mar 29, 2026
8d68e8a
chore(demo): track electron prebuilt tar via git-lfs
xuelin-cell Mar 29, 2026
fda711d
chore(demo): remove legacy wsl msi artifact
xuelin-cell Mar 29, 2026
caa0c81
chore(build): enforce windows packaging preflight rules
xuelin-cell Mar 29, 2026
b6f280e
fix(electron): pass HEXAGENT_APP_DIR for local prebuilt import
xuelin-cell Mar 29, 2026
6b452d6
fix(frontend): replace garbled mac shortcut labels in sidebar/tooltips
xuelin-cell Mar 29, 2026
d66874f
feat: improve windows preview/download and rebrand packaging to UniClaw
xuelin-cell Mar 29, 2026
19a93d7
fix(electron): enable exe resource editing for windows icon
xuelin-cell Mar 29, 2026
9dc6959
fix(ui): dedupe presented files and discourage duplicate PresentToUse…
xuelin-cell Mar 29, 2026
069d986
fix(ui): keep stream error content and disable exe resource edit
xuelin-cell Mar 29, 2026
4c3d75e
chore: ignore backend skills directory
xuelin-cell Mar 29, 2026
0207b79
fix: align WSL mount logic with lint and unit tests
xuelin-cell Mar 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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/prebuilt/hexagent-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
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Byte-compiled / optimized / DLL files
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
Expand Down Expand Up @@ -227,8 +227,11 @@ libs/hexagent_demo/electron/dist.zip
# Vite dev-server cache
**/.vite/

# WSL prebuilt VM image (build artifact — generate with prepare-wsl-prebuilt.ps1)
# WSL prebuilt VM image (build artifact, generated by prepare-wsl-prebuilt.ps1)
**/wsl/prebuilt/*.tar
libs/hexagent_demo/electron/prebuilt/*.tar
!libs/hexagent_demo/electron/prebuilt/hexagent-prebuilt.tar

# One-off investigation/diagnostic reports
reports/

169 changes: 138 additions & 31 deletions libs/hexagent/hexagent/computer/local/_wsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import sys
import time
from pathlib import Path
from typing import Any

from hexagent.computer.base import ExecutionMetadata
from hexagent.computer.local._types import ResolvedMount
Expand All @@ -35,6 +36,39 @@

logger = logging.getLogger(__name__)


# --- WSL Logging ---
def _get_wsl_log_file() -> Path:
"""Return the path to wsl.log."""
data_dir = os.environ.get("HEXAGENT_DATA_DIR")
base = Path(data_dir) if data_dir else Path.home() / ".hexagent"
log_dir = base / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
return log_dir / "wsl.log"


_wsl_logger = logging.getLogger("hexagent.wsl")
_wsl_logger.setLevel(logging.DEBUG)
if not any(isinstance(h, logging.FileHandler) and h.baseFilename == str(_get_wsl_log_file().resolve()) for h in _wsl_logger.handlers):
log_file = _get_wsl_log_file()
_fh = logging.FileHandler(log_file, encoding="utf-8")
_fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
_wsl_logger.addHandler(_fh)
# Ensure logs are visible in the main logger too
_wsl_logger.propagate = True
_wsl_logger.info("WSL LOG FILE: %s", log_file.resolve())


def wsl_log(msg: str, *args: Any, level: int = logging.INFO) -> None:
"""Log a message to the dedicated wsl.log and flush it."""
_wsl_logger.log(level, msg, *args)
for h in _wsl_logger.handlers:
if isinstance(h, logging.FileHandler):
h.flush()


# -------------------

# UNC path prefixes for accessing WSL filesystem from Windows.
# Modern Windows 11 uses ``wsl.localhost``; older builds use ``wsl$``.
_UNC_PREFIXES = (r"\\wsl.localhost", r"\\wsl$")
Expand Down Expand Up @@ -375,27 +409,30 @@ async def start(self) -> None:
async def apply_mounts(self, mounts: list[ResolvedMount]) -> None:
"""Apply mount configuration to the distribution.

Writes the config, terminates the distro (clearing old bind
mounts), restarts, and applies new bind mounts.
Writes the config. If the distro is running, it applies the mounts
live via ``mount --bind`` to avoid a full restart. If stopped,
they will be applied on the next ``start()``.

Args:
mounts: Complete list of resolved mounts. Replaces all
existing mounts in ``mounts.json``.

Raises:
WslError: If the distribution does not exist or start fails.
WslError: If the distribution does not exist or live apply fails.
"""
current = await self.status()
if current is None:
msg = f"WSL distro '{self._instance}' does not exist"
raise WslError(msg)

wsl_log("WslVM.apply_mounts: Updating mounts.json with %d mounts", len(mounts))
self.write_mounts(mounts)

if current == "Running":
await self.stop()

await self.start()
wsl_log("WslVM.apply_mounts: Distro is running, applying mounts live to avoid restart")
await self._apply_bind_mounts()
else:
wsl_log("WslVM.apply_mounts: Distro is not running, mounts will be applied on next start")

async def stop(self) -> None:
"""Terminate the WSL distribution.
Expand Down Expand Up @@ -467,6 +504,7 @@ async def shell(
exec_args += ["-c", inner]

start_time = time.monotonic()
wsl_log("WSL Shell Execution (Instance: %s, User: %s, CWD: %s): %s", self._instance, user or "default", cwd or "default", command)

process = await asyncio.create_subprocess_exec(
*exec_args,
Expand All @@ -485,17 +523,22 @@ async def shell(
process.kill()
await process.wait()
msg = f"timed out after {timeout}s"
wsl_log("WSL Shell TIMEOUT (Instance: %s): %s", self._instance, msg, level=logging.ERROR)
raise WslError(msg) from None

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
duration_ms = int((time.monotonic() - start_time) * 1000)

wsl_log("WSL Shell Result (Exit: %d, Duration: %dms):\nSTDOUT: %s\nSTDERR: %s", rc, duration_ms, stdout or "(empty)", stderr or "(empty)")

return CLIResult(
stdout=stdout,
stderr=stderr,
exit_code=rc,
metadata=ExecutionMetadata(duration_ms=int((time.monotonic() - start_time) * 1000)),
metadata=ExecutionMetadata(duration_ms=duration_ms),
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -546,6 +589,7 @@ async def _run_wsl(self, *cmd: str, timeout: float = 300) -> str: # noqa: ASYNC
Raises:
WslError: On non-zero exit code or timeout.
"""
wsl_log("WSL.exe Command (Instance: %s): %s", self._instance, " ".join(cmd))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
Expand All @@ -561,51 +605,108 @@ async def _run_wsl(self, *cmd: str, timeout: float = 300) -> str: # noqa: ASYNC
proc.kill()
await proc.wait()
msg = f"wsl.exe timed out after {timeout}s: {' '.join(cmd[:3])}"
wsl_log("WSL.exe TIMEOUT (Instance: %s): %s", self._instance, msg, level=logging.ERROR)
raise WslError(msg) from None

stdout = _decode_wsl_output(stdout_bytes)
stderr = _decode_wsl_output(stderr_bytes)

if proc.returncode != 0:
stderr = _decode_wsl_output(stderr_bytes).strip()
msg = f"wsl.exe failed (exit {proc.returncode}): {stderr}"
stderr_strip = stderr.strip()
msg = f"wsl.exe failed (exit {proc.returncode}): {stderr_strip}"
wsl_log(
"WSL.exe ERROR (Instance: %s, Exit: %d):\nSTDOUT: %s\nSTDERR: %s",
self._instance,
proc.returncode,
stdout.strip() or "(empty)",
stderr_strip or "(empty)",
level=logging.ERROR,
)
raise WslError(msg)

return _decode_wsl_output(stdout_bytes)
wsl_log("WSL.exe Success (Instance: %s):\nSTDOUT: %s", self._instance, stdout.strip() or "(empty)")
return stdout

async def _apply_bind_mounts(self) -> None:
async def _apply_bind_mounts(self) -> None: # noqa: PLR0912, PLR0915
"""Apply all bind mounts from ``mounts.json`` inside the distro.

Idempotent: skips mounts that are already active (detected via
``mountpoint -q``).
"""
mounts = self.read_mounts()
if not mounts:
wsl_log("WSL applying bind mounts: No mounts found in mounts.json")
return

logger.debug("WSL applying %d bind mount(s) from mounts.json", len(mounts))
wsl_log("WSL applying %d bind mount(s) from mounts.json", len(mounts))

# Diagnostic: Log all current mounts
all_mounts = await self.shell("mount", user="root")
wsl_log("WSL Current System Mounts:\n%s", all_mounts.stdout or "(empty)")

for m in mounts:
wsl_host = _win_path_to_wsl(m.host_path)
# Use wslpath to get the accurate Linux path for the Windows host path.
# This handles drive letters, mount points, and case-sensitivity correctly.
wsl_host_res = await self.shell(f"wslpath -u {shlex.quote(m.host_path)}", user="root")
if wsl_host_res.exit_code == 0 and wsl_host_res.stdout.strip():
wsl_host = wsl_host_res.stdout.strip()
else:
wsl_host = _win_path_to_wsl(m.host_path)
wsl_log("WSL wslpath failed for %s, falling back to %s", m.host_path, wsl_host, level=logging.WARNING)

qguest = shlex.quote(m.guest_path)
qhost = shlex.quote(wsl_host)

# Skip if already mounted.
check = await self.shell(f"mountpoint -q {qguest}", user="root")
if check.exit_code == 0:
logger.info(
"WSL bind-mount already active: guest=%s wsl_source=%s host_win=%s",
m.guest_path,
wsl_host,
m.host_path,
)
continue
# Even if mounted, check if it's empty
ls_check = await self.shell(f"ls -A {qguest}", user="root")
if ls_check.stdout.strip():
wsl_log(
"WSL bind-mount already active and NOT empty: guest=%s host_win=%s",
m.guest_path,
m.host_path,
)
continue

wsl_log("WSL bind-mount active but EMPTY, forcing re-mount: %s", m.guest_path, level=logging.WARNING)
await self.shell(f"umount -l {qguest}", user="root")

# Diagnostic: check source path existence and content
src_ls = await self.shell(f"ls -ld {qhost} && ls -A {qhost} | head -n 5", user="root")
wsl_log("WSL Source Path Check (%s):\n%s", wsl_host, src_ls.stdout or "(empty)")

wsl_log("WSL applying NEW sync/copy: %s -> %s", m.host_path, m.guest_path)

cmd = f"mkdir -p {qguest} && mount --bind {qhost} {qguest}"
if not m.writable:
cmd += f" && mount -o remount,ro,bind {qguest}"
# Ensure target parent exists
guest_parent = str(Path(m.guest_path).parent).replace("\\", "/")
setup_cmd = f"mkdir -p {shlex.quote(guest_parent)} && chmod 777 {shlex.quote(guest_parent)}"
await self.shell(setup_cmd, user="root")

result = await self.shell(cmd, user="root")
if result.exit_code != 0:
msg = f"Failed to bind-mount {m.host_path} → {m.guest_path}: {result.stderr}"
raise WslError(msg)
# Check if this is a skills directory (usually in /mnt/skills/)
is_skill = "/mnt/skills/" in m.guest_path

if is_skill:
wsl_log("WSL: Skills directory detected, using CP instead of BIND for reliability")
# 1. Clean up potential old mount points
await self.shell(f"umount -l {qguest} 2>/dev/null || true", user="root")
# 2. Sync files from Windows to WSL internal storage
sync_cmd = f"mkdir -p {qguest} && cp -r {qhost}/* {qguest}/ 2>/dev/null || true && chmod -R 777 {qguest}"
result = await self.shell(sync_cmd, user="root")
wsl_log("WSL Skills sync finished (Exit: %d)", result.exit_code)
else:
# For workspace/working dirs, we still try bind mount as they need real-time sync.
wsl_log("WSL: Workspace directory detected, using robust bind mount")
cmd = f"mkdir -p {qguest} && chmod 777 {qguest} && mount --bind {qhost} {qguest}"
if not m.writable:
cmd += f" && mount -o remount,ro,bind {qguest}"

result = await self.shell(cmd, user="root")
if result.exit_code != 0:
wsl_log("WSL mount failed, falling back to symlink: %s", result.stderr, level=logging.WARNING)
ln_cmd = f"rm -rf {qguest} && ln -s {qhost} {qguest}"
result = await self.shell(ln_cmd, user="root")

# Cowork mounts bind a DrvFs path (/mnt/<drive>/...) into /sessions/<user>/mnt/....
# DrvFs often presents files as root:root with mode 755 to Linux UIDs that are not
Expand All @@ -620,33 +721,39 @@ async def _apply_bind_mounts(self) -> None:
)
if m.writable and sess is not None and not skip_chown:
quser = shlex.quote(sess)
wsl_log("WSL post-bind chown for session user: %s -> %s", sess, m.guest_path)
own = await self.shell(f"chown -R {quser}:{quser} {qguest}", user="root")
if own.exit_code != 0:
logger.warning(
wsl_log(
"WSL post-bind chown failed (session=%s guest=%s): %s",
sess,
m.guest_path,
(own.stderr or own.stdout or "").strip() or "(empty)",
level=logging.WARNING,
)

# Post-verify so logs show whether the guest path is really a mount (debug empty ls issues).
verify = await self.shell(f"findmnt -n {qguest}", user="root")
content_verify = await self.shell(f"ls -A {qguest} | head -n 5", user="root")

if verify.exit_code == 0 and (verify.stdout or "").strip():
logger.info(
"WSL bind-mount applied+verified: guest=%s host_win=%s findmnt=%s",
wsl_log(
"WSL bind-mount applied+verified: guest=%s host_win=%s findmnt=%s Content: %s",
m.guest_path,
m.host_path,
verify.stdout.strip().replace("\n", " | "),
content_verify.stdout.strip().replace("\n", ", ") or "(empty)",
)
else:
logger.warning(
wsl_log(
"WSL bind-mount mount(8) succeeded but findmnt could not confirm guest=%s "
"(host_win=%s, wsl_source=%s, findmnt_exit=%s, findmnt_stderr=%s)",
m.guest_path,
m.host_path,
wsl_host,
verify.exit_code,
(verify.stderr or "").strip() or "(empty)",
level=logging.WARNING,
)

async def _resolve_unc_prefix(self) -> str:
Expand Down
Loading
Loading