"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node.""" from __future__ import annotations import errno import hashlib import http.client import importlib import json import logging import os import socket import ssl import time import uuid import urllib.error from pathlib import Path from . import broker _LOG = logging.getLogger(__name__) _RETRY_MAX_SECONDS = 30 class NodeError(ValueError): pass def _original(name): broker.original_modules() return importlib.import_module("framebridge_linux_chatgpt." + name) def _write_json(path, value): path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = path.with_name("." + path.name + "." + uuid.uuid4().hex + ".tmp") fd = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) try: with os.fdopen(fd, "w") as stream: json.dump(value, stream, indent=2, sort_keys=True, allow_nan=False) stream.write("\n") stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) finally: if temporary.exists(): temporary.unlink() def _context(state_dir=None, create=False, node_label=None): state = broker.state_path(state_dir) config = broker._config(state) label = node_label or config.get("name") or socket.gethostname() if not isinstance(label, str) or broker._LABEL.fullmatch(label) is None or label.casefold() == "local": raise NodeError("NODE_LABEL_INVALID") ctx = _original("seed_core").SeedContext(state_dir=state / "node", config_dir=state / "node/config") identity = ctx.load_identity() if identity: node_id = identity.get("node_id") if config.get("node_id") and config["node_id"] != node_id: raise NodeError("NODE_IDENTITY_CONFIG_MISMATCH") if create and identity.get("hostname") != label: raise NodeError("NODE_IDENTITY_LABEL_MISMATCH") elif create: node_id = config.get("node_id") or str(uuid.uuid4()) try: if str(uuid.UUID(node_id)) != node_id: raise ValueError except (ValueError, TypeError, AttributeError): raise NodeError("NODE_ID_INVALID") from None crypto = _original("node_identity").ensure_node_keypair(ctx.state_dir, comment="framebridge-" + node_id[:8]) identity = {"schema": 2, "node_id": node_id, "node_id_short": node_id.replace("-", "")[:8], "node_host_id": crypto["host_id"], "node_public_jwk": crypto["public_jwk"], "node_public_key_sha256": crypto["public_key_sha256"], "hostname": label, "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} _write_json(ctx.identity_path, identity) else: raise NodeError("NODE_IDENTITY_MISSING") if create and (config.get("node_id") != node_id or config.get("name") != label): config.update(schema=1, node_id=node_id, name=label) _write_json(state / "config.json", config) return state, config, ctx, identity def _poster(state): def post(url, value, *, timeout=10): return broker.post_json(url, value, state_dir=state, timeout=timeout) return post def enroll(broker_url, node_label, state_dir=None): url = broker.validate_endpoint(broker_url) state, config, ctx, identity = _context(state_dir, create=True, node_label=node_label) store = _original("pairing").PairingStore(ctx.state_dir) existing = store.load() if existing.get("state") in {"pair_requested", "paired"}: raise NodeError("NODE_ENROLLMENT_ALREADY_EXISTS") manifest = _original("seed_capabilities").list_capabilities() digest = hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()).hexdigest() result = _original("pairing_client").start_pairing( state_dir=ctx.state_dir, node_id=identity["node_id"], node_label=node_label, node_host_id=identity["node_host_id"], public_jwk=identity["node_public_jwk"], provider="chatgpt", capability_manifest_sha256=digest, broker_base_url=url, allow_insecure_dev=url.startswith("http://"), poster=_poster(state)) config["broker_url"] = url _write_json(state / "config.json", config) return dict(result, node_id=identity["node_id"], node_label=node_label, fingerprint=identity["node_public_key_sha256"]) def poll_enrollment(state_dir=None): state, config, ctx, identity = _context(state_dir) url = broker.validate_endpoint(config.get("broker_url", "")) store = _original("pairing").PairingStore(ctx.state_dir) pairing = store.load() if pairing.get("broker_base_url") and pairing["broker_base_url"].rstrip("/") != url: raise NodeError("PAIRING_BROKER_CONFIG_MISMATCH") if pairing.get("state") == "paired" and pairing.get("broker_confirm_required") is not True: return status(state) result = _original("pairing_client").poll_pairing( state_dir=ctx.state_dir, allow_insecure_dev=url.startswith("http://"), poster=_poster(state)) pairing = store.load() if pairing.get("state") == "paired": _write_json(ctx.config_path, {"schema": 1, "provider": "chatgpt", "pairing": { "state": "paired", "broker_base_url": url, "broker_confirm_required": pairing.get("broker_confirm_required") is True}}) return dict(result, node_id=identity["node_id"]) def status(state_dir=None): try: state, config, ctx, identity = _context(state_dir) except NodeError as exc: return {"ok": False, "error": str(exc), "state": "not_enrolled"} pairing = _original("pairing").PairingStore(ctx.state_dir).load() return {"ok": True, "node_id": identity["node_id"], "node_label": identity["hostname"], "fingerprint": identity["node_public_key_sha256"], "state": pairing.get("state", "not_paired"), "broker_confirm_required": pairing.get("broker_confirm_required") is True, "broker_url": config.get("broker_url"), "agent_state": "not_checked"} def dispatch_request(request, adapter_request=None, state_dir=None): """Recheck binding and authorization before invoking the real local Original adapter.""" if not isinstance(request, dict): return {"ok": False, "error": "REQUEST_INVALID"} try: _, _, _, identity = _context(state_dir) if request.get("node_id") != identity["node_id"]: raise NodeError("REQUEST_NODE_MISMATCH") expires = request.get("expires_at") if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not expires > time.time(): raise NodeError("REQUEST_EXPIRED") rid = request.get("request_id") if not isinstance(rid, str) or not rid or len(rid) > 80: raise NodeError("REQUEST_ID_INVALID") operation = request.get("operation") if operation: payload = broker._admin_payload(operation, request.get("payload")) payload = dict(payload, version=1, mode="EXEC") if operation == "admin.cua": payload["request_type"] = "cua" if adapter_request is None: from .runtime import adapter_request result = adapter_request(payload) if not isinstance(result, dict): raise NodeError("ADAPTER_RESPONSE_INVALID") result = dict(result, mcp_job_id=payload["job_id"]) if operation == "admin.exec": result["mcp_script_sha256"] = payload["sha256"] if payload.get("include_stdout") is not True: result.update(stdout="", stdout_sanitized=False, stdout_truncated=False, stdout_omitted_reason="NOT_REQUESTED") return result cap, args = request.get("capability"), request.get("args") if cap not in broker.original_modules().READ_CAPS or not isinstance(args, dict): raise NodeError("CAPABILITY_NOT_ALLOWED") value = _original("seed_capabilities").execute_capability(cap, args) if isinstance(value, dict) and value.get("ok") is False: return {"ok": False, "capability": cap, "error": "READ_CAPABILITY_FAILED"} return {"ok": True, "capability": cap, "result": value} except (NodeError, broker.BrokerError) as exc: return {"ok": False, "error": str(exc)} except Exception: return {"ok": False, "error": "NODE_DISPATCH_FAILED"} def _retryable_broker_failure(exc): # post_json intentionally hides transport details from its public error. Its # exception context still distinguishes interruptions from TLS/JSON failures; # never log that context or treat an unknown failure as transient. code = str(exc) if code in {"BROKER_HTTP_REJECTED_502", "BROKER_HTTP_REJECTED_503", "BROKER_HTTP_REJECTED_504"}: return True if code != "BROKER_TRANSPORT_FAILED": return False cause = exc.__context__ if isinstance(cause, urllib.error.URLError): cause = cause.reason if isinstance(cause, ssl.SSLError): return False if isinstance(cause, socket.gaierror): return cause.errno == socket.EAI_AGAIN if isinstance(cause, (ConnectionError, TimeoutError, http.client.IncompleteRead)): return True return isinstance(cause, OSError) and cause.errno in { errno.ECONNREFUSED, errno.ECONNRESET, errno.ECONNABORTED, errno.ETIMEDOUT, errno.EPIPE, errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN, } def _post_with_recovery(url, operation, payload, *, ctx, state, outbound, recover): delay = 1 retries = 0 while True: # A new signature/nonce is mandatory: the previous attempt may have been # verified even when its response was lost. Do not replay its envelope. envelope = outbound._signed_envelope(ctx, operation, payload) try: result = broker.post_json(url, envelope, state_dir=state) except broker.BrokerError as exc: if not recover or not _retryable_broker_failure(exc): raise retries += 1 _LOG.warning("NODE_BROKER_RETRY operation=%s error=%s delay_seconds=%d", operation, str(exc), delay) # Signals and cancellation exceptions are deliberately not caught. time.sleep(delay) delay = min(delay * 2, _RETRY_MAX_SECONDS) else: if retries: _LOG.warning("NODE_BROKER_RECOVERED operation=%s retries=%d", operation, retries) return result def run(state_dir=None, once=False): state, config, ctx, identity = _context(state_dir) url = broker.validate_endpoint(config.get("broker_url", "")) pairing = _original("pairing").PairingStore(ctx.state_dir).load() if pairing.get("state") != "paired" or pairing.get("broker_confirm_required") is True: raise NodeError("NODE_NOT_CONFIRMED") if pairing.get("node_id") != identity["node_id"] or pairing.get("broker_base_url", "").rstrip("/") != url: raise NodeError("PAIRING_BINDING_MISMATCH") outbound = _original("outbound_agent") while True: poll = _post_with_recovery(url + "/fbpair/v1/node/poll", "node.poll", {}, ctx=ctx, state=state, outbound=outbound, recover=not once) if poll.get("ok") is not True: raise NodeError("NODE_POLL_REJECTED") req = poll.get("request") result = {"ok": True, "idle": True} if isinstance(req, dict): value = dispatch_request(req, state_dir=state) # Original put_result only records this request's result; repeating # that acknowledgement cannot execute the command. Keep the value # here and never return to dispatch/poll while its delivery is pending. posted = _post_with_recovery(url + "/fbpair/v1/node/result", "node.result", {"request_id": req.get("request_id"), "result": value}, ctx=ctx, state=state, outbound=outbound, recover=not once) if posted.get("ok") is not True: raise NodeError("NODE_RESULT_REJECTED") result = {"ok": True, "idle": False, "request_id": req.get("request_id")} if once: return result time.sleep(1)