FrameBridge 0.3.1 community source release

This commit is contained in:
FrameBridge Development committed 2026-10-26 22:09:18 -04:00
commit b23819fff1
1354 files changed
+343716

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
__version__ = "0.1.0-alpha.1"
@@ -0,0 +1,276 @@
from __future__ import annotations
import base64
import hashlib
import json
import secrets
import socket
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
AUTHORIZATION_ENDPOINT="https://auth.openai.com/api/accounts/authorize"
TOKEN_ENDPOINT="https://auth.openai.com/api/accounts/oauth/token"
DISCOVERY_ENDPOINT="https://auth.openai.com/.well-known/openid-configuration"
RESOURCE="https://api.openai.com/v1"
DYNAMIC_CLIENT_ID="dynamic_agent_client"
DEFAULT_SCOPES=("openid","profile","email","offline_access","resource.invoke","chatgpt.tokens.use.direct")
AGENT_NAME="FrameBridge"
class ChatGPTAuthError(RuntimeError):
pass
def discover_openai(*, requests_module=None) -> dict[str,str]:
if requests_module is None:
import requests as requests_module
response=requests_module.get(DISCOVERY_ENDPOINT,timeout=15)
if response.status_code!=200:
raise ChatGPTAuthError("CHATGPT_DISCOVERY_FAILED")
value=response.json()
if not isinstance(value,dict):
raise ChatGPTAuthError("CHATGPT_DISCOVERY_INVALID")
issuer=str(value.get("issuer") or "")
auth=str(value.get("authorization_endpoint") or "")
token=str(value.get("token_endpoint") or "")
jwks=str(value.get("jwks_uri") or "")
if issuer!="https://auth.openai.com":
raise ChatGPTAuthError("CHATGPT_DISCOVERY_INVALID")
for url in (auth,token,jwks):
if not url.startswith("https://auth.openai.com/"):
raise ChatGPTAuthError("CHATGPT_DISCOVERY_INVALID")
return {
"issuer":issuer,
"authorization_endpoint":auth,
"token_endpoint":token,
"jwks_uri":jwks,
}
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _random(n: int=48) -> str:
return secrets.token_urlsafe(n)
def account_binding_id(issuer: str, subject: str) -> str:
raw=(str(issuer)+"\0"+str(subject)).encode("utf-8")
return "fbacct_"+hashlib.sha256(raw).hexdigest()[:40]
def build_authorization(
host_id: str,
redirect_uri: str,
*,
scopes: tuple[str,...]=DEFAULT_SCOPES,
authorization_endpoint: str=AUTHORIZATION_ENDPOINT,
client_id: str=DYNAMIC_CLIENT_ID,
) -> tuple[str,dict[str,str]]:
host_id=str(host_id).strip()
if not host_id:
raise ChatGPTAuthError("CHATGPT_HOST_ID_REQUIRED")
state=_random(32)
nonce=_random(32)
verifier=_random(64)
challenge=_b64url(hashlib.sha256(verifier.encode("ascii")).digest())
client_id=str(client_id or DYNAMIC_CLIENT_ID)
tx={
"state":state,
"nonce":nonce,
"code_verifier":verifier,
"redirect_uri":str(redirect_uri),
"created_at":str(time.time()),
"client_id":client_id,
}
query={
"client_id":client_id,
"ext_agent_host_id":host_id,
"response_type":"code",
"redirect_uri":str(redirect_uri),
"scope":" ".join(scopes),
"resource":RESOURCE,
"state":state,
"nonce":nonce,
"code_challenge_method":"S256",
"code_challenge":challenge,
}
if client_id==DYNAMIC_CLIENT_ID:
query["agent_name_hint"]=AGENT_NAME
return str(authorization_endpoint)+"?"+urllib.parse.urlencode(query),tx
class _Callback:
def __init__(self):
self.event=threading.Event()
self.query: dict[str,str]={}
def _handler(callback: _Callback):
class Handler(BaseHTTPRequestHandler):
def log_message(self,fmt,*args):
return
def do_GET(self):
p=urllib.parse.urlparse(self.path)
if p.path!="/auth/callback":
self.send_response(404);self.end_headers();return
q=urllib.parse.parse_qs(p.query,keep_blank_values=True)
callback.query={k:(v[0] if v else "") for k,v in q.items()}
callback.event.set()
body=(
b"<!doctype html><meta charset=utf-8>"
b"<title>FrameBridge</title>"
b"<h2>FrameBridge</h2>"
b"<p>ChatGPT authentication returned to FrameBridge.</p>"
b"<p>You may close this browser tab.</p>"
)
self.send_response(200)
self.send_header("Content-Type","text/html; charset=utf-8")
self.send_header("Cache-Control","no-store")
self.send_header("Content-Length",str(len(body)))
self.end_headers()
self.wfile.write(body)
return Handler
def start_loopback_listener() -> tuple[HTTPServer,_Callback,str]:
cb=_Callback()
server=HTTPServer(("127.0.0.1",0),_handler(cb))
port=int(server.server_address[1])
uri=f"http://127.0.0.1:{port}/auth/callback"
thread=threading.Thread(target=server.serve_forever,daemon=True)
thread.start()
return server,cb,uri
def exchange_and_validate(
callback_query: dict[str,str],
tx: dict[str,str],
*,
requests_module=None,
jwt_module=None,
provider_metadata: dict[str,str] | None=None,
) -> dict[str,Any]:
if callback_query.get("state") != tx.get("state"):
raise ChatGPTAuthError("CHATGPT_STATE_MISMATCH")
if callback_query.get("error"):
raise ChatGPTAuthError("CHATGPT_AUTH_DENIED")
code=str(callback_query.get("code") or "")
requested_client_id=str(tx.get("client_id") or DYNAMIC_CLIENT_ID)
callback_client_id=str(callback_query.get("client_id") or "")
if requested_client_id==DYNAMIC_CLIENT_ID:
issued_client_id=callback_client_id
if not code or not issued_client_id or issued_client_id==DYNAMIC_CLIENT_ID:
raise ChatGPTAuthError("CHATGPT_CALLBACK_INCOMPLETE")
else:
issued_client_id=requested_client_id
if callback_client_id and callback_client_id!=requested_client_id:
raise ChatGPTAuthError("CHATGPT_AUTH_CLIENT_MISMATCH")
if not code:
raise ChatGPTAuthError("CHATGPT_CALLBACK_INCOMPLETE")
if requests_module is None:
import requests as requests_module
if jwt_module is None:
import jwt as jwt_module
provider_metadata=provider_metadata or discover_openai(requests_module=requests_module)
token_endpoint=provider_metadata["token_endpoint"]
issuer=provider_metadata["issuer"]
jwks_uri=provider_metadata["jwks_uri"]
token_resp=requests_module.post(
token_endpoint,
data={
"grant_type":"authorization_code",
"client_id":issued_client_id,
"code":code,
"code_verifier":tx["code_verifier"],
"redirect_uri":tx["redirect_uri"],
"resource":RESOURCE,
},
timeout=20,
)
if token_resp.status_code != 200:
raise ChatGPTAuthError("CHATGPT_TOKEN_EXCHANGE_FAILED")
tokens=token_resp.json()
id_token=str(tokens.get("id_token") or "")
if not id_token:
raise ChatGPTAuthError("CHATGPT_ID_TOKEN_MISSING")
jwk_client=jwt_module.PyJWKClient(jwks_uri)
signing_key=jwk_client.get_signing_key_from_jwt(id_token)
alg=str(getattr(signing_key,"algorithm_name","") or "")
if not alg:
try:
alg=str(jwt_module.get_unverified_header(id_token).get("alg") or "")
except Exception:
alg=""
if alg not in {"RS256","ES256","EdDSA"}:
raise ChatGPTAuthError("CHATGPT_ID_TOKEN_ALG_INVALID")
claims=jwt_module.decode(
id_token,
signing_key.key,
algorithms=[alg],
audience=issued_client_id,
issuer=issuer,
options={"require":["sub","exp","iat"]},
leeway=5,
)
if claims.get("nonce") != tx.get("nonce"):
raise ChatGPTAuthError("CHATGPT_NONCE_MISMATCH")
subject=str(claims.get("sub") or "")
if not subject:
raise ChatGPTAuthError("CHATGPT_SUBJECT_MISSING")
granted=str(tokens.get("scope") or callback_query.get("scope") or "")
scopes=sorted({x for x in granted.split() if x})
email=str(claims.get("email") or "")
# No OAuth access/refresh/ID token is persisted or returned.
return {
"ok":True,
"issuer":issuer,
"client_id":issued_client_id,
"account_binding_id":account_binding_id(issuer,subject),
"subject_sha256":hashlib.sha256(subject.encode("utf-8")).hexdigest(),
"email_sha256":hashlib.sha256(email.encode("utf-8")).hexdigest() if email else None,
"scopes":scopes,
"identity_only": "chatgpt.tokens.use.direct" not in scopes,
}
def authenticate_interactive(
host_id: str,
*,
browser_opener=None,
timeout_seconds: int=300,
client_id: str=DYNAMIC_CLIENT_ID,
) -> dict[str,Any]:
server,cb,redirect_uri=start_loopback_listener()
try:
provider=discover_openai()
url,tx=build_authorization(
host_id,
redirect_uri,
authorization_endpoint=provider["authorization_endpoint"],
client_id=client_id,
)
if browser_opener is None:
import webbrowser
opened=webbrowser.open(url,new=1,autoraise=True)
else:
opened=bool(browser_opener(url))
if not opened:
raise ChatGPTAuthError("CHATGPT_BROWSER_OPEN_FAILED")
if not cb.event.wait(timeout_seconds):
raise ChatGPTAuthError("CHATGPT_AUTH_TIMEOUT")
return exchange_and_validate(cb.query,tx,provider_metadata=provider)
finally:
server.shutdown()
server.server_close()
@@ -0,0 +1,224 @@
from __future__ import annotations
import hashlib
import json
import re
import time
from dataclasses import dataclass
from typing import Any
from .product_core import ProductError
from .seed_capabilities import execute_capability, list_capabilities
from .seed_core import SeedContext
FRAME_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
@dataclass(frozen=True)
class ClassicRequest:
frame_id: str
target: str
mode: str
lang: str
body: dict[str, Any]
body_sha256: str
def parse_frame(text: str) -> ClassicRequest:
raw = str(text).replace("\r\n", "\n").replace("\r", "\n")
lines = raw.splitlines()
try:
start = lines.index("FRAMEEXEC/1")
except ValueError:
raise ProductError("CLASSIC_FRAME_START_MISSING")
try:
end = lines.index("FRAMEEND/1", start + 1)
except ValueError:
raise ProductError("CLASSIC_FRAME_END_MISSING")
frame_lines = lines[start + 1:end]
headers: dict[str, str] = {}
body_start = None
for i, line in enumerate(frame_lines):
if not line.strip():
body_start = i + 1
break
if "=" not in line:
raise ProductError("CLASSIC_HEADER_INVALID")
key, value = line.split("=", 1)
headers[key.strip().upper()] = value.strip()
if body_start is None:
raise ProductError("CLASSIC_BODY_SEPARATOR_MISSING")
frame_id = headers.get("ID", "")
target = headers.get("TARGET", "")
mode = headers.get("MODE", "")
lang = headers.get("LANG", "")
if not FRAME_ID_RE.fullmatch(frame_id):
raise ProductError("CLASSIC_ID_INVALID")
if not target:
raise ProductError("CLASSIC_TARGET_REQUIRED")
if mode != "EXEC":
raise ProductError("CLASSIC_MODE_UNSUPPORTED")
if lang != "framebridge-cap-v1":
raise ProductError("CLASSIC_LANG_UNSUPPORTED")
body_text = "\n".join(frame_lines[body_start:]).strip()
if not body_text:
raise ProductError("CLASSIC_BODY_MISSING")
if len(body_text.encode("utf-8")) > 32768:
raise ProductError("CLASSIC_BODY_TOO_LARGE")
try:
body = json.loads(body_text)
except Exception:
raise ProductError("CLASSIC_BODY_JSON_INVALID")
if not isinstance(body, dict):
raise ProductError("CLASSIC_BODY_NOT_OBJECT")
canonical = json.dumps(
body,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
return ClassicRequest(
frame_id=frame_id,
target=target,
mode=mode,
lang=lang,
body=body,
body_sha256=hashlib.sha256(canonical).hexdigest(),
)
def _target_matches(seed: SeedContext, target: str) -> bool:
identity = seed.load_identity()
if identity is None:
raise ProductError("SEED_NOT_INITIALIZED")
accepted = {
str(identity.get("hostname") or ""),
str(identity.get("node_id") or ""),
str(identity.get("node_id_short") or ""),
}
return target in accepted
def execute_frame(seed: SeedContext, text: str) -> dict[str, Any]:
request = parse_frame(text)
if not _target_matches(seed, request.target):
raise ProductError("CLASSIC_TARGET_MISMATCH")
capability = str(request.body.get("capability") or "").strip()
args = request.body.get("args") or {}
if "script" in request.body or "shell" in request.body or "command" in request.body:
raise ProductError("CLASSIC_RAW_SHELL_FORBIDDEN")
started = time.monotonic()
try:
result = execute_capability(capability, args)
status = "PASS"
exit_code = 0
except Exception as exc:
result = {
"ok": False,
"error": str(exc),
"error_type": type(exc).__name__,
}
status = "ERROR"
exit_code = 1
duration_ms = int((time.monotonic() - started) * 1000)
seed.audit.append(
"classic." + capability,
status,
request={
"id": request.frame_id,
"target": request.target,
"lang": request.lang,
"body_sha256": request.body_sha256,
"capability": capability,
"args": args,
},
result={
"exit": exit_code,
"duration_ms": duration_ms,
},
principal="chat-manual",
)
return {
"id": request.frame_id,
"exit": exit_code,
"channel": "SEED-CLASSIC-TTY",
"capability": capability,
"body_sha256": request.body_sha256,
"duration_ms": duration_ms,
"result": result,
}
def render_result(value: dict[str, Any]) -> str:
payload = json.dumps(
value.get("result"),
indent=2,
sort_keys=True,
ensure_ascii=True,
)
return "\n".join([
"FRAMERESULT/1",
"ID=" + str(value.get("id")),
"EXIT=" + str(value.get("exit")),
"CHANNEL=" + str(value.get("channel")),
"CAPABILITY=" + str(value.get("capability")),
"BODY_SHA256=" + str(value.get("body_sha256")),
"DURATION_MS=" + str(value.get("duration_ms")),
"",
payload,
"FRAMEEND/1",
])
def render_snapshot(seed: SeedContext) -> str:
identity = seed.load_identity()
config = seed.load_config()
if identity is None or config is None:
raise ProductError("SEED_NOT_INITIALIZED")
snapshot = {
"protocol": "framebridge-cap-v1",
"hostname": identity.get("hostname"),
"node_id": identity.get("node_id"),
"node_id_short": identity.get("node_id_short"),
"provider": config.get("provider"),
"connection_mode": config.get("connection_mode", "MANUAL"),
"capability_profile": config.get("capability_profile"),
"capabilities": list_capabilities(),
"instruction": (
"Return only one FRAMEEXEC/1 frame using LANG=framebridge-cap-v1. "
"Use only a listed READONLY capability. Never include raw shell."
),
}
payload = json.dumps(snapshot, indent=2, sort_keys=True, ensure_ascii=True)
return "\n".join([
"FRAMEBRIDGE-SNAPSHOT/1",
"TARGET=" + str(identity.get("node_id_short")),
"PROVIDER=" + str(config.get("provider")),
"CONNECTION=MANUAL",
"",
payload,
"FRAMEEND/1",
])
@@ -0,0 +1,133 @@
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
from .memory_store import MemoryStore
from .product_core import ProductError
BANNER = """
==============================================================
FrameBridge Console
==============================================================
""".strip("\n")
HELP = """
Commands:
help Show this help
status Show node/Memory summary
manifest Show the AI bootstrap manifest
memory <domain> Query normalized Memory
phone Show official mobile-app guidance
provider Show configured AI provider
objectives Show future objective-prompt roadmap
quit / exit Leave the console
Anything else is treated as a chat message.
""".strip("\n")
PHONE_HINT = "📱 You can also continue from the official ChatGPT app on your phone."
def _setup(ctx) -> dict[str, Any]:
cfg = ctx.load_config() or {}
return cfg.get("setup") or {}
def session_header(ctx) -> str:
setup = _setup(ctx)
provider = setup.get("provider") or (ctx.load_config() or {}).get("provider") or "unknown"
memory = MemoryStore(ctx).verify()
identity = ctx.load_identity() or {}
connected = os.environ.get("FRAMEBRIDGE_AGENT_CONNECTED") == "1"
icon = "🤖" if connected else "○"
lines = [
BANNER,
f"Node: {identity.get('hostname') or 'unknown'} ({identity.get('node_id_short') or '--------'})",
f"AI provider: {provider}",
f"Agent: {icon} {'connected' if connected else 'not connected'}",
f"Memory: {memory.get('protocol')} R/W | events={memory.get('events')}",
PHONE_HINT,
"",
"Type help for commands.",
]
return "\n".join(lines)
def _chat_response(ctx, text: str) -> str:
setup = _setup(ctx)
provider = setup.get("provider") or "chatgpt"
# Chat transcripts are intentionally NOT written to technical Memory.
# Only normalized technical facts/decisions/incidents/artifacts belong in FBM.
if provider == "chatgpt":
if os.environ.get("FRAMEBRIDGE_AGENT_CONNECTED") == "1":
return (
"A remote ChatGPT session is connected to this node. "
"Console-to-agent conversational transport is not enabled in this candidate yet. "
"Continue in the official ChatGPT app while this console remains available locally."
)
return (
"ChatGPT companion mode is configured, but no remote agent session is currently attached.\n"
+ PHONE_HINT + "\n"
"Open ChatGPT and connect to this FrameBridge node. The local console will remain active."
)
return (
f"Provider '{provider}' is configured, but direct conversational AgentAdapter transport "
"is not implemented in this candidate."
)
def execute_console_command(ctx, line: str) -> tuple[bool, str]:
raw = str(line).strip()
if not raw:
return True, ""
if raw in {"quit", "exit"}:
return False, "Goodbye."
if raw == "help":
return True, HELP
if raw == "phone":
return True, PHONE_HINT
if raw == "provider":
setup = _setup(ctx)
return True, json.dumps({"provider": setup.get("provider"), "mode": "companion" if setup.get("provider") == "chatgpt" else "adapter"}, indent=2)
if raw == "status":
return True, json.dumps({"status": ctx.status(), "memory": MemoryStore(ctx).stats()}, indent=2, sort_keys=True)
if raw == "manifest":
return True, json.dumps(MemoryStore(ctx).bootstrap(), indent=2, sort_keys=True)
if raw.startswith("memory "):
domain = raw.split(None, 1)[1].strip()
return True, json.dumps(MemoryStore(ctx).query(domain, 20), indent=2, sort_keys=True)
if raw == "objectives":
return True, (
"Objective prompts are planned for a later update. Examples: install a desktop, "
"create a server, configure a workstation, prepare a NAS. They are NOT active in this candidate."
)
return True, _chat_response(ctx, raw)
def run_console_chat(ctx) -> int:
try:
MemoryStore(ctx).require_ready()
except Exception as exc:
print("FrameBridge Console blocked: mandatory Memory is not healthy.")
print(str(exc))
return 2
print(session_header(ctx))
while True:
try:
line = input("\nYou > ")
except (EOFError, KeyboardInterrupt):
print("\nGoodbye.")
return 0
keep, out = execute_console_command(ctx, line)
if out:
print("\n" + ("AI > " if line.strip() not in {"help","status","manifest","phone","provider","objectives"} and not line.strip().startswith("memory ") else "") + out)
if not keep:
return 0
@@ -0,0 +1,88 @@
from __future__ import annotations
import sys
from .classic_tty import execute_frame, render_result, render_snapshot
from .product_core import ProductError
from .seed_core import SeedContext
BANNER = """FrameBridge Seed // Classic TTY
Modo: MANUAL / CHAT_ONLY
Seguridad: READONLY typed capabilities
Comandos locales: /snapshot /status /audit /help /quit
Pegue un FRAMEEXEC/1 completo para ejecutarlo.
"""
def _read_frame(first_line: str) -> str:
lines = [first_line.rstrip("\n")]
while True:
raw = sys.stdin.readline()
if raw == "":
raise ProductError("CLASSIC_FRAME_EOF_BEFORE_END")
line = raw.rstrip("\n")
lines.append(line)
if line == "FRAMEEND/1":
return "\n".join(lines)
def run_console(seed: SeedContext) -> int:
if seed.load_identity() is None:
print("ERROR: Seed no inicializado. Ejecute: framebridge init --provider chatgpt")
return 2
print(BANNER)
print("--- SNAPSHOT PARA SU IA ---")
print(render_snapshot(seed))
print("--- FIN SNAPSHOT ---")
while True:
if sys.stdin.isatty():
print("fb> ", end="", flush=True)
raw = sys.stdin.readline()
if raw == "":
return 0
line = raw.rstrip("\n")
stripped = line.strip()
if not stripped:
continue
if stripped == "/quit":
return 0
if stripped == "/help":
print(BANNER)
continue
if stripped == "/snapshot":
print(render_snapshot(seed))
continue
if stripped == "/status":
import json
print(json.dumps(seed.status(), indent=2, sort_keys=True, ensure_ascii=True))
continue
if stripped == "/audit":
import json
print(json.dumps(seed.audit.verify(), indent=2, sort_keys=True, ensure_ascii=True))
continue
if stripped != "FRAMEEXEC/1":
print("ERROR: comando desconocido. Use /help o pegue FRAMEEXEC/1.")
continue
try:
frame = _read_frame(line)
result = execute_frame(seed, frame)
print(render_result(result))
except ProductError as exc:
print("FRAMERESULT/1")
print("ID=unknown")
print("EXIT=2")
print("CHANNEL=SEED-CLASSIC-TTY")
print("")
print('{"ok":false,"error":' + __import__("json").dumps(str(exc)) + "}")
print("FRAMEEND/1")
return 0
@@ -0,0 +1,121 @@
from __future__ import annotations
import json
import os
import shutil
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .memory_store import MemoryStore
from .product_core import ProductError
@dataclass(frozen=True)
class SetupChoice:
privilege_mode: str
provider: str
ui_language: str
VALID_PRIVILEGE_MODES = {"totp", "sudo_tty"}
VALID_PROVIDERS = {"chatgpt", "ollama", "lmstudio", "other"}
VALID_LANGUAGES = {"en", "es", "zh", "fr", "de", "pt", "ja", "ko"}
def detect_system() -> dict[str, Any]:
from .seed_capabilities import hardware_summary, system_status, storage_inspect, network_inspect
system = system_status({})
hardware = hardware_summary({})
storage = storage_inspect({})
network = network_inspect({})
return {
"hostname": socket.gethostname(),
"system": system,
"hardware": hardware,
"storage": storage,
"network": network,
}
def validate_choice(choice: SetupChoice) -> SetupChoice:
pm = str(choice.privilege_mode).strip().lower()
provider = str(choice.provider).strip().lower()
lang = str(choice.ui_language).strip().lower()
if pm not in VALID_PRIVILEGE_MODES:
raise ProductError("SETUP_PRIVILEGE_MODE_INVALID")
if provider not in VALID_PROVIDERS:
raise ProductError("SETUP_PROVIDER_INVALID")
if lang not in VALID_LANGUAGES:
raise ProductError("SETUP_LANGUAGE_INVALID")
return SetupChoice(pm, provider, lang)
def save_setup_config(ctx, choice: SetupChoice) -> dict[str, Any]:
choice = validate_choice(choice)
config = ctx.load_config() or {}
config["provider"] = choice.provider
config["setup"] = {
"completed": True,
"privilege_mode": choice.privilege_mode,
"provider": choice.provider,
"ui_language": choice.ui_language,
"chat_mode": "companion" if choice.provider == "chatgpt" else "agent_adapter",
"pairing_state": (
(config.get("pairing") or {}).get("state", "not_paired")
if choice.provider == "chatgpt"
else "adapter_pending"
),
}
path = Path(ctx.config_path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".setup.tmp")
tmp.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
return config
def setup_summary(ctx) -> dict[str, Any]:
config = ctx.load_config() or {}
setup = config.get("setup") or {}
memory = MemoryStore(ctx).verify() if (config.get("memory") or {}).get("required") else {"ok": False, "required": False}
return {
"ok": True,
"setup": setup,
"memory": memory,
"provider": config.get("provider"),
"seed_version": config.get("seed_version"),
}
def render_privilege_explanation(mode: str) -> list[str]:
if mode == "totp":
return [
"Authenticator / QR TOTP selected.",
"FrameBridge will show enrollment QR locally.",
"The TOTP secret and OTP codes never leave this machine.",
"Privileged CHANGE operations still use typed plans and one-shot approval.",
]
return [
"Local sudo password selected.",
"FrameBridge never stores or forwards your sudo password.",
"sudo prompts are handled directly by the local terminal.",
"The AI does not receive general sudo or raw root shell access.",
]
def provider_instructions(provider: str) -> list[str]:
if provider == "chatgpt":
return [
"ChatGPT companion mode selected.",
"Use the official ChatGPT app or web interface for your OpenAI account.",
"FrameBridge does not ask for or store your ChatGPT password.",
"After pairing, this local console remains the primary on-machine FrameBridge interface.",
]
if provider == "ollama":
return ["Ollama selected. Local AgentAdapter integration will be configured when available."]
if provider == "lmstudio":
return ["LM Studio selected. Local AgentAdapter integration will be configured when available."]
return ["Custom provider selected. Configure an AgentAdapter after setup."]
@@ -0,0 +1,548 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import struct
import tempfile
import time
import uuid
import zlib
from pathlib import Path
from typing import Any, Iterator
from .product_core import ProductError, redact
from .seed_capabilities import hardware_summary, list_capabilities, network_inspect, storage_inspect, system_status
FBAM_PROTOCOL = "FBAM/1"
FBM_PROTOCOL = "FBM/1"
FBM_SCHEMA = 1
CODEC_ZLIB = 1
HEADER = struct.Struct(">4sBBII32s")
MAGIC_MANIFEST = b"FBAM"
MAGIC_STATE = b"FBM1"
MAGIC_RECORD = b"FBMR"
MAGIC_INDEX = b"FBI1"
MAX_EVENT_RAW = 65536
MAX_QUERY_EVENTS = 100
READ_CAPABILITIES = tuple(x["name"] for x in list_capabilities())
CHANGE_CAPABILITIES = (
"workspace.write_text",
"package.install",
"package.remove",
"service.manage",
"system.hostname",
"system.timezone",
"package.refresh",
"package.upgrade",
"system.file.write_text",
"system.directory.ensure",
"system.file.mode",
"system.daemon_reload",
)
MEMORY_CAPABILITIES = ("memory.bootstrap", "memory.query", "memory.append", "memory.checkpoint", "memory.stats")
RECORD_TYPE_IDS = {
"checkpoint": 1,
"change": 2,
"incident": 3,
"decision": 4,
"artifact": 5,
"recovery": 6,
"certification": 7,
"note": 8,
}
RECORD_TYPE_NAMES = {v: k for k, v in RECORD_TYPE_IDS.items()}
SOURCE_IDS = {"system": 1, "agent": 2, "operator": 3, "import": 4}
SOURCE_NAMES = {v: k for k, v in SOURCE_IDS.items()}
FIELD_REGISTRY = {
1: "schema", 2: "record_type", 3: "timestamp_ns", 4: "node_id",
5: "identity", 6: "platform", 7: "hardware", 8: "storage", 9: "network",
10: "software", 11: "capabilities", 12: "policy", 13: "connections",
14: "plans", 15: "audit", 16: "changes", 17: "incidents", 18: "decisions",
19: "artifacts", 20: "recovery", 21: "relationships", 22: "pending",
23: "freshness", 24: "provenance",
}
DOMAIN_TO_FIELD = {name: fid for fid, name in FIELD_REGISTRY.items() if fid >= 5}
FORBIDDEN_KEY_RE = re.compile(r"(?i)(password|passwd|secret|token|private[_-]?key|otp|credential|client[_-]?secret|api[_-]?key)")
def _uint_head(major: int, value: int) -> bytes:
if value < 0:
raise ValueError("CBOR_UINT_NEGATIVE")
if value < 24:
return bytes([(major << 5) | value])
if value <= 0xFF:
return bytes([(major << 5) | 24, value])
if value <= 0xFFFF:
return bytes([(major << 5) | 25]) + struct.pack(">H", value)
if value <= 0xFFFFFFFF:
return bytes([(major << 5) | 26]) + struct.pack(">I", value)
if value <= 0xFFFFFFFFFFFFFFFF:
return bytes([(major << 5) | 27]) + struct.pack(">Q", value)
raise ValueError("CBOR_INTEGER_TOO_LARGE")
def cbor_encode(value: Any) -> bytes:
if value is None:
return b"\xf6"
if value is False:
return b"\xf4"
if value is True:
return b"\xf5"
if isinstance(value, int) and not isinstance(value, bool):
return _uint_head(0, value) if value >= 0 else _uint_head(1, -1 - value)
if isinstance(value, float):
return b"\xfb" + struct.pack(">d", value)
if isinstance(value, bytes):
return _uint_head(2, len(value)) + value
if isinstance(value, str):
raw = value.encode("utf-8")
return _uint_head(3, len(raw)) + raw
if isinstance(value, (list, tuple)):
parts = [cbor_encode(x) for x in value]
return _uint_head(4, len(parts)) + b"".join(parts)
if isinstance(value, dict):
items = []
for key, item in value.items():
ek = cbor_encode(key)
items.append((len(ek), ek, cbor_encode(item)))
items.sort(key=lambda x: (x[0], x[1]))
return _uint_head(5, len(items)) + b"".join(ek + ev for _, ek, ev in items)
raise TypeError("CBOR_TYPE_UNSUPPORTED:" + type(value).__name__)
def _read_ai(data: bytes, offset: int, ai: int) -> tuple[int, int]:
if ai < 24:
return ai, offset
if ai == 24:
if offset + 1 > len(data): raise ValueError("CBOR_TRUNCATED")
return data[offset], offset + 1
if ai == 25:
if offset + 2 > len(data): raise ValueError("CBOR_TRUNCATED")
return struct.unpack_from(">H", data, offset)[0], offset + 2
if ai == 26:
if offset + 4 > len(data): raise ValueError("CBOR_TRUNCATED")
return struct.unpack_from(">I", data, offset)[0], offset + 4
if ai == 27:
if offset + 8 > len(data): raise ValueError("CBOR_TRUNCATED")
return struct.unpack_from(">Q", data, offset)[0], offset + 8
raise ValueError("CBOR_INDEFINITE_NOT_SUPPORTED")
def _decode_one(data: bytes, offset: int = 0) -> tuple[Any, int]:
if offset >= len(data):
raise ValueError("CBOR_TRUNCATED")
first = data[offset]; offset += 1
major, ai = first >> 5, first & 31
if major in (0, 1):
n, offset = _read_ai(data, offset, ai)
return (n if major == 0 else -1 - n), offset
if major in (2, 3):
n, offset = _read_ai(data, offset, ai)
end = offset + n
if end > len(data): raise ValueError("CBOR_TRUNCATED")
raw = data[offset:end]
return (raw if major == 2 else raw.decode("utf-8")), end
if major == 4:
n, offset = _read_ai(data, offset, ai)
out = []
for _ in range(n):
item, offset = _decode_one(data, offset); out.append(item)
return out, offset
if major == 5:
n, offset = _read_ai(data, offset, ai)
out = {}
for _ in range(n):
key, offset = _decode_one(data, offset)
val, offset = _decode_one(data, offset)
out[key] = val
return out, offset
if major == 7:
if ai == 20: return False, offset
if ai == 21: return True, offset
if ai == 22: return None, offset
if ai == 27:
if offset + 8 > len(data): raise ValueError("CBOR_TRUNCATED")
return struct.unpack_from(">d", data, offset)[0], offset + 8
raise ValueError("CBOR_VALUE_UNSUPPORTED")
def cbor_decode(data: bytes) -> Any:
value, offset = _decode_one(bytes(data), 0)
if offset != len(data):
raise ValueError("CBOR_TRAILING_DATA")
return value
def _pack_container(magic: bytes, value: Any) -> bytes:
raw = cbor_encode(value)
comp = zlib.compress(raw, 9)
digest = hashlib.sha256(raw).digest()
return HEADER.pack(magic, FBM_SCHEMA, CODEC_ZLIB, len(raw), len(comp), digest) + comp
def _unpack_container(data: bytes, expected_magic: bytes) -> tuple[Any, dict[str, Any]]:
if len(data) < HEADER.size:
raise ProductError("MEMORY_CONTAINER_TRUNCATED")
magic, schema, codec, raw_len, comp_len, digest = HEADER.unpack_from(data, 0)
if magic != expected_magic: raise ProductError("MEMORY_MAGIC_INVALID")
if schema != FBM_SCHEMA: raise ProductError("MEMORY_SCHEMA_UNSUPPORTED")
if codec != CODEC_ZLIB: raise ProductError("MEMORY_CODEC_UNSUPPORTED")
if len(data) != HEADER.size + comp_len: raise ProductError("MEMORY_CONTAINER_SIZE_INVALID")
try: raw = zlib.decompress(data[HEADER.size:])
except Exception as exc: raise ProductError("MEMORY_DECOMPRESS_FAILED") from exc
if len(raw) != raw_len: raise ProductError("MEMORY_RAW_SIZE_INVALID")
if hashlib.sha256(raw).digest() != digest: raise ProductError("MEMORY_DIGEST_MISMATCH")
try: value = cbor_decode(raw)
except Exception as exc: raise ProductError("MEMORY_CBOR_INVALID") from exc
return value, {"raw_bytes": raw_len, "compressed_bytes": comp_len, "raw_sha256": digest.hex()}
def _atomic_write(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
fd, tmp = tempfile.mkstemp(prefix=".fbm-", dir=str(path.parent))
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data); handle.flush(); os.fsync(handle.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, path)
finally:
if os.path.exists(tmp): os.unlink(tmp)
def _atomic_write_json(path: Path, value: dict[str, Any]) -> None:
data = (json.dumps(value, sort_keys=True, indent=2, ensure_ascii=True) + "\n").encode("utf-8")
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".fbm-config-", dir=str(path.parent))
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data); handle.flush(); os.fsync(handle.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, path)
finally:
if os.path.exists(tmp): os.unlink(tmp)
def _uuid_bytes(value: str) -> bytes:
try: return uuid.UUID(str(value)).bytes
except Exception: return hashlib.sha256(str(value).encode("utf-8")).digest()[:16]
def _uuid_text(value: bytes) -> str:
try: return str(uuid.UUID(bytes=bytes(value)))
except Exception: return bytes(value).hex()
def _human(value: Any) -> Any:
if isinstance(value, bytes):
return value.hex()
if isinstance(value, dict):
return {str(k): _human(v) for k, v in value.items()}
if isinstance(value, list):
return [_human(v) for v in value]
return value
def _safe_payload(value: Any, depth: int = 0) -> Any:
if depth > 8: raise ProductError("MEMORY_PAYLOAD_DEPTH_LIMIT")
if isinstance(value, dict):
out = {}
for key, item in value.items():
key = str(key)
if len(key) > 80: raise ProductError("MEMORY_KEY_TOO_LONG")
out[key] = "[REDACTED]" if FORBIDDEN_KEY_RE.search(key) else _safe_payload(item, depth + 1)
return out
if isinstance(value, list):
if len(value) > 256: raise ProductError("MEMORY_LIST_TOO_LONG")
return [_safe_payload(x, depth + 1) for x in value]
if isinstance(value, str):
return str(redact(value))[:4096]
if isinstance(value, (int, float, bool)) or value is None:
return value
return str(value)[:4096]
class MemoryStore:
def __init__(self, seed):
self.seed = seed
self.root = Path(seed.state_dir) / "memory"
self.state_path = self.root / "node.fbm"
self.events_path = self.root / "events.fbm"
self.index_path = self.root / "index.fbi"
self.manifest_path = self.root / "manifest.fbam"
def _identity_config(self) -> tuple[dict[str, Any], dict[str, Any]]:
identity = self.seed.load_identity()
config = self.seed.load_config()
if not isinstance(identity, dict) or not isinstance(config, dict):
raise ProductError("MEMORY_SEED_NOT_INITIALIZED")
return identity, config
def _plans(self) -> list[dict[str, Any]]:
out = []
plans_dir = Path(self.seed.state_dir) / "plans"
if not plans_dir.is_dir(): return out
import json
for path in sorted(plans_dir.glob("*.json")):
try:
value = json.loads(path.read_text(encoding="utf-8"))
plan = value.get("plan") or {}
out.append({1: path.stem, 2: value.get("state"), 3: value.get("plan_sha256"), 4: plan.get("title")})
except Exception:
out.append({1: path.stem, 2: "INVALID"})
return out
@staticmethod
def _normalize_storage(value: dict[str, Any]) -> dict[int, Any]:
devices = []
for item in (value.get("blockdevices") or [])[:128]:
if not isinstance(item, dict): continue
devices.append({
1: item.get("name"), 2: item.get("type"), 3: item.get("size"),
4: item.get("fstype"), 5: item.get("mountpoints"), 6: item.get("rota"), 7: item.get("model"),
})
return {1: value.get("ok") is True, 2: devices}
@staticmethod
def _normalize_network(value: dict[str, Any]) -> dict[int, Any]:
addresses = []
for item in (value.get("addresses") or [])[:64]:
if not isinstance(item, dict): continue
infos = []
for a in (item.get("addr_info") or [])[:32]:
if not isinstance(a, dict): continue
infos.append({1:a.get("family"),2:a.get("local"),3:a.get("prefixlen"),4:a.get("scope")})
addresses.append({1:item.get("ifname"),2:item.get("operstate"),3:infos})
routes = []
for r in (value.get("routes") or [])[:128]:
if not isinstance(r, dict): continue
routes.append({1:r.get("dst"),2:r.get("gateway"),3:r.get("dev"),4:r.get("protocol"),5:r.get("metric")})
return {1:value.get("ok") is True,2:addresses,3:routes}
def _collect_state(self) -> dict[int, Any]:
identity, config = self._identity_config()
doctor = self.seed.doctor()
sys_state = system_status({})
hw = hardware_summary({})
storage = storage_inspect({})
network = network_inspect({})
audit = self.seed.audit.verify()
plans = self._plans()
pending = [p for p in plans if p.get(2) not in ("ROLLED_BACK",)]
memory_cfg = config.get("memory") or {}
return {
1: FBM_SCHEMA,
2: 1,
3: time.time_ns(),
4: _uuid_bytes(identity.get("node_id", "")),
5: {
1: identity.get("hostname"),
2: identity.get("node_id_short"),
3: config.get("provider"),
4: identity.get("node_host_id"),
5: identity.get("node_public_key_sha256"),
},
6: {1: sys_state.get("os_id"), 2: (doctor.get("checks") or {}).get("os_version"), 3: sys_state.get("os_name"), 4: sys_state.get("kernel"), 5: sys_state.get("architecture"), 6: (doctor.get("checks") or {}).get("python")},
7: {1: hw.get("cpu_model"), 2: hw.get("logical_cpus"), 3: hw.get("memory_total_kib"), 4: hw.get("architecture")},
8: self._normalize_storage(storage),
9: self._normalize_network(network),
10: {1: config.get("seed_version"), 2: (doctor.get("checks") or {}).get("python")},
11: {1: list(READ_CAPABILITIES), 2: list(CHANGE_CAPABILITIES), 3: list(MEMORY_CAPABILITIES)},
12: {1: config.get("capability_profile"), 2: True, 3: "plan-required"},
13: {1: config.get("connection_name"), 2: config.get("connection_mode"), 3: (config.get("pairing") or {}).get("state"), 4: (config.get("pairing") or {}).get("transport")},
14: plans,
15: {1: audit.get("ok") is True, 2: audit.get("entries"), 3: audit.get("last_hash")},
21: [],
22: pending,
23: {1: time.time_ns(), 2: "live-collection"},
24: {1: "framebridge-node", 2: memory_cfg.get("authority", "deployment-defined"), 3: "node-operational"},
}
def _manifest_object(self, state: dict[int, Any]) -> dict[int, Any]:
identity, config = self._identity_config()
return {
1: 1,
2: "FrameBridge",
3: FBAM_PROTOCOL,
4: _uuid_bytes(identity.get("node_id", "")),
5: identity.get("node_id_short"),
6: identity.get("hostname"),
7: config.get("seed_version"),
8: state.get(6),
9: state.get(7),
10: state.get(11),
11: {1: config.get("capability_profile"), 2: True},
12: {1: True, 2: "read_write", 3: FBM_PROTOCOL, 4: list(MEMORY_CAPABILITIES)},
13: {1: (config.get("memory") or {}).get("authority", "deployment-defined"), 2: "node-operational"},
14: time.time_ns(),
}
def _read_container(self, path: Path, magic: bytes) -> tuple[Any, dict[str, Any]]:
try: data = path.read_bytes()
except FileNotFoundError as exc: raise ProductError("MEMORY_FILE_MISSING") from exc
return _unpack_container(data, magic)
def _load_index(self) -> dict[int, Any]:
if not self.index_path.is_file(): return {1:1,2:0,3:b"",4:[]}
value, _ = self._read_container(self.index_path, MAGIC_INDEX)
if not isinstance(value, dict): raise ProductError("MEMORY_INDEX_INVALID")
return value
def initialize(self) -> dict[str, Any]:
_, config = self._identity_config()
desired = {
"required": True, "protocol": FBM_PROTOCOL, "manifest_protocol": FBAM_PROTOCOL,
"mode": "read_write", "authority": (config.get("memory") or {}).get("authority", "deployment-defined"),
}
if config.get("memory") != desired:
config["memory"] = desired
_atomic_write_json(Path(self.seed.config_path), config)
self.root.mkdir(parents=True, exist_ok=True); os.chmod(self.root, 0o700)
if not self.events_path.exists():
self.events_path.touch(mode=0o600); os.chmod(self.events_path, 0o600)
if not self.index_path.exists():
_atomic_write(self.index_path, _pack_container(MAGIC_INDEX, {1:1,2:0,3:b"",4:[]}))
checkpoint = self.checkpoint()
if self._load_index().get(2, 0) == 0:
self._append_event_raw("checkpoint", {"action":"memory.initialize","protocol":FBM_PROTOCOL}, "system")
return {"ok":True,"protocol":FBM_PROTOCOL,"manifest_protocol":FBAM_PROTOCOL,"required":True,"mode":"read_write","checkpoint":checkpoint,"verify":self.verify()}
def checkpoint(self) -> dict[str, Any]:
state = self._collect_state()
manifest = self._manifest_object(state)
state_blob = _pack_container(MAGIC_STATE, state)
manifest_blob = _pack_container(MAGIC_MANIFEST, manifest)
_atomic_write(self.state_path, state_blob)
_atomic_write(self.manifest_path, manifest_blob)
return {
"ok": True,
"state_sha256": hashlib.sha256(state_blob).hexdigest(),
"manifest_sha256": hashlib.sha256(manifest_blob).hexdigest(),
"state_bytes": len(state_blob),
"manifest_bytes": len(manifest_blob),
}
def ready(self) -> bool:
try: return self.verify().get("ok") is True
except Exception: return False
def require_ready(self) -> None:
_, config = self._identity_config()
required = bool((config.get("memory") or {}).get("required"))
if required and not self.ready(): raise ProductError("MEMORY_REQUIRED_NOT_READY")
def _append_event_raw(self, record_type: str, data: dict[str, Any], source: str) -> dict[str, Any]:
if record_type not in RECORD_TYPE_IDS: raise ProductError("MEMORY_RECORD_TYPE_INVALID")
if source not in SOURCE_IDS: raise ProductError("MEMORY_SOURCE_INVALID")
identity, _ = self._identity_config()
safe = _safe_payload(data)
if not isinstance(safe, dict): raise ProductError("MEMORY_PAYLOAD_NOT_OBJECT")
index = self._load_index()
prev = index.get(3) or b""
record = {1:FBM_SCHEMA,2:RECORD_TYPE_IDS[record_type],3:time.time_ns(),4:_uuid_bytes(identity.get("node_id","")),5:safe,6:SOURCE_IDS[source],7:prev}
raw = cbor_encode(record)
if len(raw) > MAX_EVENT_RAW: raise ProductError("MEMORY_EVENT_TOO_LARGE")
block = _pack_container(MAGIC_RECORD, record)
self.events_path.parent.mkdir(parents=True, exist_ok=True)
offset = self.events_path.stat().st_size if self.events_path.exists() else 0
with self.events_path.open("ab") as handle:
handle.write(block); handle.flush(); os.fsync(handle.fileno())
os.chmod(self.events_path, 0o600)
record_hash = hashlib.sha256(raw).digest()
offsets = list(index.get(4) or [])
offsets.append(offset)
offsets = offsets[-1024:]
new_index = {1:1,2:int(index.get(2) or 0)+1,3:record_hash,4:offsets}
_atomic_write(self.index_path, _pack_container(MAGIC_INDEX, new_index))
return {"ok":True,"record_type":record_type,"event_number":new_index[2],"event_sha256":record_hash.hex(),"source":source}
def append(self, record_type: str, data: dict[str, Any], source: str = "agent") -> dict[str, Any]:
self.require_ready()
return self._append_event_raw(record_type, data, source)
def _iter_events(self) -> Iterator[tuple[dict[int, Any], bytes, int]]:
if not self.events_path.exists(): return
data = self.events_path.read_bytes(); off = 0; number = 0
while off < len(data):
if off + HEADER.size > len(data): raise ProductError("MEMORY_EVENT_STREAM_TRUNCATED")
_, _, _, _, comp_len, _ = HEADER.unpack_from(data, off)
end = off + HEADER.size + comp_len
if end > len(data): raise ProductError("MEMORY_EVENT_STREAM_TRUNCATED")
value, _ = _unpack_container(data[off:end], MAGIC_RECORD)
if not isinstance(value, dict): raise ProductError("MEMORY_EVENT_INVALID")
number += 1
yield value, hashlib.sha256(cbor_encode(value)).digest(), off
off = end
def verify(self) -> dict[str, Any]:
state, sm = self._read_container(self.state_path, MAGIC_STATE)
manifest, mm = self._read_container(self.manifest_path, MAGIC_MANIFEST)
index = self._load_index()
prev = b""; count = 0; offsets = []
for record, record_hash, offset in self._iter_events():
if record.get(7, b"") != prev: raise ProductError("MEMORY_EVENT_CHAIN_INVALID")
prev = record_hash; count += 1; offsets.append(offset)
if int(index.get(2) or 0) != count: raise ProductError("MEMORY_INDEX_COUNT_MISMATCH")
if (index.get(3) or b"") != prev: raise ProductError("MEMORY_INDEX_HASH_MISMATCH")
identity, _ = self._identity_config()
if state.get(4) != _uuid_bytes(identity.get("node_id","")): raise ProductError("MEMORY_NODE_ID_MISMATCH")
if manifest.get(4) != state.get(4): raise ProductError("MEMORY_MANIFEST_NODE_MISMATCH")
return {
"ok":True,"protocol":FBM_PROTOCOL,"manifest_protocol":FBAM_PROTOCOL,"events":count,
"event_head_sha256":prev.hex() if prev else "GENESIS",
"state_raw_bytes":sm["raw_bytes"],"state_compressed_bytes":sm["compressed_bytes"],
"manifest_raw_bytes":mm["raw_bytes"],"manifest_compressed_bytes":mm["compressed_bytes"],
}
def bootstrap(self) -> dict[str, Any]:
self.require_ready()
manifest, _ = self._read_container(self.manifest_path, MAGIC_MANIFEST)
platform = manifest.get(8) or {}; hardware = manifest.get(9) or {}; caps = manifest.get(10) or {}; mem = manifest.get(12) or {}; authority = manifest.get(13) or {}
return {
"ok": True,
"protocol": FBAM_PROTOCOL,
"framebridge": {"product":"FrameBridge","seed_version":manifest.get(7)},
"node": {"node_id":_uuid_text(manifest.get(4,b"")),"node_id_short":manifest.get(5),"hostname":manifest.get(6)},
"os": {"id":platform.get(1),"version":platform.get(2),"name":platform.get(3),"kernel":platform.get(4),"architecture":platform.get(5)},
"hardware": {"cpu_model":hardware.get(1),"logical_cpus":hardware.get(2),"memory_total_kib":hardware.get(3),"architecture":hardware.get(4)},
"capabilities": {"read":caps.get(1) or [],"change":caps.get(2) or [],"memory":caps.get(3) or []},
"policy": {"profile":(manifest.get(11) or {}).get(1),"change_requires_plan":True},
"memory": {"required":mem.get(1) is True,"mode":mem.get(2),"protocol":mem.get(3),"capabilities":mem.get(4) or [],"rule":"READ_BEFORE_DEPENDENT_ACTION__WRITE_AFTER_MATERIAL_CHANGE"},
"authority": {"mode":authority.get(1),"node_role":authority.get(2)},
"generated_at_ns": manifest.get(14),
"manifest_sha256": hashlib.sha256(self.manifest_path.read_bytes()).hexdigest(),
}
def query(self, domain: str = "summary", limit: int = 20) -> dict[str, Any]:
self.require_ready()
domain = str(domain).strip().lower()
limit = max(1, min(int(limit), MAX_QUERY_EVENTS))
state, _ = self._read_container(self.state_path, MAGIC_STATE)
if domain == "summary":
verify = self.verify()
return {"ok":True,"domain":"summary","node_id":_uuid_text(state.get(4,b"")),"hostname":(state.get(5) or {}).get(1),"seed_version":(state.get(10) or {}).get(1),"audit":_human(state.get(15)),"pending":_human(state.get(22)),"memory":verify}
if domain == "events":
items = []
for record, record_hash, _ in self._iter_events():
items.append({"record_type":RECORD_TYPE_NAMES.get(record.get(2),"unknown"),"timestamp_ns":record.get(3),"source":SOURCE_NAMES.get(record.get(6),"unknown"),"data":_human(record.get(5)),"sha256":record_hash.hex()})
return {"ok":True,"domain":"events","events":items[-limit:]}
if domain not in DOMAIN_TO_FIELD: raise ProductError("MEMORY_QUERY_DOMAIN_INVALID")
return {"ok":True,"domain":domain,"value":_human(state.get(DOMAIN_TO_FIELD[domain]))}
def stats(self) -> dict[str, Any]:
self.require_ready(); verify = self.verify()
return {
"ok":True,"protocol":FBM_PROTOCOL,
"files": {
"node.fbm":self.state_path.stat().st_size,
"events.fbm":self.events_path.stat().st_size,
"index.fbi":self.index_path.stat().st_size,
"manifest.fbam":self.manifest_path.stat().st_size,
},
"events":verify.get("events"),"event_head_sha256":verify.get("event_head_sha256"),
"state_compression_ratio":round(verify["state_compressed_bytes"] / max(1, verify["state_raw_bytes"]), 4),
}
@@ -0,0 +1,353 @@
from __future__ import annotations
import argparse
import json
import sys
import time
from datetime import datetime, timezone
from typing import Any, Callable
from .memory_store import MemoryStore
from .product_core import ProductError
from .network_security import inspect_network
from .pairing import PairingError, PairingStore, public_pairing_view
from .pairing_client import PairingClientError, cancel_pairing_remote
from .pairing_window import close_pairing_window, pairing_window_status
from .seed_core import SeedContext, atomic_write_json
MONITOR_SCHEMA = 1
DEFAULT_INTERVAL_SECONDS = 1.0
def _candidate_binding(candidate: dict[str, Any] | None) -> dict[str, Any]:
c=candidate or {}
return {
"interface":str(c.get("interface") or ""),
"source_subnet":str(c.get("subnet") or ""),
"destination_address":str(c.get("address") or ""),
"network_fingerprint":str(c.get("network_fingerprint") or ""),
}
def _window_binding(window: dict[str, Any]) -> dict[str, Any]:
return {
"interface":str(window.get("interface") or ""),
"source_subnet":str(window.get("source_subnet") or ""),
"destination_address":str(window.get("destination_address") or ""),
"network_fingerprint":str(window.get("network_fingerprint") or ""),
}
def compare_binding(window: dict[str, Any], candidate: dict[str, Any] | None) -> dict[str, Any]:
old=_window_binding(window)
new=_candidate_binding(candidate)
reasons=[]
if not candidate:
reasons.append("NO_ELIGIBLE_PAIRING_CANDIDATE")
else:
for key,label in (
("interface","INTERFACE_CHANGED"),
("source_subnet","SUBNET_CHANGED"),
("destination_address","ADDRESS_CHANGED"),
("network_fingerprint","NETWORK_FINGERPRINT_CHANGED"),
):
if old.get(key)!=new.get(key):
reasons.append(label)
return {
"changed":bool(reasons),
"reasons":reasons,
"old":old,
"new":new,
}
def _sync_pairing_config(ctx: SeedContext, view: dict[str, Any]) -> None:
config=ctx.load_config() or {}
pairing=config.get("pairing") or {}
pairing["state"]=view.get("state","expired")
if view.get("expiration_reason"):
pairing["expiration_reason"]=view.get("expiration_reason")
pairing["requires_new_local_action"]=True
config["pairing"]=pairing
config["connection_mode"]="PAIRING"
setup=config.get("setup")
if isinstance(setup,dict):
setup["pairing_state"]=view.get("state","expired")
atomic_write_json(ctx.config_path,config)
def _invalidate_pending_pair(ctx: SeedContext, reason: str) -> dict[str, Any] | None:
store=PairingStore(ctx.state_dir)
current=store.load()
if current.get("state")!="pair_requested":
return None
view=store.invalidate_pending(reason)
_sync_pairing_config(ctx,view)
return view
def reconcile_broker_cancel(
ctx: SeedContext,
*,
cancel_func=cancel_pairing_remote,
retry_seconds: float=5.0,
) -> dict[str, Any]:
store=PairingStore(ctx.state_dir)
current=store.load()
if current.get("broker_cancel_required") is not True:
return {"ok":True,"state":"NOT_REQUIRED","cancelled":False}
pair_id=str(current.get("pair_id") or "")
reason=str(current.get("broker_cancel_reason") or current.get("expiration_reason") or "reconcile")
last=str(current.get("broker_cancel_last_attempt_at") or "")
if last:
try:
last_dt=datetime.fromisoformat(last.replace("Z","+00:00")).astimezone(timezone.utc)
age=(datetime.now(timezone.utc)-last_dt).total_seconds()
if age<max(1.0,float(retry_seconds)):
return {
"ok":True,"state":"RETRY_DEFERRED","cancelled":False,
"pair_id":pair_id,"retry_after":round(max(0.0,float(retry_seconds)-age),3),
}
except Exception:
pass
try:
result=cancel_func(state_dir=ctx.state_dir,reason=reason,record=current)
except PairingClientError as exc:
store.note_broker_cancel_error(pair_id,str(exc))
return {
"ok":False,"state":"BROKER_CANCEL_PENDING","cancelled":False,
"pair_id":pair_id,"error":str(exc),
}
updated=store.mark_broker_cancelled(pair_id,result)
ctx.audit.append(
"pair.broker_cancelled","PASS",
request={"pair_id":pair_id,"reason":reason},
result={"broker_state":result.get("state"),"idempotent":result.get("idempotent",False)},
principal="network-monitor",
)
memory=MemoryStore(ctx)
if memory.ready():
memory.append(
"recovery",
{"operation":"pair.broker_cancelled","pair_id":pair_id,"reason":reason,"broker_state":result.get("state")},
source="system",
)
return {
"ok":True,"state":"BROKER_CANCELLED","cancelled":True,
"pair_id":pair_id,"broker_state":result.get("state"),
"local_state":updated.get("state"),
}
def _lifecycle_state_path(ctx: SeedContext):
return ctx.state_dir/"network-lifecycle.json"
def _sync_expired_window(ctx: SeedContext, window: dict[str, Any]) -> dict[str, Any]:
window_id=str(window.get("window_id") or "")
if window.get("state")=="expired" and window.get("rule_present") is not False:
raise ProductError("NETWORK_WINDOW_EXPIRY_UNVERIFIED")
if not window_id or window.get("state")!="expired" or window.get("close_reason")!="expired":
return {"ok":True,"synced":False}
path=_lifecycle_state_path(ctx)
prior={}
if path.is_file():
try:
prior=json.loads(path.read_text())
except Exception:
prior={}
if prior.get("last_expiry_window_id")==window_id:
return {"ok":True,"synced":False,"already_synced":True,"window_id":window_id}
pair_view=_invalidate_pending_pair(ctx,"window_expired")
broker_cancel=reconcile_broker_cancel(ctx)
ctx.audit.append(
"network.pairing_window.expired","PASS",
request={"window_id":window_id,"expires_at":window.get("expires_at")},
result={
"rule_present":window.get("rule_present"),
"pair_state":(pair_view or {}).get("state"),
"requires_new_local_action":bool(pair_view),
"broker_cancel_state":broker_cancel.get("state"),
},
principal="network-monitor",
)
memory=MemoryStore(ctx)
if memory.ready():
memory.append(
"recovery",
{
"operation":"network.pairing_window.expired",
"window_id":window_id,
"rule_present":window.get("rule_present"),
"pair_state":(pair_view or {}).get("state"),
"requires_new_local_action":bool(pair_view),
"broker_cancel_state":broker_cancel.get("state"),
},source="system",
)
atomic_write_json(path,{"schema":MONITOR_SCHEMA,"last_expiry_window_id":window_id,"last_expiry_synced_at":time.time()})
return {
"ok":True,"synced":True,"window_id":window_id,
"pair_state":(pair_view or {}).get("state"),
"requires_new_local_action":bool(pair_view),
"broker_cancel":broker_cancel,
}
def _record_invalidation(
ctx: SeedContext,
*,
reason: str,
comparison: dict[str, Any],
window: dict[str, Any],
closed: dict[str, Any],
pair_view: dict[str, Any] | None,
) -> None:
result={
"window_id":window.get("window_id"),
"close_reason":closed.get("close_reason"),
"rule_present":closed.get("rule_present"),
"pair_state":(pair_view or {}).get("state"),
"requires_new_local_action":True,
"reasons":comparison.get("reasons") or [reason],
}
ctx.audit.append(
"network.pairing_window.invalidated",
"PASS",
request={
"reason":reason,
"old":comparison.get("old"),
"new":comparison.get("new"),
},
result=result,
principal="network-monitor",
)
store=MemoryStore(ctx)
if store.ready():
store.append(
"incident",
{
"operation":"network.pairing_window.invalidated",
"reason":reason,
"reasons":comparison.get("reasons") or [reason],
"window_id":window.get("window_id"),
"old":comparison.get("old"),
"new":comparison.get("new"),
"pair_state":(pair_view or {}).get("state"),
"requires_new_local_action":True,
},
source="system",
)
def invalidate_active_window(
*,
ctx: SeedContext | None=None,
reason: str,
comparison: dict[str, Any] | None=None,
status_func: Callable[..., dict[str, Any]]=pairing_window_status,
close_func: Callable[..., dict[str, Any]]=close_pairing_window,
) -> dict[str, Any]:
ctx=ctx or SeedContext()
window=status_func(non_interactive=True)
if window.get("active") is not True:
return {"ok":True,"schema":MONITOR_SCHEMA,"state":"NO_ACTIVE_PAIRING_WINDOW","invalidated":False}
if comparison is None:
comparison={"changed":True,"reasons":[reason.upper()],"old":_window_binding(window),"new":{}}
closed=close_func(reason,non_interactive=True)
if closed.get("ok") is not True or closed.get("rule_present") is not False or closed.get("active") is True:
raise ProductError("NETWORK_WINDOW_CLOSE_UNVERIFIED")
pair_view=_invalidate_pending_pair(ctx,reason)
broker_cancel=reconcile_broker_cancel(ctx)
_record_invalidation(
ctx,reason=reason,comparison=comparison,window=window,closed=closed,pair_view=pair_view,
)
return {
"ok":True,"schema":MONITOR_SCHEMA,"state":"INVALIDATED","invalidated":True,
"reason":reason,"reasons":comparison.get("reasons") or [],
"window_id":window.get("window_id"),"rule_present":closed.get("rule_present"),
"pair_state":(pair_view or {}).get("state"),"requires_new_local_action":True,
"broker_cancel":broker_cancel,"old":comparison.get("old"),"new":comparison.get("new"),
}
def reconcile_once(
*,
ctx: SeedContext | None=None,
snapshot: dict[str, Any] | None=None,
status_func: Callable[..., dict[str, Any]]=pairing_window_status,
close_func: Callable[..., dict[str, Any]]=close_pairing_window,
) -> dict[str, Any]:
ctx=ctx or SeedContext()
broker_cancel=reconcile_broker_cancel(ctx)
window=status_func(non_interactive=True)
if window.get("active") is not True:
expiry_sync=_sync_expired_window(ctx,window)
return {
"ok":True,"schema":MONITOR_SCHEMA,
"state":"PAIRING_WINDOW_EXPIRED_SYNCED" if expiry_sync.get("synced") else "NO_ACTIVE_PAIRING_WINDOW",
"invalidated":False,"expiry_sync":expiry_sync,"broker_cancel":broker_cancel,
}
try:
snap=snapshot if snapshot is not None else inspect_network()
if not isinstance(snap,dict): raise ValueError("NETWORK_SNAPSHOT_NOT_OBJECT")
except Exception:
return invalidate_active_window(
ctx=ctx,reason="reconcile",
comparison={"changed":True,"reasons":["NETWORK_INSPECTION_FAILED"],"old":_window_binding(window),"new":{}},
status_func=lambda **_:window,close_func=close_func,
)
candidate=snap.get("pairing_candidate")
comparison=compare_binding(window,candidate)
if not comparison["changed"]:
return {
"ok":True,"schema":MONITOR_SCHEMA,"state":"STABLE","invalidated":False,
"window_id":window.get("window_id"),"binding":comparison["old"],"broker_cancel":broker_cancel,
}
return invalidate_active_window(
ctx=ctx,reason="network_change",comparison=comparison,
status_func=lambda **_:window,close_func=close_func,
)
def run_forever(interval: float=DEFAULT_INTERVAL_SECONDS) -> None:
interval=max(0.25,float(interval))
ctx=SeedContext()
while True:
try:
result=reconcile_once(ctx=ctx)
if result.get("invalidated") is True:
print(json.dumps(result,sort_keys=True),flush=True)
except Exception as exc:
print(json.dumps({
"ok":False,
"schema":MONITOR_SCHEMA,
"state":"MONITOR_ERROR",
"error":type(exc).__name__,
"message":str(exc),
},sort_keys=True),file=sys.stderr,flush=True)
time.sleep(interval)
def main() -> None:
parser=argparse.ArgumentParser(prog="framebridge-network-monitor")
sub=parser.add_subparsers(dest="command",required=True)
run=sub.add_parser("run")
run.add_argument("--interval",type=float,default=DEFAULT_INTERVAL_SECONDS)
sub.add_parser("once")
close=sub.add_parser("close")
close.add_argument("--reason",choices=("service_stop","reconcile"),default="service_stop")
args=parser.parse_args()
if args.command=="run":
run_forever(args.interval)
elif args.command=="once":
print(json.dumps(reconcile_once(),sort_keys=True,indent=2))
else:
print(json.dumps(invalidate_active_window(reason=args.reason),sort_keys=True,indent=2))
if __name__=="__main__":
main()
@@ -0,0 +1,513 @@
from __future__ import annotations
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import subprocess
from typing import Any
SCHEMA = 1
PAIRING_WINDOW_TTL_SECONDS = 600
RFC1918 = (
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
)
ULA_V6 = ipaddress.ip_network("fc00::/7")
OVERLAY_NAMES = {"tailscale0"}
VIRTUAL_PREFIXES = (
"docker", "br-", "virbr", "veth", "lxc", "waydroid",
"tap", "tun", "zt", "wg",
)
def _run(argv: list[str], timeout: int = 5) -> subprocess.CompletedProcess:
return subprocess.run(
argv,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
def _run_json(argv: list[str]) -> list[dict[str, Any]]:
try:
cp = _run(argv)
except Exception:
return []
if cp.returncode != 0:
return []
try:
value = json.loads(cp.stdout or "[]")
except Exception:
return []
return value if isinstance(value, list) else []
def address_class(address: str) -> str:
try:
ip = ipaddress.ip_address(str(address))
except ValueError:
return "INVALID"
if ip.is_loopback:
return "LOOPBACK"
if ip.version == 4:
if any(ip in net for net in RFC1918):
return "PRIVATE_LAN"
if ip.is_link_local:
return "LINK_LOCAL"
if ip.is_global:
return "PUBLIC"
return "SPECIAL"
if ip in ULA_V6:
return "ULA"
if ip.is_link_local:
return "LINK_LOCAL"
if ip.is_global:
return "PUBLIC"
return "SPECIAL"
def classify_interface(
name: str,
*,
has_device: bool = False,
wireless: bool = False,
) -> str:
n = str(name or "")
if n == "lo":
return "LOOPBACK"
if n in OVERLAY_NAMES:
return "PRIVATE_OVERLAY"
if n.startswith(VIRTUAL_PREFIXES):
return "VIRTUAL_LOCAL"
if wireless or n.startswith(("wl", "wlan")):
return "PHYSICAL_LAN"
if has_device or n.startswith(("en", "eth")):
return "PHYSICAL_LAN"
return "UNKNOWN"
def _sysfs_interface_kind(name: str) -> str:
base = Path("/sys/class/net") / name
return classify_interface(
name,
has_device=(base / "device").exists(),
wireless=(base / "wireless").exists(),
)
def _prefix_subnet(address: str, prefixlen: int) -> str:
try:
return str(ipaddress.ip_interface(f"{address}/{int(prefixlen)}").network)
except Exception:
return ""
def _default_routes() -> list[dict[str, Any]]:
routes = _run_json(["ip", "-j", "-4", "route", "show", "default"])
clean: list[dict[str, Any]] = []
for r in routes:
if not isinstance(r, dict):
continue
clean.append(
{
"dev": str(r.get("dev") or ""),
"gateway": str(r.get("gateway") or ""),
"metric": int(r.get("metric") or 0),
"prefsrc": str(r.get("prefsrc") or ""),
}
)
return clean
def _network_manager_identity(ifname: str) -> dict[str, str]:
result = {"connection": "", "connection_uuid": ""}
try:
cp = _run(["nmcli", "-g", "GENERAL.CONNECTION", "device", "show", ifname])
except Exception:
return result
if cp.returncode != 0:
return result
connection = (cp.stdout or "").strip().splitlines()
if not connection:
return result
name = connection[0].strip()
if not name or name == "--":
return result
result["connection"] = name
try:
cp2 = _run(["nmcli", "-g", "connection.uuid", "connection", "show", name])
except Exception:
return result
if cp2.returncode == 0:
result["connection_uuid"] = (cp2.stdout or "").strip().splitlines()[0] if (cp2.stdout or "").strip() else ""
return result
def collect_interfaces() -> list[dict[str, Any]]:
links = _run_json(["ip", "-j", "link", "show"])
addrs = _run_json(["ip", "-j", "addr", "show"])
addr_by_name = {str(x.get("ifname") or ""): x for x in addrs if isinstance(x, dict)}
out: list[dict[str, Any]] = []
for link in links:
if not isinstance(link, dict):
continue
name = str(link.get("ifname") or "")
if not name:
continue
a = addr_by_name.get(name, {})
ipv4 = []
ipv6 = []
for ai in a.get("addr_info") or []:
if not isinstance(ai, dict):
continue
local = str(ai.get("local") or "")
if not local:
continue
item = {
"address": local,
"prefixlen": int(ai.get("prefixlen") or 0),
"class": address_class(local),
"scope": str(ai.get("scope") or ""),
}
if ai.get("family") == "inet":
ipv4.append(item)
elif ai.get("family") == "inet6":
ipv6.append(item)
kind = _sysfs_interface_kind(name)
out.append(
{
"name": name,
"kind": kind,
"operstate": str(link.get("operstate") or ""),
"flags": list(link.get("flags") or []),
"ipv4": ipv4,
"ipv6": ipv6,
}
)
return out
def choose_pairing_candidate(
interfaces: list[dict[str, Any]],
routes: list[dict[str, Any]],
) -> dict[str, Any] | None:
"""Select only an unambiguous physical private-LAN binding.
Metric zero is valid. Route preferred source is honored. Equal-priority
candidates that differ in interface/address/prefix/gateway are blocked,
not resolved by comparing payload dictionaries or snapshot order.
"""
route_by_dev: dict[str, list[dict[str, Any]]] = {}
for route in routes:
route_by_dev.setdefault(str(route.get("dev") or ""), []).append(route)
choices: dict[tuple, tuple[tuple, dict[str, Any]]] = {}
for iface in interfaces:
if iface.get("kind") != "PHYSICAL_LAN":
continue
if str(iface.get("operstate") or "").upper() not in {"UP", "UNKNOWN"}:
continue
name = str(iface.get("name") or "")
if not name:
continue
dev_routes = route_by_dev.get(name, [])
if dev_routes:
minimum = min(int(r.get("metric") or 0) for r in dev_routes)
best_routes = [r for r in dev_routes if int(r.get("metric") or 0) == minimum]
else:
best_routes = [{}]
for selected_route in best_routes:
preferred = str(selected_route.get("prefsrc") or "")
metric = int(selected_route.get("metric") or 0) if dev_routes else None
for addr in iface.get("ipv4") or []:
address = str(addr.get("address") or "")
if address_class(address) != "PRIVATE_LAN":
continue
if preferred and address != preferred:
continue
prefix = addr.get("prefixlen")
if isinstance(prefix, bool) or not isinstance(prefix, int) or not 1 <= prefix <= 32:
continue
gateway = str(selected_route.get("gateway") or "")
item = {
"interface": name,
"interface_kind": "PHYSICAL_LAN",
"address": address,
"prefixlen": prefix,
"subnet": _prefix_subnet(address, prefix),
"address_class": "PRIVATE_LAN",
"gateway": gateway,
"route_metric": metric,
}
rank = (0 if dev_routes else 1, metric if metric is not None else 0,
0 if preferred else 1)
identity = (name, address, prefix, gateway, metric)
choices[identity] = (rank, item)
if not choices:
return None
best_rank = min(rank for rank, item in choices.values())
top = [item for rank, item in choices.values() if rank == best_rank]
if len(top) != 1:
return None
selected = dict(top[0])
selected.update(_network_manager_identity(selected["interface"]))
binding = {key: selected.get(key) for key in (
"interface", "address", "prefixlen", "gateway", "route_metric", "connection_uuid"
)}
selected["network_fingerprint_schema"] = 2
raw = json.dumps(binding, sort_keys=True, separators=(",", ":")).encode("utf-8")
selected["network_fingerprint"] = hashlib.sha256(raw).hexdigest()
return selected
def _service_active(name: str) -> bool:
try:
cp = _run(["systemctl", "is-active", "--quiet", name])
except Exception:
return False
return cp.returncode == 0
def _ufw_config_enabled(path: Path=Path("/etc/ufw/ufw.conf")) -> bool:
try:
text=path.read_text(encoding="utf-8",errors="replace")
except OSError:
return False
for raw in text.splitlines():
line=raw.strip()
if not line or line.startswith("#"):
continue
if line.upper().startswith("ENABLED="):
return line.split("=",1)[1].strip().lower() in {"yes","true","1"}
return False
def firewall_backend() -> dict[str, Any]:
ufw_systemd_active=_service_active("ufw")
ufw_config_enabled=_ufw_config_enabled()
if ufw_systemd_active or ufw_config_enabled:
return {
"backend": "ufw",
"active": True,
"systemd_active": ufw_systemd_active,
"config_enabled": ufw_config_enabled,
"managed_pairing_rules": True,
}
if _service_active("firewalld"):
return {"backend": "firewalld", "active": True, "managed_pairing_rules": False}
if _service_active("nftables"):
return {"backend": "nftables", "active": True, "managed_pairing_rules": False}
return {"backend": "none", "active": False, "managed_pairing_rules": False}
def ufw_pairing_plan(candidate: dict[str, Any], port: int) -> dict[str, Any]:
iface = str(candidate.get("interface") or "")
subnet = str(candidate.get("subnet") or "")
address = str(candidate.get("address") or "")
fp = str(candidate.get("network_fingerprint") or "")
if not iface or not subnet or not address or not fp:
return {"ok": False, "error": "PAIRING_CANDIDATE_INCOMPLETE"}
return {
"ok": True,
"backend": "ufw",
"execution_backend": "iptables-runtime-inside-ufw",
"chain": "ufw-user-input",
"execute": False,
"operation": "runtime_scoped_accept",
"interface": iface,
"source_subnet": subnet,
"destination_address": address,
"port": int(port),
"protocol": "tcp",
"network_fingerprint": fp,
"ttl_seconds": PAIRING_WINDOW_TTL_SECONDS,
"requires_local_tty": True,
"runtime_only": True,
"reboot_fail_closed": True,
"automatic_expiry": True,
"persistent_ufw_rule": False,
"rule_comment_template": "FrameBridge:<window_id>:pairing",
}
def inspect_network() -> dict[str, Any]:
interfaces = collect_interfaces()
routes = _default_routes()
candidate = choose_pairing_candidate(interfaces, routes)
fw = firewall_backend()
return {
"ok": True,
"schema": SCHEMA,
"interfaces": interfaces,
"default_routes": routes,
"pairing_candidate": candidate,
"firewall": fw,
"policy": {
"local_pairing_transport": "LAN_HTTP",
"tailscale_required": False,
"public_http_allowed": False,
"wildcard_pairing_bind_allowed": False,
"local_tty_required": True,
"pairing_window_ttl_seconds": PAIRING_WINDOW_TTL_SECONDS,
},
}
def pairing_policy(port: int = 18765) -> dict[str, Any]:
snap = inspect_network()
candidate = snap.get("pairing_candidate")
fw = snap.get("firewall") or {}
if not candidate:
return {
"ok": False,
"schema": SCHEMA,
"state": "BLOCKED",
"reason": "NO_ELIGIBLE_PHYSICAL_PRIVATE_LAN",
"execute": False,
"snapshot": snap,
}
if not fw.get("active"):
return {
"ok": False,
"schema": SCHEMA,
"state": "BLOCKED",
"reason": "NO_ACTIVE_FIREWALL_BACKEND",
"execute": False,
"snapshot": snap,
}
if fw.get("backend") != "ufw":
return {
"ok": False,
"schema": SCHEMA,
"state": "BLOCKED",
"reason": "FIREWALL_BACKEND_NOT_YET_MANAGED",
"execute": False,
"snapshot": snap,
}
return {
"ok": True,
"schema": SCHEMA,
"state": "ELIGIBLE_REQUIRES_LOCAL_TTY",
"execute": False,
"candidate": candidate,
"firewall_plan": ufw_pairing_plan(candidate, int(port)),
"network_change_policy": "INVALIDATE_PENDING_PAIR_AND_CLOSE_OLD_WINDOW",
}
CGNAT_V4 = ipaddress.ip_network("100.64.0.0/10")
FORWARD_PROTOCOLS = {"any", "tcp", "udp"}
def _valid_interface_name(value: str) -> bool:
import re
return re.fullmatch(r"[A-Za-z0-9_.:-]{1,32}", str(value or "")) is not None
def _normalize_ipv4_network(value: str) -> str:
try:
net = ipaddress.ip_network(str(value), strict=False)
except ValueError as exc:
raise ValueError("NETWORK_CIDR_INVALID") from exc
if net.version != 4:
raise ValueError("NETWORK_IPV4_REQUIRED")
if net.is_multicast or net.is_loopback or net.is_link_local:
raise ValueError("NETWORK_CIDR_UNSAFE")
return str(net)
def _source_network_allowed(value: str) -> bool:
net = ipaddress.ip_network(value, strict=False)
if net.version != 4:
return False
if net.subnet_of(CGNAT_V4):
return True
return any(net.subnet_of(x) for x in RFC1918)
def forwarding_policy(
*,
ingress_interface: str,
egress_interface: str,
source_subnet: str,
destination_subnet: str,
protocol: str = "any",
destination_port: int = 0,
nat: bool = False,
lifetime: str = "runtime",
) -> dict[str, Any]:
ingress_interface = str(ingress_interface or "").strip()
egress_interface = str(egress_interface or "").strip()
protocol = str(protocol or "any").strip().lower()
lifetime = str(lifetime or "runtime").strip().lower()
try:
destination_port = int(destination_port)
except Exception:
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PORT_INVALID", "execute": False}
if not _valid_interface_name(ingress_interface) or not _valid_interface_name(egress_interface):
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_INTERFACE_INVALID", "execute": False}
if ingress_interface == egress_interface:
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_INTERFACES_MUST_DIFFER", "execute": False}
try:
source_subnet = _normalize_ipv4_network(source_subnet)
destination_subnet = _normalize_ipv4_network(destination_subnet)
except ValueError as exc:
return {"ok": False, "state": "BLOCKED", "reason": str(exc), "execute": False}
if not _source_network_allowed(source_subnet):
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_SOURCE_NOT_PRIVATE", "execute": False}
if protocol not in FORWARD_PROTOCOLS:
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PROTOCOL_INVALID", "execute": False}
if destination_port < 0 or destination_port > 65535:
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PORT_INVALID", "execute": False}
if protocol == "any" and destination_port != 0:
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PORT_REQUIRES_PROTOCOL", "execute": False}
if lifetime != "runtime":
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PERSISTENCE_REQUIRES_SEPARATE_CAPABILITY", "execute": False}
fw = firewall_backend()
if not fw.get("active"):
return {"ok": False, "state": "BLOCKED", "reason": "NO_ACTIVE_FIREWALL_BACKEND", "execute": False}
if fw.get("backend") != "ufw":
return {"ok": False, "state": "BLOCKED", "reason": "FIREWALL_BACKEND_NOT_YET_MANAGED", "execute": False}
names = {x.get("name") for x in collect_interfaces()}
if ingress_interface not in names or egress_interface not in names:
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_INTERFACE_NOT_PRESENT", "execute": False}
args = {
"ingress_interface": ingress_interface,
"egress_interface": egress_interface,
"source_subnet": source_subnet,
"destination_subnet": destination_subnet,
"protocol": protocol,
"destination_port": destination_port,
"nat": bool(nat),
"lifetime": "runtime",
}
return {
"ok": True,
"schema": SCHEMA,
"state": "ELIGIBLE_REQUIRES_TYPED_PRIVILEGED_PLAN",
"execute": False,
"capability": "network.forward",
"args": args,
"security": {
"local_tty_privileged_required": True,
"independent_rollback_approval_required": True,
"runtime_only": True,
"reboot_fail_closed": True,
"nat_explicit": bool(nat),
"wildcard_source_forbidden": True,
},
}
@@ -0,0 +1,239 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import stat
import struct
import subprocess
from pathlib import Path
from typing import Any, Callable
NODE_KEY_NAMESPACE = "framebridge-pairing"
NODE_KEY_ALGORITHM = "ssh-ed25519"
class NodeIdentityError(RuntimeError):
pass
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _ssh_string(blob: bytes, offset: int) -> tuple[bytes, int]:
if offset + 4 > len(blob):
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
size = struct.unpack(">I", blob[offset:offset + 4])[0]
offset += 4
end = offset + size
if end > len(blob):
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
return blob[offset:end], end
def read_ed25519_public_key(public_path: Path) -> dict[str, Any]:
path = Path(public_path)
if not path.is_file() or path.is_symlink():
raise NodeIdentityError("NODE_PUBLIC_KEY_MISSING")
parts = path.read_text(encoding="utf-8").strip().split()
if len(parts) < 2 or parts[0] != NODE_KEY_ALGORITHM:
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
try:
blob = base64.b64decode(parts[1], validate=True)
except Exception as exc:
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID") from exc
alg, off = _ssh_string(blob, 0)
raw, off = _ssh_string(blob, off)
if off != len(blob) or alg != b"ssh-ed25519" or len(raw) != 32:
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
x = _b64url(raw)
jwk = {"crv": "Ed25519", "kty": "OKP", "x": x}
canonical = json.dumps(
jwk, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("ascii")
thumbprint = _b64url(hashlib.sha256(canonical).digest())
host_id = (
"urn:ietf:params:oauth:jwk-thumbprint:sha-256:" + thumbprint
)
return {
"algorithm": "Ed25519",
"public_jwk": jwk,
"jwk_thumbprint_sha256": thumbprint,
"host_id": host_id,
"public_key_sha256": hashlib.sha256(blob).hexdigest(),
}
def ensure_node_keypair(
state_dir: Path,
*,
comment: str = "framebridge-node",
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> dict[str, Any]:
root = Path(state_dir) / "node-identity"
private_path = root / "node_ed25519"
public_path = root / "node_ed25519.pub"
if root.exists() and root.is_symlink():
raise NodeIdentityError("NODE_KEY_DIR_SYMLINK_FORBIDDEN")
root.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chmod(root, 0o700)
private_exists = private_path.exists()
public_exists = public_path.exists()
if private_exists != public_exists:
raise NodeIdentityError("NODE_KEYPAIR_INCOMPLETE")
if not private_exists:
proc = runner(
[
"/usr/bin/ssh-keygen",
"-q",
"-t",
"ed25519",
"-N",
"",
"-C",
str(comment),
"-f",
str(private_path),
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if proc.returncode != 0:
raise NodeIdentityError("NODE_KEYGEN_FAILED")
if (
not private_path.is_file()
or private_path.is_symlink()
or not public_path.is_file()
or public_path.is_symlink()
):
raise NodeIdentityError("NODE_KEYPAIR_INVALID")
os.chmod(private_path, 0o600)
os.chmod(public_path, 0o644)
mode = stat.S_IMODE(private_path.stat().st_mode)
if mode != 0o600:
raise NodeIdentityError("NODE_PRIVATE_KEY_MODE_INVALID")
public = read_ed25519_public_key(public_path)
return {
"ok": True,
"private_key_path": str(private_path),
"public_key_path": str(public_path),
"public_key_ssh": _safe_public_line(public_path),
**public,
}
def _safe_public_line(public_path: Path) -> str:
parts=Path(public_path).read_text(encoding="utf-8").strip().split()
if len(parts)<2 or parts[0] != NODE_KEY_ALGORITHM:
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
return parts[0] + " " + parts[1]
def sign_pairing_payload(
private_path: Path,
payload: bytes,
*,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> str:
private_path=Path(private_path)
if not private_path.is_file() or private_path.is_symlink():
raise NodeIdentityError("NODE_PRIVATE_KEY_MISSING")
import tempfile
with tempfile.TemporaryDirectory(prefix="framebridge-pair-sign-") as td:
msg=Path(td)/"message"
msg.write_bytes(bytes(payload))
proc=runner(
[
"/usr/bin/ssh-keygen","-Y","sign",
"-f",str(private_path),
"-n",NODE_KEY_NAMESPACE,
str(msg),
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
sig=Path(str(msg)+".sig")
if proc.returncode!=0 or not sig.is_file():
raise NodeIdentityError("NODE_PAIR_SIGNATURE_FAILED")
return base64.b64encode(sig.read_bytes()).decode("ascii")
def verify_pairing_payload(
public_key_ssh: str,
payload: bytes,
signature_b64: str,
*,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> bool:
import tempfile
try:
signature=base64.b64decode(str(signature_b64),validate=True)
except Exception:
return False
public_key_ssh=str(public_key_ssh).strip()
if not public_key_ssh.startswith("ssh-ed25519 "):
return False
with tempfile.TemporaryDirectory(prefix="framebridge-pair-verify-") as td:
root=Path(td)
msg=root/"message"
sig=root/"message.sig"
allowed=root/"allowed_signers"
msg.write_bytes(bytes(payload))
sig.write_bytes(signature)
allowed.write_text(
"framebridge-node " + public_key_ssh + "\n",
encoding="utf-8",
)
proc=runner(
[
"/usr/bin/ssh-keygen","-Y","verify",
"-f",str(allowed),
"-I","framebridge-node",
"-n",NODE_KEY_NAMESPACE,
"-s",str(sig),
],
input=bytes(payload),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
return proc.returncode==0
def parse_ed25519_public_key_line(line: str) -> dict[str, Any]:
parts=str(line).strip().split()
if len(parts)<2 or parts[0] != NODE_KEY_ALGORITHM:
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
try:
blob=base64.b64decode(parts[1],validate=True)
except Exception as exc:
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID") from exc
alg,off=_ssh_string(blob,0)
raw,off=_ssh_string(blob,off)
if off!=len(blob) or alg!=b"ssh-ed25519" or len(raw)!=32:
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
x=_b64url(raw)
jwk={"crv":"Ed25519","kty":"OKP","x":x}
canonical=json.dumps(
jwk,sort_keys=True,separators=(",",":"),ensure_ascii=True
).encode("ascii")
thumbprint=_b64url(hashlib.sha256(canonical).digest())
return {
"algorithm":"Ed25519",
"public_jwk":jwk,
"jwk_thumbprint_sha256":thumbprint,
"host_id":"urn:ietf:params:oauth:jwk-thumbprint:sha-256:"+thumbprint,
"public_key_sha256":hashlib.sha256(blob).hexdigest(),
}
@@ -0,0 +1,287 @@
from __future__ import annotations
import json
import secrets
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
from .node_identity import ensure_node_keypair, sign_pairing_payload
from .pairing import PairingStore, canonical
from .pairing_client import PairingClientError, confirm_pairing_remote
from .product_core import ProductError
from .seed_capabilities import execute_capability, list_capabilities
from .seed_core import SeedContext, atomic_write_json
from .plan_engine import PlanEngine
from .memory_store import MemoryStore
READ_CAPS={x["name"] for x in list_capabilities() if x.get("risk")=="READONLY"}
class OutboundAgentError(RuntimeError):
pass
def _post(url: str, value: dict[str,Any], timeout: float=10.0) -> dict[str,Any]:
req=urllib.request.Request(
url,
data=json.dumps(value,sort_keys=True,separators=(",",":")).encode("utf-8"),
headers={"Content-Type":"application/json","Accept":"application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req,timeout=timeout) as resp:
out=json.loads(resp.read(1024*1024).decode("utf-8"))
except urllib.error.HTTPError as exc:
detail=""
try:
body=json.loads(exc.read(1024*1024).decode("utf-8"))
if isinstance(body,dict):
detail=str(body.get("error") or "")
except Exception:
detail=""
if detail=="node_revoked":
raise OutboundAgentError("OUTBOUND_NODE_REVOKED") from exc
raise OutboundAgentError("OUTBOUND_BROKER_REJECTED") from exc
except Exception as exc:
raise OutboundAgentError("OUTBOUND_BROKER_UNREACHABLE") from exc
if not isinstance(out,dict):
raise OutboundAgentError("OUTBOUND_BROKER_RESPONSE_INVALID")
return out
def _signed_envelope(ctx: SeedContext, kind: str, payload: dict[str,Any]) -> dict[str,Any]:
identity=ctx.load_identity() or {}
node_id=str(identity.get("node_id") or "")
host_id=str(identity.get("node_host_id") or "")
if not node_id or not host_id:
raise OutboundAgentError("OUTBOUND_NODE_IDENTITY_MISSING")
crypto=ensure_node_keypair(ctx.state_dir,comment="framebridge-"+node_id.replace("-","")[:8])
base={
"schema":1,
"kind":str(kind),
"node_id":node_id,
"node_host_id":host_id,
"timestamp":time.time(),
"nonce":secrets.token_hex(16),
"payload":payload,
}
sig=sign_pairing_payload(crypto["private_key_path"],canonical(base))
return {
**base,
"node_proof":{
"format":"sshsig",
"namespace":"framebridge-pairing",
"signature_b64":sig,
},
}
def broker_base(ctx: SeedContext) -> str:
config=ctx.load_config() or {}
pairing=config.get("pairing") or {}
base=str(pairing.get("broker_base_url") or "").rstrip("/")
if not base:
raise OutboundAgentError("OUTBOUND_BROKER_NOT_CONFIGURED")
return base
def sync_local_revocation(ctx: SeedContext) -> dict[str,Any]:
store=PairingStore(ctx.state_dir)
current=store.load()
if current.get("state")=="paired":
view=store.revoke()
elif current.get("state")=="revoked":
view=current
else:
raise OutboundAgentError("OUTBOUND_LOCAL_PAIRING_STATE_INVALID")
config=ctx.load_config() or {}
pairing=config.get("pairing") or {}
pairing["state"]="revoked"
pairing["transport"]="broker_outbound"
if view.get("revoked_at"):
pairing["revoked_at"]=view.get("revoked_at")
config["pairing"]=pairing
config["connection_mode"]="PAIRING"
setup=config.get("setup")
if isinstance(setup,dict):
setup["pairing_state"]="revoked"
atomic_write_json(ctx.config_path,config)
ctx.audit.append(
"pair.revoked",
"PASS",
request={"source":"broker"},
result={"state":"revoked"},
principal="pairing-broker",
)
MemoryStore(ctx).append(
"recovery",
{"event":"pair.revoked","state":"revoked","source":"broker"},
source="system",
)
return {"ok":True,"state":"revoked"}
def execute_plan_operation(ctx: SeedContext, operation: str, payload: dict[str,Any]) -> dict[str,Any]:
if operation not in {"plan.import","plan.status","plan.apply","plan.rollback"}:
raise OutboundAgentError("OUTBOUND_PLAN_OPERATION_NOT_ALLOWED")
if not isinstance(payload,dict):
raise OutboundAgentError("OUTBOUND_PLAN_PAYLOAD_INVALID")
engine=PlanEngine(ctx)
if operation=="plan.import":
plan=payload.get("plan")
if not isinstance(plan,dict):
raise OutboundAgentError("OUTBOUND_PLAN_DOCUMENT_INVALID")
return engine.import_document(plan)
plan_id=str(payload.get("plan_id") or "")
if not plan_id:
raise OutboundAgentError("OUTBOUND_PLAN_ID_MISSING")
if operation=="plan.status":
return engine.show(plan_id)
expected=str(payload.get("plan_sha256") or "")
shown=engine.show(plan_id)
if shown.get("plan_sha256")!=expected:
raise OutboundAgentError("OUTBOUND_PLAN_SHA_MISMATCH")
if operation=="plan.apply":
return engine.apply(plan_id)
return engine.rollback(plan_id)
def run_once(ctx: SeedContext | None=None) -> dict[str,Any]:
ctx=ctx or SeedContext()
store=PairingStore(ctx.state_dir)
local_pair=store.load()
if local_pair.get("state")=="paired" and local_pair.get("broker_confirm_required") is True:
try:
confirm_pairing_remote(state_dir=ctx.state_dir,record=local_pair)
except PairingClientError as exc:
raise OutboundAgentError("OUTBOUND_PAIR_CONFIRM_PENDING") from exc
local_pair=store.load()
config=ctx.load_config() or {}
pairing_cfg=config.get("pairing") or {}
pairing_cfg["broker_confirm_required"]=False
pairing_cfg["broker_confirmed_at"]=local_pair.get("broker_confirmed_at")
config["pairing"]=pairing_cfg
atomic_write_json(ctx.config_path,config)
config=ctx.load_config() or {}
pairing=config.get("pairing") or {}
if pairing.get("state")!="paired":
raise OutboundAgentError("OUTBOUND_NODE_NOT_PAIRED")
if store.load().get("broker_confirm_required") is True:
raise OutboundAgentError("OUTBOUND_PAIR_CONFIRM_PENDING")
base=broker_base(ctx)
poll=_post(
base+"/fbpair/v1/node/poll",
_signed_envelope(ctx,"node.poll",{}),
)
if poll.get("ok") is not True:
raise OutboundAgentError("OUTBOUND_POLL_REJECTED")
req=poll.get("request")
if not isinstance(req,dict):
return {"ok":True,"idle":True}
rid=str(req.get("request_id") or "")
operation=str(req.get("operation") or "")
if operation:
payload=req.get("payload") or {}
try:
value=execute_plan_operation(ctx,operation,payload)
result={"ok":True,"operation":operation,"result":value}
ctx.audit.append(
"outbound."+operation,
"PASS",
request={"request_id":rid,"operation":operation},
result={"ok":True},
principal="paired-ai",
)
except Exception as exc:
result={"ok":False,"operation":operation,"error":str(exc)}
ctx.audit.append(
"outbound."+operation,
"FAIL",
request={"request_id":rid,"operation":operation},
result={"ok":False,"error":type(exc).__name__},
principal="paired-ai",
)
cap=operation
else:
cap=str(req.get("capability") or "")
args=req.get("args") or {}
if cap not in READ_CAPS:
result={"ok":False,"error":"CAPABILITY_NOT_ALLOWED","capability":cap}
elif not isinstance(args,dict):
result={"ok":False,"error":"CAPABILITY_ARGS_NOT_OBJECT","capability":cap}
else:
try:
value=execute_capability(cap,args)
result={"ok":True,"capability":cap,"result":value}
ctx.audit.append(
"outbound."+cap,
"PASS",
request={"request_id":rid,"capability":cap},
result={"ok":True},
principal="paired-ai",
)
except Exception as exc:
result={"ok":False,"capability":cap,"error":str(exc)}
ctx.audit.append(
"outbound."+cap,
"FAIL",
request={"request_id":rid,"capability":cap},
result={"ok":False,"error":type(exc).__name__},
principal="paired-ai",
)
response=_post(
base+"/fbpair/v1/node/result",
_signed_envelope(
ctx,
"node.result",
{"request_id":rid,"result":result},
),
)
if response.get("ok") is not True:
raise OutboundAgentError("OUTBOUND_RESULT_REJECTED")
return {"ok":True,"idle":False,"request_id":rid,"capability":cap}
def run_forever(interval: float=1.0) -> None:
interval=max(0.5,min(float(interval),30.0))
ctx=SeedContext()
while True:
try:
run_once(ctx)
except OutboundAgentError as exc:
if str(exc)=="OUTBOUND_NODE_REVOKED":
try:
sync_local_revocation(ctx)
except Exception:
pass
except Exception:
pass
time.sleep(interval)
def main() -> None:
import argparse
parser=argparse.ArgumentParser(prog="framebridge-outbound-agent")
sub=parser.add_subparsers(dest="command",required=True)
sub.add_parser("once")
run=sub.add_parser("run")
run.add_argument("--interval",type=float,default=1.0)
args=parser.parse_args()
if args.command=="once":
print(json.dumps(run_once(),sort_keys=True))
else:
run_forever(args.interval)
if __name__=="__main__":
main()
@@ -0,0 +1,484 @@
from __future__ import annotations
import hashlib
import ipaddress
import json
import fcntl
import os
import secrets
import tempfile
from datetime import datetime, timedelta, timezone
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
FBPAIR_PROTOCOL = "FBPAIR/1"
PAIR_STATES = {
"not_paired",
"pair_requested",
"paired",
"denied",
"expired",
"revoked",
}
PROVIDERS = {"chatgpt", "ollama", "lmstudio", "other"}
class PairingError(RuntimeError):
pass
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def canonical(value: Any) -> bytes:
return json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("ascii")
def sha256_hex(value: Any) -> str:
return hashlib.sha256(canonical(value)).hexdigest()
def _is_local_ip(host: str) -> bool:
try:
addr=ipaddress.ip_address(str(host))
except ValueError:
return False
if addr.is_loopback or addr.is_link_local:
return True
if addr.version==4:
return any(addr in net for net in (
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
))
return addr in ipaddress.ip_network("fc00::/7")
def _https_or_loopback_http(uri: str, allow_insecure_dev: bool=False) -> bool:
try:
p = urlparse(str(uri))
except Exception:
return False
if p.scheme == "https" and bool(p.netloc):
return True
if p.scheme == "http" and bool(p.netloc):
host=str(p.hostname or "")
if _is_local_ip(host):
return True
if allow_insecure_dev:
return True
return False
def create_pair_request(
*,
node_id: str,
node_label: str,
node_host_id: str,
public_jwk: dict[str, Any],
provider: str,
capability_manifest_sha256: str,
) -> dict[str, Any]:
provider = str(provider).strip().lower()
if provider not in PROVIDERS:
raise PairingError("PAIR_PROVIDER_UNSUPPORTED")
node_label=str(node_label).strip()
if not node_id or not node_host_id or not node_label or len(node_label)>253:
raise PairingError("PAIR_NODE_IDENTITY_REQUIRED")
manifest_sha = str(capability_manifest_sha256).strip().lower()
if len(manifest_sha) != 64 or any(c not in "0123456789abcdef" for c in manifest_sha):
raise PairingError("PAIR_CAPABILITY_MANIFEST_SHA_INVALID")
nonce = secrets.token_urlsafe(32)
request = {
"protocol": FBPAIR_PROTOCOL,
"schema": 1,
"node_id": str(node_id),
"node_label": node_label,
"node_host_id": str(node_host_id),
"public_jwk": public_jwk,
"provider": provider,
"capability_manifest_sha256": manifest_sha,
"node_nonce": nonce,
"requested_at": utc_now(),
}
request["request_sha256"] = sha256_hex(request)
return request
def attach_node_proof(
request: dict[str, Any],
*,
public_key_ssh: str,
signature_b64: str,
) -> dict[str, Any]:
if request.get("protocol") != FBPAIR_PROTOCOL:
raise PairingError("PAIR_REQUEST_PROTOCOL_INVALID")
if not str(public_key_ssh).startswith("ssh-ed25519 "):
raise PairingError("PAIR_PUBLIC_KEY_INVALID")
if not signature_b64:
raise PairingError("PAIR_NODE_PROOF_REQUIRED")
value=dict(request)
value["public_key_ssh"]=str(public_key_ssh)
value["node_proof"]={
"format":"sshsig",
"namespace":"framebridge-pairing",
"signature_b64":str(signature_b64),
}
return value
def pairing_signing_payload(request: dict[str, Any]) -> bytes:
value=dict(request)
value.pop("node_proof",None)
value.pop("public_key_ssh",None)
return canonical(value)
def validate_device_authorization(
request: dict[str, Any],
response: dict[str, Any],
*,
allow_insecure_dev: bool=False,
) -> dict[str, Any]:
if not isinstance(response, dict):
raise PairingError("PAIR_DEVICE_RESPONSE_INVALID")
required = {
"pair_id",
"device_code",
"user_code",
"verification_uri",
"expires_in",
}
if not required.issubset(response):
raise PairingError("PAIR_DEVICE_RESPONSE_INCOMPLETE")
pair_id = str(response["pair_id"])
device_code = str(response["device_code"])
user_code = str(response["user_code"])
verification_uri = str(response["verification_uri"])
complete = str(response.get("verification_uri_complete") or "")
try:
expires_in = int(response["expires_in"])
interval = int(response.get("interval", 5))
except Exception as exc:
raise PairingError("PAIR_DEVICE_RESPONSE_INVALID") from exc
if not pair_id or len(device_code) < 24 or not user_code:
raise PairingError("PAIR_DEVICE_RESPONSE_INVALID")
if not _https_or_loopback_http(verification_uri, allow_insecure_dev):
raise PairingError("PAIR_VERIFICATION_URI_INVALID")
if complete and not _https_or_loopback_http(complete, allow_insecure_dev):
raise PairingError("PAIR_VERIFICATION_URI_COMPLETE_INVALID")
if not (30 <= expires_in <= 1800):
raise PairingError("PAIR_EXPIRES_INVALID")
if not (1 <= interval <= 60):
raise PairingError("PAIR_INTERVAL_INVALID")
try:
requested_dt=datetime.fromisoformat(
str(request["requested_at"]).replace("Z","+00:00")
).astimezone(timezone.utc)
except Exception as exc:
raise PairingError("PAIR_REQUEST_TIME_INVALID") from exc
expires_at=(requested_dt+timedelta(seconds=expires_in)).isoformat()
return {
"protocol": FBPAIR_PROTOCOL,
"schema": 1,
"state": "pair_requested",
"pair_id": pair_id,
"request_sha256": request["request_sha256"],
"node_id": request["node_id"],
"node_label": request["node_label"],
"node_host_id": request["node_host_id"],
"provider": request["provider"],
"device_code": device_code,
"device_code_sha256": hashlib.sha256(device_code.encode("utf-8")).hexdigest(),
"user_code": user_code,
"verification_uri": verification_uri,
"verification_uri_complete": complete or None,
"expires_in": expires_in,
"expires_at": expires_at,
"interval": interval,
"requested_at": request["requested_at"],
"last_poll_at": None,
}
def public_pairing_view(record: dict[str, Any]) -> dict[str, Any]:
value = dict(record)
value.pop("device_code", None)
value.pop("account_subject", None)
value.pop("provider_access_token", None)
value.pop("provider_refresh_token", None)
return value
def pairing_qr_payload(record: dict[str, Any]) -> str:
if record.get("state") != "pair_requested":
raise PairingError("PAIR_NOT_PENDING")
return str(
record.get("verification_uri_complete")
or record.get("verification_uri")
or ""
)
def validate_pair_receipt(
pending: dict[str, Any],
receipt: dict[str, Any],
) -> dict[str, Any]:
if pending.get("state") != "pair_requested":
raise PairingError("PAIR_NOT_PENDING")
if not isinstance(receipt, dict):
raise PairingError("PAIR_RECEIPT_INVALID")
checks = {
"protocol": receipt.get("protocol") == FBPAIR_PROTOCOL,
"schema": receipt.get("schema") == 1,
"state": receipt.get("state") == "paired",
"pair_id": receipt.get("pair_id") == pending.get("pair_id"),
"request_sha256": receipt.get("request_sha256") == pending.get("request_sha256"),
"node_id": receipt.get("node_id") == pending.get("node_id"),
"node_label": receipt.get("node_label") == pending.get("node_label"),
"node_host_id": receipt.get("node_host_id") == pending.get("node_host_id"),
"provider": receipt.get("provider") == pending.get("provider"),
}
if not all(checks.values()):
raise PairingError("PAIR_RECEIPT_BINDING_MISMATCH")
broker_issuer = str(receipt.get("broker_issuer") or "")
account_binding_id = str(receipt.get("account_binding_id") or "")
grant_id = str(receipt.get("grant_id") or "")
if (
not broker_issuer
or not account_binding_id
or len(account_binding_id)>160
or not grant_id
or len(grant_id)>160
):
raise PairingError("PAIR_ACCOUNT_BINDING_MISSING")
return {
"protocol": FBPAIR_PROTOCOL,
"schema": 1,
"state": "paired",
"pair_id": pending["pair_id"],
"request_sha256": pending["request_sha256"],
"node_id": pending["node_id"],
"node_label": pending["node_label"],
"node_host_id": pending["node_host_id"],
"provider": pending["provider"],
"broker_issuer": broker_issuer,
"account_binding_id": account_binding_id,
"grant_id": grant_id,
"paired_at": str(receipt.get("paired_at") or utc_now()),
"scopes": sorted({str(x) for x in (receipt.get("scopes") or [])}),
"revocation_id": str(receipt.get("revocation_id") or ""),
"node_grant_binding": str(receipt.get("node_grant_binding") or "proof-of-possession-required"),
}
class PairingStore:
def __init__(self, state_dir: Path):
self.path = Path(state_dir) / "pairing.json"
self.lock_path = Path(state_dir) / ".pairing.lock"
@contextmanager
def _exclusive(self):
self.lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
flags=os.O_RDWR|os.O_CREAT|getattr(os,"O_NOFOLLOW",0)
fd=os.open(self.lock_path,flags,0o600)
try:
fcntl.flock(fd,fcntl.LOCK_EX)
yield
finally:
try:
fcntl.flock(fd,fcntl.LOCK_UN)
finally:
os.close(fd)
def _load_unlocked(self) -> dict[str, Any]:
if not self.path.is_file():
return {"protocol": FBPAIR_PROTOCOL, "schema": 1, "state": "not_paired"}
value = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(value, dict) or value.get("state") not in PAIR_STATES:
raise PairingError("PAIR_STATE_INVALID")
return value
def load(self) -> dict[str, Any]:
# Atomic replacement makes a lock-free reader see old-or-new complete JSON.
return self._load_unlocked()
def _save_unlocked(self, value: dict[str, Any]) -> None:
if value.get("state") not in PAIR_STATES:
raise PairingError("PAIR_STATE_INVALID")
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, tmp = tempfile.mkstemp(prefix=".pairing.", dir=str(self.path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, self.path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def save(self, value: dict[str, Any]) -> None:
with self._exclusive():
self._save_unlocked(value)
def begin(self, pending: dict[str, Any]) -> dict[str, Any]:
with self._exclusive():
current = self._load_unlocked()
if current.get("state") == "paired":
raise PairingError("PAIR_ALREADY_PAIRED")
if current.get("state") == "pair_requested":
raise PairingError("PAIR_REQUEST_ALREADY_PENDING")
self._save_unlocked(pending)
return public_pairing_view(pending)
def update_pending_fields(self, pair_id: str, fields: dict[str, Any]) -> dict[str, Any]:
pair_id=str(pair_id or "")
if not pair_id:
raise PairingError("PAIR_ID_REQUIRED")
with self._exclusive():
current=self._load_unlocked()
if current.get("state")!="pair_requested" or str(current.get("pair_id") or "")!=pair_id:
return current
current=dict(current)
current.update(dict(fields or {}))
self._save_unlocked(current)
return current
def complete(self, receipt: dict[str, Any]) -> dict[str, Any]:
with self._exclusive():
current = self._load_unlocked()
paired = validate_pair_receipt(current, receipt)
paired["broker_base_url"]=str(current.get("broker_base_url") or "")
paired["broker_confirm_required"]=True
self._save_unlocked(paired)
return public_pairing_view(paired)
def invalidate_unconfirmed_pair(self, pair_id: str, broker_state: str) -> dict[str, Any]:
pair_id=str(pair_id or "")
broker_state=str(broker_state or "unknown").lower()
with self._exclusive():
current=self._load_unlocked()
if current.get("state")!="paired" or current.get("broker_confirm_required") is not True or str(current.get("pair_id") or "")!=pair_id:
return current
current=dict(current)
current["state"]="expired"
current["expired_at"]=utc_now()
current["expiration_reason"]="broker_confirm_"+broker_state
current["requires_new_local_action"]=True
current["broker_confirm_required"]=False
current["broker_confirm_state"]=broker_state
self._save_unlocked(current)
return current
def mark_broker_confirmed(self, pair_id: str, result: dict[str, Any]) -> dict[str, Any]:
pair_id=str(pair_id or "")
with self._exclusive():
current=self._load_unlocked()
if current.get("state")!="paired" or str(current.get("pair_id") or "")!=pair_id:
return current
current=dict(current)
current["broker_confirm_required"]=False
current["broker_confirmed_at"]=utc_now()
current["broker_confirm_state"]=str((result or {}).get("state") or "paired")
self._save_unlocked(current)
return current
def transition_pending(self, pair_id: str, new_state: str, fields: dict[str, Any] | None=None) -> dict[str, Any]:
pair_id=str(pair_id or "")
if new_state not in {"denied","expired"}:
raise PairingError("PAIR_TRANSITION_STATE_INVALID")
with self._exclusive():
current=self._load_unlocked()
if current.get("state")!="pair_requested" or str(current.get("pair_id") or "")!=pair_id:
return current
current=dict(current)
current.pop("device_code",None)
current["state"]=new_state
current.update(dict(fields or {}))
self._save_unlocked(current)
return current
def cancel_pending(self) -> dict[str, Any]:
with self._exclusive():
current = self._load_unlocked()
if current.get("state") != "pair_requested":
raise PairingError("PAIR_NOT_PENDING")
current=dict(current)
current.pop("device_code", None)
current["state"] = "revoked"
current["revoked_at"] = utc_now()
current["revocation_reason"] = "local_operator_cancelled"
if current.get("pair_id") and current.get("broker_base_url"):
current["broker_cancel_required"]=True
current["broker_cancel_reason"]="local_operator_cancelled"
self._save_unlocked(current)
return public_pairing_view(current)
def invalidate_pending(self, reason: str) -> dict[str, Any]:
reason=str(reason or "").strip().lower()
if reason not in {"network_change","service_stop","reconcile","window_expired","window_closed"}:
raise PairingError("PAIR_INVALIDATION_REASON_INVALID")
with self._exclusive():
current=self._load_unlocked()
if current.get("state") != "pair_requested":
raise PairingError("PAIR_NOT_PENDING")
current=dict(current)
current.pop("device_code",None)
current["state"]="expired"
current["expired_at"]=utc_now()
current["expiration_reason"]=reason
current["requires_new_local_action"]=True
if current.get("pair_id") and current.get("broker_base_url"):
current["broker_cancel_required"]=True
current["broker_cancel_reason"]=reason
self._save_unlocked(current)
return public_pairing_view(current)
def mark_broker_cancelled(self, pair_id: str, result: dict[str, Any]) -> dict[str, Any]:
pair_id=str(pair_id or "")
with self._exclusive():
current=self._load_unlocked()
if str(current.get("pair_id") or "")!=pair_id:
return current
current=dict(current)
current["broker_cancel_required"]=False
current["broker_cancel_state"]=str((result or {}).get("state") or "cancelled")
current["broker_cancelled_at"]=utc_now()
current.pop("broker_cancel_last_error",None)
self._save_unlocked(current)
return current
def note_broker_cancel_error(self, pair_id: str, error: str) -> dict[str, Any]:
pair_id=str(pair_id or "")
with self._exclusive():
current=self._load_unlocked()
if str(current.get("pair_id") or "")!=pair_id:
return current
current=dict(current)
current["broker_cancel_required"]=True
current["broker_cancel_last_error"]=str(error or "PAIR_BROKER_CANCEL_FAILED")[:128]
current["broker_cancel_attempts"]=int(current.get("broker_cancel_attempts") or 0)+1
current["broker_cancel_last_attempt_at"]=utc_now()
self._save_unlocked(current)
return current
def revoke(self) -> dict[str, Any]:
with self._exclusive():
current = self._load_unlocked()
if current.get("state") != "paired":
raise PairingError("PAIR_NOT_PAIRED")
current=dict(current)
current["state"] = "revoked"
current["revoked_at"] = utc_now()
self._save_unlocked(current)
return public_pairing_view(current)
@@ -0,0 +1,380 @@
from __future__ import annotations
import hashlib
import ipaddress
import json
import secrets
import ssl
import time
from datetime import datetime, timezone
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Callable
from .node_identity import ensure_node_keypair, sign_pairing_payload
from .pairing import (
PairingError,
PairingStore,
attach_node_proof,
create_pair_request,
pairing_signing_payload,
public_pairing_view,
validate_device_authorization,
)
class PairingClientError(RuntimeError):
pass
def _is_local_ip(host: str) -> bool:
try:
addr=ipaddress.ip_address(str(host))
except ValueError:
return False
if addr.is_loopback or addr.is_link_local:
return True
if addr.version==4:
return any(addr in net for net in (
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
))
return addr in ipaddress.ip_network("fc00::/7")
def _broker_base_ok(url: str, allow_insecure_dev: bool) -> bool:
try:
p=urllib.parse.urlparse(str(url))
except Exception:
return False
if p.scheme=="https" and p.netloc:
return True
if p.scheme=="http" and p.netloc:
host=str(p.hostname or "")
if _is_local_ip(host):
return True
if allow_insecure_dev:
return True
return False
def _endpoint(base: str, path: str) -> str:
return str(base).rstrip("/") + "/" + path.lstrip("/")
def post_json(
url: str,
value: dict[str, Any],
*,
timeout: float=10.0,
opener: Callable[..., Any] | None=None,
) -> dict[str, Any]:
data=json.dumps(
value, sort_keys=True, separators=(",",":"), ensure_ascii=True
).encode("utf-8")
req=urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Content-Type":"application/json",
"Accept":"application/json",
"User-Agent":"FrameBridge-Linux-Core/0.5",
},
)
try:
if opener is None:
with urllib.request.urlopen(req,timeout=timeout) as resp:
raw=resp.read(1024*1024)
else:
raw=opener(req,timeout=timeout)
if hasattr(raw,"read"):
raw=raw.read(1024*1024)
result=json.loads(bytes(raw).decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
raw=exc.read(1024*1024)
result=json.loads(bytes(raw).decode("utf-8"))
except Exception as parse_exc:
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID") from parse_exc
if not isinstance(result,dict):
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID")
return result
except (urllib.error.URLError,TimeoutError,OSError) as exc:
raise PairingClientError("PAIR_BROKER_UNREACHABLE") from exc
except Exception as exc:
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID") from exc
if not isinstance(result,dict):
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID")
return result
def start_pairing(
*,
state_dir: Path,
node_id: str,
node_label: str,
node_host_id: str,
public_jwk: dict[str, Any],
provider: str,
capability_manifest_sha256: str,
broker_base_url: str,
allow_insecure_dev: bool=False,
poster: Callable[..., dict[str,Any]]=post_json,
) -> dict[str,Any]:
if not _broker_base_ok(broker_base_url,allow_insecure_dev):
raise PairingClientError("PAIR_BROKER_URL_INVALID")
crypto=ensure_node_keypair(Path(state_dir),comment="framebridge-"+str(node_id)[:8])
request=create_pair_request(
node_id=node_id,
node_label=node_label,
node_host_id=node_host_id,
public_jwk=public_jwk,
provider=provider,
capability_manifest_sha256=capability_manifest_sha256,
)
signature=sign_pairing_payload(
crypto["private_key_path"],
pairing_signing_payload(request),
)
signed=attach_node_proof(
request,
public_key_ssh=crypto["public_key_ssh"],
signature_b64=signature,
)
response=poster(
_endpoint(broker_base_url,"fbpair/v1/device_authorization"),
signed,
)
pending=validate_device_authorization(
request,response,allow_insecure_dev=allow_insecure_dev
)
pending["broker_base_url"]=str(broker_base_url).rstrip("/")
PairingStore(Path(state_dir)).begin(pending)
return public_pairing_view(pending)
def confirm_pairing_remote(
*,
state_dir: Path,
allow_insecure_dev: bool=False,
poster: Callable[..., dict[str,Any]]=post_json,
record: dict[str,Any] | None=None,
) -> dict[str,Any]:
store=PairingStore(Path(state_dir))
current=dict(record) if isinstance(record,dict) else store.load()
if current.get("state")!="paired":
raise PairingClientError("PAIR_CONFIRM_STATE_INVALID")
pair_id=str(current.get("pair_id") or "")
node_id=str(current.get("node_id") or "")
node_host_id=str(current.get("node_host_id") or "")
request_sha256=str(current.get("request_sha256") or "")
broker=str(current.get("broker_base_url") or "")
if not pair_id or not node_id or not node_host_id or len(request_sha256)!=64:
raise PairingClientError("PAIR_CONFIRM_STATE_INCOMPLETE")
if not _broker_base_ok(broker,allow_insecure_dev):
raise PairingClientError("PAIR_BROKER_URL_INVALID")
crypto=ensure_node_keypair(Path(state_dir),comment="framebridge-"+node_id.replace("-","")[:8])
base={
"protocol":"FBPAIR/1",
"schema":1,
"operation":"pair.confirm",
"pair_id":pair_id,
"request_sha256":request_sha256,
"node_id":node_id,
"node_host_id":node_host_id,
"confirm_nonce":secrets.token_urlsafe(24),
"confirmed_at":datetime.now(timezone.utc).isoformat(),
}
signature=sign_pairing_payload(crypto["private_key_path"],pairing_signing_payload(base))
signed=attach_node_proof(
base,public_key_ssh=crypto["public_key_ssh"],signature_b64=signature
)
response=poster(_endpoint(broker,"fbpair/v1/confirm"),signed)
if isinstance(response,dict) and response.get("ok") is not True and str(response.get("state") or "") in {"cancelled","denied","expired"}:
store.invalidate_unconfirmed_pair(pair_id,str(response.get("state")))
return response
if not isinstance(response,dict) or response.get("ok") is not True or response.get("state")!="paired":
error=str((response or {}).get("error") or "pair_confirm_failed").upper() if isinstance(response,dict) else "PAIR_CONFIRM_RESPONSE_INVALID"
raise PairingClientError("PAIR_BROKER_"+error)
store.mark_broker_confirmed(pair_id,response)
return response
def cancel_pairing_remote(
*,
state_dir: Path,
reason: str,
allow_insecure_dev: bool=False,
poster: Callable[..., dict[str,Any]]=post_json,
record: dict[str,Any] | None=None,
) -> dict[str,Any]:
reason=str(reason or "").strip().lower()
if reason not in {
"network_change","service_stop","reconcile","window_expired",
"window_closed","local_operator_cancelled",
}:
raise PairingClientError("PAIR_CANCEL_REASON_INVALID")
store=PairingStore(Path(state_dir))
current=dict(record) if isinstance(record,dict) else store.load()
pair_id=str(current.get("pair_id") or "")
node_id=str(current.get("node_id") or "")
node_host_id=str(current.get("node_host_id") or "")
broker=str(current.get("broker_base_url") or "")
if not pair_id or not node_id or not node_host_id:
raise PairingClientError("PAIR_CANCEL_STATE_INCOMPLETE")
if not _broker_base_ok(broker,allow_insecure_dev):
raise PairingClientError("PAIR_BROKER_URL_INVALID")
crypto=ensure_node_keypair(Path(state_dir),comment="framebridge-"+node_id.replace("-","")[:8])
base={
"protocol":"FBPAIR/1",
"schema":1,
"operation":"pair.cancel",
"pair_id":pair_id,
"node_id":node_id,
"node_host_id":node_host_id,
"reason":reason,
"cancel_nonce":secrets.token_urlsafe(24),
"cancelled_at":datetime.now(timezone.utc).isoformat(),
}
signature=sign_pairing_payload(crypto["private_key_path"],pairing_signing_payload(base))
signed=attach_node_proof(
base,
public_key_ssh=crypto["public_key_ssh"],
signature_b64=signature,
)
response=poster(_endpoint(broker,"fbpair/v1/cancel"),signed)
if not isinstance(response,dict):
raise PairingClientError("PAIR_CANCEL_RESPONSE_INVALID")
if response.get("ok") is True and str(response.get("state") or "") in {
"cancelled","expired","denied",
}:
return response
error=str(response.get("error") or "pair_cancel_failed").upper()
raise PairingClientError("PAIR_BROKER_"+error)
def poll_pairing(
*,
state_dir: Path,
allow_insecure_dev: bool=False,
poster: Callable[..., dict[str,Any]]=post_json,
) -> dict[str,Any]:
store=PairingStore(Path(state_dir))
pending=store.load()
if pending.get("state")=="paired" and pending.get("broker_confirm_required") is True:
try:
confirm_pairing_remote(
state_dir=Path(state_dir),allow_insecure_dev=allow_insecure_dev,
poster=poster,record=pending,
)
except PairingClientError:
result=public_pairing_view(store.load())
result["broker_status"]="confirm_pending"
return result
return public_pairing_view(store.load())
if pending.get("state")!="pair_requested":
raise PairingClientError("PAIR_NOT_PENDING")
pair_id=str(pending.get("pair_id") or "")
broker=str(pending.get("broker_base_url") or "")
if not _broker_base_ok(broker,allow_insecure_dev):
raise PairingClientError("PAIR_BROKER_URL_INVALID")
now=datetime.now(timezone.utc)
try:
expires=datetime.fromisoformat(
str(pending.get("expires_at") or "").replace("Z","+00:00")
).astimezone(timezone.utc)
except Exception as exc:
raise PairingClientError("PAIR_EXPIRY_STATE_INVALID") from exc
if now>=expires:
current=store.transition_pending(
pair_id,"expired",
{"expired_at":now.isoformat(),"expiration_reason":"pairing_timeout"},
)
return public_pairing_view(current)
last=pending.get("last_poll_at")
interval=max(1,int(pending.get("interval") or 5))
if last:
try:
last_dt=datetime.fromisoformat(
str(last).replace("Z","+00:00")
).astimezone(timezone.utc)
except Exception as exc:
raise PairingClientError("PAIR_POLL_STATE_INVALID") from exc
remaining=interval-(now-last_dt).total_seconds()
if remaining>0:
result=public_pairing_view(pending)
result["broker_status"]="local_poll_interval"
result["retry_after"]=max(1,int(remaining+0.999))
return result
pending=store.update_pending_fields(pair_id,{"last_poll_at":now.isoformat()})
if pending.get("state")!="pair_requested":
return public_pairing_view(pending)
device_code=str(pending.get("device_code") or "")
if not device_code:
return public_pairing_view(pending)
response=poster(
_endpoint(broker,"fbpair/v1/token"),
{
"protocol":"FBPAIR/1",
"pair_id":pair_id,
"device_code":device_code,
},
)
# A network monitor may have invalidated this pair while the POST was in flight.
current=store.load()
if current.get("state")!="pair_requested" or str(current.get("pair_id") or "")!=pair_id:
return public_pairing_view(current)
error=str(response.get("error") or "")
if error in {"authorization_pending","slow_down"}:
if error=="slow_down":
current=store.update_pending_fields(
pair_id,{"interval":min(60,int(current.get("interval") or 5)+5)}
)
result=public_pairing_view(current)
result["broker_status"]=error
return result
if error=="access_denied":
current=store.transition_pending(
pair_id,"denied",{"denied_at":datetime.now(timezone.utc).isoformat()}
)
return public_pairing_view(current)
if error in {"expired_token","expired"}:
current=store.transition_pending(
pair_id,"expired",
{"expired_at":datetime.now(timezone.utc).isoformat(),"expiration_reason":"broker_expired"},
)
return public_pairing_view(current)
if error:
raise PairingClientError("PAIR_BROKER_"+error.upper())
try:
store.complete(response)
except PairingError as exc:
# If local invalidation won the race, do not revive the pairing.
current=store.load()
if current.get("state")!="pair_requested":
return public_pairing_view(current)
raise PairingClientError(str(exc)) from exc
paired=store.load()
try:
confirm_pairing_remote(
state_dir=Path(state_dir),
allow_insecure_dev=allow_insecure_dev,
poster=poster,
record=paired,
)
except PairingClientError:
current=public_pairing_view(store.load())
current["broker_status"]="confirm_pending"
return current
return public_pairing_view(store.load())
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
import subprocess
import sys
from typing import Any
from .network_security import PAIRING_WINDOW_TTL_SECONDS, pairing_policy
from .product_core import ProductError
SUDO = "/usr/bin/sudo"
PRIV_HELPER = "/usr/local/libexec/framebridge-seed-priv"
MIN_TTL = 30
MAX_TTL = PAIRING_WINDOW_TTL_SECONDS
def _decode_helper(proc: subprocess.CompletedProcess[str]) -> dict[str, Any]:
try:
value=json.loads(proc.stdout or "{}")
except Exception as exc:
raise ProductError("PAIRING_WINDOW_HELPER_INVALID_JSON") from exc
if not isinstance(value,dict):
raise ProductError("PAIRING_WINDOW_HELPER_INVALID_RESPONSE")
if proc.returncode!=0 or value.get("ok") is not True:
raise ProductError(str(value.get("error") or "PAIRING_WINDOW_HELPER_FAILED"))
return value
def _run_helper(args: list[str], *, interactive: bool=True) -> dict[str, Any]:
argv=[SUDO]
if not interactive:
argv.append("-n")
argv.extend([PRIV_HELPER,*args])
proc=subprocess.run(
argv,
text=True,
stdout=subprocess.PIPE,
stderr=None,
check=False,
shell=False,
)
return _decode_helper(proc)
def open_pairing_window(port: int=18765, ttl_seconds: int=PAIRING_WINDOW_TTL_SECONDS) -> dict[str, Any]:
if not sys.stdin.isatty():
raise ProductError("PAIRING_WINDOW_LOCAL_TTY_REQUIRED")
if isinstance(ttl_seconds,bool) or not isinstance(ttl_seconds,int) or not (MIN_TTL <= ttl_seconds <= MAX_TTL):
raise ProductError("PAIRING_WINDOW_TTL_INVALID")
policy=pairing_policy(port)
if policy.get("ok") is not True:
raise ProductError(str(policy.get("reason") or "PAIRING_WINDOW_POLICY_BLOCKED"))
candidate=policy.get("candidate") or {}
required=("interface","subnet","address","network_fingerprint")
if not all(candidate.get(k) for k in required):
raise ProductError("PAIRING_WINDOW_CANDIDATE_INCOMPLETE")
result=_run_helper([
"pairing-window-open",
"--interface",str(candidate["interface"]),
"--source-subnet",str(candidate["subnet"]),
"--destination-address",str(candidate["address"]),
"--port",str(int(port)),
"--network-fingerprint",str(candidate["network_fingerprint"]),
"--ttl",str(int(ttl_seconds)),
])
result["policy_state"]=policy.get("state")
result["transport"]="LAN_HTTP"
result["listen_url_base"]=f"http://{candidate['address']}:{int(port)}"
return result
def pairing_window_status(*, non_interactive: bool=False) -> dict[str, Any]:
return _run_helper(["pairing-window-status"],interactive=not non_interactive)
def close_pairing_window(reason: str="user", *, non_interactive: bool=False) -> dict[str, Any]:
return _run_helper(["pairing-window-close","--reason",str(reason)],interactive=not non_interactive)
@@ -0,0 +1,349 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import sqlite3
import tempfile
import threading
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from . import __version__
SECRET_KEY_RE = re.compile(r"(secret|token|password|passwd|api[_-]?key|private[_-]?key|credential|authorization|cookie)", re.I)
TOKEN_RE = re.compile(r"(?i)\\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9_]{10,}|github_pat_[A-Za-z0-9_]{10,}|xox[baprs]-[A-Za-z0-9-]{10,})\\b")
class ProductError(RuntimeError):
pass
class PolicyDenied(ProductError):
pass
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def sha256_text(text: str) -> str:
return sha256_bytes(text.encode("utf-8"))
def canonical_json(value: Any) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def redact(value: Any, depth: int = 0) -> Any:
if depth > 8:
return "[DEPTH_LIMIT]"
if isinstance(value, dict):
out = {}
for key, item in value.items():
out[str(key)] = "[REDACTED]" if SECRET_KEY_RE.search(str(key)) else redact(item, depth + 1)
return out
if isinstance(value, list):
return [redact(x, depth + 1) for x in value[:200]]
if isinstance(value, tuple):
return [redact(x, depth + 1) for x in value[:200]]
if isinstance(value, str):
clean = TOKEN_RE.sub("[REDACTED TOKEN]", value)
return clean[:8192] + ("[TRUNCATED]" if len(clean) > 8192 else "")
if isinstance(value, (int, float, bool)) or value is None:
return value
return redact(str(value), depth + 1)
@dataclass(frozen=True)
class PolicyDecision:
allowed: bool
mode: str
reason: str
class PolicyEngine:
VALID = {"allow", "deny", "require_authorized"}
def __init__(self, policy: dict[str, Any] | None = None):
policy = policy or {}
self.default = str(policy.get("default", "deny")).strip().lower()
if self.default not in self.VALID:
self.default = "deny"
self.tools = {}
for key, value in (policy.get("tools") or {}).items():
mode = str(value).strip().lower()
self.tools[str(key)] = mode if mode in self.VALID else "deny"
def decide(self, tool: str, authorized: bool = False) -> PolicyDecision:
mode = self.tools.get(tool, self.default)
if mode == "allow":
return PolicyDecision(True, mode, "POLICY_ALLOW")
if mode == "require_authorized" and authorized is True:
return PolicyDecision(True, mode, "AUTHORIZED_TRUE")
if mode == "require_authorized":
return PolicyDecision(False, mode, "AUTHORIZED_TRUE_REQUIRED")
return PolicyDecision(False, mode, "POLICY_DENY")
class AuditLog:
def __init__(self, path: Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
def verify(self) -> dict[str, Any]:
previous = "GENESIS"
count = 0
if not self.path.exists():
return {"ok": True, "entries": 0, "last_hash": previous}
try:
with self.path.open("r", encoding="utf-8") as handle:
for number, raw in enumerate(handle, 1):
raw = raw.strip()
if not raw:
continue
item = json.loads(raw)
event_hash = str(item.pop("event_hash"))
if item.get("prev_hash") != previous:
return {"ok": False, "entries": count, "line": number, "error": "PREV_HASH_MISMATCH"}
expected = sha256_text(previous + canonical_json(item))
if event_hash != expected:
return {"ok": False, "entries": count, "line": number, "error": "EVENT_HASH_MISMATCH"}
previous = event_hash
count += 1
except Exception as exc:
return {"ok": False, "entries": count, "error": type(exc).__name__}
return {"ok": True, "entries": count, "last_hash": previous}
def append(self, tool: str, status: str, request: Any = None, result: Any = None, principal: str = "chatgpt") -> dict[str, Any]:
with self._lock:
current = self.verify()
if not current.get("ok"):
raise ProductError("AUDIT_CHAIN_INVALID")
previous = str(current.get("last_hash", "GENESIS"))
event = {"event_id": uuid.uuid4().hex, "timestamp": utc_now(), "principal": str(principal), "tool": str(tool), "status": str(status), "request": redact(request), "result": redact(result), "prev_hash": previous}
event_hash = sha256_text(previous + canonical_json(event))
stored = dict(event)
stored["event_hash"] = event_hash
with self.path.open("a", encoding="utf-8") as handle:
handle.write(canonical_json(stored) + "\n")
handle.flush()
os.fsync(handle.fileno())
return {"event_id": event["event_id"], "event_hash": event_hash}
def status(self) -> dict[str, Any]:
value = self.verify()
value["path"] = str(self.path)
return value
def tail(self, limit: int = 20) -> dict[str, Any]:
limit = max(1, min(int(limit), 100))
if not self.path.exists():
return {"ok": True, "events": []}
events = []
with self.path.open("r", encoding="utf-8") as handle:
for raw in handle:
raw = raw.strip()
if not raw:
continue
try:
item = json.loads(raw)
except Exception:
continue
events.append({
"timestamp": item.get("timestamp"),
"tool": item.get("tool"),
"status": item.get("status"),
"event_hash": item.get("event_hash"),
"request": item.get("request"),
"result": item.get("result"),
})
return {"ok": True, "events": events[-limit:]}
class SafeFiles:
def __init__(self, roots: list[str]):
self.roots = [Path(x).expanduser().resolve(strict=False) for x in roots]
def _resolve(self, value: str) -> Path:
if not self.roots:
raise ProductError("NO_ALLOWED_ROOTS")
candidate = Path(value).expanduser()
if not candidate.is_absolute():
candidate = self.roots[0] / candidate
resolved = candidate.resolve(strict=False)
for root in self.roots:
try:
if os.path.commonpath([str(root), str(resolved)]) == str(root):
return resolved
except ValueError:
pass
raise ProductError("PATH_OUTSIDE_ALLOWED_ROOTS")
def list_dir(self, path: str = ".", limit: int = 200) -> dict[str, Any]:
target = self._resolve(path)
if not target.is_dir():
raise ProductError("DIRECTORY_NOT_FOUND")
limit = max(1, min(int(limit), 500))
items = []
for entry in sorted(target.iterdir(), key=lambda p: p.name.lower())[:limit]:
info = entry.lstat()
items.append({"name": entry.name, "type": "symlink" if entry.is_symlink() else ("dir" if entry.is_dir() else "file"), "size": info.st_size})
return {"path": str(target), "items": items, "count": len(items)}
def read_text(self, path: str, max_bytes: int = 65536) -> dict[str, Any]:
target = self._resolve(path)
if not target.is_file():
raise ProductError("FILE_NOT_FOUND")
max_bytes = max(1, min(int(max_bytes), 262144))
size = target.stat().st_size
if size > max_bytes:
raise ProductError("FILE_TOO_LARGE")
data = target.read_bytes()
if b"\\x00" in data:
raise ProductError("BINARY_FILE_NOT_ALLOWED")
text = data.decode("utf-8", errors="strict")
return {"path": str(target), "size": len(data), "sha256": sha256_bytes(data), "text": text}
def write_text(self, path: str, text: str, expected_sha256: str = "", max_bytes: int = 262144) -> dict[str, Any]:
target = self._resolve(path)
parent = target.parent
if not parent.is_dir():
raise ProductError("PARENT_DIRECTORY_NOT_FOUND")
if target.exists() and target.is_symlink():
raise ProductError("SYMLINK_WRITE_BLOCKED")
data = str(text).encode("utf-8")
if len(data) > int(max_bytes):
raise ProductError("WRITE_TOO_LARGE")
before_sha = ""
if target.exists():
before_sha = sha256_bytes(target.read_bytes())
if expected_sha256 and before_sha != expected_sha256:
raise ProductError("EXPECTED_SHA256_MISMATCH")
elif expected_sha256:
raise ProductError("EXPECTED_SHA256_WITHOUT_EXISTING_FILE")
fd, tmp_name = tempfile.mkstemp(prefix=".framebridge-", dir=str(parent))
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, target)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return {"path": str(target), "size": len(data), "sha256": sha256_bytes(data), "previous_sha256": before_sha}
class MemoryIndex:
ALLOWED_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml"}
BLOCKED_NAMES = {"rclone.conf", "runtime.env", ".env"}
BLOCKED_SUFFIXES = {".pem", ".key", ".p12", ".pfx"}
def __init__(self, db_path: Path, sources: list[str]):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.sources = [Path(x).expanduser().resolve(strict=False) for x in sources]
self._init_db()
def _connect(self):
con = sqlite3.connect(self.db_path)
con.row_factory = sqlite3.Row
return con
def _init_db(self):
with self._connect() as con:
con.execute("CREATE TABLE IF NOT EXISTS documents (source_path TEXT PRIMARY KEY, title TEXT NOT NULL, sha256 TEXT NOT NULL, mtime REAL NOT NULL, body TEXT NOT NULL, indexed_at TEXT NOT NULL)")
con.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(source_path UNINDEXED, title, body)")
def _safe_file(self, path: Path, root: Path) -> bool:
try:
resolved = path.resolve(strict=True)
if os.path.commonpath([str(root), str(resolved)]) != str(root):
return False
except Exception:
return False
if path.is_symlink() or not path.is_file():
return False
if path.name in self.BLOCKED_NAMES or path.suffix.lower() in self.BLOCKED_SUFFIXES:
return False
if path.suffix.lower() not in self.ALLOWED_SUFFIXES:
return False
try:
return path.stat().st_size <= 2_000_000
except OSError:
return False
def refresh(self) -> dict[str, Any]:
docs = []
for root in self.sources:
if not root.is_dir():
continue
for path in sorted(root.rglob("*")):
if not self._safe_file(path, root):
continue
try:
body = path.read_text(encoding="utf-8")
except Exception:
continue
data = body.encode("utf-8")
lines = body.splitlines()
title = lines[0].lstrip("# ").strip() if lines else path.name
docs.append({"source_path": str(path.resolve()), "title": title[:500] or path.name, "sha256": sha256_bytes(data), "mtime": path.stat().st_mtime, "body": body, "indexed_at": utc_now()})
with self._connect() as con:
con.execute("DELETE FROM documents")
con.execute("DELETE FROM docs_fts")
for item in docs:
con.execute("INSERT INTO documents(source_path,title,sha256,mtime,body,indexed_at) VALUES (?,?,?,?,?,?)", (item["source_path"], item["title"], item["sha256"], item["mtime"], item["body"], item["indexed_at"]))
con.execute("INSERT INTO docs_fts(source_path,title,body) VALUES (?,?,?)", (item["source_path"], item["title"], item["body"]))
return self.status()
def status(self) -> dict[str, Any]:
with self._connect() as con:
row = con.execute("SELECT COUNT(*) AS n, MAX(indexed_at) AS updated FROM documents").fetchone()
return {"db_path": str(self.db_path), "documents": int(row["n"]), "last_indexed_at": row["updated"], "sources": [str(x) for x in self.sources]}
def search(self, query: str, limit: int = 8) -> dict[str, Any]:
tokens = re.findall(r"[A-Za-z0-9_]{2,}", str(query))
if not tokens:
return {"query": str(query), "results": []}
expression = " AND ".join('"' + token.replace('"', '') + '"' for token in tokens[:12])
limit = max(1, min(int(limit), 20))
with self._connect() as con:
rows = con.execute("SELECT source_path,title,snippet(docs_fts,2,'[',']','...',24) AS snippet,bm25(docs_fts) AS rank FROM docs_fts WHERE docs_fts MATCH ? ORDER BY rank LIMIT ?", (expression, limit)).fetchall()
return {"query": str(query), "results": [{"source_path": row["source_path"], "title": row["title"], "snippet": row["snippet"], "rank": row["rank"]} for row in rows]}
def read(self, source_path: str, start_line: int = 1, max_lines: int = 120) -> dict[str, Any]:
start_line = max(1, int(start_line))
max_lines = max(1, min(int(max_lines), 400))
with self._connect() as con:
row = con.execute("SELECT source_path,title,sha256,body,indexed_at FROM documents WHERE source_path=?", (str(source_path),)).fetchone()
if row is None:
raise ProductError("MEMORY_DOCUMENT_NOT_FOUND")
lines = row["body"].splitlines()
selected = lines[start_line - 1:start_line - 1 + max_lines]
return {"source_path": row["source_path"], "title": row["title"], "sha256": row["sha256"], "indexed_at": row["indexed_at"], "start_line": start_line, "end_line": start_line + len(selected) - 1 if selected else start_line - 1, "text": "\n".join(selected)}
DEFAULT_POLICY = {"default": "deny", "tools": {"framebridge_product_status": "allow", "framebridge_files_list": "allow", "framebridge_files_read": "allow", "framebridge_files_write": "require_authorized", "framebridge_memory_status": "allow", "framebridge_memory_refresh": "require_authorized", "framebridge_memory_search": "allow", "framebridge_memory_read": "allow", "framebridge_audit_verify": "allow", "framebridge_exec": "require_authorized", "framebridge_cua": "require_authorized"}}
def load_config(path: Path) -> dict[str, Any]:
if path.is_file():
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ProductError("CONFIG_NOT_OBJECT")
return value
return {"node_name": os.uname().nodename, "state_dir": "~/.local/share/framebridge-linux-chatgpt", "allowed_roots": ["~/FrameBridge-Workspace"], "memory_sources": [], "policy": DEFAULT_POLICY}
class AlphaContext:
def __init__(self, config_path: Path):
self.config_path = Path(config_path).expanduser()
self.config = load_config(self.config_path)
self.node_name = str(self.config.get("node_name") or os.uname().nodename)
self.state_dir = Path(str(self.config.get("state_dir") or "~/.local/share/framebridge-linux-chatgpt")).expanduser()
self.state_dir.mkdir(parents=True, exist_ok=True)
self.policy = PolicyEngine(self.config.get("policy") or DEFAULT_POLICY)
self.files = SafeFiles(list(self.config.get("allowed_roots") or []))
self.memory = MemoryIndex(self.state_dir / "memory.sqlite3", list(self.config.get("memory_sources") or []))
self.audit = AuditLog(self.state_dir / "audit.jsonl")
@classmethod
def from_environment(cls) -> "AlphaContext":
raw = os.environ.get("FRAMEBRIDGE_ALPHA_CONFIG", "~/.config/framebridge-linux-chatgpt/config.json")
return cls(Path(raw).expanduser())
def status(self) -> dict[str, Any]:
return {"ok": True, "version": __version__, "node_name": self.node_name, "config_path": str(self.config_path), "state_dir": str(self.state_dir), "allowed_roots": [str(x) for x in self.files.roots], "memory": self.memory.status(), "audit": self.audit.status(), "policy_default": self.policy.default}
def guarded(self, tool: str, authorized: bool, request: dict[str, Any], fn):
decision = self.policy.decide(tool, authorized)
if not decision.allowed:
result = {"ok": False, "error": decision.reason, "policy_mode": decision.mode}
self.audit.append(tool, "DENY", request=request, result=result)
return result
try:
value = fn()
result = {"ok": True, "policy_mode": decision.mode, "result": value}
self.audit.append(tool, "PASS", request=request, result=result)
return result
except Exception as exc:
result = {"ok": False, "error": str(exc), "error_type": type(exc).__name__}
self.audit.append(tool, "ERROR", request=request, result=result)
return result
@@ -0,0 +1,67 @@
from __future__ import annotations
import sys
from .vendor.qrcodegen import QrCode
class TerminalQrError(RuntimeError):
pass
def _prepare(text, border, stream, require_tty):
if not isinstance(text,str) or not text: raise TerminalQrError('QR_TEXT_INVALID')
if not (2 <= int(border) <= 8): raise TerminalQrError('QR_BORDER_INVALID')
out=stream or sys.stdout
if require_tty and not getattr(out,'isatty',lambda:False)(): raise TerminalQrError('INTERACTIVE_TTY_REQUIRED')
return QrCode.encode_text(text,QrCode.Ecc.MEDIUM),out
def _dark(qr,border,x,y):
size=qr.get_size()
if x<border or y<border or x>=border+size or y>=border+size: return False
return qr.get_module(x-border,y-border)
def render_qr_compact(text, *, border=4, stream=None, require_tty=True):
qr,out=_prepare(text,border,stream,require_tty)
total=qr.get_size()+border*2
for y in range(0,total,2):
row=[]
for x in range(total):
top=_dark(qr,border,x,y)
bottom=_dark(qr,border,x,y+1) if y+1<total else False
if top and bottom: row.append('\x1b[40m ')
elif top: row.append('\x1b[30;47m▀')
elif bottom: row.append('\x1b[30;47m▄')
else: row.append('\x1b[47m ')
out.write(''.join(row)+'\x1b[0m\n')
out.flush()
def render_qr_mono(text, *, border=4, stream=None, require_tty=True):
qr,out=_prepare(text,border,stream,require_tty)
total=qr.get_size()+border*2
for y in range(0,total,2):
row=[]
for x in range(total):
top=_dark(qr,border,x,y)
bottom=_dark(qr,border,x,y+1) if y+1<total else False
row.append('█' if top and bottom else '▀' if top else '▄' if bottom else ' ')
out.write(''.join(row)+'\n')
out.flush()
def render_qr_ansi(text, *, border=4, stream=None, require_tty=True):
qr,out=_prepare(text,border,stream,require_tty)
size=qr.get_size(); black='\x1b[40m '; white='\x1b[47m '; reset='\x1b[0m'; quiet=white*(size+border*2)+reset+'\n'
for _ in range(border): out.write(quiet)
for y in range(size):
row=[white]*border
for x in range(size): row.append(black if qr.get_module(x,y) else white)
row.extend([white]*border); out.write(''.join(row)+reset+'\n')
for _ in range(border): out.write(quiet)
out.flush()
def render_qr_unicode(text, *, border=4, stream=None, require_tty=True):
qr,out=_prepare(text,border,stream,require_tty)
size=qr.get_size(); pad=' '*(size+border*2)
for _ in range(border): out.write(pad+'\n')
for y in range(size):
out.write(' '*border)
for x in range(size): out.write('██' if qr.get_module(x,y) else ' ')
out.write(' '*border+'\n')
for _ in range(border): out.write(pad+'\n')
out.flush()
@@ -0,0 +1,780 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import platform
import shutil
import socket
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
from .product_core import ProductError
MAX_OUTPUT = 32768
def _run(args: list[str], timeout: float = 5.0) -> dict[str, Any]:
try:
proc = subprocess.run(
args,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
shell=False,
)
return {
"ok": proc.returncode == 0,
"rc": proc.returncode,
"stdout": proc.stdout[:MAX_OUTPUT],
"stderr": proc.stderr[:4096],
}
except FileNotFoundError:
return {"ok": False, "error": "COMMAND_NOT_FOUND"}
except subprocess.TimeoutExpired:
return {"ok": False, "error": "COMMAND_TIMEOUT"}
except Exception as exc:
return {"ok": False, "error": type(exc).__name__}
def _os_release() -> dict[str, str]:
path = Path("/etc/os-release")
out: dict[str, str] = {}
if not path.is_file():
return out
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if "=" not in line:
continue
key, value = line.split("=", 1)
out[key] = value.strip().strip('"')
return out
def system_status(args: dict[str, Any]) -> dict[str, Any]:
osr = _os_release()
uptime = _run(["uptime", "-p"]) if shutil.which("uptime") else {"ok": False}
return {
"hostname": socket.gethostname(),
"kernel": platform.release(),
"architecture": platform.machine(),
"os_id": osr.get("ID"),
"os_name": osr.get("PRETTY_NAME"),
"uptime": uptime.get("stdout", "").strip(),
}
def hardware_summary(args: dict[str, Any]) -> dict[str, Any]:
cpu_model = ""
cpuinfo = Path("/proc/cpuinfo")
if cpuinfo.is_file():
for line in cpuinfo.read_text(errors="replace").splitlines():
if line.lower().startswith("model name") and ":" in line:
cpu_model = line.split(":", 1)[1].strip()
break
mem_total_kib = None
meminfo = Path("/proc/meminfo")
if meminfo.is_file():
for line in meminfo.read_text(errors="replace").splitlines():
if line.startswith("MemTotal:"):
parts = line.split()
if len(parts) >= 2 and parts[1].isdigit():
mem_total_kib = int(parts[1])
break
return {
"cpu_model": cpu_model or None,
"logical_cpus": os.cpu_count(),
"memory_total_kib": mem_total_kib,
"architecture": platform.machine(),
}
def storage_inspect(args: dict[str, Any]) -> dict[str, Any]:
if not shutil.which("lsblk"):
return {"ok": False, "error": "LSBLK_NOT_FOUND"}
result = _run([
"lsblk",
"-J",
"-o",
"NAME,TYPE,SIZE,FSTYPE,MOUNTPOINTS,ROTA,MODEL",
])
if not result.get("ok"):
return result
try:
return {
"ok": True,
"blockdevices": json.loads(result["stdout"]).get("blockdevices", []),
}
except Exception:
return {"ok": False, "error": "LSBLK_JSON_INVALID"}
def network_inspect(args: dict[str, Any]) -> dict[str, Any]:
if not shutil.which("ip"):
return {"ok": False, "error": "IP_COMMAND_NOT_FOUND"}
addresses = _run(["ip", "-j", "address"])
routes = _run(["ip", "-j", "route"])
out: dict[str, Any] = {
"ok": bool(addresses.get("ok") and routes.get("ok")),
}
try:
out["addresses"] = json.loads(addresses.get("stdout", "[]"))
except Exception:
out["addresses"] = []
out["ok"] = False
try:
out["routes"] = json.loads(routes.get("stdout", "[]"))
except Exception:
out["routes"] = []
out["ok"] = False
return out
def service_status(args: dict[str, Any]) -> dict[str, Any]:
service = str(args.get("service") or "").strip()
if not service:
raise ProductError("SERVICE_REQUIRED")
if len(service) > 128:
raise ProductError("SERVICE_NAME_TOO_LONG")
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@_.-")
if any(ch not in allowed for ch in service):
raise ProductError("SERVICE_NAME_INVALID")
if not shutil.which("systemctl"):
return {"ok": False, "error": "SYSTEMCTL_NOT_FOUND"}
active = _run(["systemctl", "is-active", service])
enabled = _run(["systemctl", "is-enabled", service])
return {
"service": service,
"active": active.get("stdout", "").strip(),
"active_rc": active.get("rc"),
"enabled": enabled.get("stdout", "").strip(),
"enabled_rc": enabled.get("rc"),
}
PACKAGE_RE = re.compile(r"^[a-z0-9][a-z0-9+.-]{0,127}$")
SIMPLE_NAME_RE = re.compile(r"^[A-Za-z0-9@_.-]{1,128}$")
SAFE_TEXT_ROOTS = (
Path("/etc"),
Path("/usr/local/etc"),
Path("/var/log"),
Path("/var/lib/framebridge-seed"),
)
SAFE_TEXT_FILES = {
Path("/usr/lib/os-release"),
}
SENSITIVE_PATH_PARTS = (
"/etc/shadow",
"/etc/gshadow",
"/etc/sudoers",
"/etc/sudoers.d/",
"/etc/ssl/private/",
"/etc/wireguard/",
"/etc/openvpn/",
"/etc/NetworkManager/system-connections/",
"/etc/apt/auth.conf",
"/.ssh/",
)
SENSITIVE_NAME_RE = re.compile(
r"(?i)(?:^|[._-])(?:secret|token|password|passwd|credential|private[_-]?key|api[_-]?key)(?:$|[._-])"
)
SENSITIVE_ASSIGNMENT_RE = re.compile(
r"(?i)^\s*([A-Za-z0-9_.-]*(?:secret|token|password|passwd|api[_-]?key|"
r"private[_-]?key|cookie|authorization|credential)[A-Za-z0-9_.-]*)\s*[:=]"
)
BEARER_RE = re.compile(r"(?i)\bBearer\s+[^\s,;]+")
URL_USERINFO_RE = re.compile(r"(?i)(https?://)[^/@\s]+:[^/@\s]+@")
PRIVATE_KEY_BEGIN_RE = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
PRIVATE_KEY_END_RE = re.compile(r"-----END [A-Z0-9 ]*PRIVATE KEY-----")
def _bounded_int(value: Any, *, default: int, low: int, high: int, error: str) -> int:
if value is None:
return default
if type(value) is not int or not (low <= value <= high):
raise ProductError(error)
return value
def _simple_name(value: Any, error: str) -> str:
name = str(value or "").strip()
if not SIMPLE_NAME_RE.fullmatch(name):
raise ProductError(error)
return name
def _package_name(value: Any) -> str:
package = str(value or "").strip()
if not PACKAGE_RE.fullmatch(package):
raise ProductError("PACKAGE_NAME_INVALID")
return package
def _sanitize_text(text: str) -> str:
out: list[str] = []
in_key = False
for line in str(text).splitlines():
if PRIVATE_KEY_BEGIN_RE.search(line):
in_key = True
out.append("[REDACTED PRIVATE KEY BLOCK]")
continue
if in_key:
if PRIVATE_KEY_END_RE.search(line):
in_key = False
continue
m = SENSITIVE_ASSIGNMENT_RE.search(line)
if m:
out.append(f"{m.group(1)}=[REDACTED]")
continue
line = BEARER_RE.sub("Bearer [REDACTED]", line)
line = URL_USERINFO_RE.sub(r"\1[REDACTED]@", line)
out.append(line)
return "\n".join(out)
def _safe_text_path(raw: Any) -> Path:
value = str(raw or "").strip()
if not value or len(value) > 4096 or "\x00" in value or "\n" in value:
raise ProductError("PATH_INVALID")
src = Path(value)
if not src.is_absolute():
raise ProductError("PATH_ABSOLUTE_REQUIRED")
try:
resolved = src.resolve(strict=True)
except FileNotFoundError:
raise ProductError("PATH_NOT_FOUND") from None
except OSError:
raise ProductError("PATH_INVALID") from None
allowed = False
for root in SAFE_TEXT_ROOTS:
try:
if os.path.commonpath([str(root), str(resolved)]) == str(root):
allowed = True
break
except ValueError:
pass
if not allowed and resolved not in SAFE_TEXT_FILES:
raise ProductError("PATH_OUTSIDE_SAFE_READ_ROOTS")
lower = str(resolved).lower()
if any(part.lower() in lower for part in SENSITIVE_PATH_PARTS):
raise ProductError("SENSITIVE_PATH_FORBIDDEN")
if SENSITIVE_NAME_RE.search(resolved.name):
raise ProductError("SENSITIVE_PATH_FORBIDDEN")
if not resolved.is_file():
raise ProductError("PATH_NOT_REGULAR_FILE")
return resolved
def system_resources(args: dict[str, Any]) -> dict[str, Any]:
mem: dict[str, int] = {}
p = Path("/proc/meminfo")
if p.is_file():
for line in p.read_text(errors="replace").splitlines():
if ":" not in line:
continue
key, rest = line.split(":", 1)
fields = rest.strip().split()
if fields and fields[0].isdigit():
mem[key] = int(fields[0])
load = os.getloadavg() if hasattr(os, "getloadavg") else (None, None, None)
process_count = 0
proc = Path("/proc")
if proc.is_dir():
process_count = sum(1 for x in proc.iterdir() if x.name.isdigit())
return {
"load_1": load[0],
"load_5": load[1],
"load_15": load[2],
"memory_total_kib": mem.get("MemTotal"),
"memory_available_kib": mem.get("MemAvailable"),
"swap_total_kib": mem.get("SwapTotal"),
"swap_free_kib": mem.get("SwapFree"),
"process_count": process_count,
}
def system_processes(args: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_int(args.get("limit"), default=40, low=1, high=200, error="PROCESS_LIMIT_INVALID")
result = _run([
"ps", "-eo",
"pid=,ppid=,user=,stat=,pcpu=,pmem=,etime=,comm=",
"--sort=-pcpu",
])
if not result.get("ok"):
return result
rows = []
for raw in result.get("stdout", "").splitlines():
parts = raw.split(None, 7)
if len(parts) != 8:
continue
pid, ppid, user, state, cpu, mem, etime, comm = parts
rows.append({
"pid": int(pid) if pid.isdigit() else pid,
"ppid": int(ppid) if ppid.isdigit() else ppid,
"user": user,
"state": state,
"cpu_pct": cpu,
"mem_pct": mem,
"etime": etime,
"comm": comm,
})
if len(rows) >= limit:
break
return {"ok": True, "processes": rows, "limit": limit}
def system_time(args: dict[str, Any]) -> dict[str, Any]:
timezone = None
if shutil.which("timedatectl"):
r = _run(["timedatectl", "show", "-p", "Timezone", "--value"])
if r.get("ok"):
timezone = r.get("stdout", "").strip() or None
if timezone is None:
tz = Path("/etc/timezone")
if tz.is_file():
timezone = tz.read_text(errors="replace").strip() or None
return {
"local_time": datetime.now().astimezone().isoformat(),
"timezone": timezone,
}
def kernel_modules(args: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="MODULE_LIMIT_INVALID")
if not shutil.which("lsmod"):
return {"ok": False, "error": "LSMOD_NOT_FOUND"}
r = _run(["lsmod"])
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines()[1:]:
parts = line.split()
if len(parts) < 3:
continue
rows.append({
"module": parts[0],
"size": int(parts[1]) if parts[1].isdigit() else parts[1],
"used_by_count": int(parts[2]) if parts[2].isdigit() else parts[2],
})
if len(rows) >= limit:
break
return {"ok": True, "modules": rows, "limit": limit}
def storage_usage(args: dict[str, Any]) -> dict[str, Any]:
r = _run(["df", "-B1", "-P", "-T"])
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines()[1:]:
parts = line.split()
if len(parts) < 7:
continue
fs, fstype, total, used, avail, pct = parts[:6]
mount = " ".join(parts[6:])
rows.append({
"filesystem": fs,
"fstype": fstype,
"bytes_total": int(total) if total.isdigit() else total,
"bytes_used": int(used) if used.isdigit() else used,
"bytes_available": int(avail) if avail.isdigit() else avail,
"use_percent": pct,
"mountpoint": mount,
})
return {"ok": True, "filesystems": rows}
def storage_mounts(args: dict[str, Any]) -> dict[str, Any]:
if not shutil.which("findmnt"):
return {"ok": False, "error": "FINDMNT_NOT_FOUND"}
r = _run(["findmnt", "-J", "-o", "TARGET,SOURCE,FSTYPE"])
if not r.get("ok"):
return r
try:
return {"ok": True, "filesystems": json.loads(r["stdout"]).get("filesystems", [])}
except Exception:
return {"ok": False, "error": "FINDMNT_JSON_INVALID"}
def network_sockets(args: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="SOCKET_LIMIT_INVALID")
if not shutil.which("ss"):
return {"ok": False, "error": "SS_NOT_FOUND"}
r = _run(["ss", "-H", "-lntu"])
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines():
parts = line.split()
if len(parts) < 5:
continue
rows.append({
"netid": parts[0],
"state": parts[1],
"local": parts[4] if len(parts) > 4 else None,
"peer": parts[5] if len(parts) > 5 else None,
})
if len(rows) >= limit:
break
return {"ok": True, "listeners": rows, "limit": limit}
def network_dns(args: dict[str, Any]) -> dict[str, Any]:
path = Path("/etc/resolv.conf")
if not path.exists():
return {"ok": False, "error": "RESOLV_CONF_NOT_FOUND"}
try:
resolved = path.resolve(strict=True)
except Exception:
resolved = path
lines = []
try:
text = path.read_text(errors="replace")
except Exception as exc:
return {"ok": False, "error": type(exc).__name__}
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith(("nameserver ", "search ", "domain ", "options ")):
lines.append(stripped)
return {
"ok": True,
"path": str(path),
"resolved_path": str(resolved),
"directives": lines,
}
def package_status_read(args: dict[str, Any]) -> dict[str, Any]:
package = _package_name(args.get("package"))
r = _run(["dpkg-query", "-W", "-f=${Status}|${Version}|${Architecture}", package])
if not r.get("ok"):
return {"ok": True, "package": package, "installed": False, "version": None, "architecture": None}
out = r.get("stdout", "").strip()
parts = out.split("|")
installed = len(parts) >= 1 and parts[0] == "install ok installed"
return {
"ok": True,
"package": package,
"installed": installed,
"version": parts[1] if installed and len(parts) > 1 else None,
"architecture": parts[2] if installed and len(parts) > 2 else None,
}
def package_search(args: dict[str, Any]) -> dict[str, Any]:
query = str(args.get("query") or "").strip()
if not query or len(query) > 80:
raise ProductError("PACKAGE_QUERY_INVALID")
if not re.fullmatch(r"[A-Za-z0-9+._ -]+", query):
raise ProductError("PACKAGE_QUERY_INVALID")
limit = _bounded_int(args.get("limit"), default=30, low=1, high=100, error="PACKAGE_LIMIT_INVALID")
if not shutil.which("apt-cache"):
return {"ok": False, "error": "APT_CACHE_NOT_FOUND"}
r = _run(["apt-cache", "search", "--names-only", re.escape(query)], timeout=15.0)
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines():
if " - " in line:
name, desc = line.split(" - ", 1)
else:
name, desc = line, ""
rows.append({"package": name.strip(), "description": desc.strip()})
if len(rows) >= limit:
break
return {"ok": True, "query": query, "results": rows, "limit": limit}
def package_upgradable(args: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="PACKAGE_LIMIT_INVALID")
if not shutil.which("apt"):
return {"ok": False, "error": "APT_NOT_FOUND"}
r = _run(["apt", "list", "--upgradable"], timeout=20.0)
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines():
line = line.strip()
if not line or line.startswith("Listing...") or "/" not in line:
continue
first, *rest = line.split()
name, suite = first.split("/", 1)
version = rest[0] if rest else None
rows.append({"package": name, "suite": suite, "candidate_version": version})
if len(rows) >= limit:
break
return {"ok": True, "packages": rows, "limit": limit}
def service_list(args: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="SERVICE_LIMIT_INVALID")
if not shutil.which("systemctl"):
return {"ok": False, "error": "SYSTEMCTL_NOT_FOUND"}
r = _run([
"systemctl", "list-units", "--type=service", "--all",
"--no-legend", "--no-pager", "--plain",
], timeout=10.0)
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines():
parts = line.split(None, 4)
if len(parts) < 4:
continue
rows.append({
"unit": parts[0],
"load": parts[1],
"active": parts[2],
"sub": parts[3],
"description": parts[4] if len(parts) > 4 else "",
})
if len(rows) >= limit:
break
return {"ok": True, "services": rows, "limit": limit}
def journal_service(args: dict[str, Any]) -> dict[str, Any]:
service = _simple_name(args.get("service"), "SERVICE_NAME_INVALID")
lines = _bounded_int(args.get("lines"), default=80, low=1, high=500, error="JOURNAL_LINES_INVALID")
if not shutil.which("journalctl"):
return {"ok": False, "error": "JOURNALCTL_NOT_FOUND"}
r = _run([
"journalctl", "-u", service, "-n", str(lines),
"--no-pager", "--output=short-iso-precise",
], timeout=10.0)
if not r.get("ok"):
return r
return {
"ok": True,
"service": service,
"lines": lines,
"text": _sanitize_text(r.get("stdout", ""))[:MAX_OUTPUT],
}
def user_list(args: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_int(args.get("limit"), default=200, low=1, high=1000, error="USER_LIMIT_INVALID")
r = _run(["getent", "passwd"])
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines():
parts = line.split(":")
if len(parts) != 7:
continue
name, _pw, uid, gid, gecos, home, shell = parts
rows.append({
"name": name,
"uid": int(uid) if uid.isdigit() else uid,
"gid": int(gid) if gid.isdigit() else gid,
"gecos": gecos,
"home": home,
"shell": shell,
})
if len(rows) >= limit:
break
return {"ok": True, "users": rows, "limit": limit}
def group_list(args: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_int(args.get("limit"), default=200, low=1, high=1000, error="GROUP_LIMIT_INVALID")
r = _run(["getent", "group"])
if not r.get("ok"):
return r
rows = []
for line in r.get("stdout", "").splitlines():
parts = line.split(":")
if len(parts) != 4:
continue
name, _pw, gid, members = parts
rows.append({
"name": name,
"gid": int(gid) if gid.isdigit() else gid,
"members": [x for x in members.split(",") if x],
})
if len(rows) >= limit:
break
return {"ok": True, "groups": rows, "limit": limit}
def filesystem_stat(args: dict[str, Any]) -> dict[str, Any]:
raw = str(args.get("path") or "").strip()
if not raw or len(raw) > 4096 or "\x00" in raw or "\n" in raw:
raise ProductError("PATH_INVALID")
path = Path(raw)
if not path.is_absolute():
raise ProductError("PATH_ABSOLUTE_REQUIRED")
try:
st = path.lstat()
except FileNotFoundError:
return {"ok": True, "path": raw, "exists": False}
return {
"ok": True,
"path": raw,
"exists": True,
"is_file": path.is_file(),
"is_dir": path.is_dir(),
"is_symlink": path.is_symlink(),
"size": st.st_size,
"mode": oct(st.st_mode & 0o7777),
"uid": st.st_uid,
"gid": st.st_gid,
"mtime_ns": st.st_mtime_ns,
}
def filesystem_list(args: dict[str, Any]) -> dict[str, Any]:
raw = str(args.get("path") or "").strip()
if not raw or len(raw) > 4096 or "\x00" in raw or "\n" in raw:
raise ProductError("PATH_INVALID")
path = Path(raw)
if not path.is_absolute():
raise ProductError("PATH_ABSOLUTE_REQUIRED")
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="FILE_LIST_LIMIT_INVALID")
if not path.is_dir():
raise ProductError("DIRECTORY_NOT_FOUND")
rows = []
try:
children = sorted(path.iterdir(), key=lambda x: x.name)
except PermissionError:
raise ProductError("DIRECTORY_PERMISSION_DENIED") from None
for child in children[:limit]:
try:
st = child.lstat()
rows.append({
"name": child.name,
"is_file": child.is_file(),
"is_dir": child.is_dir(),
"is_symlink": child.is_symlink(),
"size": st.st_size,
"mode": oct(st.st_mode & 0o7777),
})
except OSError:
rows.append({"name": child.name, "error": "STAT_FAILED"})
return {"ok": True, "path": raw, "entries": rows, "limit": limit}
def filesystem_hash(args: dict[str, Any]) -> dict[str, Any]:
raw = str(args.get("path") or "").strip()
if not raw or len(raw) > 4096 or "\x00" in raw or "\n" in raw:
raise ProductError("PATH_INVALID")
path = Path(raw)
if not path.is_absolute():
raise ProductError("PATH_ABSOLUTE_REQUIRED")
if not path.is_file():
raise ProductError("PATH_NOT_REGULAR_FILE")
size = path.stat().st_size
if size > 16 * 1024 * 1024:
raise ProductError("FILE_TOO_LARGE_TO_HASH")
h = hashlib.sha256()
try:
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
except PermissionError:
raise ProductError("FILE_PERMISSION_DENIED") from None
return {"ok": True, "path": raw, "size": size, "sha256": h.hexdigest()}
def filesystem_read_text(args: dict[str, Any]) -> dict[str, Any]:
path = _safe_text_path(args.get("path"))
max_bytes = _bounded_int(args.get("max_bytes"), default=16384, low=1, high=32768, error="MAX_BYTES_INVALID")
try:
data = path.read_bytes()
except PermissionError:
raise ProductError("FILE_PERMISSION_DENIED") from None
if len(data) > max_bytes:
data = data[:max_bytes]
truncated = True
else:
truncated = False
if b"\x00" in data:
raise ProductError("BINARY_FILE_NOT_ALLOWED")
text = data.decode("utf-8", errors="replace")
return {
"ok": True,
"path": str(path),
"bytes_returned": len(data),
"truncated": truncated,
"text": _sanitize_text(text),
}
CAPABILITIES = {
"system.status": system_status,
"system.resources": system_resources,
"system.processes": system_processes,
"system.time": system_time,
"hardware.summary": hardware_summary,
"kernel.modules": kernel_modules,
"storage.inspect": storage_inspect,
"storage.usage": storage_usage,
"storage.mounts": storage_mounts,
"network.inspect": network_inspect,
"network.sockets": network_sockets,
"network.dns": network_dns,
"package.status": package_status_read,
"package.search": package_search,
"package.upgradable": package_upgradable,
"service.status": service_status,
"service.list": service_list,
"journal.service": journal_service,
"user.list": user_list,
"group.list": group_list,
"filesystem.stat": filesystem_stat,
"filesystem.list": filesystem_list,
"filesystem.hash": filesystem_hash,
"filesystem.read_text": filesystem_read_text,
}
def list_capabilities() -> list[dict[str, Any]]:
return [
{"name": "system.status", "risk": "READONLY"},
{"name": "system.resources", "risk": "READONLY"},
{"name": "system.processes", "risk": "READONLY", "args": {"limit": "int:1..200"}},
{"name": "system.time", "risk": "READONLY"},
{"name": "hardware.summary", "risk": "READONLY"},
{"name": "kernel.modules", "risk": "READONLY", "args": {"limit": "int:1..500"}},
{"name": "storage.inspect", "risk": "READONLY"},
{"name": "storage.usage", "risk": "READONLY"},
{"name": "storage.mounts", "risk": "READONLY"},
{"name": "network.inspect", "risk": "READONLY"},
{"name": "network.sockets", "risk": "READONLY", "args": {"limit": "int:1..500"}},
{"name": "network.dns", "risk": "READONLY"},
{"name": "package.status", "risk": "READONLY", "args": {"package": "debian-package-name"}},
{"name": "package.search", "risk": "READONLY", "args": {"query": "string", "limit": "int:1..100"}},
{"name": "package.upgradable", "risk": "READONLY", "args": {"limit": "int:1..500"}},
{"name": "service.status", "risk": "READONLY", "args": {"service": "systemd-unit"}},
{"name": "service.list", "risk": "READONLY", "args": {"limit": "int:1..500"}},
{"name": "journal.service", "risk": "READONLY", "args": {"service": "systemd-unit", "lines": "int:1..500"}},
{"name": "user.list", "risk": "READONLY", "args": {"limit": "int:1..1000"}},
{"name": "group.list", "risk": "READONLY", "args": {"limit": "int:1..1000"}},
{"name": "filesystem.stat", "risk": "READONLY", "args": {"path": "absolute-path"}},
{"name": "filesystem.list", "risk": "READONLY", "args": {"path": "absolute-directory", "limit": "int:1..500"}},
{"name": "filesystem.hash", "risk": "READONLY", "args": {"path": "absolute-file<=16MiB"}},
{"name": "filesystem.read_text", "risk": "READONLY", "args": {"path": "safe-absolute-text-path", "max_bytes": "int:1..32768"}},
]
def execute_capability(name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
name = str(name).strip()
fn = CAPABILITIES.get(name)
if fn is None:
raise ProductError("CAPABILITY_NOT_ALLOWED")
args = args or {}
if not isinstance(args, dict):
raise ProductError("CAPABILITY_ARGS_NOT_OBJECT")
return fn(args)
@@ -0,0 +1,572 @@
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import time
from .product_core import ProductError
from .seed_core import SEED_VERSION, SeedContext, atomic_write_json
from .classic_tty import execute_frame, render_result, render_snapshot
from .console_tty import run_console
from .plan_engine import PlanEngine
from .memory_store import MemoryStore
from .first_run import SetupChoice, detect_system, provider_instructions, render_privilege_explanation, save_setup_config, setup_summary
from .console_chat import run_console_chat
from .qr_terminal import render_qr_compact, render_qr_mono, render_qr_ansi, render_qr_unicode, TerminalQrError
from .pairing import FBPAIR_PROTOCOL, PairingStore, pairing_qr_payload, public_pairing_view
from .pairing_client import PairingClientError, poll_pairing, start_pairing
from .network_security import inspect_network, pairing_policy, forwarding_policy
from .pairing_window import open_pairing_window, pairing_window_status, close_pairing_window
from .network_lifecycle import reconcile_once, reconcile_broker_cancel
def emit(value) -> None:
print(json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False))
def _sync_pairing_config(ctx: SeedContext, view: dict) -> dict:
config=ctx.load_config() or {}
pairing=config.get("pairing") or {}
pairing.update({
"protocol":FBPAIR_PROTOCOL,
"state":view.get("state","not_paired"),
"transport":"broker_outbound" if view.get("state")!="not_paired" else "unpaired",
})
for key in (
"pair_id","broker_base_url","account_issuer",
"account_subject_sha256","revocation_id","paired_at",
"broker_confirm_required","broker_confirmed_at",
"broker_cancel_required","broker_cancelled_at","broker_cancel_state",
):
if view.get(key) is not None:
pairing[key]=view.get(key)
config["pairing"]=pairing
config["connection_mode"]="PAIRED" if view.get("state")=="paired" else "PAIRING"
setup=config.get("setup")
if isinstance(setup,dict):
setup["pairing_state"]=view.get("state","not_paired")
atomic_write_json(ctx.config_path,config)
return config
def _render_pair_qr(view: dict, mode: str) -> None:
payload=pairing_qr_payload(view)
if not sys.stdout.isatty():
return
if mode=="compact":
render_qr_compact(payload)
elif mode=="mono":
render_qr_mono(payload)
elif mode=="unicode":
render_qr_unicode(payload)
else:
render_qr_ansi(payload)
def main() -> None:
parser = argparse.ArgumentParser(
prog="framebridge",
description="FrameBridge Seed - Linux text-mode bootstrap",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {SEED_VERSION}",
)
sub = parser.add_subparsers(dest="command", required=True)
init = sub.add_parser("init", help="Initialize node identity and provider")
init.add_argument("--provider", default="chatgpt")
init.add_argument("--hostname", default="")
sub.add_parser("identity", help="Show persistent node identity")
sub.add_parser("doctor", help="Run READONLY bootstrap diagnostics")
sub.add_parser("status", help="Show Seed status")
sub.add_parser("fingerprint", help="Show non-secret node fingerprint")
sub.add_parser("snapshot", help="Generate Classic AI context snapshot")
sub.add_parser("console", help="Open FrameBridge Classic in the current TTY")
classic = sub.add_parser("classic", help="Process one Classic frame from stdin or file")
classic.add_argument("--file", default="-")
setup = sub.add_parser("setup", help="Run the FrameBridge first-run setup wizard")
setup.add_argument("--provider", choices=("chatgpt","ollama","lmstudio","other"), default="")
setup.add_argument("--privilege-mode", choices=("totp","sudo_tty"), default="")
setup.add_argument("--language", default="en")
setup.add_argument("--non-interactive", action="store_true")
sub.add_parser("setup-status", help="Show first-run setup state")
net = sub.add_parser("net", help="Inspect FrameBridge network/firewall policy")
net_sub = net.add_subparsers(dest="net_command", required=True)
net_sub.add_parser("status", help="Show READONLY FBNET interface/firewall status")
p_net_policy = net_sub.add_parser("pairing-policy", help="Render pairing-window policy without applying changes")
p_net_policy.add_argument("--port", type=int, default=18765)
p_net_forward = net_sub.add_parser("forwarding-policy", help="Render typed runtime forwarding policy without applying changes")
p_net_forward.add_argument("--in-interface", required=True)
p_net_forward.add_argument("--out-interface", required=True)
p_net_forward.add_argument("--source-subnet", required=True)
p_net_forward.add_argument("--destination-subnet", required=True)
p_net_forward.add_argument("--protocol", choices=("any","tcp","udp"), default="any")
p_net_forward.add_argument("--destination-port", type=int, default=0)
p_net_forward.add_argument("--nat", action="store_true")
p_net_forward.add_argument("--lifetime", choices=("runtime",), default="runtime")
p_window = net_sub.add_parser("pairing-window", help="Manage the short-lived local LAN pairing firewall window")
p_window_sub = p_window.add_subparsers(dest="pairing_window_command", required=True)
p_window_open = p_window_sub.add_parser("open", help="Open a scoped runtime pairing window after local authorization")
p_window_open.add_argument("--port", type=int, default=18765)
p_window_open.add_argument("--ttl", type=int, default=600)
p_window_sub.add_parser("status", help="Show the current pairing-window transaction")
p_window_close = p_window_sub.add_parser("close", help="Close the active pairing window")
p_window_close.add_argument("--reason", choices=("user","cancelled","paired","denied","network_change","service_stop","reconcile"), default="user")
net_sub.add_parser("reconcile", help="Re-evaluate an active pairing window against current network identity")
pair = sub.add_parser("pair", help="Pair this Linux node with an authorized AI account")
pair_sub = pair.add_subparsers(dest="pair_command", required=True)
p_pair_start = pair_sub.add_parser("start", help="Start FBPAIR/1 device-style pairing")
p_pair_start.add_argument("--broker", required=True)
p_pair_start.add_argument("--provider", default="")
p_pair_start.add_argument("--mode", choices=("compact","mono","ansi","unicode"), default="compact")
p_pair_start.add_argument("--dev-insecure", action="store_true")
pair_sub.add_parser("status", help="Show public pairing status")
pair_sub.add_parser("cancel", help="Cancel the current pending pairing request")
p_pair_poll = pair_sub.add_parser("poll", help="Poll pairing broker once")
p_pair_poll.add_argument("--dev-insecure", action="store_true")
p_pair_wait = pair_sub.add_parser("wait", help="Wait for human account authorization")
p_pair_wait.add_argument("--dev-insecure", action="store_true")
p_pair_wait.add_argument("--max-wait", type=int, default=600)
p_pair_qr = pair_sub.add_parser("qr", help="Render the current pairing QR")
p_pair_qr.add_argument("--mode", choices=("compact","mono","ansi","unicode"), default="compact")
sub.add_parser("console-chat", help="Open the primary FrameBridge conversational console")
sub.add_parser("agent-manifest", help="Return the mandatory compact AI bootstrap manifest")
memory = sub.add_parser("memory", help="Manage mandatory FrameBridge binary technical memory")
memory_sub = memory.add_subparsers(dest="memory_command", required=True)
memory_sub.add_parser("init", help="Initialize or repair node binary memory")
memory_sub.add_parser("bootstrap", help="Return compact mandatory AI memory bootstrap")
p_mem_query = memory_sub.add_parser("query", help="Query a normalized memory domain")
p_mem_query.add_argument("--domain", default="summary")
p_mem_query.add_argument("--limit", type=int, default=20)
p_mem_append = memory_sub.add_parser("append", help="Append one normalized memory record")
p_mem_append.add_argument("--record-type", required=True, choices=("change","incident","decision","artifact","recovery","certification","note"))
p_mem_append.add_argument("--json-stdin", action="store_true", required=True)
memory_sub.add_parser("checkpoint", help="Refresh the compact current-state checkpoint")
memory_sub.add_parser("stats", help="Show binary memory statistics")
plan = sub.add_parser("plan", help="Manage local CHANGE plans")
plan_sub = plan.add_subparsers(dest="plan_command", required=True)
p_import = plan_sub.add_parser("import")
p_import.add_argument("--file", required=True)
plan_sub.add_parser("import-stdin")
p_show = plan_sub.add_parser("show")
p_show.add_argument("plan_id")
p_validate = plan_sub.add_parser("validate")
p_validate.add_argument("plan_id")
p_approve = plan_sub.add_parser("approve")
p_approve.add_argument("plan_id")
p_approve_system = plan_sub.add_parser("approve-system")
p_approve_system.add_argument("plan_id")
p_apply = plan_sub.add_parser("apply")
p_apply.add_argument("plan_id")
p_approve_rollback = plan_sub.add_parser("approve-rollback")
p_approve_rollback.add_argument("plan_id")
p_remote_challenge = plan_sub.add_parser("remote-challenge")
p_remote_challenge.add_argument("plan_id")
p_remote_challenge.add_argument("--rollback", action="store_true")
p_remote_challenge.add_argument("--ttl", type=int, default=300)
p_remote_approve = plan_sub.add_parser("remote-approve")
p_remote_approve.add_argument("plan_id")
p_remote_approve.add_argument("--challenge-id", required=True)
p_remote_approve.add_argument("--key-id", required=True)
p_remote_approve.add_argument("--signature-b64", required=True)
p_remote_approve.add_argument("--rollback", action="store_true")
p_totp_enroll = plan_sub.add_parser("totp-enroll")
p_totp_enroll.add_argument("--key-id", required=True)
p_totp_enroll.add_argument("--node-label", required=True)
p_totp_enroll.add_argument("--issuer", default="FrameBridge")
p_totp_enroll.add_argument("--mode", choices=("compact","mono","ansi","unicode"), default="compact")
p_totp_challenge = plan_sub.add_parser("totp-challenge")
p_totp_challenge.add_argument("plan_id")
p_totp_challenge.add_argument("--key-id", required=True)
p_totp_challenge.add_argument("--rollback", action="store_true")
p_totp_challenge.add_argument("--ttl", type=int, default=120)
p_totp_approve = plan_sub.add_parser("totp-approve")
p_totp_approve.add_argument("plan_id")
p_totp_approve.add_argument("--challenge-id", required=True)
p_totp_approve.add_argument("--key-id", required=True)
p_totp_approve.add_argument("--rollback", action="store_true")
p_rollback = plan_sub.add_parser("rollback")
p_rollback.add_argument("plan_id")
plan_sub.add_parser("list")
sub.add_parser("audit", help="Verify the audit chain")
args = parser.parse_args()
ctx = SeedContext()
try:
if args.command == "init":
emit(ctx.init(args.provider, args.hostname))
elif args.command == "identity":
emit(ctx.identity())
elif args.command == "doctor":
emit(ctx.doctor())
elif args.command == "status":
emit(ctx.status())
elif args.command == "fingerprint":
emit(ctx.fingerprint())
elif args.command == "snapshot":
print(render_snapshot(ctx))
elif args.command == "setup":
if not (ctx.load_identity() and ctx.load_config()):
emit(ctx.init("chatgpt", ""))
detected = detect_system()
provider = args.provider
privilege = args.privilege_mode
language = args.language or "en"
if args.non_interactive:
if not provider or not privilege:
raise ProductError("SETUP_NON_INTERACTIVE_OPTIONS_REQUIRED")
else:
print("\nFrameBridge First-Run Setup\n")
print("System detected:")
sysinfo = detected.get("system") or {}
hw = detected.get("hardware") or {}
print(f" OS: {sysinfo.get('os_name')} / kernel {sysinfo.get('kernel')}")
print(f" CPU: {hw.get('cpu_model')}")
print(f" RAM: {hw.get('memory_total_kib')} KiB")
print("\nPrivileged authorization:")
print(" 1) Authenticator / QR TOTP")
print(" 2) Local sudo password")
if not privilege:
answer=input("Select [1/2]: ").strip()
privilege="totp" if answer=="1" else "sudo_tty" if answer=="2" else ""
print("\nAI provider:")
print(" 1) ChatGPT")
print(" 2) Ollama")
print(" 3) LM Studio")
print(" 4) Other")
if not provider:
answer=input("Select [1-4]: ").strip()
provider={"1":"chatgpt","2":"ollama","3":"lmstudio","4":"other"}.get(answer,"")
choice=SetupChoice(privilege,provider,language)
config=save_setup_config(ctx,choice)
mem=MemoryStore(ctx).initialize()
print("\n"+"\n".join(render_privilege_explanation(choice.privilege_mode)))
print("\n"+"\n".join(provider_instructions(choice.provider)))
print("\nFrameBridge setup complete.")
print("Run: framebridge console-chat")
emit({"ok":True,"setup":config.get("setup"),"memory":mem,"detected":detected})
elif args.command == "setup-status":
emit(setup_summary(ctx))
elif args.command == "net":
if args.net_command == "status":
emit(inspect_network())
elif args.net_command == "pairing-policy":
emit(pairing_policy(args.port))
elif args.net_command == "forwarding-policy":
emit(forwarding_policy(
ingress_interface=args.in_interface,
egress_interface=args.out_interface,
source_subnet=args.source_subnet,
destination_subnet=args.destination_subnet,
protocol=args.protocol,
destination_port=args.destination_port,
nat=args.nat,
lifetime=args.lifetime,
))
elif args.net_command == "pairing-window":
if args.pairing_window_command == "open":
MemoryStore(ctx).require_ready()
result=open_pairing_window(args.port,args.ttl)
ctx.audit.append(
"network.pairing_window.open","PASS",
request={"port":args.port,"ttl_seconds":args.ttl},
result={
"window_id":result.get("window_id"),
"interface":result.get("interface"),
"source_subnet":result.get("source_subnet"),
"destination_address":result.get("destination_address"),
"expires_at":result.get("expires_at"),
},
principal="local-operator",
)
MemoryStore(ctx).append(
"change",
{
"operation":"network.pairing_window.open",
"window_id":result.get("window_id"),
"interface":result.get("interface"),
"source_subnet":result.get("source_subnet"),
"destination_address":result.get("destination_address"),
"port":result.get("port"),
"expires_at":result.get("expires_at"),
},
source="operator",
)
emit(result)
elif args.pairing_window_command == "status":
emit(pairing_window_status())
elif args.pairing_window_command == "close":
MemoryStore(ctx).require_ready()
before=pairing_window_status()
result=close_pairing_window(args.reason)
ctx.audit.append(
"network.pairing_window.close","PASS",
request={"reason":args.reason,"window_id":before.get("window_id")},
result={"state":result.get("state"),"rule_present":result.get("rule_present")},
principal="local-operator",
)
MemoryStore(ctx).append(
"recovery",
{
"operation":"network.pairing_window.close",
"reason":args.reason,
"window_id":before.get("window_id"),
"state":result.get("state"),
},
source="operator",
)
emit(result)
elif args.net_command == "reconcile":
emit(reconcile_once(ctx=ctx))
elif args.command == "pair":
if not (ctx.load_identity() and ctx.load_config()):
raise ProductError("PAIR_NODE_NOT_INITIALIZED")
pair_store=PairingStore(ctx.state_dir)
if args.pair_command == "status":
emit(public_pairing_view(pair_store.load()))
elif args.pair_command == "start":
MemoryStore(ctx).require_ready()
identity=ctx.load_identity() or {}
config=ctx.load_config() or {}
manifest_path=ctx.state_dir/"memory"/"manifest.fbam"
if not manifest_path.is_file():
raise ProductError("PAIR_CAPABILITY_MANIFEST_MISSING")
manifest_sha=hashlib.sha256(manifest_path.read_bytes()).hexdigest()
provider=(args.provider or config.get("provider") or "chatgpt").strip().lower()
try:
view=start_pairing(
state_dir=ctx.state_dir,
node_id=str(identity.get("node_id") or ""),
node_label=str(identity.get("hostname") or ""),
node_host_id=str(identity.get("node_host_id") or ""),
public_jwk=identity.get("node_public_jwk") or {},
provider=provider,
capability_manifest_sha256=manifest_sha,
broker_base_url=args.broker,
allow_insecure_dev=args.dev_insecure,
)
except PairingClientError as exc:
raise ProductError(str(exc)) from exc
_sync_pairing_config(ctx,view)
ctx.audit.append(
"pair.request","PASS",
request={
"protocol":FBPAIR_PROTOCOL,
"pair_id":view.get("pair_id"),
"provider":provider,
"node_host_id":identity.get("node_host_id"),
},
result={"state":view.get("state")},
principal="local-operator",
)
print("\nFrameBridge AI pairing")
print("Open: "+str(view.get("verification_uri_complete") or view.get("verification_uri") or ""))
print("Code: "+str(view.get("user_code")))
print("Expires in: "+str(view.get("expires_in"))+" seconds")
_render_pair_qr(view,args.mode)
emit(view)
elif args.pair_command == "cancel":
try:
view=pair_store.cancel_pending()
except Exception as exc:
raise ProductError(str(exc)) from exc
_sync_pairing_config(ctx,view)
broker_cancel=reconcile_broker_cancel(ctx)
view=public_pairing_view(pair_store.load())
_sync_pairing_config(ctx,view)
ctx.audit.append(
"pair.cancel","PASS",
request={
"protocol":FBPAIR_PROTOCOL,
"pair_id":view.get("pair_id"),
},
result={"state":view.get("state"),"broker_cancel_state":broker_cancel.get("state")},
principal="local-operator",
)
emit(view)
elif args.pair_command == "poll":
before=pair_store.load()
try:
view=poll_pairing(
state_dir=ctx.state_dir,
allow_insecure_dev=args.dev_insecure,
)
except PairingClientError as exc:
raise ProductError(str(exc)) from exc
_sync_pairing_config(ctx,view)
if view.get("state") != before.get("state"):
ctx.audit.append(
"pair."+str(view.get("state")),"PASS",
request={
"protocol":FBPAIR_PROTOCOL,
"pair_id":view.get("pair_id"),
},
result={
"state":view.get("state"),
"provider":view.get("provider"),
},
principal="pairing-broker",
)
if view.get("state")=="paired":
MemoryStore(ctx).checkpoint()
emit(view)
elif args.pair_command == "wait":
max_wait=int(args.max_wait)
if max_wait<1 or max_wait>1800:
raise ProductError("PAIR_WAIT_TIMEOUT_INVALID")
started=time.monotonic()
view=public_pairing_view(pair_store.load())
while (
view.get("state")=="pair_requested"
or (view.get("state")=="paired" and view.get("broker_confirm_required") is True)
):
if time.monotonic()-started>=max_wait:
raise ProductError("PAIR_WAIT_TIMEOUT")
try:
view=poll_pairing(
state_dir=ctx.state_dir,
allow_insecure_dev=args.dev_insecure,
)
except PairingClientError as exc:
raise ProductError(str(exc)) from exc
if view.get("state")=="paired" and view.get("broker_confirm_required") is not True:
break
if view.get("state") not in {"pair_requested","paired"}:
break
retry=int(view.get("retry_after") or view.get("interval") or 2)
time.sleep(max(1,min(60,retry)))
_sync_pairing_config(ctx,view)
ctx.audit.append(
"pair."+str(view.get("state")),"PASS",
request={
"protocol":FBPAIR_PROTOCOL,
"pair_id":view.get("pair_id"),
},
result={
"state":view.get("state"),
"provider":view.get("provider"),
},
principal="pairing-broker",
)
if view.get("state")=="paired":
MemoryStore(ctx).checkpoint()
emit(view)
if view.get("state")!="paired" or view.get("broker_confirm_required") is True:
raise SystemExit(3)
elif args.pair_command == "qr":
view=public_pairing_view(pair_store.load())
_render_pair_qr(view,args.mode)
emit(view)
elif args.command == "console-chat":
raise SystemExit(run_console_chat(ctx))
elif args.command == "agent-manifest":
emit(MemoryStore(ctx).bootstrap())
elif args.command == "memory":
store = MemoryStore(ctx)
if args.memory_command == "init":
value = store.initialize()
ctx.audit.append("memory.init", "PASS", request={"protocol":"FBM/1"}, result={"required":True,"mode":"read_write"}, principal="local-operator")
emit(value)
elif args.memory_command == "bootstrap":
emit(store.bootstrap())
elif args.memory_command == "query":
emit(store.query(args.domain, args.limit))
elif args.memory_command == "append":
try:
value = json.load(sys.stdin)
except Exception:
raise ProductError("MEMORY_APPEND_JSON_INVALID")
if not isinstance(value, dict):
raise ProductError("MEMORY_APPEND_JSON_NOT_OBJECT")
result = store.append(args.record_type, value, source="agent")
ctx.audit.append("memory.append", "PASS", request={"record_type":args.record_type}, result={"event_number":result.get("event_number"),"event_sha256":result.get("event_sha256")}, principal="ai-agent")
store.checkpoint()
emit(result)
elif args.memory_command == "checkpoint":
value = store.checkpoint()
ctx.audit.append("memory.checkpoint", "PASS", request={}, result={"state_sha256":value.get("state_sha256"),"manifest_sha256":value.get("manifest_sha256")}, principal="local-operator")
emit(value)
elif args.memory_command == "stats":
emit(store.stats())
elif args.command == "console":
raise SystemExit(run_console(ctx))
elif args.command == "classic":
if args.file == "-":
text = sys.stdin.read()
else:
with open(args.file, "r", encoding="utf-8") as handle:
text = handle.read()
print(render_result(execute_frame(ctx, text)))
elif args.command == "plan":
engine = PlanEngine(ctx)
if args.plan_command == "import":
emit(engine.import_file(args.file))
elif args.plan_command == "import-stdin":
try:
value = json.load(sys.stdin)
except Exception:
raise ProductError("PLAN_IMPORT_JSON_INVALID")
emit(engine.import_document(value))
elif args.plan_command == "show":
emit(engine.show(args.plan_id))
elif args.plan_command == "validate":
emit(engine.validate(args.plan_id))
elif args.plan_command == "approve":
emit(engine.approve(args.plan_id))
elif args.plan_command == "approve-system":
emit(engine.approve_system(args.plan_id))
elif args.plan_command == "apply":
emit(engine.apply(args.plan_id))
elif args.plan_command == "approve-rollback":
emit(engine.approve_rollback(args.plan_id))
elif args.plan_command == "remote-challenge":
emit(engine.remote_challenge(args.plan_id, rollback=args.rollback, ttl_seconds=args.ttl))
elif args.plan_command == "remote-approve":
emit(engine.approve_remote(args.plan_id, args.challenge_id, args.key_id, args.signature_b64, rollback=args.rollback))
elif args.plan_command == "totp-enroll":
if not sys.stdin.isatty() or not sys.stdout.isatty():
raise ProductError("INTERACTIVE_TTY_REQUIRED")
value=engine.totp_enroll(args.key_id,args.node_label,args.issuer)
print("\nFrameBridge TOTP enrollment")
print("Scan this QR with your authenticator app.\n")
if args.mode == "compact": render_qr_compact(value["otpauth_uri"])
elif args.mode == "mono": render_qr_mono(value["otpauth_uri"])
elif args.mode == "unicode": render_qr_unicode(value["otpauth_uri"])
else: render_qr_ansi(value["otpauth_uri"])
print("Key ID: "+value["key_id"])
print("Enrollment complete. Do not share the secret or a QR screenshot.\n")
emit({"ok":True,"key_id":value["key_id"],"secret_sha256":value["secret_sha256"],"enrolled_at":value["enrolled_at"]})
elif args.plan_command == "totp-challenge":
emit(engine.totp_challenge(args.plan_id, args.key_id, rollback=args.rollback, ttl_seconds=args.ttl))
elif args.plan_command == "totp-approve":
code=sys.stdin.readline().strip()
emit(engine.approve_totp(args.plan_id, args.challenge_id, args.key_id, code, rollback=args.rollback))
elif args.plan_command == "rollback":
emit(engine.rollback(args.plan_id))
elif args.plan_command == "list":
emit(engine.list())
elif args.command == "audit":
emit(ctx.audit.verify())
except ProductError as exc:
emit({
"ok": False,
"error": str(exc),
})
raise SystemExit(2)
if __name__ == "__main__":
main()
@@ -0,0 +1,341 @@
from __future__ import annotations
import hashlib
import json
import os
import platform
import shutil
import socket
import ssl
import subprocess
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .product_core import AuditLog, ProductError
from .node_identity import NodeIdentityError, ensure_node_keypair
from .pairing import FBPAIR_PROTOCOL
SUPPORTED_PROVIDERS = {"chatgpt"}
SEED_VERSION = "0.5.0-candidate.1"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def atomic_write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name("." + path.name + ".tmp")
data = json.dumps(value, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
tmp.write_text(data, encoding="utf-8")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
def read_os_release() -> dict[str, str]:
path = Path("/etc/os-release")
result: dict[str, str] = {}
if not path.is_file():
return result
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
if "=" not in raw:
continue
key, value = raw.split("=", 1)
result[key] = value.strip().strip('"')
return result
def run_readonly(args: list[str], timeout: float = 3.0) -> dict[str, Any]:
try:
proc = subprocess.run(
args,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
return {
"ok": proc.returncode == 0,
"rc": proc.returncode,
"stdout": proc.stdout.strip()[:8192],
"stderr": proc.stderr.strip()[:2048],
}
except Exception as exc:
return {
"ok": False,
"error": type(exc).__name__,
}
class SeedContext:
def __init__(
self,
state_dir: Path | None = None,
config_dir: Path | None = None,
):
self.state_dir = Path(
state_dir
or os.environ.get(
"FRAMEBRIDGE_SEED_STATE_DIR",
"~/.local/share/framebridge-seed",
)
).expanduser()
self.config_dir = Path(
config_dir
or os.environ.get(
"FRAMEBRIDGE_SEED_CONFIG_DIR",
"~/.config/framebridge-seed",
)
).expanduser()
self.identity_path = self.state_dir / "identity.json"
self.config_path = self.config_dir / "config.json"
self.audit = AuditLog(self.state_dir / "audit.jsonl")
def detect_hostname(self) -> str:
value = socket.gethostname().strip()
return value or "linux-node"
def load_identity(self) -> dict[str, Any] | None:
if not self.identity_path.is_file():
return None
value = json.loads(self.identity_path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ProductError("SEED_IDENTITY_INVALID")
return value
def load_config(self) -> dict[str, Any] | None:
if not self.config_path.is_file():
return None
value = json.loads(self.config_path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ProductError("SEED_CONFIG_INVALID")
return value
def init(
self,
provider: str = "chatgpt",
hostname: str = "",
) -> dict[str, Any]:
provider = str(provider).strip().lower()
if provider not in SUPPORTED_PROVIDERS:
raise ProductError("SEED_PROVIDER_UNSUPPORTED")
existing = self.load_identity()
host = str(hostname).strip() or self.detect_hostname()
if existing:
node_id = str(existing.get("node_id") or "")
if not node_id:
raise ProductError("SEED_IDENTITY_NODE_ID_MISSING")
created_at = str(existing.get("created_at") or utc_now())
else:
node_id = str(uuid.uuid4())
created_at = utc_now()
try:
crypto_identity = ensure_node_keypair(
self.state_dir,
comment="framebridge-" + node_id.replace("-", "")[:8],
)
except NodeIdentityError as exc:
raise ProductError(str(exc)) from exc
identity = {
"schema": 2,
"node_id": node_id,
"node_id_short": node_id.replace("-", "")[:8],
"node_host_id": crypto_identity["host_id"],
"node_public_jwk": crypto_identity["public_jwk"],
"node_public_key_sha256": crypto_identity["public_key_sha256"],
"hostname": host,
"created_at": created_at,
"updated_at": utc_now(),
}
config = {
"schema": 1,
"seed_version": SEED_VERSION,
"provider": provider,
"connection_name": f"FrameBridge {host}",
"pairing": {
"protocol": FBPAIR_PROTOCOL,
"state": "not_paired",
"transport": "unpaired",
},
"connection_mode": "PAIRING",
"capability_profile": "seed-readonly",
"memory": {
"required": True,
"protocol": "FBM/1",
"manifest_protocol": "FBAM/1",
"mode": "read_write",
"authority": os.environ.get("FRAMEBRIDGE_MEMORY_AUTHORITY", "deployment-defined"),
},
"hostname": host,
"node_id": node_id,
"updated_at": utc_now(),
}
atomic_write_json(self.identity_path, identity)
atomic_write_json(self.config_path, config)
self.audit.append(
"seed.init",
"PASS",
request={
"provider": provider,
"hostname": host,
},
result={
"node_id_short": identity["node_id_short"],
"pairing_state": "not_paired",
"capability_profile": "seed-readonly",
"memory_required": True,
},
principal="local-operator",
)
from .memory_store import MemoryStore
memory = MemoryStore(self).initialize()
return {
"ok": True,
"seed_version": SEED_VERSION,
"identity": identity,
"config": config,
"memory": memory,
}
def identity(self) -> dict[str, Any]:
identity = self.load_identity()
if identity is None:
return {
"ok": False,
"error": "SEED_NOT_INITIALIZED",
}
return {
"ok": True,
"identity": identity,
}
def doctor(self) -> dict[str, Any]:
os_release = read_os_release()
route = (
run_readonly(["ip", "route", "show", "default"])
if shutil.which("ip")
else {"ok": False, "error": "IP_COMMAND_MISSING"}
)
dns_path = Path("/etc/resolv.conf")
dns_present = dns_path.exists() and dns_path.stat().st_size > 0
ca = ssl.get_default_verify_paths()
ca_present = bool(
(ca.cafile and Path(ca.cafile).is_file())
or (ca.capath and Path(ca.capath).is_dir())
)
checks = {
"kernel": platform.release(),
"architecture": platform.machine(),
"python": platform.python_version(),
"hostname": self.detect_hostname(),
"os_id": os_release.get("ID"),
"os_version": os_release.get("VERSION_ID"),
"shell": os.environ.get("SHELL"),
"network_default_route": bool(route.get("ok") and route.get("stdout")),
"dns_config_present": dns_present,
"ca_certificates_present": ca_present,
"systemd_present": shutil.which("systemctl") is not None,
"ip_command_present": shutil.which("ip") is not None,
"curl_present": shutil.which("curl") is not None,
"python_venv_available": self._venv_available(),
}
required = [
checks["network_default_route"],
checks["dns_config_present"],
checks["ca_certificates_present"],
checks["ip_command_present"],
checks["python_venv_available"],
]
return {
"ok": all(bool(x) for x in required),
"seed_version": SEED_VERSION,
"checks": checks,
"pairing_ready": all(bool(x) for x in required),
"readonly_only": True,
}
def _venv_available(self) -> bool:
try:
import venv # noqa: F401
return True
except Exception:
return False
def status(self) -> dict[str, Any]:
identity = self.load_identity()
config = self.load_config()
doctor = self.doctor()
if identity is None or config is None:
return {
"ok": False,
"initialized": False,
"seed_version": SEED_VERSION,
"doctor": doctor,
}
from .memory_store import MemoryStore
memory_required = bool((config.get("memory") or {}).get("required"))
try:
memory = MemoryStore(self).verify() if memory_required else {"ok": True, "required": False}
except Exception as exc:
memory = {"ok": False, "required": memory_required, "error": str(exc)}
return {
"ok": bool(memory.get("ok")) if memory_required else True,
"initialized": True,
"seed_version": SEED_VERSION,
"node": {
"hostname": identity.get("hostname"),
"node_id": identity.get("node_id"),
"node_id_short": identity.get("node_id_short"),
},
"provider": config.get("provider"),
"connection_name": config.get("connection_name"),
"pairing": config.get("pairing"),
"capability_profile": config.get("capability_profile"),
"memory": memory,
"doctor": doctor,
"audit": self.audit.verify(),
}
def fingerprint(self) -> dict[str, Any]:
identity = self.load_identity()
config = self.load_config()
if identity is None or config is None:
raise ProductError("SEED_NOT_INITIALIZED")
body = json.dumps(
{
"node_id": identity.get("node_id"),
"hostname": identity.get("hostname"),
"provider": config.get("provider"),
"connection_name": config.get("connection_name"),
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"ok": True,
"sha256": hashlib.sha256(body).hexdigest(),
"node_id_short": identity.get("node_id_short"),
"hostname": identity.get("hostname"),
"provider": config.get("provider"),
}
@@ -0,0 +1,41 @@
from __future__ import annotations
import base64,hashlib,hmac,os,struct,time
from pathlib import Path
class TotpError(RuntimeError): pass
def b32decode_nopad(value:str)->bytes:
s=''.join(str(value).strip().upper().split())
if not s or any(c not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' for c in s): raise TotpError('TOTP_SECRET_INVALID')
s += '='*((8-len(s)%8)%8)
try:return base64.b32decode(s,casefold=True)
except Exception: raise TotpError('TOTP_SECRET_INVALID') from None
def hotp(secret:bytes,counter:int,digits:int=6)->str:
digest=hmac.new(secret,struct.pack('>Q',int(counter)),hashlib.sha1).digest()
off=digest[-1]&0x0f
code=((digest[off]&0x7f)<<24)|((digest[off+1]&0xff)<<16)|((digest[off+2]&0xff)<<8)|(digest[off+3]&0xff)
return str(code%(10**digits)).zfill(digits)
def verify_code(secret:bytes,code:str,when:float|None=None,window:int=1,period:int=30)->int|None:
if not isinstance(code,str) or len(code)!=6 or not code.isdigit(): return None
now=time.time() if when is None else float(when)
step=int(now//period)
for s in range(step-window,step+window+1):
if hmac.compare_digest(hotp(secret,s),code): return s
return None
def load_secret(path:Path)->bytes:
p=Path(path)
if not p.is_file() or p.is_symlink(): raise TotpError('TOTP_KEY_NOT_FOUND')
raw=p.read_text().strip()
secret=b32decode_nopad(raw)
if len(secret)<20: raise TotpError('TOTP_SECRET_TOO_SHORT')
return secret
def consume_step(used_dir:Path,key_id:str,step:int,challenge_id:str)->None:
d=Path(used_dir);d.mkdir(parents=True,exist_ok=True,mode=0o700)
p=d/(f'{key_id}-{step}.used')
try:fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
except FileExistsError: raise TotpError('TOTP_REPLAY') from None
with os.fdopen(fd,'w') as f:f.write(str(challenge_id)+'\n')
@@ -0,0 +1 @@
"""Vendored third-party libraries used by FrameBridge."""
@@ -0,0 +1,907 @@
#
# QR Code generator library (Python)
#
# Copyright (c) Project Nayuki. (MIT License)
# https://www.nayuki.io/page/qr-code-generator-library
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# - The Software is provided "as is", without warranty of any kind, express or
# implied, including but not limited to the warranties of merchantability,
# fitness for a particular purpose and noninfringement. In no event shall the
# authors or copyright holders be liable for any claim, damages or other
# liability, whether in an action of contract, tort or otherwise, arising from,
# out of or in connection with the Software or the use or other dealings in the
# Software.
#
from __future__ import annotations
import collections, itertools, re
from collections.abc import Sequence
from typing import Optional, Union
# ---- QR Code symbol class ----
class QrCode:
"""A QR Code symbol, which is a type of two-dimension barcode.
Invented by Denso Wave and described in the ISO/IEC 18004 standard.
Instances of this class represent an immutable square grid of dark and light cells.
The class provides static factory functions to create a QR Code from text or binary data.
The class covers the QR Code Model 2 specification, supporting all versions (sizes)
from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
Ways to create a QR Code object:
- High level: Take the payload data and call QrCode.encode_text() or QrCode.encode_binary().
- Mid level: Custom-make the list of segments and call QrCode.encode_segments().
- Low level: Custom-make the array of data codeword bytes (including
segment headers and final padding, excluding error correction codewords),
supply the appropriate version number, and call the QrCode() constructor.
(Note that all ways require supplying the desired error correction level.)"""
# ---- Static factory functions (high level) ----
@staticmethod
def encode_text(text: str, ecl: QrCode.Ecc) -> QrCode:
"""Returns a QR Code representing the given Unicode text string at the given error correction level.
As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
ecl argument if it can be done without increasing the version."""
segs: list[QrSegment] = QrSegment.make_segments(text)
return QrCode.encode_segments(segs, ecl)
@staticmethod
def encode_binary(data: Union[bytes,Sequence[int]], ecl: QrCode.Ecc) -> QrCode:
"""Returns a QR Code representing the given binary data at the given error correction level.
This function always encodes using the binary segment mode, not any text mode. The maximum number of
bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version."""
return QrCode.encode_segments([QrSegment.make_bytes(data)], ecl)
# ---- Static factory functions (mid level) ----
@staticmethod
def encode_segments(segs: Sequence[QrSegment], ecl: QrCode.Ecc, minversion: int = 1, maxversion: int = 40, mask: int = -1, boostecl: bool = True) -> QrCode:
"""Returns a QR Code representing the given segments with the given encoding parameters.
The smallest possible QR Code version within the given range is automatically
chosen for the output. Iff boostecl is true, then the ECC level of the result
may be higher than the ecl argument if it can be done without increasing the
version. The mask number is either between 0 to 7 (inclusive) to force that
mask, or -1 to automatically choose an appropriate mask (which may be slow).
This function allows the user to create a custom sequence of segments that switches
between modes (such as alphanumeric and byte) to encode text in less space.
This is a mid-level API; the high-level API is encode_text() and encode_binary()."""
if not (QrCode.MIN_VERSION <= minversion <= maxversion <= QrCode.MAX_VERSION) or not (-1 <= mask <= 7):
raise ValueError("Invalid value")
# Find the minimal version number to use
for version in range(minversion, maxversion + 1):
datacapacitybits: int = QrCode._get_num_data_codewords(version, ecl) * 8 # Number of data bits available
datausedbits: Optional[int] = QrSegment.get_total_bits(segs, version)
if (datausedbits is not None) and (datausedbits <= datacapacitybits):
break # This version number is found to be suitable
if version >= maxversion: # All versions in the range could not fit the given data
msg: str = "Segment too long"
if datausedbits is not None:
msg = f"Data length = {datausedbits} bits, Max capacity = {datacapacitybits} bits"
raise DataTooLongError(msg)
assert datausedbits is not None
# Increase the error correction level while the data still fits in the current version number
for newecl in (QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH): # From low to high
if boostecl and (datausedbits <= QrCode._get_num_data_codewords(version, newecl) * 8):
ecl = newecl
# Concatenate all segments to create the data bit string
bb = _BitBuffer()
for seg in segs:
bb.append_bits(seg.get_mode().get_mode_bits(), 4)
bb.append_bits(seg.get_num_chars(), seg.get_mode().num_char_count_bits(version))
bb.extend(seg._bitdata)
assert len(bb) == datausedbits
# Add terminator and pad up to a byte if applicable
datacapacitybits = QrCode._get_num_data_codewords(version, ecl) * 8
assert len(bb) <= datacapacitybits
bb.append_bits(0, min(4, datacapacitybits - len(bb)))
bb.append_bits(0, -len(bb) % 8) # Note: Python's modulo on negative numbers behaves better than C family languages
assert len(bb) % 8 == 0
# Pad with alternating bytes until data capacity is reached
for padbyte in itertools.cycle((0xEC, 0x11)):
if len(bb) >= datacapacitybits:
break
bb.append_bits(padbyte, 8)
# Pack bits into bytes in big endian
datacodewords = bytearray([0] * (len(bb) // 8))
for (i, bit) in enumerate(bb):
datacodewords[i >> 3] |= bit << (7 - (i & 7))
# Create the QR Code object
return QrCode(version, ecl, datacodewords, mask)
# ---- Private fields ----
# The version number of this QR Code, which is between 1 and 40 (inclusive).
# This determines the size of this barcode.
_version: int
# The width and height of this QR Code, measured in modules, between
# 21 and 177 (inclusive). This is equal to version * 4 + 17.
_size: int
# The error correction level used in this QR Code.
_errcorlvl: QrCode.Ecc
# The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
# Even if a QR Code is created with automatic masking requested (mask = -1),
# the resulting object still has a mask value between 0 and 7.
_mask: int
# The modules of this QR Code (False = light, True = dark).
# Immutable after constructor finishes. Accessed through get_module().
_modules: list[list[bool]]
# Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
_isfunction: list[list[bool]]
# ---- Constructor (low level) ----
def __init__(self, version: int, errcorlvl: QrCode.Ecc, datacodewords: Union[bytes,Sequence[int]], msk: int) -> None:
"""Creates a new QR Code with the given version number,
error correction level, data codeword bytes, and mask number.
This is a low-level API that most users should not use directly.
A mid-level API is the encode_segments() function."""
# Check scalar arguments and set fields
if not (QrCode.MIN_VERSION <= version <= QrCode.MAX_VERSION):
raise ValueError("Version value out of range")
if not (-1 <= msk <= 7):
raise ValueError("Mask value out of range")
self._version = version
self._size = version * 4 + 17
self._errcorlvl = errcorlvl
# Initialize both grids to be size*size arrays of Boolean false
self._modules = [[False] * self._size for _ in range(self._size)] # Initially all light
self._isfunction = [[False] * self._size for _ in range(self._size)]
# Compute ECC, draw modules
self._draw_function_patterns()
allcodewords: bytes = self._add_ecc_and_interleave(bytearray(datacodewords))
self._draw_codewords(allcodewords)
# Do masking
if msk == -1: # Automatically choose best mask
minpenalty: int = 1 << 32
for i in range(8):
self._apply_mask(i)
self._draw_format_bits(i)
penalty = self._get_penalty_score()
if penalty < minpenalty:
msk = i
minpenalty = penalty
self._apply_mask(i) # Undoes the mask due to XOR
assert 0 <= msk <= 7
self._mask = msk
self._apply_mask(msk) # Apply the final choice of mask
self._draw_format_bits(msk) # Overwrite old format bits
del self._isfunction
# ---- Accessor methods ----
def get_version(self) -> int:
"""Returns this QR Code's version number, in the range [1, 40]."""
return self._version
def get_size(self) -> int:
"""Returns this QR Code's size, in the range [21, 177]."""
return self._size
def get_error_correction_level(self) -> QrCode.Ecc:
"""Returns this QR Code's error correction level."""
return self._errcorlvl
def get_mask(self) -> int:
"""Returns this QR Code's mask, in the range [0, 7]."""
return self._mask
def get_module(self, x: int, y: int) -> bool:
"""Returns the color of the module (pixel) at the given coordinates, which is False
for light or True for dark. The top left corner has the coordinates (x=0, y=0).
If the given coordinates are out of bounds, then False (light) is returned."""
return (0 <= x < self._size) and (0 <= y < self._size) and self._modules[y][x]
# ---- Private helper methods for constructor: Drawing function modules ----
def _draw_function_patterns(self) -> None:
"""Reads this object's version field, and draws and marks all function modules."""
# Draw horizontal and vertical timing patterns
for i in range(self._size):
self._set_function_module(6, i, i % 2 == 0)
self._set_function_module(i, 6, i % 2 == 0)
# Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
self._draw_finder_pattern(3, 3)
self._draw_finder_pattern(self._size - 4, 3)
self._draw_finder_pattern(3, self._size - 4)
# Draw numerous alignment patterns
alignpatpos: list[int] = self._get_alignment_pattern_positions()
numalign: int = len(alignpatpos)
skips: Sequence[tuple[int,int]] = ((0, 0), (0, numalign - 1), (numalign - 1, 0))
for i in range(numalign):
for j in range(numalign):
if (i, j) not in skips: # Don't draw on the three finder corners
self._draw_alignment_pattern(alignpatpos[i], alignpatpos[j])
# Draw configuration data
self._draw_format_bits(0) # Dummy mask value; overwritten later in the constructor
self._draw_version()
def _draw_format_bits(self, mask: int) -> None:
"""Draws two copies of the format bits (with its own error correction code)
based on the given mask and this object's error correction level field."""
# Calculate error correction code and pack bits
data: int = self._errcorlvl.formatbits << 3 | mask # errCorrLvl is uint2, mask is uint3
rem: int = data
for _ in range(10):
rem = (rem << 1) ^ ((rem >> 9) * 0x537)
bits: int = (data << 10 | rem) ^ 0x5412 # uint15
assert bits >> 15 == 0
# Draw first copy
for i in range(0, 6):
self._set_function_module(8, i, _get_bit(bits, i))
self._set_function_module(8, 7, _get_bit(bits, 6))
self._set_function_module(8, 8, _get_bit(bits, 7))
self._set_function_module(7, 8, _get_bit(bits, 8))
for i in range(9, 15):
self._set_function_module(14 - i, 8, _get_bit(bits, i))
# Draw second copy
for i in range(0, 8):
self._set_function_module(self._size - 1 - i, 8, _get_bit(bits, i))
for i in range(8, 15):
self._set_function_module(8, self._size - 15 + i, _get_bit(bits, i))
self._set_function_module(8, self._size - 8, True) # Always dark
def _draw_version(self) -> None:
"""Draws two copies of the version bits (with its own error correction code),
based on this object's version field, iff 7 <= version <= 40."""
if self._version < 7:
return
# Calculate error correction code and pack bits
rem: int = self._version # version is uint6, in the range [7, 40]
for _ in range(12):
rem = (rem << 1) ^ ((rem >> 11) * 0x1F25)
bits: int = self._version << 12 | rem # uint18
assert bits >> 18 == 0
# Draw two copies
for i in range(18):
bit: bool = _get_bit(bits, i)
a: int = self._size - 11 + i % 3
b: int = i // 3
self._set_function_module(a, b, bit)
self._set_function_module(b, a, bit)
def _draw_finder_pattern(self, x: int, y: int) -> None:
"""Draws a 9*9 finder pattern including the border separator,
with the center module at (x, y). Modules can be out of bounds."""
for dy in range(-4, 5):
for dx in range(-4, 5):
xx, yy = x + dx, y + dy
if (0 <= xx < self._size) and (0 <= yy < self._size):
# Chebyshev/infinity norm
self._set_function_module(xx, yy, max(abs(dx), abs(dy)) not in (2, 4))
def _draw_alignment_pattern(self, x: int, y: int) -> None:
"""Draws a 5*5 alignment pattern, with the center module
at (x, y). All modules must be in bounds."""
for dy in range(-2, 3):
for dx in range(-2, 3):
self._set_function_module(x + dx, y + dy, max(abs(dx), abs(dy)) != 1)
def _set_function_module(self, x: int, y: int, isdark: bool) -> None:
"""Sets the color of a module and marks it as a function module.
Only used by the constructor. Coordinates must be in bounds."""
assert type(isdark) is bool
self._modules[y][x] = isdark
self._isfunction[y][x] = True
# ---- Private helper methods for constructor: Codewords and masking ----
def _add_ecc_and_interleave(self, data: bytearray) -> bytes:
"""Returns a new byte string representing the given data with the appropriate error correction
codewords appended to it, based on this object's version and error correction level."""
version: int = self._version
assert len(data) == QrCode._get_num_data_codewords(version, self._errcorlvl)
# Calculate parameter numbers
numblocks: int = QrCode._NUM_ERROR_CORRECTION_BLOCKS[self._errcorlvl.ordinal][version]
blockecclen: int = QrCode._ECC_CODEWORDS_PER_BLOCK [self._errcorlvl.ordinal][version]
rawcodewords: int = QrCode._get_num_raw_data_modules(version) // 8
numshortblocks: int = numblocks - rawcodewords % numblocks
shortblocklen: int = rawcodewords // numblocks
# Split data into blocks and append ECC to each block
blocks: list[bytes] = []
rsdiv: bytes = QrCode._reed_solomon_compute_divisor(blockecclen)
k: int = 0
for i in range(numblocks):
dat: bytearray = data[k : k + shortblocklen - blockecclen + (0 if i < numshortblocks else 1)]
k += len(dat)
ecc: bytes = QrCode._reed_solomon_compute_remainder(dat, rsdiv)
if i < numshortblocks:
dat.append(0)
blocks.append(dat + ecc)
assert k == len(data)
# Interleave (not concatenate) the bytes from every block into a single sequence
result = bytearray()
for i in range(len(blocks[0])):
for (j, blk) in enumerate(blocks):
# Skip the padding byte in short blocks
if (i != shortblocklen - blockecclen) or (j >= numshortblocks):
result.append(blk[i])
assert len(result) == rawcodewords
return result
def _draw_codewords(self, data: bytes) -> None:
"""Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
data area of this QR Code. Function modules need to be marked off before this is called."""
assert len(data) == QrCode._get_num_raw_data_modules(self._version) // 8
i: int = 0 # Bit index into the data
# Do the funny zigzag scan
for right in range(self._size - 1, 0, -2): # Index of right column in each column pair
if right <= 6:
right -= 1
for vert in range(self._size): # Vertical counter
for j in range(2):
x: int = right - j # Actual x coordinate
upward: bool = (right + 1) & 2 == 0
y: int = (self._size - 1 - vert) if upward else vert # Actual y coordinate
if (not self._isfunction[y][x]) and (i < len(data) * 8):
self._modules[y][x] = _get_bit(data[i >> 3], 7 - (i & 7))
i += 1
# If this QR Code has any remainder bits (0 to 7), they were assigned as
# 0/false/light by the constructor and are left unchanged by this method
assert i == len(data) * 8
def _apply_mask(self, mask: int) -> None:
"""XORs the codeword modules in this QR Code with the given mask pattern.
The function modules must be marked and the codeword bits must be drawn
before masking. Due to the arithmetic of XOR, calling _apply_mask() with
the same mask value a second time will undo the mask. A final well-formed
QR Code needs exactly one (not zero, two, etc.) mask applied."""
if not (0 <= mask <= 7):
raise ValueError("Mask value out of range")
masker: collections.abc.Callable[[int,int],int] = QrCode._MASK_PATTERNS[mask]
for y in range(self._size):
for x in range(self._size):
self._modules[y][x] ^= (masker(x, y) == 0) and (not self._isfunction[y][x])
def _get_penalty_score(self) -> int:
"""Calculates and returns the penalty score based on state of this QR Code's current modules.
This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score."""
result: int = 0
size: int = self._size
modules: list[list[bool]] = self._modules
# Adjacent modules in row having same color, and finder-like patterns
for y in range(size):
runcolor: bool = False
runx: int = 0
runhistory = collections.deque([0] * 7, 7)
for x in range(size):
if modules[y][x] == runcolor:
runx += 1
if runx == 5:
result += QrCode._PENALTY_N1
elif runx > 5:
result += 1
else:
self._finder_penalty_add_history(runx, runhistory)
if not runcolor:
result += self._finder_penalty_count_patterns(runhistory) * QrCode._PENALTY_N3
runcolor = modules[y][x]
runx = 1
result += self._finder_penalty_terminate_and_count(runcolor, runx, runhistory) * QrCode._PENALTY_N3
# Adjacent modules in column having same color, and finder-like patterns
for x in range(size):
runcolor = False
runy: int = 0
runhistory = collections.deque([0] * 7, 7)
for y in range(size):
if modules[y][x] == runcolor:
runy += 1
if runy == 5:
result += QrCode._PENALTY_N1
elif runy > 5:
result += 1
else:
self._finder_penalty_add_history(runy, runhistory)
if not runcolor:
result += self._finder_penalty_count_patterns(runhistory) * QrCode._PENALTY_N3
runcolor = modules[y][x]
runy = 1
result += self._finder_penalty_terminate_and_count(runcolor, runy, runhistory) * QrCode._PENALTY_N3
# 2*2 blocks of modules having same color
for y in range(size - 1):
for x in range(size - 1):
if modules[y][x] == modules[y][x + 1] == modules[y + 1][x] == modules[y + 1][x + 1]:
result += QrCode._PENALTY_N2
# Balance of dark and light modules
dark: int = sum((1 if cell else 0) for row in modules for cell in row)
total: int = size**2 # Note that size is odd, so dark/total != 1/2
# Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
k: int = (abs(dark * 20 - total * 10) + total - 1) // total - 1
assert 0 <= k <= 9
result += k * QrCode._PENALTY_N4
assert 0 <= result <= 2568888 # Non-tight upper bound based on default values of PENALTY_N1, ..., N4
return result
# ---- Private helper functions ----
def _get_alignment_pattern_positions(self) -> list[int]:
"""Returns an ascending list of positions of alignment patterns for this version number.
Each position is in the range [0,177), and are used on both the x and y axes.
This could be implemented as lookup table of 40 variable-length lists of integers."""
if self._version == 1:
return []
else:
numalign: int = self._version // 7 + 2
step: int = (self._version * 8 + numalign * 3 + 5) // (numalign * 4 - 4) * 2
result: list[int] = [(self._size - 7 - i * step) for i in range(numalign - 1)] + [6]
return list(reversed(result))
@staticmethod
def _get_num_raw_data_modules(ver: int) -> int:
"""Returns the number of data bits that can be stored in a QR Code of the given version number, after
all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table."""
if not (QrCode.MIN_VERSION <= ver <= QrCode.MAX_VERSION):
raise ValueError("Version number out of range")
result: int = (16 * ver + 128) * ver + 64
if ver >= 2:
numalign: int = ver // 7 + 2
result -= (25 * numalign - 10) * numalign - 55
if ver >= 7:
result -= 36
assert 208 <= result <= 29648
return result
@staticmethod
def _get_num_data_codewords(ver: int, ecl: QrCode.Ecc) -> int:
"""Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
QR Code of the given version number and error correction level, with remainder bits discarded.
This stateless pure function could be implemented as a (40*4)-cell lookup table."""
return QrCode._get_num_raw_data_modules(ver) // 8 \
- QrCode._ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] \
* QrCode._NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]
@staticmethod
def _reed_solomon_compute_divisor(degree: int) -> bytes:
"""Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
implemented as a lookup table over all possible parameter values, instead of as an algorithm."""
if not (1 <= degree <= 255):
raise ValueError("Degree out of range")
# Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
# For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
result = bytearray([0] * (degree - 1) + [1]) # Start off with the monomial x^0
# Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
# and drop the highest monomial term which is always 1x^degree.
# Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
root: int = 1
for _ in range(degree): # Unused variable i
# Multiply the current product by (x - r^i)
for j in range(degree):
result[j] = QrCode._reed_solomon_multiply(result[j], root)
if j + 1 < degree:
result[j] ^= result[j + 1]
root = QrCode._reed_solomon_multiply(root, 0x02)
return result
@staticmethod
def _reed_solomon_compute_remainder(data: bytes, divisor: bytes) -> bytes:
"""Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials."""
result = bytearray([0] * len(divisor))
for b in data: # Polynomial division
factor: int = b ^ result.pop(0)
result.append(0)
for (i, coef) in enumerate(divisor):
result[i] ^= QrCode._reed_solomon_multiply(coef, factor)
return result
@staticmethod
def _reed_solomon_multiply(x: int, y: int) -> int:
"""Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8."""
if (x >> 8 != 0) or (y >> 8 != 0):
raise ValueError("Byte out of range")
# Russian peasant multiplication
z: int = 0
for i in reversed(range(8)):
z = (z << 1) ^ ((z >> 7) * 0x11D)
z ^= ((y >> i) & 1) * x
assert z >> 8 == 0
return z
def _finder_penalty_count_patterns(self, runhistory: collections.deque[int]) -> int:
"""Can only be called immediately after a light run is added, and
returns either 0, 1, or 2. A helper function for _get_penalty_score()."""
n: int = runhistory[1]
assert n <= self._size * 3
core: bool = n > 0 and (runhistory[2] == runhistory[4] == runhistory[5] == n) and runhistory[3] == n * 3
return (1 if (core and runhistory[0] >= n * 4 and runhistory[6] >= n) else 0) \
+ (1 if (core and runhistory[6] >= n * 4 and runhistory[0] >= n) else 0)
def _finder_penalty_terminate_and_count(self, currentruncolor: bool, currentrunlength: int, runhistory: collections.deque[int]) -> int:
"""Must be called at the end of a line (row or column) of modules. A helper function for _get_penalty_score()."""
if currentruncolor: # Terminate dark run
self._finder_penalty_add_history(currentrunlength, runhistory)
currentrunlength = 0
currentrunlength += self._size # Add light border to final run
self._finder_penalty_add_history(currentrunlength, runhistory)
return self._finder_penalty_count_patterns(runhistory)
def _finder_penalty_add_history(self, currentrunlength: int, runhistory: collections.deque[int]) -> None:
if runhistory[0] == 0:
currentrunlength += self._size # Add light border to initial run
runhistory.appendleft(currentrunlength)
# ---- Constants and tables ----
MIN_VERSION: int = 1 # The minimum version number supported in the QR Code Model 2 standard
MAX_VERSION: int = 40 # The maximum version number supported in the QR Code Model 2 standard
# For use in _get_penalty_score(), when evaluating which mask is best.
_PENALTY_N1: int = 3
_PENALTY_N2: int = 3
_PENALTY_N3: int = 40
_PENALTY_N4: int = 10
_ECC_CODEWORDS_PER_BLOCK: Sequence[Sequence[int]] = (
# Version: (note that index 0 is for padding, and is set to an illegal value)
# 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
(-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30), # Low
(-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28), # Medium
(-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30), # Quartile
(-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30)) # High
_NUM_ERROR_CORRECTION_BLOCKS: Sequence[Sequence[int]] = (
# Version: (note that index 0 is for padding, and is set to an illegal value)
# 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
(-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25), # Low
(-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49), # Medium
(-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68), # Quartile
(-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81)) # High
_MASK_PATTERNS: Sequence[collections.abc.Callable[[int,int],int]] = (
(lambda x, y: (x + y) % 2 ),
(lambda x, y: y % 2 ),
(lambda x, y: x % 3 ),
(lambda x, y: (x + y) % 3 ),
(lambda x, y: (x // 3 + y // 2) % 2 ),
(lambda x, y: x * y % 2 + x * y % 3 ),
(lambda x, y: (x * y % 2 + x * y % 3) % 2 ),
(lambda x, y: ((x + y) % 2 + x * y % 3) % 2),
)
# ---- Public helper enumeration ----
class Ecc:
ordinal: int # (Public) In the range 0 to 3 (unsigned 2-bit integer)
formatbits: int # (Package-private) In the range 0 to 3 (unsigned 2-bit integer)
"""The error correction level in a QR Code symbol. Immutable."""
# Private constructor
def __init__(self, i: int, fb: int) -> None:
self.ordinal = i
self.formatbits = fb
# Placeholders
LOW : QrCode.Ecc
MEDIUM : QrCode.Ecc
QUARTILE: QrCode.Ecc
HIGH : QrCode.Ecc
# Public constants. Create them outside the class.
Ecc.LOW = Ecc(0, 1) # The QR Code can tolerate about 7% erroneous codewords
Ecc.MEDIUM = Ecc(1, 0) # The QR Code can tolerate about 15% erroneous codewords
Ecc.QUARTILE = Ecc(2, 3) # The QR Code can tolerate about 25% erroneous codewords
Ecc.HIGH = Ecc(3, 2) # The QR Code can tolerate about 30% erroneous codewords
# ---- Data segment class ----
class QrSegment:
"""A segment of character/binary/control data in a QR Code symbol.
Instances of this class are immutable.
The mid-level way to create a segment is to take the payload data
and call a static factory function such as QrSegment.make_numeric().
The low-level way to create a segment is to custom-make the bit buffer
and call the QrSegment() constructor with appropriate values.
This segment class imposes no length restrictions, but QR Codes have restrictions.
Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
Any segment longer than this is meaningless for the purpose of generating QR Codes."""
# ---- Static factory functions (mid level) ----
@staticmethod
def make_bytes(data: Union[bytes,Sequence[int]]) -> QrSegment:
"""Returns a segment representing the given binary data encoded in byte mode.
All input byte lists are acceptable. Any text string can be converted to
UTF-8 bytes (s.encode("UTF-8")) and encoded as a byte mode segment."""
bb = _BitBuffer()
for b in data:
bb.append_bits(b, 8)
return QrSegment(QrSegment.Mode.BYTE, len(data), bb)
@staticmethod
def make_numeric(digits: str) -> QrSegment:
"""Returns a segment representing the given string of decimal digits encoded in numeric mode."""
if not QrSegment.is_numeric(digits):
raise ValueError("String contains non-numeric characters")
bb = _BitBuffer()
i: int = 0
while i < len(digits): # Consume up to 3 digits per iteration
n: int = min(len(digits) - i, 3)
bb.append_bits(int(digits[i : i + n]), n * 3 + 1)
i += n
return QrSegment(QrSegment.Mode.NUMERIC, len(digits), bb)
@staticmethod
def make_alphanumeric(text: str) -> QrSegment:
"""Returns a segment representing the given text string encoded in alphanumeric mode.
The characters allowed are: 0 to 9, A to Z (uppercase only), space,
dollar, percent, asterisk, plus, hyphen, period, slash, colon."""
if not QrSegment.is_alphanumeric(text):
raise ValueError("String contains unencodable characters in alphanumeric mode")
bb = _BitBuffer()
for i in range(0, len(text) - 1, 2): # Process groups of 2
temp: int = QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[i]] * 45
temp += QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[i + 1]]
bb.append_bits(temp, 11)
if len(text) % 2 > 0: # 1 character remaining
bb.append_bits(QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[-1]], 6)
return QrSegment(QrSegment.Mode.ALPHANUMERIC, len(text), bb)
@staticmethod
def make_segments(text: str) -> list[QrSegment]:
"""Returns a new mutable list of zero or more segments to represent the given Unicode text string.
The result may use various segment modes and switch modes to optimize the length of the bit stream."""
# Select the most efficient segment encoding automatically
if text == "":
return []
elif QrSegment.is_numeric(text):
return [QrSegment.make_numeric(text)]
elif QrSegment.is_alphanumeric(text):
return [QrSegment.make_alphanumeric(text)]
else:
return [QrSegment.make_bytes(text.encode("UTF-8"))]
@staticmethod
def make_eci(assignval: int) -> QrSegment:
"""Returns a segment representing an Extended Channel Interpretation
(ECI) designator with the given assignment value."""
bb = _BitBuffer()
if assignval < 0:
raise ValueError("ECI assignment value out of range")
elif assignval < (1 << 7):
bb.append_bits(assignval, 8)
elif assignval < (1 << 14):
bb.append_bits(0b10, 2)
bb.append_bits(assignval, 14)
elif assignval < 1000000:
bb.append_bits(0b110, 3)
bb.append_bits(assignval, 21)
else:
raise ValueError("ECI assignment value out of range")
return QrSegment(QrSegment.Mode.ECI, 0, bb)
# Tests whether the given string can be encoded as a segment in numeric mode.
# A string is encodable iff each character is in the range 0 to 9.
@staticmethod
def is_numeric(text: str) -> bool:
return QrSegment._NUMERIC_REGEX.fullmatch(text) is not None
# Tests whether the given string can be encoded as a segment in alphanumeric mode.
# A string is encodable iff each character is in the following set: 0 to 9, A to Z
# (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
@staticmethod
def is_alphanumeric(text: str) -> bool:
return QrSegment._ALPHANUMERIC_REGEX.fullmatch(text) is not None
# ---- Private fields ----
# The mode indicator of this segment. Accessed through get_mode().
_mode: QrSegment.Mode
# The length of this segment's unencoded data. Measured in characters for
# numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
# Always zero or positive. Not the same as the data's bit length.
# Accessed through get_num_chars().
_numchars: int
# The data bits of this segment. Accessed through get_data().
_bitdata: list[int]
# ---- Constructor (low level) ----
def __init__(self, mode: QrSegment.Mode, numch: int, bitdata: Sequence[int]) -> None:
"""Creates a new QR Code segment with the given attributes and data.
The character count (numch) must agree with the mode and the bit buffer length,
but the constraint isn't checked. The given bit buffer is cloned and stored."""
if numch < 0:
raise ValueError()
self._mode = mode
self._numchars = numch
self._bitdata = list(bitdata) # Make defensive copy
# ---- Accessor methods ----
def get_mode(self) -> QrSegment.Mode:
"""Returns the mode field of this segment."""
return self._mode
def get_num_chars(self) -> int:
"""Returns the character count field of this segment."""
return self._numchars
def get_data(self) -> list[int]:
"""Returns a new copy of the data bits of this segment."""
return list(self._bitdata) # Make defensive copy
# Package-private function
@staticmethod
def get_total_bits(segs: Sequence[QrSegment], version: int) -> Optional[int]:
"""Calculates the number of bits needed to encode the given segments at
the given version. Returns a non-negative number if successful. Otherwise
returns None if a segment has too many characters to fit its length field."""
result = 0
for seg in segs:
ccbits: int = seg.get_mode().num_char_count_bits(version)
if seg.get_num_chars() >= (1 << ccbits):
return None # The segment's length doesn't fit the field's bit width
result += 4 + ccbits + len(seg._bitdata)
return result
# ---- Constants ----
# Describes precisely all strings that are encodable in numeric mode.
_NUMERIC_REGEX: re.Pattern[str] = re.compile(r"[0-9]*")
# Describes precisely all strings that are encodable in alphanumeric mode.
_ALPHANUMERIC_REGEX: re.Pattern[str] = re.compile(r"[A-Z0-9 $%*+./:-]*")
# Dictionary of "0"->0, "A"->10, "$"->37, etc.
_ALPHANUMERIC_ENCODING_TABLE: dict[str,int] = {ch: i for (i, ch) in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:")}
# ---- Public helper enumeration ----
class Mode:
"""Describes how a segment's data bits are interpreted. Immutable."""
_modebits: int # The mode indicator bits, which is a uint4 value (range 0 to 15)
_charcounts: tuple[int,int,int] # Number of character count bits for three different version ranges
# Private constructor
def __init__(self, modebits: int, charcounts: tuple[int,int,int]):
self._modebits = modebits
self._charcounts = charcounts
# Package-private method
def get_mode_bits(self) -> int:
"""Returns an unsigned 4-bit integer value (range 0 to 15) representing the mode indicator bits for this mode object."""
return self._modebits
# Package-private method
def num_char_count_bits(self, ver: int) -> int:
"""Returns the bit width of the character count field for a segment in this mode
in a QR Code at the given version number. The result is in the range [0, 16]."""
return self._charcounts[(ver + 7) // 17]
# Placeholders
NUMERIC : QrSegment.Mode
ALPHANUMERIC: QrSegment.Mode
BYTE : QrSegment.Mode
KANJI : QrSegment.Mode
ECI : QrSegment.Mode
# Public constants. Create them outside the class.
Mode.NUMERIC = Mode(0x1, (10, 12, 14))
Mode.ALPHANUMERIC = Mode(0x2, ( 9, 11, 13))
Mode.BYTE = Mode(0x4, ( 8, 16, 16))
Mode.KANJI = Mode(0x8, ( 8, 10, 12))
Mode.ECI = Mode(0x7, ( 0, 0, 0))
# ---- Private helper class ----
class _BitBuffer(list[int]):
"""An appendable sequence of bits (0s and 1s). Mainly used by QrSegment."""
def append_bits(self, val: int, n: int) -> None:
"""Appends the given number of low-order bits of the given
value to this buffer. Requires n >= 0 and 0 <= val < 2^n."""
if (n < 0) or (val >> n != 0):
raise ValueError("Value out of range")
self.extend(((val >> i) & 1) for i in reversed(range(n)))
def _get_bit(x: int, i: int) -> bool:
"""Returns true iff the i'th bit of x is set to 1."""
return (x >> i) & 1 != 0
class DataTooLongError(ValueError):
"""Raised when the supplied data does not fit any QR Code version. Ways to handle this exception include:
- Decrease the error correction level if it was greater than Ecc.LOW.
- If the encode_segments() function was called with a maxversion argument, then increase
it if it was less than QrCode.MAX_VERSION. (This advice does not apply to the other
factory functions because they search all versions up to QrCode.MAX_VERSION.)
- Split the text data into better or optimal segments in order to reduce the number of bits required.
- Change the text or binary data to be shorter.
- Change the text to fit the character set of a particular segment mode (e.g. alphanumeric).
- Propagate the error upward to the caller/user."""
pass