From b8851d471d9b74cc9bf402b4ee67f7f25354cc12 Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Sun, 4 Oct 2026 18:20:37 +0800 Subject: [PATCH 1/9] fix(skills): publish by exclusive copy where hard links are unavailable Android's CPython has no os.link, and skills_store read it at import time while computing _DIRECTORY_FDS, so the whole backend failed to start there. Detect hard-link support instead and, without it, publish each file with an O_EXCL copy that still refuses to replace an entry that raced us; rollback compares against the copy's own inode. Co-Authored-By: Claude Opus 5.5 --- backend/agent_team_backend/skills_store.py | 34 ++++++++++++++++--- backend/tests/test_skills_store.py | 38 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/backend/agent_team_backend/skills_store.py b/backend/agent_team_backend/skills_store.py index 3b588fa21..2d1ed1221 100644 --- a/backend/agent_team_backend/skills_store.py +++ b/backend/agent_team_backend/skills_store.py @@ -49,16 +49,37 @@ _STATE_FILE_SIZE_LIMIT = 1_000_000 _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _AGENT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") +#: Android's CPython has no os.link; publishing falls back to an exclusive copy. +_HARD_LINKS = hasattr(os, "link") _DIRECTORY_FDS = hasattr(os, "O_DIRECTORY") and all( operation in os.supports_dir_fd - for operation in (os.open, os.mkdir, os.link, os.stat, os.unlink, os.rmdir) -) + for operation in (os.open, os.mkdir, os.stat, os.unlink, os.rmdir) +) and (not _HARD_LINKS or os.link in os.supports_dir_fd) _FRONTMATTER_RE = re.compile( r"\A---[ \t]*\r?\n(?P.*?)^---[ \t]*(?:\r?\n|\Z)(?P.*)\Z", re.MULTILINE | re.DOTALL, ) +def _copy_exclusive(source: Path, target: Path, dir_fd: int | None) -> os.stat_result: + """Publish ``source`` at ``target`` without hard links. + + Still refuses to replace an entry that raced us (O_EXCL), but the bytes + land with a write, not atomically. Returns the new entry's stat, which is + what rollback compares against. + """ + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + mode = stat.S_IMODE(source.stat().st_mode) + if dir_fd is None: + fd = os.open(target, flags, mode) + else: + fd = os.open(target.name, flags, mode, dir_fd=dir_fd) + with os.fdopen(fd, "wb") as out, source.open("rb") as data: + os.fchmod(out.fileno(), mode) + shutil.copyfileobj(data, out) + return os.fstat(out.fileno()) + + def agent_targets() -> list[dict[str, Any]]: """Every CLI vendor and what the managed library can actually do with it. @@ -818,11 +839,16 @@ def claim_directory(directory: Path) -> None: else: # Same-filesystem hard links publish complete file # bytes atomically and fail if an entry raced us. - if _DIRECTORY_FDS: + if not _HARD_LINKS: + published.append((target, _copy_exclusive( + child, target, directory_fds[target.parent] if _DIRECTORY_FDS else None, + ))) + elif _DIRECTORY_FDS: os.link(child, target.name, dst_dir_fd=directory_fds[target.parent]) + published.append((target, child.stat())) else: os.link(child, target) - published.append((target, child.stat())) + published.append((target, child.stat())) check_directories() state[name] = True if targets is not None: diff --git a/backend/tests/test_skills_store.py b/backend/tests/test_skills_store.py index 7ef1bc81e..377454b41 100644 --- a/backend/tests/test_skills_store.py +++ b/backend/tests/test_skills_store.py @@ -922,3 +922,41 @@ def test_migrate_then_restore_round_trips_the_state_file( store.restore_native("bug-buster") assert store.targets_for("claude") == [] + + +# ── platforms without hard links (Android's CPython has no os.link) ────────── + +def test_module_imports_without_os_link() -> None: + import subprocess + import sys + + result = subprocess.run( + [sys.executable, "-c", "import os; del os.link; import agent_team_backend.skills_store"], + capture_output=True, text=True, cwd=Path(__file__).resolve().parents[1], + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("directory_fds", [True, False]) +def test_install_bundle_copies_when_hard_links_are_unavailable( + store: SkillsStore, monkeypatch: pytest.MonkeyPatch, directory_fds: bool, +) -> None: + if directory_fds and not skills_store._DIRECTORY_FDS: + pytest.skip("directory file descriptors are unavailable here") + monkeypatch.delattr(skills_store.os, "link") + monkeypatch.setattr(skills_store, "_HARD_LINKS", False) + monkeypatch.setattr(skills_store, "_DIRECTORY_FDS", directory_fds) + skill = b"---\nname: copied\ndescription: Copied skill\n---\nBody.\n" + files = { + "SKILL.md": {"data": skill, "executable": False}, + "bin/run.sh": {"data": b"#!/bin/sh\necho hi\n", "executable": True}, + } + + store.install_bundle("copied", files, targets=None, consent=True) + + root = store.root / "copied" + assert (root / "SKILL.md").read_bytes() == skill + assert (root / "bin" / "run.sh").read_bytes() == b"#!/bin/sh\necho hi\n" + assert os.stat(root / "bin" / "run.sh").st_mode & 0o777 == 0o755 + assert os.stat(root / "SKILL.md").st_mode & 0o777 == 0o644 + assert store.get_skill("copied")["skill"]["name"] == "copied" From 1b338565da8ad55661666de37c6c12a816aa74b6 Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Sun, 4 Oct 2026 18:20:37 +0800 Subject: [PATCH 2/9] fix(codex): promote a stranded login without hard links promote_stranded_auth hard-linked the newest in-pane auth.json into the real Codex home. Where os.link does not exist (Android) it raised AttributeError past the OSError handler. Fall back to an exclusive copy that keeps the source mode, so a concurrent writer still wins. Co-Authored-By: Claude Opus 5.5 --- .../agent_team_backend/cli_vendors/codex.py | 11 ++++++++- backend/tests/test_codex_home.py | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/backend/agent_team_backend/cli_vendors/codex.py b/backend/agent_team_backend/cli_vendors/codex.py index ec4e45e48..e175e3dda 100644 --- a/backend/agent_team_backend/cli_vendors/codex.py +++ b/backend/agent_team_backend/cli_vendors/codex.py @@ -25,6 +25,7 @@ import re import shutil import sqlite3 +import stat import threading import time @@ -888,7 +889,15 @@ def promote_stranded_auth(self) -> bool: newest = max(stranded, key=lambda p: p.stat().st_mtime) self.real_home.mkdir(parents=True, exist_ok=True) try: - os.link(newest, real_auth) + if hasattr(os, "link"): + os.link(newest, real_auth) + else: + # Android's CPython has no os.link: an exclusive copy + # keeps "a concurrent writer beating us simply wins". + fd = os.open(real_auth, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as out: + os.fchmod(out.fileno(), stat.S_IMODE(newest.stat().st_mode)) + out.write(newest.read_bytes()) except FileExistsError: return False except OSError as err: diff --git a/backend/tests/test_codex_home.py b/backend/tests/test_codex_home.py index 053fc02fa..0fb5a22db 100644 --- a/backend/tests/test_codex_home.py +++ b/backend/tests/test_codex_home.py @@ -523,3 +523,27 @@ def test_seed_hook_trust_escapes_backslashes_in_toml_keys(tmp_path: Path) -> Non assert manager.seed_hook_trust(pane) == 1 state = tomllib.loads((real / "config.toml").read_text(encoding="utf-8"))["hooks"]["state"] assert state[f'{pane / "hooks.json"}:stop:0:0'] == {"trusted_hash": "sha256:aaaa"} + + +def test_promote_stranded_auth_copies_without_hard_links( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Android's CPython has no os.link; promotion must still adopt the login. + from agent_team_backend.cli_vendors import codex as codex_vendor + + monkeypatch.delattr(codex_vendor.os, "link") + real = tmp_path / "real-codex" + panes = tmp_path / "panes" + manager = CodexHomeManager(real_home=real, panes_root=panes) + stranded = panes / "pane-1" / "auth.json" + _write_pane_auth(stranded, "pane-token") + stranded.chmod(0o600) + + assert manager.promote_stranded_auth() is True + + real_auth = real / "auth.json" + assert real_auth.is_file() and not real_auth.is_symlink() + assert "pane-token" in real_auth.read_text(encoding="utf-8") + assert os.stat(real_auth).st_mode & 0o777 == 0o600 + assert stranded.is_symlink() and stranded.resolve() == real_auth + assert manager.promote_stranded_auth() is False From dc1ff5747f07c0e63b35f7a9c45d5bc392675f0d Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Sun, 4 Oct 2026 18:20:37 +0800 Subject: [PATCH 3/9] fix(deps): declare packaging as a runtime dependency plugins/host.py imports packaging.version at load, but packaging only arrived through the dev group (pytest, pyinstaller). An install with runtime dependencies alone could not import the backend. Co-Authored-By: Claude Opus 5.5 --- backend/pyproject.toml | 4 +++ backend/tests/test_runtime_dependencies.py | 30 ++++++++++++++++++++++ backend/uv.lock | 2 ++ 3 files changed, 36 insertions(+) create mode 100644 backend/tests/test_runtime_dependencies.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8d6c93e4a..6b9360e27 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -32,6 +32,10 @@ dependencies = [ # been migrated to. "mcp>=1.28.1,<2", "openpyxl>=3.1.5", + # Imported by plugins/host.py for version comparison. It used to arrive + # only through the dev group (pytest, pyinstaller), so an install with + # runtime dependencies alone could not start the backend. + "packaging", # Process-tree walking and per-process counters for the Windows # implementation of osplat.process_tree / resource_probe (psutil has # binary wheels for every platform the backend ships on, so it costs the diff --git a/backend/tests/test_runtime_dependencies.py b/backend/tests/test_runtime_dependencies.py new file mode 100644 index 000000000..d318bbbde --- /dev/null +++ b/backend/tests/test_runtime_dependencies.py @@ -0,0 +1,30 @@ +"""Runtime imports must be declared as runtime dependencies. + +A package that only arrives through the dev group (pytest, pyinstaller) is +present in every developer and CI environment, so nothing notices until an +install that carries runtime dependencies only fails to start the backend. +""" +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +BACKEND = Path(__file__).resolve().parents[1] + + +def _runtime_dependency_names() -> set[str]: + project = tomllib.loads((BACKEND / "pyproject.toml").read_text(encoding="utf-8"))["project"] + names = set() + for spec in project["dependencies"]: + match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", spec) + assert match, spec + names.add(match.group(1).lower().replace("_", "-")) + return names + + +def test_packaging_is_a_declared_runtime_dependency() -> None: + # plugins/host.py imports packaging.version at module load. + source = (BACKEND / "agent_team_backend" / "plugins" / "host.py").read_text(encoding="utf-8") + assert "from packaging.version import" in source + assert "packaging" in _runtime_dependency_names() diff --git a/backend/uv.lock b/backend/uv.lock index 41725c69b..1ab7f9d97 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -23,6 +23,7 @@ dependencies = [ { name = "mammoth" }, { name = "mcp" }, { name = "openpyxl" }, + { name = "packaging" }, { name = "psutil" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, @@ -54,6 +55,7 @@ requires-dist = [ { name = "mammoth", specifier = ">=1.12.0" }, { name = "mcp", specifier = ">=1.28.1,<2" }, { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "packaging" }, { name = "psutil", specifier = ">=7" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=3.0" }, { name = "pyyaml", specifier = ">=6.0" }, From 81f82f193541e3368ffc7871e6666c282e0f87eb Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Sun, 4 Oct 2026 20:52:18 +0800 Subject: [PATCH 4/9] fix(osplat): open a pty from /dev/ptmx where os.openpty is missing Chaquopy's CPython on Android has neither os.openpty nor os.forkpty, so every terminal spawn failed there. Unlock /dev/ptmx and open the slave by number (TIOCSPTLCK, TIOCGPTN) instead. Wherever os.openpty exists, which is every desktop build, spawn still calls pty.openpty unchanged. Co-Authored-By: Claude Opus 5.5 --- backend/agent_team_backend/osplat/_posix.py | 24 ++++++++- backend/tests/test_osplat_android_compat.py | 55 +++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_osplat_android_compat.py diff --git a/backend/agent_team_backend/osplat/_posix.py b/backend/agent_team_backend/osplat/_posix.py index 1470307ec..0cd649ff1 100644 --- a/backend/agent_team_backend/osplat/_posix.py +++ b/backend/agent_team_backend/osplat/_posix.py @@ -297,6 +297,28 @@ def close(self) -> None: pass +# Linux pty ioctls, for interpreters built without os.openpty (Chaquopy's +# CPython on Android lacks it and os.forkpty): unlock the master, ask for the +# slave's number. Values are the asm-generic ones every Android ABI uses. +_TIOCSPTLCK = getattr(termios, "TIOCSPTLCK", 0x40045431) +_TIOCGPTN = getattr(termios, "TIOCGPTN", 0x80045430) + + +def _openpty() -> tuple[int, int]: + """`pty.openpty`, or the same pair opened from `/dev/ptmx` by hand.""" + if hasattr(os, "openpty"): + return pty.openpty() + master = os.open("/dev/ptmx", os.O_RDWR | os.O_NOCTTY) + try: + fcntl.ioctl(master, _TIOCSPTLCK, struct.pack("i", 0)) + number = struct.unpack("I", fcntl.ioctl(master, _TIOCGPTN, b"\0" * 4))[0] + slave = os.open(f"/dev/pts/{number}", os.O_RDWR | os.O_NOCTTY) + except BaseException: + os.close(master) + raise + return master, slave + + class PosixTerminalBackend: """`pty.openpty` + `Popen(start_new_session=True, preexec_fn=_claim_ctty)`.""" @@ -322,7 +344,7 @@ def spawn( ) -> PosixTerminalHandle: if isinstance(argv, str): argv = self.parse_command(argv) - master, slave = pty.openpty() + master, slave = _openpty() _set_winsize(master, rows, cols) flags = fcntl.fcntl(master, fcntl.F_GETFL) fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK) diff --git a/backend/tests/test_osplat_android_compat.py b/backend/tests/test_osplat_android_compat.py new file mode 100644 index 000000000..d6edcf5f8 --- /dev/null +++ b/backend/tests/test_osplat_android_compat.py @@ -0,0 +1,55 @@ +"""POSIX seams on Android, with the desktop behaviour locked in place. + +Android has no /bin (only /system/bin/sh), and Chaquopy's CPython has neither +os.openpty nor os.forkpty. Every fallback below must leave macOS and desktop +Linux exactly as they were, which the *_desktop_* tests pin. +""" +from __future__ import annotations + +import os +import struct +import sys + +import pytest + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX seams") + +from agent_team_backend.osplat import _posix # noqa: E402 + + +# ── PTY without os.openpty ─────────────────────────────────────────────────── + +def test_openpty_desktop_uses_pty_openpty(monkeypatch: pytest.MonkeyPatch) -> None: + assert hasattr(os, "openpty") + monkeypatch.setattr(_posix.pty, "openpty", lambda: (101, 102)) + assert _posix._openpty() == (101, 102) + + +def test_openpty_without_os_openpty_unlocks_dev_ptmx(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delattr(_posix.os, "openpty") + opened, ioctls = [], [] + + def fake_open(path, flags, *a, **k): + opened.append(path) + return {"/dev/ptmx": 50, "/dev/pts/7": 51}[path] + + def fake_ioctl(fd, request, arg=0, *a): + ioctls.append((fd, request)) + return struct.pack("I", 7) if request == _posix._TIOCGPTN else arg + + monkeypatch.setattr(_posix.os, "open", fake_open) + monkeypatch.setattr(_posix.fcntl, "ioctl", fake_ioctl) + assert _posix._openpty() == (50, 51) + assert opened == ["/dev/ptmx", "/dev/pts/7"] + assert ioctls == [(50, _posix._TIOCSPTLCK), (50, _posix._TIOCGPTN)] + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="/dev/ptmx ioctls are Linux's") +def test_openpty_ptmx_round_trip_on_linux(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delattr(_posix.os, "openpty") + master, slave = _posix._openpty() + try: + os.write(master, b"ping\n") + assert os.read(slave, 16).strip() == b"ping" + finally: + os.close(master); os.close(slave) From b4ebaef8c596306eb62dcff29b57b395368460e3 Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Sun, 4 Oct 2026 20:52:18 +0800 Subject: [PATCH 5/9] fix(osplat): find sh and bash where /bin does not exist Android has only /system/bin/sh, and Termux keeps its shells under its own prefix, so shell_command and the login-path probe pointed at files that are not there. Use /bin/sh and /bin/bash when they exist, as before, and fall back to PATH and then /system/bin/sh. Co-Authored-By: Claude Opus 5.5 --- .../agent_team_backend/osplat/_posix_paths.py | 19 ++++++++-- backend/tests/test_osplat_android_compat.py | 35 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/backend/agent_team_backend/osplat/_posix_paths.py b/backend/agent_team_backend/osplat/_posix_paths.py index 7520465d1..a7ca88b5a 100644 --- a/backend/agent_team_backend/osplat/_posix_paths.py +++ b/backend/agent_team_backend/osplat/_posix_paths.py @@ -111,7 +111,7 @@ def login_path_probe(*, interactive_bash: bool = False) -> list[str]: an interactive-but-headless shell in mind, and that is what shipped there. Same split as `loginShellFlags` in src/shared/osplat.ts. """ - shell = os.environ.get("SHELL") or "/bin/bash" + shell = os.environ.get("SHELL") or _default_login_shell() name = os.path.basename(shell) interactive = name == "zsh" or (interactive_bash and name == "bash") return [shell, "-ilc" if interactive else "-lc", LOGIN_PATH_PROBE_SCRIPT] @@ -209,8 +209,23 @@ def symlinks_available() -> bool: return True +def _system_sh() -> str: + """`/bin/sh`, or where `sh` lives on a system without `/bin` (Android has + only `/system/bin/sh`; Termux puts its own under its prefix).""" + if os.path.exists("/bin/sh"): + return "/bin/sh" + return shutil.which("sh") or "/system/bin/sh" + + +def _default_login_shell() -> str: + """`/bin/bash` when $SHELL is unset, falling back the same way as `sh`.""" + if os.path.exists("/bin/bash"): + return "/bin/bash" + return shutil.which("bash") or _system_sh() + + def shell_command(command: str) -> list[str]: - return ["/bin/sh", "-c", command] + return [_system_sh(), "-c", command] def quote_arg(arg: str) -> str: diff --git a/backend/tests/test_osplat_android_compat.py b/backend/tests/test_osplat_android_compat.py index d6edcf5f8..1a6ece9bc 100644 --- a/backend/tests/test_osplat_android_compat.py +++ b/backend/tests/test_osplat_android_compat.py @@ -14,7 +14,40 @@ pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX seams") -from agent_team_backend.osplat import _posix # noqa: E402 +from agent_team_backend.osplat import _posix, _posix_paths # noqa: E402 + + +# ── /bin/sh and /bin/bash ──────────────────────────────────────────────────── + +def test_shell_command_desktop_keeps_bin_sh() -> None: + assert os.path.exists("/bin/sh") + assert _posix_paths.shell_command("echo hi") == ["/bin/sh", "-c", "echo hi"] + + +def test_shell_command_falls_back_when_bin_sh_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: + real_exists = os.path.exists + monkeypatch.setattr(_posix_paths.os.path, "exists", lambda p: False if p == "/bin/sh" else real_exists(p)) + monkeypatch.setattr(_posix_paths.shutil, "which", lambda name: None) + assert _posix_paths.shell_command("echo hi") == ["/system/bin/sh", "-c", "echo hi"] + monkeypatch.setattr(_posix_paths.shutil, "which", lambda name: "/data/prefix/bin/sh" if name == "sh" else None) + assert _posix_paths.shell_command("echo hi") == ["/data/prefix/bin/sh", "-c", "echo hi"] + + +def test_login_probe_desktop_defaults_to_bin_bash(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SHELL", raising=False) + assert os.path.exists("/bin/bash") + assert _posix_paths.login_path_probe()[0] == "/bin/bash" + monkeypatch.setenv("SHELL", "/bin/zsh") + assert _posix_paths.login_path_probe()[0] == "/bin/zsh" + + +def test_login_probe_falls_back_when_bin_bash_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SHELL", raising=False) + real_exists = os.path.exists + monkeypatch.setattr(_posix_paths.os.path, "exists", + lambda p: False if p in ("/bin/bash", "/bin/sh") else real_exists(p)) + monkeypatch.setattr(_posix_paths.shutil, "which", lambda name: None) + assert _posix_paths.login_path_probe()[0] == "/system/bin/sh" # ── PTY without os.openpty ─────────────────────────────────────────────────── From c97f527a9e38fc289f8f6adea3e23be87a2b8c06 Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Sun, 4 Oct 2026 20:52:18 +0800 Subject: [PATCH 6/9] feat(codex): reach the network on Android through a loopback CONNECT proxy Codex ships as a static musl binary, and musl resolves names from /etc/resolv.conf alone, which Android does not have, so every request failed. On Android only (osplat.paths.needs_loopback_dns_proxy), the backend starts a CONNECT proxy bound to 127.0.0.1 and a Codex spawn gets HTTPS_PROXY pointing at it, SSL_CERT_FILE from certifi and TMPDIR. The proxy resolves through the system, never reads the tunnelled bytes, and records the hosts asked for so the UI can disclose them (ANDROID_DISCLOSED_HOSTS). VendorSpec.spawn_env_extra is the per-spawn counterpart of spawn_env_defaults with the same precedence: a value from the request or the inherited environment always wins. It returns {} on every desktop platform, so their spawns are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../agent_team_backend/cli_vendors/base.py | 16 +++ .../agent_team_backend/cli_vendors/codex.py | 32 ++++++ .../loopback_connect_proxy.py | 101 ++++++++++++++++++ backend/agent_team_backend/osplat/_darwin.py | 3 + backend/agent_team_backend/osplat/_linux.py | 3 + .../agent_team_backend/osplat/_posix_paths.py | 10 ++ backend/agent_team_backend/osplat/_windows.py | 3 + backend/agent_team_backend/osplat/spec.py | 8 ++ backend/agent_team_backend/ws_handlers.py | 2 + backend/tests/test_codex_android_network.py | 69 ++++++++++++ backend/tests/test_osplat_android_compat.py | 17 +++ 11 files changed, 264 insertions(+) create mode 100644 backend/agent_team_backend/loopback_connect_proxy.py create mode 100644 backend/tests/test_codex_android_network.py diff --git a/backend/agent_team_backend/cli_vendors/base.py b/backend/agent_team_backend/cli_vendors/base.py index 86adf3e98..9389e9bbc 100644 --- a/backend/agent_team_backend/cli_vendors/base.py +++ b/backend/agent_team_backend/cli_vendors/base.py @@ -1008,6 +1008,10 @@ class VendorSpec: # Vendor-specific defaults added only when neither the request nor the # inherited process environment provides the variable. spawn_env_defaults: tuple[tuple[str, str], ...] = () + # Like spawn_env_defaults, but computed per spawn (a port, a path). Must + # return {} wherever the vendor needs nothing extra; see + # apply_spawn_env_extra for the precedence. + spawn_env_extra: Callable[[], dict[str, str]] = field(default=lambda: {}) # Env var names that relocate this CLI's home/config; the backend strips # them from inherited env at startup and from probe spawns. home_env_vars: tuple[str, ...] = () @@ -1086,3 +1090,15 @@ class VendorSpec: # override disables this vendor's default network profile; it is never # interpreted as a trusted endpoint or persisted as a credential URL. network_override_env_vars: tuple[str, ...] = () + + +def apply_spawn_env_extra(spec: Any, env: dict[str, str], inherited: Mapping[str, str]) -> None: + """Merge ``spec.spawn_env_extra()`` into a spawn's env as defaults. + + Same precedence as spawn_env_defaults: a value the request already set, or + one the backend inherited, is never displaced (a user's own HTTPS_PROXY + wins over the Android loopback proxy). + """ + for key, value in spec.spawn_env_extra().items(): + if key not in inherited: + env.setdefault(key, value) diff --git a/backend/agent_team_backend/cli_vendors/codex.py b/backend/agent_team_backend/cli_vendors/codex.py index e175e3dda..5ac3bf796 100644 --- a/backend/agent_team_backend/cli_vendors/codex.py +++ b/backend/agent_team_backend/cli_vendors/codex.py @@ -1584,8 +1584,40 @@ def _risk_data_dirs(ctx: VendorRuntimeContext) -> tuple[Path, ...]: return (root,) +#: Hosts Codex 0.160.0 was seen reaching through the Android loopback proxy +#: (2026-10-04, emulator, fake key). Disclosed in the UI, never enforced: the +#: proxy relays whatever Codex asks for and records it (hosts_seen()). +ANDROID_DISCLOSED_HOSTS = ( + "api.openai.com", "chatgpt.com", "api.github.com", "codeload.github.com", "files.openai.com", +) + + +def _android_network_env() -> dict[str, str]: + """Env that lets the static musl Codex reach the network on Android. + + Empty everywhere else. On Android there is no /etc/resolv.conf, so Codex + is pointed at the backend's loopback CONNECT proxy (which resolves through + the system), given a CA bundle, and a writable TMPDIR in place of /tmp. + """ + from .. import osplat + + if not osplat.paths.needs_loopback_dns_proxy(): + return {} + import certifi + + from .. import loopback_connect_proxy + + port = loopback_connect_proxy.ensure_started() + return { + "HTTPS_PROXY": f"http://127.0.0.1:{port}", + "SSL_CERT_FILE": certifi.where(), + "TMPDIR": tempfile.gettempdir(), + } + + SPEC = VendorSpec( key="codex", + spawn_env_extra=_android_network_env, # Built-in OpenAI API and ChatGPT service defaults (verified 2026-09-21): # https://learn.chatgpt.com/docs/config-file/config-sample # https://github.com/openai/codex/blob/main/codex-rs/model-provider-info/src/lib.rs diff --git a/backend/agent_team_backend/loopback_connect_proxy.py b/backend/agent_team_backend/loopback_connect_proxy.py new file mode 100644 index 000000000..e438b75b9 --- /dev/null +++ b/backend/agent_team_backend/loopback_connect_proxy.py @@ -0,0 +1,101 @@ +"""A CONNECT-only HTTP proxy on 127.0.0.1, for static CLI binaries on Android. + +A musl binary (Codex) resolves names from /etc/resolv.conf alone, and Android +has no such file. Pointed at this proxy through HTTPS_PROXY, the binary never +resolves anything itself: it sends `CONNECT host:port` here and this process, +linked against the system resolver, opens the connection and relays bytes. + +It listens on loopback only, never inspects the tunnelled (TLS) bytes, and +records which hosts were asked for so the UI can disclose where a CLI went. +Started on first use in its own thread; idle otherwise. +""" +from __future__ import annotations + +import asyncio +import logging +import threading + +log = logging.getLogger(__name__) + +_lock = threading.Lock() +_address: tuple[str, int] | None = None +_hosts: set[str] = set() + + +async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + except (ConnectionError, OSError): + pass + finally: + writer.close() + + +async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + request = await reader.readline() + while (line := await reader.readline()) not in (b"\r\n", b"\n", b""): + pass + parts = request.decode("latin-1").split() + if len(parts) < 2 or parts[0].upper() != "CONNECT": + writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") + await writer.drain() + writer.close() + return + host, _, port = parts[1].rpartition(":") + host = host.strip("[]") + try: + upstream_reader, upstream_writer = await asyncio.open_connection(host, int(port)) + except (OSError, ValueError) as err: + log.info("loopback proxy: CONNECT %s failed: %s", parts[1], err) + writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + await writer.drain() + writer.close() + return + with _lock: + _hosts.add(host) + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + await asyncio.gather(_pipe(reader, upstream_writer), _pipe(upstream_reader, writer)) + + +def ensure_started() -> int: + """The proxy's port, starting it on first call.""" + global _address + with _lock: + if _address is not None: + return _address[1] + ready = threading.Event() + box: dict[str, object] = {} + + def run() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + server = loop.run_until_complete(asyncio.start_server(_handle, "127.0.0.1", 0)) + box["address"] = server.sockets[0].getsockname()[:2] + except BaseException as err: # noqa: BLE001 - reported to the caller below + box["error"] = err + ready.set() + return + ready.set() + loop.run_forever() + + threading.Thread(target=run, name="loopback-connect-proxy", daemon=True).start() + ready.wait() + if "error" in box: + raise RuntimeError(f"loopback proxy failed to start: {box['error']}") + _address = box["address"] # type: ignore[assignment] + log.info("loopback CONNECT proxy listening on %s:%d", *_address) + return _address[1] + + +def bound_address() -> tuple[str, int] | None: + return _address + + +def hosts_seen() -> set[str]: + """Hosts a CLI asked this proxy to reach, for disclosure.""" + with _lock: + return set(_hosts) diff --git a/backend/agent_team_backend/osplat/_darwin.py b/backend/agent_team_backend/osplat/_darwin.py index 96083d54f..cc7e3b7d0 100644 --- a/backend/agent_team_backend/osplat/_darwin.py +++ b/backend/agent_team_backend/osplat/_darwin.py @@ -204,6 +204,9 @@ def symlinks_available(self) -> bool: def shell_command(self, command: str) -> list[str]: return _posix_paths.shell_command(command) + def needs_loopback_dns_proxy(self) -> bool: + return False + def quote_arg(self, arg: str) -> str: return _posix_paths.quote_arg(arg) diff --git a/backend/agent_team_backend/osplat/_linux.py b/backend/agent_team_backend/osplat/_linux.py index 5cc055df3..3936cb5dc 100644 --- a/backend/agent_team_backend/osplat/_linux.py +++ b/backend/agent_team_backend/osplat/_linux.py @@ -291,6 +291,9 @@ def symlinks_available(self) -> bool: def shell_command(self, command: str) -> list[str]: return _posix_paths.shell_command(command) + def needs_loopback_dns_proxy(self) -> bool: + return _posix_paths.static_binaries_lack_resolver() + def quote_arg(self, arg: str) -> str: return _posix_paths.quote_arg(arg) diff --git a/backend/agent_team_backend/osplat/_posix_paths.py b/backend/agent_team_backend/osplat/_posix_paths.py index a7ca88b5a..d26a5a9ef 100644 --- a/backend/agent_team_backend/osplat/_posix_paths.py +++ b/backend/agent_team_backend/osplat/_posix_paths.py @@ -228,6 +228,16 @@ def shell_command(command: str) -> list[str]: return [_system_sh(), "-c", command] +def static_binaries_lack_resolver() -> bool: + """Whether a statically linked (musl) binary cannot resolve host names here. + + musl reads `/etc/resolv.conf` and nothing else; Android has no such file, + so a static CLI such as Codex fails every lookup unless it is pointed at a + loopback proxy that resolves through the system. Never true on a desktop. + """ + return sys.platform == "android" and not os.path.exists("/etc/resolv.conf") + + def quote_arg(arg: str) -> str: return shlex.quote(arg) diff --git a/backend/agent_team_backend/osplat/_windows.py b/backend/agent_team_backend/osplat/_windows.py index 9800551d7..a2df426cf 100644 --- a/backend/agent_team_backend/osplat/_windows.py +++ b/backend/agent_team_backend/osplat/_windows.py @@ -1314,6 +1314,9 @@ def symlinks_available(self) -> bool: def shell_command(self, command: str) -> list[str]: return ["cmd.exe", "/d", "/s", "/c", command] + def needs_loopback_dns_proxy(self) -> bool: + return False + def quote_arg(self, arg: str) -> str: return subprocess.list2cmdline([arg]) diff --git a/backend/agent_team_backend/osplat/spec.py b/backend/agent_team_backend/osplat/spec.py index a1dfc4278..22d514c02 100644 --- a/backend/agent_team_backend/osplat/spec.py +++ b/backend/agent_team_backend/osplat/spec.py @@ -240,6 +240,14 @@ def symlinks_available(self) -> bool: """ ... + def needs_loopback_dns_proxy(self) -> bool: + """Whether static (musl) CLI binaries need a loopback proxy to reach the network. + + True only on Android, where `/etc/resolv.conf` does not exist; false on + every desktop platform, so their spawns never change. + """ + ... + def shell_command(self, command: str) -> list[str]: """argv that runs `command` through the platform's shell. diff --git a/backend/agent_team_backend/ws_handlers.py b/backend/agent_team_backend/ws_handlers.py index bd003e7d2..ae11fabe8 100644 --- a/backend/agent_team_backend/ws_handlers.py +++ b/backend/agent_team_backend/ws_handlers.py @@ -43,6 +43,7 @@ storage_service, trust_store, ) +from .cli_vendors.base import apply_spawn_env_extra from .cli_vendors.codex import command_with_resume_id as codex_command_with_resume_id from .cli_vendors.registry import VENDORS as CLI_VENDORS from .cli_vendors.registry import vendor as cli_vendor @@ -6900,6 +6901,7 @@ async def _terminal_create_impl( for key, value in vendor_spec.spawn_env_defaults: if key not in os.environ: env.setdefault(key, value) + apply_spawn_env_extra(vendor_spec, env, os.environ) await app._ensure_fresh_path_for_spawn(agent_key) payload["command"] = app._command_with_persisted_cli_binary( agent_key, payload.get("command") diff --git a/backend/tests/test_codex_android_network.py b/backend/tests/test_codex_android_network.py new file mode 100644 index 000000000..776272e28 --- /dev/null +++ b/backend/tests/test_codex_android_network.py @@ -0,0 +1,69 @@ +"""Codex on Android: a static musl binary with no /etc/resolv.conf. + +The fix is a loopback CONNECT proxy in the backend plus HTTPS_PROXY, +SSL_CERT_FILE and TMPDIR for the spawn. On every desktop platform the extra +env is empty, so spawns stay byte-for-byte what they were. +""" +from __future__ import annotations + +import socket +import tempfile +import threading + +import certifi +import pytest + +from agent_team_backend import loopback_connect_proxy, osplat +from agent_team_backend.cli_vendors import codex as codex_vendor +from agent_team_backend.cli_vendors.base import apply_spawn_env_extra + + +def test_desktop_codex_spawn_env_extra_is_empty() -> None: + assert codex_vendor.SPEC.spawn_env_extra() == {} + + +def test_android_codex_spawn_env_points_at_the_loopback_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(osplat.paths, "needs_loopback_dns_proxy", lambda: True) + env = codex_vendor.SPEC.spawn_env_extra() + assert env["HTTPS_PROXY"].startswith("http://127.0.0.1:") + assert env["SSL_CERT_FILE"] == certifi.where() + assert env["TMPDIR"] == tempfile.gettempdir() + + +def test_spawn_env_extra_never_displaces_a_value_already_set() -> None: + class Spec: + @staticmethod + def spawn_env_extra(): + return {"HTTPS_PROXY": "http://127.0.0.1:1", "TMPDIR": "/x"} + + env = {"HTTPS_PROXY": "http://user-choice:3128"} + apply_spawn_env_extra(Spec, env, inherited={"TMPDIR": "/inherited"}) + assert env == {"HTTPS_PROXY": "http://user-choice:3128"} + + +def _echo_server() -> int: + srv = socket.socket(); srv.bind(("127.0.0.1", 0)); srv.listen(1) + + def serve(): + conn, _ = srv.accept() + conn.sendall(conn.recv(64).upper()); conn.close(); srv.close() + + threading.Thread(target=serve, daemon=True).start() + return srv.getsockname()[1] + + +def test_proxy_relays_connect_and_records_the_host() -> None: + port = loopback_connect_proxy.ensure_started() + target = _echo_server() + with socket.create_connection(("127.0.0.1", port), timeout=5) as s: + s.sendall(f"CONNECT localhost:{target} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()) + assert s.recv(64).startswith(b"HTTP/1.1 200") + s.sendall(b"hello") + assert s.recv(64) == b"HELLO" + assert "localhost" in loopback_connect_proxy.hosts_seen() + + +def test_proxy_listens_on_loopback_only() -> None: + port = loopback_connect_proxy.ensure_started() + assert loopback_connect_proxy.bound_address() == ("127.0.0.1", port) + assert loopback_connect_proxy.ensure_started() == port diff --git a/backend/tests/test_osplat_android_compat.py b/backend/tests/test_osplat_android_compat.py index 1a6ece9bc..06c0000b9 100644 --- a/backend/tests/test_osplat_android_compat.py +++ b/backend/tests/test_osplat_android_compat.py @@ -14,6 +14,7 @@ pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX seams") +from agent_team_backend import osplat # noqa: E402 from agent_team_backend.osplat import _posix, _posix_paths # noqa: E402 @@ -86,3 +87,19 @@ def test_openpty_ptmx_round_trip_on_linux(monkeypatch: pytest.MonkeyPatch) -> No assert os.read(slave, 16).strip() == b"ping" finally: os.close(master); os.close(slave) + + +# ── static binaries and DNS ────────────────────────────────────────────────── + +def test_desktop_never_needs_the_loopback_dns_proxy() -> None: + assert osplat.paths.needs_loopback_dns_proxy() is False + + +def test_android_without_resolv_conf_needs_the_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + real_exists = os.path.exists + monkeypatch.setattr(_posix_paths.os.path, "exists", + lambda p: False if p == "/etc/resolv.conf" else real_exists(p)) + monkeypatch.setattr(_posix_paths.sys, "platform", "android") + assert _posix_paths.static_binaries_lack_resolver() is True + monkeypatch.setattr(_posix_paths.sys, "platform", "linux") + assert _posix_paths.static_binaries_lack_resolver() is False From 8d76aad69837bdd20edba53b283eabcaafe9fb8e Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Sun, 4 Oct 2026 20:57:00 +0800 Subject: [PATCH 7/9] fix(codex): require a per-process token on the loopback proxy Any app on an Android device can connect to 127.0.0.1, so the CONNECT proxy was an open relay: another app could reach the network through it, around its own permissions. Mint a random token per process and answer 407 to a CONNECT without Proxy-Authorization: Basic navide:. Codex receives the credential in HTTPS_PROXY's userinfo and sends it itself; other apps cannot read another process's environment on Android. Destinations stay unrestricted and are disclosed instead (hosts_seen). Co-Authored-By: Claude Opus 5.5 --- .../agent_team_backend/cli_vendors/codex.py | 6 +-- .../loopback_connect_proxy.py | 45 ++++++++++++++++++- backend/tests/test_codex_android_network.py | 28 +++++++++++- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/backend/agent_team_backend/cli_vendors/codex.py b/backend/agent_team_backend/cli_vendors/codex.py index 5ac3bf796..0d69b6956 100644 --- a/backend/agent_team_backend/cli_vendors/codex.py +++ b/backend/agent_team_backend/cli_vendors/codex.py @@ -1597,7 +1597,8 @@ def _android_network_env() -> dict[str, str]: Empty everywhere else. On Android there is no /etc/resolv.conf, so Codex is pointed at the backend's loopback CONNECT proxy (which resolves through - the system), given a CA bundle, and a writable TMPDIR in place of /tmp. + the system, and only serves callers holding its per-process token), given a + CA bundle, and a writable TMPDIR in place of /tmp. """ from .. import osplat @@ -1607,9 +1608,8 @@ def _android_network_env() -> dict[str, str]: from .. import loopback_connect_proxy - port = loopback_connect_proxy.ensure_started() return { - "HTTPS_PROXY": f"http://127.0.0.1:{port}", + "HTTPS_PROXY": loopback_connect_proxy.proxy_url(), "SSL_CERT_FILE": certifi.where(), "TMPDIR": tempfile.gettempdir(), } diff --git a/backend/agent_team_backend/loopback_connect_proxy.py b/backend/agent_team_backend/loopback_connect_proxy.py index e438b75b9..c3c5fe4ca 100644 --- a/backend/agent_team_backend/loopback_connect_proxy.py +++ b/backend/agent_team_backend/loopback_connect_proxy.py @@ -8,11 +8,22 @@ It listens on loopback only, never inspects the tunnelled (TLS) bytes, and records which hosts were asked for so the UI can disclose where a CLI went. Started on first use in its own thread; idle otherwise. + +Loopback is not private on Android: every app on the device can connect to +127.0.0.1. Without a credential this would be an open relay any app could use +to reach the network on Navide's behalf. So each start mints a random token +and a CONNECT without `Proxy-Authorization: Basic navide:` gets 407. +The token travels to the CLI inside HTTPS_PROXY's userinfo; other apps cannot +read another process's environment on Android. Where a CLI may connect is not +restricted, only disclosed (hosts_seen()). """ from __future__ import annotations import asyncio +import base64 +import hmac import logging +import secrets import threading log = logging.getLogger(__name__) @@ -20,6 +31,8 @@ _lock = threading.Lock() _address: tuple[str, int] | None = None _hosts: set[str] = set() +_USER = "navide" +_token = secrets.token_urlsafe(24) async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: @@ -33,10 +46,30 @@ async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> N writer.close() +def _credential_ok(value: str) -> bool: + scheme, _, encoded = value.partition(" ") + if scheme.lower() != "basic": + return False + try: + supplied = base64.b64decode(encoded.strip(), validate=True) + except ValueError: + return False + return hmac.compare_digest(supplied, f"{_USER}:{_token}".encode()) + + async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: request = await reader.readline() + authorized = False while (line := await reader.readline()) not in (b"\r\n", b"\n", b""): - pass + name, _, value = line.decode("latin-1").partition(":") + if name.strip().lower() == "proxy-authorization": + authorized = _credential_ok(value.strip()) + if not authorized: + writer.write(b'HTTP/1.1 407 Proxy Authentication Required\r\n' + b'Proxy-Authenticate: Basic realm="navide"\r\n\r\n') + await writer.drain() + writer.close() + return parts = request.decode("latin-1").split() if len(parts) < 2 or parts[0].upper() != "CONNECT": writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") @@ -91,6 +124,16 @@ def run() -> None: return _address[1] +def token() -> str: + """The credential a client must present; minted once per process.""" + return _token + + +def proxy_url() -> str: + """HTTPS_PROXY value for a CLI spawn, credential included.""" + return f"http://{_USER}:{_token}@127.0.0.1:{ensure_started()}" + + def bound_address() -> tuple[str, int] | None: return _address diff --git a/backend/tests/test_codex_android_network.py b/backend/tests/test_codex_android_network.py index 776272e28..589ee2191 100644 --- a/backend/tests/test_codex_android_network.py +++ b/backend/tests/test_codex_android_network.py @@ -6,9 +6,11 @@ """ from __future__ import annotations +import base64 import socket import tempfile import threading +from urllib.parse import urlsplit import certifi import pytest @@ -25,7 +27,10 @@ def test_desktop_codex_spawn_env_extra_is_empty() -> None: def test_android_codex_spawn_env_points_at_the_loopback_proxy(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(osplat.paths, "needs_loopback_dns_proxy", lambda: True) env = codex_vendor.SPEC.spawn_env_extra() - assert env["HTTPS_PROXY"].startswith("http://127.0.0.1:") + proxy = urlsplit(env["HTTPS_PROXY"]) + assert (proxy.scheme, proxy.hostname, proxy.port) == ("http", "127.0.0.1", loopback_connect_proxy.ensure_started()) + # Credentials ride in the URL so Codex sends Proxy-Authorization itself. + assert (proxy.username, proxy.password) == ("navide", loopback_connect_proxy.token()) assert env["SSL_CERT_FILE"] == certifi.where() assert env["TMPDIR"] == tempfile.gettempdir() @@ -52,11 +57,30 @@ def serve(): return srv.getsockname()[1] +def _auth_header(token: str) -> str: + return "Proxy-Authorization: Basic " + base64.b64encode(f"navide:{token}".encode()).decode() + "\r\n" + + +def _connect(port: int, target: int, header: str = "") -> bytes: + with socket.create_connection(("127.0.0.1", port), timeout=5) as s: + s.sendall(f"CONNECT localhost:{target} HTTP/1.1\r\nHost: localhost\r\n{header}\r\n".encode()) + return s.recv(64) + + +def test_proxy_refuses_connect_without_the_token() -> None: + # Any app on an Android device can reach loopback; without the token the + # proxy would be an open relay for all of them. + port = loopback_connect_proxy.ensure_started() + assert _connect(port, 9).startswith(b"HTTP/1.1 407") + assert _connect(port, 9, _auth_header("wrong-token")).startswith(b"HTTP/1.1 407") + + def test_proxy_relays_connect_and_records_the_host() -> None: port = loopback_connect_proxy.ensure_started() target = _echo_server() with socket.create_connection(("127.0.0.1", port), timeout=5) as s: - s.sendall(f"CONNECT localhost:{target} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()) + s.sendall(f"CONNECT localhost:{target} HTTP/1.1\r\nHost: localhost\r\n" + f"{_auth_header(loopback_connect_proxy.token())}\r\n".encode()) assert s.recv(64).startswith(b"HTTP/1.1 200") s.sendall(b"hello") assert s.recv(64) == b"HELLO" From 5d8ede7e738ba7b33005e156790245176e03d6e4 Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Wed, 14 Oct 2026 02:42:31 +0800 Subject: [PATCH 8/9] test(osplat): pin a /bin/sh host for the POSIX shell_command answer The Android branch resolves sh when /bin/sh is missing, so on the Windows runner _posix_paths.shell_command found Git's sh.exe and the desktop assertion failed. Production Windows never calls the POSIX module; the test now states the host it describes. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/tests/test_osplat_windows_discovery.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_osplat_windows_discovery.py b/backend/tests/test_osplat_windows_discovery.py index 053da5e7e..9a7849399 100644 --- a/backend/tests/test_osplat_windows_discovery.py +++ b/backend/tests/test_osplat_windows_discovery.py @@ -62,8 +62,12 @@ def test_ntfs_has_no_posix_modes(self, win) -> None: assert win.enforces_posix_modes() is False assert _posix_paths.enforces_posix_modes() is True - def test_shell_command_goes_through_cmd_exe(self, win) -> None: + def test_shell_command_goes_through_cmd_exe(self, win, monkeypatch) -> None: assert win.shell_command("echo hi") == ["cmd.exe", "/d", "/s", "/c", "echo hi"] + # The POSIX answer on a host that has /bin/sh; a Windows runner has none + # and would otherwise resolve Git's sh.exe instead. + real_exists = _posix_paths.os.path.exists + monkeypatch.setattr(_posix_paths.os.path, "exists", lambda p: p == "/bin/sh" or real_exists(p)) assert _posix_paths.shell_command("echo hi") == ["/bin/sh", "-c", "echo hi"] def test_symlink_probe_is_asked_once_and_cached(self, win, monkeypatch) -> None: From 0a47ddaa8c4fdde33c15f75a4eb7925c00583398 Mon Sep 17 00:00:00 2001 From: nt-nerdtechnic Date: Wed, 14 Oct 2026 02:46:15 +0800 Subject: [PATCH 9/9] test(android): run the no-os.link copy fallbacks on POSIX hosts only Both tests delete os.link to stand in for Android, but on a Windows host the fallback then reaches os.fchmod, which Windows CPython lacks. The fallback only runs where os.link is missing, which is never Windows, and it asserts POSIX modes. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/tests/test_codex_home.py | 1 + backend/tests/test_skills_store.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_codex_home.py b/backend/tests/test_codex_home.py index 1551e2bea..76462c662 100644 --- a/backend/tests/test_codex_home.py +++ b/backend/tests/test_codex_home.py @@ -569,6 +569,7 @@ def test_seed_hook_trust_escapes_backslashes_in_toml_keys(tmp_path: Path) -> Non assert state[f'{pane / "hooks.json"}:stop:0:0'] == {"trusted_hash": "sha256:aaaa"} +@pytest.mark.skipif(not osplat.paths.enforces_posix_modes(), reason="Android fallback: POSIX modes and os.fchmod") def test_promote_stranded_auth_copies_without_hard_links( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/backend/tests/test_skills_store.py b/backend/tests/test_skills_store.py index 377454b41..4684bcefd 100644 --- a/backend/tests/test_skills_store.py +++ b/backend/tests/test_skills_store.py @@ -6,7 +6,7 @@ import pytest -from agent_team_backend import skills_store +from agent_team_backend import osplat, skills_store from agent_team_backend.skills_store import ( SKILL_FILE_SIZE_LIMIT, SkillConflictError, @@ -937,6 +937,7 @@ def test_module_imports_without_os_link() -> None: assert result.returncode == 0, result.stderr +@pytest.mark.skipif(not osplat.paths.enforces_posix_modes(), reason="Android fallback: POSIX modes and os.fchmod") @pytest.mark.parametrize("directory_fds", [True, False]) def test_install_bundle_copies_when_hard_links_are_unavailable( store: SkillsStore, monkeypatch: pytest.MonkeyPatch, directory_fds: bool,