FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1,358 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user