Skip to content

Commit e9bb635

Browse files
xuelin-cellCodex Botzhanghr136an7tang
authored
feat: demo electron packaging fixes and windows cowork stability (#21)
## Summary - improve WSL/VM setup reliability on Windows (instance startup, dependency staging, proxy-warning tolerance) - fix cowork file staging/present flow in WSL and mixed-encoding stderr decode - add desktop-triggered Windows restart action from setup modal - bundle backend default config into packaged backend artifacts - include additional skill bundles (email-mail-master, pptx-plus-linux) ## Included commits - d90d3eb fix mcp httpx timeout - 48155ab fix wsl io decoding and vm file staging - 6c17a92 improve windows setup flow and desktop restart UX - 61f0196 add email and pptx skill bundles --------- Co-authored-by: Codex Bot <codex-bot@example.com> Co-authored-by: zhanghr136 <zhanghr136@noreply.gitcode.com> Co-authored-by: Anqi (Anthony) Tang <anqi.tang.ai@gmail.com>
1 parent 2fd64cb commit e9bb635

115 files changed

Lines changed: 27101 additions & 70 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

libs/hexagent/hexagent/computer/local/_wsl.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,37 @@
4646

4747

4848
def _decode_wsl_output(raw: bytes) -> str:
49-
"""Decode WSL output that may be UTF-16-LE on some Windows builds."""
50-
if raw[:2] == b"\xff\xfe" or b"\x00" in raw:
51-
return raw.decode("utf-16-le", errors="replace").replace("\x00", "")
52-
return raw.decode("utf-8", errors="replace")
49+
"""Decode WSL output that may mix UTF-16-LE and UTF-8 bytes.
50+
51+
Some Windows builds emit UTF-16-LE diagnostics from ``wsl.exe`` and then
52+
append plain UTF-8 stderr from the invoked shell in the same stream.
53+
"""
54+
if not raw:
55+
return ""
56+
57+
# Handle BOM-prefixed UTF-16-LE while preserving the remaining bytes for
58+
# mixed-stream recovery below.
59+
if raw.startswith(b"\xff\xfe"):
60+
raw = raw[2:]
61+
62+
# Fast path: regular UTF-8 output.
63+
if b"\x00" not in raw:
64+
return raw.decode("utf-8", errors="replace")
65+
66+
# Mixed-path: decode the UTF-16-LE prefix up to the last NUL byte, then
67+
# decode any trailing bytes as UTF-8 (common bash stderr tail).
68+
last_nul = raw.rfind(b"\x00")
69+
split = last_nul + 1
70+
if split % 2 != 0:
71+
split += 1
72+
73+
head = raw[:split]
74+
tail = raw[split:]
75+
76+
text = head.decode("utf-16-le", errors="replace").replace("\x00", "")
77+
if tail:
78+
text += tail.decode("utf-8", errors="replace")
79+
return text
5380

5481

5582
def _resolve_wsl_exe() -> str | None:

libs/hexagent/hexagent/computer/local/vm.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import shlex
1616
import sys
1717
import uuid
18-
from pathlib import Path
18+
from pathlib import Path, PurePosixPath
1919
from typing import TYPE_CHECKING
2020

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

130-
dst_parent = str(Path(dst).parent)
130+
# Destination path is always POSIX inside the guest.
131+
dst_parent = str(PurePosixPath(dst).parent)
132+
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
133+
sudo_prefix = ""
131134
try:
132-
await self._vm.shell(f"sudo mkdir -p {shlex.quote(dst_parent)}")
135+
sudo_probe = await self._vm.shell("command -v sudo >/dev/null 2>&1")
136+
sudo_prefix = "sudo " if sudo_probe.exit_code == 0 else ""
137+
mk_result = await self._vm.shell(f"{sudo_prefix}mkdir -p {shlex.quote(dst_parent)}")
138+
if mk_result.exit_code != 0:
139+
msg = mk_result.stderr or mk_result.stdout or f"Failed to create upload directory: {dst_parent}"
140+
raise CLIError(msg)
133141
# Copy to /tmp first (always writable), then sudo mv into place.
134142
# This works regardless of destination directory ownership.
135-
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
136143
await self._vm.copy(src, tmp, host_to_guest=True)
137-
await self._vm.shell(
138-
f"sudo mv {tmp} {shlex.quote(dst)} && "
139-
f"sudo chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
140-
f"sudo chmod 644 {shlex.quote(dst)}"
144+
stage_result = await self._vm.shell(
145+
f"{sudo_prefix}mv {tmp} {shlex.quote(dst)} && "
146+
f"{sudo_prefix}chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
147+
f"{sudo_prefix}chmod 644 {shlex.quote(dst)}"
141148
)
149+
if stage_result.exit_code != 0:
150+
msg = stage_result.stderr or stage_result.stdout or f"Failed to stage uploaded file: {dst}"
151+
raise CLIError(msg)
142152
except VMError as e:
143153
raise CLIError(str(e)) from e
154+
finally:
155+
# Best-effort cleanup when stage command failed before move.
156+
await self._vm.shell(f"{sudo_prefix}rm -f {tmp}")
144157

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

169185
def _check_active(self) -> None:
170186
"""Raise if handle is inactive."""

libs/hexagent/hexagent/computer/local/vm_win.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import os
3131
import shlex
3232
import uuid
33-
from pathlib import Path
33+
from pathlib import Path, PurePosixPath
3434
from typing import TYPE_CHECKING
3535

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

157-
dst_parent = str(Path(dst).parent)
157+
# Destination path is always a Linux path; keep POSIX semantics on Windows.
158+
dst_parent = str(PurePosixPath(dst).parent)
159+
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
160+
sudo_prefix = ""
158161
try:
159-
await self._vm.shell(f"sudo mkdir -p {shlex.quote(dst_parent)}")
162+
sudo_probe = await self._vm.shell("command -v sudo >/dev/null 2>&1")
163+
sudo_prefix = "sudo " if sudo_probe.exit_code == 0 else ""
164+
mk_result = await self._vm.shell(f"{sudo_prefix}mkdir -p {shlex.quote(dst_parent)}")
165+
if mk_result.exit_code != 0:
166+
msg = mk_result.stderr or mk_result.stdout or f"Failed to create upload directory: {dst_parent}"
167+
raise CLIError(msg)
160168
# Copy to /tmp first (always writable), then sudo mv into place.
161169
# This works regardless of destination directory ownership.
162-
tmp = f"/tmp/.upload-{uuid.uuid4().hex}" # noqa: S108
163170
await self._vm.copy(src, tmp, host_to_guest=True)
164-
await self._vm.shell(
165-
f"sudo mv {tmp} {shlex.quote(dst)} && "
166-
f"sudo chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
167-
f"sudo chmod 644 {shlex.quote(dst)}"
171+
stage_result = await self._vm.shell(
172+
f"{sudo_prefix}mv {tmp} {shlex.quote(dst)} && "
173+
f"{sudo_prefix}chown {self._session_name}:{self._session_name} {shlex.quote(dst)} && "
174+
f"{sudo_prefix}chmod 644 {shlex.quote(dst)}"
168175
)
176+
if stage_result.exit_code != 0:
177+
msg = stage_result.stderr or stage_result.stdout or f"Failed to stage uploaded file: {dst}"
178+
raise CLIError(msg)
169179
except VMError as e:
170180
raise CLIError(str(e)) from e
181+
finally:
182+
# Best-effort cleanup when stage command failed before move.
183+
await self._vm.shell(f"{sudo_prefix}rm -f {tmp}")
171184

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

194210
def _check_active(self) -> None:
195211
"""Raise if handle is inactive."""

libs/hexagent/hexagent/mcp/_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,10 @@ async def _open_transport(self) -> tuple[Any, Any]:
166166
if transport_type == "http":
167167
http_cfg = cast("McpHttpServerConfig", config)
168168
http_client = await self._exit_stack.enter_async_context(
169-
httpx.AsyncClient(headers=dict(http_cfg.get("headers", {}))),
169+
httpx.AsyncClient(
170+
headers=dict(http_cfg.get("headers", {})),
171+
timeout=httpx.Timeout(300, connect=10),
172+
),
170173
)
171174
read_stream, write_stream, _ = await self._exit_stack.enter_async_context(
172175
streamable_http_client(http_cfg["url"], http_client=http_client),

libs/hexagent/hexagent/tools/ui/present_to_user.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,10 @@ def _build_case_block() -> str:
125125
return "\n".join(arms)
126126

127127

128-
# The bash script body template. ``{case_arms}`` is replaced at import
129-
# time with the generated case block. $1 is the output directory;
130-
# $2.. are file paths.
128+
# The bash script body template. ``{case_arms}`` is replaced at import
129+
# time with the generated case block. ``OUTPUT_DIR`` is injected by
130+
# ``_build_command`` and file paths are passed via ``$@``.
131131
_SCRIPT_BODY = r"""
132-
OUTPUT_DIR="$1"; shift
133132
mkdir -p "$OUTPUT_DIR"
134133
REAL_OUT="$(realpath "$OUTPUT_DIR")"
135134
@@ -204,8 +203,14 @@ def _build_command(filepaths: list[str], output_dir: str) -> str:
204203
Returns:
205204
A shell command string safe for ``Computer.run()``.
206205
"""
207-
quoted_args = " ".join(shlex.quote(p) for p in [output_dir, *filepaths])
208-
return f"bash -c {shlex.quote(_SCRIPT_BODY_LF)} _ {quoted_args}"
206+
quoted_file_args = " ".join(shlex.quote(p) for p in filepaths)
207+
set_args = f"set -- {quoted_file_args}" if quoted_file_args else "set --"
208+
script = f"OUTPUT_DIR={shlex.quote(output_dir)}\n{set_args}\n{_SCRIPT_BODY_LF}"
209+
# WSL can evaluate one outer shell layer before the intended ``bash -c``
210+
# command, which would eagerly expand ``$...`` and break the script.
211+
# Pre-escape dollars so expansion happens only in the inner bash.
212+
script_for_outer = script.replace("$", r"\$")
213+
return f"bash -c {shlex.quote(script_for_outer)}"
209214

210215

211216
class PresentToUserTool(BaseAgentTool[PresentToUserToolParams]):

libs/hexagent/tests/unit_tests/computer/test_vm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ async def test_upload_copies_via_tmp_then_moves(self, tmp_path: Path) -> None:
216216
assert copy_call.kwargs.get("host_to_guest") is True
217217

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

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

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

libs/hexagent/tests/unit_tests/computer/test_wsl.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# ruff: noqa: PLR2004 S108 ARG005 UP012
22
"""Tests for WslVM and _VMSessionComputer (Windows variant).
33
4-
All tests mock the WSL backend no wsl.exe or WSL2 required.
4+
All tests mock the WSL backend - no wsl.exe or WSL2 required.
55
"""
66

77
from __future__ import annotations
@@ -19,6 +19,7 @@
1919
from hexagent.computer.local._types import ResolvedMount
2020
from hexagent.computer.local._wsl import (
2121
WslVM,
22+
_decode_wsl_output,
2223
_parse_status_output,
2324
_session_user_from_guest_mount_path,
2425
_win_path_to_wsl,
@@ -243,12 +244,26 @@ async def test_upload_copies_via_tmp_then_moves(self, tmp_path: Path) -> None:
243244
assert copy_call.args[1].startswith("/tmp/.upload-")
244245
assert copy_call.kwargs.get("host_to_guest") is True
245246

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

253+
async def test_upload_uses_posix_parent_for_session_paths(self, tmp_path: Path) -> None:
254+
vm = _mock_vm()
255+
vm.copy = AsyncMock()
256+
computer = _make_computer(vm)
257+
258+
src = tmp_path / "file.txt"
259+
src.write_text("data")
260+
261+
await computer.upload(str(src), "/sessions/alice/mnt/uploads/file.txt")
262+
263+
mkdir_call = next(c for c in vm.shell.call_args_list if "mkdir -p" in c.args[0])
264+
assert "/sessions/alice/mnt/uploads" in mkdir_call.args[0]
265+
assert "\\sessions\\alice\\mnt\\uploads" not in mkdir_call.args[0]
266+
252267
async def test_upload_missing_src_raises_file_not_found(self, tmp_path: Path) -> None:
253268
vm = _mock_vm()
254269
computer = _make_computer(vm)
@@ -288,7 +303,7 @@ async def test_download_stages_via_tmp(self, tmp_path: Path) -> None:
288303
await computer.download("/remote/file.txt", str(dst))
289304

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

@@ -340,7 +355,7 @@ def test_satisfies_computer_protocol(self) -> None:
340355

341356

342357
# ===========================================================================
343-
# WslVM pure logic only (no subprocess)
358+
# WslVM - pure logic only (no subprocess)
344359
# ===========================================================================
345360

346361

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

421436

437+
# ===========================================================================
438+
# WSL output decoding
439+
# ===========================================================================
440+
441+
442+
class TestDecodeWslOutput:
443+
"""Tests for mixed-encoding stderr decoding."""
444+
445+
def test_utf8_plain(self) -> None:
446+
assert _decode_wsl_output("hello".encode("utf-8")) == "hello"
447+
448+
def test_utf16le_with_bom(self) -> None:
449+
raw = b"\xff\xfe" + "warning: test".encode("utf-16-le")
450+
assert "warning: test" in _decode_wsl_output(raw)
451+
452+
def test_mixed_utf16le_prefix_and_utf8_tail(self) -> None:
453+
prefix = "wsl: localhost proxy config detected but not mirrored to WSL.\r\n".encode("utf-16-le")
454+
tail = b"/bin/bash: line 1: _mime_by_ext: command not found\n"
455+
text = _decode_wsl_output(prefix + tail)
456+
457+
assert "localhost proxy config detected" in text
458+
assert "_mime_by_ext: command not found" in text
459+
460+
422461
# ===========================================================================
423462
# Status output parsing
424463
# ===========================================================================

libs/hexagent/tests/unit_tests/tools/ui/test_present_to_user.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,16 @@ def test_embedded_script_normalized_to_lf(self) -> None:
165165
cmd = _build_command(["/a.txt"], "/out")
166166
assert "\r" not in cmd
167167

168+
def test_uses_inner_bash_c_without_positional_arg_shim(self) -> None:
169+
cmd = _build_command(["/a.txt"], "/out")
170+
assert "bash -c" in cmd
171+
assert " _ " not in cmd
172+
assert "OUTPUT_DIR=/out" in cmd
173+
174+
def test_escapes_dollar_for_wsl_outer_shell(self) -> None:
175+
cmd = _build_command(["/a.txt"], "/out")
176+
assert r"\$OUTPUT_DIR" in cmd
177+
168178

169179
# ---------------------------------------------------------------------------
170180
# _EXT_MIME_MAP / generated script tests

0 commit comments

Comments
 (0)