206 lines
9.7 KiB
Python
206 lines
9.7 KiB
Python
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib
|
|
import json
|
|
import os
|
|
import socket
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from . import broker
|
|
|
|
|
|
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 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 = broker.post_json(url + "/fbpair/v1/node/poll",
|
|
outbound._signed_envelope(ctx, "node.poll", {}), state_dir=state)
|
|
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)
|
|
posted = broker.post_json(url + "/fbpair/v1/node/result", outbound._signed_envelope(
|
|
ctx, "node.result", {"request_id": req.get("request_id"), "result": value}), state_dir=state)
|
|
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)
|