FrameBridge 0.3.1 community source release
This commit is contained in:
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
@@ -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
@@ -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
@@ -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)
|
||||
Executable
+123
@@ -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
|
||||
+175
@@ -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
|
||||
+119
@@ -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,
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user