Snapshot assembled only from the signed corresponding-source archive. Source archive SHA-256: db91a270cdefc4be79056ed38a0b4a16747e1ab130592645eac40fa3809809df
422 lines
19 KiB
Python
422 lines
19 KiB
Python
"""Profile-scoped detached ChatGPT supervision without a persistent credential."""
|
|
from __future__ import annotations
|
|
import fcntl
|
|
import hashlib
|
|
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"}
|
|
_RUNTIME_PARENT = Path("/run/user")
|
|
_HEARTBEAT_BACKEND = "user-runtime-v1"
|
|
_IDENTITY_FIELDS = ("pid", "uid", "start_ticks", "boot_id", "session_id", "process_group", "state_dir", "lock_fd")
|
|
_DURABLE_HEARTBEAT_INTERVAL = 5.0
|
|
_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 _heartbeat_directory(state, create=False):
|
|
"""Keep disposable liveness updates out of the durable profile filesystem.
|
|
|
|
Do not create a login runtime directory, follow symlinks, change existing
|
|
permissions, or trust an arbitrary XDG_RUNTIME_DIR supplied by a caller.
|
|
Hosts without a user runtime directory retain bounded durable heartbeats.
|
|
"""
|
|
root = _RUNTIME_PARENT / str(os.getuid())
|
|
for parent in (_RUNTIME_PARENT.parent, _RUNTIME_PARENT):
|
|
try:
|
|
parent_info = parent.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
if not stat.S_ISDIR(parent_info.st_mode) or parent_info.st_uid not in (0, os.getuid()) or stat.S_IMODE(parent_info.st_mode) & 0o022:
|
|
raise RuntimeError("The user runtime parent directory is unsafe.")
|
|
try:
|
|
info = root.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
|
|
raise RuntimeError("The user runtime directory is not private and owned by this user.")
|
|
name = "framebridge-connection-" + hashlib.sha256(str(state_path(state)).encode()).hexdigest()
|
|
directory = root / name
|
|
if create:
|
|
return private_dir(directory)
|
|
try:
|
|
info = directory.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
|
|
raise RuntimeError("The connection heartbeat directory is not private and owned by this user.")
|
|
return directory
|
|
|
|
|
|
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")
|
|
if record.get("heartbeat_backend") == _HEARTBEAT_BACKEND:
|
|
heartbeat_directory = _heartbeat_directory(state)
|
|
heartbeat = _read(heartbeat_directory / "status.json") if heartbeat_directory else {}
|
|
same_worker = all(heartbeat.get(key) == record.get(key) for key in _IDENTITY_FIELDS)
|
|
checked = heartbeat.get("verified_at") if same_worker and heartbeat.get("state") == phase else None
|
|
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, heartbeat_path=None):
|
|
self.path = path
|
|
self.record = dict(identity)
|
|
self.heartbeat_path = heartbeat_path
|
|
if heartbeat_path is not None:
|
|
self.record["heartbeat_backend"] = _HEARTBEAT_BACKEND
|
|
self.last_observed = None
|
|
self.last_durable_write = 0.0
|
|
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):
|
|
if self.record.get("state") == state and self.record.get("message") == message:
|
|
return
|
|
self.record.update(state=state, message=message, updated_at=time.time())
|
|
_write(self.path, self.record)
|
|
self.last_durable_write = time.monotonic()
|
|
|
|
def observe(self, state):
|
|
self.record["checked_at"] = time.time()
|
|
self.record["verified_at"] = time.time() if state == "ready" else None
|
|
if self.heartbeat_path is not None:
|
|
heartbeat = {key: self.record[key] for key in _IDENTITY_FIELDS}
|
|
heartbeat.update(state=state, checked_at=self.record["checked_at"], verified_at=self.record["verified_at"])
|
|
_write(self.heartbeat_path, heartbeat)
|
|
elif state != self.last_observed or time.monotonic() - self.last_durable_write >= _DURABLE_HEARTBEAT_INTERVAL:
|
|
# Legacy/manual sessions can run without systemd's user runtime
|
|
# directory. Preserve the 15-second freshness contract there too.
|
|
_write(self.path, self.record)
|
|
self.last_durable_write = time.monotonic()
|
|
self.last_observed = state
|
|
|
|
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
|
|
heartbeat_directory = _heartbeat_directory(state, create=True)
|
|
heartbeat_path = heartbeat_directory / "status.json" if heartbeat_directory else None
|
|
sink = _StatusOutput(directory / "status.json", _identity(os.getpid(), state, lock_fd), heartbeat_path)
|
|
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)
|