Repair the official desktop console without replacing existing work

This commit is contained in:
Gerardo Betancourt committed 2026-10-28 11:56:27 -04:00
1 parent 7c9371b76a
commit e98d0d70e6
7 files changed
+445 -17

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# Desktop console upgrade
The desktop console and its authenticated tty1 view share the existing local Bash and FBTERM activity dashboard. A desktop attachment preserves its Bash process, other work windows and login. The dashboard is read-only; the Bash pane is the command prompt.
Older private installations used a user-level desktop launcher. That launcher could hide the package launcher and reject a valid console after other work windows were added. Version 0.3.3 targets the existing named `Console` window, while preserving the other windows.
During an upgrade, a running profile can migrate the exact recognized old launcher to the packaged CLI. Custom launchers and changed legacy components are preserved. The old desktop entry is backed up privately in the profile; the console socket/session binding is recorded there. No login or boot policy changes.
For an explicit owner repair, use the real configured profile:
```sh
fb --state-dir /absolute/path/to/profile --json console desktop migrate-legacy
```
Then open **FrameBridge Console** from the desktop. The command does not open a generic replacement terminal, recreate the Bash session or delete additional work windows.
@@ -592,6 +592,8 @@ def build_parser():
con_sub = console.add_subparsers(dest="console_command") con_sub = console.add_subparsers(dest="console_command")
login = con_sub.add_parser("login", help="Optional work-view opening after authenticated tty1 login; not service startup.") login = con_sub.add_parser("login", help="Optional work-view opening after authenticated tty1 login; not service startup.")
login.add_argument("console_login_action", choices=("enable", "status", "disable")) login.add_argument("console_login_action", choices=("enable", "status", "disable"))
desktop = con_sub.add_parser("desktop", help="Migrate a recognized legacy desktop console into this package.")
desktop.add_argument("console_desktop_action", choices=("migrate-legacy",))
tty = sub.add_parser("tty", help="Manage persistent Bash sessions; persistence is between calls, not reboots.") tty = sub.add_parser("tty", help="Manage persistent Bash sessions; persistence is between calls, not reboots.")
ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION") ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION")
tty_service = ts.add_parser("service", help="Manage the terminal daemon.") tty_service = ts.add_parser("service", help="Manage the terminal daemon.")
@@ -764,6 +766,12 @@ def _dispatch(parser, args):
return _interaction_required("fb nodes run") return _interaction_required("fb nodes run")
return nodes_command(args, state) return nodes_command(args, state)
if args.command == "console": if args.command == "console":
if getattr(args, "console_desktop_action", None):
if args.node != "LOCAL" or args.status or args.login_action:
raise ValueError("Desktop console migration must target the local profile alone.")
result = _module("console_desktop").migrate_legacy(state)
_show(result)
return _result_code(result)
action = getattr(args, "console_login_action", None) or args.login_action action = getattr(args, "console_login_action", None) or args.login_action
if getattr(args, "console_login_action", None) and args.login_action: if getattr(args, "console_login_action", None) and args.login_action:
raise ValueError("Choose one console login command form.") raise ValueError("Choose one console login command form.")
@@ -49,8 +49,10 @@ def context(state, node="LOCAL", *, create=False):
label = _node(state, node) label = _node(state, node)
key = hashlib.sha256(str(state).encode()).hexdigest()[:20] key = hashlib.sha256(str(state).encode()).hexdigest()[:20]
runtime = _private_runtime(Path("/run/user") / str(os.getuid())) runtime = _private_runtime(Path("/run/user") / str(os.getuid()))
root = runtime / "framebridge-expanded-console" from .console_desktop import legacy_context
profile = root / key legacy = legacy_context(state, label, runtime)
root = legacy["socket"].parent if legacy else runtime / "framebridge-expanded-console"
profile = root if legacy else root / key
if create: if create:
private_dir(state) private_dir(state)
for path in (root, profile): for path in (root, profile):
@@ -63,7 +65,7 @@ def context(state, node="LOCAL", *, create=False):
return { return {
"state": state, "node": label, "runtime": runtime, "state": state, "node": label, "runtime": runtime,
"socket": profile / "console.sock", "socket": profile / "console.sock",
"session": "FB-" + label + "-" + key[:10], "session": legacy["session"] if legacy else "FB-" + label + "-" + key[:10],
"jobs_root": state / "home/.local/share/framebridge/fbterm", "jobs_root": state / "home/.local/share/framebridge/fbterm",
} }
@@ -75,10 +77,20 @@ def _active_graphical_display(env=None):
env = os.environ if env is None else env env = os.environ if env is None else env
display = env.get("DISPLAY", "") display = env.get("DISPLAY", "")
sid = env.get("XDG_SESSION_ID", "") sid = env.get("XDG_SESSION_ID", "")
if (re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None if re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None:
or re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None):
return False return False
try: try:
# Desktop launchers started through the user bus may omit XDG_SESSION_ID.
# Ask logind for this user's display session and validate every field below.
if not sid:
session = subprocess.run([
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value",
], capture_output=True, text=True, timeout=5)
if session.returncode:
return False
sid = session.stdout.strip()
if re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None:
return False
result = subprocess.run([ result = subprocess.run([
"loginctl", "show-session", sid, "--no-pager", "loginctl", "show-session", sid, "--no-pager",
"-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote", "-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote",
@@ -142,7 +154,7 @@ def status(state, node="LOCAL"):
try: try:
result = subprocess.run([ result = subprocess.run([
"/usr/bin/tmux", "-S", str(sock), "list-panes", "/usr/bin/tmux", "-S", str(sock), "list-panes",
"-t", "=" + ctx["session"], "-t", "=" + ctx["session"] + ":Console",
"-F", "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{@framebridge_role}", "-F", "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{@framebridge_role}",
], capture_output=True, text=True, timeout=5) ], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
@@ -231,6 +243,7 @@ def run(state, node="LOCAL", *, inside=False, mode="auto"):
result = subprocess.run([ result = subprocess.run([
sys.executable, str(ui), "--node", ctx["node"], sys.executable, str(ui), "--node", ctx["node"],
"--socket", str(ctx["socket"]), "--session", ctx["session"], "--socket", str(ctx["socket"]), "--session", ctx["session"],
"--jobs-root", str(ctx["jobs_root"]),
"--layout", "horizontal" if inside else "vertical", "--apply", "--layout", "horizontal" if inside else "vertical", "--apply",
], env=env, capture_output=True, text=True, timeout=20) ], env=env, capture_output=True, text=True, timeout=20)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
@@ -0,0 +1,171 @@
"""Migrate the recognized pre-package desktop console without replacing its Bash."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import stat
import subprocess
import tempfile
RECORD = "console-desktop.json"
LEGACY_WRAPPER_SHA256 = "71a6436d7411606312b009aee62a6d736a0d31a31194abb445f600740d8174f5"
def _need(value, error):
if not value:
raise ValueError(error)
def _owned(path, *, directory=False):
path = Path(path)
for parent in [path, *path.parents]:
_need(not parent.is_symlink(), "CONSOLE_DESKTOP_SYMLINK_REFUSED")
info = path.lstat()
kind = stat.S_ISDIR if directory else stat.S_ISREG
_need(kind(info.st_mode) and info.st_uid == os.getuid() and not info.st_mode & 0o022,
"CONSOLE_DESKTOP_PATH_NOT_OWNED")
return info
def _snapshot(path):
_owned(path)
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
_need(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid()
and not info.st_mode & 0o022 and info.st_size <= 32768,
"CONSOLE_DESKTOP_FILE_UNTRUSTED")
return stream.read(), stat.S_IMODE(info.st_mode)
def _atomic(path, data, mode=0o600):
fd, temporary = tempfile.mkstemp(prefix=".fb-desktop-", dir=path.parent)
try:
with os.fdopen(fd, "wb") as stream:
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def _legacy_entry(home):
return ("[Desktop Entry]\nVersion=1.0\nType=Application\nName=FrameBridge Console\n"
"Comment=Open the persistent local Bash and FBTERM activity console\n"
"Exec=" + str(home / ".local/bin/framebridge-console") + "\n"
"Icon=utilities-terminal\nTerminal=false\nCategories=System;TerminalEmulator;\n"
"StartupWMClass=Gnome-terminal\n").encode()
def _new_entry(state):
# Desktop Entry Exec quoting, without a shell or environment expansion.
_need(not any(ord(char) < 32 or ord(char) == 127 for char in str(state)),
"CONSOLE_DESKTOP_STATE_PATH_INVALID")
quoted = str(state).replace("%", "%%").replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
return ("[Desktop Entry]\nVersion=1.0\nType=Application\nName=FrameBridge Console\n"
"Comment=Open the persistent local Bash and FBTERM activity console\n"
'Exec=/usr/bin/fb --state-dir "' + quoted + '" console\n'
"Icon=utilities-terminal\nTerminal=false\nCategories=System;TerminalEmulator;\n"
"StartupWMClass=Gnome-terminal\n").encode()
def _legacy_identity(node, runtime):
return {"socket": runtime / (node.lower() + "-tty1") / "console.sock",
"session": node.upper() + "-FBTERM"}
def legacy_context(state, node, runtime):
"""Return a user-approved legacy binding; ordinary profiles remain isolated."""
path = Path(state) / RECORD
if not path.exists() and not path.is_symlink():
return None
state_info = _owned(Path(state), directory=True)
_need(not state_info.st_mode & 0o077, "CONSOLE_DESKTOP_STATE_NOT_PRIVATE")
raw, mode = _snapshot(path)
_need(mode == 0o600, "CONSOLE_DESKTOP_RECORD_NOT_PRIVATE")
try:
record = json.loads(raw)
except (ValueError, TypeError):
raise ValueError("CONSOLE_DESKTOP_RECORD_INVALID") from None
expected = _legacy_identity(node, Path(runtime))
_need(type(record) is dict and record.get("schema") == 1
and record.get("state") == str(state) and record.get("node") == node
and record.get("socket") == str(expected["socket"])
and record.get("session") == expected["session"], "CONSOLE_DESKTOP_RECORD_INVALID")
return expected
def migrate_legacy(state, *, home=None, node=None, runtime=None):
"""Change only the exact known legacy entry; called under the profile owner's UID."""
from .config import private_dir, state_path
import socket
uid = os.getuid()
_need(uid != 0 and os.geteuid() == uid, "CONSOLE_DESKTOP_USER_REQUIRED")
state = state_path(state)
home = Path(home if home is not None else pwd.getpwuid(uid).pw_dir)
node = node or re.sub(r"[^A-Za-z0-9_-]", "_", socket.gethostname())[:64] or "NODE"
runtime = Path(runtime if runtime is not None else Path("/run/user") / str(uid))
desktop = home / ".local/share/applications/framebridge-console.desktop"
if not desktop.exists() and not desktop.is_symlink():
return {"ok": True, "changed": False, "reason": "NO_LEGACY_DESKTOP_ENTRY"}
_owned(home, directory=True)
_owned(desktop.parent, directory=True)
before, mode = _snapshot(desktop)
after = _new_entry(state)
if before == after:
_need(legacy_context(state, node, runtime) is not None, "CONSOLE_DESKTOP_RECORD_MISSING")
return {"ok": True, "changed": False, "reason": "ALREADY_MIGRATED"}
if before != _legacy_entry(home):
return {"ok": True, "changed": False, "reason": "CUSTOM_DESKTOP_ENTRY_PRESERVED"}
wrapper, _ = _snapshot(home / ".local/bin/framebridge-console")
_need(hashlib.sha256(wrapper).hexdigest() == LEGACY_WRAPPER_SHA256,
"CONSOLE_DESKTOP_LEGACY_WRAPPER_CHANGED")
identity = _legacy_identity(node, runtime)
parent = identity["socket"].parent
_owned(runtime, directory=True)
_owned(parent, directory=True)
info = identity["socket"].lstat()
_need(stat.S_ISSOCK(info.st_mode) and info.st_uid == uid and not info.st_mode & 0o077,
"CONSOLE_DESKTOP_LEGACY_SOCKET_UNTRUSTED")
result = subprocess.run(["/usr/bin/tmux", "-S", str(identity["socket"]), "list-windows",
"-t", "=" + identity["session"], "-F", "#{window_id}|#{window_name}"],
capture_output=True, text=True, timeout=5)
rows = result.stdout.splitlines()
_need(result.returncode == 0 and len([row for row in rows
if re.fullmatch(r"@[0-9]+\|Console", row)]) == 1,
"CONSOLE_DESKTOP_LEGACY_WINDOW_UNVERIFIED")
window = next(row.split("|", 1)[0] for row in rows if re.fullmatch(r"@[0-9]+\|Console", row))
panes = subprocess.run(["/usr/bin/tmux", "-S", str(identity["socket"]), "list-panes",
"-t", window, "-F", "#{pane_current_command}|#{pane_dead}|#{@framebridge_role}"],
capture_output=True, text=True, timeout=5)
pane_rows = [row.split("|") for row in panes.stdout.splitlines()]
_need(panes.returncode == 0 and len(pane_rows) == 2
and all(len(row) == 3 and row[1] == "0" for row in pane_rows)
and {row[2] for row in pane_rows} == {"shell", "monitor"}
and any(row == ["bash", "0", "shell"] for row in pane_rows),
"CONSOLE_DESKTOP_LEGACY_PANES_UNVERIFIED")
private_dir(state)
backup = state / "console-desktop-legacy.backup"
if backup.exists() or backup.is_symlink():
old, backup_mode = _snapshot(backup)
_need(old == before and backup_mode == 0o600, "CONSOLE_DESKTOP_BACKUP_CHANGED")
else:
_atomic(backup, before)
record = {"schema": 1, "state": str(state), "node": node,
"socket": str(identity["socket"]), "session": identity["session"],
"desktop_before_sha256": hashlib.sha256(before).hexdigest()}
_atomic(state / RECORD, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
_need(_snapshot(desktop)[0] == before, "CONSOLE_DESKTOP_CHANGED_DURING_MIGRATION")
_atomic(desktop, after, mode)
return {"ok": True, "changed": True, "desktop": str(desktop),
"session": identity["session"], "socket": str(identity["socket"]),
"backup": str(backup), "runtime_changed": False}
@@ -29,6 +29,7 @@ def arguments():
p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname())) p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname()))
p.add_argument('--socket') p.add_argument('--socket')
p.add_argument('--session') p.add_argument('--session')
p.add_argument('--jobs-root', type=Path)
p.add_argument('--apply', action='store_true', help='Set up without attaching a client') p.add_argument('--apply', action='store_true', help='Set up without attaching a client')
p.add_argument('--probe', action='store_true', help='Read current pane metadata only') p.add_argument('--probe', action='store_true', help='Read current pane metadata only')
p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus') p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus')
@@ -68,10 +69,14 @@ class Console:
if len(matches)!=1: if len(matches)!=1:
raise RuntimeError('Console session identity is ambiguous') raise RuntimeError('Console session identity is ambiguous')
self.session_target=matches[0] self.session_target=matches[0]
windows=self.tmux('list-windows','-t',self.session_target,'-F','#{window_id}').splitlines() windows=[line.split('|',1) for line in self.tmux(
if len(windows)!=1: 'list-windows','-t',self.session_target,'-F','#{window_id}|#{window_name}').splitlines()]
raise RuntimeError('Use a dedicated console session containing exactly one window') if not windows or any(len(row)!=2 or not re.fullmatch(r'@[0-9]+',row[0]) for row in windows):
self.window=windows[0] raise RuntimeError('Console window identity is invalid')
selected=windows if len(windows)==1 else [row for row in windows if row[1]=='Console']
if len(selected)!=1:
raise RuntimeError('The dedicated Console window is absent or ambiguous')
self.window=selected[0][0]
return True return True
return False return False
@@ -87,7 +92,10 @@ class Console:
return rows return rows
def monitor_command(self): def monitor_command(self):
return shlex.join(['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node]) command=['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node]
if getattr(self.a,'jobs_root',None) is not None:
command.extend(['--jobs-root',str(self.a.jobs_root)])
return shlex.join(command)
def locate(self, rows): def locate(self, rows):
shell = next((p for p in rows if p['role']=='shell'), None) shell = next((p for p in rows if p['role']=='shell'), None)
@@ -164,7 +172,7 @@ class Console:
cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes() cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes()
except OSError: except OSError:
cmd=b'' cmd=b''
if self.a.refresh_monitor or monitor['dead'] or b'framebridge_console.py' not in cmd: if self.a.refresh_monitor or monitor['dead'] or str(HERE/'framebridge_console.py').encode() not in cmd:
self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command()) self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command())
for role,pane in [('shell',shell),('monitor',monitor)]: for role,pane in [('shell',shell),('monitor',monitor)]:
self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role) self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role)
@@ -190,9 +198,12 @@ class Console:
# Reattachment must preserve the user's zoom and pane focus. # Reattachment must preserve the user's zoom and pane focus.
if not previous_zoom: if not previous_zoom:
self.layout(shell,monitor) self.layout(shell,monitor)
command=shlex.join(['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node, reflow=['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node,
'--socket',self.a.socket,'--session',self.a.session, '--socket',self.a.socket,'--session',self.a.session,
'--layout',getattr(self.a,'layout','auto'),'--reflow']) '--layout',getattr(self.a,'layout','auto'),'--reflow']
if getattr(self.a,'jobs_root',None) is not None:
reflow.extend(['--jobs-root',str(self.a.jobs_root)])
command=shlex.join(reflow)
self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command)) self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command))
self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command)) self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command))
self.tmux('select-window','-t',self.window) self.tmux('select-window','-t',self.window)
@@ -205,10 +216,13 @@ class Console:
raise RuntimeError('Bash identity changed unexpectedly') raise RuntimeError('Bash identity changed unexpectedly')
def supervise(node): def supervise(node, jobs_root=None):
command=['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node]
if jobs_root is not None:
command.extend(['--root',str(jobs_root)])
while True: while True:
try: try:
result=subprocess.run(['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node]) result=subprocess.run(command)
print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True) print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True)
print('Ctrl+B o switches to the shell.',flush=True) print('Ctrl+B o switches to the shell.',flush=True)
time.sleep(2) time.sleep(2)
@@ -220,7 +234,7 @@ def supervise(node):
def main(): def main():
args=arguments() args=arguments()
if args.monitor: if args.monitor:
supervise(args.node) supervise(args.node, args.jobs_root)
return 0 return 0
console=Console(args) console=Console(args)
if args.probe: if args.probe:
+20
View File
@@ -33,6 +33,26 @@ class ConsoleTests(unittest.TestCase):
mock.patch.object(Path, "read_text", return_value="tty7\n"): mock.patch.object(Path, "read_text", return_value="tty7\n"):
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"})) self.assertTrue(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"}))
def test_desktop_launcher_without_session_environment_uses_verified_own_display(self):
with mock.patch.object(console.subprocess, "run", side_effect=[
SimpleNamespace(returncode=0, stdout="19\n"), self.desktop()]) as run, \
mock.patch.object(Path, "read_text", return_value="tty7\n"):
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0"}))
self.assertEqual(run.call_args_list[0].args[0], [
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value"])
self.assertIn("19", run.call_args_list[1].args[0])
def test_missing_primary_session_does_not_guess_a_display_session(self):
with mock.patch.object(console.subprocess, "run", return_value=
SimpleNamespace(returncode=0, stdout="\n")) as run:
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0"}))
run.assert_called_once()
def test_invalid_supplied_session_is_not_replaced(self):
with mock.patch.object(console.subprocess, "run") as run:
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "../bad"}))
run.assert_not_called()
def test_private_virtual_display_is_not_chosen_as_user_desktop(self): def test_private_virtual_display_is_not_chosen_as_user_desktop(self):
with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \ with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \
mock.patch.object(Path, "read_text", return_value="tty7\n"): mock.patch.object(Path, "read_text", return_value="tty7\n"):
+187
View File
@@ -0,0 +1,187 @@
import hashlib
import os
from pathlib import Path
import shutil
import shlex
import socket
import subprocess
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
from framebridge_expanded import console_desktop
from framebridge_expanded.console_ui import framebridge_console as ui
@unittest.skipIf(os.getuid() == 0, "desktop migration requires a normal user")
class DesktopMigrationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
root = Path(self.temp.name)
self.home = root / "home"
self.home.mkdir(mode=0o700)
self.state = self.home / "profile"
self.runtime = root / "runtime"
self.runtime.mkdir(mode=0o700)
parent = self.runtime / "testhost-tty1"
parent.mkdir(mode=0o700)
self.sock = socket.socket(socket.AF_UNIX)
self.sock.bind(str(parent / "console.sock"))
os.chmod(parent / "console.sock", 0o600)
self.addCleanup(self.sock.close)
self.desktop = self.home / ".local/share/applications/framebridge-console.desktop"
self.desktop.parent.mkdir(parents=True, mode=0o700)
self.desktop.write_bytes(console_desktop._legacy_entry(self.home))
self.wrapper = self.home / ".local/bin/framebridge-console"
self.wrapper.parent.mkdir(parents=True, mode=0o700)
self.wrapper.write_bytes(b"#!/usr/bin/python3\npass\n")
self.old_hash = hashlib.sha256(self.wrapper.read_bytes()).hexdigest()
def migrate(self, windows="@0|Console\n@1|FB-piloto\n@3|FB-Muffin\n"):
def result(args, **kwargs):
text = windows if "list-windows" in args else "bash|0|shell\npython3|0|monitor\n"
return SimpleNamespace(returncode=0, stdout=text)
with mock.patch.object(console_desktop, "LEGACY_WRAPPER_SHA256", self.old_hash), \
mock.patch.object(console_desktop.subprocess, "run", side_effect=result) as run:
value = console_desktop.migrate_legacy(
self.state, home=self.home, node="TestHost", runtime=self.runtime)
return value, run
def test_known_legacy_icon_keeps_existing_socket_and_session(self):
before = self.desktop.read_bytes()
value, run = self.migrate()
self.assertTrue(value["changed"])
self.assertFalse(value["runtime_changed"])
self.assertEqual((self.state / "console-desktop-legacy.backup").read_bytes(), before)
self.assertEqual(self.desktop.read_bytes(), console_desktop._new_entry(self.state))
context = console_desktop.legacy_context(self.state, "TestHost", self.runtime)
self.assertEqual(context["session"], "TESTHOST-FBTERM")
self.assertEqual(context["socket"], self.runtime / "testhost-tty1/console.sock")
self.assertEqual(run.call_count, 2)
self.assertIn("list-windows", run.call_args_list[0].args[0])
self.assertIn("list-panes", run.call_args_list[1].args[0])
second, run = self.migrate()
self.assertFalse(second["changed"])
run.assert_not_called()
def test_custom_desktop_entry_is_preserved(self):
self.desktop.write_text("[Desktop Entry]\nExec=my-custom-console\n")
before = self.desktop.read_bytes()
value, run = self.migrate()
self.assertEqual(value["reason"], "CUSTOM_DESKTOP_ENTRY_PRESERVED")
self.assertEqual(self.desktop.read_bytes(), before)
self.assertFalse(self.state.exists())
run.assert_not_called()
def test_changed_legacy_wrapper_does_not_get_replaced(self):
self.wrapper.write_text("custom owner command\n")
before = self.desktop.read_bytes()
with self.assertRaisesRegex(ValueError, "LEGACY_WRAPPER_CHANGED"):
self.migrate()
self.assertEqual(self.desktop.read_bytes(), before)
self.assertFalse(self.state.exists())
def test_ambiguous_console_window_prevents_migration(self):
before = self.desktop.read_bytes()
with self.assertRaisesRegex(ValueError, "LEGACY_WINDOW_UNVERIFIED"):
self.migrate("@0|Console\n@1|Console\n")
self.assertEqual(self.desktop.read_bytes(), before)
self.assertFalse(self.state.exists())
def test_symlinked_entry_is_refused_without_touching_target(self):
self.desktop.unlink()
target = self.home / "owner-file"
target.write_text("keep\n")
self.desktop.symlink_to(target)
with self.assertRaisesRegex(ValueError, "SYMLINK_REFUSED"):
self.migrate()
self.assertEqual(target.read_text(), "keep\n")
def test_foreign_profile_cannot_reuse_the_binding(self):
self.migrate()
record = self.state / console_desktop.RECORD
raw = record.read_text().replace(str(self.state), str(self.state) + "-other")
record.write_text(raw)
with self.assertRaisesRegex(ValueError, "RECORD_INVALID"):
console_desktop.legacy_context(self.state, "TestHost", self.runtime)
class ConsoleWindowSelectionTests(unittest.TestCase):
def console(self, windows):
console = ui.Console(SimpleNamespace(socket="/no-real-socket", session="NODE-FBTERM"))
def tmux(*args, **kwargs):
if args[0] == "list-sessions":
return "NODE-FBTERM|$0"
if args[0] == "list-windows":
return windows
raise AssertionError(args)
with mock.patch.object(ui.subprocess, "run", return_value=SimpleNamespace(returncode=0)), \
mock.patch.object(console, "tmux", side_effect=tmux):
value = console.exists()
return console, value
def test_monitor_receives_profile_jobs_root_as_an_explicit_argument(self):
args = SimpleNamespace(socket="/owned/socket", session="NODE-FBTERM", node="NODE",
jobs_root=Path("/profile with space/jobs"))
command = shlex.split(ui.Console(args).monitor_command())
self.assertEqual(command[-2:], ["--jobs-root", "/profile with space/jobs"])
def test_desktop_entry_escapes_percent_field_codes(self):
entry = console_desktop._new_entry(Path("/home/user/100%complete/profile"))
self.assertIn(b"100%%complete", entry)
def test_desktop_entry_refuses_newline_in_profile_path(self):
with self.assertRaisesRegex(ValueError, "STATE_PATH_INVALID"):
console_desktop._new_entry(Path("/home/user/profile\nExec=bad"))
def test_extra_owner_windows_do_not_prevent_console_attachment(self):
console, value = self.console("@0|Console\n@1|FB-piloto\n@3|FB-Muffin")
self.assertTrue(value)
self.assertEqual(console.window, "@0")
self.assertEqual(console.session_target, "$0")
def test_ambiguous_console_window_is_not_guessed(self):
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
self.console("@0|Console\n@1|Console")
def test_multiwindow_session_without_console_is_preserved(self):
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
self.console("@0|Owner work\n@1|Other")
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
class RealConsolePreservationTests(unittest.TestCase):
def test_apply_preserves_shell_and_other_windows_in_existing_session(self):
with tempfile.TemporaryDirectory() as directory:
sock = str(Path(directory) / "console.sock")
def tmux(*args):
return subprocess.check_output(["/usr/bin/tmux", "-S", sock, *args], text=True).strip()
try:
tmux("new-session", "-d", "-s", "TEST-FBTERM", "-n", "Console", "/bin/bash --noprofile --norc")
shell = tmux("display-message", "-p", "-t", "=TEST-FBTERM:Console", "#{pane_pid}")
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-piloto", "/bin/bash --noprofile --norc")
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-Muffin", "/bin/bash --noprofile --norc")
fmt = "#{window_name}|#{pane_id}|#{pane_pid}"
other = tmux("list-panes", "-a", "-F", fmt).splitlines()[1:]
jobs = Path(directory) / "profile-jobs"
args = SimpleNamespace(socket=sock, session="TEST-FBTERM", node="Test", jobs_root=jobs,
layout="horizontal", reflow=False, refresh_monitor=False)
ui.Console(args).apply()
panes = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_pid}|#{@framebridge_role}")
self.assertIn(shell + "|shell", panes)
start_commands = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_start_command}")
self.assertIn("--jobs-root", start_commands)
self.assertIn(str(jobs), start_commands)
after = tmux("list-panes", "-a", "-F", fmt).splitlines()
for row in other:
self.assertIn(row, after)
self.assertEqual(tmux("list-windows", "-t", "=TEST-FBTERM", "-F", "#{window_name}"),
"Console\nFB-piloto\nFB-Muffin")
finally:
subprocess.run(["/usr/bin/tmux", "-S", sock, "kill-server"], capture_output=True)
if __name__ == "__main__":
unittest.main()