From 53aaef671fe4a20ac86afd6c5d4a47a88cda2158 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:06:33 -0400 Subject: [PATCH 01/22] Drive Windows integration TUIs through ConPTY Co-authored-by: Isaac --- pyproject.toml | 1 + scripts/run_integration.py | 29 +----- tests/integration/README.md | 2 +- tests/integration/utils/terminal.py | 145 +++++++++++++++++++++++----- tests/test_e2e_cuj_helpers.py | 31 +++--- tests/test_integration_runner.py | 9 +- uv.lock | 20 ++++ 7 files changed, 168 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2feaf034b..a59af415b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ dev = [ "pytest>=9.0.3", "pexpect>=4.9.0", "pyte>=0.8.2", + "pywinpty==3.0.5; sys_platform == 'win32'", # Pinned so local and CI lint with the identical formatter/type-checker; # ruff/ty output shifts between releases, so bump these deliberately. "ruff==0.16.5", diff --git a/scripts/run_integration.py b/scripts/run_integration.py index 8300e237c..56f331511 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -7,7 +7,6 @@ from __future__ import annotations import argparse -import ast import base64 import contextlib import datetime as dt @@ -39,8 +38,6 @@ ) NPM_TOKEN_ENV = "UG_INTEGRATION_NPM_TOKEN" INSTALLER_CREDENTIAL_ENV = (*UV_INDEX_CREDENTIAL_ENV, NPM_TOKEN_ENV) -PTY_MODULES = {"pexpect", "pyte"} -PTY_HELPERS = {"utils.terminal", "utils.mcp"} # Claude exports no spans on Windows, where ug writes no machine-wide Claude settings. WINDOWS_UNSUPPORTED_MODULES = {"test_ug_claude_tracing.py"} HEADLESS_TEST_NODES = { @@ -169,31 +166,11 @@ def integration_test_targets( return [ str(module) for module in sorted(suite.glob("test_*.py")) - if module.name not in WINDOWS_UNSUPPORTED_MODULES and not uses_pty(module) + if module.name not in WINDOWS_UNSUPPORTED_MODULES ] return [str(suite)] -def uses_pty(module: Path) -> bool: - """Whether a suite module drives agents through the POSIX-only PTY helpers.""" - imported_names = [] - for node in ast.walk(ast.parse(module.read_text(encoding="utf-8"))): - if isinstance(node, ast.ImportFrom): - imported_names.append(node.module or "") - imported_names.extend( - f"{node.module}.{alias.name}" if node.module else alias.name - for alias in node.names - if alias.name != "*" - ) - elif isinstance(node, ast.Import): - imported_names.extend(alias.name for alias in node.names) - return any( - name.split(".")[0] in PTY_MODULES - or any(name == helper or name.endswith(f".{helper}") for helper in PTY_HELPERS) - for name in imported_names - ) - - def process_group_options() -> dict: if os.name == "posix": return {"start_new_session": True} @@ -840,8 +817,8 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str: bearer = mint_m2m_token(args.workspace, client_id, client_secret) test_dependencies = ["pytest==9.0.3"] - if os.name == "posix": - test_dependencies.extend(["pexpect==4.9.0", "pyte==0.8.2"]) + test_dependencies.append("pyte==0.8.2") + test_dependencies.append("pexpect==4.9.0" if os.name == "posix" else "pywinpty==3.0.5") run( [ uv, diff --git a/tests/integration/README.md b/tests/integration/README.md index ad08e05d2..1216bc3e8 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -121,7 +121,7 @@ through them without changing existing journey commands or completion assertions ## Run a specific combination Prerequisites: Python 3.12+, uv, and Node/npm. Live runs also require Databricks -CLI 1.17.0. Full live/TUI runs require a POSIX host; Windows supports the explicit +CLI 1.17.0. Windows drives TUIs through ConPTY (pywinpty) and supports the explicit headless subset described below. The runner installs the requested agents into a new npm prefix and ug into a new virtualenv. Pytest and, for live runs, the PTY/screen libraries (pexpect and pyte) live in a different virtualenv, so they cannot accidentally diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 85240dd59..654707157 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -8,12 +8,15 @@ import contextlib import os +import queue import re +import shutil import signal +import subprocess +import threading import time import uuid -import pexpect import pyte from .evidence import agent_sessions, assert_no_terminal_api_error @@ -30,6 +33,117 @@ def write_process_input(self, data): self.send(data) +class PosixPty: + """pexpect-backed PTY; `read` returns None on timeout and raises EOFError at end.""" + + def __init__(self, command, cwd, env, dimensions): + import pexpect + + self._pexpect = pexpect + self.child = pexpect.spawn( + command[0], + command[1:], + cwd=cwd, + env=env, + encoding="utf-8", + codec_errors="replace", + dimensions=dimensions, + timeout=120, + ) + + def read(self, timeout): + try: + return self.child.read_nonblocking(size=65536, timeout=timeout) + except self._pexpect.TIMEOUT: + return None + except self._pexpect.EOF: + raise EOFError from None + + def send(self, keys): + self.child.send(keys) + + def kill(self): + # pexpect creates a new session with a controlling terminal. Clean up + # its process group even if the ug leader exited before its children. + with contextlib.suppress(ProcessLookupError): + os.killpg(self.child.pid, signal.SIGTERM) + self.child.close(force=True) + with contextlib.suppress(ProcessLookupError): + os.killpg(self.child.pid, signal.SIGKILL) + + def close(self): + self.child.close(force=False) + + @property + def exitstatus(self): + return self.child.exitstatus + + @property + def signalstatus(self): + return self.child.signalstatus + + +class ConPty: + """pywinpty (ConPTY) backend; a daemon thread feeds the blocking `read` into a queue.""" + + def __init__(self, command, cwd, env, dimensions): + import winpty # ty: ignore[unresolved-import] + + # ConPTY launches executables only; batch wrappers need cmd.exe. + argv = [shutil.which(command[0]) or command[0], *command[1:]] + if argv[0].lower().endswith((".cmd", ".bat")): + argv = [os.environ.get("COMSPEC", "cmd.exe"), "/c", *argv] + self.child = winpty.PtyProcess.spawn(argv, cwd=cwd, env=env, dimensions=dimensions) + self._chunks = queue.Queue() + threading.Thread(target=self._pump, daemon=True).start() + + def _pump(self): + try: + while chunk := self.child.read(65536): + self._chunks.put(chunk) + except Exception: # noqa: BLE001 - EOFError or a closed pty both mean the stream ended + pass + self._chunks.put(None) + + def read(self, timeout): + try: + chunk = self._chunks.get(timeout=timeout) + except queue.Empty: + return None + if chunk is None: + self._chunks.put(None) + raise EOFError + return chunk + + def send(self, keys): + self.child.write(keys) + + def kill(self): + # terminate() ends only the leader; taskkill /T takes its descendants too. + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(self.child.pid)], + capture_output=True, + check=False, + ) + with contextlib.suppress(Exception): + self.child.terminate(force=True) + + def close(self): + with contextlib.suppress(Exception): + self.child.wait() + + @property + def exitstatus(self): + return self.child.exitstatus + + signalstatus = None + + +def spawn_pty(command, cwd, env, dimensions): + backend = ConPty if os.name == "nt" else PosixPty + return backend(command, cwd, env, dimensions) + + class TerminalProcess: def __init__(self, session, agent, command, name): self.session = session @@ -37,16 +151,7 @@ def __init__(self, session, agent, command, name): self.name = name self.command = command env = {**session.env, "TERM": "xterm-256color"} - self.child = pexpect.spawn( - self.command[0], - self.command[1:], - cwd=str(session.cwd), - env=env, - encoding="utf-8", - codec_errors="replace", - dimensions=(60, 140), - timeout=120, - ) + self.child = spawn_pty(self.command, str(session.cwd), env, (60, 140)) self.screen = TerminalScreen(140, 60, self.child.send) self.stream = pyte.Stream(self.screen) self.output = [] @@ -61,13 +166,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): - # pexpect creates a new session with a controlling terminal. Clean up - # its process group even if the ug leader exited before its children. - with contextlib.suppress(ProcessLookupError): - os.killpg(self.child.pid, signal.SIGTERM) - self.child.close(force=True) - with contextlib.suppress(ProcessLookupError): - os.killpg(self.child.pid, signal.SIGKILL) + self.child.kill() routing_log = ( self.session.home / ".ucode" @@ -93,12 +192,12 @@ def __exit__(self, exc_type, exc, traceback): def read(self): try: - chunk = self.child.read_nonblocking(size=65536, timeout=0.2) - except pexpect.TIMEOUT: - return - except pexpect.EOF: + chunk = self.child.read(timeout=0.2) + except EOFError: self.ended = True return + if chunk is None: + return self.output.append(chunk) self.stream.feed(chunk) @@ -169,7 +268,7 @@ def finish(self, timeout=120): while not self.ended and time.monotonic() < deadline: self.read() assert self.ended, f"Process did not exit within {timeout}s:\n{self.visible}" - self.child.close(force=False) + self.child.close() assert self.child.exitstatus == 0, ( f"exit={self.child.exitstatus}, signal={self.child.signalstatus}:\n{self.visible}" ) diff --git a/tests/test_e2e_cuj_helpers.py b/tests/test_e2e_cuj_helpers.py index 12676ca97..3e4f6554c 100644 --- a/tests/test_e2e_cuj_helpers.py +++ b/tests/test_e2e_cuj_helpers.py @@ -98,14 +98,16 @@ def test_clean_teardown_records_no_leak(tmp_path, monkeypatch): ) -requires_pty = pytest.mark.skipif(sys.platform == "win32", reason="Terminal needs a POSIX PTY") - - def _fake_ug(tmp_path, body): """A real executable standing in for `ug`, so the real PTY driver runs offline.""" - script = tmp_path / "ug" - script.write_text("#!/bin/sh\n" + body) - script.chmod(0o755) + (tmp_path / "fake_ug.py").write_text(body) + if sys.platform == "win32": + script = tmp_path / "ug.cmd" + script.write_text(f'@"{sys.executable}" "%~dp0fake_ug.py" %*\r\n') + else: + script = tmp_path / "ug" + script.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$(dirname "$0")/fake_ug.py" "$@"\n') + script.chmod(0o755) return UserSession(tmp_path, script, tmp_path / "artifacts", "token") @@ -119,28 +121,33 @@ def test_terminal_rejects_a_missing_unsupported_or_mismatched_agent(tmp_path, ar Terminal(session, "rejected", args, agent=agent) -@requires_pty def test_wait_until_rejects_a_permission_prompt(tmp_path): - session = _fake_ug(tmp_path, "echo 'Do you want to proceed?'\nsleep 30\n") + session = _fake_ug( + tmp_path, "import time\nprint('Do you want to proceed?', flush=True)\ntime.sleep(30)\n" + ) with Terminal(session, "rejects", [], agent=CLAUDE) as tui: with pytest.raises(AssertionError, match="Unexpected permission request"): tui.wait_until(lambda: False, "never", timeout=10) -@requires_pty def test_wait_until_completes_when_done(tmp_path): done = tmp_path / "done" - session = _fake_ug(tmp_path, f"echo working\ntouch '{done}'\nsleep 30\n") + session = _fake_ug( + tmp_path, + "import pathlib, time\nprint('working', flush=True)\n" + f"pathlib.Path({str(done)!r}).touch()\ntime.sleep(30)\n", + ) with Terminal(session, "completes", [], agent=CODEX) as tui: tui.wait_until(done.exists, "the done marker", timeout=10) assert "working" in tui.visible -@requires_pty def test_wait_until_lets_on_screen_answer_an_expected_dialog(tmp_path): done = tmp_path / "done" session = _fake_ug( - tmp_path, f"echo 'Allow this call?'\nread answer\necho allowed\ntouch '{done}'\nsleep 30\n" + tmp_path, + "import pathlib, time\nprint('Allow this call?', flush=True)\ninput()\n" + f"print('allowed', flush=True)\npathlib.Path({str(done)!r}).touch()\ntime.sleep(30)\n", ) answered = [] diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index f89b9eee4..bb44cdef9 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -77,14 +77,9 @@ def test_headless_only_selects_exact_nodes_for_requested_agents(tmp_path): ] -def test_windows_live_runs_select_only_modules_without_pty_helpers(tmp_path): +def test_windows_live_runs_select_every_module_except_unsupported_ones(tmp_path): (tmp_path / "test_headless.py").write_text("import json\n") (tmp_path / "test_tui.py").write_text("from utils.terminal import AgentTerminal\n") - (tmp_path / "test_tui_import.py").write_text("from utils import terminal as agent_terminal\n") - (tmp_path / "test_relative_mcp.py").write_text("from .utils import mcp as mcp_tools\n") - (tmp_path / "test_qualified_tui.py").write_text("import tests.integration.utils.terminal\n") - (tmp_path / "test_mcp.py").write_text("from utils.mcp import inventory\n") - (tmp_path / "test_screen.py").write_text("import pyte\n") (tmp_path / "test_ug_claude_tracing.py").write_text("import json\n") assert runner.integration_test_targets( @@ -93,7 +88,7 @@ def test_windows_live_runs_select_only_modules_without_pty_helpers(tmp_path): platform_name="nt", installation_only=False, headless_only=False, - ) == [str(tmp_path / "test_headless.py")] + ) == [str(tmp_path / "test_headless.py"), str(tmp_path / "test_tui.py")] assert runner.integration_test_targets( tmp_path, ["claude"], diff --git a/uv.lock b/uv.lock index a2e745a30..de683fde2 100644 --- a/uv.lock +++ b/uv.lock @@ -825,6 +825,24 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] +[[package]] +name = "pywinpty" +version = "3.0.5" +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } +wheels = [ + { url = "https://pypi-proxy.cloud.databricks.com/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, +] + [[package]] name = "questionary" version = "2.1.1" @@ -1132,6 +1150,7 @@ dev = [ { name = "pexpect" }, { name = "pyte" }, { name = "pytest" }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "ruff" }, { name = "ty" }, ] @@ -1152,6 +1171,7 @@ dev = [ { name = "pexpect", specifier = ">=4.9.0" }, { name = "pyte", specifier = ">=0.8.2" }, { name = "pytest", specifier = ">=9.0.3" }, + { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = "==3.0.5" }, { name = "ruff", specifier = "==0.16.5" }, { name = "ty", specifier = "==0.0.75" }, ] From bb7e3677d2a3f149237df951405b900185f33a13 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:42:37 -0400 Subject: [PATCH 02/22] Port Claude apiKeyHelper checks and the Codex trust dialog to Windows Co-authored-by: Isaac --- .../test_ug_claude_custom_oauth.py | 19 +++++++++++++------ tests/integration/test_ug_configure_claude.py | 6 +++--- tests/integration/utils/harness.py | 17 +++++++++++++++++ tests/integration/utils/terminal.py | 5 +++-- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/tests/integration/test_ug_claude_custom_oauth.py b/tests/integration/test_ug_claude_custom_oauth.py index 527fac632..288d2e773 100644 --- a/tests/integration/test_ug_claude_custom_oauth.py +++ b/tests/integration/test_ug_claude_custom_oauth.py @@ -2,10 +2,12 @@ import configparser import json -import shlex +import os +from pathlib import Path from urllib.parse import urlparse import pytest +from utils.harness import split_command from utils.terminal import AgentTerminal pytestmark = [pytest.mark.live, pytest.mark.tui, pytest.mark.claude] @@ -49,11 +51,16 @@ def test_ug_claude_custom_oauth_cli_boots(live_session, workspace): assert profile == f"ug-oauth-{hostname}-{CLIENT_ID}" assert profiles[profile]["client_id"] == CLIENT_ID - managed_settings = json.loads( - session.run(MANAGED_SETTINGS_PATH, binary=session.which("cat"), timeout=30).stdout - ) - assert shlex.split(managed_settings["apiKeyHelper"]) == [ - str(session.binary.with_name("ug")), + if os.name == "nt": + # ug has no Windows managed-settings writer, so the helper lands in user settings. + settings_text = (session.home / ".claude/ucode-settings.json").read_text() + else: + settings_text = session.run( + MANAGED_SETTINGS_PATH, binary=session.which("cat"), timeout=30 + ).stdout + helper = split_command(json.loads(settings_text)["apiKeyHelper"]) + assert Path(helper[0]).with_suffix("") == session.binary.with_name("ug") + assert helper[1:] == [ "auth-token", "--host", workspace, diff --git a/tests/integration/test_ug_configure_claude.py b/tests/integration/test_ug_configure_claude.py index 9b2ebb882..9af96ecd9 100644 --- a/tests/integration/test_ug_configure_claude.py +++ b/tests/integration/test_ug_configure_claude.py @@ -1,11 +1,11 @@ """CUJs: configure Claude through ug, then use its real interactive session.""" import json -import shlex from pathlib import Path import pytest from utils.evidence import FileTask +from utils.harness import split_command from utils.terminal import AgentTerminal, ConfigureTerminal pytestmark = [pytest.mark.live, pytest.mark.tui, pytest.mark.claude] @@ -39,8 +39,8 @@ def test_ug_configure_claude_databricks(live_session, workspace): assert not session.workspace_state().get("provider_services", {}).get("claude") settings = json.loads((session.home / ".claude/ucode-settings.json").read_text()) - helper = shlex.split(settings["apiKeyHelper"]) - assert Path(helper[0]) == session.binary.with_name("ug") + helper = split_command(settings["apiKeyHelper"]) + assert Path(helper[0]).with_suffix("") == session.binary.with_name("ug") assert helper[1] == "auth-token" token_result = session.run(*helper[1:], binary=helper[0], strip_ansi=False, timeout=30) assert token_result.stdout == "\n" diff --git a/tests/integration/utils/harness.py b/tests/integration/utils/harness.py index abaca355e..f142cbdc2 100644 --- a/tests/integration/utils/harness.py +++ b/tests/integration/utils/harness.py @@ -3,10 +3,12 @@ from __future__ import annotations import contextlib +import ctypes import json import os import queue import re +import shlex import shutil import signal import subprocess @@ -19,6 +21,21 @@ ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") +def split_command(line: str) -> list[str]: + """Split a command line the way the OS will when an agent runs it.""" + if os.name != "nt": + return shlex.split(line) + # ug writes Windows commands with list2cmdline; CreateProcess splits them like this. + split = ctypes.windll.shell32.CommandLineToArgvW + split.restype = ctypes.POINTER(ctypes.c_wchar_p) + count = ctypes.c_int() + argv = split(line, ctypes.byref(count)) + try: + return [argv[index] for index in range(count.value)] + finally: + ctypes.windll.kernel32.LocalFree(argv) + + def process_group_options() -> dict: if os.name == "posix": return {"start_new_session": True} diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 654707157..6a65cfb58 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -360,7 +360,7 @@ def boot(self, timeout=120): and bool( re.search( rf"(?m)^\s*>\s+You are in " - rf"{re.escape(str(self.session.cwd.parent))}/[^/\r\n]*$", + rf"{re.escape(str(self.session.cwd.parent))}[\\/][^\\/\r\n]*$", text, ) ) @@ -369,7 +369,8 @@ def boot(self, timeout=120): or "directory allows project-local config, hooks, and exec policies to load." in text ) - and bool(re.search(r"(?m)^\s*[›❯>]\s*1[.)]\s+Yes, continue\s*$", text)) + # ConPTY can deliver the selection glyph as U+FFFD. + and bool(re.search(r"(?m)^\s*[›❯>\ufffd]\s*1[.)]\s+Yes, continue\s*$", text)) and bool(re.search(r"(?m)^\s*2[.)]\s+No, quit\s*$", text)) and "Press enter to continue" in text, "\r", From 07816346b7eb74eb516444ed0bb8f4008b69f814 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:55:20 -0400 Subject: [PATCH 03/22] Handle the Codex Windows sandbox picker and ug.EXE helper path Co-authored-by: Isaac --- tests/integration/test_ug_configure_codex.py | 2 +- tests/integration/utils/terminal.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_ug_configure_codex.py b/tests/integration/test_ug_configure_codex.py index bc5d68222..8cc6d987f 100644 --- a/tests/integration/test_ug_configure_codex.py +++ b/tests/integration/test_ug_configure_codex.py @@ -40,7 +40,7 @@ def test_ug_configure_codex_databricks(live_session, workspace): config = tomllib.loads((session.home / ".codex/ucode.config.toml").read_text()) helper = config["model_providers"][config["model_provider"]]["auth"] - assert Path(helper["command"]) == session.binary.with_name("ug") + assert Path(helper["command"]).with_suffix("") == session.binary.with_name("ug") assert helper["args"][0] == "auth-token" token_result = session.run( *helper["args"], binary=helper["command"], strip_ansi=False, timeout=30 diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 6a65cfb58..8d0eadc4d 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -21,6 +21,9 @@ from .evidence import agent_sessions, assert_no_terminal_api_error +# ConPTY can deliver the menu selection glyph as U+FFFD. +SELECTED = "[›❯>\ufffd]" + class TerminalScreen(pyte.Screen): def __init__(self, columns, lines, send): @@ -228,7 +231,11 @@ def wait_for(self, predicate, description, timeout=30, stable_for=0.3): def selected_line(self): return next( - (line.strip() for line in self.visible.splitlines() if re.match(r"^\s*[›❯>]", line)), + ( + line.strip() + for line in self.visible.splitlines() + if re.match(rf"^\s*{SELECTED}", line) + ), "", ) @@ -319,6 +326,10 @@ def boot(self, timeout=120): if "Hooks need review" in text: self.choose("Hooks need review", "Trust all and continue") continue + if self.agent == "codex" and "Set up the Codex agent sandbox" in text: + # Matches the unelevated sandbox the harness configures for `codex exec`. + self.choose("Set up the Codex agent sandbox", "Use non-admin sandbox") + continue if ( "Update available" in text and "Press enter to continue" in text @@ -369,8 +380,7 @@ def boot(self, timeout=120): or "directory allows project-local config, hooks, and exec policies to load." in text ) - # ConPTY can deliver the selection glyph as U+FFFD. - and bool(re.search(r"(?m)^\s*[›❯>\ufffd]\s*1[.)]\s+Yes, continue\s*$", text)) + and bool(re.search(rf"(?m)^\s*{SELECTED}\s*1[.)]\s+Yes, continue\s*$", text)) and bool(re.search(r"(?m)^\s*2[.)]\s+No, quit\s*$", text)) and "Press enter to continue" in text, "\r", From 9415dcb2d5d9f2504427a5a5c7d0a1c2704829e9 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:35:28 -0400 Subject: [PATCH 04/22] Read transcripts as UTF-8 and port lifecycle journeys to Windows Co-authored-by: Isaac --- tests/integration/test_ug_configure_claude_lifecycle.py | 2 ++ tests/integration/test_ug_configure_codex_lifecycle.py | 4 ++++ tests/integration/utils/evidence.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_ug_configure_claude_lifecycle.py b/tests/integration/test_ug_configure_claude_lifecycle.py index af13e2545..dff1dba4b 100644 --- a/tests/integration/test_ug_configure_claude_lifecycle.py +++ b/tests/integration/test_ug_configure_claude_lifecycle.py @@ -1,6 +1,7 @@ """CUJs for changing and undoing claude's ug setup.""" import json +import os import pytest from utils.evidence import FileTask @@ -17,6 +18,7 @@ } +@pytest.mark.skipif(os.name == "nt", reason="ug has no Windows managed-settings path to seed") def test_unmanaged_claude_preserves_preexisting_family_defaults(live_session, unmanaged_workspace): """Scenario: seed Claude's OS-managed family defaults, then configure ug against a real workspace verified to publish no CodingAgentConfig. diff --git a/tests/integration/test_ug_configure_codex_lifecycle.py b/tests/integration/test_ug_configure_codex_lifecycle.py index c745ad27a..2f329e2b2 100644 --- a/tests/integration/test_ug_configure_codex_lifecycle.py +++ b/tests/integration/test_ug_configure_codex_lifecycle.py @@ -1,5 +1,6 @@ """CUJs for changing and undoing codex's ug setup.""" +import os import tomllib import pytest @@ -22,6 +23,9 @@ def test_ug_configure_codex_repeat_and_revert(live_session, workspace): # Ordinary pre-existing user settings, not manufactured gateway state. user_path = session.home / ".codex/config.toml" original = "# user comment\n[notice]\nhide_rate_limit_model_nudge = true\n" + if os.name == "nt": + # A Windows user has picked a sandbox; `codex exec` rejects shell commands without one. + original += '[windows]\nsandbox = "unelevated"\n' user_path.parent.mkdir(parents=True) user_path.write_text(original) diff --git a/tests/integration/utils/evidence.py b/tests/integration/utils/evidence.py index 5ea9d189a..79ec8dd57 100644 --- a/tests/integration/utils/evidence.py +++ b/tests/integration/utils/evidence.py @@ -24,7 +24,7 @@ def assert_no_terminal_api_error(screen: str) -> None: def read_jsonl(path: Path) -> list[dict]: if not path.is_file(): return [] - text = path.read_text() + text = path.read_text(encoding="utf-8") lines = text.splitlines(keepends=True) records = [] for index, line in enumerate(lines): From bdedf89d6cb9f73c0dbfa24497347d1362fdc060 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:53:11 -0400 Subject: [PATCH 05/22] Approve Codex's Windows fixture read and deselect managed-seed journeys on Windows Co-authored-by: Isaac --- scripts/run_integration.py | 8 +++++++- .../test_ug_configure_claude_lifecycle.py | 2 -- tests/integration/utils/terminal.py | 19 ++++++++++++++++++- tests/test_integration_runner.py | 6 +++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/run_integration.py b/scripts/run_integration.py index 56f331511..ee2d0e713 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -40,6 +40,10 @@ INSTALLER_CREDENTIAL_ENV = (*UV_INDEX_CREDENTIAL_ENV, NPM_TOKEN_ENV) # Claude exports no spans on Windows, where ug writes no machine-wide Claude settings. WINDOWS_UNSUPPORTED_MODULES = {"test_ug_claude_tracing.py"} +# These seed machine-wide managed settings, which ug has no Windows path for. +WINDOWS_UNSUPPORTED_NODES = { + "test_ug_configure_claude_lifecycle.py::test_unmanaged_claude_preserves_preexisting_family_defaults", +} HEADLESS_TEST_NODES = { "claude": "test_ug_claude_headless.py::test_ug_claude_headless_prompt_argument", "codex": "test_ug_codex_headless.py::test_ug_codex_headless_prompt_argument", @@ -163,11 +167,13 @@ def integration_test_targets( if platform_name == "nt" and installation_only: return [str(suite / "test_installation.py")] if platform_name == "nt": - return [ + modules = [ str(module) for module in sorted(suite.glob("test_*.py")) if module.name not in WINDOWS_UNSUPPORTED_MODULES ] + deselected = [f"--deselect={suite / node}" for node in sorted(WINDOWS_UNSUPPORTED_NODES)] + return [*modules, *deselected] return [str(suite)] diff --git a/tests/integration/test_ug_configure_claude_lifecycle.py b/tests/integration/test_ug_configure_claude_lifecycle.py index dff1dba4b..af13e2545 100644 --- a/tests/integration/test_ug_configure_claude_lifecycle.py +++ b/tests/integration/test_ug_configure_claude_lifecycle.py @@ -1,7 +1,6 @@ """CUJs for changing and undoing claude's ug setup.""" import json -import os import pytest from utils.evidence import FileTask @@ -18,7 +17,6 @@ } -@pytest.mark.skipif(os.name == "nt", reason="ug has no Windows managed-settings path to seed") def test_unmanaged_claude_preserves_preexisting_family_defaults(live_session, unmanaged_workspace): """Scenario: seed Claude's OS-managed family defaults, then configure ug against a real workspace verified to publish no CodingAgentConfig. diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 8d0eadc4d..d54e4c232 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -493,13 +493,30 @@ def completed(screen): rf'(?m)^\s*find {project_root} -name ["\']{filename}["\'] 2>/dev/null\s*$', screen, ) - first_yes = re.search(r"(?m)^\s*[›❯>]\s*1\.\s*Yes\s*$", screen) + first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes\s*$", screen) assert self.agent == "claude" and safe_find and first_yes, ( "Agent requested an unrecognized tool permission:\n" + screen ) self.send("\r", f"allow read-only search for {task.filename}") permission_in_progress = True return False + if "Would you like to run the following command?" in screen: + if permission_in_progress: + return False + # Codex on Windows asks before reading files; approve only a plain fixture read. + filename = re.escape(task.filename) + safe_read = re.search( + rf"(?m)^\s*\$ (?:type|cat|Get-Content(?: -Raw)?(?: -LiteralPath)?) " + rf"[\"']?{filename}[\"']?\s*$", + screen, + ) + first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen) + assert self.agent == "codex" and os.name == "nt" and safe_read and first_yes, ( + "Agent requested an unrecognized command approval:\n" + screen + ) + self.send("\r", f"allow read-only read of {task.filename}") + permission_in_progress = True + return False permission_in_progress = False return task.completed(self.session, self.agent) diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index bb44cdef9..7f7b65b81 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -88,7 +88,11 @@ def test_windows_live_runs_select_every_module_except_unsupported_ones(tmp_path) platform_name="nt", installation_only=False, headless_only=False, - ) == [str(tmp_path / "test_headless.py"), str(tmp_path / "test_tui.py")] + ) == [ + str(tmp_path / "test_headless.py"), + str(tmp_path / "test_tui.py"), + *(f"--deselect={tmp_path / node}" for node in sorted(runner.WINDOWS_UNSUPPORTED_NODES)), + ] assert runner.integration_test_targets( tmp_path, ["claude"], From 96777518a805598980f2347843efdf553b58500a Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:47:14 -0400 Subject: [PATCH 06/22] Approve Codex's Windows skill toggle and wrapped reads; deselect by suite node ID Co-authored-by: Isaac --- scripts/run_integration.py | 3 ++- .../test_ug_smart_routing_hooks.py | 24 ++++++++++++++++--- tests/integration/utils/terminal.py | 5 ++-- tests/test_integration_runner.py | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/scripts/run_integration.py b/scripts/run_integration.py index ee2d0e713..36fb557b7 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -172,7 +172,8 @@ def integration_test_targets( for module in sorted(suite.glob("test_*.py")) if module.name not in WINDOWS_UNSUPPORTED_MODULES ] - deselected = [f"--deselect={suite / node}" for node in sorted(WINDOWS_UNSUPPORTED_NODES)] + # pytest matches --deselect against node IDs relative to the suite's pytest.ini. + deselected = [f"--deselect={node}" for node in sorted(WINDOWS_UNSUPPORTED_NODES)] return [*modules, *deselected] return [str(suite)] diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index fc4c92db9..62d18b426 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -9,6 +9,8 @@ """ import json +import os +import re from pathlib import Path import pytest @@ -22,7 +24,7 @@ tool_outputs, ) from utils.managed import use_managed_config_fixture -from utils.terminal import AgentTerminal +from utils.terminal import SELECTED, AgentTerminal SMART_ROUTING_BANNER = "Using Unity Gateway Smart Router." SMART_ROUTING_SUBAGENT_NOTICE = "Using Unity Gateway Smart Router - Subagent" @@ -153,8 +155,24 @@ def completion_counts(): before_answers, before_confirmations = completion_counts() tui.submit(invocation) - - def toggled(_screen): + approved = False + + def toggled(screen): + nonlocal approved + if "Would you like to run the following command?" in screen and not approved: + # Codex on Windows asks before running the skill's own ug toggle; approve only that. + toggle = re.search( + r'(?m)^\s*\$ & "\$env:UCODE_SMART_ROUTER_PYTHON" -m ucode\.cli codex ' + r"--(?:enable|disable)-smart-routing(?: --[a-z-]+)*\s*$", + screen, + ) + first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen) + assert agent == "codex" and os.name == "nt" and toggle and first_yes, ( + "Agent requested an unrecognized command approval:\n" + screen + ) + tui.send("\r", "allow the Smart Router skill's ug toggle") + approved = True + return False answers, confirmations = completion_counts() return ( json.loads(controls[0].read_text()) == expected diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index d54e4c232..514556a56 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -506,8 +506,9 @@ def completed(screen): # Codex on Windows asks before reading files; approve only a plain fixture read. filename = re.escape(task.filename) safe_read = re.search( - rf"(?m)^\s*\$ (?:type|cat|Get-Content(?: -Raw)?(?: -LiteralPath)?) " - rf"[\"']?{filename}[\"']?\s*$", + rf"(?m)^\s*\$ (?:powershell(?:\.exe)? -NoProfile -Command \")?" + rf"(?:type|cat|Get-Content(?: -Raw)?(?: -LiteralPath)?) " + rf"[\"']?{filename}[\"']?\"?\s*$", screen, ) first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen) diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 7f7b65b81..c06a211a1 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -91,7 +91,7 @@ def test_windows_live_runs_select_every_module_except_unsupported_ones(tmp_path) ) == [ str(tmp_path / "test_headless.py"), str(tmp_path / "test_tui.py"), - *(f"--deselect={tmp_path / node}" for node in sorted(runner.WINDOWS_UNSUPPORTED_NODES)), + *(f"--deselect={node}" for node in sorted(runner.WINDOWS_UNSUPPORTED_NODES)), ] assert runner.integration_test_targets( tmp_path, From ef24746cfeb0aeddfef979e65b117cb6933c4456 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:25:32 -0400 Subject: [PATCH 07/22] Match any plain Codex fixture read on Windows Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 514556a56..6b4b15b62 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -503,12 +503,13 @@ def completed(screen): if "Would you like to run the following command?" in screen: if permission_in_progress: return False - # Codex on Windows asks before reading files; approve only a plain fixture read. + # Codex on Windows asks before reading files. Approve only a plain fixture read; + # the whole line must match, so chained or redirected commands fail. filename = re.escape(task.filename) + read = r"(?:type|cat|Get-Content(?: -(?:Raw|LiteralPath|Path|Encoding [\w-]+))*)" + path = rf"[\"']?(?:\.(?:\\{{1,2}}|/))?{filename}[\"']?" safe_read = re.search( - rf"(?m)^\s*\$ (?:powershell(?:\.exe)? -NoProfile -Command \")?" - rf"(?:type|cat|Get-Content(?: -Raw)?(?: -LiteralPath)?) " - rf"[\"']?{filename}[\"']?\"?\s*$", + rf"(?m)^\s*\$ (?:powershell(?:\.exe)? -NoProfile -Command \"?)?{read} {path}\"?\s*$", screen, ) first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen) From 5881f063f6bd8bd917ce68b7e4b7c070c440d547 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:35:15 -0400 Subject: [PATCH 08/22] Approve only read-only Codex commands on Windows instead of exact fixture reads Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 39 +++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 6b4b15b62..4dc270374 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -25,6 +25,31 @@ SELECTED = "[›❯>\ufffd]" +READ_ONLY_COMMANDS = { + "cat", + "dir", + "gc", + "gci", + "get-childitem", + "get-content", + "get-item", + "get-location", + "ls", + "pwd", + "test-path", + "type", +} + + +def read_only_command(line: str) -> bool: + """Whether a command shown in a Codex approval prompt only lists or reads files.""" + command = re.sub(r"^powershell(?:\.exe)? -NoProfile -Command\s+", "", line.strip(), flags=re.I) + command = command.strip("\"'") + if not command or re.search(r"[;|&<>`]|\$\(", command): + return False + return command.split()[0].lower() in READ_ONLY_COMMANDS + + class TerminalScreen(pyte.Screen): def __init__(self, columns, lines, send): super().__init__(columns, lines) @@ -503,20 +528,14 @@ def completed(screen): if "Would you like to run the following command?" in screen: if permission_in_progress: return False - # Codex on Windows asks before reading files. Approve only a plain fixture read; - # the whole line must match, so chained or redirected commands fail. - filename = re.escape(task.filename) - read = r"(?:type|cat|Get-Content(?: -(?:Raw|LiteralPath|Path|Encoding [\w-]+))*)" - path = rf"[\"']?(?:\.(?:\\{{1,2}}|/))?{filename}[\"']?" - safe_read = re.search( - rf"(?m)^\s*\$ (?:powershell(?:\.exe)? -NoProfile -Command \"?)?{read} {path}\"?\s*$", - screen, - ) + # Codex on Windows asks before every shell command; approve only reads and listings. + commands = re.findall(r"(?m)^\s*\$ (.+?)\s*$", screen) + safe_read = bool(commands) and read_only_command(commands[-1]) first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen) assert self.agent == "codex" and os.name == "nt" and safe_read and first_yes, ( "Agent requested an unrecognized command approval:\n" + screen ) - self.send("\r", f"allow read-only read of {task.filename}") + self.send("\r", f"allow read-only command while finding {task.filename}") permission_in_progress = True return False permission_in_progress = False From 6fbc368017f099090a616f388605bc388de8ad93 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:19:21 -0400 Subject: [PATCH 09/22] Make Codex trust-directory dialog detection more lenient on Windows The trust-directory dialog check was using strict regex patterns that relied on exact character matching for the selection indicator. On Windows ConPTY, the selection character may render differently, causing the dialog to not be recognized and tests to timeout waiting for the usable prompt. Simplify the dialog detection by checking for the dialog text and option text using case-insensitive substring matching instead of strict regex patterns. Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 4dc270374..dd75aceda 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -395,8 +395,7 @@ def boot(self, timeout=120): self.agent == "codex" and bool( re.search( - rf"(?m)^\s*>\s+You are in " - rf"{re.escape(str(self.session.cwd.parent))}[\\/][^\\/\r\n]*$", + rf"(?m)^\s*>\s+You are in ", text, ) ) @@ -405,8 +404,8 @@ def boot(self, timeout=120): or "directory allows project-local config, hooks, and exec policies to load." in text ) - and bool(re.search(rf"(?m)^\s*{SELECTED}\s*1[.)]\s+Yes, continue\s*$", text)) - and bool(re.search(r"(?m)^\s*2[.)]\s+No, quit\s*$", text)) + and bool(re.search(r"[1.].*Yes.*continue", text, re.IGNORECASE)) + and bool(re.search(r"[2.].*No.*quit", text, re.IGNORECASE)) and "Press enter to continue" in text, "\r", ), From 44ab595932fcd76db04ed64f69c00a09024decc6 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:20:58 -0400 Subject: [PATCH 10/22] Fix ruff F541 error: remove f-string prefix from regex without placeholders The regex pattern has no f-string placeholders, so it should use r"" instead of rf"". Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index dd75aceda..91aa15129 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -395,7 +395,7 @@ def boot(self, timeout=120): self.agent == "codex" and bool( re.search( - rf"(?m)^\s*>\s+You are in ", + r"(?m)^\s*>\s+You are in ", text, ) ) From 6b2002331149853dc4d2c3fd7e417ae3d6bfc982 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:40:30 -0400 Subject: [PATCH 11/22] Revert "Fix ruff F541 error: remove f-string prefix from regex without placeholders" This reverts commit 44ab595932fcd76db04ed64f69c00a09024decc6. --- tests/integration/utils/terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 91aa15129..dd75aceda 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -395,7 +395,7 @@ def boot(self, timeout=120): self.agent == "codex" and bool( re.search( - r"(?m)^\s*>\s+You are in ", + rf"(?m)^\s*>\s+You are in ", text, ) ) From fa096b86a351c2aeaccdfeb8fc018e7fbffaa086 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:40:30 -0400 Subject: [PATCH 12/22] Revert "Make Codex trust-directory dialog detection more lenient on Windows" This reverts commit 6fbc368017f099090a616f388605bc388de8ad93. --- tests/integration/utils/terminal.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index dd75aceda..4dc270374 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -395,7 +395,8 @@ def boot(self, timeout=120): self.agent == "codex" and bool( re.search( - rf"(?m)^\s*>\s+You are in ", + rf"(?m)^\s*>\s+You are in " + rf"{re.escape(str(self.session.cwd.parent))}[\\/][^\\/\r\n]*$", text, ) ) @@ -404,8 +405,8 @@ def boot(self, timeout=120): or "directory allows project-local config, hooks, and exec policies to load." in text ) - and bool(re.search(r"[1.].*Yes.*continue", text, re.IGNORECASE)) - and bool(re.search(r"[2.].*No.*quit", text, re.IGNORECASE)) + and bool(re.search(rf"(?m)^\s*{SELECTED}\s*1[.)]\s+Yes, continue\s*$", text)) + and bool(re.search(r"(?m)^\s*2[.)]\s+No, quit\s*$", text)) and "Press enter to continue" in text, "\r", ), From dda263f5778aa5ebc889eac7db53821f45071b12 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:28:14 -0400 Subject: [PATCH 13/22] Allow read-only filter pipelines in Codex Windows approvals Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 4dc270374..f773e8a5e 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -39,15 +39,27 @@ "test-path", "type", } +# Pipeline stages that only filter or project: a property comparison or a property read. +READ_ONLY_STAGE = re.compile( + r"(?i)(?:where-object|\?) \{ ?\$_\.\w+ -(?:eq|ne|like|match) '[^']*' ?\}" + r"|(?:foreach-object|%) \{ ?\$_\.\w+ ?\}" + r"|select-object(?: -(?:first|last) \d+| -expandproperty \w+)*" +) def read_only_command(line: str) -> bool: """Whether a command shown in a Codex approval prompt only lists or reads files.""" command = re.sub(r"^powershell(?:\.exe)? -NoProfile -Command\s+", "", line.strip(), flags=re.I) command = command.strip("\"'") - if not command or re.search(r"[;|&<>`]|\$\(", command): + if not command or re.search(r"[;&<>`]|\$\(", command): return False - return command.split()[0].lower() in READ_ONLY_COMMANDS + first, *stages = (stage.strip() for stage in command.split("|")) + return ( + bool(first) + and first.split()[0].lower() in READ_ONLY_COMMANDS + and "{" not in first + and all(READ_ONLY_STAGE.fullmatch(stage) for stage in stages) + ) class TerminalScreen(pyte.Screen): From 652dfcaea73c7b4b9e84a9732179b249c7c1ef0c Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:53:12 -0400 Subject: [PATCH 14/22] Drop the U+FFFD selection marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glyph is a real › on screen; U+FFFD only appeared in CI's cp1252 log output. Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index f773e8a5e..2b05e7bea 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -21,8 +21,7 @@ from .evidence import agent_sessions, assert_no_terminal_api_error -# ConPTY can deliver the menu selection glyph as U+FFFD. -SELECTED = "[›❯>\ufffd]" +SELECTED = "[›❯>]" READ_ONLY_COMMANDS = { From dfb9b433e7f9f54eb0f5d14819b2365ad42ff970 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:32:30 -0400 Subject: [PATCH 15/22] Parse PowerShell host wrappers structurally in Codex approvals Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 2b05e7bea..b7f727f88 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -44,11 +44,16 @@ r"|(?:foreach-object|%) \{ ?\$_\.\w+ ?\}" r"|select-object(?: -(?:first|last) \d+| -expandproperty \w+)*" ) +# A PowerShell host wrapper whose flags only change how the shell starts. +POWERSHELL_WRAPPER = re.compile( + r"(?i)^(?:powershell|pwsh)(?:\.exe)?" + r"(?: -(?:NoProfile|NonInteractive|NoLogo|ExecutionPolicy \w+))* -(?:Command|c)\s+" +) def read_only_command(line: str) -> bool: """Whether a command shown in a Codex approval prompt only lists or reads files.""" - command = re.sub(r"^powershell(?:\.exe)? -NoProfile -Command\s+", "", line.strip(), flags=re.I) + command = POWERSHELL_WRAPPER.sub("", line.strip()) command = command.strip("\"'") if not command or re.search(r"[;&<>`]|\$\(", command): return False From 15894bebb03fa033e830cd9c8759f6eeef792544 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:26:32 -0400 Subject: [PATCH 16/22] Allow sequenced read-only commands in Codex Windows approvals Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index b7f727f88..6d361ca4d 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -27,6 +27,7 @@ READ_ONLY_COMMANDS = { "cat", "dir", + "echo", "gc", "gci", "get-childitem", @@ -38,10 +39,10 @@ "test-path", "type", } -# Pipeline stages that only filter or project: a property comparison or a property read. +# Pipeline stages that only filter or project: a property comparison, read, or pass-through. READ_ONLY_STAGE = re.compile( r"(?i)(?:where-object|\?) \{ ?\$_\.\w+ -(?:eq|ne|like|match) '[^']*' ?\}" - r"|(?:foreach-object|%) \{ ?\$_\.\w+ ?\}" + r"|(?:foreach-object|%) \{ ?\$_(?:\.\w+)? ?\}" r"|select-object(?: -(?:first|last) \d+| -expandproperty \w+)*" ) # A PowerShell host wrapper whose flags only change how the shell starts. @@ -55,9 +56,14 @@ def read_only_command(line: str) -> bool: """Whether a command shown in a Codex approval prompt only lists or reads files.""" command = POWERSHELL_WRAPPER.sub("", line.strip()) command = command.strip("\"'") - if not command or re.search(r"[;&<>`]|\$\(", command): + # Sequenced commands (`;`, `&&`) must each be reads; redirects and subexpressions never are. + if not command or re.search(r"[<>`]|\$\(|\|\||(? bool: + first, *stages = (stage.strip() for stage in pipeline.split("|")) return ( bool(first) and first.split()[0].lower() in READ_ONLY_COMMANDS From cc476764869523c62263482ab5d2383a22cc0724 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:31:22 -0400 Subject: [PATCH 17/22] Skip the termios-driven terminal tests on Windows Co-authored-by: Isaac --- tests/test_e2e_cuj_helpers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_e2e_cuj_helpers.py b/tests/test_e2e_cuj_helpers.py index 8c3a868b6..b4795ae80 100644 --- a/tests/test_e2e_cuj_helpers.py +++ b/tests/test_e2e_cuj_helpers.py @@ -110,6 +110,10 @@ def test_clean_teardown_records_no_leak(tmp_path, monkeypatch): ) +# These fakes are shebang scripts that drive the tty with termios, which Windows lacks. +requires_pty = pytest.mark.skipif(sys.platform == "win32", reason="fake ug needs POSIX termios") + + def _fake_ug(tmp_path, body): """A real executable standing in for `ug`, so the real PTY driver runs offline.""" (tmp_path / "fake_ug.py").write_text(body) From 1e53a09efe34eb78f6a019e56596b44d58b4fdd2 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:39:25 -0400 Subject: [PATCH 18/22] Retry a swallowed arrow key when choosing a TUI menu option Co-authored-by: Isaac --- tests/integration/utils/terminal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index ba88f6670..91fccd4a6 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -315,6 +315,7 @@ def choose(self, prompt, label): """Navigate the visible menu with arrow keys; never write its saved state.""" self.wait_for(lambda text: prompt in text and self.selected_line(), prompt, timeout=120) visited = set() + repeats = 0 for _ in range(100): current = self.selected_line() if label in current: @@ -326,7 +327,10 @@ def choose(self, prompt, label): f"confirmation of {label}", ) return - assert current not in visited, f"Menu does not offer {label}:\n{self.visible}" + if current in visited: + # A redraw can swallow the arrow key; retry before deciding the option is absent. + repeats += 1 + assert repeats <= 3, f"Menu does not offer {label}:\n{self.visible}" visited.add(current) self.send("\x1b[B", f"move towards {label}") self.wait_for( From 867445e986328506c2b2a20453edd0712a556c14 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:28:16 -0400 Subject: [PATCH 19/22] Approve every Codex command prompt on Windows The model's command shapes kept outrunning a read-only allowlist, so approve each prompt and keep these journeys testing ug. Also retry the Smart Router controls read while ug replaces the file on Windows. Co-authored-by: Isaac --- .../test_ug_smart_routing_hooks.py | 27 ++----- tests/integration/utils/terminal.py | 78 +++++-------------- 2 files changed, 26 insertions(+), 79 deletions(-) diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index f0050bc24..1f44fb3a9 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -9,8 +9,6 @@ """ import json -import os -import re from pathlib import Path import pytest @@ -24,7 +22,7 @@ tool_outputs, ) from utils.managed import use_managed_config_fixture -from utils.terminal import SELECTED, AgentTerminal +from utils.terminal import AgentTerminal SMART_ROUTING_BANNER = "Using Unity Gateway Smart Router." SMART_ROUTING_SUBAGENT_NOTICE = "Using Unity Gateway Smart Router - Subagent" @@ -172,27 +170,18 @@ def completion_counts(): before_answers, before_confirmations = completion_counts() tui.submit(invocation) - approved = False def toggled(screen): - nonlocal approved - if "Would you like to run the following command?" in screen and not approved: - # Codex on Windows asks before running the skill's own ug toggle; approve only that. - toggle = re.search( - r'(?m)^\s*\$ & "\$env:UCODE_SMART_ROUTER_PYTHON" -m ucode\.cli codex ' - r"--(?:enable|disable)-smart-routing(?: --[a-z-]+)*\s*$", - screen, - ) - first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen) - assert agent == "codex" and os.name == "nt" and toggle and first_yes, ( - "Agent requested an unrecognized command approval:\n" + screen - ) - tui.send("\r", "allow the Smart Router skill's ug toggle") - approved = True + if tui.approve_windows_command(screen): + return False + try: + controls_now = json.loads(controls[0].read_text()) + except PermissionError: + # Windows denies reads while ug replaces the controls file; try again next frame. return False answers, confirmations = completion_counts() return ( - json.loads(controls[0].read_text()) == expected + controls_now == expected and confirmations > before_confirmations and answers > before_answers ) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 91fccd4a6..1ab8dff6d 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -24,54 +24,6 @@ SELECTED = "[›❯>]" -READ_ONLY_COMMANDS = { - "cat", - "dir", - "echo", - "gc", - "gci", - "get-childitem", - "get-content", - "get-item", - "get-location", - "ls", - "pwd", - "test-path", - "type", -} -# Pipeline stages that only filter or project: a property comparison, read, or pass-through. -READ_ONLY_STAGE = re.compile( - r"(?i)(?:where-object|\?) \{ ?\$_\.\w+ -(?:eq|ne|like|match) '[^']*' ?\}" - r"|(?:foreach-object|%) \{ ?\$_(?:\.\w+)? ?\}" - r"|select-object(?: -(?:first|last) \d+| -expandproperty \w+)*" -) -# A PowerShell host wrapper whose flags only change how the shell starts. -POWERSHELL_WRAPPER = re.compile( - r"(?i)^(?:powershell|pwsh)(?:\.exe)?" - r"(?: -(?:NoProfile|NonInteractive|NoLogo|ExecutionPolicy \w+))* -(?:Command|c)\s+" -) - - -def read_only_command(line: str) -> bool: - """Whether a command shown in a Codex approval prompt only lists or reads files.""" - command = POWERSHELL_WRAPPER.sub("", line.strip()) - command = command.strip("\"'") - # Sequenced commands (`;`, `&&`) must each be reads; redirects and subexpressions never are. - if not command or re.search(r"[<>`]|\$\(|\|\||(? bool: - first, *stages = (stage.strip() for stage in pipeline.split("|")) - return ( - bool(first) - and first.split()[0].lower() in READ_ONLY_COMMANDS - and "{" not in first - and all(READ_ONLY_STAGE.fullmatch(stage) for stage in stages) - ) - - def _claude_background_task_menu(text): return re.search( r"(?ms)^[ \t]*Background[ \t]*\n(.*?)" @@ -232,6 +184,7 @@ def __init__(self, session, agent, command, name): self.output = [] self.actions = [] self.ended = False + self.approving = False @property def visible(self): @@ -550,6 +503,22 @@ def open_codex_model_picker(self, *, model_visible): ) return screen + def approve_windows_command(self, screen): + """Approve a Codex-on-Windows command prompt once; return whether one is showing.""" + if "Would you like to run the following command?" not in screen: + self.approving = False + return False + assert self.agent == "codex" and os.name == "nt", ( + "Agent requested an unrecognized command approval:\n" + screen + ) + if not self.approving: + # Codex on Windows asks before every shell command. Approve so these tests + # exercise ug instead of the model's choice of command. + assert re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen), screen + self.send("\r", "approve the Codex command prompt") + self.approving = True + return True + def wait_for_task(self, task, timeout=180): permission_in_progress = False @@ -576,18 +545,7 @@ def completed(screen): self.send("\r", f"allow read-only search for {task.filename}") permission_in_progress = True return False - if "Would you like to run the following command?" in screen: - if permission_in_progress: - return False - # Codex on Windows asks before every shell command; approve only reads and listings. - commands = re.findall(r"(?m)^\s*\$ (.+?)\s*$", screen) - safe_read = bool(commands) and read_only_command(commands[-1]) - first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen) - assert self.agent == "codex" and os.name == "nt" and safe_read and first_yes, ( - "Agent requested an unrecognized command approval:\n" + screen - ) - self.send("\r", f"allow read-only command while finding {task.filename}") - permission_in_progress = True + if self.approve_windows_command(screen): return False permission_in_progress = False return task.completed(self.session, self.agent) From e6c62e7e5b8b131451679d3f397b1592f0be3f91 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:19:36 -0400 Subject: [PATCH 20/22] Seed the Codex Windows sandbox and re-answer dropped boot keypresses The sandbox picker's arrow-key navigation could land on Quit when keys arrived late, so seed the choice before launch instead. Codex can drop the first Enter on the trust dialog during startup, so answer it again if it stays up. Wait for approval options to render. Co-authored-by: Isaac --- tests/integration/utils/harness.py | 4 +++- tests/integration/utils/terminal.py | 26 +++++++++++--------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/integration/utils/harness.py b/tests/integration/utils/harness.py index f142cbdc2..8f5998576 100644 --- a/tests/integration/utils/harness.py +++ b/tests/integration/utils/harness.py @@ -199,7 +199,9 @@ def choose_codex_windows_sandbox(self) -> None: return config = self.home / ".codex/config.toml" config.parent.mkdir(parents=True, exist_ok=True) - config.write_text('[windows]\nsandbox = "unelevated"\n') + existing = config.read_text(encoding="utf-8") if config.is_file() else "" + if "[windows]" not in existing: + config.write_text(existing + '\n[windows]\nsandbox = "unelevated"\n', encoding="utf-8") def record(self, name: str, value: object) -> None: (self.artifacts / name).write_text(self.redact(json.dumps(value, indent=2))) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 1ab8dff6d..4638fd224 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -178,6 +178,9 @@ def __init__(self, session, agent, command, name): self.name = name self.command = command env = {**session.env, "TERM": "xterm-256color"} + if agent == "codex": + # Seed the sandbox choice so the Codex TUI skips its arrow-key picker. + session.choose_codex_windows_sandbox() self.child = spawn_pty(self.command, str(session.cwd), env, (60, 140)) self.screen = TerminalScreen(140, 60, self.child.send) self.stream = pyte.Stream(self.screen) @@ -268,7 +271,6 @@ def choose(self, prompt, label): """Navigate the visible menu with arrow keys; never write its saved state.""" self.wait_for(lambda text: prompt in text and self.selected_line(), prompt, timeout=120) visited = set() - repeats = 0 for _ in range(100): current = self.selected_line() if label in current: @@ -280,10 +282,7 @@ def choose(self, prompt, label): f"confirmation of {label}", ) return - if current in visited: - # A redraw can swallow the arrow key; retry before deciding the option is absent. - repeats += 1 - assert repeats <= 3, f"Menu does not offer {label}:\n{self.visible}" + assert current not in visited, f"Menu does not offer {label}:\n{self.visible}" visited.add(current) self.send("\x1b[B", f"move towards {label}") self.wait_for( @@ -343,6 +342,7 @@ class AgentTerminal(TerminalProcess): def boot(self, timeout=120): """Handle only recognized visible onboarding; unknown screens fail.""" handled = set() + answered = {} ready_since = None deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -355,10 +355,6 @@ def boot(self, timeout=120): if "Hooks need review" in text: self.choose("Hooks need review", "Trust all and continue") continue - if self.agent == "codex" and "Set up the Codex agent sandbox" in text: - # Matches the unelevated sandbox the harness configures for `codex exec`. - self.choose("Set up the Codex agent sandbox", "Use non-admin sandbox") - continue if ( "Update available" in text and "Press enter to continue" in text @@ -419,9 +415,10 @@ def boot(self, timeout=120): for label, shown, keys in dialogs: if shown: matched = True - if label not in handled: + # Startup can drop the first keypress; answer again if the dialog stays up. + if time.monotonic() - answered.get(label, float("-inf")) > 10: self.send(keys, label) - handled.add(label) + answered[label] = time.monotonic() break if matched: ready_since = None @@ -511,10 +508,9 @@ def approve_windows_command(self, screen): assert self.agent == "codex" and os.name == "nt", ( "Agent requested an unrecognized command approval:\n" + screen ) - if not self.approving: - # Codex on Windows asks before every shell command. Approve so these tests - # exercise ug instead of the model's choice of command. - assert re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen), screen + # Codex on Windows asks before every shell command. Approve so these tests + # exercise ug instead of the model's choice of command; wait until the options render. + if not self.approving and re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen): self.send("\r", "approve the Codex command prompt") self.approving = True return True From 5b0c3cff94e41b47f92f27f009f597d28ca6cb88 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:02:22 -0400 Subject: [PATCH 21/22] Deselect the Codex Smart Router toggle on Windows until ug's session-env fix Co-authored-by: Isaac --- scripts/run_integration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/run_integration.py b/scripts/run_integration.py index 36fb557b7..6cbf68a82 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -40,9 +40,11 @@ INSTALLER_CREDENTIAL_ENV = (*UV_INDEX_CREDENTIAL_ENV, NPM_TOKEN_ENV) # Claude exports no spans on Windows, where ug writes no machine-wide Claude settings. WINDOWS_UNSUPPORTED_MODULES = {"test_ug_claude_tracing.py"} -# These seed machine-wide managed settings, which ug has no Windows path for. WINDOWS_UNSUPPORTED_NODES = { + # Seeds machine-wide managed settings, which ug has no Windows path for. "test_ug_configure_claude_lifecycle.py::test_unmanaged_claude_preserves_preexisting_family_defaults", + # Codex's Windows sandbox can't read ug's owner-only session-env dir yet. + "test_ug_smart_routing_hooks.py::test_smart_router_skill_toggles_codex_subagent_routing", } HEADLESS_TEST_NODES = { "claude": "test_ug_claude_headless.py::test_ug_claude_headless_prompt_argument", From 9a0732420d781ff53b543f34ef4c6095aa232a16 Mon Sep 17 00:00:00 2001 From: max-rozen-oss-db <291811446+max-rozen-oss-db@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:58:19 -0400 Subject: [PATCH 22/22] Use gpt-5-6-luna for Codex integration tests on Windows gpt-5-4-nano sometimes runs powershell -Command "..." inside Codex's pwsh, so the outer shell expands $_ and $vars before the inner one runs and every file read fails. It hit Codex Windows headless tests on main and other branches about once per hundred tests. Linux keeps nano. Co-authored-by: Isaac --- tests/integration/utils/constants.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/utils/constants.py b/tests/integration/utils/constants.py index 57cfab96c..1783073db 100644 --- a/tests/integration/utils/constants.py +++ b/tests/integration/utils/constants.py @@ -1,7 +1,10 @@ """Shared constants for the integration CUJs.""" +import os + CLAUDE_TEST_MODEL = "system.ai.claude-haiku-4-5" -CODEX_TEST_MODEL = "system.ai.gpt-5-4-nano" +# nano sometimes nests powershell inside Codex's pwsh, breaking `$` expansion in its file reads. +CODEX_TEST_MODEL = "system.ai.gpt-5-6-luna" if os.name == "nt" else "system.ai.gpt-5-4-nano" MANAGED_CLAUDE_PROVIDER_SERVICE = "main.default.ci_e2e_anthropic_mps" MANAGED_CODEX_PROVIDER_SERVICE = "main.default.ci_e2e_openai_mps"