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

@@ -0,0 +1,125 @@
{
"format": "FRAMEBRIDGE_ORIGINAL_SOURCE_SNAPSHOT_V1",
"source_host": "Original desktop source capture; community derivative",
"file_count": 14,
"scan_result": "HISTORICAL_CAPTURE_CHECK_ONLY; see current community privacy review",
"scope": "PUBLIC_PROGRAM_FILES_ONLY",
"runtime_state_included": false,
"configuration_files_included": false,
"embedded_lab_route_mapping_in_host_source": false,
"files": [
{
"archive_path": "tool-adapter/bin/framebridge_tool_adapter.py",
"source_path": "tool-adapter/bin/framebridge_tool_adapter.py",
"bytes": 10268,
"sha256": "dca9bf3dfbae7881aa6824ca79a2922fb4ec7ab1b61b5696b39cb64630c4d86b",
"source_mode": "0o700",
"archive_mode": "0755"
},
{
"archive_path": "tool-adapter/bin/framebridge_tool_client.py",
"source_path": "tool-adapter/bin/framebridge_tool_client.py",
"bytes": 1073,
"sha256": "1d1514f08653c31f6b67ebcdcf61686947321bec96752a692b7e46cee6285f6e",
"source_mode": "0o700",
"archive_mode": "0755"
},
{
"archive_path": "fbterm/bin/fbterm_engine.py",
"source_path": "fbterm/bin/fbterm_engine.py",
"bytes": 13763,
"sha256": "9408aa2dfba2ceecf1bcc4d9e6b1d52fec1f8971183f5baa7145f2d6357c7635",
"source_mode": "0o700",
"archive_mode": "0755"
},
{
"archive_path": "fbterm/bin/fbterm-run",
"source_path": "fbterm/bin/fbterm-run",
"bytes": 2628,
"sha256": "bbfc19c878746518e482adbe61cd6f8ae8560dca75a1e3db9921e1cb40b42e71",
"source_mode": "0o700",
"archive_mode": "0755"
},
{
"archive_path": "fbterm/bin/fbterm-shell",
"source_path": "fbterm/bin/fbterm-shell",
"bytes": 612,
"sha256": "eb6de30bfd6430165f7ebc982021f3565b2e80c4b82480b4e01e64e15e65957a",
"source_mode": "0o700",
"archive_mode": "0755"
},
{
"archive_path": "fbterm/bin/fbterm-prompt-ready",
"source_path": "fbterm/bin/fbterm-prompt-ready",
"bytes": 286,
"sha256": "276f657e23ff26c75f5e430731fdf5b095d777eabcbaad86e11c69b0919c691c",
"source_mode": "0o700",
"archive_mode": "0755"
},
{
"archive_path": "fbterm/bin/fbx",
"source_path": "fbterm/bin/fbx",
"bytes": 2517,
"sha256": "84589ad19ea0eab7cfc766ef4dba736be05c509c3918f572f4054585ee4c5efb",
"source_mode": "0o700",
"archive_mode": "0755"
},
{
"archive_path": "framebridge-native/host/framebridge_host.py",
"source_path": "framebridge-native/host/framebridge_host.py",
"bytes": 45347,
"sha256": "85d165bf8875fa8f9b202b129f985ca4cf040a1f4778ef43ed4c9f5b0164b3eb",
"source_mode": "0o755",
"archive_mode": "0755"
},
{
"archive_path": "framebridge-native/host/framebridge_cua_linux.py",
"source_path": "framebridge-native/host/framebridge_cua_linux.py",
"bytes": 45731,
"sha256": "aed73125f88b4408bad83ef4aae220fd79bd8f57e41ff15ebc9930453bdc69a1",
"source_mode": "0o600",
"archive_mode": "0644"
},
{
"archive_path": "framebridge-native/host/framebridge_voice_command.py",
"source_path": "framebridge-native/host/framebridge_voice_command.py",
"bytes": 1127,
"sha256": "ed0aff0070799d437a5570b16dbfa0a3878e51b73166c14bebaf8df45ebdc86c",
"source_mode": "0o664",
"archive_mode": "0644"
},
{
"archive_path": "framebridge-native/host/framebridge_voice_status.py",
"source_path": "framebridge-native/host/framebridge_voice_status.py",
"bytes": 1843,
"sha256": "f8520095e973a91249876d733143399fe40a0d72cd64c741c5cf85d8898a3ffb",
"source_mode": "0o664",
"archive_mode": "0644"
},
{
"archive_path": "framebridge-native/host/open-terminal.sh",
"source_path": "framebridge-native/host/open-terminal.sh",
"bytes": 690,
"sha256": "facaea7a5ca6635311a4c05e18edecdec98a137ce552fce39bdbc0ee755aa34c",
"source_mode": "0o775",
"archive_mode": "0755"
},
{
"archive_path": "framebridge-native/host/open-interactive-terminal.sh",
"source_path": "framebridge-native/host/open-interactive-terminal.sh",
"bytes": 4631,
"sha256": "e5c191c9cc354fd4d88a23c02e5eafbe454a7d3b3586265288d5f89ee9d8c602",
"source_mode": "0o775",
"archive_mode": "0755"
},
{
"archive_path": "framebridge-native/host/open-progress-console.sh",
"source_path": "framebridge-native/host/open-progress-console.sh",
"bytes": 3488,
"sha256": "cb519186ce299e21381e4698faf5490a3e947ad01187b9674f78d4e8bc21ef65",
"source_mode": "0o775",
"archive_mode": "0755"
}
],
"community_derivative": true
}
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set +e
umask 077
ROOT="$FRAMEBRIDGE_FBTERM_ROOT"
[ -n "$ROOT" ] || ROOT="$HOME/.local/share/framebridge/fbterm"
READY="$ROOT/prompt.ready"
TMP="$ROOT/.prompt.ready.$$"
mkdir -p "$ROOT" 2>/dev/null || true
printf '%s\n' "$(date +%s%N)" > "$TMP"
mv -f "$TMP" "$READY"
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
umask 077
ROOT="${FBTERM_ROOT:?FBTERM_ROOT no definido}"
# R2: mientras fbterm-run esta activo, el pane no esta disponible.
rm -f "$ROOT/prompt.ready"
JOB_ID="${1:-}"
case "$JOB_ID" in
''|*[!A-Za-z0-9._-]*)
printf 'FBTERM_ERROR=INVALID_JOB_ID\n' >&2
exit 64
;;
esac
JOB="$ROOT/jobs/$JOB_ID"
CMD="$JOB/command.bash"
TIMEOUT_FILE="$JOB/timeout.seconds"
if [ ! -f "$CMD" ]; then
printf 'FBTERM_ERROR=COMMAND_NOT_FOUND JOB_ID=%s\n' "$JOB_ID" >&2
exit 66
fi
TIMEOUT_S='120'
if [ -f "$TIMEOUT_FILE" ]; then
VALUE="$(cat "$TIMEOUT_FILE" 2>/dev/null || true)"
case "$VALUE" in
''|*[!0-9]*)
;;
*)
if [ "$VALUE" -ge 1 ] 2>/dev/null &&
[ "$VALUE" -le 3600 ] 2>/dev/null
then
TIMEOUT_S="$VALUE"
fi
;;
esac
fi
OUT_FIFO="$JOB/stdout.fifo"
ERR_FIFO="$JOB/stderr.fifo"
STDOUT="$JOB/stdout.bin"
STDERR="$JOB/stderr.bin"
rm -f "$OUT_FIFO" "$ERR_FIFO"
mkfifo "$OUT_FIFO" "$ERR_FIFO"
: > "$STDOUT"
: > "$STDERR"
date -Ins > "$JOB/started.at"
sha256sum "$CMD" |
awk '{print $1}' > "$JOB/command.sha256"
tee "$STDOUT" < "$OUT_FIFO" &
OUT_TEE_PID=$!
tee "$STDERR" < "$ERR_FIFO" >&2 &
ERR_TEE_PID=$!
/usr/bin/timeout \
--signal=TERM \
--kill-after=2s \
"${TIMEOUT_S}s" \
bash --noprofile --norc "$CMD" \
> "$OUT_FIFO" \
2> "$ERR_FIFO"
RC=$?
wait "$OUT_TEE_PID"
OUT_TEE_RC=$?
wait "$ERR_TEE_PID"
ERR_TEE_RC=$?
printf '%s\n' "$RC" > "$JOB/exit.code"
printf '%s\n' "$OUT_TEE_RC" > "$JOB/stdout-tee.exit"
printf '%s\n' "$ERR_TEE_RC" > "$JOB/stderr-tee.exit"
sha256sum "$STDOUT" |
awk '{print $1}' > "$JOB/stdout.sha256"
sha256sum "$STDERR" |
awk '{print $1}' > "$JOB/stderr.sha256"
stat -c '%s' "$STDOUT" > "$JOB/stdout.bytes"
stat -c '%s' "$STDERR" > "$JOB/stderr.bytes"
date -Ins > "$JOB/finished.at"
{
printf 'JOB_ID=%s\n' "$JOB_ID"
printf 'EXIT=%s\n' "$RC"
printf 'COMMAND_SHA256=%s\n' \
"$(cat "$JOB/command.sha256")"
printf 'STDOUT_SHA256=%s\n' \
"$(cat "$JOB/stdout.sha256")"
printf 'STDERR_SHA256=%s\n' \
"$(cat "$JOB/stderr.sha256")"
printf 'STDOUT_BYTES=%s\n' \
"$(cat "$JOB/stdout.bytes")"
printf 'STDERR_BYTES=%s\n' \
"$(cat "$JOB/stderr.bytes")"
printf 'STDOUT_TEE_EXIT=%s\n' "$OUT_TEE_RC"
printf 'STDERR_TEE_EXIT=%s\n' "$ERR_TEE_RC"
} > "$JOB/result.tmp"
mv "$JOB/result.tmp" "$JOB/result.env"
touch "$JOB/DONE"
rm -f "$OUT_FIFO" "$ERR_FIFO"
printf 'FBTERM_JOB_COMPLETE JOB_ID=%s EXIT=%s\n' \
"$JOB_ID" "$RC"
exit "$RC"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
umask 077
export HISTFILE=/dev/null
set +o history 2>/dev/null || true
printf '\n'
printf '========================================\n'
printf ' FrameBridge Terminal R1\n'
printf ' PTY persistente / comandos controlados\n'
printf '========================================\n'
printf '\n'
export FRAMEBRIDGE_FBTERM_ROOT="$HOME/.local/share/framebridge/fbterm"
if [ -n "$FRAMEBRIDGE_FBTERM_ROOT_OVERRIDE" ]; then
export FRAMEBRIDGE_FBTERM_ROOT="$FRAMEBRIDGE_FBTERM_ROOT_OVERRIDE"
fi
export PROMPT_COMMAND='"$FRAMEBRIDGE_FBTERM_ROOT/bin/fbterm-prompt-ready"'
exec bash --noprofile --norc
+497
View File
@@ -0,0 +1,497 @@
#!/usr/bin/env python3
import fcntl
import hashlib
import os
from pathlib import Path
import re
import subprocess
import time
class FBTermError(RuntimeError):
pass
class FBTermBusy(FBTermError):
pass
class FBTerm:
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
def __init__(self, root, session, title):
self.root = Path(root)
self.session = session
self.title = title
self.bin = self.root / "bin"
self.jobs = self.root / "jobs"
self.runner = self.bin / "fbterm-run"
self.shell = self.bin / "fbterm-shell"
self.lock_path = self.root / "controller.lock"
@staticmethod
def _run(args, **kwargs):
return subprocess.run(
args,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
**kwargs,
)
def has_session(self):
p = self._run(
["tmux", "has-session", "-t", self.session]
)
return p.returncode == 0
def ensure(self, visible=True):
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
self.jobs.mkdir(mode=0o700, parents=True, exist_ok=True)
if not self.has_session():
env = os.environ.copy()
env["FBTERM_ROOT"] = str(self.root)
env["FBTERM_SESSION"] = self.session
env["PATH"] = str(self.bin) + os.pathsep + env.get("PATH", "")
p = subprocess.run(
[
"tmux",
"new-session",
"-d",
"-s",
self.session,
str(self.shell),
],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if p.returncode != 0:
raise FBTermError(
"tmux new-session fallo: " + p.stderr.strip()
)
subprocess.run(
[
"tmux",
"set-environment",
"-t",
self.session,
"FBTERM_ROOT",
str(self.root),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
[
"tmux",
"set-environment",
"-t",
self.session,
"FBTERM_SESSION",
self.session,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
[
"tmux",
"set-option",
"-t",
self.session,
"allow-rename",
"off",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
[
"tmux",
"set-option",
"-t",
self.session,
"set-titles",
"off",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
[
"tmux",
"send-keys",
"-t",
self.session + ":0.0",
(
"export FBTERM_ROOT="
+ subprocess.list2cmdline([str(self.root)])
+ "; export FBTERM_SESSION="
+ subprocess.list2cmdline([self.session])
+ "; export PATH="
+ subprocess.list2cmdline(
[str(self.bin) + ":$PATH"]
)
),
"C-m",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(0.5)
if visible and not self.visible_window():
subprocess.Popen(
[
"gnome-terminal",
"--title=" + self.title,
"--",
"bash",
"-lc",
(
"exec tmux attach-session -r -t "
+ subprocess.list2cmdline([self.session])
),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
for _ in range(40):
if self.visible_window():
break
time.sleep(0.1)
return self.status()
def visible_window(self):
p = self._run(["wmctrl", "-l"])
return (
p.returncode == 0
and self.title in p.stdout
)
def status(self):
if not self.has_session():
return {
"session": False,
"visible": False,
"pane_pid": None,
"pane_tty": None,
"pane_command": None,
"clients": 0,
"readonly_clients": 0,
}
fmt = (
"#{pane_pid}|#{pane_tty}|"
"#{pane_current_command}"
)
p = self._run(
[
"tmux",
"display-message",
"-p",
"-t",
self.session + ":0.0",
fmt,
]
)
parts = p.stdout.strip().split("|", 2)
pane_pid = parts[0] if len(parts) > 0 else ""
pane_tty = parts[1] if len(parts) > 1 else ""
pane_command = parts[2] if len(parts) > 2 else ""
clients = self._run(
[
"tmux",
"list-clients",
"-t",
self.session,
"-F",
"#{client_readonly}",
]
)
values = [
x.strip()
for x in clients.stdout.splitlines()
if x.strip()
]
readonly = sum(
1
for x in values
if x in ("1", "on", "true")
)
return {
"session": True,
"visible": self.visible_window(),
"pane_pid": pane_pid,
"pane_tty": pane_tty,
"pane_command": pane_command,
"clients": len(values),
"readonly_clients": readonly,
}
def execute(self, job_id, command, timeout_s=120):
if not self.JOB_RE.fullmatch(job_id):
raise FBTermError("JOB_ID invalido")
if not isinstance(command, str) or not command:
raise FBTermError("command vacio")
timeout_s = int(timeout_s)
if timeout_s < 1 or timeout_s > 3600:
raise FBTermError("timeout fuera de rango")
self.ensure(visible=True)
lock_fd = os.open(
self.lock_path,
os.O_CREAT | os.O_RDWR,
0o600,
)
try:
try:
fcntl.flock(
lock_fd,
fcntl.LOCK_EX | fcntl.LOCK_NB,
)
except BlockingIOError:
raise FBTermBusy("FBTERM ocupado")
# R2: fail-closed si existe evidencia durable incompleta.
# La reconciliacion conserva esos directorios fuera de jobs.
incomplete_jobs = []
try:
for prior in self.jobs.iterdir():
if (
prior.is_dir()
and (
not (prior / "DONE").exists()
or not (prior / "result.env").exists()
)
):
incomplete_jobs.append(prior.name)
except OSError as exc:
raise FBTermError(
"no se pudo auditar jobs previos: " + str(exc)
)
if incomplete_jobs:
sample = ", ".join(sorted(incomplete_jobs)[:3])
raise FBTermBusy(
"FBTERM tiene job incompleto: " + sample
)
job = self.jobs / job_id
if job.exists():
raise FBTermError("JOB_ID ya existe")
job.mkdir(mode=0o700)
command_file = job / "command.bash"
command_file.write_text(
command,
encoding="utf-8",
)
os.chmod(command_file, 0o600)
(job / "timeout.seconds").write_text(
str(timeout_s) + "\n",
encoding="ascii",
)
expected_command_sha = hashlib.sha256(
command.encode("utf-8")
).hexdigest()
# R2 idle gate: prompt.ready existe solo cuando el shell
# persistente esta realmente esperando el siguiente comando.
ready_path = self.jobs.parent / "prompt.ready"
ready_deadline = time.monotonic() + 3.0
while time.monotonic() < ready_deadline:
if ready_path.exists():
break
time.sleep(0.02)
else:
raise FBTermBusy(
"FBTERM pane no esta en prompt"
)
try:
ready_path.unlink()
except FileNotFoundError:
raise FBTermBusy(
"FBTERM prompt cambio antes del envio"
)
except OSError as exc:
raise FBTermError(
"no se pudo reservar prompt: " + str(exc)
)
send = self._run(
[
"tmux",
"send-keys",
"-t",
self.session + ":0.0",
"fbterm-run " + job_id,
"C-m",
]
)
if send.returncode != 0:
raise FBTermError(
"tmux send-keys fallo: "
+ send.stderr.strip()
)
deadline = time.monotonic() + timeout_s + 8
while time.monotonic() < deadline:
if (job / "DONE").exists():
break
time.sleep(0.05)
if not (job / "DONE").exists():
raise FBTermError(
"resultado durable no aparecio"
)
# R2: no liberar el lock hasta que bash haya vuelto realmente
# al prompt. fbterm-shell actualiza prompt.ready mediante
# PROMPT_COMMAND despues de que fbterm-run haya terminado.
done_path = job / "DONE"
prompt_deadline = time.monotonic() + 5.0
prompt_ready = False
while time.monotonic() < prompt_deadline:
try:
if (
ready_path.exists()
and ready_path.stat().st_mtime_ns
> done_path.stat().st_mtime_ns
):
prompt_ready = True
break
except OSError:
pass
time.sleep(0.02)
if not prompt_ready:
raise FBTermError(
"prompt durable no aparecio"
)
result = {}
for line in (job / "result.env").read_text(
encoding="utf-8"
).splitlines():
if "=" in line:
k, v = line.split("=", 1)
result[k] = v
result["EXPECTED_COMMAND_SHA256"] = (
expected_command_sha
)
result["COMMAND_HASH_GATE"] = (
"PASS"
if result.get("COMMAND_SHA256")
== expected_command_sha
else "FAIL"
)
result["STDOUT_PATH"] = str(
job / "stdout.bin"
)
result["STDERR_PATH"] = str(
job / "stderr.bin"
)
return result
finally:
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
except Exception:
pass
os.close(lock_fd)
def file_text(path):
return Path(path).read_text(
encoding="utf-8",
errors="replace",
)
if __name__ == "__main__":
import json
import sys
root = os.environ["FBTERM_ROOT"]
session = os.environ["FBTERM_SESSION"]
title = os.environ["FBTERM_TITLE"]
term = FBTerm(root, session, title)
action = sys.argv[1]
if action == "ensure":
print(json.dumps(term.ensure(), sort_keys=True))
elif action == "status":
print(json.dumps(term.status(), sort_keys=True))
elif action == "exec":
job_id = sys.argv[2]
timeout_s = int(sys.argv[3])
command_path = sys.argv[4]
try:
r = term.execute(
job_id,
file_text(command_path),
timeout_s,
)
print(json.dumps(r, sort_keys=True))
except FBTermBusy as e:
print(json.dumps({
"ERROR": "BUSY",
"MESSAGE": str(e),
}))
sys.exit(75)
else:
raise SystemExit(64)
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
umask 077
ROOT="${FBTERM_ROOT:-$HOME/.local/share/framebridge/fbterm}"
LIB="$ROOT/library"
JOBS="$ROOT/jobs"
valid() {
[[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]
}
secret_guard() {
grep -Eqi \
'BEGIN .*PRIVATE KEY|rclone\.conf|client_secret|access_token|refresh_token|password[[:space:]]*=|token[[:space:]]*=|cookie[[:space:]]*=' \
"$1"
}
case "${1:-}" in
list)
mkdir -p "$LIB"
find "$LIB" -maxdepth 1 -type f -name '*.sh' -printf '%f\n' |
sed 's/\.sh$//' | sort
;;
adopt)
job="${2:-}"
name="${3:-}"
valid "$job" && valid "$name" || {
echo 'FBX_ERROR=INVALID_NAME'
exit 64
}
src="$JOBS/$job/command.bash"
dst="$LIB/$name.sh"
[ -f "$src" ] || {
echo 'FBX_ERROR=JOB_COMMAND_NOT_FOUND'
exit 66
}
[ ! -e "$dst" ] || {
echo 'FBX_ERROR=LIBRARY_NAME_EXISTS'
exit 73
}
cp -p "$src" "$dst"
chmod 600 "$dst"
sha256sum "$dst" | awk '{print $1}' > "$LIB/$name.sha256"
printf '%s\n' "$job" > "$LIB/$name.source-job"
echo "FBX_ADOPT=PASS NAME=$name JOB=$job SHA256=$(cat "$LIB/$name.sha256")"
;;
hash)
name="${2:-}"
valid "$name" || exit 64
file="$LIB/$name.sh"
[ -f "$file" ] || exit 66
sha256sum "$file"
;;
show|run)
action="$1"
name="${2:-}"
valid "$name" || {
echo 'FBX_ERROR=INVALID_NAME'
exit 64
}
file="$LIB/$name.sh"
hashfile="$LIB/$name.sha256"
[ -f "$file" ] && [ -f "$hashfile" ] || {
echo 'FBX_ERROR=LIBRARY_ITEM_NOT_FOUND'
exit 66
}
expected="$(cat "$hashfile")"
actual="$(sha256sum "$file" | awk '{print $1}')"
[ "$actual" = "$expected" ] || {
echo 'FBX_ERROR=HASH_MISMATCH'
exit 74
}
if secret_guard "$file"; then
echo 'FBX_ERROR=POSSIBLE_SECRET_REFUSE_DISPLAY'
exit 77
fi
echo
echo "========== FBX SCRIPT: $name =========="
echo "SHA256=$actual"
echo
cat "$file"
echo
echo "========== END FBX SCRIPT =========="
echo
[ "$action" = show ] && exit 0
bash --noprofile --norc "$file"
rc=$?
echo
echo "========== FBX RESULT: $name EXIT=$rc =========="
exit "$rc"
;;
status)
echo "FBX_ROOT=$ROOT"
echo "FBX_LIBRARY=$LIB"
echo "FBX_ITEMS=$(find "$LIB" -maxdepth 1 -type f -name '*.sh' | wc -l)"
;;
*)
echo 'USAGE: fbx {status|list|adopt JOB NAME|show NAME|run NAME|hash NAME}'
exit 64
;;
esac
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import json
import os
import socket
import threading
from pathlib import Path
_lock = threading.Lock()
_active = False
def _socket_path():
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
return runtime / "framebridge-voice-r1.sock"
def _listen_worker():
global _active
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(30)
s.connect(str(_socket_path()))
s.sendall(b'{"cmd":"listen_once"}\n')
f = s.makefile("rb")
f.readline() # respuesta local; NO se expone
s.close()
except Exception:
pass
finally:
with _lock:
_active = False
def start_listen_once():
global _active
with _lock:
if _active:
return {"ok": True, "started": False, "reason": "already_active"}
if not _socket_path().is_socket():
return {"ok": False, "error": "VOICE_DAEMON_NOT_AVAILABLE"}
_active = True
threading.Thread(target=_listen_worker, daemon=True).start()
return {"ok": True, "started": True}
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
import json
import os
import stat
import time
from pathlib import Path
_ALLOWED = {"escuchando", "pensando", "trabajando", "listo", "detenido"}
_MAX_BYTES = 16384
_MAX_AGE_SECONDS = 5.0
def read_voice_status(home=None):
base = Path(home).expanduser() if home else Path.home()
path = base / ".local/share/framebridge-voice/state-r1.json"
safe = {
"state": "listo",
"available": False,
"updated_at": None,
}
try:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
if hasattr(os, "O_NONBLOCK"):
flags |= os.O_NONBLOCK
fd = os.open(path, flags)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
return safe
if st.st_uid != os.getuid():
return safe
if st.st_size > _MAX_BYTES:
return safe
raw = os.read(fd, _MAX_BYTES + 1)
finally:
os.close(fd)
if len(raw) > _MAX_BYTES:
return safe
data = json.loads(raw.decode("utf-8"))
state = str(data.get("status", "listo")).lower()
if state not in _ALLOWED:
state = "listo"
updated = data.get("updated_at")
if not isinstance(updated, (int, float)):
return safe
age = max(0.0, time.time() - float(updated))
if age > _MAX_AGE_SECONDS:
return safe
if state == "detenido":
state = "listo"
return {
"state": state,
"available": True,
"updated_at": float(updated),
}
except (
FileNotFoundError,
PermissionError,
OSError,
ValueError,
json.JSONDecodeError,
):
return safe
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
set +e
ACTION="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
[ -n "$ACTION" ] || ACTION="ENSURE"
[ "$ACTION" = "OPEN" ] && ACTION="ENSURE"
case "$ACTION" in
ENSURE|FOCUS|STATUS|CLOSE|TOGGLE) ;;
*)
echo "INTERACTIVE_TERMINAL_GATE=FAIL"
echo "ERROR=INVALID_ACTION"
exit 2
;;
esac
TITLE="FrameBridge - Terminal"
STATE_DIR="${FRAMEBRIDGE_UI_STATE_DIR:-$HOME/.local/share/framebridge/ui-state}"
STATE_FILE="$STATE_DIR/interactive-terminal.window"
DISPLAY_VALUE="$(printenv DISPLAY 2>/dev/null)"
[ -n "$DISPLAY_VALUE" ] || DISPLAY_VALUE=":0"
XAUTH_VALUE="$(printenv XAUTHORITY 2>/dev/null)"
[ -n "$XAUTH_VALUE" ] || XAUTH_VALUE="$HOME/.Xauthority"
TERM_FG="#a8ffb5"
TERM_BG="#010402"
TERM_CURSOR="#c8ffd1"
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR" 2>/dev/null || true
window_exists()
{
W="$1"
[ -n "$W" ] || return 1
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool getwindowname "$W" >/dev/null 2>&1
}
read_state_window()
{
if [ -r "$STATE_FILE" ]; then
head -n1 "$STATE_FILE" 2>/dev/null | tr -cd '0-9'
fi
}
write_state_window()
{
W="$1"
printf '%s\n' "$W" > "$STATE_FILE"
chmod 600 "$STATE_FILE" 2>/dev/null || true
}
clear_state()
{
rm -f "$STATE_FILE"
}
W="$(read_state_window)"
if ! window_exists "$W"; then
W=""
clear_state
fi
if [ "$ACTION" = "STATUS" ]; then
echo "INTERACTIVE_TERMINAL_GATE=PASS"
if [ -n "$W" ]; then
echo "STATE=ON"
echo "WINDOW_ID=$W"
else
echo "STATE=OFF"
echo "WINDOW_ID="
fi
echo "INTERACTIVE=YES"
echo "STYLE=GREEN_MONOCHROME"
exit 0
fi
if [ "$ACTION" = "TOGGLE" ]; then
if [ -n "$W" ]; then
ACTION="CLOSE"
else
ACTION="ENSURE"
fi
fi
if [ "$ACTION" = "CLOSE" ]; then
if [ -n "$W" ]; then
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool windowclose "$W" >/dev/null 2>&1 || true
for _ in $(seq 1 30); do
if ! window_exists "$W"; then
break
fi
sleep 0.1
done
fi
clear_state
echo "INTERACTIVE_TERMINAL_GATE=PASS"
echo "ACTION=CLOSE"
echo "STATE=OFF"
echo "WINDOW_ID="
echo "INTERACTIVE=YES"
echo "STYLE=GREEN_MONOCHROME"
exit 0
fi
if [ -z "$W" ]; then
BEFORE="$(mktemp)"
AFTER="$(mktemp)"
trap 'rm -f "$BEFORE" "$AFTER"' EXIT
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" wmctrl -lx 2>/dev/null | awk '$3 ~ /gnome-terminal-server.Gnome-terminal/ {print $1}' | sort -u > "$BEFORE"
export FB_TERM_TITLE="$TITLE"
export FB_TERM_FG="$TERM_FG"
export FB_TERM_BG="$TERM_BG"
export FB_TERM_CURSOR="$TERM_CURSOR"
if command -v gnome-terminal >/dev/null 2>&1; then
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" gnome-terminal --title="$TITLE" -- bash -lc '
printf "\033]0;%s\007" "$FB_TERM_TITLE"
printf "\033]10;%s\007" "$FB_TERM_FG"
printf "\033]11;%s\007" "$FB_TERM_BG"
printf "\033]12;%s\007" "$FB_TERM_CURSOR"
exec bash -l
' >/dev/null 2>&1 &
elif command -v xfce4-terminal >/dev/null 2>&1; then
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xfce4-terminal --title="$TITLE" --command="bash -lc 'printf \"\\033]10;$TERM_FG\\007\\033]11;$TERM_BG\\007\"; exec bash -l'" >/dev/null 2>&1 &
elif command -v xterm >/dev/null 2>&1; then
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xterm -T "$TITLE" -fg "$TERM_FG" -bg "$TERM_BG" -e bash -l >/dev/null 2>&1 &
else
echo "INTERACTIVE_TERMINAL_GATE=FAIL"
echo "ERROR=NO_SUPPORTED_TERMINAL"
exit 3
fi
for _ in $(seq 1 50); do
TITLE_W="$(DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool search --name "^$TITLE$" 2>/dev/null | tail -n1)"
if window_exists "$TITLE_W"; then
W="$TITLE_W"
break
fi
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" wmctrl -lx 2>/dev/null | awk '$3 ~ /gnome-terminal-server.Gnome-terminal/ {print $1}' | sort -u > "$AFTER"
NEW_HEX="$(comm -13 "$BEFORE" "$AFTER" | tail -n1)"
if [ -n "$NEW_HEX" ]; then
W="$((NEW_HEX))"
if window_exists "$W"; then
break
fi
W=""
fi
sleep 0.1
done
if [ -z "$W" ]; then
echo "INTERACTIVE_TERMINAL_GATE=FAIL"
echo "ERROR=WINDOW_NOT_FOUND"
exit 4
fi
write_state_window "$W"
fi
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool windowactivate --sync "$W" >/dev/null 2>&1 || true
echo "INTERACTIVE_TERMINAL_GATE=PASS"
echo "ACTION=$ACTION"
echo "STATE=ON"
echo "WINDOW_ID=$W"
echo "INTERACTIVE=YES"
echo "STYLE=GREEN_MONOCHROME"
echo "TERM_FG=$TERM_FG"
echo "TERM_BG=$TERM_BG"
exit 0
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set +e
ACTION="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
[ -n "$ACTION" ] || ACTION="ENSURE"
case "$ACTION" in
ENSURE|FOCUS|STATUS) ;;
*)
echo "PROGRESS_CONSOLE_GATE=FAIL"
echo "ERROR=INVALID_ACTION"
exit 2
;;
esac
TITLE="FrameBridge - Progreso DIG"
LOG_DIR="${FRAMEBRIDGE_PROGRESS_DIR:-$HOME/.local/state/framebridge/progress}"
LOG="${FRAMEBRIDGE_PROGRESS_LOG:-$LOG_DIR/framebridge-progress.log}"
DISPLAY_VALUE="$(printenv DISPLAY 2>/dev/null)"
[ -n "$DISPLAY_VALUE" ] || DISPLAY_VALUE=":0"
XAUTH_VALUE="$(printenv XAUTHORITY 2>/dev/null)"
[ -n "$XAUTH_VALUE" ] || XAUTH_VALUE="$HOME/.Xauthority"
mkdir -p "$LOG_DIR"
chmod 700 "$LOG_DIR" 2>/dev/null || true
touch "$LOG"
chmod 600 "$LOG" 2>/dev/null || true
if [ ! -s "$LOG" ]; then
cat > "$LOG" <<'HEADER'
============================================================
FRAMEBRIDGE ASUS // DIG PROGRESS
MODO: SOLO LECTURA
============================================================
HEADER
fi
find_window()
{
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool search --name "^$TITLE$" 2>/dev/null | tail -n1
}
W="$(find_window)"
if [ "$ACTION" = "STATUS" ]; then
echo "PROGRESS_CONSOLE_GATE=PASS"
if [ -n "$W" ]; then
echo "STATE=ON"
echo "WINDOW_ID=$W"
else
echo "STATE=OFF"
echo "WINDOW_ID="
fi
echo "LOG=$LOG"
echo "READ_ONLY=YES"
echo "STYLE=GREEN_MONOCHROME_DIG"
exit 0
fi
if [ -z "$W" ]; then
export FB_PROGRESS_LOG="$LOG"
export FB_PROGRESS_TITLE="$TITLE"
if command -v gnome-terminal >/dev/null 2>&1; then
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" gnome-terminal --title="$TITLE" -- bash -lc '
printf "\033]0;%s\007" "$FB_PROGRESS_TITLE"
printf "\033]10;#a8ffb5\007"
printf "\033]11;#010402\007"
printf "\033[1;38;2;168;255;181m"
printf "============================================================\n"
printf " FRAMEBRIDGE // DIG PROGRESS // SOLO LECTURA\n"
printf "============================================================\n"
exec tail -n 200 -F "$FB_PROGRESS_LOG"
' >/dev/null 2>&1 &
elif command -v xterm >/dev/null 2>&1; then
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xterm -T "$TITLE" -fg "#a8ffb5" -bg "#010402" -e bash -lc 'exec tail -n 200 -F "$FB_PROGRESS_LOG"' >/dev/null 2>&1 &
else
echo "PROGRESS_CONSOLE_GATE=FAIL"
echo "ERROR=NO_SUPPORTED_TERMINAL"
exit 3
fi
for _ in $(seq 1 50); do
W="$(find_window)"
[ -n "$W" ] && break
sleep 0.1
done
fi
if [ -z "$W" ]; then
echo "PROGRESS_CONSOLE_GATE=FAIL"
echo "ERROR=WINDOW_NOT_FOUND"
exit 4
fi
if command -v wmctrl >/dev/null 2>&1; then
GEOM="$(DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool getdisplaygeometry 2>/dev/null)"
SW="$(printf '%s' "$GEOM" | awk '{print $1}')"
SH="$(printf '%s' "$GEOM" | awk '{print $2}')"
if [ -n "$SW" ] && [ -n "$SH" ]; then
H=330
Y=$((SH - H - 28))
[ "$Y" -lt 0 ] && Y=0
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" wmctrl -ir "$W" -e "0,0,$Y,$SW,$H" >/dev/null 2>&1 || true
fi
fi
if [ "$ACTION" = "FOCUS" ]; then
DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool windowactivate --sync "$W" >/dev/null 2>&1 || true
fi
echo "PROGRESS_CONSOLE_GATE=PASS"
echo "ACTION=$ACTION"
echo "STATE=ON"
echo "WINDOW_ID=$W"
echo "LOG=$LOG"
echo "READ_ONLY=YES"
echo "STYLE=GREEN_MONOCHROME_DIG"
exit 0
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SESSION="framebridge"
tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION"
if command -v gnome-terminal >/dev/null 2>&1; then
exec gnome-terminal -- bash -lc "tmux attach -t '$SESSION'"
elif command -v xfce4-terminal >/dev/null 2>&1; then
exec xfce4-terminal --command="bash -lc \"tmux attach -t '$SESSION'\""
elif command -v konsole >/dev/null 2>&1; then
exec konsole -e bash -lc "tmux attach -t '$SESSION'"
elif command -v xterm >/dev/null 2>&1; then
exec xterm -e tmux attach -t "$SESSION"
else
echo "No encontré terminal gráfica compatible."
echo "Ejecuta manualmente: tmux attach -t $SESSION"
exit 1
fi
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import re
import socket
import stat
import struct
import subprocess
import sys
from pathlib import Path
VERSION = 1
MAX_SCRIPT_BYTES = 262144
MAX_REQUEST_BYTES = 393216
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
HOME = Path.home()
FBROOT = HOME / ".local/share/framebridge/fbterm"
ENGINE = FBROOT / "bin/fbterm_engine.py"
ROOT = Path(
os.environ.get(
"FRAMEBRIDGE_TOOL_ROOT",
str(HOME / ".local/share/framebridge/tool-adapter")
)
)
SOCKET_PATH = ROOT / "adapter.sock"
INBOX = ROOT / "inbox"
PID_FILE = ROOT / "adapter.pid"
NATIVE_HOST = Path(os.environ.get("FRAMEBRIDGE_NATIVE_HOST", str(HOME / ".local/lib/framebridge-native/host/framebridge_host.py")))
def prepare_root():
ROOT.mkdir(parents=True, exist_ok=True)
INBOX.mkdir(parents=True, exist_ok=True)
os.chmod(ROOT, 0o700)
os.chmod(INBOX, 0o700)
def recv_request(conn):
data = bytearray()
while True:
chunk = conn.recv(65536)
if not chunk:
break
data.extend(chunk)
if len(data) > MAX_REQUEST_BYTES:
raise ValueError("REQUEST_TOO_LARGE")
if b"\n" in data:
break
raw = bytes(data).split(b"\n", 1)[0]
if not raw:
raise ValueError("EMPTY_REQUEST")
return json.loads(raw.decode("utf-8"))
def send_response(conn, obj):
raw = (
json.dumps(
obj,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
+ "\n"
).encode("utf-8")
try:
conn.sendall(raw)
except (BrokenPipeError, ConnectionResetError):
return False
return True
def peer_uid(conn):
fmt = "3i"
raw = conn.getsockopt(
socket.SOL_SOCKET,
socket.SO_PEERCRED,
struct.calcsize(fmt),
)
_pid, uid, _gid = struct.unpack(fmt, raw)
return uid
def last_result_final(stdout_path):
try:
lines = Path(stdout_path).read_text(
encoding="utf-8",
errors="replace",
).splitlines()
except OSError:
return None
for line in reversed(lines):
if line.startswith("RESULTADO_FINAL="):
return line.split("=", 1)[1].strip()[:2000]
return None
def engine_result(stdout):
for line in reversed(stdout.splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
return obj
return None
def native_cua_roundtrip(payload, timeout_s=60):
if not NATIVE_HOST.is_file():
raise ValueError("FRAMEBRIDGE_NATIVE_HOST_NOT_FOUND")
raw = json.dumps(
payload,
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
frame = struct.pack("<I", len(raw)) + raw
try:
proc = subprocess.run(
[str(NATIVE_HOST)],
input=frame,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_s,
check=False,
)
except subprocess.TimeoutExpired:
raise ValueError("CUA_NATIVE_HOST_TIMEOUT")
out = proc.stdout or b""
if len(out) < 4:
raise ValueError("CUA_NATIVE_RESPONSE_HEADER_MISSING")
size = struct.unpack("<I", out[:4])[0]
if size <= 0 or size > 2_000_000:
raise ValueError("CUA_NATIVE_RESPONSE_SIZE_INVALID")
body = out[4:4 + size]
if len(body) != size:
raise ValueError("CUA_NATIVE_RESPONSE_TRUNCATED")
try:
response = json.loads(body.decode("utf-8"))
except Exception:
raise ValueError("CUA_NATIVE_RESPONSE_INVALID_JSON")
if not isinstance(response, dict):
raise ValueError("CUA_NATIVE_RESPONSE_NOT_OBJECT")
return response
def handle_cua_request(req, job_id):
timeout_s = int(req.get("timeout_s", 60))
if not 1 <= timeout_s <= 180:
raise ValueError("INVALID_CUA_TIMEOUT")
cua = req.get("cua")
if not isinstance(cua, dict):
raise ValueError("CUA_PAYLOAD_REQUIRED")
payload = dict(cua)
payload["op"] = "cua"
payload["id"] = job_id
payload["target"] = "ASUS"
payload["authorized"] = "YES"
response = native_cua_roundtrip(
payload,
timeout_s=timeout_s,
)
return {
"version": VERSION,
"ok": bool(response.get("executed")),
"job_id": job_id,
"mode": "CUA",
"action": str(payload.get("action", ""))[:32],
"channel": str(response.get("channel", "CUA-DIRECT")),
"exit_code": response.get("exit_code"),
"frame_output": str(response.get("output", "")),
"error": str(response.get("error", "")),
}
def handle(req):
if not isinstance(req, dict):
raise ValueError("REQUEST_NOT_OBJECT")
if req.get("version") != VERSION:
raise ValueError("UNSUPPORTED_VERSION")
job_id = str(req.get("job_id", ""))
if not JOB_RE.fullmatch(job_id):
raise ValueError("INVALID_JOB_ID")
mode = str(req.get("mode", "EXEC")).upper()
if mode != "EXEC":
raise ValueError("UNSUPPORTED_MODE")
if req.get("authorized") is not True:
raise ValueError("AUTHORIZED_TRUE_REQUIRED")
request_type = str(
req.get("request_type", "exec")
).lower().strip()
if request_type == "cua":
return handle_cua_request(req, job_id)
if request_type != "exec":
raise ValueError("UNSUPPORTED_REQUEST_TYPE")
script = req.get("script")
if not isinstance(script, str) or not script:
raise ValueError("SCRIPT_REQUIRED")
script_bytes = script.encode("utf-8")
if len(script_bytes) > MAX_SCRIPT_BYTES:
raise ValueError("SCRIPT_TOO_LARGE")
timeout_s = int(req.get("timeout_s", 120))
if not 1 <= timeout_s <= 600:
raise ValueError("INVALID_TIMEOUT")
digest = hashlib.sha256(script_bytes).hexdigest()
expected = req.get("sha256")
if expected is not None:
expected = str(expected).lower()
if digest != expected:
raise ValueError("SCRIPT_SHA256_MISMATCH")
command_path = INBOX / f"{job_id}.bash"
temp_path = INBOX / f".{job_id}.tmp"
temp_path.write_bytes(script_bytes)
os.chmod(temp_path, 0o600)
temp_path.replace(command_path)
proc = subprocess.run(
[
sys.executable,
str(ENGINE),
"exec",
job_id,
str(timeout_s),
str(command_path),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout_s + 45,
check=False,
env={
**os.environ,
"FBTERM_ROOT": str(FBROOT),
"FBTERM_SESSION": os.environ.get(
"FBTERM_SESSION",
"framebridge-fbterm-r1",
),
"FBTERM_TITLE": os.environ.get(
"FBTERM_TITLE",
"FrameBridge Terminal R1",
),
},
)
result = engine_result(proc.stdout)
if result is None:
return {
"version": VERSION,
"ok": False,
"job_id": job_id,
"adapter_error": "ENGINE_RESULT_NOT_FOUND",
"engine_rc": proc.returncode,
"engine_stderr": proc.stderr[-2000:],
"command_sha256": digest,
}
stdout_path = result.get("STDOUT_PATH")
stderr_path = result.get("STDERR_PATH")
return {
"version": VERSION,
"ok": True,
"job_id": job_id,
"mode": mode,
"command_sha256": digest,
"engine_rc": proc.returncode,
"exit_code": result.get("EXIT"),
"result_final": (
last_result_final(stdout_path)
if stdout_path
else None
),
"stdout_path": stdout_path,
"stderr_path": stderr_path,
"stdout_sha256": result.get("STDOUT_SHA256"),
"stderr_sha256": result.get("STDERR_SHA256"),
"command_hash_gate": result.get(
"COMMAND_HASH_GATE"
),
"pty": result.get("PTY"),
"pane_pid": result.get("PANE_PID"),
"pane_tty": result.get("PANE_TTY"),
"shell_ready": result.get("SHELL_READY"),
}
def serve():
prepare_root()
if not ENGINE.is_file():
raise RuntimeError("FBTERM_ENGINE_NOT_FOUND")
if SOCKET_PATH.exists():
st = SOCKET_PATH.lstat()
if not stat.S_ISSOCK(st.st_mode):
raise RuntimeError("SOCKET_PATH_OCCUPIED")
SOCKET_PATH.unlink()
server = socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
)
server.bind(str(SOCKET_PATH))
os.chmod(SOCKET_PATH, 0o600)
server.listen(4)
PID_FILE.write_text(
str(os.getpid()) + "\n",
encoding="ascii",
)
os.chmod(PID_FILE, 0o600)
try:
while True:
conn, _ = server.accept()
with conn:
try:
if peer_uid(conn) != os.getuid():
raise PermissionError(
"PEER_UID_MISMATCH"
)
req = recv_request(conn)
resp = handle(req)
except Exception as exc:
resp = {
"version": VERSION,
"ok": False,
"adapter_error": (
f"{type(exc).__name__}:"
f"{exc}"
)[:2000],
}
send_response(conn, resp)
finally:
server.close()
try:
SOCKET_PATH.unlink()
except FileNotFoundError:
pass
if __name__ == "__main__":
action = sys.argv[1] if len(sys.argv) > 1 else ""
if action == "serve":
serve()
else:
print(
json.dumps(
{
"ok": False,
"error": "USAGE: serve",
},
sort_keys=True,
)
)
raise SystemExit(64)
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import json
import os
import socket
import sys
from pathlib import Path
HOME = Path.home()
ROOT = Path(
os.environ.get(
"FRAMEBRIDGE_TOOL_ROOT",
str(HOME / ".local/share/framebridge/tool-adapter")
)
)
SOCKET_PATH = ROOT / "adapter.sock"
if len(sys.argv) != 2:
print("USAGE: framebridge_tool_client.py REQUEST.json")
raise SystemExit(64)
request_path = Path(sys.argv[1])
request = json.loads(
request_path.read_text(encoding="utf-8")
)
raw = (
json.dumps(
request,
ensure_ascii=True,
separators=(",", ":"),
)
+ "\n"
).encode("utf-8")
sock = socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
)
sock.connect(str(SOCKET_PATH))
sock.sendall(raw)
sock.shutdown(socket.SHUT_WR)
data = bytearray()
while True:
chunk = sock.recv(65536)
if not chunk:
break
data.extend(chunk)
sock.close()
response = json.loads(
bytes(data).decode("utf-8")
)
print(
json.dumps(
response,
ensure_ascii=True,
sort_keys=True,
)
)
@@ -0,0 +1,59 @@
{
"origin": "Original text-console source capture; community derivative",
"files": [
{
"source": "gerameg_cua_runtime.py",
"path": "gerameg_cua_runtime.py",
"bytes": 5049,
"sha256": "381a7dc740227e27085e66d5f7e5d398a09f86eff66d6aefce9025e896634887"
},
{
"source": "real_tty_manager.py",
"path": "real_tty_manager.py",
"bytes": 9647,
"sha256": "3c8fe23cf1499172d4064f04f373656f6fd6109a1c1bc01657d669aabd4121d8"
},
{
"source": "cua_tty_rpc.py",
"path": "cua_tty_rpc.py",
"bytes": 9957,
"sha256": "e64b0b9283c8143b5fc4db2c6020c288927f0a26a9c4fd42cfc8aca8bfc36175"
},
{
"source": "framebridge_cua_tty/__init__.py",
"path": "framebridge_cua_tty/__init__.py",
"bytes": 98,
"sha256": "a873aecc609c08bd8b703e0917370278a64e6ad24fc96b5da2fd04c9d958855c"
},
{
"source": "framebridge_cua_tty/client.py",
"path": "framebridge_cua_tty/client.py",
"bytes": 1465,
"sha256": "6a1fc004f2d50944661fcce67002b04534721bc3118eaf44e3f0694e19d15fe7"
},
{
"source": "framebridge_cua_tty/core.py",
"path": "framebridge_cua_tty/core.py",
"bytes": 7850,
"sha256": "1c259afd5233f8dc4b0df58c2e69e7df346637e20f8c6fc24303bdb7a4e29590"
},
{
"source": "framebridge_cua_tty/demo.py",
"path": "framebridge_cua_tty/demo.py",
"bytes": 1385,
"sha256": "22614e47bc2189f83fed4ef294bd48a78bea587b3619f8d333eff74570dee39d"
},
{
"source": "framebridge_cua_tty/screen.py",
"path": "framebridge_cua_tty/screen.py",
"bytes": 5362,
"sha256": "20f47b9d21189f1e9ac679ba7a0a01d0298d7dcce459d88cfb589504be0893d7"
},
{
"source": "framebridge_cua_tty/server.py",
"path": "framebridge_cua_tty/server.py",
"bytes": 5977,
"sha256": "7c803ca0de25e28914fd15804d0cc785c4362d38c8a29b6f7fb377c5b963674d"
}
]
}
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""RPC local CUA-TTY de GERAMEG, invocado por el FBEXEC original firmado."""
from __future__ import annotations
import base64
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import socket
import stat
import sys
import uuid
UID = 1000
HOSTNAME = "GERAMEG"
HOME = Path.home()
SOCKET_PATH = Path("/run/user/1000/framebridge-alpha01-cua/agent.sock")
CLIENT_PATH = Path("/opt/framebridge-alpha-01/vendor/framebridge_cua_tty/client.py")
CLIENT_SHA256 = "6a1fc004f2d50944661fcce67002b04534721bc3118eaf44e3f0694e19d15fe7"
RESULTS_DIR = HOME / ".local/share/framebridge/cua-tty-results"
PROTOCOL = "FB-CUA-TTY-RPC/1"
MAX_BYTES = 65536
INLINE_BYTES = 3000
CHUNK_BYTES = 2100
SID = re.compile(r"[A-Za-z0-9_-]{12,80}\Z")
REPLY_ID = re.compile(r"[0-9a-f]{32}\Z")
KEYS = frozenset(['UP', 'DOWN', 'LEFT', 'RIGHT', 'ENTER', 'RETURN', 'TAB', 'SHIFT_TAB', 'ESC', 'BACKSPACE', 'DELETE', 'INSERT', 'HOME', 'END', 'PAGE_UP', 'PAGE_DOWN', 'SPACE', 'CTRL_A', 'CTRL_B', 'CTRL_C', 'CTRL_D', 'CTRL_E', 'CTRL_F', 'CTRL_G', 'CTRL_H', 'CTRL_I', 'CTRL_J', 'CTRL_K', 'CTRL_L', 'CTRL_M', 'CTRL_N', 'CTRL_O', 'CTRL_P', 'CTRL_Q', 'CTRL_R', 'CTRL_S', 'CTRL_T', 'CTRL_U', 'CTRL_V', 'CTRL_W', 'CTRL_X', 'CTRL_Y', 'CTRL_Z', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'])
SENSITIVE = re.compile(r"(?i)(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|authorization|cookie|credential)")
KEY_BEGIN = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
KEY_END = re.compile(r"-----END [A-Z0-9 ]*PRIVATE KEY-----")
class RpcError(RuntimeError):
pass
def require(ok, code):
if not ok:
raise RpcError(code)
def encode(value):
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("ascii")
def sanitize(value):
# Sanear ANTES de persistir o codificar en base64, igual que el stdout original.
private_key = [False]
def clean(item):
if isinstance(item, dict):
return {key: clean(val) for key, val in item.items()}
if isinstance(item, list):
return [clean(val) for val in item]
if not isinstance(item, str):
return item
lines = []
for line in item.splitlines():
if KEY_BEGIN.search(line):
private_key[0] = True
lines.append("[REDACTED PRIVATE KEY BLOCK]")
elif private_key[0]:
if KEY_END.search(line):
private_key[0] = False
elif SENSITIVE.search(line) and ("=" in line or ":" in line):
lines.append("[REDACTED SENSITIVE LINE]")
else:
lines.append(line)
return "\n".join(lines)
return clean(value)
def validate_request(req):
require(type(req) is dict and type(req.get("op")) is str, "REQUEST_INVALID")
op = req["op"]
shapes = {
"status": {"op"}, "open_demo": {"op"}, "open_shell": {"op"},
"observe": {"op", "session_id"}, "abort": {"op", "session_id"},
"resize": {"op", "session_id", "rows", "cols"},
"key_send": {"op", "session_id", "key"},
"text_type": {"op", "session_id", "text"},
}
require(op in shapes and set(req) == shapes[op], "REQUEST_INVALID")
if "session_id" in req:
require(type(req["session_id"]) is str and SID.fullmatch(req["session_id"]) is not None, "SESSION_INVALID")
if op == "resize":
require(type(req["rows"]) is int and type(req["cols"]) is int and 5 <= req["rows"] <= 100 and 20 <= req["cols"] <= 220, "SIZE_INVALID")
if op == "key_send":
require(type(req["key"]) is str and req["key"] in KEYS, "KEY_NOT_ALLOWLISTED")
if op == "text_type":
text = req["text"]
require(type(text) is str and 0 < len(text.encode("utf-8")) <= 16384 and "\x00" not in text, "TEXT_NOT_ALLOWED")
try:
text.encode("utf-8")
except UnicodeEncodeError:
raise RpcError("TEXT_NOT_ALLOWED") from None
args = {key: val for key, val in req.items() if key != "op"}
if op == "open_shell":
return "open", {"program": "bash"}
if op == "open_demo":
return "open", {"program": "demo-readonly"}
if op in {"key_send", "text_type"}:
# Modo local legado autorizado por el propietario; no es una firma Owner.
args["owner_assertion"] = {}
op = {"key_send": "key.send", "text_type": "text.type"}[op]
return op, args
def guard_runtime():
require(os.getuid() == UID and os.geteuid() == UID, "UID_MISMATCH")
require(socket.gethostname().upper() == HOSTNAME, "HOSTNAME_MISMATCH")
require(Path.home().resolve() == HOME.resolve(), "HOME_MISMATCH")
def load_client():
require(CLIENT_PATH.is_file() and not CLIENT_PATH.is_symlink(), "CLIENT_PATH_INVALID")
source = CLIENT_PATH.read_bytes()
require(hashlib.sha256(source).hexdigest() == CLIENT_SHA256, "CLIENT_SHA256_MISMATCH")
spec = importlib.util.spec_from_file_location("_gerameg_cua_client_pinned", CLIENT_PATH)
require(spec is not None and spec.loader is not None, "CLIENT_IMPORT_INVALID")
module = importlib.util.module_from_spec(spec)
exec(compile(source, str(CLIENT_PATH), "exec"), module.__dict__)
return module.rpc
def backend_call(req):
op, args = validate_request(req)
try:
parent, target = SOCKET_PATH.parent.lstat(), SOCKET_PATH.lstat()
except OSError:
raise RpcError("CUA_TTY_OFFLINE") from None
require(stat.S_ISDIR(parent.st_mode) and parent.st_uid == UID and parent.st_mode & 0o077 == 0, "SOCKET_PARENT_UNTRUSTED")
require(stat.S_ISSOCK(target.st_mode) and target.st_uid == UID and target.st_mode & 0o077 == 0, "SOCKET_UNTRUSTED")
reply = load_client()(op, path=SOCKET_PATH, **args)
require(type(reply) is dict and type(reply.get("ok")) is bool, "BACKEND_RESPONSE_INVALID")
if reply.get("ok") is True:
require(type(reply.get("result")) is dict, "BACKEND_RESPONSE_INVALID")
return sanitize(reply)
def result_directory(create=False):
if create:
RESULTS_DIR.mkdir(mode=0o700, exist_ok=True)
require(RESULTS_DIR.resolve() == RESULTS_DIR.absolute(), "RESULT_DIRECTORY_UNSAFE")
fd = os.open(RESULTS_DIR, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
st = os.fstat(fd)
if st.st_uid != UID or st.st_mode & 0o077:
os.close(fd)
raise RpcError("RESULT_DIRECTORY_UNSAFE")
return fd
def pack_reply(reply):
reply = sanitize(reply)
data = encode(reply)
require(0 < len(data) <= MAX_BYTES, "RESPONSE_TOO_LARGE")
meta = {"protocol": PROTOCOL, "ok": True, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
# El sobre ASCII evita que campos del backend como "authorization_mode"
# hagan que el saneador de stdout sustituya toda una línea JSON válida.
inline = {**meta, "kind": "inline", "data_b64": base64.b64encode(data).decode("ascii")}
if len(encode(inline)) <= INLINE_BYTES:
return inline
reply_id = uuid.uuid4().hex
directory = result_directory(create=True)
try:
fd = os.open(reply_id + ".json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.fsync(directory)
finally:
os.close(directory)
return {**meta, "kind": "file", "reply_id": reply_id}
def read_chunk(reply_id, offset):
require(type(reply_id) is str and REPLY_ID.fullmatch(reply_id) is not None, "REPLY_ID_INVALID")
require(type(offset) is int and 0 <= offset < MAX_BYTES and offset % CHUNK_BYTES == 0, "OFFSET_INVALID")
directory = result_directory()
try:
fd = os.open(reply_id + ".json", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory)
with os.fdopen(fd, "rb") as stream:
st = os.fstat(stream.fileno())
require(stat.S_ISREG(st.st_mode) and st.st_nlink == 1 and st.st_uid == UID and st.st_mode & 0o077 == 0, "RESULT_FILE_UNSAFE")
require(0 < st.st_size <= MAX_BYTES, "RESULT_SIZE_INVALID")
data = stream.read(MAX_BYTES + 1)
require(len(data) == st.st_size, "RESULT_SIZE_CHANGED")
finally:
os.close(directory)
require(offset < len(data), "OFFSET_INVALID")
chunk = data[offset:offset + CHUNK_BYTES]
return {"protocol": PROTOCOL, "ok": True, "kind": "chunk", "reply_id": reply_id,
"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(),
"offset": offset, "length": len(chunk), "data_b64": base64.b64encode(chunk).decode("ascii")}
def dispatch(argv):
require(type(argv) is list, "CLI_INVALID")
if len(argv) == 2 and argv[0] == "invoke":
require(len(argv[1]) <= 50000, "REQUEST_TOO_LARGE")
try:
raw = base64.b64decode(argv[1], validate=True)
require(len(raw) <= 40000, "REQUEST_TOO_LARGE")
request = json.loads(raw)
except (ValueError, UnicodeError):
raise RpcError("REQUEST_ENCODING_INVALID") from None
return pack_reply(backend_call(request))
if len(argv) == 3 and argv[0] == "read":
require(re.fullmatch(r"0|[1-9][0-9]{0,4}", argv[2]) is not None, "OFFSET_INVALID")
return read_chunk(argv[1], int(argv[2]))
raise RpcError("CLI_INVALID")
def main():
try:
guard_runtime()
result = dispatch(sys.argv[1:])
except RpcError as exc:
result = {"protocol": PROTOCOL, "ok": False, "error": str(exc)}
except Exception as exc:
result = {"protocol": PROTOCOL, "ok": False, "error": "RPC_FAILED", "error_type": type(exc).__name__}
output = encode(result)
require(len(output) <= 3500, "ENVELOPE_TOO_LARGE")
sys.stdout.write(output.decode("ascii") + "\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
"""FrameBridge CUA-TTY prototype. No network or shell execution."""
__version__ = "0.1.0-staging"
@@ -0,0 +1,39 @@
"""Local staging inspection client; input operations cannot bypass OwnerDevice."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import socket
def rpc(op, *, path, **kwargs):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(4)
sock.connect(str(path))
sock.sendall((json.dumps({'op':op, **kwargs})+'\n').encode())
reply = bytearray()
while len(reply) < 65536:
chunk = sock.recv(min(8192, 65536-len(reply)))
if not chunk: break
reply.extend(chunk)
if b'\n' in chunk: break
if len(reply) >= 65536 or b'\n' not in reply: raise RuntimeError('RESPONSE_TOO_LARGE')
return json.loads(reply.split(b'\n',1)[0])
def main(argv=None):
p=argparse.ArgumentParser()
p.add_argument('op', choices=('status','open','observe','resize','abort','key.send','text.type'))
p.add_argument('--session-id',default='')
p.add_argument('--socket',default=str(Path.home()/'.local/share/framebridge-cua-tty-r1/agent.sock'))
p.add_argument('--key',default='')
p.add_argument('--text',default='')
args=p.parse_args(argv)
req={}
if args.session_id: req['session_id']=args.session_id
if args.key: req['key']=args.key
if args.text: req['text']=args.text
print(json.dumps(rpc(args.op,path=args.socket,**req),ensure_ascii=False,indent=2))
if __name__=='__main__':main()
@@ -0,0 +1,204 @@
"""Unprivileged local PTY session engine with mandatory owner gate for inputs.
Staging only: only fixed, no-side-effects demo program may be opened. Never
use an LLM-provided shell/argv or `authorized=True` as authority.
"""
from __future__ import annotations
from dataclasses import dataclass
import fcntl
import hashlib
import os
from pathlib import Path
import pty
import secrets
import select
import signal
import struct
import subprocess
import sys
import termios
import threading
import time
from typing import Callable
from .screen import Screen
class TerminalDenied(PermissionError):
pass
KEYS = {'UP': b'\x1b[A', 'DOWN': b'\x1b[B', 'LEFT': b'\x1b[D',
'RIGHT': b'\x1b[C', 'ENTER': b'\r', 'TAB': b'\t', 'ESC': b'\x1b',
'BACKSPACE': b'\x7f', 'CTRL_C': b'\x03'}
@dataclass
class Session:
session_id: str
proc: subprocess.Popen
fd: int
screen: Screen
born: float
last_access: float
program: str
sequence: int = 0
class Manager:
"""A trusted OwnerDevice verifier must implement callable(context)->bool.
IMPORTANT: no grants can be passed in via RPC, environment, or `authorized`.
An absent verifier rejects ALL input. A production verifier must validate
signed challenge, owner identity, exact payload hash, nonce, TTL and one-use.
"""
def __init__(self, owner_verifier: Callable | None = None, max_sessions: int = 2,
idle_timeout_s: int = 600):
if not 1 <= max_sessions <= 4: raise ValueError('MAX_SESSIONS_INVALID')
self._gate = owner_verifier
self._sessions = {}
self._lock = threading.RLock()
self.max_sessions, self.idle_timeout_s = max_sessions, idle_timeout_s
def _get(self, sid):
s = self._sessions.get(sid)
if not s: raise KeyError('SESSION_NOT_FOUND')
if time.monotonic() - s.last_access > self.idle_timeout_s:
self._terminate(s)
del self._sessions[sid]
raise TerminalDenied('SESSION_EXPIRED')
s.last_access = time.monotonic()
return s
@staticmethod
def _terminate(s):
if s.proc.poll() is None:
try: os.killpg(s.proc.pid, signal.SIGTERM)
except ProcessLookupError: pass
try: s.proc.wait(timeout=0.7)
except subprocess.TimeoutExpired:
try: os.killpg(s.proc.pid, signal.SIGKILL)
except ProcessLookupError: pass
try: s.proc.wait(timeout=1)
except subprocess.TimeoutExpired: pass
try: os.close(s.fd)
except OSError: pass
@staticmethod
def _child_ctty():
# CUA does not use setuid. Child has same UID as daemon.
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
def open(self, program='demo-readonly', rows=24, cols=80):
if program != 'demo-readonly':
raise TerminalDenied('PROGRAM_NOT_ALLOWLISTED')
if os.geteuid() == 0:
raise TerminalDenied('ROOT_DAEMON_FORBIDDEN')
with self._lock:
self.reap()
if len(self._sessions) >= self.max_sessions:
raise TerminalDenied('SESSION_LIMIT')
screen = Screen(rows, cols)
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
env = {'PATH': '/usr/bin:/bin', 'TERM': 'xterm', 'LANG': 'C.UTF-8',
'HOME': str(Path.home())}
try:
proc = subprocess.Popen([sys.executable, '-B', str(Path(__file__).with_name('demo.py'))],
stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True,
start_new_session=True, preexec_fn=self._child_ctty)
except BaseException:
os.close(master)
raise
finally:
os.close(slave)
os.set_blocking(master, False)
sid = secrets.token_hex(16)
now = time.monotonic()
self._sessions[sid] = Session(sid, proc, master, screen, now, now, program)
return {'session_id': sid, 'program': program, 'state': 'OPEN'}
def _drain(self, s):
total = 0
while total < 65536:
ready, _, _ = select.select([s.fd], [], [], 0)
if not ready: break
try: data = os.read(s.fd, min(4096, 65536-total))
except (BlockingIOError, OSError): break
if not data: break
total += len(data)
s.screen.feed(data)
return total
def observe(self, sid):
with self._lock:
s = self._get(sid)
n = self._drain(s)
s.sequence += 1
return {'session_id': sid, 'program': s.program, 'running': s.proc.poll() is None,
'sequence': s.sequence, 'drained_bytes': n, 'screen': s.screen.snapshot()}
def status(self):
with self._lock:
self.reap()
return {'active_sessions': len(self._sessions), 'max_sessions': self.max_sessions,
'owner_device_connected': self._gate is not None,
'input_enabled': False if self._gate is None else 'per-call-only'}
def _authorize(self, s, operation, payload: bytes, approved_context=None):
if self._gate is None:
raise TerminalDenied('OWNER_DEVICE_NOT_CONNECTED')
if approved_context is None:
raise TerminalDenied('OWNER_APPROVAL_REQUIRED')
context = {'session_id': s.session_id, 'program': s.program, 'operation': operation,
'payload_sha256': hashlib.sha256(payload).hexdigest(),
'owner_assertion': approved_context}
if self._gate(context) is not True:
raise TerminalDenied('OWNER_APPROVAL_INVALID_OR_REPLAYED')
def key(self, sid, key, approved_context=None):
if key not in KEYS:
raise TerminalDenied('KEY_NOT_ALLOWLISTED')
with self._lock:
s = self._get(sid)
data = KEYS[key]
self._authorize(s, 'key.send', data, approved_context)
if s.proc.poll() is not None: raise TerminalDenied('SESSION_CLOSED')
os.write(s.fd, data)
return {'session_id': sid, 'submitted': key, 'state': 'SENT'}
def text(self, sid, text, approved_context=None):
if not isinstance(text, str) or not 1 <= len(text) <= 64 or any(ord(c)<32 or ord(c)==127 for c in text):
raise TerminalDenied('TEXT_NOT_ALLOWED')
with self._lock:
s = self._get(sid)
data = text.encode('utf-8')
self._authorize(s, 'text.type', data, approved_context)
if s.proc.poll() is not None: raise TerminalDenied('SESSION_CLOSED')
os.write(s.fd, data)
return {'session_id': sid, 'submitted_chars': len(text), 'state': 'SENT'}
def resize(self, sid, rows, cols):
with self._lock:
s = self._get(sid)
s.screen.resize(rows, cols)
fcntl.ioctl(s.fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
return {'session_id': sid, 'rows': rows, 'cols': cols}
def abort(self, sid):
# Emergency stop always works, but only against its own demo session.
with self._lock:
s = self._get(sid)
self._terminate(s)
del self._sessions[sid]
return {'session_id': sid, 'state': 'ABORTED'}
def reap(self):
for sid, s in list(self._sessions.items()):
if time.monotonic() - s.last_access > self.idle_timeout_s:
self._terminate(s)
self._sessions.pop(sid, None)
def shutdown(self):
with self._lock:
for s in self._sessions.values(): self._terminate(s)
self._sessions.clear()
@@ -0,0 +1,43 @@
"""No-side-effects simulated interactive menu. Not a shell."""
import os
import sys
import tty
OPTIONS = ('Estado del sistema (demo)', 'Almacenamiento (demo)', 'Red (demo)', 'Salir')
def show(i):
sys.stdout.write('\x1b[2J\x1b[H')
sys.stdout.write('FrameBridge CUA-TTY - DEMOSTRACION AISLADA\r\n')
sys.stdout.write('NO ejecuta comandos; NO modifica el servidor.\r\n\r\n')
for n, label in enumerate(OPTIONS):
sys.stdout.write(('> ' if n == i else ' ') + label + '\r\n')
sys.stdout.write('\r\nUse flechas arriba/abajo, Enter y q.\r\n')
sys.stdout.flush()
def main():
tty.setcbreak(sys.stdin.fileno())
i = 0
show(i)
while True:
b = os.read(0, 1)
if not b: break
if b in (b'q', b'Q', b'\x03'): break
if b == b'\x1b':
seq = os.read(0, 2)
if seq == b'[A': i = (i - 1) % len(OPTIONS)
elif seq == b'[B': i = (i + 1) % len(OPTIONS)
elif b in (b'j', b'J'): i = (i + 1) % len(OPTIONS)
elif b in (b'k', b'K'): i = (i - 1) % len(OPTIONS)
elif b in (b'\r', b'\n'):
if i == len(OPTIONS) - 1: break
sys.stdout.write('\r\nSeleccion simulada: ' + OPTIONS[i] + '\r\n')
sys.stdout.flush()
show(i)
sys.stdout.write('\r\nDEMOSTRACION_FINALIZADA\r\n')
sys.stdout.flush()
if __name__ == '__main__':
main()
@@ -0,0 +1,121 @@
"""Small bounded VT-like ASCII/UTF-8 text screen for staging.
This is deliberately NOT advertised as a full xterm emulator. It handles common
CSI cursor/erase controls and suppresses OSC/DCS/other escape payloads.
"""
from __future__ import annotations
import codecs
import unicodedata
class Screen:
def __init__(self, rows: int = 24, cols: int = 80):
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
raise ValueError('SCREEN_DIMENSIONS_INVALID')
self.rows, self.cols = rows, cols
self.buf = [[' '] * cols for _ in range(rows)]
self.y = self.x = 0
self.mode = 'normal'
self.control = ''
self.decoder = codecs.getincrementaldecoder('utf-8')('replace')
self.max_control = 96
def reset(self):
self.buf = [[' '] * self.cols for _ in range(self.rows)]
self.y = self.x = 0
def resize(self, rows: int, cols: int):
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
raise ValueError('SCREEN_DIMENSIONS_INVALID')
new = [[' '] * cols for _ in range(rows)]
for y in range(min(rows, self.rows)):
for x in range(min(cols, self.cols)):
new[y][x] = self.buf[y][x]
self.rows, self.cols, self.buf = rows, cols, new
self.y = min(self.y, rows - 1)
self.x = min(self.x, cols - 1)
def scroll(self):
self.buf.pop(0)
self.buf.append([' '] * self.cols)
self.y = self.rows - 1
def newline(self):
self.y += 1
if self.y >= self.rows:
self.scroll()
def put(self, c: str):
if unicodedata.combining(c):
return
# Avoid unmodeled full-width terminal-cell ambiguity in this R1 pilot.
if unicodedata.east_asian_width(c) in ('W', 'F'):
c = '?'
if self.x >= self.cols:
self.x = 0
self.newline()
self.buf[self.y][self.x] = c
self.x += 1
def csi(self, final: str, args: str):
if len(args) > self.max_control:
return
if args.startswith('?'):
if final in ('h', 'l') and args in ('?1049', '?1047', '?47') and final == 'h':
self.reset()
return
nums = [int(a) if a.isdecimal() else 0 for a in args.split(';')[:8]]
n = nums[0] if nums else 0
count = max(1, min(n, 200))
if final == 'A': self.y = max(0, self.y - count)
elif final == 'B': self.y = min(self.rows - 1, self.y + count)
elif final == 'C': self.x = min(self.cols - 1, self.x + count)
elif final == 'D': self.x = max(0, self.x - count)
elif final in ('H', 'f'):
self.y = max(0, min(self.rows - 1, (nums[0] if nums and nums[0] else 1) - 1))
self.x = max(0, min(self.cols - 1, (nums[1] if len(nums) > 1 and nums[1] else 1) - 1))
elif final == 'G': self.x = max(0, min(self.cols - 1, count - 1))
elif final == 'd': self.y = max(0, min(self.rows - 1, count - 1))
elif final == 'J':
if n == 2: self.reset()
elif n == 0:
for y in range(self.y, self.rows):
start = self.x if y == self.y else 0
for x in range(start, self.cols): self.buf[y][x] = ' '
elif final == 'K':
start, end = (0, self.cols) if n == 2 else ((0, self.x + 1) if n == 1 else (self.x, self.cols))
for x in range(start, min(end, self.cols)): self.buf[self.y][x] = ' '
# SGR and unrecognized commands are intentionally ignored.
def feed(self, data: bytes):
for c in self.decoder.decode(data):
if self.mode == 'normal':
if c == '\x1b': self.mode = 'esc'
elif c == '\r': self.x = 0
elif c == '\n': self.newline()
elif c == '\b': self.x = max(0, self.x - 1)
elif c == '\t': self.x = min(self.cols - 1, (self.x // 8 + 1) * 8)
elif c.isprintable() and c not in '\x7f': self.put(c)
elif self.mode == 'esc':
if c == '[': self.mode, self.control = 'csi', ''
elif c == ']': self.mode, self.control = 'osc', ''
elif c in ('P', '^', '_'): self.mode, self.control = 'str', ''
elif c == 'c': self.reset(); self.mode = 'normal'
else: self.mode = 'normal'
elif self.mode == 'csi':
if '@' <= c <= '~':
self.csi(c, self.control); self.mode = 'normal'; self.control = ''
elif len(self.control) >= self.max_control: self.mode, self.control = 'discard_csi', ''
else: self.control += c
elif self.mode == 'discard_csi':
if '@' <= c <= '~': self.mode = 'normal'
elif self.mode in ('osc', 'str'):
if c == '\x07' and self.mode == 'osc': self.mode = 'normal'
elif c == '\x1b': self.mode = 'string_esc'
elif self.mode == 'string_esc':
if c == '\\': self.mode = 'normal'
elif c == '\x1b': self.mode = 'string_esc'
else: self.mode = 'str'
def snapshot(self):
return {'rows': self.rows, 'cols': self.cols, 'cursor': {'row': self.y, 'col': self.x},
'lines': [''.join(row).rstrip() for row in self.buf]}
@@ -0,0 +1,129 @@
"""Local-only JSON-lines Unix socket prototype. No TCP listener, no shell."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import socket
import socketserver
import stat
import struct
from .core import Manager, TerminalDenied
MAX_REQUEST = 65536
class Handler(socketserver.StreamRequestHandler):
def handle(self):
# On Linux: SO_PEERCRED is the connecting process's kernel UID.
try:
_, uid, _ = struct.unpack('3i', self.request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid != self.server.owner_uid:
return
raw = self.rfile.readline(MAX_REQUEST + 1)
if len(raw) > MAX_REQUEST or not raw.endswith(b'\n'):
self.send({'ok': False, 'error': 'REQUEST_TOO_LARGE_OR_TRUNCATED'})
return
req = json.loads(raw)
if type(req) is not dict or type(req.get('op')) is not str:
self.send({'ok': False, 'error': 'INVALID_REQUEST'})
return
# Only specific RPC shapes. The caller cannot supply binary argv,
# host path, a shell, an approval bool, or an OwnerDevice verifier.
op = req['op']
# Enforce exact wire shape for every operation. Untrusted clients
# may not attach arbitrary authority or additional arguments.
permitted = {
'status': ({'op'},),
'open': ({'op'}, {'op','program'}),
'observe': ({'op','session_id'},),
'resize': ({'op','session_id','rows','cols'},),
'abort': ({'op','session_id'},),
'key.send': ({'op','session_id','key','owner_assertion'},),
'text.type': ({'op','session_id','text','owner_assertion'},),
}
accepted = permitted.get(op)
if accepted is None:
raise TerminalDenied('UNKNOWN_OPERATION')
if set(req) not in accepted:
raise TerminalDenied('REQUEST_INVALID')
m = self.server.manager
if op == 'status': data = m.status()
elif op == 'open': data = m.open(req.get('program', 'demo-readonly'))
elif op == 'observe': data = m.observe(req['session_id'])
elif op == 'resize': data = m.resize(req['session_id'], req['rows'], req['cols'])
elif op == 'abort': data = m.abort(req['session_id'])
elif op in ('key.send', 'text.type'):
# Server default remains Manager(owner_verifier=None) -> DENY.
# A separately deployed trusted bootstrap must bind a real
# WebAuthn/Owner verifier. Neither AUTHORIZED nor an RPC bool
# can alter the verifier. Never accept arbitrary shell/argv.
if m._gate is None:
raise TerminalDenied('OWNER_DEVICE_NOT_CONNECTED')
if type(req.get('owner_assertion')) is not dict:
raise TerminalDenied('OWNER_APPROVAL_REQUIRED')
required = ({'op', 'session_id', 'key', 'owner_assertion'}
if op == 'key.send' else
{'op', 'session_id', 'text', 'owner_assertion'})
if set(req) != required:
raise TerminalDenied('REQUEST_INVALID')
if op == 'key.send':
data = m.key(req['session_id'], req['key'],
approved_context=req['owner_assertion'])
else:
data = m.text(req['session_id'], req['text'],
approved_context=req['owner_assertion'])
else: raise TerminalDenied('UNKNOWN_OPERATION')
self.send({'ok': True, 'result': data})
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
self.send({'ok': False, 'error': 'REQUEST_INVALID'})
except TerminalDenied as exc:
self.send({'ok': False, 'error': str(exc)})
except Exception:
# Never echo arbitrary error content, terminal output or secrets.
self.send({'ok': False, 'error': 'INTERNAL_ERROR'})
def send(self, data):
self.wfile.write((json.dumps(data, ensure_ascii=True, separators=(',', ':')) + '\n').encode())
class LocalServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
allow_reuse_address = False
def __init__(self, path, manager=None):
if os.geteuid() == 0:
raise TerminalDenied('ROOT_SERVER_FORBIDDEN')
p = Path(path).absolute()
p.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
# Do not operate in a shared or symlinked parent directory.
if p.parent.is_symlink() or stat.S_IMODE(p.parent.stat().st_mode) != 0o700:
raise TerminalDenied('SOCKET_PARENT_NOT_PRIVATE')
if p.exists() or p.is_symlink():
raise TerminalDenied('SOCKET_ALREADY_EXISTS')
self.owner_uid = os.geteuid()
self.manager = manager or Manager()
self._socket_path = p
old = os.umask(0o077)
try: super().__init__(str(p), Handler)
finally: os.umask(old)
os.chmod(p, 0o600)
def server_close(self):
self.manager.shutdown()
super().server_close()
try: self._socket_path.unlink(missing_ok=True)
except OSError: pass
def main(argv=None):
parser = argparse.ArgumentParser(description='FrameBridge CUA-TTY staging demo, local socket only')
parser.add_argument('--socket', default=str(Path.home()/'.local/share/framebridge-cua-tty-r1/agent.sock'))
args = parser.parse_args(argv)
with LocalServer(args.socket) as server:
print('CUA_TTY_R1_LOCAL_ONLY_SOCKET_READY', flush=True)
try: server.serve_forever(poll_interval=0.2)
except KeyboardInterrupt: pass
if __name__ == '__main__':
main()
@@ -0,0 +1,132 @@
#!/usr/bin/python3
"""GERAMEG CUA-TTY with the explicitly selected local-user input policy.
Reuse the installed LocalServer and Manager without changing their source,
socket protocol, existing demos and PTY handling, or input/session limits for demos.
Remote callers must still enter through authenticated FrameBridge. This
process authenticates LOCAL peers by the original LocalServer's SO_PEERCRED
check. It cannot distinguish an authenticated FBEXEC process from another
process running as gerabh (UID 1000). Both have the same local authority.
The legacy owner_assertion RPC field remains mandatory for wire compatibility
but must be an empty object. It is not a grant, signature, or approval flag.
No Owner R12 signing code, private keys, or FBAUTH state is loaded.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import re
import signal
import stat
EXPECTED_UID = 1000
EXPECTED_HOST = "GERAMEG"
DEFAULT_SOCKET = "/run/user/1000/framebridge-alpha01-cua/agent.sock"
_SESSION_ID = re.compile(r"[a-f0-9]{32}\Z")
_PAYLOAD_HASH = re.compile(r"[a-f0-9]{64}\Z")
_CONTEXT_FIELDS = frozenset(
{"session_id", "program", "operation", "payload_sha256", "owner_assertion"}
)
def require_local_identity() -> None:
if (
os.getuid() != EXPECTED_UID
or os.geteuid() != EXPECTED_UID
or os.uname().nodename.upper() != EXPECTED_HOST
):
raise RuntimeError("CUA_LOCAL_UID_OR_HOST_INVALID")
class LocalUidVerifier:
"""Apply the selected UID policy after LocalServer checks the peer UID.
Manager constructs this context after resolving its own live session and
validating the input. No caller-provided boolean or assertion grants access.
"""
def __call__(self, context: object) -> bool:
try:
require_local_identity()
if type(context) is not dict or set(context) != _CONTEXT_FIELDS:
return False
if context.get("program") not in ("demo-readonly", "bash"):
return False
if context.get("operation") not in ("key.send", "text.type"):
return False
session_id = context.get("session_id")
payload_hash = context.get("payload_sha256")
if type(session_id) is not str or _SESSION_ID.fullmatch(session_id) is None:
return False
if type(payload_hash) is not str or _PAYLOAD_HASH.fullmatch(payload_hash) is None:
return False
assertion = context.get("owner_assertion")
return type(assertion) is dict and not assertion
except (AttributeError, OSError, RuntimeError, TypeError, ValueError):
return False
def _stop(_signum: int, _frame: object) -> None:
# Exiting the LocalServer context closes only this runtime's own sessions
# and socket. Do not leave a stale socket after a normal systemd stop.
raise SystemExit(0)
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="GERAMEG CUA-TTY local UID policy")
parser.add_argument("--socket", default=DEFAULT_SOCKET)
args = parser.parse_args(argv)
require_local_identity()
if args.socket != DEFAULT_SOCKET:
raise RuntimeError("CUA_LOCAL_SOCKET_INVALID")
parent = Path(args.socket).parent
try:
parent_state = parent.lstat()
except FileNotFoundError:
# LocalServer creates its own private directory with mode 0700.
pass
else:
if (
not stat.S_ISDIR(parent_state.st_mode)
or parent_state.st_uid != EXPECTED_UID
or stat.S_IMODE(parent_state.st_mode) != 0o700
):
raise RuntimeError("CUA_LOCAL_SOCKET_PARENT_UNTRUSTED")
# Keep imports on the existing service's installed PYTHONPATH. This file
# does not copy, patch, or replace the original CUA-TTY package.
from framebridge_cua_tty.core import Manager
from framebridge_cua_tty.server import LocalServer
from real_tty_manager import RealTerminalMixin
class LocalPolicyManager(RealTerminalMixin, Manager):
def status(self):
value = super().status()
# A callable verifier is not evidence of an Owner device. Report
# the selected authority honestly while retaining session status.
value["owner_device_connected"] = False
value["input_enabled"] = "same-uid-1000"
value["input_policy"] = "local-uid-1000"
value["owner_signature_required"] = False
return value
manager = LocalPolicyManager(
owner_verifier=LocalUidVerifier(), max_sessions=2, idle_timeout_s=600
)
signal.signal(signal.SIGTERM, _stop)
with LocalServer(args.socket, manager=manager) as server:
print("CUA_TTY_READY POLICY=LOCAL_UID_1000 OWNER_SIGNATURE_REQUIRED=NO", flush=True)
try:
server.serve_forever(poll_interval=0.2)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
@@ -0,0 +1,190 @@
"""Real terminals for the existing FrameBridge CUA-TTY Manager.
Transport identity and same-UID socket policy are unchanged. No Owner prompts.
Only this module's tmux sessions are operated on; no existing tty is hijacked.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import os
from pathlib import Path
import re
import subprocess
import time
import uuid
from framebridge_cua_tty.core import TerminalDenied
TMUX_SOCKET = '/run/user/1000/framebridge-alpha01-cua/real-tmux.sock'
KEY_NAMES = {'UP':'Up','DOWN':'Down','LEFT':'Left','RIGHT':'Right',
'ENTER':'Enter','RETURN':'Enter','TAB':'Tab','SHIFT_TAB':'BTab','ESC':'Escape',
'BACKSPACE':'BSpace','DELETE':'DC','INSERT':'IC','HOME':'Home','END':'End',
'PAGE_UP':'PPage','PAGE_DOWN':'NPage','SPACE':'Space'}
KEY_NAMES.update({'CTRL_'+c:'C-'+c.lower() for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'})
KEY_NAMES.update({'F'+str(i):'F'+str(i) for i in range(1,13)})
@dataclass
class RealSession:
session_id: str
name: str
pane: str
born: float
program: str = 'bash'
sequence: int = 0
class RealTerminalMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._real = {}
def _tmux(self, *args, data=None, optional=False):
env={k:v for k,v in os.environ.items() if k not in ('TMUX','TMUX_PANE')}
env.update({'HOME':str(Path.home()),'USER':'gerabh','LOGNAME':'gerabh',
'LANG':'C.UTF-8','TERM':'xterm-256color','HISTFILE':'/dev/null'})
try:
p=subprocess.run(['/usr/bin/tmux','-S',TMUX_SOCKET,'-f','/dev/null',*args],
input=data,stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env,timeout=3)
except subprocess.TimeoutExpired:
raise TerminalDenied('TMUX_COMMAND_TIMEOUT') from None
except OSError:
raise TerminalDenied('TMUX_NOT_AVAILABLE') from None
if p.returncode and not optional:
raise TerminalDenied('TMUX_COMMAND_FAILED')
return p
def _info(self, session):
keys=['pane_width','pane_height','cursor_x','cursor_y','pane_dead',
'pane_dead_status','pane_pid']
fmt='|'.join('#{'+key+'}' for key in keys)
p=self._tmux('display-message','-p','-t',session.pane,fmt)
parts=p.stdout.decode('utf-8','replace').rstrip('\n').split('|')
if len(parts)!=len(keys):raise TerminalDenied('TMUX_METADATA_INVALID')
info=dict(zip(keys,parts))
for key in ['pane_current_path','pane_current_command','pane_tty']:
p=self._tmux('display-message','-p','-t',session.pane,'#{'+key+'}')
info[key]=p.stdout.decode('utf-8','replace').rstrip('\n')
return info
def open(self, program='demo-readonly', rows=24, cols=80):
if program!='bash':
with self._lock:
self.reap()
if len(self._sessions)+len(self._real)>=self.max_sessions:
raise TerminalDenied('SESSION_LIMIT')
return super().open(program,rows,cols)
if not (5<=rows<=100 and 20<=cols<=220):raise TerminalDenied('SIZE_INVALID')
with self._lock:
self.reap()
if len(self._sessions)+len(self._real)>=self.max_sessions:
raise TerminalDenied('SESSION_LIMIT')
sid=uuid.uuid4().hex
name='fb-real-'+sid
pane=None
try:
p=self._tmux('new-session','-d','-s',name,'-x',str(cols),'-y',str(rows),
'-c',str(Path.home()),'-P','-F','#{pane_id}',
'/usr/bin/bash --noprofile --norc -i')
pane=p.stdout.decode().strip()
if not re.fullmatch(r'%[0-9]+',pane):raise TerminalDenied('PANE_ID_INVALID')
self._tmux('set-option','-p','-t',pane,'remain-on-exit','on')
self._tmux('set-option','-t',name,'status','off')
self._tmux('set-option','-t',name,'history-limit','10000')
s=RealSession(sid,name,pane,time.monotonic())
self._real[sid]=s
info=self._info(s)
return {'session_id':sid,'program':'bash','state':'OPEN','backend':'tmux',
'real_terminal':True,'tty':info['pane_tty'],'pid':int(info['pane_pid']),
'cwd':info['pane_current_path'],'persistent_between_chat_calls':True,
'attach_command':'tmux -S '+TMUX_SOCKET+' attach-session -t '+name}
except BaseException:
self._real.pop(sid,None)
self._tmux('kill-session','-t',name,optional=True)
raise
def status(self):
with self._lock:
value=super().status()
sessions=[]
for sid,s in list(self._real.items()):
try:
info=self._info(s)
except TerminalDenied:
self._real.pop(sid,None)
continue
sessions.append({'session_id':sid,'program':'bash',
'running':info['pane_dead']!='1','tty':info['pane_tty'],
'cwd':info['pane_current_path'],'command':info['pane_current_command']})
value.update({'active_sessions':value['active_sessions']+len(sessions),
'real_sessions':sessions,'real_terminal_enabled':True,
'real_programs':['bash'],'input_policy':'local-uid-1000',
'owner_device_connected':False,'owner_signature_required':False,
'input_enabled':'same-uid-1000','real_idle_timeout_s':None})
return value
def observe(self, sid):
with self._lock:
if sid not in self._real:return super().observe(sid)
s=self._real[sid];info=self._info(s)
text=self._tmux('capture-pane','-p','-t',s.pane).stdout.decode('utf-8','replace')
lines=text.splitlines()
# The original RPC response has a finite frame size, not an approval gate.
limited=False
import json
while len(json.dumps(lines,ensure_ascii=True).encode())>48000:
lines=lines[:-1];limited=True
return {'session_id':sid,'program':'bash','real_terminal':True,'backend':'tmux',
'running':info['pane_dead']!='1','exit_code':int(info['pane_dead_status']) if info['pane_dead_status'] else None,
'cwd':info['pane_current_path'],'current_command':info['pane_current_command'],
'tty':info['pane_tty'],'pid':int(info['pane_pid']),'sequence':s.sequence,
'screen':{'rows':int(info['pane_height']),'cols':int(info['pane_width']),
'cursor':{'row':int(info['cursor_y']),'col':int(info['cursor_x'])},'lines':lines},
'screen_truncated':limited,'screen_sha256':hashlib.sha256(text.encode()).hexdigest()}
def text(self, sid, text, approved_context=None):
with self._lock:
if sid not in self._real:return super().text(sid,text,approved_context=approved_context)
if not isinstance(text,str) or not text or '\x00' in text or len(text.encode())>16384:
raise TerminalDenied('TEXT_INVALID_OR_TOO_LARGE')
s=self._real[sid];payload=text.encode('utf-8')
self._authorize(s,'text.type',payload,approved_context)
if self._info(s)['pane_dead']=='1':raise TerminalDenied('SESSION_EXITED')
name='fb-input-'+sid
self._tmux('load-buffer','-b',name,'-',data=payload)
try:self._tmux('paste-buffer','-d','-r','-b',name,'-t',s.pane)
finally:self._tmux('delete-buffer','-b',name,optional=True)
s.sequence+=1
return {'session_id':sid,'state':'TYPED','submitted_chars':len(text),'sequence':s.sequence}
def key(self, sid, key, approved_context=None):
with self._lock:
if sid not in self._real:return super().key(sid,key,approved_context=approved_context)
if not isinstance(key,str) or key not in KEY_NAMES:raise TerminalDenied('KEY_NOT_ALLOWLISTED')
s=self._real[sid]
self._authorize(s,'key.send',key.encode(),approved_context)
if self._info(s)['pane_dead']=='1':raise TerminalDenied('SESSION_EXITED')
self._tmux('send-keys','-t',s.pane,KEY_NAMES[key]);s.sequence+=1
return {'session_id':sid,'state':'KEY_SENT','submitted':key,'sequence':s.sequence}
def resize(self, sid, rows, cols):
with self._lock:
if sid not in self._real:return super().resize(sid,rows,cols)
if type(rows) is not int or type(cols) is not int or not (5<=rows<=100 and 20<=cols<=220):
raise TerminalDenied('SIZE_INVALID')
self._tmux('resize-window','-t',self._real[sid].pane,'-x',str(cols),'-y',str(rows))
self._real[sid].sequence+=1
return {'session_id':sid,'rows':rows,'cols':cols}
def abort(self, sid):
with self._lock:
if sid not in self._real:return super().abort(sid)
s=self._real[sid]
self._tmux('kill-session','-t',s.name)
self._real.pop(sid,None)
probe=self._tmux('has-session','-t',s.name,optional=True)
if probe.returncode==0:raise TerminalDenied('SESSION_CLOSE_NOT_VERIFIED')
return {'session_id':sid,'state':'CLOSED','cleanup_verified':True}
def shutdown(self):
with self._lock:
for sid in list(self._real):
try:self.abort(sid)
except TerminalDenied:pass
super().shutdown()
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import re
import socket
import stat
import struct
import subprocess
import sys
from pathlib import Path
VERSION = 1
MAX_SCRIPT_BYTES = 262144
MAX_REQUEST_BYTES = 393216
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
HOME = Path.home()
FBROOT = HOME / ".local/share/framebridge/fbterm"
ENGINE = FBROOT / "bin/fbterm_engine.py"
ROOT = Path(
os.environ.get(
"FRAMEBRIDGE_TOOL_ROOT",
str(HOME / ".local/share/framebridge/tool-adapter")
)
)
SOCKET_PATH = ROOT / "adapter.sock"
INBOX = ROOT / "inbox"
PID_FILE = ROOT / "adapter.pid"
NATIVE_HOST = HOME / "Descargas/framebridge_v0_1/host/framebridge_host.py"
def prepare_root():
ROOT.mkdir(parents=True, exist_ok=True)
INBOX.mkdir(parents=True, exist_ok=True)
os.chmod(ROOT, 0o700)
os.chmod(INBOX, 0o700)
def recv_request(conn):
data = bytearray()
while True:
chunk = conn.recv(65536)
if not chunk:
break
data.extend(chunk)
if len(data) > MAX_REQUEST_BYTES:
raise ValueError("REQUEST_TOO_LARGE")
if b"\n" in data:
break
raw = bytes(data).split(b"\n", 1)[0]
if not raw:
raise ValueError("EMPTY_REQUEST")
return json.loads(raw.decode("utf-8"))
def send_response(conn, obj):
raw = (
json.dumps(
obj,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
+ "\n"
).encode("utf-8")
try:
conn.sendall(raw)
except (BrokenPipeError, ConnectionResetError):
return False
return True
def peer_uid(conn):
fmt = "3i"
raw = conn.getsockopt(
socket.SOL_SOCKET,
socket.SO_PEERCRED,
struct.calcsize(fmt),
)
_pid, uid, _gid = struct.unpack(fmt, raw)
return uid
def last_result_final(stdout_path):
try:
lines = Path(stdout_path).read_text(
encoding="utf-8",
errors="replace",
).splitlines()
except OSError:
return None
for line in reversed(lines):
if line.startswith("RESULTADO_FINAL="):
return line.split("=", 1)[1].strip()[:2000]
return None
def engine_result(stdout):
for line in reversed(stdout.splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
return obj
return None
def native_cua_roundtrip(payload, timeout_s=60):
if not NATIVE_HOST.is_file():
raise ValueError("FRAMEBRIDGE_NATIVE_HOST_NOT_FOUND")
raw = json.dumps(
payload,
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
frame = struct.pack("<I", len(raw)) + raw
try:
proc = subprocess.run(
[str(NATIVE_HOST)],
input=frame,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_s,
check=False,
)
except subprocess.TimeoutExpired:
raise ValueError("CUA_NATIVE_HOST_TIMEOUT")
out = proc.stdout or b""
if len(out) < 4:
raise ValueError("CUA_NATIVE_RESPONSE_HEADER_MISSING")
size = struct.unpack("<I", out[:4])[0]
if size <= 0 or size > 2_000_000:
raise ValueError("CUA_NATIVE_RESPONSE_SIZE_INVALID")
body = out[4:4 + size]
if len(body) != size:
raise ValueError("CUA_NATIVE_RESPONSE_TRUNCATED")
try:
response = json.loads(body.decode("utf-8"))
except Exception:
raise ValueError("CUA_NATIVE_RESPONSE_INVALID_JSON")
if not isinstance(response, dict):
raise ValueError("CUA_NATIVE_RESPONSE_NOT_OBJECT")
return response
def handle_cua_request(req, job_id):
timeout_s = int(req.get("timeout_s", 60))
if not 1 <= timeout_s <= 180:
raise ValueError("INVALID_CUA_TIMEOUT")
cua = req.get("cua")
if not isinstance(cua, dict):
raise ValueError("CUA_PAYLOAD_REQUIRED")
payload = dict(cua)
payload["op"] = "cua"
payload["id"] = job_id
payload["target"] = "ASUS"
payload["authorized"] = "YES"
response = native_cua_roundtrip(
payload,
timeout_s=timeout_s,
)
return {
"version": VERSION,
"ok": bool(response.get("executed")),
"job_id": job_id,
"mode": "CUA",
"action": str(payload.get("action", ""))[:32],
"channel": str(response.get("channel", "CUA-DIRECT")),
"exit_code": response.get("exit_code"),
"frame_output": str(response.get("output", "")),
"error": str(response.get("error", "")),
}
def handle(req):
if not isinstance(req, dict):
raise ValueError("REQUEST_NOT_OBJECT")
if req.get("version") != VERSION:
raise ValueError("UNSUPPORTED_VERSION")
job_id = str(req.get("job_id", ""))
if not JOB_RE.fullmatch(job_id):
raise ValueError("INVALID_JOB_ID")
mode = str(req.get("mode", "EXEC")).upper()
if mode != "EXEC":
raise ValueError("UNSUPPORTED_MODE")
if req.get("authorized") is not True:
raise ValueError("AUTHORIZED_TRUE_REQUIRED")
request_type = str(
req.get("request_type", "exec")
).lower().strip()
if request_type == "cua":
return handle_cua_request(req, job_id)
if request_type != "exec":
raise ValueError("UNSUPPORTED_REQUEST_TYPE")
script = req.get("script")
if not isinstance(script, str) or not script:
raise ValueError("SCRIPT_REQUIRED")
script_bytes = script.encode("utf-8")
if len(script_bytes) > MAX_SCRIPT_BYTES:
raise ValueError("SCRIPT_TOO_LARGE")
timeout_s = int(req.get("timeout_s", 120))
if not 1 <= timeout_s <= 600:
raise ValueError("INVALID_TIMEOUT")
digest = hashlib.sha256(script_bytes).hexdigest()
expected = req.get("sha256")
if expected is not None:
expected = str(expected).lower()
if digest != expected:
raise ValueError("SCRIPT_SHA256_MISMATCH")
command_path = INBOX / f"{job_id}.bash"
temp_path = INBOX / f".{job_id}.tmp"
temp_path.write_bytes(script_bytes)
os.chmod(temp_path, 0o600)
temp_path.replace(command_path)
proc = subprocess.run(
[
sys.executable,
str(ENGINE),
"exec",
job_id,
str(timeout_s),
str(command_path),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout_s + 45,
check=False,
env={
**os.environ,
"FBTERM_ROOT": str(FBROOT),
"FBTERM_SESSION": os.environ.get(
"FBTERM_SESSION",
"framebridge-fbterm-r1",
),
"FBTERM_TITLE": os.environ.get(
"FBTERM_TITLE",
"FrameBridge Terminal R1",
),
},
)
result = engine_result(proc.stdout)
if result is None:
return {
"version": VERSION,
"ok": False,
"job_id": job_id,
"adapter_error": "ENGINE_RESULT_NOT_FOUND",
"engine_rc": proc.returncode,
"engine_stderr": proc.stderr[-2000:],
"command_sha256": digest,
}
stdout_path = result.get("STDOUT_PATH")
stderr_path = result.get("STDERR_PATH")
return {
"version": VERSION,
"ok": True,
"job_id": job_id,
"mode": mode,
"command_sha256": digest,
"engine_rc": proc.returncode,
"exit_code": result.get("EXIT"),
"result_final": (
last_result_final(stdout_path)
if stdout_path
else None
),
"stdout_path": stdout_path,
"stderr_path": stderr_path,
"stdout_sha256": result.get("STDOUT_SHA256"),
"stderr_sha256": result.get("STDERR_SHA256"),
"command_hash_gate": result.get(
"COMMAND_HASH_GATE"
),
"pty": result.get("PTY"),
"pane_pid": result.get("PANE_PID"),
"pane_tty": result.get("PANE_TTY"),
"shell_ready": result.get("SHELL_READY"),
}
def serve():
prepare_root()
if not ENGINE.is_file():
raise RuntimeError("FBTERM_ENGINE_NOT_FOUND")
if SOCKET_PATH.exists():
st = SOCKET_PATH.lstat()
if not stat.S_ISSOCK(st.st_mode):
raise RuntimeError("SOCKET_PATH_OCCUPIED")
SOCKET_PATH.unlink()
server = socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
)
server.bind(str(SOCKET_PATH))
os.chmod(SOCKET_PATH, 0o600)
server.listen(4)
PID_FILE.write_text(
str(os.getpid()) + "\n",
encoding="ascii",
)
os.chmod(PID_FILE, 0o600)
try:
while True:
conn, _ = server.accept()
with conn:
try:
if peer_uid(conn) != os.getuid():
raise PermissionError(
"PEER_UID_MISMATCH"
)
req = recv_request(conn)
resp = handle(req)
except Exception as exc:
resp = {
"version": VERSION,
"ok": False,
"adapter_error": (
f"{type(exc).__name__}:"
f"{exc}"
)[:2000],
}
send_response(conn, resp)
finally:
server.close()
try:
SOCKET_PATH.unlink()
except FileNotFoundError:
pass
if __name__ == "__main__":
action = sys.argv[1] if len(sys.argv) > 1 else ""
if action == "serve":
serve()
else:
print(
json.dumps(
{
"ok": False,
"error": "USAGE: serve",
},
sort_keys=True,
)
)
raise SystemExit(64)
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
@@ -0,0 +1,42 @@
"""Separate loopback-only MCP entry point for the six Fleet READ tools."""
from __future__ import annotations
import os
from mcp.server.fastmcp import FastMCP
from framebridge_node_gateway import discover_nodes, node_read
from framebridge_linux_chatgpt.fleet_hub import FleetHub
from framebridge_linux_chatgpt.fleet_mcp_surface import register_fleet_read_tools
def _server_address() -> tuple[str, int]:
host = os.environ.get("FRAMEBRIDGE_FLEET_HUB_HOST", "127.0.0.1")
if not isinstance(host, str) or host not in ("127.0.0.1", "::1"):
raise ValueError("FLEET_HUB_HOST_INVALID")
try:
port = int(os.environ.get("FRAMEBRIDGE_FLEET_HUB_PORT", "8877"))
except (TypeError, ValueError):
raise ValueError("FLEET_HUB_PORT_INVALID") from None
if not 1024 <= port <= 65535:
raise ValueError("FLEET_HUB_PORT_INVALID")
return host, port
host, port = _server_address()
mcp = FastMCP(
"FrameBridge Fleet Hub",
host=host,
port=port,
streamable_http_path="/mcp",
)
hub = FleetHub(discover_nodes, node_read)
register_fleet_read_tools(mcp, hub)
def main() -> None:
mcp.run(transport="streamable-http")
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""FrameBridge Fleet Hub runtime."""
@@ -0,0 +1,42 @@
"""Injected Node Gateway READ boundary; no transport or live gateway imports."""
from __future__ import annotations
from typing import Callable
from .fleet_registry import FleetNode
from .fleet_routing import Operation, ReadRequest, normalize_read_args
class GatewayReadError(ValueError):
"""Fixed local error codes; remote exceptions are never copied."""
class GatewayReadAdapter:
def __init__(self, gateway_node_read: Callable[[str, str, dict], dict]) -> None:
if not callable(gateway_node_read):
raise GatewayReadError("FLEET_GATEWAY_CONFIG_INVALID")
self._gateway_node_read = gateway_node_read
def __call__(self, node: FleetNode, request: ReadRequest) -> dict:
if type(node) is not FleetNode or type(request) is not ReadRequest:
raise GatewayReadError("FLEET_GATEWAY_REQUEST_INVALID")
if request.node_id != node.node_id:
raise GatewayReadError("FLEET_GATEWAY_NODE_MISMATCH")
if request.operation is not Operation.READ or not node.online:
raise GatewayReadError("FLEET_GATEWAY_READ_BLOCKED")
try:
args = normalize_read_args(request.capability, request.args)
except ValueError:
raise GatewayReadError("FLEET_GATEWAY_REQUEST_INVALID") from None
try:
response = self._gateway_node_read(node.alias, request.capability.value, args)
except Exception:
raise GatewayReadError("FLEET_GATEWAY_READ_FAILED") from None
if type(response) is not dict or response.get("ok") is not True:
raise GatewayReadError("FLEET_GATEWAY_READ_FAILED")
for key in ("node", "alias"):
if key in response and (type(response[key]) is not str
or response[key] != node.alias):
raise GatewayReadError("FLEET_GATEWAY_ALIAS_MISMATCH")
# Metadata is payload only. It cannot update identity, routing or policy.
return response
@@ -0,0 +1,65 @@
"""Synchronous, in-memory Fleet READ hub using injected discovery and gateway."""
from __future__ import annotations
from typing import Callable
from .fleet_gateway_adapter import GatewayReadAdapter
from .fleet_registry import FleetNode, FleetRegistry, RegistryError
from .fleet_routing import FleetRequest, FleetRouter, RouteResult
class FleetHubError(ValueError):
"""Sanitized discovery/configuration failure."""
class FleetHub:
def __init__(self, discover_nodes: Callable[[], dict],
gateway_node_read: Callable[[str, str, dict], dict]) -> None:
if not callable(discover_nodes):
raise FleetHubError("FLEET_HUB_CONFIG_INVALID")
self._discover_nodes = discover_nodes
self._adapter = GatewayReadAdapter(gateway_node_read)
registry = FleetRegistry()
self._snapshot = (registry, FleetRouter(registry, self._adapter))
def refresh(self) -> tuple[FleetNode, ...]:
"""Publish only a complete valid registry; failures preserve the old one.
Entries with explicit pairing state must be paired. Legacy entries need
an explicit stable ID too. Missing descriptive metadata stays unknown;
it never supplies identity or readiness. Invalid entries are omitted,
but duplicate IDs/aliases and cross-namespace collisions abort refresh.
"""
try:
discovery = self._discover_nodes()
if (type(discovery) is not dict or discovery.get("ok") is not True
or type(discovery.get("nodes")) is not list):
raise FleetHubError("FLEET_REFRESH_FAILED")
registry = FleetRegistry()
for entry in discovery["nodes"]:
if type(entry) is not dict:
continue
if "pairing_state" in entry and entry["pairing_state"] != "paired":
continue
try:
node = FleetNode(
node_id=entry.get("node_id"), alias=entry.get("alias"),
hostname=entry.get("hostname", "unknown"),
transport=entry.get("transport", "unknown"),
capability_profile=entry.get("capability_profile", "unknown"),
online=entry.get("operational_transport_ready") is True,
)
except RegistryError:
continue
registry.register(node)
router = FleetRouter(registry, self._adapter)
except Exception:
raise FleetHubError("FLEET_REFRESH_FAILED") from None
self._snapshot = (registry, router)
return registry.list_nodes()
def read(self, target: str, capability: str, args: dict | None = None) -> RouteResult:
return self._snapshot[1].route(FleetRequest(target, capability, args=args))
def nodes(self) -> tuple[FleetNode, ...]:
return self._snapshot[0].list_nodes()
@@ -0,0 +1,83 @@
"""Register exactly six READ tools on an injected MCP-compatible registrar."""
from __future__ import annotations
from dataclasses import asdict
import json
from .fleet_hub import FleetHub
from .fleet_routing import RouteResult
_ANNOTATIONS = {
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
}
def _public_data(value: object) -> object:
"""Copy JSON-only data and omit inert context even in nested gateway payloads."""
if value is None or type(value) in (str, bool, int, float):
return value
if type(value) is list:
return [_public_data(item) for item in value]
if type(value) is dict and all(type(key) is str for key in value):
return {key: _public_data(item) for key, item in value.items()
if key != "context_text"}
raise ValueError("FLEET_RESPONSE_INVALID")
def _serializable(value: dict) -> dict:
# Reject nonfinite numbers as well as custom objects; no repr/str fallback.
return json.loads(json.dumps(_public_data(value), allow_nan=False))
def register_fleet_read_tools(mcp, hub: FleetHub) -> None:
def read(node: str, capability: str, args: dict | None = None) -> dict:
try:
hub.refresh()
except Exception:
return {"ok": False, "error": "FLEET_REFRESH_FAILED"}
try:
result = hub.read(node, capability, args=args)
if type(result) is not RouteResult or result.ok is not True:
return {"ok": False, "error": "FLEET_READ_FAILED"}
return _serializable({"ok": True, "node_id": result.node_id,
"capability": result.capability, "data": result.data})
except Exception:
return {"ok": False, "error": "FLEET_READ_FAILED"}
@mcp.tool(annotations=dict(_ANNOTATIONS))
def framebridge_fleet_nodes() -> dict:
"""Refresh and list Fleet nodes with stable identities and readiness."""
try:
hub.refresh()
return _serializable({"ok": True, "nodes": [asdict(node) for node in hub.nodes()]})
except Exception:
return {"ok": False, "error": "FLEET_NODES_FAILED"}
@mcp.tool(annotations=dict(_ANNOTATIONS))
def framebridge_fleet_node_system_status(node: str) -> dict:
"""Read system status for a Fleet node ID or alias."""
return read(node, "system.status")
@mcp.tool(annotations=dict(_ANNOTATIONS))
def framebridge_fleet_node_hardware_summary(node: str) -> dict:
"""Read hardware summary for a Fleet node ID or alias."""
return read(node, "hardware.summary")
@mcp.tool(annotations=dict(_ANNOTATIONS))
def framebridge_fleet_node_storage_inspect(node: str) -> dict:
"""Inspect storage for a Fleet node ID or alias."""
return read(node, "storage.inspect")
@mcp.tool(annotations=dict(_ANNOTATIONS))
def framebridge_fleet_node_network_inspect(node: str) -> dict:
"""Inspect networking for a Fleet node ID or alias."""
return read(node, "network.inspect")
@mcp.tool(annotations=dict(_ANNOTATIONS))
def framebridge_fleet_node_service_status(node: str, service: str) -> dict:
"""Read status of one validated service for a Fleet node ID or alias."""
return read(node, "service.status", {"service": service})
@@ -0,0 +1,77 @@
"""In-memory Fleet registry. Identity is explicit; no discovery or transport I/O."""
from __future__ import annotations
from dataclasses import dataclass, replace
import re
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
class RegistryError(ValueError):
"""Stable error code only; never includes supplied metadata."""
def _identifier(value: object) -> bool:
return type(value) is str and _IDENTIFIER.fullmatch(value) is not None
@dataclass(frozen=True)
class FleetNode:
node_id: str
alias: str
hostname: str
transport: str
capability_profile: str
online: bool = False
def __post_init__(self) -> None:
if not _identifier(self.node_id) or not _identifier(self.alias):
raise RegistryError("FLEET_IDENTIFIER_INVALID")
for value in (self.hostname, self.transport, self.capability_profile):
if (type(value) is not str or not value or len(value) > 253
or any(ord(char) < 32 or ord(char) == 127 for char in value)):
raise RegistryError("FLEET_METADATA_INVALID")
if type(self.online) is not bool:
raise RegistryError("FLEET_STATE_INVALID")
class FleetRegistry:
"""Case-sensitive identifiers, sorted by node_id; offline unless specified.
Aliases and IDs share one namespace to prevent ambiguous destination lookup.
Metadata cannot grant capabilities. Instances are for synchronous local use.
"""
def __init__(self) -> None:
self._nodes: dict[str, FleetNode] = {}
self._aliases: dict[str, str] = {}
def register(self, node: FleetNode) -> FleetNode:
if type(node) is not FleetNode:
raise RegistryError("FLEET_NODE_INVALID")
if node.node_id in self._nodes:
raise RegistryError("FLEET_DUPLICATE_NODE_ID")
if node.alias in self._aliases:
raise RegistryError("FLEET_DUPLICATE_ALIAS")
if node.node_id in self._aliases or node.alias in self._nodes:
raise RegistryError("FLEET_IDENTIFIER_COLLISION")
self._nodes[node.node_id] = node
self._aliases[node.alias] = node.node_id
return node
def list_nodes(self) -> tuple[FleetNode, ...]:
return tuple(self._nodes[key] for key in sorted(self._nodes))
def resolve(self, target: str) -> FleetNode:
if not _identifier(target):
raise RegistryError("FLEET_IDENTIFIER_INVALID")
node_id = target if target in self._nodes else self._aliases.get(target)
if node_id is None:
raise RegistryError("FLEET_NODE_NOT_FOUND")
return self._nodes[node_id]
def set_online(self, target: str, online: bool) -> FleetNode:
node = replace(self.resolve(target), online=online)
self._nodes[node.node_id] = node
return node
@@ -0,0 +1,131 @@
"""Typed READ dispatch only. The injected adapter is the sole execution boundary.
No default adapter, shell, SSH, sockets, plan engine, or authority from text.
The embedding application must supply a trusted read-only adapter; this module
does not sandbox arbitrary Python callbacks or authenticate remote callers.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import re
from types import MappingProxyType
from typing import Callable, Mapping
from .fleet_registry import FleetNode, FleetRegistry, RegistryError
class Operation(Enum):
READ = "READ"
WRITE = "WRITE"
PLAN = "PLAN"
APPLY = "APPLY"
ROLLBACK = "ROLLBACK"
EXEC = "EXEC"
class ReadCapability(Enum):
SYSTEM_STATUS = "system.status"
HARDWARE_SUMMARY = "hardware.summary"
STORAGE_INSPECT = "storage.inspect"
NETWORK_INSPECT = "network.inspect"
SERVICE_STATUS = "service.status"
READ_ALLOWLIST = frozenset(item.value for item in ReadCapability)
_FORBIDDEN_EXEC = frozenset({"framebridge_exec", "shell", "bash", "exec"})
_SERVICE_NAME = re.compile(r"[A-Za-z0-9_.@:-]{1,128}")
def normalize_read_args(capability: ReadCapability, args: object) -> dict[str, str]:
"""Return a fresh, closed-schema dictionary; never interpret supplied text."""
if type(capability) is not ReadCapability:
raise ValueError("FLEET_ARGS_INVALID")
if args is None:
args = {}
if type(args) not in (dict, MappingProxyType):
raise ValueError("FLEET_ARGS_INVALID")
if capability is ReadCapability.SERVICE_STATUS:
if (set(args) != {"service"} or type(args["service"]) is not str
or _SERVICE_NAME.fullmatch(args["service"]) is None):
raise ValueError("FLEET_ARGS_INVALID")
return {"service": args["service"]}
if args:
raise ValueError("FLEET_ARGS_INVALID")
return {}
@dataclass(frozen=True)
class FleetRequest:
target: str
capability: str
operation: Operation = Operation.READ
context_text: str = "" # Inert text; never forwarded to an adapter.
args: Mapping[str, str] | None = None
def __post_init__(self) -> None:
# Snapshot caller dictionaries before dispatch, including read-only views.
# Invalid inputs remain available for the router's fixed error result.
if type(self.args) in (dict, MappingProxyType):
object.__setattr__(self, "args", MappingProxyType(dict(self.args)))
@dataclass(frozen=True)
class ReadRequest:
node_id: str
capability: ReadCapability
operation: Operation = Operation.READ
args: Mapping[str, str] | None = None
def __post_init__(self) -> None:
object.__setattr__(self, "args", MappingProxyType(
normalize_read_args(self.capability, self.args)))
@dataclass(frozen=True)
class RouteResult:
ok: bool
error: str | None = None
node_id: str | None = None
capability: str | None = None
data: object = None
class FleetRouter:
def __init__(self, registry: FleetRegistry,
adapter: Callable[[FleetNode, ReadRequest], object]) -> None:
if not isinstance(registry, FleetRegistry) or not callable(adapter):
raise ValueError("FLEET_ROUTER_CONFIG_INVALID")
self._registry = registry
self._adapter = adapter
def route(self, request: FleetRequest) -> RouteResult:
if type(request) is not FleetRequest:
return RouteResult(False, "FLEET_REQUEST_INVALID")
# Enum identity deliberately rejects strings and foreign enum values.
if request.operation is not Operation.READ:
return RouteResult(False, "FLEET_OPERATION_BLOCKED")
if type(request.capability) is not str or type(request.context_text) is not str:
return RouteResult(False, "FLEET_REQUEST_INVALID")
if request.capability.strip().casefold() in _FORBIDDEN_EXEC:
return RouteResult(False, "FLEET_EXEC_FORBIDDEN")
if request.capability not in READ_ALLOWLIST:
return RouteResult(False, "FLEET_CAPABILITY_NOT_ALLOWED")
try:
args = normalize_read_args(ReadCapability(request.capability), request.args)
except ValueError:
return RouteResult(False, "FLEET_ARGS_INVALID")
try:
node = self._registry.resolve(request.target)
except RegistryError as exc:
return RouteResult(False, str(exc))
if not node.online:
return RouteResult(False, "FLEET_NODE_OFFLINE", node.node_id)
typed = ReadRequest(node.node_id, ReadCapability(request.capability), args=args)
try:
data = self._adapter(node, typed)
except Exception:
return RouteResult(False, "FLEET_ADAPTER_FAILED", node.node_id,
request.capability)
return RouteResult(True, node_id=node.node_id,
capability=request.capability, data=data)
@@ -0,0 +1,295 @@
Metadata-Version: 2.4
Name: annotated-types
Version: 0.8.0
Summary: Reusable constraint types to use with typing.Annotated
Project-URL: Homepage, https://github.com/annotated-types/annotated-types
Project-URL: Source, https://github.com/annotated-types/annotated-types
Project-URL: Changelog, https://github.com/annotated-types/annotated-types/releases
Author-email: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, Samuel Colvin <s@muelcolvin.com>, Zac Hatfield-Dodds <zac@zhd.dev>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: MacOS X
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Unix
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
# annotated-types
[![CI](https://github.com/annotated-types/annotated-types/workflows/CI/badge.svg?event=push)](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
[![pypi](https://img.shields.io/pypi/v/annotated-types.svg)](https://pypi.python.org/pypi/annotated-types)
[![versions](https://img.shields.io/pypi/pyversions/annotated-types.svg)](https://github.com/annotated-types/annotated-types)
[![license](https://img.shields.io/github/license/annotated-types/annotated-types.svg)](https://github.com/annotated-types/annotated-types/blob/main/LICENSE)
[PEP-593](https://peps.python.org/pep-0593/) added `typing.Annotated` as a way of
adding context-specific metadata to existing types, and specifies that
`Annotated[T, x]` _should_ be treated as `T` by any tool or library without special
logic for `x`.
This package provides metadata objects which can be used to represent common
constraints such as upper and lower bounds on scalar values and collection sizes,
a `Predicate` marker for runtime checks, and
descriptions of how we intend these metadata to be interpreted. In some cases,
we also note alternative representations which do not require this package.
## Install
```bash
pip install annotated-types
```
## Examples
```python
from typing import Annotated
from annotated_types import Gt, Len, Predicate
class MyClass:
age: Annotated[int, Gt(18)] # Valid: 19, 20, ...
# Invalid: 17, 18, "19", 19.0, ...
factors: list[Annotated[int, Predicate(is_prime)]] # Valid: 2, 3, 5, 7, 11, ...
# Invalid: 4, 8, -2, 5.0, "prime", ...
my_list: Annotated[list[int], Len(0, 10)] # Valid: [], [10, 20, 30, 40, 50]
# Invalid: (1, 2), ["abc"], [0] * 20
```
## Documentation
_While `annotated-types` avoids runtime checks for performance, users should not
construct invalid combinations such as `MultipleOf("non-numeric")` or `Annotated[int, Len(3)]`.
Downstream implementors may choose to raise an error, emit a warning, silently ignore
a metadata item, etc., if the metadata objects described below are used with an
incompatible type - or for any other reason!_
### Gt, Ge, Lt, Le
Express inclusive and/or exclusive bounds on orderable values - which may be numbers,
dates, times, strings, sets, etc. Note that the boundary value need not be of the
same type that was annotated, so long as they can be compared: `Annotated[int, Gt(1.5)]`
is fine, for example, and implies that the value is an integer x such that `x > 1.5`.
We suggest that implementors may also interpret `functools.partial(operator.le, 1.5)`
as being equivalent to `Gt(1.5)`, for users who wish to avoid a runtime dependency on
the `annotated-types` package.
To be explicit, these types have the following meanings:
* `Gt(x)` - value must be "Greater Than" `x` - equivalent to exclusive minimum
* `Ge(x)` - value must be "Greater than or Equal" to `x` - equivalent to inclusive minimum
* `Lt(x)` - value must be "Less Than" `x` - equivalent to exclusive maximum
* `Le(x)` - value must be "Less than or Equal" to `x` - equivalent to inclusive maximum
### Interval
`Interval(gt, ge, lt, le)` allows you to specify an upper and lower bound with a single
metadata object. `None` attributes should be ignored, and non-`None` attributes
treated as per the single bounds above.
### MultipleOf
`MultipleOf(multiple_of=x)` might be interpreted in two ways:
1. Python semantics, implying `value % multiple_of == 0`, or
2. [JSONschema semantics](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.1),
where `int(value / multiple_of) == value / multiple_of`.
We encourage users to be aware of these two common interpretations and their
distinct behaviours, especially since very large or non-integer numbers make
it easy to cause silent data corruption due to floating-point imprecision.
We encourage libraries to carefully document which interpretation they implement.
### MinLen, MaxLen, Len
`Len()` implies that `min_length <= len(value) <= max_length` - lower and upper bounds are inclusive.
As well as `Len()` which can optionally include upper and lower bounds, we also
provide `MinLen(x)` and `MaxLen(y)` which are equivalent to `Len(min_length=x)`
and `Len(max_length=y)` respectively.
`Len`, `MinLen`, and `MaxLen` may be used with any type which supports `len(value)`.
Examples of usage:
* `Annotated[list, MaxLen(10)]` (or `Annotated[list, Len(max_length=10)]`) - list must have a length of 10 or less
* `Annotated[str, MaxLen(10)]` - string must have a length of 10 or less
* `Annotated[list, MinLen(3)]` (or `Annotated[list, Len(min_length=3)]`) - list must have a length of 3 or more
* `Annotated[list, Len(4, 6)]` - list must have a length of 4, 5, or 6
* `Annotated[list, Len(8, 8)]` - list must have a length of exactly 8
#### Changed in v0.4.0
* `min_inclusive` has been renamed to `min_length`, no change in meaning
* `max_exclusive` has been renamed to `max_length`, upper bound is now **inclusive** instead of **exclusive**
* The recommendation that slices are interpreted as `Len` has been removed due to ambiguity and different semantic
meaning of the upper bound in slices vs. `Len`
See [issue #23](https://github.com/annotated-types/annotated-types/issues/23) for discussion.
### Timezone
`Timezone` can be used with a `datetime` or a `time` to express which timezones
are allowed. `Annotated[datetime, Timezone(None)]` must be a naive datetime.
`Timezone[...]` ([literal ellipsis](https://docs.python.org/3/library/constants.html#Ellipsis))
expresses that any timezone-aware datetime is allowed. You may also pass a specific
timezone string or [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects)
object such as `Timezone(timezone.utc)` or `Timezone("Africa/Abidjan")` to express that you only
allow a specific timezone, though we note that this is often a symptom of fragile design.
#### Changed in v0.x.x
* `Timezone` accepts [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) objects instead of
`timezone`, extending compatibility to [`zoneinfo`](https://docs.python.org/3/library/zoneinfo.html) and third party libraries.
### Unit
`Unit(unit: str)` expresses that the annotated numeric value is the magnitude of
a quantity with the specified unit. For example, `Annotated[float, Unit("m/s")]`
would be a float representing a velocity in meters per second.
Please note that `annotated_types` itself makes no attempt to parse or validate
the unit string in any way. That is left entirely to downstream libraries,
such as [`pint`](https://pint.readthedocs.io) or
[`astropy.units`](https://docs.astropy.org/en/stable/units/).
An example of how a library might use this metadata:
```python
from annotated_types import Unit
from typing import Annotated, TypeVar, Callable, Any, get_origin, get_args
# given a type annotated with a unit:
Meters = Annotated[float, Unit("m")]
# you can cast the annotation to a specific unit type with any
# callable that accepts a string and returns the desired type
T = TypeVar("T")
def cast_unit(tp: Any, unit_cls: Callable[[str], T]) -> T | None:
if get_origin(tp) is Annotated:
for arg in get_args(tp):
if isinstance(arg, Unit):
return unit_cls(arg.unit)
return None
# using `pint`
import pint
pint_unit = cast_unit(Meters, pint.Unit)
# using `astropy.units`
import astropy.units as u
astropy_unit = cast_unit(Meters, u.Unit)
```
### Predicate
`Predicate(func: Callable)` expresses that `func(value)` is truthy for valid values.
Users should prefer the statically inspectable metadata above, but if you need
the full power and flexibility of arbitrary runtime predicates... here it is.
For some common constraints, we provide generic types:
* `LowerCase = Annotated[T, Predicate(str.islower)]`
* `UpperCase = Annotated[T, Predicate(str.isupper)]`
* `IsDigit = Annotated[T, Predicate(str.isdigit)]`
* `IsFinite = Annotated[T, Predicate(math.isfinite)]`
* `IsNotFinite = Annotated[T, Predicate(Not(math.isfinite))]`
* `IsNan = Annotated[T, Predicate(math.isnan)]`
* `IsNotNan = Annotated[T, Predicate(Not(math.isnan))]`
* `IsInfinite = Annotated[T, Predicate(math.isinf)]`
* `IsNotInfinite = Annotated[T, Predicate(Not(math.isinf))]`
so that you can write e.g. `x: IsFinite[float] = 2.0` instead of the longer
(but exactly equivalent) `x: Annotated[float, Predicate(math.isfinite)] = 2.0`.
Some libraries might have special logic to handle known or understandable predicates,
for example by checking for `str.isdigit` and using its presence to both call custom
logic to enforce digit-only strings, and customise some generated external schema.
Users are therefore encouraged to avoid indirection like `lambda s: s.lower()`, in
favor of introspectable methods such as `str.lower` or `re.compile("pattern").search`.
To enable basic negation of commonly used predicates like `math.isnan` without introducing introspection that makes it impossible for implementers to introspect the predicate we provide a `Not` wrapper that simply negates the predicate in an introspectable manner. Several of the predicates listed above are created in this manner.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propagate or discard the resulting
`TypeError: descriptor 'isdigit' for 'str' objects doesn't apply to a 'int' object`
exception. We encourage libraries to document the behaviour they choose.
### Doc
`doc()` can be used to add documentation information in `Annotated`, for function and method parameters, variables, class attributes, return types, and any place where `Annotated` can be used.
It expects a value that can be statically analyzed, as the main use case is for static analysis, editors, documentation generators, and similar tools.
It returns a `DocInfo` class with a single attribute `documentation` containing the value passed to `doc()`.
This is the early adopter's alternative form of the [`typing-doc` proposal](https://github.com/tiangolo/fastapi/blob/typing-doc/typing_doc.md).
### Integrating downstream types with `GroupedMetadata`
Implementers may choose to provide a convenience wrapper that groups multiple pieces of metadata.
This can help reduce verbosity and cognitive overhead for users.
For example, an implementer like Pydantic might provide a `Field` or `Meta` type that accepts keyword arguments and transforms these into low-level metadata:
```python
from dataclasses import dataclass
from typing import Iterator
from annotated_types import GroupedMetadata, Ge
@dataclass
class Field(GroupedMetadata):
ge: int | None = None
description: str | None = None
def __iter__(self) -> Iterator[object]:
# Iterating over a GroupedMetadata object should yield annotated-types
# constraint metadata objects which describe it as fully as possible,
# and may include other unknown objects too.
if self.ge is not None:
yield Ge(self.ge)
if self.description is not None:
yield Description(self.description)
```
Libraries consuming annotated-types constraints should check for `GroupedMetadata` and unpack it by iterating over the object and treating the results as if they had been "unpacked" in the `Annotated` type. The same logic should be applied to the [PEP 646 `Unpack` type](https://peps.python.org/pep-0646/), so that `Annotated[T, Field(...)]`, `Annotated[T, Unpack[Field(...)]]` and `Annotated[T, *Field(...)]` are all treated consistently.
Libraries consuming annotated-types should also ignore any metadata they do not recongize that came from unpacking a `GroupedMetadata`, just like they ignore unrecognized metadata in `Annotated` itself.
Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern. Similarly, `annotated_types.Len` is a `GroupedMetadata` which unpacks itself into `MinLen` (optionally) and `MaxLen`.
### Consuming metadata
We intend to not be prescriptive as to _how_ the metadata and constraints are used, but as an example of how one might parse constraints from types annotations see our [implementation in `test_main.py`](https://github.com/annotated-types/annotated-types/blob/f59cf6d1b5255a0fe359b93896759a180bec30ae/tests/test_main.py#L94-L103).
It is up to the implementer to determine how this metadata is used.
You could use the metadata for runtime type checking, for generating schemas or to generate example data, amongst other use cases.
## Design & History
This package was designed at the PyCon 2022 sprints by the maintainers of Pydantic
and Hypothesis, with the goal of making it as easy as possible for end-users to
provide more informative annotations for use by runtime libraries.
It is deliberately minimal, and following PEP-593 allows considerable downstream
discretion in what (if anything!) they choose to support. Nonetheless, we expect
that staying simple and covering _only_ the most common use-cases will give users
and maintainers the best experience we can. If you'd like more constraints for your
types - follow our lead, by defining them and documenting them downstream!
@@ -0,0 +1,7 @@
annotated_types/__init__.py,sha256=pxBKTUObJ6n3T8C-I2ubobeDHmBEAmgCogWrwSmKm8g,13273
annotated_types/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
annotated_types/test_cases.py,sha256=2GdHKstXuBzpf3iXKCjmvFZpyk2zP9Hm9kXGGDhGBo4,6310
annotated_types-0.8.0.dist-info/METADATA,sha256=YUmFsnj2Abjvhj-CeDuZHMOtpLifWS63HHxIxfLvIpg,15009
annotated_types-0.8.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
annotated_types-0.8.0.dist-info/licenses/LICENSE,sha256=_hBJiEsaDZNCkB6I4H8ykl0ksxIdmXK2poBfuYJLCV0,1083
annotated_types-0.8.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: hatchling 1.31.0
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022 the contributors
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.
@@ -0,0 +1,416 @@
import math
import types
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import tzinfo
from types import EllipsisType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Literal,
Protocol,
SupportsFloat,
SupportsIndex,
TypeVar,
Union,
runtime_checkable,
)
__all__ = (
'BaseMetadata',
'GroupedMetadata',
'Gt',
'Ge',
'Lt',
'Le',
'Interval',
'MultipleOf',
'MinLen',
'MaxLen',
'Len',
'Timezone',
'Predicate',
'LowerCase',
'UpperCase',
'IsDigits',
'IsFinite',
'IsNotFinite',
'IsNan',
'IsNotNan',
'IsInfinite',
'IsNotInfinite',
'doc',
'DocInfo',
'__version__',
)
__version__ = '0.8.0'
T = TypeVar('T')
# arguments that start with __ are considered
# positional only
# see https://peps.python.org/pep-0484/#positional-only-arguments
class SupportsGt(Protocol):
def __gt__(self: T, __other: T) -> bool:
...
class SupportsGe(Protocol):
def __ge__(self: T, __other: T) -> bool:
...
class SupportsLt(Protocol):
def __lt__(self: T, __other: T) -> bool:
...
class SupportsLe(Protocol):
def __le__(self: T, __other: T) -> bool:
...
class SupportsMod(Protocol):
def __mod__(self: T, __other: T) -> T:
...
class SupportsDiv(Protocol):
def __div__(self: T, __other: T) -> T:
...
class BaseMetadata:
"""Base class for all metadata.
This exists mainly so that implementers
can do `isinstance(..., BaseMetadata)` while traversing field annotations.
"""
__slots__ = ()
@dataclass(frozen=True, slots=True)
class Gt(BaseMetadata):
"""Gt(gt=x) implies that the value must be greater than x.
It can be used with any type that supports the ``>`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
gt: SupportsGt
@dataclass(frozen=True, slots=True)
class Ge(BaseMetadata):
"""Ge(ge=x) implies that the value must be greater than or equal to x.
It can be used with any type that supports the ``>=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
ge: SupportsGe
@dataclass(frozen=True, slots=True)
class Lt(BaseMetadata):
"""Lt(lt=x) implies that the value must be less than x.
It can be used with any type that supports the ``<`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
lt: SupportsLt
@dataclass(frozen=True, slots=True)
class Le(BaseMetadata):
"""Le(le=x) implies that the value must be less than or equal to x.
It can be used with any type that supports the ``<=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
le: SupportsLe
@runtime_checkable
class GroupedMetadata(Protocol):
"""A grouping of multiple objects, like typing.Unpack.
`GroupedMetadata` on its own is not metadata and has no meaning.
All of the constraints and metadata should be fully expressable
in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`.
Concrete implementations should override `GroupedMetadata.__iter__()`
to add their own metadata.
For example:
>>> @dataclass
>>> class Field(GroupedMetadata):
>>> gt: float | None = None
>>> description: str | None = None
...
>>> def __iter__(self) -> Iterable[object]:
>>> if self.gt is not None:
>>> yield Gt(self.gt)
>>> if self.description is not None:
>>> yield Description(self.gt)
Also see the implementation of `Interval` below for an example.
Parsers should recognize this and unpack it so that it can be used
both with and without unpacking:
- `Annotated[int, Field(...)]` (parser must unpack Field)
- `Annotated[int, *Field(...)]` (PEP-646)
""" # noqa: trailing-whitespace
@property
def __is_annotated_types_grouped_metadata__(self) -> Literal[True]:
return True
def __iter__(self) -> Iterator[object]:
...
if not TYPE_CHECKING:
__slots__ = () # allow subclasses to use slots
def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
# Basic ABC like functionality without the complexity of an ABC
super().__init_subclass__(*args, **kwargs)
if cls.__iter__ is GroupedMetadata.__iter__:
raise TypeError("Can't subclass GroupedMetadata without implementing __iter__")
def __iter__(self) -> Iterator[object]: # noqa: F811
raise NotImplementedError # more helpful than "None has no attribute..." type errors
@dataclass(frozen=True, kw_only=True, slots=True)
class Interval(GroupedMetadata):
"""Interval can express inclusive or exclusive bounds with a single object.
It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which
are interpreted the same way as the single-bound constraints.
"""
gt: SupportsGt | None = None
ge: SupportsGe | None = None
lt: SupportsLt | None = None
le: SupportsLe | None = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack an Interval into zero or more single-bounds."""
if self.gt is not None:
yield Gt(self.gt)
if self.ge is not None:
yield Ge(self.ge)
if self.lt is not None:
yield Lt(self.lt)
if self.le is not None:
yield Le(self.le)
@dataclass(frozen=True, slots=True)
class MultipleOf(BaseMetadata):
"""MultipleOf(multiple_of=x) might be interpreted in two ways:
1. Python semantics, implying ``value % multiple_of == 0``, or
2. JSONschema semantics, where ``int(value / multiple_of) == value / multiple_of``
We encourage users to be aware of these two common interpretations,
and libraries to carefully document which they implement.
"""
multiple_of: SupportsDiv | SupportsMod
@dataclass(frozen=True, slots=True)
class MinLen(BaseMetadata):
"""
MinLen() implies minimum inclusive length,
e.g. ``len(value) >= min_length``.
"""
min_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, slots=True)
class MaxLen(BaseMetadata):
"""
MaxLen() implies maximum inclusive length,
e.g. ``len(value) <= max_length``.
"""
max_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, slots=True)
class Len(GroupedMetadata):
"""
Len() implies that ``min_length <= len(value) <= max_length``.
Upper bound may be omitted or ``None`` to indicate no upper length bound.
"""
min_length: Annotated[int, Ge(0)] = 0
max_length: Annotated[int, Ge(0)] | None = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack a Len into zero or more single-bounds."""
if self.min_length > 0:
yield MinLen(self.min_length)
if self.max_length is not None:
yield MaxLen(self.max_length)
@dataclass(frozen=True, slots=True)
class Timezone(BaseMetadata):
"""Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive).
``Annotated[datetime, Timezone(None)]`` must be a naive datetime.
``Timezone(...)`` (the ellipsis literal) expresses that the datetime must be
tz-aware but any timezone is allowed.
You may also pass a specific timezone string or tzinfo object such as
``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that
you only allow a specific timezone, though we note that this is often
a symptom of poor design.
"""
tz: str | tzinfo | EllipsisType | None
@dataclass(frozen=True, slots=True)
class Unit(BaseMetadata):
"""Indicates that the value is a physical quantity with the specified unit.
It is intended for usage with numeric types, where the value represents the
magnitude of the quantity. For example, ``distance: Annotated[float, Unit('m')]``
or ``speed: Annotated[float, Unit('m/s')]``.
Interpretation of the unit string is left to the discretion of the consumer.
It is suggested to follow conventions established by python libraries that work
with physical quantities, such as
- ``pint`` : <https://pint.readthedocs.io/en/stable/>
- ``astropy.units``: <https://docs.astropy.org/en/stable/units/>
For indicating a quantity with a certain dimensionality but without a specific unit
it is recommended to use square brackets, e.g. `Annotated[float, Unit('[time]')]`.
Note, however, ``annotated_types`` itself makes no use of the unit string.
"""
unit: str
@dataclass(frozen=True, slots=True)
class Predicate(BaseMetadata):
"""``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.
Users should prefer statically inspectable metadata, but if you need the full
power and flexibility of arbitrary runtime predicates... here it is.
We provide a few predefined predicates for common string constraints:
``LowerCase = Predicate(str.islower)``, ``UpperCase = Predicate(str.isupper)``, and
``IsDigits = Predicate(str.isdigit)``. Users are encouraged to use methods which
can be given special handling, and avoid indirection like ``lambda s: s.lower()``.
Some libraries might have special logic to handle certain predicates, e.g. by
checking for `str.isdigit` and using its presence to both call custom logic to
enforce digit-only strings, and customise some generated external schema.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propagate or discard the resulting exception.
"""
func: Callable[[Any], bool]
def __repr__(self) -> str:
if getattr(self.func, "__name__", "<lambda>") == "<lambda>":
return f"{self.__class__.__name__}({self.func!r})"
if isinstance(self.func, (types.MethodType, types.BuiltinMethodType)) and (
namespace := getattr(self.func.__self__, "__name__", None)
):
return f"{self.__class__.__name__}({namespace}.{self.func.__name__})"
if isinstance(self.func, type(str.isascii)): # method descriptor
return f"{self.__class__.__name__}({self.func.__qualname__})"
return f"{self.__class__.__name__}({self.func.__name__})"
@dataclass
class Not:
func: Callable[[Any], bool]
def __call__(self, __v: Any) -> bool:
return not self.func(__v)
_StrType = TypeVar("_StrType", bound=str)
LowerCase = Annotated[_StrType, Predicate(str.islower)]
"""
Return True if the string is a lowercase string, False otherwise.
A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
""" # noqa: E501
UpperCase = Annotated[_StrType, Predicate(str.isupper)]
"""
Return True if the string is an uppercase string, False otherwise.
A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
""" # noqa: E501
IsDigit = Annotated[_StrType, Predicate(str.isdigit)]
IsDigits = IsDigit # type: ignore # plural for backwards compatibility, see #63
"""
Return True if the string is a digit string, False otherwise.
A string is a digit string if all characters in the string are digits and there is at least one character in the string.
""" # noqa: E501
IsAscii = Annotated[_StrType, Predicate(str.isascii)]
"""
Return True if all characters in the string are ASCII, False otherwise.
ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
"""
_NumericType = TypeVar('_NumericType', bound=Union[SupportsFloat, SupportsIndex])
IsFinite = Annotated[_NumericType, Predicate(math.isfinite)]
"""Return True if x is neither an infinity nor a NaN, and False otherwise."""
IsNotFinite = Annotated[_NumericType, Predicate(Not(math.isfinite))]
"""Return True if x is one of infinity or NaN, and False otherwise"""
IsNan = Annotated[_NumericType, Predicate(math.isnan)]
"""Return True if x is a NaN (not a number), and False otherwise."""
IsNotNan = Annotated[_NumericType, Predicate(Not(math.isnan))]
"""Return True if x is anything but NaN (not a number), and False otherwise."""
IsInfinite = Annotated[_NumericType, Predicate(math.isinf)]
"""Return True if x is a positive or negative infinity, and False otherwise."""
IsNotInfinite = Annotated[_NumericType, Predicate(Not(math.isinf))]
"""Return True if x is neither a positive or negative infinity, and False otherwise."""
try:
# PEP 727 – Documentation in Annotated Metadata
from typing_extensions import Doc # type: ignore[attr-defined]
except ImportError:
@dataclass(frozen=True, slots=True)
class Doc: # type: ignore [no-redef]
""" "
The return value of doc(), mainly to be used by tools that want to extract the
Annotated documentation at runtime.
"""
documentation: str
"""The documentation string passed to doc()."""
DocInfo = Doc # backwards compatibility
doc = Doc
Whitespace-only changes.
@@ -0,0 +1,146 @@
import math
from collections.abc import Iterable, Iterator
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Annotated, Any, NamedTuple
import annotated_types as at
class Case(NamedTuple):
"""
A test case for `annotated_types`.
"""
annotation: Any
valid_cases: Iterable[Any]
invalid_cases: Iterable[Any]
def cases() -> Iterable[Case]:
# Gt, Ge, Lt, Le
yield Case(Annotated[int, at.Gt(4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[float, at.Gt(0.5)], (0.6, 0.7, 0.8, 0.9), (0.5, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Gt(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(date(2000, 1, 1))],
[date(2000, 1, 2), date(2000, 1, 3)],
[date(2000, 1, 1), date(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(Decimal('1.123'))],
[Decimal('1.1231'), Decimal('123')],
[Decimal('1.123'), Decimal('0')],
)
yield Case(Annotated[int, at.Ge(4)], (4, 5, 6, 1000, 4), (0, -1))
yield Case(Annotated[float, at.Ge(0.5)], (0.5, 0.6, 0.7, 0.8, 0.9), (0.4, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Ge(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(1998, 1, 1), datetime(1999, 12, 31)],
)
yield Case(Annotated[int, at.Lt(4)], (0, -1), (4, 5, 6, 1000, 4))
yield Case(Annotated[float, at.Lt(0.5)], (0.4, 0.0, -0.1), (0.5, 0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Lt(datetime(2000, 1, 1))],
[datetime(1999, 12, 31), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
yield Case(Annotated[int, at.Le(4)], (4, 0, -1), (5, 6, 1000))
yield Case(Annotated[float, at.Le(0.5)], (0.5, 0.0, -0.1), (0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Le(datetime(2000, 1, 1))],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
# Interval
yield Case(Annotated[int, at.Interval(gt=4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[int, at.Interval(gt=4, lt=10)], (5, 6), (4, 10, 1000, 0, -1))
yield Case(Annotated[float, at.Interval(ge=0.5, le=1)], (0.5, 0.9, 1), (0.49, 1.1))
yield Case(
Annotated[datetime, at.Interval(gt=datetime(2000, 1, 1), le=datetime(2000, 1, 3))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(2000, 1, 4)],
)
yield Case(Annotated[int, at.MultipleOf(multiple_of=3)], (0, 3, 9), (1, 2, 4))
yield Case(Annotated[float, at.MultipleOf(multiple_of=0.5)], (0, 0.5, 1, 1.5), (0.4, 1.1))
# lengths
yield Case(Annotated[str, at.MinLen(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[str, at.Len(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[list[int], at.MinLen(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[list[int], at.Len(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[str, at.MaxLen(4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[str, at.Len(0, 4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[list[str], at.MaxLen(4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[list[str], at.Len(0, 4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[str, at.Len(3, 5)], ('123', '12345'), ('', '1', '12', '123456', 'x' * 10))
yield Case(Annotated[str, at.Len(3, 3)], ('123',), ('12', '1234'))
yield Case(Annotated[dict[int, int], at.Len(2, 3)], [{1: 1, 2: 2}], [{}, {1: 1}, {1: 1, 2: 2, 3: 3, 4: 4}])
yield Case(Annotated[set[int], at.Len(2, 3)], ({1, 2}, {1, 2, 3}), (set(), {1}, {1, 2, 3, 4}))
yield Case(Annotated[tuple[int, ...], at.Len(2, 3)], ((1, 2), (1, 2, 3)), ((), (1,), (1, 2, 3, 4)))
# Timezone
yield Case(
Annotated[datetime, at.Timezone(None)], [datetime(2000, 1, 1)], [datetime(2000, 1, 1, tzinfo=timezone.utc)]
)
yield Case(
Annotated[datetime, at.Timezone(...)], [datetime(2000, 1, 1, tzinfo=timezone.utc)], [datetime(2000, 1, 1)]
)
yield Case(
Annotated[datetime, at.Timezone(timezone.utc)],
[datetime(2000, 1, 1, tzinfo=timezone.utc)],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
yield Case(
Annotated[datetime, at.Timezone('Europe/London')],
[datetime(2000, 1, 1, tzinfo=timezone(timedelta(0), name='Europe/London'))],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
# Quantity
yield Case(Annotated[float, at.Unit(unit='m')], (5, 4.2), ('5m', '4.2m'))
# predicate types
yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom'])
yield Case(at.UpperCase[str], ['ABC', 'DEFO'], ['', 'a', 'abc', 'AbC'])
yield Case(at.IsDigit[str], ['123'], ['', 'ab', 'a1b2'])
yield Case(at.IsAscii[str], ['123', 'foo bar'], ['£100', '😊', 'whatever 👀'])
yield Case(Annotated[int, at.Predicate(lambda x: x % 2 == 0)], [0, 2, 4], [1, 3, 5])
yield Case(at.IsFinite[float], [1.23], [math.nan, math.inf, -math.inf])
yield Case(at.IsNotFinite[float], [math.nan, math.inf], [1.23])
yield Case(at.IsNan[float], [math.nan], [1.23, math.inf])
yield Case(at.IsNotNan[float], [1.23, math.inf], [math.nan])
yield Case(at.IsInfinite[float], [math.inf], [math.nan, 1.23])
yield Case(at.IsNotInfinite[float], [math.nan, 1.23], [math.inf])
# check stacked predicates
yield Case(at.IsInfinite[Annotated[float, at.Predicate(lambda x: x > 0)]], [math.inf], [-math.inf, 1.23, math.nan])
# doc
yield Case(Annotated[int, at.doc("A number")], [1, 2], [])
# custom GroupedMetadata
class MyCustomGroupedMetadata(at.GroupedMetadata):
def __iter__(self) -> Iterator[at.Predicate]:
yield at.Predicate(lambda x: float(x).is_integer())
yield Case(Annotated[float, MyCustomGroupedMetadata()], [0, 2.0], [0.01, 1.5])
@@ -0,0 +1,108 @@
Metadata-Version: 2.4
Name: anyio
Version: 4.15.1
Summary: High-level concurrency and networking framework on top of asyncio or Trio
Author-email: Alex Grönholm <alex.gronholm@nextday.fi>
License-Expression: MIT
Project-URL: Documentation, https://anyio.readthedocs.io/en/latest/
Project-URL: Changelog, https://anyio.readthedocs.io/en/stable/versionhistory.html
Project-URL: Source code, https://github.com/agronholm/anyio
Project-URL: Issue tracker, https://github.com/agronholm/anyio/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Framework :: AnyIO
Classifier: Typing :: Typed
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
Requires-Python: >=3.10
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: exceptiongroup>=1.0.2; python_version < "3.11"
Requires-Dist: idna>=2.8
Requires-Dist: typing_extensions>=4.16.0; python_version < "3.15"
Provides-Extra: trio
Requires-Dist: trio>=0.32.0; extra == "trio"
Dynamic: license-file
.. image:: https://github.com/agronholm/anyio/actions/workflows/test.yml/badge.svg
:target: https://github.com/agronholm/anyio/actions/workflows/test.yml
:alt: Build Status
.. image:: https://coveralls.io/repos/github/agronholm/anyio/badge.svg?branch=master
:target: https://coveralls.io/github/agronholm/anyio?branch=master
:alt: Code Coverage
.. image:: https://readthedocs.org/projects/anyio/badge/?version=latest
:target: https://anyio.readthedocs.io/en/latest/?badge=latest
:alt: Documentation
.. image:: https://badges.gitter.im/gitterHQ/gitter.svg
:target: https://gitter.im/python-trio/AnyIO
:alt: Gitter chat
.. image:: https://tidelift.com/badges/package/pypi/anyio
:target: https://tidelift.com/subscription/pkg/pypi-anyio
:alt: Tidelift
AnyIO is an asynchronous networking and concurrency library that works on top of either asyncio_ or
Trio_. It implements Trio-like `structured concurrency`_ (SC) on top of asyncio and works in harmony
with the native SC of Trio itself.
Applications and libraries written against AnyIO's API will run unmodified on either asyncio_ or
Trio_. AnyIO can also be adopted into a library or application incrementally – bit by bit, no full
refactoring necessary. It will blend in with the native libraries of your chosen backend.
To find out why you might want to use AnyIO's APIs instead of asyncio's, you can read about it
`here <https://anyio.readthedocs.io/en/stable/why.html>`_.
Documentation
-------------
View full documentation at: https://anyio.readthedocs.io/
Features
--------
AnyIO offers the following functionality:
* Task groups (nurseries_ in trio terminology)
* High-level networking (TCP, UDP and UNIX sockets)
* `Happy eyeballs`_ algorithm for TCP connections (more robust than that of asyncio on Python
3.8)
* async/await style UDP sockets (unlike asyncio where you still have to use Transports and
Protocols)
* A versatile API for byte streams and object streams
* Inter-task synchronization and communication (locks, conditions, events, semaphores, object
streams and futures)
* Worker threads
* Subprocesses
* Subinterpreter support for code parallelization (on Python 3.13 and later)
* Asynchronous file I/O (using worker threads)
* Signal handling
* Asynchronous versions of the functools_ and itertools_ modules
AnyIO also comes with its own pytest_ plugin which also supports asynchronous fixtures.
It even works with the popular Hypothesis_ library.
.. _asyncio: https://docs.python.org/3/library/asyncio.html
.. _Trio: https://github.com/python-trio/trio
.. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency
.. _nurseries: https://trio.readthedocs.io/en/stable/reference-core.html#nurseries-and-spawning
.. _Happy eyeballs: https://en.wikipedia.org/wiki/Happy_Eyeballs
.. _pytest: https://docs.pytest.org/en/latest/
.. _functools: https://docs.python.org/3/library/functools.html
.. _itertools: https://docs.python.org/3/library/itertools.html
.. _Hypothesis: https://hypothesis.works/
Security contact information
----------------------------
To report a security vulnerability, please use the `Tidelift security contact`_.
Tidelift will coordinate the fix and disclosure.
.. _Tidelift security contact: https://tidelift.com/security
@@ -0,0 +1,53 @@
anyio/__init__.py,sha256=OfroYRjIUw_JFj0Zwh1JAaajMQg4VqtoAt0XoOHDRwE,7602
anyio/_lazyimport.py,sha256=LCjnYanIfYE9G-Dv_EsqNLDuVRppNicWZjCTfS9-sJU,6082
anyio/from_thread.py,sha256=iZZbfcNySi4wAcx1_cxAQE4J0YZC88qRMIYqQ6SF9sc,19415
anyio/functools.py,sha256=T4JS8IXq-x1S0Lbo2owF8l9fza2KypO147QLeyz4cjs,11797
anyio/itertools.py,sha256=TS7_UCa2Y3cggtaLlWHbjOyf6QhPfQUdPduNW-ddXTE,16255
anyio/lowlevel.py,sha256=QyWkvfetbCH0mHkqv1yxGwOaiZ7fyxhpZkHWOXPV5JU,6280
anyio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/pytest_plugin.py,sha256=oLXyOstvRZW3VVbqIODhZsPt4jSz2UGh44YeG2nJXx0,14516
anyio/to_interpreter.py,sha256=ATnoT6kei9wDmQZtXTA3SsY4CK7XKr8Ta4_x5i6pbTw,7100
anyio/to_process.py,sha256=p6n8j0nreJUl1DEkR_9PL6KRoKtNqFxORTn8qZBvn94,9850
anyio/to_thread.py,sha256=8MPVrrQq8ttGvV21cSd68rMto5bhxJ0-i9CupXag5Gs,2750
anyio/_backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/_backends/_asyncio.py,sha256=Vqy7O1ymmEpxTiPzehSuBCVTziGALNV-BbsIRaKXsg4,107767
anyio/_backends/_trio.py,sha256=Gs8XzX0IeBvje8IEZtJmpNylKCUAChKv7I_YZi3wlmg,45715
anyio/_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/_core/_asyncio_selector_thread.py,sha256=2PdxFM3cs02Kp6BSppbvmRT7q7asreTW5FgBxEsflBo,5626
anyio/_core/_concurrency_utils.py,sha256=kvRXMrnDqgQTBkZc-f279-lBnZM4YbSToXqhnAUCfSc,4522
anyio/_core/_contextmanagers.py,sha256=YInBCabiEeS-UaP_Jdxa1CaFC71ETPW8HZTHIM8Rsc8,7215
anyio/_core/_eventloop.py,sha256=ByZUeJD9alMfcyTseRo5IzTO0IltEul_Gyq9iqSjqDk,6658
anyio/_core/_exceptions.py,sha256=B4ZhLkBQT4tI6vShv4-GJui492IJsoIDTw1FQJEkmAI,5618
anyio/_core/_fileio.py,sha256=q--ft9h5Y_FRLzhAnoYrXf3tOuZ8ZyIN2A9mCfMbZRc,34163
anyio/_core/_futures.py,sha256=YQq5uhnCnKQMQD62tOLfzEm7dUXY3GQorJdH6ghfgIQ,6177
anyio/_core/_resources.py,sha256=Ld5duCe9NwgPfI7EIOh3CMIBYQ7rq6ZQEa7CFS0u9Ec,450
anyio/_core/_signals.py,sha256=mjTBB2hTKNPRlU0IhnijeQedpWOGERDiMjSlJQsFrug,1016
anyio/_core/_sockets.py,sha256=C8b-3w5H9n0Fo4g7X8WJvk0m6CGh6NVAAFtieBGHK3g,35289
anyio/_core/_streams.py,sha256=FczFwIgDpnkK0bODWJXMpsUJYdvAD04kaUaGzJU8DK0,1806
anyio/_core/_subprocesses.py,sha256=TMBuGNILz-47w5t9vayJ1GpCpDT6LxsMohJDFCewIRc,7938
anyio/_core/_synchronization.py,sha256=ggsd74QPd846faA2HQeT07ONQ5HQ1S78gy8mvB3qCrw,21658
anyio/_core/_tasks.py,sha256=J2dhM_Jq3Grsfg_enNWpRz2SxfM15uLt-pEfvoQq3uA,15391
anyio/_core/_tempfile.py,sha256=IgoBCFaPhrFvsF9KHcCcrnCRn-UqMfw0WRdSDWgxAiI,19726
anyio/_core/_testing.py,sha256=ZRucLh5kqhiFWAJMMbd8yZ6uIvz0rq3WwL72ldosxAU,2340
anyio/_core/_typedattr.py,sha256=gt9Dzn-IL0QW14aHPDD8PAtq95FYKNNupHyf37xZGPQ,2607
anyio/abc/__init__.py,sha256=XIVvCI2c6oyaOcqXe94k2rzU5_s7-ZNnlxGTk34H4T0,3050
anyio/abc/_eventloop.py,sha256=0-UwgB351rkocCt8m3fjTmcNx2Fxn52nUoujG5DzMvU,11127
anyio/abc/_resources.py,sha256=hrb0LXUgHSp3i8G6W8JJchdzKXgg1m0T-L7udXmv2y0,900
anyio/abc/_sockets.py,sha256=jQWnPX7iDIDyvyvVkbuFhjezw4oI2WggzbW6Vl2-eqs,13162
anyio/abc/_streams.py,sha256=IMeOPPutFXRbyeAutxV1HJe7FUQZYp2qKrJJpP1DCv0,7620
anyio/abc/_subprocesses.py,sha256=CDqU1p0HuBS0pXUzBW_hbfhihTJRM67n__7qZmoPU84,2123
anyio/abc/_tasks.py,sha256=HBPpT72aOy0bwMAxBCKmnXsR328wCYRjYm3iaVw2fQo,7070
anyio/abc/_testing.py,sha256=A9Y8RAClIPn1A0aNHBN8YoaRy_MKOpbFGEbTHAXgB6U,2145
anyio/streams/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/streams/buffered.py,sha256=u7hCD8SNrYHcutG6K5wEiy1F88-pizgjvEFM22Kq2Cw,6746
anyio/streams/file.py,sha256=6jujI2m-QJITqqKFamrupX_DNsU7y2Fz3omLZxOLuY0,4524
anyio/streams/memory.py,sha256=rpxNzDMag9PCOynBqN64KyuAfAPB41vFyRP-Npo-lE0,10793
anyio/streams/stapled.py,sha256=UJ2Rgt0NUmM1VovRNOoZoic960lcbDjb3vhfZg3b_Z0,4845
anyio/streams/text.py,sha256=BcVAGJw1VRvtIqnv-o0Rb0pwH7p8vwlvl21xHq522ag,5765
anyio/streams/tls.py,sha256=fqLmxvULFO9rvxGyJ33VPrOIN7E3eiU1q90BkmKigQ8,15818
anyio-4.15.1.dist-info/licenses/LICENSE,sha256=U2GsncWPLvX9LpsJxoKXwX8ElQkJu8gCO9uC6s8iwrA,1081
anyio-4.15.1.dist-info/METADATA,sha256=BDrstXGvcYnopsUo9djjJUXxss36r3YdUebVtn6jyoE,4733
anyio-4.15.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
anyio-4.15.1.dist-info/entry_points.txt,sha256=_d6Yu6uiaZmNe0CydowirE9Cmg7zUL2g08tQpoS3Qvc,39
anyio-4.15.1.dist-info/top_level.txt,sha256=QglSMiWX8_5dpoVAEIHdEYzvqFMdSYWmCj6tYw2ITkQ,6
anyio-4.15.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (84.0.0)
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,2 @@
[pytest11]
anyio = anyio.pytest_plugin
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2018 Alex Grönholm
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.
@@ -0,0 +1,154 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ._lazyimport import (
fix_package_names,
install_lazy_importer,
set_deprecated_aliases,
)
if TYPE_CHECKING or not install_lazy_importer():
from ._core._concurrency_utils import amap as amap
from ._core._concurrency_utils import as_completed as as_completed
from ._core._concurrency_utils import gather as gather
from ._core._contextmanagers import (
AsyncContextManagerMixin as AsyncContextManagerMixin,
)
from ._core._contextmanagers import ContextManagerMixin as ContextManagerMixin
from ._core._eventloop import current_time as current_time
from ._core._eventloop import get_all_backends as get_all_backends
from ._core._eventloop import get_available_backends as get_available_backends
from ._core._eventloop import get_cancelled_exc_class as get_cancelled_exc_class
from ._core._eventloop import run as run
from ._core._eventloop import sleep as sleep
from ._core._eventloop import sleep_forever as sleep_forever
from ._core._eventloop import sleep_until as sleep_until
from ._core._exceptions import BrokenResourceError as BrokenResourceError
from ._core._exceptions import BrokenWorkerInterpreter as BrokenWorkerInterpreter
from ._core._exceptions import BrokenWorkerProcess as BrokenWorkerProcess
from ._core._exceptions import BusyResourceError as BusyResourceError
from ._core._exceptions import ClosedResourceError as ClosedResourceError
from ._core._exceptions import ConnectionFailed as ConnectionFailed
from ._core._exceptions import DelimiterNotFound as DelimiterNotFound
from ._core._exceptions import EndOfStream as EndOfStream
from ._core._exceptions import FutureAlreadyFinished as FutureAlreadyFinished
from ._core._exceptions import FutureCancelled as FutureCancelled
from ._core._exceptions import FutureFailed as FutureFailed
from ._core._exceptions import FutureNotFinished as FutureNotFinished
from ._core._exceptions import IncompleteRead as IncompleteRead
from ._core._exceptions import NoEventLoopError as NoEventLoopError
from ._core._exceptions import RunFinishedError as RunFinishedError
from ._core._exceptions import TaskCancelled as TaskCancelled
from ._core._exceptions import TaskFailed as TaskFailed
from ._core._exceptions import TaskNotFinished as TaskNotFinished
from ._core._exceptions import (
TypedAttributeLookupError as TypedAttributeLookupError,
)
from ._core._exceptions import WouldBlock as WouldBlock
from ._core._fileio import AsyncFile as AsyncFile
from ._core._fileio import Path as Path
from ._core._fileio import open_file as open_file
from ._core._fileio import wrap_file as wrap_file
from ._core._futures import Future as Future
from ._core._resources import aclose_forcefully as aclose_forcefully
from ._core._signals import open_signal_receiver as open_signal_receiver
from ._core._sockets import TCPConnectable as TCPConnectable
from ._core._sockets import UNIXConnectable as UNIXConnectable
from ._core._sockets import as_connectable as as_connectable
from ._core._sockets import connect_tcp as connect_tcp
from ._core._sockets import connect_unix as connect_unix
from ._core._sockets import (
create_connected_udp_socket as create_connected_udp_socket,
)
from ._core._sockets import (
create_connected_unix_datagram_socket as create_connected_unix_datagram_socket,
)
from ._core._sockets import create_tcp_listener as create_tcp_listener
from ._core._sockets import create_udp_socket as create_udp_socket
from ._core._sockets import (
create_unix_datagram_socket as create_unix_datagram_socket,
)
from ._core._sockets import create_unix_listener as create_unix_listener
from ._core._sockets import getaddrinfo as getaddrinfo
from ._core._sockets import getnameinfo as getnameinfo
from ._core._sockets import notify_closing as notify_closing
from ._core._sockets import wait_readable as wait_readable
from ._core._sockets import wait_socket_readable as wait_socket_readable
from ._core._sockets import wait_socket_writable as wait_socket_writable
from ._core._sockets import wait_writable as wait_writable
from ._core._streams import (
create_memory_object_stream as create_memory_object_stream,
)
from ._core._subprocesses import open_process as open_process
from ._core._subprocesses import run_process as run_process
from ._core._synchronization import CapacityLimiter as CapacityLimiter
from ._core._synchronization import (
CapacityLimiterStatistics as CapacityLimiterStatistics,
)
from ._core._synchronization import Condition as Condition
from ._core._synchronization import ConditionStatistics as ConditionStatistics
from ._core._synchronization import Event as Event
from ._core._synchronization import EventStatistics as EventStatistics
from ._core._synchronization import Lock as Lock
from ._core._synchronization import LockStatistics as LockStatistics
from ._core._synchronization import ResourceGuard as ResourceGuard
from ._core._synchronization import Semaphore as Semaphore
from ._core._synchronization import SemaphoreStatistics as SemaphoreStatistics
from ._core._tasks import TASK_STATUS_IGNORED as TASK_STATUS_IGNORED
from ._core._tasks import CancelScope as CancelScope
from ._core._tasks import TaskHandle as TaskHandle
from ._core._tasks import create_task_group as create_task_group
from ._core._tasks import current_effective_deadline as current_effective_deadline
from ._core._tasks import fail_after as fail_after
from ._core._tasks import fail_at as fail_at
from ._core._tasks import move_on_after as move_on_after
from ._core._tasks import move_on_at as move_on_at
from ._core._tempfile import NamedTemporaryFile as NamedTemporaryFile
from ._core._tempfile import SpooledTemporaryFile as SpooledTemporaryFile
from ._core._tempfile import TemporaryDirectory as TemporaryDirectory
from ._core._tempfile import TemporaryFile as TemporaryFile
from ._core._tempfile import gettempdir as gettempdir
from ._core._tempfile import gettempdirb as gettempdirb
from ._core._tempfile import mkdtemp as mkdtemp
from ._core._tempfile import mkstemp as mkstemp
from ._core._testing import TaskInfo as TaskInfo
from ._core._testing import get_current_task as get_current_task
from ._core._testing import get_running_tasks as get_running_tasks
from ._core._testing import wait_all_tasks_blocked as wait_all_tasks_blocked
from ._core._typedattr import TypedAttributeProvider as TypedAttributeProvider
from ._core._typedattr import TypedAttributeSet as TypedAttributeSet
from ._core._typedattr import typed_attribute as typed_attribute
# ruff: isort: off
from . import (
abc as abc,
)
from . import (
from_thread as from_thread,
)
from . import (
functools as functools,
)
from . import (
itertools as itertools,
)
from . import (
lowlevel as lowlevel,
)
from . import (
to_interpreter as to_interpreter,
)
from . import (
to_process as to_process,
)
from . import (
to_thread as to_thread,
)
fix_package_names()
set_deprecated_aliases(
{
"BrokenWorkerIntepreter": "anyio.BrokenWorkerInterpreter",
}
)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Whitespace-only changes.
@@ -0,0 +1,167 @@
from __future__ import annotations
import asyncio
import socket
import threading
from collections.abc import Callable
from selectors import EVENT_READ, EVENT_WRITE, DefaultSelector
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from _typeshed import FileDescriptorLike
_selector_lock = threading.Lock()
_selector: Selector | None = None
class Selector:
def __init__(self) -> None:
self._thread = threading.Thread(target=self.run, name="AnyIO socket selector")
self._selector = DefaultSelector()
self._send, self._receive = socket.socketpair()
self._send.setblocking(False)
self._receive.setblocking(False)
# This somewhat reduces the amount of memory wasted queueing up data
# for wakeups. With these settings, maximum number of 1-byte sends
# before getting BlockingIOError:
# Linux 4.8: 6
# macOS (darwin 15.5): 1
# Windows 10: 525347
# Windows you're weird. (And on Windows setting SNDBUF to 0 makes send
# blocking, even on non-blocking sockets, so don't do that.)
self._receive.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1)
self._send.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1)
# On Windows this is a TCP socket so this might matter. On other
# platforms this fails b/c AF_UNIX sockets aren't actually TCP.
try:
self._send.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except OSError:
pass
self._selector.register(self._receive, EVENT_READ)
self._closed = False
def start(self) -> None:
self._thread.start()
threading._register_atexit(self._stop) # type: ignore[attr-defined]
def _stop(self) -> None:
global _selector
self._closed = True
self._notify_self()
self._send.close()
self._thread.join()
self._selector.unregister(self._receive)
self._receive.close()
self._selector.close()
_selector = None
assert not self._selector.get_map(), (
"selector still has registered file descriptors after shutdown"
)
def _notify_self(self) -> None:
try:
self._send.send(b"\x00")
except BlockingIOError:
pass
def add_reader(self, fd: FileDescriptorLike, callback: Callable[[], Any]) -> None:
loop = asyncio.get_running_loop()
try:
key = self._selector.get_key(fd)
except KeyError:
self._selector.register(fd, EVENT_READ, {EVENT_READ: (loop, callback)})
else:
if EVENT_READ in key.data:
raise ValueError(
"this file descriptor is already registered for reading"
)
key.data[EVENT_READ] = loop, callback
self._selector.modify(fd, key.events | EVENT_READ, key.data)
self._notify_self()
def add_writer(self, fd: FileDescriptorLike, callback: Callable[[], Any]) -> None:
loop = asyncio.get_running_loop()
try:
key = self._selector.get_key(fd)
except KeyError:
self._selector.register(fd, EVENT_WRITE, {EVENT_WRITE: (loop, callback)})
else:
if EVENT_WRITE in key.data:
raise ValueError(
"this file descriptor is already registered for writing"
)
key.data[EVENT_WRITE] = loop, callback
self._selector.modify(fd, key.events | EVENT_WRITE, key.data)
self._notify_self()
def remove_reader(self, fd: FileDescriptorLike) -> bool:
try:
key = self._selector.get_key(fd)
except KeyError:
return False
if new_events := key.events ^ EVENT_READ:
del key.data[EVENT_READ]
self._selector.modify(fd, new_events, key.data)
else:
self._selector.unregister(fd)
return True
def remove_writer(self, fd: FileDescriptorLike) -> bool:
try:
key = self._selector.get_key(fd)
except KeyError:
return False
if new_events := key.events ^ EVENT_WRITE:
del key.data[EVENT_WRITE]
self._selector.modify(fd, new_events, key.data)
else:
self._selector.unregister(fd)
return True
def run(self) -> None:
while not self._closed:
for key, events in self._selector.select():
if key.fileobj is self._receive:
try:
while self._receive.recv(4096):
pass
except BlockingIOError:
pass
continue
if events & EVENT_READ:
loop, callback = key.data[EVENT_READ]
self.remove_reader(key.fd)
try:
loop.call_soon_threadsafe(callback)
except RuntimeError:
pass # the loop was already closed
if events & EVENT_WRITE:
loop, callback = key.data[EVENT_WRITE]
self.remove_writer(key.fd)
try:
loop.call_soon_threadsafe(callback)
except RuntimeError:
pass # the loop was already closed
def get_selector() -> Selector:
global _selector
with _selector_lock:
if _selector is None:
_selector = Selector()
_selector.start()
return _selector
@@ -0,0 +1,156 @@
from __future__ import annotations
from inspect import iscoroutine
__all__ = ("amap", "as_completed", "gather")
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Iterable
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, TypeVar, overload
from ..abc._tasks import get_coro_name
from ._exceptions import BrokenResourceError
from ._streams import create_memory_object_stream
from ._tasks import TaskHandle, create_task_group
if TYPE_CHECKING:
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
R = TypeVar("R")
S = TypeVar("S")
T = TypeVar("T")
U = TypeVar("U")
V = TypeVar("V")
W = TypeVar("W")
@overload
async def gather(
coro1: Coroutine[Any, Any, R], coro2: Coroutine[Any, Any, S], /
) -> tuple[R, S]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
/,
) -> tuple[R, S, T]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
coro4: Coroutine[Any, Any, U],
/,
) -> tuple[R, S, T, U]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
coro4: Coroutine[Any, Any, U],
coro5: Coroutine[Any, Any, V],
/,
) -> tuple[R, S, T, U, V]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
coro4: Coroutine[Any, Any, U],
coro5: Coroutine[Any, Any, V],
coro6: Coroutine[Any, Any, W],
/,
) -> tuple[R, S, T, U, V, W]: ...
# handle arbitrary length if awaitables are all of the same type
@overload
async def gather(*coros: Coroutine[Any, Any, R]) -> tuple[R, ...]: ...
async def gather(*coros: Coroutine[Any, Any, Any]) -> tuple[Any, ...]:
"""
Run coroutines concurrently in a task group. The order of result values corresponds
to the order of coroutines passed.
:param coros: coroutine objects to run as tasks
:return: task results for each argument in the same order as they were passed
"""
handles: list[TaskHandle[Any]] = []
async with create_task_group() as tg:
handles.extend(tg.create_task(coro) for coro in coros)
return tuple(h.return_value for h in handles)
@asynccontextmanager
async def as_completed(
*awaitables: Awaitable[R],
) -> AsyncGenerator[MemoryObjectReceiveStream[TaskHandle[R]]]:
"""
Run awaitable objects concurrently in a task group, returning an iterator which can
be used to get finished task handles in the order they complete.
:param awaitables: awaitable objects to run as tasks
:return: MemoryObjectReceiveStream for iterating over task handles as tasks complete.
"""
if not awaitables:
raise ValueError("as_completed() takes at least one awaitable")
send, recv = create_memory_object_stream[TaskHandle[R]](len(awaitables))
task_handles: list[TaskHandle[R]] = []
async def runner(
awaitable: Awaitable[R],
index: int,
_send: MemoryObjectSendStream[TaskHandle[R]],
) -> R:
async with _send:
try:
return await awaitable
finally:
try:
_send.send_nowait(task_handles[index])
except BrokenResourceError:
pass
async with recv, create_task_group() as tg:
async with send:
for i, awaitable in enumerate(awaitables):
name = get_coro_name(awaitable) if iscoroutine(awaitable) else None
task_handles.append(
tg.start_soon(runner, awaitable, i, send.clone(), name=name)
)
try:
yield recv
finally:
tg.cancel_scope.cancel()
async def amap(
func: Callable[[T], Coroutine[Any, Any, R]], args: Iterable[T]
) -> list[R]:
"""
Run the given coroutine function concurrently for multiple argument values.
:param func: a coroutine function that takes a single argument
:param args: a sequence of argument values to pass to ``func``
:return: task results for each argument in the same order as they were passed
"""
handles: list[TaskHandle[R]] = []
async with create_task_group() as tg:
handles.extend(tg.start_soon(func, arg) for arg in args)
return [h.return_value for h in handles]
@@ -0,0 +1,200 @@
from __future__ import annotations
from abc import abstractmethod
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from inspect import isasyncgen, iscoroutine, isgenerator
from types import TracebackType
from typing import Protocol, TypeVar, cast, final
_T_co = TypeVar("_T_co", covariant=True)
_ExitT_co = TypeVar("_ExitT_co", covariant=True, bound="bool | None")
class _SupportsCtxMgr(Protocol[_T_co, _ExitT_co]):
def __contextmanager__(self) -> AbstractContextManager[_T_co, _ExitT_co]: ...
class _SupportsAsyncCtxMgr(Protocol[_T_co, _ExitT_co]):
def __asynccontextmanager__(
self,
) -> AbstractAsyncContextManager[_T_co, _ExitT_co]: ...
class ContextManagerMixin:
"""
Mixin class providing context manager functionality via a generator-based
implementation.
This class allows you to implement a context manager via :meth:`__contextmanager__`
which should return a generator. The mechanics are meant to mirror those of
:func:`@contextmanager <contextlib.contextmanager>`.
.. note:: Classes using this mix-in are not reentrant as context managers, meaning
that once you enter it, you can't re-enter before first exiting it.
.. seealso:: :doc:`contextmanagers`
"""
__cm: AbstractContextManager[object, bool | None] | None = None
@final
def __enter__(self: _SupportsCtxMgr[_T_co, bool | None]) -> _T_co:
# Needed for mypy to assume self still has the __cm member
assert isinstance(self, ContextManagerMixin)
if self.__cm is not None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has already been entered"
)
cm = self.__contextmanager__()
if not isinstance(cm, AbstractContextManager):
if isgenerator(cm):
raise TypeError(
"__contextmanager__() returned a generator object instead of "
"a context manager. Did you forget to add the @contextmanager "
"decorator?"
)
raise TypeError(
f"__contextmanager__() did not return a context manager object, "
f"but {cm.__class__!r}"
)
if cm is self:
raise TypeError(
f"{self.__class__.__qualname__}.__contextmanager__() returned "
f"self. Did you forget to add the @contextmanager decorator and a "
f"'yield' statement?"
)
value = cm.__enter__()
self.__cm = cm
return value
@final
def __exit__(
self: _SupportsCtxMgr[object, _ExitT_co],
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> _ExitT_co:
# Needed for mypy to assume self still has the __cm member
assert isinstance(self, ContextManagerMixin)
if self.__cm is None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has not been entered yet"
)
# Prevent circular references
cm = self.__cm
del self.__cm
return cast(_ExitT_co, cm.__exit__(exc_type, exc_val, exc_tb))
@abstractmethod
def __contextmanager__(self) -> AbstractContextManager[object, bool | None]:
"""
Implement your context manager logic here.
This method **must** be decorated with
:func:`@contextmanager <contextlib.contextmanager>`.
.. note:: Remember that the ``yield`` will raise any exception raised in the
enclosed context block, so use a ``finally:`` block to clean up resources!
:return: a context manager object
"""
class AsyncContextManagerMixin:
"""
Mixin class providing async context manager functionality via a generator-based
implementation.
This class allows you to implement a context manager via
:meth:`__asynccontextmanager__`. The mechanics are meant to mirror those of
:func:`@asynccontextmanager <contextlib.asynccontextmanager>`.
.. note:: Classes using this mix-in are not reentrant as context managers, meaning
that once you enter it, you can't re-enter before first exiting it.
.. seealso:: :doc:`contextmanagers`
"""
__cm: AbstractAsyncContextManager[object, bool | None] | None = None
@final
async def __aenter__(self: _SupportsAsyncCtxMgr[_T_co, bool | None]) -> _T_co:
# Needed for mypy to assume self still has the __cm member
assert isinstance(self, AsyncContextManagerMixin)
if self.__cm is not None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has already been entered"
)
cm = self.__asynccontextmanager__()
if not isinstance(cm, AbstractAsyncContextManager):
if isasyncgen(cm):
raise TypeError(
"__asynccontextmanager__() returned an async generator instead of "
"an async context manager. Did you forget to add the "
"@asynccontextmanager decorator?"
)
elif iscoroutine(cm):
cm.close()
raise TypeError(
"__asynccontextmanager__() returned a coroutine object instead of "
"an async context manager. Did you forget to add the "
"@asynccontextmanager decorator and a 'yield' statement?"
)
raise TypeError(
f"__asynccontextmanager__() did not return an async context manager, "
f"but {cm.__class__!r}"
)
if cm is self:
raise TypeError(
f"{self.__class__.__qualname__}.__asynccontextmanager__() returned "
f"self. Did you forget to add the @asynccontextmanager decorator and a "
f"'yield' statement?"
)
value = await cm.__aenter__()
self.__cm = cm
return value
@final
async def __aexit__(
self: _SupportsAsyncCtxMgr[object, _ExitT_co],
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> _ExitT_co:
assert isinstance(self, AsyncContextManagerMixin)
if self.__cm is None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has not been entered yet"
)
# Prevent circular references
cm = self.__cm
del self.__cm
return cast(_ExitT_co, await cm.__aexit__(exc_type, exc_val, exc_tb))
@abstractmethod
def __asynccontextmanager__(
self,
) -> AbstractAsyncContextManager[object, bool | None]:
"""
Implement your async context manager logic here.
This method **must** be decorated with
:func:`@asynccontextmanager <contextlib.asynccontextmanager>`.
.. note:: Remember that the ``yield`` will raise any exception raised in the
enclosed context block, so use a ``finally:`` block to clean up resources!
:return: an async context manager object
"""
@@ -0,0 +1,240 @@
from __future__ import annotations
import math
import sys
import threading
from collections.abc import Awaitable, Callable, Generator
from contextlib import contextmanager
from contextvars import Token
from importlib import import_module
from typing import TYPE_CHECKING, Any, TypeVar
from ._exceptions import NoEventLoopError
if sys.version_info >= (3, 11):
from typing import TypeVarTuple, Unpack
else:
from typing_extensions import TypeVarTuple, Unpack
sniffio: Any
try:
import sniffio
except ModuleNotFoundError:
sniffio = None
if TYPE_CHECKING:
from ..abc import AsyncBackend
# This must be updated when new backends are introduced
BACKENDS = "asyncio", "trio"
T_Retval = TypeVar("T_Retval")
PosArgsT = TypeVarTuple("PosArgsT")
threadlocals = threading.local()
loaded_backends: dict[str, type[AsyncBackend]] = {}
def run(
func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]],
*args: Unpack[PosArgsT],
backend: str = "asyncio",
backend_options: dict[str, Any] | None = None,
) -> T_Retval:
"""
Run the given coroutine function in an asynchronous event loop.
The current thread must not be already running an event loop.
:param func: a coroutine function
:param args: positional arguments to ``func``
:param backend: name of the asynchronous event loop implementation – currently
either ``asyncio`` or ``trio``
:param backend_options: keyword arguments to call the backend ``run()``
implementation with (documented :ref:`here <backend options>`)
:return: the return value of the coroutine function
:raises RuntimeError: if an asynchronous event loop is already running in this
thread
:raises LookupError: if the named backend is not found
"""
if asynclib_name := current_async_library():
raise RuntimeError(f"Already running {asynclib_name} in this thread")
try:
async_backend = get_async_backend(backend)
except ImportError as exc:
if backend in BACKENDS:
raise LookupError(
f"Backend {backend!r} is not available. "
f"Install it with: pip install anyio[{backend}]"
) from exc
raise LookupError(f"No such backend: {backend}") from exc
token = None
if asynclib_name is None:
# Since we're in control of the event loop, we can cache the name of the async
# library
token = set_current_async_library(backend)
try:
backend_options = backend_options or {}
return async_backend.run(func, args, {}, backend_options)
finally:
reset_current_async_library(token)
async def sleep(delay: float) -> None:
"""
Pause the current task for the specified duration.
:param delay: the duration, in seconds
"""
return await get_async_backend().sleep(delay)
async def sleep_forever() -> None:
"""
Pause the current task until it's cancelled.
This is a shortcut for ``sleep(math.inf)``.
.. versionadded:: 3.1
"""
await sleep(math.inf)
async def sleep_until(deadline: float) -> None:
"""
Pause the current task until the given time.
:param deadline: the absolute time to wake up at (according to the internal
monotonic clock of the event loop)
.. versionadded:: 3.1
"""
now = current_time()
await sleep(max(deadline - now, 0))
def current_time() -> float:
"""
Return the current value of the event loop's internal clock.
:return: the clock value (seconds)
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().current_time()
def get_all_backends() -> tuple[str, ...]:
"""Return a tuple of the names of all built-in backends."""
return BACKENDS
def get_available_backends() -> tuple[str, ...]:
"""
Test for the availability of built-in backends.
:return a tuple of the built-in backend names that were successfully imported
.. versionadded:: 4.12
"""
available_backends: list[str] = []
for backend_name in get_all_backends():
try:
get_async_backend(backend_name)
except ImportError:
continue
available_backends.append(backend_name)
return tuple(available_backends)
def get_cancelled_exc_class() -> type[BaseException]:
"""
Return the current async library's cancellation exception class.
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().cancelled_exception_class()
#
# Private API
#
@contextmanager
def claim_worker_thread(
backend_class: type[AsyncBackend], token: object
) -> Generator[Any, None, None]:
from ..lowlevel import EventLoopToken
threadlocals.current_token = EventLoopToken(backend_class, token)
try:
yield
finally:
del threadlocals.current_token
def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]:
if asynclib_name is None:
asynclib_name = current_async_library()
if not asynclib_name:
raise NoEventLoopError(
f"Not currently running on any asynchronous event loop. "
f"Available async backends: {', '.join(get_all_backends())}"
)
# We use our own dict instead of sys.modules to get the already imported back-end
# class because the appropriate modules in sys.modules could potentially be only
# partially initialized
try:
return loaded_backends[asynclib_name]
except KeyError:
module = import_module(f"anyio._backends._{asynclib_name}")
loaded_backends[asynclib_name] = module.backend_class
return module.backend_class
def current_async_library() -> str | None:
if sniffio is None:
# If sniffio is not installed, we assume we're either running asyncio or nothing
import asyncio
try:
asyncio.get_running_loop()
return "asyncio"
except RuntimeError:
pass
else:
try:
return sniffio.current_async_library()
except sniffio.AsyncLibraryNotFoundError:
pass
return None
def set_current_async_library(asynclib_name: str | None) -> Token | None:
# no-op if sniffio is not installed
if sniffio is None:
return None
return sniffio.current_async_library_cvar.set(asynclib_name)
def reset_current_async_library(token: Token | None) -> None:
if token is not None:
sniffio.current_async_library_cvar.reset(token)
@@ -0,0 +1,205 @@
from __future__ import annotations
import sys
from collections.abc import Generator
from textwrap import dedent
from typing import Any
if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup
class BrokenResourceError(Exception):
"""
Raised when trying to use a resource that has been rendered unusable due to external
causes (e.g. a send stream whose peer has disconnected).
"""
class BrokenWorkerProcess(Exception):
"""
Raised by :meth:`~anyio.to_process.run_sync` if the worker process terminates abruptly or
otherwise misbehaves.
"""
class BrokenWorkerInterpreter(Exception):
"""
Raised by :meth:`~anyio.to_interpreter.run_sync` if an unexpected exception is
raised in the subinterpreter.
"""
def __init__(self, excinfo: Any):
# This was adapted from concurrent.futures.interpreter.ExecutionFailed
msg = excinfo.formatted
if not msg:
if excinfo.type and excinfo.msg:
msg = f"{excinfo.type.__name__}: {excinfo.msg}"
else:
msg = excinfo.type.__name__ or excinfo.msg
super().__init__(msg)
self.excinfo = excinfo
def __str__(self) -> str:
try:
formatted = self.excinfo.errdisplay
except Exception:
return super().__str__()
else:
return dedent(
f"""
{super().__str__()}
Uncaught in the interpreter:
{formatted}
""".strip()
)
class BusyResourceError(Exception):
"""
Raised when two tasks are trying to read from or write to the same resource
concurrently.
"""
def __init__(self, action: str):
super().__init__(f"Another task is already {action} this resource")
class ClosedResourceError(Exception):
"""Raised when trying to use a resource that has been closed."""
class ConnectionFailed(OSError):
"""
Raised when a connection attempt fails.
.. note:: This class inherits from :exc:`OSError` for backwards compatibility.
"""
def iterate_exceptions(
exception: BaseException,
) -> Generator[BaseException, None, None]:
if isinstance(exception, BaseExceptionGroup):
for exc in exception.exceptions:
yield from iterate_exceptions(exc)
else:
yield exception
class DelimiterNotFound(Exception):
"""
Raised during
:meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_until` if the
maximum number of bytes has been read without the delimiter being found.
"""
def __init__(self, max_bytes: int) -> None:
super().__init__(
f"The delimiter was not found among the first {max_bytes} bytes"
)
class EndOfStream(Exception):
"""
Raised when trying to read from a stream that has been closed from the other end.
"""
class IncompleteRead(Exception):
"""
Raised during
:meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_exactly` or
:meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_until` if the
connection is closed before the requested amount of bytes has been read.
"""
def __init__(self) -> None:
super().__init__(
"The stream was closed before the read operation could be completed"
)
class TypedAttributeLookupError(LookupError):
"""
Raised by :meth:`~anyio.TypedAttributeProvider.extra` when the given typed attribute
is not found and no default value has been given.
"""
class WouldBlock(Exception):
"""Raised by ``X_nowait`` functions if ``X()`` would block."""
class NoEventLoopError(RuntimeError):
"""
Raised by several functions that require an event loop to be running in the current
thread when there is no running event loop.
This is also raised by :func:`.from_thread.run` and :func:`.from_thread.run_sync`
if not calling from an AnyIO worker thread, and no ``token`` was passed.
"""
class RunFinishedError(RuntimeError):
"""
Raised by :func:`.from_thread.run` and :func:`.from_thread.run_sync` if the event
loop associated with the explicitly passed token has already finished.
"""
def __init__(self) -> None:
super().__init__(
"The event loop associated with the given token has already finished"
)
class TaskFailed(Exception):
"""
Raised when awaiting on, or attempting to access the return value of, a
:class:`.TaskHandle` that raised an exception.
"""
class TaskCancelled(TaskFailed):
"""
Raised when awaiting on, or attempting to access the return value of, a
:class:`.TaskHandle` that was cancelled.
"""
class TaskNotFinished(Exception):
"""
Raised when attempting to access the return value or exception of a
:class:`.TaskHandle` that is still pending completion.
"""
class FutureFailed(Exception):
"""
Raised when awaiting on, or attempting to access the return value of, a
:class:`.Future` that raised an exception.
"""
class FutureCancelled(FutureFailed):
"""
Raised when attempting to access the return value or exception of a
:class:`.Future` that was cancelled.
"""
class FutureNotFinished(Exception):
"""
Raised when attempting to access the return value or exception of a
:class:`.Future` that is still pending completion.
"""
class FutureAlreadyFinished(Exception):
"""
Raised when attempting set a result of or await a
:class:`.Future` that has already completed.
"""
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,191 @@
from __future__ import annotations
from collections.abc import Generator
from enum import Enum, auto
from typing import Any, Generic, TypeVar
from ._exceptions import (
FutureAlreadyFinished,
FutureCancelled,
FutureFailed,
FutureNotFinished,
)
from ._synchronization import Event
T = TypeVar("T")
class Future(Generic[T]):
"""
An awaitable object that works similar to a :class:`asyncio.Future` but
with similar characteristics to a :class:`.TaskHandle`.
"""
class Status(Enum):
"""
The status of a future handle.
.. attribute:: PENDING
The future has not finished yet.
.. attribute:: FINISHED
The future has finished with a return value.
.. attribute:: CANCELLED
The future was cancelled and has finished since.
.. attribute:: FAILED
The future raised an exception.
"""
PENDING = auto()
FINISHED = auto()
CANCELLED = auto()
FAILED = auto()
__slots__ = (
"_cancelled",
"_exception",
"_finished_event",
"_name",
"_return_value",
)
_return_value: T
def __init__(self, *, name: str | None = None) -> None:
self._finished_event = Event()
self._exception: BaseException | None = None
self._cancelled: bool = False
self._name = name
def _check_pending(self) -> None:
"""Shortcut for checking if a Future is pending
:raises FutureAlreadyFinished: if future was already given a result or exception.
:raises FutureCancelled: if future has been cancelled previously.
"""
match self.status:
case Future.Status.PENDING:
return
case Future.Status.FINISHED:
raise FutureAlreadyFinished("future has already finished")
case Future.Status.FAILED:
raise FutureAlreadyFinished("future already failed")
case Future.Status.CANCELLED:
raise FutureCancelled("future was cancelled")
async def wait(self) -> None:
"""
Waits for the future to finish.
This method will attempt to wait for a result or exception
"""
await self._finished_event.wait()
def cancel(self) -> None:
"""Cancels a pending `.Future` object
Does nothing if the Future was already finished.
"""
if self.status is Future.Status.PENDING:
self._cancelled = True
self._finished_event.set()
@property
def exception(self) -> BaseException | None:
"""
The exception value of a `.Future`
:raises FutureNotFinished: if future is still pending
:raises FutureCancelled: if future was cancelled
:returns: None if future succeeds with a result sent instead
otherwise this will be an exception
"""
match self.status:
case Future.Status.PENDING:
raise FutureNotFinished("the future has not finished yet")
case Future.Status.FINISHED:
return None
case Future.Status.CANCELLED:
raise FutureCancelled("the future was cancelled")
case Future.Status.FAILED:
return self._exception
@exception.setter
def exception(self, exception: BaseException) -> None:
"""Send exception for a `.Future` object
:raises FutureAlreadyFinished: if future was already given a result or exception.
:raises FutureCancelled: if future has been cancelled previously.
"""
self._check_pending()
self._exception = exception
self._finished_event.set()
@property
def return_value(self) -> T:
"""
The return value of the future.
:raises FutureNotFinished: if the future has not finished yet
:raises FutureCancelled: if the future was cancelled
:raises FutureFailed: if the future raised an exception
"""
match self.status:
case Future.Status.PENDING:
raise FutureNotFinished("the future has not finished yet")
case Future.Status.FINISHED:
return self._return_value
case Future.Status.CANCELLED:
raise FutureCancelled("the future was cancelled")
case Future.Status.FAILED:
raise FutureFailed(
"the future raised an exception"
) from self._exception
@return_value.setter
def return_value(self, value: T) -> None:
"""
Send pending result for a `.Future` object
:raises FutureAlreadyFinished: if future was already given a result or exception.
:raises FutureCancelled: if future has been cancelled previously.
"""
self._check_pending()
self._return_value = value
self._finished_event.set()
@property
def status(self) -> Future.Status:
"""
The current status of a future.
Every future starts in the :attr:`~Future.Status.PENDING` state.
If a future is cancelled while in this state, it will transition to the
:attr:`Future.Status.CANCELLED` state. When the task finishes, it will
transition to one of the three final states (
:attr:`Future.Status.FINISHED`, :attr:`Future.Status.FAILED`, or
:attr:`Future.Status.CANCELLED`) depending on the exception the task
raised, if any. No other status transitions will happen.
"""
if not self._finished_event.is_set():
return Future.Status.PENDING
elif self._cancelled:
return Future.Status.CANCELLED
elif self._exception is not None:
return Future.Status.FAILED
else:
return Future.Status.FINISHED
def __await__(self) -> Generator[Any, Any, T]:
yield from self.wait().__await__()
return self.return_value
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__} {self.status.name.lower()} "
f"name={self._name!r}>"
)
@@ -0,0 +1,18 @@
from __future__ import annotations
from ..abc import AsyncResource # noqa: TC001
from ._tasks import CancelScope
async def aclose_forcefully(resource: AsyncResource) -> None:
"""
Close an asynchronous resource in a cancelled scope.
Doing this closes the resource without waiting on anything.
:param resource: the resource to close
"""
with CancelScope() as scope:
scope.cancel()
await resource.aclose()
@@ -0,0 +1,29 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import AbstractContextManager
from signal import Signals
from ._eventloop import get_async_backend
def open_signal_receiver(
*signals: Signals,
) -> AbstractContextManager[AsyncIterator[Signals]]:
"""
Start receiving operating system signals.
:param signals: signals to receive (e.g. ``signal.SIGINT``)
:return: an asynchronous context manager for an asynchronous iterator which yields
signal numbers
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. warning:: Windows does not support signals natively so it is best to avoid
relying on this in cross-platform applications.
.. warning:: On asyncio, this permanently replaces any previous signal handler for
the given signals, as set via :meth:`~asyncio.loop.add_signal_handler`.
"""
return get_async_backend().open_signal_receiver(*signals)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
from __future__ import annotations
import math
from typing import TypeVar
from warnings import warn
from ..streams.memory import (
MemoryObjectReceiveStream,
MemoryObjectSendStream,
_MemoryObjectStreamState,
)
T_Item = TypeVar("T_Item")
class create_memory_object_stream(
tuple[MemoryObjectSendStream[T_Item], MemoryObjectReceiveStream[T_Item]],
):
"""
Create a memory object stream.
The stream's item type can be annotated like
:func:`create_memory_object_stream[T_Item]`.
:param max_buffer_size: number of items held in the buffer until ``send()`` starts
blocking
:param item_type: old way of marking the streams with the right generic type for
static typing (does nothing on AnyIO 4)
.. deprecated:: 4.0
Use ``create_memory_object_stream[YourItemType](...)`` instead.
:return: a tuple of (send stream, receive stream)
"""
def __new__( # type: ignore[misc]
cls, max_buffer_size: float = 0, item_type: object = None
) -> tuple[MemoryObjectSendStream[T_Item], MemoryObjectReceiveStream[T_Item]]:
if max_buffer_size != math.inf and not isinstance(max_buffer_size, int):
raise ValueError("max_buffer_size must be either an integer or math.inf")
if max_buffer_size < 0:
raise ValueError("max_buffer_size cannot be negative")
if item_type is not None:
warn(
"The item_type argument has been deprecated in AnyIO 4.0. "
"Use create_memory_object_stream[YourItemType](...) instead.",
DeprecationWarning,
stacklevel=2,
)
state = _MemoryObjectStreamState[T_Item](max_buffer_size)
return (MemoryObjectSendStream(state), MemoryObjectReceiveStream(state))
@@ -0,0 +1,196 @@
from __future__ import annotations
from collections.abc import AsyncIterable, Iterable, Mapping, Sequence
from io import BytesIO
from os import PathLike
from subprocess import PIPE, CalledProcessError, CompletedProcess
from typing import IO, Any, TypeAlias, cast
from ..abc import Process # noqa: TC001
from ._eventloop import get_async_backend
from ._tasks import create_task_group
StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes]
async def run_process(
command: StrOrBytesPath | Sequence[StrOrBytesPath],
*,
input: bytes | None = None,
stdin: int | IO[Any] | None = None,
stdout: int | IO[Any] | None = PIPE,
stderr: int | IO[Any] | None = PIPE,
check: bool = True,
cwd: StrOrBytesPath | None = None,
env: Mapping[str, str] | None = None,
startupinfo: Any = None,
creationflags: int = 0,
start_new_session: bool = False,
pass_fds: Sequence[int] = (),
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
) -> CompletedProcess[bytes]:
"""
Run an external command in a subprocess and wait until it completes.
.. seealso:: :func:`subprocess.run`
:param command: either a string to pass to the shell, or an iterable of strings
containing the executable name or path and its arguments
:param input: bytes passed to the standard input of the subprocess
:param stdin: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
a file-like object, or `None`; ``input`` overrides this
:param stdout: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
a file-like object, or `None`
:param stderr: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
:data:`subprocess.STDOUT`, a file-like object, or `None`
:param check: if ``True``, raise :exc:`~subprocess.CalledProcessError` if the
process terminates with a return code other than 0
:param cwd: If not ``None``, change the working directory to this before running the
command
:param env: if not ``None``, this mapping replaces the inherited environment
variables from the parent process
:param startupinfo: an instance of :class:`subprocess.STARTUPINFO` that can be used
to specify process startup parameters (Windows only)
:param creationflags: flags that can be used to control the creation of the
subprocess (see :class:`subprocess.Popen` for the specifics)
:param start_new_session: if ``true`` the setsid() system call will be made in the
child process prior to the execution of the subprocess. (POSIX only)
:param pass_fds: sequence of file descriptors to keep open between the parent and
child processes. (POSIX only)
:param user: effective user to run the process as (Python >= 3.9, POSIX only)
:param group: effective group to run the process as (Python >= 3.9, POSIX only)
:param extra_groups: supplementary groups to set in the subprocess (Python >= 3.9,
POSIX only)
:param umask: if not negative, this umask is applied in the child process before
running the given command (Python >= 3.9, POSIX only)
:return: an object representing the completed process
:raises ~subprocess.CalledProcessError: if ``check`` is ``True`` and the process
exits with a nonzero return code
"""
async def drain_stream(stream: AsyncIterable[bytes], index: int) -> None:
buffer = BytesIO()
async for chunk in stream:
buffer.write(chunk)
stream_contents[index] = buffer.getvalue()
if stdin is not None and input is not None:
raise ValueError("only one of stdin and input is allowed")
async with await open_process(
command,
stdin=PIPE if input else stdin,
stdout=stdout,
stderr=stderr,
cwd=cwd,
env=env,
startupinfo=startupinfo,
creationflags=creationflags,
start_new_session=start_new_session,
pass_fds=pass_fds,
user=user,
group=group,
extra_groups=extra_groups,
umask=umask,
) as process:
stream_contents: list[bytes | None] = [None, None]
async with create_task_group() as tg:
if process.stdout:
tg.start_soon(drain_stream, process.stdout, 0)
if process.stderr:
tg.start_soon(drain_stream, process.stderr, 1)
if process.stdin and input:
await process.stdin.send(input)
await process.stdin.aclose()
await process.wait()
output, errors = stream_contents
if check and process.returncode != 0:
raise CalledProcessError(cast(int, process.returncode), command, output, errors)
return CompletedProcess(command, cast(int, process.returncode), output, errors)
async def open_process(
command: StrOrBytesPath | Sequence[StrOrBytesPath],
*,
stdin: int | IO[Any] | None = PIPE,
stdout: int | IO[Any] | None = PIPE,
stderr: int | IO[Any] | None = PIPE,
cwd: StrOrBytesPath | None = None,
env: Mapping[str, str] | None = None,
startupinfo: Any = None,
creationflags: int = 0,
start_new_session: bool = False,
pass_fds: Sequence[int] = (),
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
) -> Process:
"""
Start an external command in a subprocess.
.. seealso:: :class:`subprocess.Popen`
:param command: either a string to pass to the shell, or an iterable of strings
containing the executable name or path and its arguments
:param stdin: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, a
file-like object, or ``None``
:param stdout: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
a file-like object, or ``None``
:param stderr: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
:data:`subprocess.STDOUT`, a file-like object, or ``None``
:param cwd: If not ``None``, the working directory is changed before executing
:param env: If env is not ``None``, it must be a mapping that defines the
environment variables for the new process
:param creationflags: flags that can be used to control the creation of the
subprocess (see :class:`subprocess.Popen` for the specifics)
:param startupinfo: an instance of :class:`subprocess.STARTUPINFO` that can be used
to specify process startup parameters (Windows only)
:param start_new_session: if ``true`` the setsid() system call will be made in the
child process prior to the execution of the subprocess. (POSIX only)
:param pass_fds: sequence of file descriptors to keep open between the parent and
child processes. (POSIX only)
:param user: effective user to run the process as (POSIX only)
:param group: effective group to run the process as (POSIX only)
:param extra_groups: supplementary groups to set in the subprocess (POSIX only)
:param umask: if not negative, this umask is applied in the child process before
running the given command (POSIX only)
:return: an asynchronous process object
"""
kwargs: dict[str, Any] = {}
if user is not None:
kwargs["user"] = user
if group is not None:
kwargs["group"] = group
if extra_groups is not None:
kwargs["extra_groups"] = extra_groups
if umask >= 0:
kwargs["umask"] = umask
return await get_async_backend().open_process(
command,
stdin=stdin,
stdout=stdout,
stderr=stderr,
cwd=cwd,
env=env,
startupinfo=startupinfo,
creationflags=creationflags,
start_new_session=start_new_session,
pass_fds=pass_fds,
**kwargs,
)
@@ -0,0 +1,778 @@
from __future__ import annotations
import math
import sys
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from types import TracebackType
from typing import TypeVar
from ..lowlevel import checkpoint_if_cancelled
from ._eventloop import get_async_backend
from ._exceptions import BusyResourceError, NoEventLoopError
from ._tasks import CancelScope
from ._testing import TaskInfo, get_current_task
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
T = TypeVar("T")
@dataclass(frozen=True)
class EventStatistics:
"""
:ivar int tasks_waiting: number of tasks waiting on :meth:`~.Event.wait`
"""
tasks_waiting: int
@dataclass(frozen=True)
class CapacityLimiterStatistics:
"""
:ivar int borrowed_tokens: number of tokens currently borrowed by tasks
:ivar float total_tokens: total number of available tokens
:ivar tuple borrowers: tasks or other objects currently holding tokens borrowed from
this limiter
:ivar int tasks_waiting: number of tasks waiting on
:meth:`~.CapacityLimiter.acquire` or
:meth:`~.CapacityLimiter.acquire_on_behalf_of`
"""
borrowed_tokens: int
total_tokens: float
borrowers: tuple[object, ...]
tasks_waiting: int
@dataclass(frozen=True)
class LockStatistics:
"""
:ivar bool locked: flag indicating if this lock is locked or not
:ivar ~anyio.TaskInfo owner: task currently holding the lock (or ``None`` if the
lock is not held by any task)
:ivar int tasks_waiting: number of tasks waiting on :meth:`~.Lock.acquire`
"""
locked: bool
owner: TaskInfo | None
tasks_waiting: int
@dataclass(frozen=True)
class ConditionStatistics:
"""
:ivar int tasks_waiting: number of tasks blocked on :meth:`~.Condition.wait`
:ivar ~anyio.LockStatistics lock_statistics: statistics of the underlying
:class:`~.Lock`
"""
tasks_waiting: int
lock_statistics: LockStatistics
@dataclass(frozen=True)
class SemaphoreStatistics:
"""
:ivar int tasks_waiting: number of tasks waiting on :meth:`~.Semaphore.acquire`
"""
tasks_waiting: int
class Event:
__slots__ = ("__weakref__",)
def __new__(cls) -> Event:
try:
return get_async_backend().create_event()
except NoEventLoopError:
return EventAdapter()
def set(self) -> None:
"""Set the flag, notifying all listeners."""
raise NotImplementedError
def is_set(self) -> bool:
"""Return ``True`` if the flag is set, ``False`` if not."""
raise NotImplementedError
async def wait(self) -> None:
"""
Wait until the flag has been set.
If the flag has already been set when this method is called, it returns
immediately.
"""
raise NotImplementedError
def statistics(self) -> EventStatistics:
"""Return statistics about the current state of this event."""
raise NotImplementedError
class EventAdapter(Event):
__slots__ = "_internal_event", "_is_set"
def __new__(cls) -> Self:
return object.__new__(cls)
def __init__(self) -> None:
self._internal_event: Event | None = None
self._is_set = False
@property
def _event(self) -> Event:
if self._internal_event is None:
self._internal_event = get_async_backend().create_event()
if self._is_set:
self._internal_event.set()
return self._internal_event
def set(self) -> None:
if self._internal_event is None:
self._is_set = True
else:
self._event.set()
def is_set(self) -> bool:
if self._internal_event is None:
return self._is_set
return self._internal_event.is_set()
async def wait(self) -> None:
await self._event.wait()
def statistics(self) -> EventStatistics:
if self._internal_event is None:
return EventStatistics(tasks_waiting=0)
return self._internal_event.statistics()
class Lock:
__slots__ = ("__weakref__",)
def __new__(cls, *, fast_acquire: bool = False) -> Lock:
try:
return get_async_backend().create_lock(fast_acquire=fast_acquire)
except NoEventLoopError:
return LockAdapter(fast_acquire=fast_acquire)
async def __aenter__(self) -> None:
await self.acquire()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.release()
async def acquire(self) -> None:
"""Acquire the lock."""
raise NotImplementedError
def acquire_nowait(self) -> None:
"""
Acquire the lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
raise NotImplementedError
def release(self) -> None:
"""Release the lock."""
raise NotImplementedError
def locked(self) -> bool:
"""Return True if the lock is currently held."""
raise NotImplementedError
def statistics(self) -> LockStatistics:
"""
Return statistics about the current state of this lock.
.. versionadded:: 3.0
"""
raise NotImplementedError
class LockAdapter(Lock):
__slots__ = "_fast_acquire", "_internal_lock"
def __new__(cls, *, fast_acquire: bool = False) -> Self:
return object.__new__(cls)
def __init__(self, *, fast_acquire: bool = False):
self._internal_lock: Lock | None = None
self._fast_acquire = fast_acquire
@property
def _lock(self) -> Lock:
if self._internal_lock is None:
self._internal_lock = get_async_backend().create_lock(
fast_acquire=self._fast_acquire
)
return self._internal_lock
async def __aenter__(self) -> None:
await self._lock.acquire()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if self._internal_lock is not None:
self._internal_lock.release()
async def acquire(self) -> None:
"""Acquire the lock."""
await self._lock.acquire()
def acquire_nowait(self) -> None:
"""
Acquire the lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
self._lock.acquire_nowait()
def release(self) -> None:
"""Release the lock."""
self._lock.release()
def locked(self) -> bool:
"""Return True if the lock is currently held."""
return self._lock.locked()
def statistics(self) -> LockStatistics:
"""
Return statistics about the current state of this lock.
.. versionadded:: 3.0
"""
if self._internal_lock is None:
return LockStatistics(False, None, 0)
return self._internal_lock.statistics()
class Condition:
__slots__ = "__weakref__", "_lock", "_owner_task", "_waiters"
def __init__(self, lock: Lock | None = None):
self._owner_task: TaskInfo | None = None
self._lock = lock or Lock()
self._waiters: deque[Event] = deque()
async def __aenter__(self) -> None:
await self.acquire()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.release()
def _check_acquired(self) -> None:
if self._owner_task != get_current_task():
raise RuntimeError("The current task is not holding the underlying lock")
async def acquire(self) -> None:
"""Acquire the underlying lock."""
await self._lock.acquire()
self._owner_task = get_current_task()
def acquire_nowait(self) -> None:
"""
Acquire the underlying lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
self._lock.acquire_nowait()
self._owner_task = get_current_task()
def release(self) -> None:
"""Release the underlying lock."""
self._lock.release()
def locked(self) -> bool:
"""Return True if the lock is set."""
return self._lock.locked()
def notify(self, n: int = 1) -> None:
"""Notify exactly n listeners."""
self._check_acquired()
for _ in range(n):
try:
event = self._waiters.popleft()
except IndexError:
break
event.set()
def notify_all(self) -> None:
"""Notify all the listeners."""
self._check_acquired()
for event in self._waiters:
event.set()
self._waiters.clear()
async def wait(self) -> None:
"""Wait for a notification."""
await checkpoint_if_cancelled()
self._check_acquired()
event = Event()
self._waiters.append(event)
self.release()
try:
await event.wait()
except BaseException:
if not event.is_set():
self._waiters.remove(event)
elif self._waiters:
# This task was notified by could not act on it, so pass
# it on to the next task
self._waiters.popleft().set()
raise
finally:
with CancelScope(shield=True):
await self.acquire()
async def wait_for(self, predicate: Callable[[], T]) -> T:
"""
Wait until a predicate becomes true.
:param predicate: a callable that returns a truthy value when the condition is
met
:return: the result of the predicate
.. versionadded:: 4.11.0
"""
while not (result := predicate()):
await self.wait()
return result
def statistics(self) -> ConditionStatistics:
"""
Return statistics about the current state of this condition.
.. versionadded:: 3.0
"""
return ConditionStatistics(len(self._waiters), self._lock.statistics())
class Semaphore:
__slots__ = "__weakref__", "_fast_acquire"
def __new__(
cls,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> Semaphore:
try:
return get_async_backend().create_semaphore(
initial_value, max_value=max_value, fast_acquire=fast_acquire
)
except NoEventLoopError:
return SemaphoreAdapter(initial_value, max_value=max_value)
def __init__(
self,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
):
if not isinstance(initial_value, int):
raise TypeError("initial_value must be an integer")
if initial_value < 0:
raise ValueError("initial_value must be >= 0")
if max_value is not None:
if not isinstance(max_value, int):
raise TypeError("max_value must be an integer or None")
if max_value < initial_value:
raise ValueError(
"max_value must be equal to or higher than initial_value"
)
self._fast_acquire = fast_acquire
async def __aenter__(self) -> Self:
await self.acquire()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.release()
async def acquire(self) -> None:
"""Decrement the semaphore value, blocking if necessary."""
raise NotImplementedError
def acquire_nowait(self) -> None:
"""
Acquire the underlying lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
raise NotImplementedError
def release(self) -> None:
"""Increment the semaphore value."""
raise NotImplementedError
@property
def value(self) -> int:
"""The current value of the semaphore."""
raise NotImplementedError
@property
def max_value(self) -> int | None:
"""The maximum value of the semaphore."""
raise NotImplementedError
def statistics(self) -> SemaphoreStatistics:
"""
Return statistics about the current state of this semaphore.
.. versionadded:: 3.0
"""
raise NotImplementedError
class SemaphoreAdapter(Semaphore):
__slots__ = "_initial_value", "_internal_semaphore", "_max_value"
def __new__(
cls,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> Self:
return object.__new__(cls)
def __init__(
self,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> None:
super().__init__(initial_value, max_value=max_value, fast_acquire=fast_acquire)
self._internal_semaphore: Semaphore | None = None
self._initial_value = initial_value
self._max_value = max_value
@property
def _semaphore(self) -> Semaphore:
if self._internal_semaphore is None:
self._internal_semaphore = get_async_backend().create_semaphore(
self._initial_value, max_value=self._max_value
)
return self._internal_semaphore
async def acquire(self) -> None:
await self._semaphore.acquire()
def acquire_nowait(self) -> None:
self._semaphore.acquire_nowait()
def release(self) -> None:
self._semaphore.release()
@property
def value(self) -> int:
if self._internal_semaphore is None:
return self._initial_value
return self._semaphore.value
@property
def max_value(self) -> int | None:
return self._max_value
def statistics(self) -> SemaphoreStatistics:
if self._internal_semaphore is None:
return SemaphoreStatistics(tasks_waiting=0)
return self._semaphore.statistics()
class CapacityLimiter:
__slots__ = ("__weakref__",)
def __new__(cls, total_tokens: float) -> CapacityLimiter:
try:
return get_async_backend().create_capacity_limiter(total_tokens)
except NoEventLoopError:
return CapacityLimiterAdapter(total_tokens)
async def __aenter__(self) -> None:
raise NotImplementedError
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
raise NotImplementedError
@property
def total_tokens(self) -> float:
"""
The total number of tokens available for borrowing.
This is a read-write property. If the total number of tokens is increased, the
proportionate number of tasks waiting on this limiter will be granted their
tokens.
.. versionchanged:: 3.0
The property is now writable.
.. versionchanged:: 4.12
The value can now be set to 0.
"""
raise NotImplementedError
@total_tokens.setter
def total_tokens(self, value: float) -> None:
raise NotImplementedError
@property
def borrowed_tokens(self) -> int:
"""The number of tokens that have currently been borrowed."""
raise NotImplementedError
@property
def available_tokens(self) -> float:
"""The number of tokens currently available to be borrowed"""
raise NotImplementedError
def acquire_nowait(self) -> None:
"""
Acquire a token for the current task without waiting for one to become
available.
:raises ~anyio.WouldBlock: if there are no tokens available for borrowing
"""
raise NotImplementedError
def acquire_on_behalf_of_nowait(self, borrower: object) -> None:
"""
Acquire a token without waiting for one to become available.
:param borrower: the entity borrowing a token
:raises ~anyio.WouldBlock: if there are no tokens available for borrowing
"""
raise NotImplementedError
async def acquire(self) -> None:
"""
Acquire a token for the current task, waiting if necessary for one to become
available.
"""
raise NotImplementedError
async def acquire_on_behalf_of(self, borrower: object) -> None:
"""
Acquire a token, waiting if necessary for one to become available.
:param borrower: the entity borrowing a token
"""
raise NotImplementedError
def release(self) -> None:
"""
Release the token held by the current task.
:raises RuntimeError: if the current task has not borrowed a token from this
limiter.
"""
raise NotImplementedError
def release_on_behalf_of(self, borrower: object) -> None:
"""
Release the token held by the given borrower.
:raises RuntimeError: if the borrower has not borrowed a token from this
limiter.
"""
raise NotImplementedError
def statistics(self) -> CapacityLimiterStatistics:
"""
Return statistics about the current state of this limiter.
.. versionadded:: 3.0
"""
raise NotImplementedError
class CapacityLimiterAdapter(CapacityLimiter):
__slots__ = "_internal_limiter", "_total_tokens"
def __new__(cls, total_tokens: float) -> Self:
return object.__new__(cls)
def __init__(self, total_tokens: float) -> None:
self._internal_limiter: CapacityLimiter | None = None
self.total_tokens = total_tokens
@property
def _limiter(self) -> CapacityLimiter:
if self._internal_limiter is None:
self._internal_limiter = get_async_backend().create_capacity_limiter(
self._total_tokens
)
return self._internal_limiter
async def __aenter__(self) -> None:
await self._limiter.__aenter__()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
return await self._limiter.__aexit__(exc_type, exc_val, exc_tb)
@property
def total_tokens(self) -> float:
if self._internal_limiter is None:
return self._total_tokens
return self._internal_limiter.total_tokens
@total_tokens.setter
def total_tokens(self, value: float) -> None:
if not isinstance(value, int) and not math.isinf(value):
raise TypeError("total_tokens must be an int or math.inf")
elif value < 0:
raise ValueError("total_tokens must be >= 0")
if self._internal_limiter is None:
self._total_tokens = value
return
self._limiter.total_tokens = value
@property
def borrowed_tokens(self) -> int:
if self._internal_limiter is None:
return 0
return self._internal_limiter.borrowed_tokens
@property
def available_tokens(self) -> float:
if self._internal_limiter is None:
return self._total_tokens
return self._internal_limiter.available_tokens
def acquire_nowait(self) -> None:
self._limiter.acquire_nowait()
def acquire_on_behalf_of_nowait(self, borrower: object) -> None:
self._limiter.acquire_on_behalf_of_nowait(borrower)
async def acquire(self) -> None:
await self._limiter.acquire()
async def acquire_on_behalf_of(self, borrower: object) -> None:
await self._limiter.acquire_on_behalf_of(borrower)
def release(self) -> None:
self._limiter.release()
def release_on_behalf_of(self, borrower: object) -> None:
self._limiter.release_on_behalf_of(borrower)
def statistics(self) -> CapacityLimiterStatistics:
if self._internal_limiter is None:
return CapacityLimiterStatistics(
borrowed_tokens=0,
total_tokens=self.total_tokens,
borrowers=(),
tasks_waiting=0,
)
return self._internal_limiter.statistics()
class ResourceGuard:
"""
A context manager for ensuring that a resource is only used by a single task at a
time.
Entering this context manager while the previous has not exited it yet will trigger
:exc:`BusyResourceError`.
:param action: the action to guard against (visible in the :exc:`BusyResourceError`
when triggered, e.g. "Another task is already {action} this resource")
.. versionadded:: 4.1
"""
__slots__ = "__weakref__", "_guarded", "action"
def __init__(self, action: str = "using"):
self.action: str = action
self._guarded = False
def __enter__(self) -> None:
if self._guarded:
raise BusyResourceError(self.action)
self._guarded = True
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self._guarded = False
@@ -0,0 +1,464 @@
from __future__ import annotations
import math
import sys
from collections.abc import (
Coroutine,
Generator,
)
from contextlib import contextmanager
from enum import Enum, auto
from inspect import iscoroutine
from types import TracebackType
from typing import Any, Generic, final
from ..abc import TaskGroup, TaskStatus
from ._eventloop import get_async_backend, get_cancelled_exc_class
from ._exceptions import TaskCancelled, TaskFailed, TaskNotFinished
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 11):
from typing import Never, Self, TypeVarTuple
else:
from typing_extensions import Never, Self, TypeVarTuple
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
T_startval_co = TypeVar("T_startval_co", covariant=True, default=Never)
PosArgsT = TypeVarTuple("PosArgsT")
class _IgnoredTaskStatus(TaskStatus[object]):
def started(self, value: object = None) -> None:
pass
TASK_STATUS_IGNORED = _IgnoredTaskStatus()
class CancelScope:
"""
Wraps a unit of work that can be made separately cancellable.
:param deadline: The time (clock value) when this scope is cancelled automatically
:param shield: ``True`` to shield the cancel scope from external cancellation
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
__slots__ = ("__weakref__",)
def __new__(
cls, *, deadline: float = math.inf, shield: bool = False
) -> CancelScope:
return get_async_backend().create_cancel_scope(shield=shield, deadline=deadline)
def cancel(self, reason: str | None = None) -> None:
"""
Cancel this scope immediately.
:param reason: a message describing the reason for the cancellation
"""
raise NotImplementedError
@property
def deadline(self) -> float:
"""
The time (clock value) when this scope is cancelled automatically.
Will be ``float('inf')`` if no timeout has been set.
"""
raise NotImplementedError
@deadline.setter
def deadline(self, value: float) -> None:
raise NotImplementedError
@property
def cancel_called(self) -> bool:
"""``True`` if :meth:`cancel` has been called."""
raise NotImplementedError
@property
def cancelled_caught(self) -> bool:
"""
``True`` if this scope suppressed a cancellation exception it itself raised.
This is typically used to check if any work was interrupted, or to see if the
scope was cancelled due to its deadline being reached. The value will, however,
only be ``True`` if the cancellation was triggered by the scope itself (and not
an outer scope).
"""
raise NotImplementedError
@property
def shield(self) -> bool:
"""
``True`` if this scope is shielded from external cancellation.
While a scope is shielded, it will not receive cancellations from outside.
"""
raise NotImplementedError
@shield.setter
def shield(self, value: bool) -> None:
raise NotImplementedError
def __enter__(self) -> Self:
raise NotImplementedError
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> bool:
raise NotImplementedError
@contextmanager
def fail_at(
deadline: float | None, shield: bool = False, reason: str | None = None
) -> Generator[CancelScope, None, None]:
"""
Create a context manager which raises a :class:`TimeoutError` if the code in the
enclosing context does not finish by the given deadline.
:param deadline: the deadline before raising the exception, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:param reason: explanation for timeout to add to the message of a raised `TimeoutError`
:return: a context manager that yields a cancel scope
:rtype: :class:`~typing.ContextManager`\\[:class:`~anyio.CancelScope`\\]
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. versionadded:: 4.15.0
"""
current_time = get_async_backend().current_time
effective_deadline = math.inf if deadline is None else deadline
with get_async_backend().create_cancel_scope(
deadline=effective_deadline, shield=shield
) as cancel_scope:
yield cancel_scope
if cancel_scope.cancelled_caught and current_time() >= cancel_scope.deadline:
raise TimeoutError(reason) if reason else TimeoutError
@contextmanager
def fail_after(
delay: float | None, shield: bool = False, reason: str | None = None
) -> Generator[CancelScope, None, None]:
"""
Create a context manager which raises a :class:`TimeoutError` if the code in the
enclosing context block does not finish in time.
:param delay: maximum allowed time (in seconds) before raising the exception, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:param reason: explanation for timeout to add to the message of a raised `TimeoutError`
:return: a context manager that yields a cancel scope
:rtype: :class:`~typing.ContextManager`\\[:class:`~anyio.CancelScope`\\]
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
current_time = get_async_backend().current_time
deadline = (current_time() + delay) if delay is not None else math.inf
with fail_at(deadline, shield=shield, reason=reason) as scope:
yield scope
def move_on_at(deadline: float | None, shield: bool = False) -> CancelScope:
"""
Create a cancel scope with a deadline that expires after the given delay.
:param deadline: the deadline before exiting the context block, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:return: a cancel scope
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. versionadded:: 4.15.0
"""
return get_async_backend().create_cancel_scope(
deadline=deadline if deadline is not None else math.inf, shield=shield
)
def move_on_after(delay: float | None, shield: bool = False) -> CancelScope:
"""
Create a cancel scope with a deadline that expires after the given delay.
:param delay: maximum allowed time (in seconds) before exiting the context block, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:return: a cancel scope
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. note:: Unlike with :func:`fail_after`, the timer starts when this function is
called, not when the context manager is entered. This will be changed in v5.0.
"""
deadline = (
(get_async_backend().current_time() + delay) if delay is not None else math.inf
)
return get_async_backend().create_cancel_scope(deadline=deadline, shield=shield)
def current_effective_deadline() -> float:
"""
Return the nearest deadline among all the cancel scopes effective for the current
task.
:return: a clock value from the event loop's internal clock (or ``float('inf')`` if
there is no deadline in effect, or ``float('-inf')`` if the current scope has
been cancelled)
:rtype: float
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().current_effective_deadline()
def create_task_group() -> TaskGroup:
"""
Create a task group.
:return: a task group
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().create_task_group()
@final
class TaskHandle(Generic[T_co, T_startval_co]):
"""
Returned from the task-spawning methods of :class:`TaskGroup`. Can be awaited on to
get the return value of the task (or the raised exception). If the task was
terminated by a :exc:`BaseException`, :exc:`TaskFailed` will be raised (or its
subclass :exc:`TaskCancelled` if the task was cancelled).
.. versionadded:: 4.14.0
"""
class Status(Enum):
"""
The status of a task handle.
.. attribute:: PENDING
The task has not finished yet.
.. attribute:: FINISHED
The task has finished with a return value.
.. attribute:: CANCELLING
The task has been cancelled but has not finished yet.
.. attribute:: CANCELLED
The task was cancelled and has finished since.
.. attribute:: FAILED
The task raised an exception.
"""
PENDING = auto()
FINISHED = auto()
CANCELLING = auto()
CANCELLED = auto()
FAILED = auto()
__slots__ = (
"__weakref__",
"_cancel_scope",
"_coro",
"_exception",
"_finished_event",
"_name",
"_return_value",
"_start_value",
)
_return_value: T_co
_start_value: T_startval_co
def __init__(
self,
coro: Coroutine[Any, Any, T_co],
name: object,
cancel_scope: CancelScope | None = None,
) -> None:
from ._synchronization import Event
self._coro = coro
self._cancel_scope = cancel_scope if cancel_scope is not None else CancelScope()
self._finished_event = Event()
self._exception: BaseException | None = None
if name is not None:
self._name = str(name)
elif iscoroutine(coro):
self._name = coro.__qualname__
else:
self._name = str(coro) # coroutine-like object (e.g. asend() objects)
async def _run_coro(self) -> None:
__tracebackhide__ = True
with self._cancel_scope:
try:
retval = await self._coro
except BaseException as exc:
self._exception = exc
raise
else:
self._return_value = retval
finally:
self._finished_event.set()
del self # Break the reference cycle
def cancel(self) -> None:
"""
Set the task to a cancelled state.
This will interrupt any interruptible asynchronous operation, and will cause
any further awaits on this task to get immediately cancelled, unless done in
a shielded cancel scope.
If the task has already finished, this method has no effect.
"""
if not self._finished_event.is_set():
self._cancel_scope.cancel()
@property
def coro(self) -> Coroutine[Any, Any, T_co]:
"""
The coroutine object that was passed to one of the task-spawning methods in
:class:`TaskGroup`.
"""
return self._coro
@property
def status(self) -> TaskHandle.Status:
"""
The current status of the task.
Every task starts in the :attr:`~TaskHandle.Status.PENDING` state.
If a task is cancelled while in this state, it will transition to the
:attr:`~TaskHandle.Status.CANCELLING` state. When the task finishes, it will
transition to one of the three final states (
:attr:`~TaskHandle.Status.FINISHED`, :attr:`~TaskHandle.Status.FAILED`, or
:attr:`~TaskHandle.Status.CANCELLING`) depending on the exception the task
raised, if any. No other status transitions will happen.
"""
if not self._finished_event.is_set():
if self._cancel_scope.cancel_called:
return TaskHandle.Status.CANCELLING
else:
return TaskHandle.Status.PENDING
elif self._exception is not None:
if isinstance(self._exception, get_cancelled_exc_class()):
return TaskHandle.Status.CANCELLED
else:
return TaskHandle.Status.FAILED
else:
return TaskHandle.Status.FINISHED
@property
def name(self) -> str:
"""The name of the task."""
return self._name
@property
def exception(self) -> BaseException | None:
"""
The exception raised by the task, or ``None`` if it finished without raising.
:raises TaskNotFinished: if the task has not finished yet
:raises TaskCancelled: if the task was cancelled
"""
match self.status:
case TaskHandle.Status.PENDING:
raise TaskNotFinished("the task has not finished yet")
case TaskHandle.Status.FINISHED:
return None
case TaskHandle.Status.CANCELLING:
raise TaskCancelled("the task was cancelled")
case TaskHandle.Status.CANCELLED:
raise TaskCancelled("the task was cancelled") from self._exception
case TaskHandle.Status.FAILED:
return self._exception
@property
def return_value(self) -> T_co:
"""
The return value of the task.
:raises TaskNotFinished: if the task has not finished yet
:raises TaskCancelled: if the task was cancelled
:raises TaskFailed: if the task raised an exception
"""
match self.status:
case TaskHandle.Status.PENDING:
raise TaskNotFinished("the task has not finished yet")
case TaskHandle.Status.FINISHED:
return self._return_value
case TaskHandle.Status.CANCELLING:
raise TaskCancelled("the task was cancelled")
case TaskHandle.Status.CANCELLED:
raise TaskCancelled("the task was cancelled") from self._exception
case TaskHandle.Status.FAILED:
raise TaskFailed("the task raised an exception") from self._exception
@property
def start_value(self) -> T_startval_co:
"""
The value passed to :meth:`task_status.started() <.abc.TaskStatus.started>`,
:raises RuntimeError: if the task was not started with :meth:`TaskGroup.start()
<.abc.TaskGroup.start>`
"""
try:
return self._start_value
except AttributeError:
raise RuntimeError(
"the task was not started with TaskGroup.start()"
) from None
async def wait(self) -> None:
"""
Wait for the task to finish.
This method will return as soon as the task has finished, no matter how it
happened.
"""
await self._finished_event.wait()
def __await__(self) -> Generator[Any, Any, T_co]:
yield from self._finished_event.wait().__await__()
return self.return_value
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__} {self.status.name.lower()} "
f"name={self._name!r} coro={self._coro!r}>"
)
@@ -0,0 +1,615 @@
from __future__ import annotations
import os
import sys
import tempfile
from collections.abc import Iterable
from io import BytesIO, TextIOWrapper
from types import TracebackType
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Generic,
overload,
)
from .. import to_thread
from .._core._fileio import AsyncFile
from ..lowlevel import checkpoint_if_cancelled
from ._tasks import CancelScope
if TYPE_CHECKING:
from _typeshed import OpenBinaryMode, OpenTextMode, ReadableBuffer, WriteableBuffer
class TemporaryFile(Generic[AnyStr]):
"""
An asynchronous temporary file that is automatically created and cleaned up.
This class provides an asynchronous context manager interface to a temporary file.
The file is created using Python's standard `tempfile.TemporaryFile` function in a
background thread, and is wrapped as an asynchronous file using `AsyncFile`.
:param mode: The mode in which the file is opened. Defaults to "w+b".
:param buffering: The buffering policy (-1 means the default buffering).
:param encoding: The encoding used to decode or encode the file. Only applicable in
text mode.
:param newline: Controls how universal newlines mode works (only applicable in text
mode).
:param suffix: The suffix for the temporary file name.
:param prefix: The prefix for the temporary file name.
:param dir: The directory in which the temporary file is created.
:param errors: The error handling scheme used for encoding/decoding errors.
"""
_async_file: AsyncFile[AnyStr]
@overload
def __init__(
self: TemporaryFile[bytes],
mode: OpenBinaryMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
@overload
def __init__(
self: TemporaryFile[str],
mode: OpenTextMode,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
def __init__(
self,
mode: OpenTextMode | OpenBinaryMode = "w+b",
buffering: int = -1,
encoding: str | None = None,
newline: str | None = None,
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
*,
errors: str | None = None,
) -> None:
self.mode = mode
self.buffering = buffering
self.encoding = encoding
self.newline = newline
self.suffix: str | None = suffix
self.prefix: str | None = prefix
self.dir: str | None = dir
self.errors = errors
async def __aenter__(self) -> AsyncFile[AnyStr]:
fp = await to_thread.run_sync(
lambda: tempfile.TemporaryFile(
self.mode,
self.buffering,
self.encoding,
self.newline,
self.suffix,
self.prefix,
self.dir,
errors=self.errors,
)
)
self._async_file = AsyncFile(fp)
return self._async_file
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
await self._async_file.aclose()
class NamedTemporaryFile(Generic[AnyStr]):
"""
An asynchronous named temporary file that is automatically created and cleaned up.
This class provides an asynchronous context manager for a temporary file with a
visible name in the file system. It uses Python's standard
:func:`~tempfile.NamedTemporaryFile` function and wraps the file object with
:class:`AsyncFile` for asynchronous operations.
:param mode: The mode in which the file is opened. Defaults to "w+b".
:param buffering: The buffering policy (-1 means the default buffering).
:param encoding: The encoding used to decode or encode the file. Only applicable in
text mode.
:param newline: Controls how universal newlines mode works (only applicable in text
mode).
:param suffix: The suffix for the temporary file name.
:param prefix: The prefix for the temporary file name.
:param dir: The directory in which the temporary file is created.
:param delete: Whether to delete the file when it is closed.
:param errors: The error handling scheme used for encoding/decoding errors.
:param delete_on_close: (Python 3.12+) Whether to delete the file on close.
"""
_async_file: AsyncFile[AnyStr]
@overload
def __init__(
self: NamedTemporaryFile[bytes],
mode: OpenBinaryMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
delete: bool = ...,
*,
errors: str | None = ...,
delete_on_close: bool = ...,
): ...
@overload
def __init__(
self: NamedTemporaryFile[str],
mode: OpenTextMode,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
delete: bool = ...,
*,
errors: str | None = ...,
delete_on_close: bool = ...,
): ...
def __init__(
self,
mode: OpenBinaryMode | OpenTextMode = "w+b",
buffering: int = -1,
encoding: str | None = None,
newline: str | None = None,
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
delete: bool = True,
*,
errors: str | None = None,
delete_on_close: bool = True,
) -> None:
self._params: dict[str, Any] = {
"mode": mode,
"buffering": buffering,
"encoding": encoding,
"newline": newline,
"suffix": suffix,
"prefix": prefix,
"dir": dir,
"delete": delete,
"errors": errors,
}
if sys.version_info >= (3, 12):
self._params["delete_on_close"] = delete_on_close
async def __aenter__(self) -> AsyncFile[AnyStr]:
fp = await to_thread.run_sync(
lambda: tempfile.NamedTemporaryFile(**self._params)
)
self._async_file = AsyncFile(fp)
return self._async_file
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
await self._async_file.aclose()
class SpooledTemporaryFile(AsyncFile[AnyStr]):
"""
An asynchronous spooled temporary file that starts in memory and is spooled to disk.
This class provides an asynchronous interface to a spooled temporary file, much like
Python's standard :class:`~tempfile.SpooledTemporaryFile`. It supports asynchronous
write operations and provides a method to force a rollover to disk.
:param max_size: Maximum size in bytes before the file is rolled over to disk.
:param mode: The mode in which the file is opened. Defaults to "w+b".
:param buffering: The buffering policy (-1 means the default buffering).
:param encoding: The encoding used to decode or encode the file (text mode only).
:param newline: Controls how universal newlines mode works (text mode only).
:param suffix: The suffix for the temporary file name.
:param prefix: The prefix for the temporary file name.
:param dir: The directory in which the temporary file is created.
:param errors: The error handling scheme used for encoding/decoding errors.
"""
_rolled: bool = False
@overload
def __init__(
self: SpooledTemporaryFile[bytes],
max_size: int = ...,
mode: OpenBinaryMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
@overload
def __init__(
self: SpooledTemporaryFile[str],
max_size: int = ...,
mode: OpenTextMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
def __init__(
self,
max_size: int = 0,
mode: OpenBinaryMode | OpenTextMode = "w+b",
buffering: int = -1,
encoding: str | None = None,
newline: str | None = None,
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
*,
errors: str | None = None,
) -> None:
self._tempfile_params: dict[str, Any] = {
"mode": mode,
"buffering": buffering,
"encoding": encoding,
"newline": newline,
"suffix": suffix,
"prefix": prefix,
"dir": dir,
"errors": errors,
}
self._max_size = max_size
if "b" in mode:
super().__init__(BytesIO()) # type: ignore[arg-type]
else:
super().__init__(
TextIOWrapper( # type: ignore[arg-type]
BytesIO(),
encoding=encoding,
errors=errors,
newline=newline,
write_through=True,
)
)
async def aclose(self) -> None:
if not self._rolled:
self._fp.close()
return
await super().aclose()
async def _check(self) -> None:
if self._rolled or self._fp.tell() <= self._max_size:
return
await self.rollover()
async def rollover(self) -> None:
if self._rolled:
return
self._rolled = True
buffer = self._fp
buffer.seek(0)
self._fp = await to_thread.run_sync(
lambda: tempfile.TemporaryFile(**self._tempfile_params)
)
await self.write(buffer.read())
buffer.close()
@property
def closed(self) -> bool:
return self._fp.closed
async def read(self, size: int = -1) -> AnyStr:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.read(size)
return await super().read(size) # type: ignore[return-value]
async def read1(self: SpooledTemporaryFile[bytes], size: int = -1) -> bytes:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.read1(size)
return await super().read1(size)
async def readline(self) -> AnyStr:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readline()
return await super().readline() # type: ignore[return-value]
async def readlines(self) -> list[AnyStr]:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readlines()
return await super().readlines() # type: ignore[return-value]
async def readinto(self: SpooledTemporaryFile[bytes], b: WriteableBuffer) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readinto(b)
return await super().readinto(b)
async def readinto1(self: SpooledTemporaryFile[bytes], b: WriteableBuffer) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readinto1(b)
return await super().readinto1(b)
async def seek(self, offset: int, whence: int | None = os.SEEK_SET) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.seek(offset, whence)
return await super().seek(offset, whence)
async def tell(self) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.tell()
return await super().tell()
async def truncate(self, size: int | None = None) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.truncate(size)
return await super().truncate(size)
@overload
async def write(self: SpooledTemporaryFile[bytes], b: ReadableBuffer) -> int: ...
@overload
async def write(self: SpooledTemporaryFile[str], b: str) -> int: ...
async def write(self, b: ReadableBuffer | str) -> int:
"""
Asynchronously write data to the spooled temporary file.
If the file has not yet been rolled over, the data is written synchronously,
and a rollover is triggered if the size exceeds the maximum size.
:param s: The data to write.
:return: The number of bytes written.
:raises RuntimeError: If the underlying file is not initialized.
"""
if not self._rolled:
await checkpoint_if_cancelled()
result = self._fp.write(b)
await self._check()
return result
return await super().write(b) # type: ignore[misc]
@overload
async def writelines(
self: SpooledTemporaryFile[bytes], lines: Iterable[ReadableBuffer]
) -> None: ...
@overload
async def writelines(
self: SpooledTemporaryFile[str], lines: Iterable[str]
) -> None: ...
async def writelines(self, lines: Iterable[str] | Iterable[ReadableBuffer]) -> None:
"""
Asynchronously write a list of lines to the spooled temporary file.
If the file has not yet been rolled over, the lines are written synchronously,
and a rollover is triggered if the size exceeds the maximum size.
:param lines: An iterable of lines to write.
:raises RuntimeError: If the underlying file is not initialized.
"""
if not self._rolled:
await checkpoint_if_cancelled()
result = self._fp.writelines(lines)
await self._check()
return result
return await super().writelines(lines) # type: ignore[misc]
class TemporaryDirectory(Generic[AnyStr]):
"""
An asynchronous temporary directory that is created and cleaned up automatically.
This class provides an asynchronous context manager for creating a temporary
directory. It wraps Python's standard :class:`~tempfile.TemporaryDirectory` to
perform directory creation and cleanup operations in a background thread.
:param suffix: Suffix to be added to the temporary directory name.
:param prefix: Prefix to be added to the temporary directory name.
:param dir: The parent directory where the temporary directory is created.
:param ignore_cleanup_errors: Whether to ignore errors during cleanup
:param delete: Whether to delete the directory upon closing (Python 3.12+).
"""
def __init__(
self,
suffix: AnyStr | None = None,
prefix: AnyStr | None = None,
dir: AnyStr | None = None,
*,
ignore_cleanup_errors: bool = False,
delete: bool = True,
) -> None:
self.suffix: AnyStr | None = suffix
self.prefix: AnyStr | None = prefix
self.dir: AnyStr | None = dir
self.ignore_cleanup_errors = ignore_cleanup_errors
self.delete = delete
self._tempdir: tempfile.TemporaryDirectory | None = None
async def __aenter__(self) -> str:
params: dict[str, Any] = {
"suffix": self.suffix,
"prefix": self.prefix,
"dir": self.dir,
"ignore_cleanup_errors": self.ignore_cleanup_errors,
}
if sys.version_info >= (3, 12):
params["delete"] = self.delete
self._tempdir = await to_thread.run_sync(
lambda: tempfile.TemporaryDirectory(**params)
)
return await to_thread.run_sync(self._tempdir.__enter__)
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if self._tempdir is not None:
with CancelScope(shield=True):
await to_thread.run_sync(
self._tempdir.__exit__, exc_type, exc_value, traceback
)
async def cleanup(self) -> None:
if self._tempdir is not None:
await to_thread.run_sync(self._tempdir.cleanup)
@overload
async def mkstemp(
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
text: bool = False,
) -> tuple[int, str]: ...
@overload
async def mkstemp(
suffix: bytes | None = None,
prefix: bytes | None = None,
dir: bytes | None = None,
text: bool = False,
) -> tuple[int, bytes]: ...
async def mkstemp(
suffix: AnyStr | None = None,
prefix: AnyStr | None = None,
dir: AnyStr | None = None,
text: bool = False,
) -> tuple[int, str | bytes]:
"""
Asynchronously create a temporary file and return an OS-level handle and the file
name.
This function wraps `tempfile.mkstemp` and executes it in a background thread.
:param suffix: Suffix to be added to the file name.
:param prefix: Prefix to be added to the file name.
:param dir: Directory in which the temporary file is created.
:param text: Whether the file is opened in text mode.
:return: A tuple containing the file descriptor and the file name.
"""
return await to_thread.run_sync(tempfile.mkstemp, suffix, prefix, dir, text)
@overload
async def mkdtemp(
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
) -> str: ...
@overload
async def mkdtemp(
suffix: bytes | None = None,
prefix: bytes | None = None,
dir: bytes | None = None,
) -> bytes: ...
async def mkdtemp(
suffix: AnyStr | None = None,
prefix: AnyStr | None = None,
dir: AnyStr | None = None,
) -> str | bytes:
"""
Asynchronously create a temporary directory and return its path.
This function wraps `tempfile.mkdtemp` and executes it in a background thread.
:param suffix: Suffix to be added to the directory name.
:param prefix: Prefix to be added to the directory name.
:param dir: Parent directory where the temporary directory is created.
:return: The path of the created temporary directory.
"""
return await to_thread.run_sync(tempfile.mkdtemp, suffix, prefix, dir)
async def gettempdir() -> str:
"""
Asynchronously return the name of the directory used for temporary files.
This function wraps `tempfile.gettempdir` and executes it in a background thread.
:return: The path of the temporary directory as a string.
"""
return await to_thread.run_sync(tempfile.gettempdir)
async def gettempdirb() -> bytes:
"""
Asynchronously return the name of the directory used for temporary files in bytes.
This function wraps `tempfile.gettempdirb` and executes it in a background thread.
:return: The path of the temporary directory as bytes.
"""
return await to_thread.run_sync(tempfile.gettempdirb)
@@ -0,0 +1,82 @@
from __future__ import annotations
from collections.abc import Awaitable, Generator
from typing import Any, cast
from ._eventloop import get_async_backend
class TaskInfo:
"""
Represents an asynchronous task.
:ivar int id: the unique identifier of the task
:ivar parent_id: the identifier of the parent task, if any
:vartype parent_id: Optional[int]
:ivar str name: the description of the task (if any)
:ivar ~collections.abc.Coroutine coro: the coroutine object of the task
"""
__slots__ = "_name", "coro", "id", "name", "parent_id"
def __init__(
self,
id: int,
parent_id: int | None,
name: str | None,
coro: Generator[Any, Any, Any] | Awaitable[Any],
):
func = get_current_task
self._name = f"{func.__module__}.{func.__qualname__}"
self.id: int = id
self.parent_id: int | None = parent_id
self.name: str | None = name
self.coro: Generator[Any, Any, Any] | Awaitable[Any] = coro
def __eq__(self, other: object) -> bool:
if isinstance(other, TaskInfo):
return self.id == other.id
return NotImplemented
def __hash__(self) -> int:
return hash(self.id)
def __repr__(self) -> str:
return f"{self.__class__.__name__}(id={self.id!r}, name={self.name!r})"
def has_pending_cancellation(self) -> bool:
"""
Return ``True`` if the task has a cancellation pending, ``False`` otherwise.
"""
return False
def get_current_task() -> TaskInfo:
"""
Return the current task.
:return: a representation of the current task
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().get_current_task()
def get_running_tasks() -> list[TaskInfo]:
"""
Return a list of running tasks in the current event loop.
:return: a list of task info objects
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return cast("list[TaskInfo]", get_async_backend().get_running_tasks())
async def wait_all_tasks_blocked() -> None:
"""Wait until all other tasks are waiting for something."""
await get_async_backend().wait_all_tasks_blocked()
@@ -0,0 +1,85 @@
from __future__ import annotations
import sys
from collections.abc import Callable, Mapping
from typing import Any, TypeVar, final, overload
from ._exceptions import TypedAttributeLookupError
if sys.version_info < (3, 15):
from typing_extensions import sentinel
T_Attr = TypeVar("T_Attr")
T_Default = TypeVar("T_Default")
undefined = sentinel("undefined")
def typed_attribute() -> Any:
"""Return a unique object, used to mark typed attributes."""
return object()
class TypedAttributeSet:
"""
Superclass for typed attribute collections.
Checks that every public attribute of every subclass has a type annotation.
"""
def __init_subclass__(cls) -> None:
annotations: dict[str, Any] = getattr(cls, "__annotations__", {})
for attrname in dir(cls):
if not attrname.startswith("_") and attrname not in annotations:
raise TypeError(
f"Attribute {attrname!r} is missing its type annotation"
)
super().__init_subclass__()
class TypedAttributeProvider:
"""Base class for classes that wish to provide typed extra attributes."""
@property
def extra_attributes(self) -> Mapping[T_Attr, Callable[[], T_Attr]]:
"""
A mapping of the extra attributes to callables that return the corresponding
values.
If the provider wraps another provider, the attributes from that wrapper should
also be included in the returned mapping (but the wrapper may override the
callables from the wrapped instance).
"""
return {}
@overload
def extra(self, attribute: T_Attr) -> T_Attr: ...
@overload
def extra(self, attribute: T_Attr, default: T_Default) -> T_Attr | T_Default: ...
@final
def extra(self, attribute: Any, default: object = undefined) -> object:
"""
extra(attribute, default=undefined)
Return the value of the given typed extra attribute.
:param attribute: the attribute (member of a :class:`~TypedAttributeSet`) to
look for
:param default: the value that should be returned if no value is found for the
attribute
:raises ~anyio.TypedAttributeLookupError: if the search failed and no default
value was given
"""
try:
getter = self.extra_attributes[attribute]
except KeyError:
if default is undefined:
raise TypedAttributeLookupError("Attribute not found") from None
else:
return default
return getter()
@@ -0,0 +1,176 @@
from __future__ import annotations
__all__ = (
"fix_package_names",
"install_lazy_importer",
"set_deprecated_aliases",
)
import ast
import inspect
import sys
import warnings
from importlib import import_module
from types import ModuleType
from typing import Any
def install_lazy_importer() -> bool:
module_globals = sys._getframe(1).f_globals
module_name = module_globals["__name__"]
module_prefix = module_name + "."
module = sys.modules[module_name]
lazy_map, deprecated_aliases, submodule_names = _build_lazy_map(module)
names = sorted(lazy_map)
# Delete symbols that are not part of the API
del module_globals["TYPE_CHECKING"]
del module_globals["install_lazy_importer"]
if not lazy_map and not deprecated_aliases:
return False
def __getattr__(name: str) -> Any:
if new_name := deprecated_aliases.get(name):
emit_deprecation_warning(module_name, name, new_name)
target_mod, target_attr = new_name.rsplit(".", 1)
elif name in submodule_names:
target_mod, target_attr = "." + name, ""
else:
try:
target_mod, target_attr = lazy_map[name]
except KeyError:
raise AttributeError(
f"module {module_name!r} has no attribute {name!r}"
) from None
imported = import_module(target_mod, module_name)
value = getattr(imported, target_attr) if target_attr else imported
# patch the module name to match
if (
getattr(value, "__module__", "").startswith(module_prefix)
and name not in deprecated_aliases
):
value.__module__ = module_name
module_globals[name] = value
return value
def __dir__() -> list[str]:
return names
module_globals["__dir__"] = __dir__
module_globals["__getattr__"] = __getattr__
module_globals.pop("fix_package_names", None)
module_globals.pop("set_deprecated_aliases", None)
return True
def fix_package_names() -> None:
module_globals = sys._getframe(1).f_globals
module_prefix = module_globals["__name__"] + "."
del module_globals[fix_package_names.__name__]
for value in module_globals.values():
if modname := getattr(value, "__module__", ""):
if modname.startswith(module_prefix):
parts = modname.split(".")
value.__module__ = ".".join(
part for part in parts if not part.startswith("_")
)
def emit_deprecation_warning(module_name: str, name: str, target: str) -> None:
warnings.warn(
f"The {module_name}.{name} alias is deprecated, use {target} instead.",
DeprecationWarning,
stacklevel=3,
)
def set_deprecated_aliases(aliases: dict[str, str]) -> None:
module_globals = sys._getframe(1).f_globals
module_name = module_globals["__name__"]
del module_globals[set_deprecated_aliases.__name__]
def __getattr__(name: str) -> Any:
try:
target = aliases[name]
except KeyError:
raise AttributeError(
f"module {module_name!r} has no attribute {name!r}"
) from None
emit_deprecation_warning(module_name, name, target)
target_modname, attrname = target.rsplit(".", 1)
module = import_module(target_modname)
return getattr(module, attrname)
sys.modules[module_name].__dict__["__getattr__"] = __getattr__
def _build_lazy_map(
module: ModuleType,
) -> tuple[dict[str, tuple[str, str]], dict[str, str], list[str]]:
try:
source = inspect.getsource(module)
except OSError:
return {}, {}, []
tree = compile(source, module.__file__ or "", "exec", ast.PyCF_ONLY_AST)
assert isinstance(tree, ast.Module)
out: dict[str, tuple[str, str]] = {}
deprecated_aliases: dict[str, str] = {}
submodule_names: list[str] = []
for node in tree.body:
if not isinstance(node, ast.If) or not _is_type_checking_block(node.test):
continue
for stmt in node.body:
match stmt:
case ast.ImportFrom():
if stmt.module is None:
submodule_names.extend(alias.name for alias in stmt.names)
else:
base = "." * stmt.level + (stmt.module or "")
for alias in stmt.names:
if alias.name == "*":
raise RuntimeError("star imports not supported")
exported = alias.asname or alias.name
out[exported] = (base, alias.name)
case ast.Expr() if isinstance(stmt.value, ast.Call):
call = stmt.value
if (
isinstance(call.func, ast.Name)
and call.func.id == "set_deprecated_aliases"
):
arg0 = call.args[0]
assert isinstance(arg0, ast.Dict)
for key, value in zip(arg0.keys, arg0.values, strict=True):
assert isinstance(key, ast.Constant)
assert isinstance(key.value, str)
assert isinstance(value, ast.Constant)
assert isinstance(value.value, str)
deprecated_aliases[key.value] = value.value
return out, deprecated_aliases, submodule_names
def _is_type_checking_block(test: ast.AST) -> bool:
if not isinstance(test, ast.BoolOp):
return False
subtest = test.values[0]
match subtest:
case ast.Name():
return subtest.id == "TYPE_CHECKING"
case ast.Attribute():
return (
isinstance(subtest.value, ast.Name)
and subtest.value.id == "typing"
and subtest.attr == "TYPE_CHECKING"
)
case _:
return False
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .._lazyimport import (
fix_package_names,
install_lazy_importer,
set_deprecated_aliases,
)
if TYPE_CHECKING or not install_lazy_importer():
from ._eventloop import AsyncBackend as AsyncBackend
from ._resources import AsyncResource as AsyncResource
from ._sockets import ConnectedUDPSocket as ConnectedUDPSocket
from ._sockets import ConnectedUNIXDatagramSocket as ConnectedUNIXDatagramSocket
from ._sockets import IPAddressType as IPAddressType
from ._sockets import IPSockAddrType as IPSockAddrType
from ._sockets import SocketAttribute as SocketAttribute
from ._sockets import SocketListener as SocketListener
from ._sockets import SocketStream as SocketStream
from ._sockets import UDPPacketType as UDPPacketType
from ._sockets import UDPSocket as UDPSocket
from ._sockets import UNIXDatagramPacketType as UNIXDatagramPacketType
from ._sockets import UNIXDatagramSocket as UNIXDatagramSocket
from ._sockets import UNIXSocketStream as UNIXSocketStream
from ._streams import AnyByteReceiveStream as AnyByteReceiveStream
from ._streams import AnyByteSendStream as AnyByteSendStream
from ._streams import AnyByteStream as AnyByteStream
from ._streams import AnyByteStreamConnectable as AnyByteStreamConnectable
from ._streams import (
AnyUnreliableByteReceiveStream as AnyUnreliableByteReceiveStream,
)
from ._streams import AnyUnreliableByteSendStream as AnyUnreliableByteSendStream
from ._streams import AnyUnreliableByteStream as AnyUnreliableByteStream
from ._streams import ByteReceiveStream as ByteReceiveStream
from ._streams import ByteSendStream as ByteSendStream
from ._streams import ByteStream as ByteStream
from ._streams import ByteStreamConnectable as ByteStreamConnectable
from ._streams import Listener as Listener
from ._streams import ObjectReceiveStream as ObjectReceiveStream
from ._streams import ObjectSendStream as ObjectSendStream
from ._streams import ObjectStream as ObjectStream
from ._streams import ObjectStreamConnectable as ObjectStreamConnectable
from ._streams import UnreliableObjectReceiveStream as UnreliableObjectReceiveStream
from ._streams import UnreliableObjectSendStream as UnreliableObjectSendStream
from ._streams import UnreliableObjectStream as UnreliableObjectStream
from ._subprocesses import Process as Process
from ._tasks import TaskGroup as TaskGroup
from ._tasks import TaskStatus as TaskStatus
from ._testing import TestRunner as TestRunner
fix_package_names()
set_deprecated_aliases(
{
"CapacityLimiter": "anyio.CapacityLimiter",
"Condition": "anyio.Condition",
"Event": "anyio.Event",
"Lock": "anyio.Lock",
"Semaphore": "anyio.Semaphore",
"CancelScope": "anyio.CancelScope",
"BlockingPortal": "anyio.from_thread.BlockingPortal",
}
)
@@ -0,0 +1,428 @@
from __future__ import annotations
import math
import sys
from abc import ABCMeta, abstractmethod
from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Coroutine,
Iterable,
Mapping,
Sequence,
)
from contextlib import AbstractContextManager
from os import PathLike
from signal import Signals
from socket import AddressFamily, SocketKind, socket
from typing import (
IO,
TYPE_CHECKING,
Any,
TypeAlias,
TypeVar,
overload,
)
if sys.version_info >= (3, 11):
from typing import TypeVarTuple, Unpack
else:
from typing_extensions import TypeVarTuple, Unpack
if TYPE_CHECKING:
from _typeshed import FileDescriptorLike
from .._core._synchronization import CapacityLimiter, Event, Lock, Semaphore
from .._core._tasks import CancelScope
from .._core._testing import TaskInfo
from ._sockets import (
ConnectedUDPSocket,
ConnectedUNIXDatagramSocket,
IPSockAddrType,
SocketListener,
SocketStream,
UDPSocket,
UNIXDatagramSocket,
UNIXSocketStream,
)
from ._subprocesses import Process
from ._tasks import TaskGroup
from ._testing import TestRunner
T_Retval = TypeVar("T_Retval")
T_co = TypeVar("T_co", covariant=True)
PosArgsT = TypeVarTuple("PosArgsT")
StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes]
class AsyncBackend(metaclass=ABCMeta):
@classmethod
@abstractmethod
def run(
cls,
func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]],
args: tuple[Unpack[PosArgsT]],
kwargs: dict[str, Any],
options: dict[str, Any],
) -> T_Retval:
"""
Run the given coroutine function in an asynchronous event loop.
The current thread must not be already running an event loop.
:param func: a coroutine function
:param args: positional arguments to ``func``
:param kwargs: positional arguments to ``func``
:param options: keyword arguments to call the backend ``run()`` implementation
with
:return: the return value of the coroutine function
"""
@classmethod
@abstractmethod
def current_token(cls) -> object:
"""
Return an object that allows other threads to run code inside the event loop.
:return: a token object, specific to the event loop running in the current
thread
"""
@classmethod
@abstractmethod
def current_time(cls) -> float:
"""
Return the current value of the event loop's internal clock.
:return: the clock value (seconds)
"""
@classmethod
@abstractmethod
def cancelled_exception_class(cls) -> type[BaseException]:
"""Return the exception class that is raised in a task if it's cancelled."""
@classmethod
@abstractmethod
async def checkpoint(cls) -> None:
"""
Check if the task has been cancelled, and allow rescheduling of other tasks.
This is effectively the same as running :meth:`checkpoint_if_cancelled` and then
:meth:`cancel_shielded_checkpoint`.
"""
@classmethod
async def checkpoint_if_cancelled(cls) -> None:
"""
Check if the current task group has been cancelled.
This will check if the task has been cancelled, but will not allow other tasks
to be scheduled if not.
"""
if cls.current_effective_deadline() == -math.inf:
await cls.checkpoint()
@classmethod
async def cancel_shielded_checkpoint(cls) -> None:
"""
Allow the rescheduling of other tasks.
This will give other tasks the opportunity to run, but without checking if the
current task group has been cancelled, unlike with :meth:`checkpoint`.
"""
with cls.create_cancel_scope(shield=True):
await cls.sleep(0)
@classmethod
@abstractmethod
async def sleep(cls, delay: float) -> None:
"""
Pause the current task for the specified duration.
:param delay: the duration, in seconds
"""
@classmethod
@abstractmethod
def create_cancel_scope(
cls, *, deadline: float = math.inf, shield: bool = False
) -> CancelScope:
pass
@classmethod
@abstractmethod
def current_effective_deadline(cls) -> float:
"""
Return the nearest deadline among all the cancel scopes effective for the
current task.
:return:
- a clock value from the event loop's internal clock
- ``inf`` if there is no deadline in effect
- ``-inf`` if the current scope has been cancelled
:rtype: float
"""
@classmethod
@abstractmethod
def create_task_group(cls) -> TaskGroup:
pass
@classmethod
@abstractmethod
def create_event(cls) -> Event:
pass
@classmethod
@abstractmethod
def create_lock(cls, *, fast_acquire: bool) -> Lock:
pass
@classmethod
@abstractmethod
def create_semaphore(
cls,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> Semaphore:
pass
@classmethod
@abstractmethod
def create_capacity_limiter(cls, total_tokens: float) -> CapacityLimiter:
pass
@classmethod
@abstractmethod
async def run_sync_in_worker_thread(
cls,
func: Callable[[Unpack[PosArgsT]], T_Retval],
args: tuple[Unpack[PosArgsT]],
abandon_on_cancel: bool = False,
limiter: CapacityLimiter | None = None,
) -> T_Retval:
pass
@classmethod
@abstractmethod
def check_cancelled(cls) -> None:
pass
@classmethod
@abstractmethod
def run_async_from_thread(
cls,
func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]],
args: tuple[Unpack[PosArgsT]],
token: object,
) -> T_co:
pass
@classmethod
@abstractmethod
def run_sync_from_thread(
cls,
func: Callable[[Unpack[PosArgsT]], T_Retval],
args: tuple[Unpack[PosArgsT]],
token: object,
) -> T_Retval:
pass
@classmethod
@abstractmethod
async def open_process(
cls,
command: StrOrBytesPath | Sequence[StrOrBytesPath],
*,
stdin: int | IO[Any] | None,
stdout: int | IO[Any] | None,
stderr: int | IO[Any] | None,
cwd: StrOrBytesPath | None = None,
env: Mapping[str, str] | None = None,
startupinfo: Any = None,
creationflags: int = 0,
start_new_session: bool = False,
pass_fds: Sequence[int] = (),
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
**kwargs: Any,
) -> Process:
pass
@classmethod
@abstractmethod
def setup_process_pool_exit_at_shutdown(cls, workers: set[Process]) -> None:
pass
@classmethod
@abstractmethod
async def connect_tcp(
cls, host: str, port: int, local_address: IPSockAddrType | None = None
) -> SocketStream:
pass
@classmethod
@abstractmethod
async def connect_unix(cls, path: str | bytes) -> UNIXSocketStream:
pass
@classmethod
@abstractmethod
def create_tcp_listener(cls, sock: socket) -> SocketListener:
pass
@classmethod
@abstractmethod
def create_unix_listener(cls, sock: socket) -> SocketListener:
pass
@classmethod
@abstractmethod
async def create_udp_socket(
cls,
family: AddressFamily,
local_address: IPSockAddrType | None,
remote_address: IPSockAddrType | None,
reuse_port: bool,
) -> UDPSocket | ConnectedUDPSocket:
pass
@classmethod
@overload
async def create_unix_datagram_socket(
cls, raw_socket: socket, remote_path: None
) -> UNIXDatagramSocket: ...
@classmethod
@overload
async def create_unix_datagram_socket(
cls, raw_socket: socket, remote_path: str | bytes
) -> ConnectedUNIXDatagramSocket: ...
@classmethod
@abstractmethod
async def create_unix_datagram_socket(
cls, raw_socket: socket, remote_path: str | bytes | None
) -> UNIXDatagramSocket | ConnectedUNIXDatagramSocket:
pass
@classmethod
@abstractmethod
async def getaddrinfo(
cls,
host: bytes | str | None,
port: str | int | None,
*,
family: int | AddressFamily = 0,
type: int | SocketKind = 0,
proto: int = 0,
flags: int = 0,
) -> Sequence[
tuple[
AddressFamily,
SocketKind,
int,
str,
tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes],
]
]:
pass
@classmethod
@abstractmethod
async def getnameinfo(
cls, sockaddr: IPSockAddrType, flags: int = 0
) -> tuple[str, str]:
pass
@classmethod
@abstractmethod
async def wait_readable(cls, obj: FileDescriptorLike) -> None:
pass
@classmethod
@abstractmethod
async def wait_writable(cls, obj: FileDescriptorLike) -> None:
pass
@classmethod
@abstractmethod
def notify_closing(cls, obj: FileDescriptorLike) -> None:
pass
@classmethod
@abstractmethod
async def wrap_listener_socket(cls, sock: socket) -> SocketListener:
pass
@classmethod
@abstractmethod
async def wrap_stream_socket(cls, sock: socket) -> SocketStream:
pass
@classmethod
@abstractmethod
async def wrap_unix_stream_socket(cls, sock: socket) -> UNIXSocketStream:
pass
@classmethod
@abstractmethod
async def wrap_udp_socket(cls, sock: socket) -> UDPSocket:
pass
@classmethod
@abstractmethod
async def wrap_connected_udp_socket(cls, sock: socket) -> ConnectedUDPSocket:
pass
@classmethod
@abstractmethod
async def wrap_unix_datagram_socket(cls, sock: socket) -> UNIXDatagramSocket:
pass
@classmethod
@abstractmethod
async def wrap_connected_unix_datagram_socket(
cls, sock: socket
) -> ConnectedUNIXDatagramSocket:
pass
@classmethod
@abstractmethod
def current_default_thread_limiter(cls) -> CapacityLimiter:
pass
@classmethod
@abstractmethod
def open_signal_receiver(
cls, *signals: Signals
) -> AbstractContextManager[AsyncIterator[Signals]]:
pass
@classmethod
@abstractmethod
def get_current_task(cls) -> TaskInfo:
pass
@classmethod
@abstractmethod
def get_running_tasks(cls) -> Sequence[TaskInfo]:
pass
@classmethod
@abstractmethod
async def wait_all_tasks_blocked(cls) -> None:
pass
@classmethod
@abstractmethod
def create_test_runner(cls, options: dict[str, Any]) -> TestRunner:
pass
@@ -0,0 +1,39 @@
from __future__ import annotations
import sys
from abc import ABCMeta, abstractmethod
from types import TracebackType
from typing import TypeVar
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
T = TypeVar("T")
class AsyncResource(metaclass=ABCMeta):
"""
Abstract base class for all closeable asynchronous resources.
Works as an asynchronous context manager which returns the instance itself on enter,
and calls :meth:`aclose` on exit.
"""
__slots__ = ()
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
@abstractmethod
async def aclose(self) -> None:
"""Close the resource."""
@@ -0,0 +1,401 @@
from __future__ import annotations
import errno
import socket
from abc import abstractmethod
from collections.abc import Callable, Collection, Mapping
from contextlib import AsyncExitStack
from io import IOBase
from ipaddress import IPv4Address, IPv6Address
from socket import AddressFamily
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from .._core._eventloop import get_async_backend
from .._core._typedattr import (
TypedAttributeProvider,
TypedAttributeSet,
typed_attribute,
)
from ._streams import ByteStream, Listener, UnreliableObjectStream
if TYPE_CHECKING:
from ._tasks import TaskGroup
IPAddressType: TypeAlias = str | IPv4Address | IPv6Address
IPSockAddrType: TypeAlias = tuple[str, int]
SockAddrType: TypeAlias = IPSockAddrType | str
UDPPacketType: TypeAlias = tuple[bytes, IPSockAddrType]
UNIXDatagramPacketType: TypeAlias = tuple[bytes, str]
T_Retval = TypeVar("T_Retval")
def _validate_socket(
sock_or_fd: socket.socket | int,
sock_type: socket.SocketKind,
addr_family: socket.AddressFamily = socket.AF_UNSPEC,
*,
require_connected: bool = False,
require_bound: bool = False,
) -> socket.socket:
if isinstance(sock_or_fd, int):
try:
sock = socket.socket(fileno=sock_or_fd)
except OSError as exc:
if exc.errno == errno.ENOTSOCK:
raise ValueError(
"the file descriptor does not refer to a socket"
) from exc
elif require_connected:
raise ValueError("the socket must be connected") from exc
elif require_bound:
raise ValueError("the socket must be bound to a local address") from exc
else:
raise
elif isinstance(sock_or_fd, socket.socket):
sock = sock_or_fd
else:
raise TypeError(
f"expected an int or socket, got {type(sock_or_fd).__qualname__} instead"
)
try:
if require_connected:
try:
sock.getpeername()
except OSError as exc:
raise ValueError("the socket must be connected") from exc
if require_bound:
try:
if sock.family in (socket.AF_INET, socket.AF_INET6):
bound_addr = sock.getsockname()[1]
else:
bound_addr = sock.getsockname()
except OSError:
bound_addr = None
if not bound_addr:
raise ValueError("the socket must be bound to a local address")
if addr_family != socket.AF_UNSPEC and sock.family != addr_family:
raise ValueError(
f"address family mismatch: expected {addr_family.name}, got "
f"{sock.family.name}"
)
if sock.type != sock_type:
raise ValueError(
f"socket type mismatch: expected {sock_type.name}, got {sock.type.name}"
)
except BaseException:
# Avoid ResourceWarning from the locally constructed socket object
if isinstance(sock_or_fd, int):
sock.detach()
raise
sock.setblocking(False)
return sock
class SocketAttribute(TypedAttributeSet):
"""
.. attribute:: family
:type: socket.AddressFamily
the address family of the underlying socket
.. attribute:: local_address
:type: tuple[str, int] | str
the local address the underlying socket is connected to
.. attribute:: local_port
:type: int
for IP based sockets, the local port the underlying socket is bound to
.. attribute:: raw_socket
:type: socket.socket
the underlying stdlib socket object
.. attribute:: remote_address
:type: tuple[str, int] | str
the remote address the underlying socket is connected to
.. attribute:: remote_port
:type: int
for IP based sockets, the remote port the underlying socket is connected to
"""
family: AddressFamily = typed_attribute()
local_address: SockAddrType = typed_attribute()
local_port: int = typed_attribute()
raw_socket: socket.socket = typed_attribute()
remote_address: SockAddrType = typed_attribute()
remote_port: int = typed_attribute()
class _SocketProvider(TypedAttributeProvider):
@property
def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]:
from .._core._sockets import convert_ipv6_sockaddr as convert
attributes: dict[Any, Callable[[], Any]] = {
SocketAttribute.family: lambda: self._raw_socket.family,
SocketAttribute.local_address: lambda: convert(
self._raw_socket.getsockname()
),
SocketAttribute.raw_socket: lambda: self._raw_socket,
}
try:
peername: tuple[str, int] | None = convert(self._raw_socket.getpeername())
except OSError:
peername = None
# Provide the remote address for connected sockets
if peername is not None:
attributes[SocketAttribute.remote_address] = lambda: peername
# Provide local and remote ports for IP based sockets
if self._raw_socket.family in (AddressFamily.AF_INET, AddressFamily.AF_INET6):
attributes[SocketAttribute.local_port] = lambda: (
self._raw_socket.getsockname()[1]
)
if peername is not None:
remote_port = peername[1]
attributes[SocketAttribute.remote_port] = lambda: remote_port
return attributes
@property
@abstractmethod
def _raw_socket(self) -> socket.socket:
pass
class SocketStream(ByteStream, _SocketProvider):
"""
Transports bytes over a socket.
Supports all relevant extra attributes from :class:`~SocketAttribute`.
"""
@classmethod
async def from_socket(cls, sock_or_fd: socket.socket | int) -> SocketStream:
"""
Wrap an existing socket object or file descriptor as a socket stream.
The newly created socket wrapper takes ownership of the socket being passed in.
The existing socket must already be connected.
:param sock_or_fd: a socket object or file descriptor
:return: a socket stream
"""
sock = _validate_socket(sock_or_fd, socket.SOCK_STREAM, require_connected=True)
return await get_async_backend().wrap_stream_socket(sock)
class UNIXSocketStream(SocketStream):
@classmethod
async def from_socket(cls, sock_or_fd: socket.socket | int) -> UNIXSocketStream:
"""
Wrap an existing socket object or file descriptor as a UNIX socket stream.
The newly created socket wrapper takes ownership of the socket being passed in.
The existing socket must already be connected.
:param sock_or_fd: a socket object or file descriptor
:return: a UNIX socket stream
"""
sock = _validate_socket(
sock_or_fd, socket.SOCK_STREAM, socket.AF_UNIX, require_connected=True
)
return await get_async_backend().wrap_unix_stream_socket(sock)
@abstractmethod
async def send_fds(self, message: bytes, fds: Collection[int | IOBase]) -> None:
"""
Send file descriptors along with a message to the peer.
:param message: a non-empty bytestring
:param fds: a collection of files (either numeric file descriptors or open file
or socket objects)
"""
@abstractmethod
async def receive_fds(self, msglen: int, maxfds: int) -> tuple[bytes, list[int]]:
"""
Receive file descriptors along with a message from the peer.
:param msglen: length of the message to expect from the peer
:param maxfds: maximum number of file descriptors to expect from the peer
:return: a tuple of (message, file descriptors)
"""
class SocketListener(Listener[SocketStream], _SocketProvider):
"""
Listens to incoming socket connections.
Supports all relevant extra attributes from :class:`~SocketAttribute`.
"""
@classmethod
async def from_socket(
cls,
sock_or_fd: socket.socket | int,
) -> SocketListener:
"""
Wrap an existing socket object or file descriptor as a socket listener.
The newly created listener takes ownership of the socket being passed in.
:param sock_or_fd: a socket object or file descriptor
:return: a socket listener
"""
sock = _validate_socket(sock_or_fd, socket.SOCK_STREAM, require_bound=True)
return await get_async_backend().wrap_listener_socket(sock)
@abstractmethod
async def accept(self) -> SocketStream:
"""Accept an incoming connection."""
async def serve(
self,
handler: Callable[[SocketStream], Any],
task_group: TaskGroup | None = None,
) -> None:
from .. import create_task_group
async with AsyncExitStack() as stack:
if task_group is None:
task_group = await stack.enter_async_context(create_task_group())
while True:
stream = await self.accept()
task_group.start_soon(handler, stream)
class UDPSocket(UnreliableObjectStream[UDPPacketType], _SocketProvider):
"""
Represents an unconnected UDP socket.
Supports all relevant extra attributes from :class:`~SocketAttribute`.
"""
@classmethod
async def from_socket(cls, sock_or_fd: socket.socket | int) -> UDPSocket:
"""
Wrap an existing socket object or file descriptor as a UDP socket.
The newly created socket wrapper takes ownership of the socket being passed in.
The existing socket must be bound to a local address.
:param sock_or_fd: a socket object or file descriptor
:return: a UDP socket
"""
sock = _validate_socket(sock_or_fd, socket.SOCK_DGRAM, require_bound=True)
return await get_async_backend().wrap_udp_socket(sock)
async def sendto(self, data: bytes, host: str, port: int) -> None:
"""
Alias for :meth:`~.UnreliableObjectSendStream.send` ((data, (host, port))).
"""
return await self.send((data, (host, port)))
class ConnectedUDPSocket(UnreliableObjectStream[bytes], _SocketProvider):
"""
Represents an connected UDP socket.
Supports all relevant extra attributes from :class:`~SocketAttribute`.
"""
@classmethod
async def from_socket(cls, sock_or_fd: socket.socket | int) -> ConnectedUDPSocket:
"""
Wrap an existing socket object or file descriptor as a connected UDP socket.
The newly created socket wrapper takes ownership of the socket being passed in.
The existing socket must already be connected.
:param sock_or_fd: a socket object or file descriptor
:return: a connected UDP socket
"""
sock = _validate_socket(
sock_or_fd,
socket.SOCK_DGRAM,
require_connected=True,
)
return await get_async_backend().wrap_connected_udp_socket(sock)
class UNIXDatagramSocket(
UnreliableObjectStream[UNIXDatagramPacketType], _SocketProvider
):
"""
Represents an unconnected Unix datagram socket.
Supports all relevant extra attributes from :class:`~SocketAttribute`.
"""
@classmethod
async def from_socket(
cls,
sock_or_fd: socket.socket | int,
) -> UNIXDatagramSocket:
"""
Wrap an existing socket object or file descriptor as a UNIX datagram
socket.
The newly created socket wrapper takes ownership of the socket being passed in.
:param sock_or_fd: a socket object or file descriptor
:return: a UNIX datagram socket
"""
sock = _validate_socket(sock_or_fd, socket.SOCK_DGRAM, socket.AF_UNIX)
return await get_async_backend().wrap_unix_datagram_socket(sock)
async def sendto(self, data: bytes, path: str) -> None:
"""Alias for :meth:`~.UnreliableObjectSendStream.send` ((data, path))."""
return await self.send((data, path))
class ConnectedUNIXDatagramSocket(UnreliableObjectStream[bytes], _SocketProvider):
"""
Represents a connected Unix datagram socket.
Supports all relevant extra attributes from :class:`~SocketAttribute`.
"""
@classmethod
async def from_socket(
cls,
sock_or_fd: socket.socket | int,
) -> ConnectedUNIXDatagramSocket:
"""
Wrap an existing socket object or file descriptor as a connected UNIX datagram
socket.
The newly created socket wrapper takes ownership of the socket being passed in.
The existing socket must already be connected.
:param sock_or_fd: a socket object or file descriptor
:return: a connected UNIX datagram socket
"""
sock = _validate_socket(
sock_or_fd, socket.SOCK_DGRAM, socket.AF_UNIX, require_connected=True
)
return await get_async_backend().wrap_connected_unix_datagram_socket(sock)
Loaded 100 of 1354 files, more files were not shown because too many files have changed in this diff. Show more