diff --git a/.gitleaksignore b/.gitleaksignore index c505be8f0..a0cde5f73 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -2,3 +2,5 @@ 30d425a86987fde79c5bc7470b4232e2aecfb750:docs/en-US/marketplace-publishing.md:curl-auth-header:139 # The quick-menu callback token alphabet constant (not a secret); generic-api-key matches its entropy. c1cdb5c016fcf1c63227714d4410635b419a739d:backend/agent_team_backend/channels/quick_menu.py:generic-api-key:23 +# The same constant as staged changes see it (a merge from main re-adds the file, so no commit prefix). +backend/agent_team_backend/channels/quick_menu.py:generic-api-key:23 diff --git a/backend/agent_team_backend/cli_vendors/base.py b/backend/agent_team_backend/cli_vendors/base.py index e6ca8b67d..19a24c2d0 100644 --- a/backend/agent_team_backend/cli_vendors/base.py +++ b/backend/agent_team_backend/cli_vendors/base.py @@ -1058,6 +1058,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, ...] = () @@ -1141,3 +1145,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 255374b6a..71fac652e 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 import tomllib @@ -917,7 +918,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: @@ -1822,6 +1831,37 @@ 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, and only serves callers holding its per-process token), 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 + + return { + "HTTPS_PROXY": loopback_connect_proxy.proxy_url(), + "SSL_CERT_FILE": certifi.where(), + "TMPDIR": tempfile.gettempdir(), + } + + # Where this CLI keeps the extensions it installed for itself (read-only # inventory, cli_extensions). Verified 2026-10-10 against the installed # binary's --help and the files on disk. @@ -1833,6 +1873,7 @@ def _risk_data_dirs(ctx: VendorRuntimeContext) -> tuple[Path, ...]: SPEC = VendorSpec( extensions=_EXTENSIONS, 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..c3c5fe4ca --- /dev/null +++ b/backend/agent_team_backend/loopback_connect_proxy.py @@ -0,0 +1,144 @@ +"""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. + +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__) + +_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: + try: + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + except (ConnectionError, OSError): + pass + finally: + 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""): + 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") + 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 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 + + +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 1b5895531..151d83639 100644 --- a/backend/agent_team_backend/osplat/_darwin.py +++ b/backend/agent_team_backend/osplat/_darwin.py @@ -210,6 +210,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 d205fe54d..5634fcc20 100644 --- a/backend/agent_team_backend/osplat/_linux.py +++ b/backend/agent_team_backend/osplat/_linux.py @@ -297,6 +297,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.py b/backend/agent_team_backend/osplat/_posix.py index 6eb9f93d0..21aed74b5 100644 --- a/backend/agent_team_backend/osplat/_posix.py +++ b/backend/agent_team_backend/osplat/_posix.py @@ -298,6 +298,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)`.""" @@ -323,7 +345,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/agent_team_backend/osplat/_posix_paths.py b/backend/agent_team_backend/osplat/_posix_paths.py index 414f37fd5..153339f27 100644 --- a/backend/agent_team_backend/osplat/_posix_paths.py +++ b/backend/agent_team_backend/osplat/_posix_paths.py @@ -152,7 +152,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] @@ -254,8 +254,33 @@ 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 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: diff --git a/backend/agent_team_backend/osplat/_windows.py b/backend/agent_team_backend/osplat/_windows.py index 0625f2c28..e6a5ea999 100644 --- a/backend/agent_team_backend/osplat/_windows.py +++ b/backend/agent_team_backend/osplat/_windows.py @@ -1358,6 +1358,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 074900166..c54d9a7ab 100644 --- a/backend/agent_team_backend/osplat/spec.py +++ b/backend/agent_team_backend/osplat/spec.py @@ -263,6 +263,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/skills_store.py b/backend/agent_team_backend/skills_store.py index 436ec750a..fbb93879d 100644 --- a/backend/agent_team_backend/skills_store.py +++ b/backend/agent_team_backend/skills_store.py @@ -50,16 +50,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. @@ -828,11 +849,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/agent_team_backend/ws_handlers.py b/backend/agent_team_backend/ws_handlers.py index fc69525a2..9f6fbe7de 100644 --- a/backend/agent_team_backend/ws_handlers.py +++ b/backend/agent_team_backend/ws_handlers.py @@ -44,6 +44,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 @@ -7811,6 +7812,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/pyproject.toml b/backend/pyproject.toml index fb7ad603f..8a76fd1b6 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_codex_android_network.py b/backend/tests/test_codex_android_network.py new file mode 100644 index 000000000..589ee2191 --- /dev/null +++ b/backend/tests/test_codex_android_network.py @@ -0,0 +1,93 @@ +"""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 base64 +import socket +import tempfile +import threading +from urllib.parse import urlsplit + +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() + 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() + + +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 _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" + 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" + 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_codex_home.py b/backend/tests/test_codex_home.py index 7c52d11a3..76462c662 100644 --- a/backend/tests/test_codex_home.py +++ b/backend/tests/test_codex_home.py @@ -569,6 +569,32 @@ 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: + # 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 + + + def _trust_headers(path: Path) -> list[str]: """The same table header in the shapes TOML allows: a trailing comment, spacing inside the brackets, a literal (single-quoted) key.""" diff --git a/backend/tests/test_osplat_android_compat.py b/backend/tests/test_osplat_android_compat.py new file mode 100644 index 000000000..06c0000b9 --- /dev/null +++ b/backend/tests/test_osplat_android_compat.py @@ -0,0 +1,105 @@ +"""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 import osplat # 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 ─────────────────────────────────────────────────── + +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) + + +# ── 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 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: 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/tests/test_skills_store.py b/backend/tests/test_skills_store.py index 7ef1bc81e..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, @@ -922,3 +922,42 @@ 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.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, +) -> 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" diff --git a/backend/uv.lock b/backend/uv.lock index 9b31bfe91..e9d4c1854 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" },