Files
framebridge/source/expanded/framebridge_expanded/connection_background.py
T

359 lines
15 KiB
Python

"""Profile-scoped detached ChatGPT supervision without a persistent credential."""
from __future__ import annotations
import fcntl
import json
import os
import resource
from pathlib import Path
import signal
import stat
import subprocess
import sys
import tempfile
import threading
import time
from .config import private_dir, state_path
_BOOTSTRAP = "import sys;sys.path.insert(0,sys.argv[1]);from framebridge_expanded.connection_background import worker_main;worker_main(sys.argv[2],int(sys.argv[3]))"
_PACKAGE_PARENT = str(Path(__file__).resolve().parents[1])
_STATES = {"starting", "checking", "access", "ready", "retrying", "rejected", "unverified", "error", "stopped", "closed"}
_MESSAGES = {
"starting": "[1/3] Starting the background tunnel client.",
"checking": "[2/3] Verifying the local MCP service.",
"access": "[3/3] Verifying tunnel access and authenticated polling.",
"ready": "[OK] Secure tunnel connected; local MCP service verified.",
"retrying": "[RETRYING] Waiting for current verified readiness; check the private connection log.",
"rejected": "[ERROR] Tunnel access was rejected; check the runtime key and tunnel permissions.",
"unverified": "[NOT READY] The connection is not verified yet; it continues retrying in the background.",
"error": "[ERROR] Background tunnel stopped or could not be verified; check the private connection log.",
"stopped": "[STOPPED] No background ChatGPT connection is running.",
"closed": "[CLOSED] The background tunnel client stopped.",
}
def _directory(state, create=True):
root = state_path(state)
if create:
private_dir(root)
return private_dir(root / "connection")
path = root / "connection"
if not path.exists() and not path.is_symlink():
return None
for item in (root, path):
st = item.lstat()
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) & 0o077:
raise RuntimeError("The connection directory is not private and owned by this user.")
return path
def _checked_fd(path, flags, create=False):
fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK | (os.O_CREAT if create else 0), 0o600)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o600:
raise RuntimeError("The connection state file is not a private regular file.")
return fd
except BaseException:
os.close(fd)
raise
def _read(path):
try:
fd = _checked_fd(path, os.O_RDONLY)
except FileNotFoundError:
return {}
try:
raw = os.read(fd, 32769)
if len(raw) > 32768:
raise RuntimeError("The connection status is too large.")
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError()
return data
except (ValueError, UnicodeError):
raise RuntimeError("The connection status cannot be read safely.") from None
finally:
os.close(fd)
def _write(path, data):
if path.exists() or path.is_symlink():
fd = _checked_fd(path, os.O_RDONLY)
os.close(fd)
fd, name = tempfile.mkstemp(prefix=".status-", dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(data, stream, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
finally:
try:
os.unlink(name)
except FileNotFoundError:
pass
def _supervisor_command(state, lock_fd):
return [sys.executable, "-c", _BOOTSTRAP, _PACKAGE_PARENT, str(state_path(state)), str(lock_fd)]
def _identity(pid, state, lock_fd):
proc = Path("/proc") / str(pid)
fields = (proc / "stat").read_text().split(") ", 1)[1].split()
return {"pid": pid, "uid": proc.stat().st_uid, "start_ticks": int(fields[19]),
"boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip(),
"session_id": int(fields[3]), "process_group": int(fields[2]),
"state_dir": str(state_path(state)), "lock_fd": lock_fd}
def _valid_identity(record, state):
try:
pid = record["pid"]
lock_fd = record["lock_fd"]
if type(pid) is not int or pid < 2 or type(lock_fd) is not int or lock_fd < 3:
return False
current = _identity(pid, state, lock_fd)
if any(current[k] != record.get(k) for k in current):
return False
if current["uid"] != os.getuid() or current["session_id"] != pid or current["process_group"] != pid:
return False
proc = Path("/proc") / str(pid)
if (proc / "stat").read_text().split(") ", 1)[1].split()[0] == "Z":
return False
if os.path.realpath(proc / "exe") != os.path.realpath(sys.executable):
return False
argv = (proc / "cmdline").read_bytes().split(b"\0")[:-1]
if argv != [os.fsencode(x) for x in _supervisor_command(state, lock_fd)]:
return False
lock = _directory(state, False) / "owner.lock"
return os.stat(proc / "fd" / str(lock_fd)).st_ino == lock.stat().st_ino and os.stat(proc / "fd" / str(lock_fd)).st_dev == lock.stat().st_dev
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return False
def status(state):
directory = _directory(state, False)
record = _read(directory / "status.json") if directory else {}
alive = bool(record) and _valid_identity(record, state)
phase = record.get("state", "stopped")
if not isinstance(phase, str) or phase not in _STATES:
phase = "error"
if not alive and phase not in {"error", "closed", "stopped"}:
phase = "stopped"
checked = record.get("verified_at")
verified = alive and phase == "ready" and type(checked) in (int, float) and 0 <= time.time() - checked <= 15
if alive and phase == "ready" and not verified:
phase = "retrying"
return {"ok": True, "alive": alive, "verified": verified, "state": phase,
"message": _MESSAGES[phase],
"pid": record.get("pid") if alive else None,
"log": str(state_path(state) / "logs/chatgpt/connection.log")}
def disconnect(state, *, timeout=16):
directory = _directory(state, False)
record = _read(directory / "status.json") if directory else {}
if not record or not _valid_identity(record, state):
return {"ok": True, "alive": False, "state": "stopped", "message": "No owned background tunnel is running."}
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
return {"ok": False, "alive": True, "state": "error", "message": "Safe process signaling is unavailable; the connection was left unchanged."}
try:
fd = os.pidfd_open(record["pid"])
except ProcessLookupError:
return {"ok": True, "alive": False, "state": "stopped"}
try:
if not _valid_identity(record, state):
return {"ok": False, "alive": False, "state": "error", "message": "Connection identity changed; no signal was sent."}
try:
signal.pidfd_send_signal(fd, signal.SIGINT)
except ProcessLookupError:
pass
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not _valid_identity(record, state):
return {"ok": True, "alive": False, "state": "stopped", "message": "Background ChatGPT connection stopped."}
time.sleep(0.1)
return {"ok": False, "alive": True, "state": "error", "message": "Connection cleanup is still running; local services were left unchanged."}
finally:
os.close(fd)
class _StatusOutput:
"""Progress destination owned by the worker, with no parent pipe or terminal."""
def __init__(self, path, identity):
self.path = path
self.record = dict(identity)
self.pending = ""
self.change("starting", "[1/3] Starting the background tunnel client.")
def isatty(self):
return False
def flush(self):
pass
def change(self, state, message):
self.record.update(state=state, message=message, updated_at=time.time())
_write(self.path, self.record)
def observe(self, state):
self.record["checked_at"] = time.time()
self.record["verified_at"] = time.time() if state == "ready" else None
_write(self.path, self.record)
def write(self, text):
self.pending += text
while "\n" in self.pending:
line, self.pending = self.pending.split("\n", 1)
phase = next((phase for token, phase in [("[1/3]", "starting"), ("[2/3]", "checking"), ("[3/3]", "access"), ("[OK]", "ready"), ("[RETRYING]", "retrying"), ("[NOT READY]", "unverified"), ("[ERROR]", "error"), ("[STOPPED]", "stopped"), ("[CLOSED]", "closed")] if line.startswith(token)), None)
if phase:
self.change(phase, line)
if len(self.pending) > 4096:
self.pending = ""
return len(text)
def worker_main(state, lock_fd):
"""Read one bounded anonymous-pipe payload; the credential is never a file."""
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
payload = credential = None
sink = None
rc = 1
try:
directory = _directory(state)
held = os.fstat(lock_fd)
owned = (directory / "owner.lock").stat()
if (held.st_dev, held.st_ino) != (owned.st_dev, owned.st_ino):
return
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
raw = sys.stdin.buffer.read(65537)
if len(raw) > 65536:
return
payload = json.loads(raw)
raw = None
credential = payload.pop("credential")
command = payload["command"]
if not isinstance(credential, str) or not credential or any(c.isspace() for c in credential) or len(credential) > 16384:
return
if not isinstance(command, list) or not command or any(not isinstance(v, str) or "\0" in v for v in command):
return
sink = _StatusOutput(directory / "status.json", _identity(os.getpid(), state, lock_fd))
from .tunnel import run_foreground
rc = run_foreground(command, credential, state=state, output=sink, observe=sink.observe)
except BaseException:
rc = 1
finally:
credential = payload = None
if sink is not None:
try:
sink.change("stopped" if rc in (0, 130) else "error", "[STOPPED] Background ChatGPT connection stopped." if rc in (0, 130) else "[ERROR] Background tunnel stopped; check the private connection log.")
except Exception:
pass
try:
os.close(lock_fd)
except OSError:
pass
def start(command, credential, state, expected_tunnel=None, expected_mcp=None, *, output=None, wait_timeout=95):
"""Detach the owned supervisor, then wait for verified readiness or a clear result."""
output = output or sys.stdout
directory = _directory(state)
lock_fd = _checked_fd(directory / "owner.lock", os.O_RDWR, create=True)
child = None
try:
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
current = status(state)
output.write("A ChatGPT connection already owns this profile. Use framebridge connection status.\n")
return 0 if current["verified"] else 1
# No earlier status may be mistaken for this new supervisor.
old = directory / "status.json"
if old.exists() or old.is_symlink():
check = _checked_fd(old, os.O_RDONLY)
os.close(check)
old.unlink()
env = dict(os.environ)
from .tunnel import KEY_ENV, _RUNTIME_PREFIXES, _RUNTIME_NAMES
for name in list(env):
if name.startswith(_RUNTIME_PREFIXES) or name in _RUNTIME_NAMES:
env.pop(name, None)
child = subprocess.Popen(_supervisor_command(state, lock_fd), stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
pass_fds=(lock_fd,), close_fds=True, start_new_session=True, env=env)
threading.Thread(target=child.wait, daemon=True, name="framebridge-tunnel-reaper").start()
try:
payload = json.dumps({"credential": credential, "command": list(command)}).encode()
if len(payload) > 65536:
raise ValueError("The connection input is too large.")
child.stdin.write(payload)
child.stdin.close()
payload = None
except (OSError, ValueError):
child.stdin.close()
output.write("[ERROR] The background connection could not receive its private input.\n")
return 1
finally:
credential = None
# Closing, rather than LOCK_UN, preserves the child's inherited flock.
os.close(lock_fd)
lock_fd = None
deadline = time.monotonic() + wait_timeout
last = None
while time.monotonic() < deadline:
current = status(state)
if current["alive"] and current["message"] != last:
output.write(current["message"] + "\n")
output.flush()
last = current["message"]
if current["verified"]:
output.write("The connection is running in the background; this terminal can be closed.\n")
output.write("Use framebridge connection status or framebridge disconnect chatgpt.\n")
output.write("Select this tunnel in ChatGPT. A successful tool call verifies that connection.\n")
return 0
if child.poll() is not None:
output.write("[ERROR] The background tunnel stopped before verified readiness. Check the private connection log.\n")
return 1
time.sleep(0.2)
output.write("[NOT READY] Verification is still pending. The connection continues retrying in the background; use framebridge connection status.\n")
return 1
except KeyboardInterrupt:
if child is not None:
result = disconnect(state)
# The Popen child remains ours even before it has published status.
if child.poll() is None:
try:
child.send_signal(signal.SIGINT)
child.wait(timeout=16)
except (OSError, subprocess.TimeoutExpired):
result = {"ok": False}
if not result["ok"]:
output.write("[ERROR] Check framebridge connection status before reconnecting.\n")
return 130
finally:
credential = None
if lock_fd is not None:
os.close(lock_fd)
def run_foreground(command, credential, state, *, output=None):
"""Retain explicit diagnostic mode, exclusive with detached connections."""
directory = _directory(state)
fd = _checked_fd(directory / "owner.lock", os.O_RDWR, create=True)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("A ChatGPT connection already owns this profile. Disconnect it before using foreground mode.") from None
from .tunnel import run_foreground as run
return run(command, credential, state=state, output=output)
finally:
os.close(fd)