Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions backend/agent_team_backend/cli_vendors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = ()
Expand Down Expand Up @@ -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)
43 changes: 42 additions & 1 deletion backend/agent_team_backend/cli_vendors/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import re
import shutil
import sqlite3
import stat
import threading
import time
import tomllib
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
144 changes: 144 additions & 0 deletions backend/agent_team_backend/loopback_connect_proxy.py
Original file line number Diff line number Diff line change
@@ -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:<token>` 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)
3 changes: 3 additions & 0 deletions backend/agent_team_backend/osplat/_darwin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions backend/agent_team_backend/osplat/_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
24 changes: 23 additions & 1 deletion backend/agent_team_backend/osplat/_posix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)`."""

Expand All @@ -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)
Expand Down
29 changes: 27 additions & 2 deletions backend/agent_team_backend/osplat/_posix_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions backend/agent_team_backend/osplat/_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
8 changes: 8 additions & 0 deletions backend/agent_team_backend/osplat/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading