FrameBridge 0.3.4 corresponding-source release

Snapshot assembled only from the signed corresponding-source archive.
Source archive SHA-256: db91a270cdefc4be79056ed38a0b4a16747e1ab130592645eac40fa3809809df
This commit is contained in:
FrameBridge release automation committed 2026-10-29 11:53:40 -04:00
1 parent b8c9b713d8
commit cdd980e1eb
993 files changed
+255481 -1720

No files matched your search

@@ -1 +1 @@
__version__ = "0.3.3"
__version__ = "0.3.4"
@@ -132,6 +132,39 @@ def _prune_helpers(state, keep=()):
_command(["sudo", "-n", "--", str(HELPER), "prune", _profile_id(state), ",".join(values) or "-"])
def _node_helper_cleanup_required(state):
"""Only omit privileged cleanup when no managed grant can authorize it.
The encrypted registry is deliberately private to root. Its orphaned
ciphertext does not grant access without a matching sudo policy. Inspect
only policy names in the secured, root-owned policy directory; never read
rule contents or treat a failed/incomplete inspection as an empty registry.
"""
from .credential_helper import SUDOERS, _root_path
_helper_ready()
uid, profile = str(os.getuid()), _profile_id(state)
prefixes = ("framebridge-credential-" + uid + "-" + profile + "-",
"framebridge-credential-authorize-" + uid + "-" + profile + "-")
patterns = (re.escape(prefixes[0]) + r"[0-9a-f]{64}",
re.escape(prefixes[1]) + r"(?:host|user)")
try:
directory = _root_path(SUDOERS, directory=True)
names = [path.name for path in directory.iterdir()]
except (OSError, RuntimeError):
return True
cleanup_required = False
for name in names:
if any(re.fullmatch(pattern, name) for pattern in patterns):
cleanup_required = True
continue
# A malformed entry in this exact owner's/profile's managed namespace
# is ambiguous. The helper's normal cleanup does not recognize every
# malformed authorization name, so require explicit review instead.
if name.startswith(prefixes):
raise RuntimeError("An ambiguous managed credential policy needs administrator review before node startup can be configured.")
return cleanup_required
@contextmanager
def _credential_transaction(state, encrypted, backend):
transaction = {"committed": False}
@@ -270,10 +303,17 @@ def _check_unit_directory(path):
for directory in [Path.home() / ".config", Path.home() / ".config/systemd", path.parent]:
if directory.is_symlink():
raise RuntimeError("User service directories must not be symbolic links.")
directory.mkdir(mode=0o700, exist_ok=True)
info = directory.stat()
if info.st_uid != os.getuid() or info.st_mode & 0o022:
raise RuntimeError("User service directories must be owned by this user and not writable by others.")
try:
directory.mkdir(mode=0o700, exist_ok=True)
except PermissionError:
raise RuntimeError("This account cannot create its user service directory; existing ownership and permissions were left unchanged.") from None
info = directory.lstat()
# Service accounts can have administrator-owned .config ancestors.
# Those ancestors are safe when immutable to other accounts; the
# directory containing the unit must still belong to this owner.
allowed_owner = info.st_uid == os.getuid() or (directory != path.parent and info.st_uid == 0)
if not stat.S_ISDIR(info.st_mode) or not allowed_owner or info.st_mode & 0o022:
raise RuntimeError("User service directories must have a safe owner and not be writable by others; the final directory must belong to this user.")
def _manifest(directory):
@@ -653,9 +693,15 @@ def _enable(state, *, start_now=True, confirm=True):
_check_unit_directory(unit)
if unit.exists() or unit.is_symlink() or (directory / "credential.cred").exists() or (directory / "credential.cred").is_symlink():
raise RuntimeError("Existing unowned service or encrypted credential; nothing was replaced.")
_command(["sudo", "-v"], timeout=180)
user = pwd.getpwuid(os.getuid()).pw_name
linger_before = _linger(user)
helper_cleanup_required = (node and _helper_present() and
(not linger_before or _node_helper_cleanup_required(state)))
# An administrator may already have enabled this node owner's user
# services at boot. Installing its own unit then needs no new privilege.
# Central profiles still authenticate before protecting connection input.
if not node or not linger_before or helper_cleanup_required:
_command(["sudo", "-v"], timeout=180)
units_before = _enabled_units()
encrypted = b""
print("[2/3] Preparing this node..." if node else "[2/3] Protecting the connection key...", flush=True)
@@ -672,7 +718,7 @@ def _enable(state, *, start_now=True, confirm=True):
runtime_key = ""
if len(encrypted) < 80 or len(encrypted) > 131072:
raise RuntimeError("Credential encryption returned an invalid result.")
elif _helper_present():
elif helper_cleanup_required:
_prune_helpers(state)
with _credential_transaction(state, encrypted, backend) as transaction:
blob = directory / "credential.cred"
@@ -693,7 +739,8 @@ def _enable(state, *, start_now=True, confirm=True):
record["changed_config_sha256"] = hashlib.sha256(_read(state / "config.json")).hexdigest()
_json(directory / "installation.json", record); created.append(directory / "installation.json")
print("[3/3] Enabling automatic startup...", flush=True)
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
if not node or not linger_before:
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
_systemctl("daemon-reload")
if start_now:
_systemctl("enable", "--now", unit.name)
@@ -946,7 +993,17 @@ def _connection_action(state, action):
def _worker_status(directory, name):
_json(directory / "worker-status.json", {"state": name, "updated_at": time.time()}, replace=True)
path = directory / "worker-status.json"
try:
previous = json.loads(_read(path))
except (FileNotFoundError, ValueError):
previous = None
# This is a transition timestamp, not a liveness heartbeat. systemd and the
# owned process identity report liveness separately. Avoid a durable fsync
# every two seconds while the worker remains in the same state.
if isinstance(previous, dict) and previous.get("state") == name:
return
_json(path, {"state": name, "updated_at": time.time()}, replace=True)
def worker(state):
+17 -2
View File
@@ -451,6 +451,18 @@ def _doctor_command(args, state):
return _result_code(value)
def _health_command(args, state):
health = _module("health")
value = health.collect(state)
if args.json:
_show(value)
else:
print(health.render(value))
# Query success and operational health are deliberately separate. An
# intentional pause can be healthy; missing/unknown state cannot pass.
return 0 if value.get("ok") is True and value.get("health", {}).get("healthy") is True else 1
def _service_command(args, state):
action = args.service_action
lifecycle = _module("lifecycle")
@@ -540,6 +552,7 @@ def build_parser():
status = sub.add_parser("status", help="Observe local tools, ChatGPT connection and startup policy without changing them.")
status.add_argument("scope", nargs="?", choices=("connection", "runtime", "boot", "console"))
sub.add_parser("doctor", help="Diagnose prerequisites and problems; does not repair or connect.")
sub.add_parser("health", help="Read one local profile's health, identity and prerequisites; no repair or connection.")
sub.add_parser("setup", help="Optional advanced role/profile setup; does not start services or connect.")
help_parser = sub.add_parser("help", help="Read the installed, versioned English guide.")
help_parser.add_argument("topic", nargs="?", default="start", help="Guide topic; use all for the complete reference.")
@@ -665,14 +678,14 @@ def _dispatch(parser, args):
return _help_command(argparse.Namespace(command="help", topic="start", json=True, non_interactive=True))
parser.print_help()
return 0
print("FrameBridge\n1. Connect\n2. Status\n3. Doctor\n4. Open console\n5. Help\n6. Advanced setup\n0. Exit")
print("FrameBridge\n1. Connect\n2. Status\n3. Doctor\n4. Open console\n5. Help\n6. Advanced setup\n7. Health summary\n0. Exit")
try:
choice = input("Choose an action: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nCancelled.", file=sys.stderr)
return 130
action = {"1":["connect"], "2":["status"], "3":["doctor"], "4":["console"],
"5":["help"], "6":["setup"]}.get(choice)
"5":["help"], "6":["setup"], "7":["health"]}.get(choice)
if choice == "0":
return 0
if action is None:
@@ -712,6 +725,8 @@ def _dispatch(parser, args):
return _status_command(args, state)
if args.command == "doctor":
return _doctor_command(args, state)
if args.command == "health":
return _health_command(args, state)
if args.command == "service":
return _service_command(args, state)
if args.command == "connection":
@@ -111,7 +111,7 @@ def assess(prefix, *, os_release=None, machine=None, python_version=None, libc=N
expected = PROFILES[manifest["profile_id"]]
if (manifest.get("architecture"), manifest.get("python_minor"), manifest.get("python_abi")) != ("amd64", expected[2], expected[3]):
raise ValueError("inconsistent profile ABI")
if manifest.get("validation_status") not in {"candidate", "validated"}:
if manifest.get("validation_status") not in {"candidate", "release_build", "validated"}:
raise ValueError("missing validation status")
if manifest["profile_id"] != selected or manifest["python_minor"] != minor or manifest["python_abi"] != abi:
issues.append("Installed artifact profile/ABI does not match this operating system and interpreter.")
@@ -126,7 +126,9 @@ def assess(prefix, *, os_release=None, machine=None, python_version=None, libc=N
warnings.append("Source checkout has no artifact manifest; only the legacy Debian 13/cp313 profile is eligible.")
issues.extend(_vendor_issues(prefix, abi))
validation = manifest.get("validation_status") if manifest else "source-unvalidated"
if validation != "validated":
if validation == "release_build":
warnings.append("Release assembly does not establish a tested installation on this target; consult the external release acceptance record.")
elif validation != "validated":
warnings.append("Candidate compatibility checks do not establish a tested installation on this target.")
return {"ok": not issues, "profile_id": selected, "artifact_profile_id": manifest.get("profile_id") if manifest else None,
"architecture": machine, "python_minor": minor, "python_abi": abi, "python_implementation": implementation, "python_abi_flags": abi_flags,
@@ -1,6 +1,7 @@
"""Profile-scoped detached ChatGPT supervision without a persistent credential."""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
import resource
@@ -17,6 +18,10 @@ 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.",
@@ -59,6 +64,40 @@ def _checked_fd(path, flags, create=False):
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)
@@ -146,6 +185,11 @@ def status(state):
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"
@@ -185,9 +229,14 @@ def disconnect(state, *, timeout=16):
class _StatusOutput:
"""Progress destination owned by the worker, with no parent pipe or terminal."""
def __init__(self, path, identity):
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.")
@@ -198,13 +247,25 @@ class _StatusOutput:
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
_write(self.path, self.record)
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
@@ -243,7 +304,9 @@ def worker_main(state, lock_fd):
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))
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:
+39 -10
View File
@@ -164,6 +164,19 @@ def resolve_node(alias: str) -> dict[str, Any]:
return matches[0]
def _target_identity(requested: str, node: dict[str, Any]) -> dict[str, Any]:
"""Describe the confirmed route, never identity claimed by a node response."""
return {
"requested_target": requested,
"resolved_target": node["alias"],
"node": node["alias"],
"target": "LOCAL" if node["transport"] == "local" else node["alias"],
"node_id": node["node_id"],
"hostname": node.get("hostname"),
"transport": node["transport"],
}
def _normalize_read(capability: str, args: dict[str, Any]) -> dict[str, Any]:
bootstrap_original()
from framebridge_linux_chatgpt.fleet_routing import ReadCapability, normalize_read_args
@@ -185,6 +198,7 @@ def read_local(capability: str, args: dict[str, Any]) -> dict[str, Any]:
def node_read(alias: str, capability: str, args: dict[str, Any]) -> dict[str, Any]:
node = None
try:
normalized = _normalize_read(capability, args)
node = resolve_node(alias)
@@ -196,10 +210,13 @@ def node_read(alias: str, capability: str, args: dict[str, Any]) -> dict[str, An
result = broker.request(node["node_id"], capability, normalized, timeout_s=30)
_require(type(result) is dict, "NODE_READ_RESPONSE_INVALID")
response = dict(result)
response.update(node=node["alias"], hostname=node["hostname"], transport=node["transport"])
response.update(_target_identity(alias, node))
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_READ_FAILED"}
response = {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_READ_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def _exec_payload(script: str, authorized: bool, timeout_s: int, job_id: str,
@@ -224,6 +241,7 @@ def _exec_payload(script: str, authorized: bool, timeout_s: int, job_id: str,
def execute_node(alias: str, script: str, authorized: bool, timeout_s: int = 120,
job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]:
node = None
try:
payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout)
node = resolve_node(alias)
@@ -241,20 +259,24 @@ def execute_node(alias: str, script: str, authorized: bool, timeout_s: int = 120
"NODE_EXEC_JOB_BINDING_INVALID")
_require(response.get("mcp_script_sha256", response.get("command_sha256")) == payload["sha256"],
"NODE_EXEC_HASH_BINDING_INVALID")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"],
node_id=node["node_id"], mcp_job_id=payload["job_id"],
response.update(_target_identity(alias, node))
response.update(mcp_job_id=payload["job_id"],
mcp_script_sha256=payload["sha256"])
if include_stdout is not True:
response.pop("stdout", None)
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_EXEC_FAILED"}
response = {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_EXEC_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def execute_screen_node(alias: str, script: str, authorized: bool, timeout_s: int = 30,
job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]:
"""Use the same confirmed-node authority with an explicit non-shell screen operation."""
from .real_screen.screen_transport import REQUEST_TYPE, validate_payload, ScreenTransportError
node = None
try:
_require(type(timeout_s) is int and 1 <= timeout_s <= 30, "INVALID_SCREEN_TIMEOUT")
payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout)
@@ -280,16 +302,20 @@ def execute_screen_node(alias: str, script: str, authorized: bool, timeout_s: in
from .real_screen.screen_rpc import encode
response.update(ok=False, exit_code="1", stdout_truncated=False,
stdout=encode({"ok": False, "error": {"code": "SCREEN_RPC_UNSUPPORTED_ON_NODE"}}).decode("ascii") + "\n")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"],
node_id=node["node_id"], mcp_job_id=payload["job_id"],
response.update(_target_identity(alias, node))
response.update(mcp_job_id=payload["job_id"],
mcp_script_sha256=payload["sha256"])
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, (FleetError, ScreenTransportError)) else "SCREEN_RPC_FAILED"}
response = {"ok": False, "error": str(exc) if isinstance(exc, (FleetError, ScreenTransportError)) else "SCREEN_RPC_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def cua_node(alias: str, payload: dict[str, Any], authorized: bool,
timeout_s: int = 60, job_id: str = "") -> dict[str, Any]:
node = None
try:
_require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
_require(type(payload) is dict, "CUA_PAYLOAD_REQUIRED")
@@ -316,10 +342,13 @@ def cua_node(alias: str, payload: dict[str, Any], authorized: bool,
response = dict(result)
if response.get("ok") is True:
_require(response.get("job_id") == job, "NODE_CUA_JOB_BINDING_INVALID")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"], node_id=node["node_id"])
response.update(_target_identity(alias, node))
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_CUA_FAILED"}
response = {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_CUA_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def main() -> None:
@@ -8,7 +8,7 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'commands': ['fb help', 'fb status', 'fb --json status']},
{'heading': 'Install and connect',
'body': 'Install the package built for your exact Debian 13, Ubuntu 24.04 or Linux Mint 22.x amd64 profile. These are the three release profiles; other distributions and architectures are outside this release. Keep the distribution system Python: 3.13 on Debian 13, 3.12 on Ubuntu 24.04 and Mint 22.x. Use the matching signed APT suite: trixie, noble or mint22. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.',
'commands': ["sudo apt install ./framebridge-expanded_" + __version__ + "_amd64.deb", 'fb connect']},
'commands': ['fb connect']},
{'heading': 'Updating and removal',
'body': 'Install an official update through the normal package manager when that update has been published. Normal removal stops package-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential-forgetting operation. On systemd 255, package removal also revokes the administrative boot-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user-profile data. An explicit downgrade also revokes administrative helper grants before the older package files replace the helper. Helper-backed profiles require a new interactive connect with a compatible package before managed startup can be restored.',
'commands': ['sudo dpkg --remove framebridge-original-expanded']},
@@ -50,6 +50,7 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'fb status boot',
'fb status console',
'fb doctor',
'fb health',
'fb setup',
'fb console',
'fb help']},
@@ -210,7 +211,11 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
{'heading': 'Targets',
'body': 'LOCAL means the actual receiving machine. Use enrolled, '
'confirmed aliases or IDs and inspect the selected target before '
'work. Do not guess SSH identities or use historical lab '
'work. Fleet results include requested_target, resolved_target, '
'node_id, hostname and transport from the confirmed descriptor. '
'The compatibility target field can remain LOCAL; resolved_target '
'names the actual local computer. Unknown targets never fall back '
'to LOCAL. Do not guess SSH identities or use historical lab '
'hostnames as defaults. CLI tty --node can use the authenticated '
'node transport. The interactive console command is local; run it '
'at the destination or use an explicitly configured SSH '
@@ -327,8 +332,11 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'fb status runtime',
'fb status boot']},
{'heading': 'Doctor',
'body': 'doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.',
'body': 'doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. The published 0.3.3 scope is Debian 13 with cp313 and Ubuntu 24.04/Mint 22.x with cp312. LMDE 7 and Ubuntu 26.04 builder experiments remain outside that validated scope. A matching profile or a successful development build does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.',
'commands': ['fb doctor']},
{'heading': 'Local health summary',
'body': 'health combines this local profile\'s existing status and prerequisite observations into an allowlisted report. It identifies LOCAL as this computer and reports the profile, role, connection intent, local tools, boot policy and desktop availability. It never starts, repairs, connects, changes boot policy or creates an absent profile. It does not scan the fleet or probe terminal commands, screen capture or input. States are healthy, paused, degraded, unavailable and unconfigured. An intentional connection pause can be healthy; missing information cannot. JSON result.ok describes observation completeness; result.health.healthy describes health. Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. The CLI version is not proof of the running version. Detailed troubleshooting remains local in status and doctor.',
'commands': ['fb health', 'fb --json health']},
{'heading': 'Logs and progress',
'body': 'Normal connection presents concise progress and a final '
'result, then returns to the prompt. Detailed diagnostics '
@@ -0,0 +1,234 @@
"""Read-only, allowlisted health observations for one local FrameBridge profile.
This combines existing status and prerequisite checks. It does not execute a
terminal/CUA probe, contact another node, repair a profile or change boot intent.
Snapshot success, observed readiness and compatibility are separate facts.
"""
from __future__ import annotations
from datetime import datetime, timezone
import re
import socket
import uuid
from . import __version__, lifecycle, runtime
from .config import state_path
_KINDS = frozenset({"physical-x11", "virtual-x11", "text", "not_started", "unavailable"})
_PHASES = frozenset({"starting", "checking", "access", "unverified", "ready",
"retrying", "error", "rejected", "stopped", "closed"})
_SERVICES = frozenset({"active", "activating", "deactivating", "inactive", "failed",
"reloading", "maintenance", "refreshing", "not_configured"})
_CAPABILITIES = ("fbexec", "fbterm", "cua_tty_module", "graphical_cua")
def _mapping(value):
return value if isinstance(value, dict) else {}
def _boolean(value):
return value if type(value) is bool else None
def _name(value):
return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,252}", value) else None
def _node_id(value):
try:
return str(uuid.UUID(value)) if isinstance(value, str) else None
except (ValueError, AttributeError):
return None
def _count(value):
return len(value) if isinstance(value, list) else 0
def _choice(value, options, fallback="unavailable"):
return value if isinstance(value, str) and value in options else fallback
def _runtime_state(value):
if value.get("ok") is False or value.get("status_unavailable") or type(value.get("running")) is not bool:
return "unavailable"
if value.get("ready") is True and value.get("running") is True:
return "ready"
return "running" if value["running"] else "stopped"
def _boot_state(value):
if value.get("configured") is False:
return "not_configured"
if value.get("configured") is not True or type(value.get("enabled")) is not bool:
return "unavailable"
return "enabled" if value["enabled"] else "disabled"
def build_report(status_snapshot=None, doctor_snapshot=None, *, observed_at=None,
host=None, status_available=True, doctor_available=True):
"""Project snapshots onto a fixed public schema; never copy raw dictionaries.
Errors deliberately omit exception text and diagnostic free text. Existing
local ``fb doctor`` remains the detailed troubleshooting interface; this
summary can be shown to an assistant without connection metadata or paths.
"""
status = _mapping(status_snapshot)
diagnosis = _mapping(doctor_snapshot)
local = _mapping(status.get("runtime"))
connection = _mapping(status.get("connection"))
boot = _mapping(status.get("boot"))
platform = _mapping(diagnosis.get("platform_compatibility"))
capabilities = _mapping(local.get("capabilities"))
prerequisite_capabilities = _mapping(diagnosis.get("capabilities"))
context = _mapping(local.get("display_context"))
status_available = bool(status_available and isinstance(status_snapshot, dict) and status.get("ok") is not False)
configured = _boolean(status.get("configured")) if status_available else None
role = status.get("role") or local.get("role")
role = _choice(role, {"standalone", "central", "node"}, None)
local_state = _runtime_state(local) if configured is not False else "not_configured"
phase = _choice(connection.get("state"), _PHASES)
intent = _choice(status.get("desired_connection"), {"connected", "disconnected", "not_configured", "not_applicable"})
kind = _choice(context.get("kind"), _KINDS)
doctor_available = bool(doctor_available and isinstance(doctor_snapshot, dict) and type(diagnosis.get("ok")) is bool) if configured else False
diagnostics_ok = _boolean(diagnosis.get("ok")) if doctor_available else None
connection_available = role == "node" or (
bool(connection) and connection.get("ok") is not False
and phase != "unavailable" and type(connection.get("verified")) is bool)
boot_available = _boot_state(boot) != "unavailable"
issues = []
if not status_available:
issues.append("STATUS_UNAVAILABLE")
if configured and not doctor_available:
issues.append("DIAGNOSTICS_UNAVAILABLE")
if configured and local_state == "unavailable":
issues.append("RUNTIME_STATUS_UNAVAILABLE")
if configured and not connection_available:
issues.append("CONNECTION_STATUS_UNAVAILABLE")
if configured and not boot_available:
issues.append("BOOT_STATUS_UNAVAILABLE")
if diagnostics_ok is False:
issues.append("PREREQUISITES_NEED_ATTENTION")
if configured is False:
health_state, healthy = "unconfigured", False
elif (not status_available or configured is None or role is None or not doctor_available
or local_state == "unavailable" or not connection_available or not boot_available):
health_state, healthy = "unavailable", None
elif diagnostics_ok is not True or local_state != "ready":
health_state, healthy = "degraded", False
elif role == "node":
health_state, healthy = "healthy", True
elif intent == "disconnected":
if connection.get("verified") is False and connection.get("alive") is not True and phase in {"stopped", "closed"}:
health_state, healthy = "paused", True
else:
health_state, healthy = "degraded", False
issues.append("CONNECTION_INTENT_MISMATCH")
elif connection.get("verified") is True:
health_state, healthy = "healthy", True
else:
health_state, healthy = "degraded", False
issues.append("CONNECTION_NOT_VERIFIED")
if configured and local_state in {"running", "stopped"}:
issues.append("LOCAL_TOOLS_NOT_READY")
observation_ok = (status_available and configured is not None and local_state != "unavailable"
and (configured is False or (doctor_available and connection_available
and boot_available and role is not None)))
build_profile = platform.get("artifact_profile_id") or platform.get("profile_id")
missing = diagnosis.get("missing_commands")
missing = sorted({v for v in missing if isinstance(v, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}", v)}) if isinstance(missing, list) else []
notes = ["This is a local profile observation, not a fleet scan.",
"Status and prerequisites are sampled sequentially; this is not an atomic snapshot.",
"No FBEXEC/FBTERM command, screen capture or input probe was performed."]
if role == "node":
notes.append("The central computer provides the ChatGPT connection; this report does not verify that central connection.")
if configured is False:
notes.append("This profile is not configured. No profile or connection was created.")
if kind == "text":
notes.append("Text mode does not require a graphical session.")
return {
"schema_version": 1, "ok": observation_ok, "read_only": True,
"observed_at": observed_at or datetime.now(timezone.utc).isoformat(),
"effective_target": {"selector": "LOCAL", "host": _name(host),
"profile_name": _name(status.get("name") or local.get("name")),
"role": role, "node_id": _node_id(local.get("node_id"))},
"software": {"cli_version": __version__, "runtime_version": None,
"runtime_version_status": "not_reported"},
"health": {"state": health_state, "healthy": healthy, "issues": issues},
"runtime": {"state": local_state, "running": _boolean(local.get("running")) if configured else None,
"ready": _boolean(local.get("ready")) if configured else None,
"availability": {k: _boolean(capabilities.get(k)) for k in _CAPABILITIES},
"operational_probe_performed": False},
"connection": {"state": "managed_by_central" if role == "node" else phase,
"verified": None if role == "node" else _boolean(connection.get("verified")),
"desired": intent},
"boot": {"policy": _boot_state(boot),
"service_state": _choice(boot.get("service_state"), _SERVICES)},
"prerequisites": {"state": "not_run" if configured is False else ("passed" if diagnostics_ok is True else "needs_attention" if diagnostics_ok is False else "unavailable"),
"compatible": _boolean(platform.get("ok")) if doctor_available else None,
"profile_id": _name(build_profile),
"validation_status": _choice(platform.get("validation_status"), {"candidate", "release_build", "validated", "source-unvalidated"}, "not_reported"),
"missing_commands": missing,
"issue_count": _count(diagnosis.get("issues")),
"compatibility_issue_count": _count(platform.get("issues")),
"availability": {k: _boolean(prerequisite_capabilities.get(k)) for k in _CAPABILITIES}},
"desktop": {"kind": kind, "graphical_available": _boolean(capabilities.get("graphical_cua")),
"capture_verified_by_this_report": False, "input_verified_by_this_report": False},
"notes": notes,
}
def collect(state):
"""Observe only the selected local profile without creating or repairing it."""
state = state_path(state)
status_snapshot, diagnosis = None, None
status_available = doctor_available = True
try:
status_snapshot = lifecycle.status(state)
except Exception:
status_available = False
if _mapping(status_snapshot).get("configured") is True:
try:
diagnosis = runtime.doctor(state)
except Exception:
doctor_available = False
else:
doctor_available = False
try:
host = socket.gethostname()
except OSError:
host = None
return build_report(status_snapshot, diagnosis, host=host,
status_available=status_available, doctor_available=doctor_available)
def render(report):
"""Render the same allowlisted report for a human terminal."""
target = report["effective_target"]
software = report["software"]
state = report["health"]["state"]
lines = ["FrameBridge " + software["cli_version"] + " — health: " + state,
"Target: LOCAL = this computer (" + (target["host"] or "hostname unavailable") + ")",
"Profile: " + (target["profile_name"] or "not available") + " | role: " + (target["role"] or "not available")]
if target["node_id"]:
lines.append("Node ID: " + target["node_id"])
lines.extend(["Observed: " + report["observed_at"],
"Local tools: " + report["runtime"]["state"],
"Connection: " + ("connected (verified)" if report["connection"]["verified"] is True else report["connection"]["state"]),
"Connection intent: " + report["connection"]["desired"],
"Automatic startup: " + report["boot"]["policy"],
"Current service: " + report["boot"]["service_state"],
"Prerequisites: " + report["prerequisites"]["state"]])
if report["prerequisites"]["profile_id"]:
lines.append("Build profile: " + report["prerequisites"]["profile_id"])
lines.append("Desktop: " + report["desktop"]["kind"] + " (capture/input not tested)")
for issue in report["health"]["issues"]:
lines.append("Finding: " + issue)
if report["prerequisites"]["missing_commands"]:
lines.append("Missing commands: " + ", ".join(report["prerequisites"]["missing_commands"]))
if state in {"degraded", "unavailable"}:
lines.append("Inspect details locally: fb status; fb doctor")
lines.append("Runtime version: not reported; the CLI version is not proof of the running version.")
lines.extend(report["notes"])
return "\n".join(lines)