fix(portability): Windows aipass init + drone stdout (DPLAN-0194)

Two latent Windows portability bugs caught by the new e2e wiring harness:

- aipass init: _preflight_check ancestor walk crashed on OSError from
  un-enumerable Windows drive-root entries (pagefile.sys), swallowed by
  route_command as 'Unknown command: init'. Walk now skips unreadable
  entries (logged).
- drone @branch: crashed with UnicodeEncodeError ('charmap') printing a
  routed branch's captured output via Rich on cp1252 stdout. PYTHONUTF8
  only affects child interpreters; entry point now reconfigure()s the live
  stdout/stderr to UTF-8.

Pure portability — Linux/macOS behaviour unchanged. e2e suite 14/14 green
locally on Linux. Lets the 3-OS CI verify Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-06-04 01:00:01 -07:00
co-authored by Claude Opus 4.8
parent cd1af34be8
commit f3b3ebad9b
3 changed files with 63 additions and 8 deletions
+24 -4
View File
@@ -18,10 +18,12 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
install + console scripts (T0), `aipass init` scaffolding (T1), a hook actually
firing via the bridge with an observable `engine.jsonl` record (T2a), and
`drone` resolving + subprocess-executing a real branch (T3). Runs on a 3-OS
matrix (ubuntu/windows/macos, `fail-fast: false`). Validated green on Linux;
Windows is **red-first by design** — the expected failures (unguarded `.venv`
symlink, `.venv/bin` vs `Scripts`, hardcoded `/tmp`) are the portability
fix-list, not regressions. (DPLAN-0194 / FPLAN-0239)
matrix (ubuntu/windows/macos, `fail-fast: false`). Ran red-first on Windows by
design and immediately earned its keep — it caught two real, *previously
uncovered* Windows wiring bugs (`aipass init` preflight + `drone` stdout
encoding, both fixed below). Notably the layers we most feared — clean-wheel
install (T0) and hook firing (T2a) — passed on Windows. (DPLAN-0194 /
FPLAN-0239)
- **`drone rm` — provider-agnostic safe delete** — a contained recursive delete
that lets agents clean up scratch dirs without tripping the `rm -rf` block.
Deletes are confined to the project root and the system temp dirs (`/tmp` and
@@ -62,6 +64,24 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
### Fixed
- **Two latent Windows portability bugs caught by the new e2e harness** — both
were always present in the code; they only surfaced now because this is the
first CI to run `aipass init` scaffolding and real-branch `drone` routing on
Windows (the old Windows CI ran an editable install, `aipass`-less, and only
routed to in-process modules, so both paths had zero Windows coverage). Pure
portability fixes — Linux/macOS behaviour is unchanged.
- **`aipass init` failed on Windows with a misleading "Unknown command: init".**
`_preflight_check` walks every ancestor of CWD up to the filesystem root
calling `iterdir()`/`is_file()`; at the Windows drive root a locked system
entry (e.g. `pagefile.sys`) raises `OSError`, which the CLI's `route_command`
swallowed and mislabeled. The ancestor walk now skips un-enumerable /
un-stattable entries (logged), so init proceeds normally.
- **`drone @branch` crashed on Windows with `UnicodeEncodeError ('charmap')`.**
`drone` resolved + subprocessed the branch correctly, then crashed *printing*
the captured output: Rich wrote Unicode through a cp1252 stdout. The existing
`PYTHONUTF8` guard only affected child interpreters, not the live process
streams — `drone`'s entry point now also `reconfigure()`s stdout/stderr to
UTF-8 in place. (DPLAN-0194)
- **A release merge can no longer destroy the `dev` branch** — `drone @git merge`
passed `--delete-branch` to `gh pr merge` unconditionally, so merging a
`dev`→`main` PR deleted the persistent `dev` branch on the remote and stranded
+17 -3
View File
@@ -690,10 +690,24 @@ def _preflight_check() -> str | None:
"This directory is an agent branch (has .trinity/passport.json).\n"
"Agents are managed by 'drone @spawn', not 'aipass init'."
)
# Block if inside an existing AIPass project (registry above us)
# Block if inside an existing AIPass project (registry above us).
# Walking up to the filesystem root can hit ancestors that can't be
# enumerated or stat'd — e.g. locked Windows system entries at the drive
# root (pagefile.sys) raise OSError. Skip those rather than crash; on
# POSIX everything up to / is readable so behaviour is unchanged there.
for parent in [cwd] + list(cwd.parents):
for f in parent.iterdir():
if f.is_file() and f.name.endswith("_REGISTRY.json"):
try:
entries = list(parent.iterdir())
except OSError as exc:
logger.info("[init_flow] skipping unreadable ancestor %s: %s", parent, exc)
entries = []
for f in entries:
try:
is_registry = f.is_file() and f.name.endswith("_REGISTRY.json")
except OSError as exc:
logger.info("[init_flow] skipping unstattable entry %s: %s", f, exc)
continue
if is_registry:
return (
f"Already inside an AIPass project (found {f.name} at {parent}).\n"
"Use 'aipass init update' to upgrade an existing project."
+22 -1
View File
@@ -1,3 +1,11 @@
# =================== AIPass ====================
# Name: cli.py
# Description: Drone CLI entry point — console_scripts wrapper
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-06-04
# =============================================
"""
Drone CLI — command-line interface for aipass.drone.
@@ -19,8 +27,21 @@ import sys
# Windows terminals default to cp1252 which can't encode Rich's Unicode
# characters (box-drawing, em dashes, arrows). Force UTF-8 before any
# imports that trigger Rich output.
#
# PYTHONUTF8 only affects *child* interpreters launched afterward — it does
# nothing for this process's already-open stdout/stderr, which were created
# with the cp1252 codec at interpreter startup. Rich writes through those
# live streams, so we must reconfigure them in place (Python 3.7+). Without
# this, `drone @branch` crashes with UnicodeEncodeError ('charmap') when it
# prints a routed branch's captured output on Windows.
if sys.platform == "win32":
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONUTF8", "1") # for child subprocesses
for _stream in (sys.stdout, sys.stderr):
# getattr guard: streams replaced by a capture layer (e.g. pytest) or
# not backed by a TextIOWrapper simply lack reconfigure — skip them.
_reconfigure = getattr(_stream, "reconfigure", None)
if _reconfigure is not None:
_reconfigure(encoding="utf-8", errors="replace")
from aipass.drone.apps.drone import main as _drone_main