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,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