diff --git a/scripts/keep-awake.sh b/scripts/keep-awake.sh index c4a3301..f2c6815 100755 --- a/scripts/keep-awake.sh +++ b/scripts/keep-awake.sh @@ -15,11 +15,13 @@ set -euo pipefail FRAME_ALIAS=${FRAME_ALIAS:-frame} +# Frame Control passes the headset it has chosen: its address and pinned identity. +ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}) HERE=${0:A:h} cmd=${1:-status} case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac -ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \ +ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \ 'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py" # Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the @@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \ # written the way Steam's settings page does (steamui module exporting the # SetSetting wrapper). logind refuses an inhibitor from an SSH session # ("Interactive authentication required") but allows one from a user unit. -ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF' +ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF' import json, os, subprocess, sys sys.path.insert(0, os.path.expanduser("~/.cache/frame-control")) from frame_steam import Page diff --git a/scripts/panel-on-frame.sh b/scripts/panel-on-frame.sh index 781767a..2ce218c 100755 --- a/scripts/panel-on-frame.sh +++ b/scripts/panel-on-frame.sh @@ -21,6 +21,8 @@ set -euo pipefail FRAME_ALIAS=${FRAME_ALIAS:-frame} +# Frame Control passes the headset it has chosen: its address and pinned identity. +ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}) REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina" id="" name="" @@ -109,4 +111,4 @@ EOF ) b64=$(print -rn -- "$remote" | base64) -ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}" +ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}" diff --git a/tests/test_agent.py b/tests/test_agent.py index 307d61a..931eccb 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -252,3 +252,23 @@ class ComputerState(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +class ScriptsFollowTheHeadset(unittest.TestCase): + """keep_awake and panel tools run scripts that ssh on their own: they must reach the + headset the server is routed to, not whatever `frame` means in ~/.ssh/config.""" + + @unittest.skipUnless(shutil.which('zsh'), 'needs zsh') + def test_scripts_get_the_routed_alias_and_options(self): + import shlex + fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui', + SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab']) + with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run: + agent.run_script(fake, 'keep-awake.sh', ['status']) + env = run.call_args.kwargs['env'] + self.assertEqual(env['FRAME_ALIAS'], 'frame-2') + self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:]) + # and the script turns that back into the same argv + out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'], + env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True) + self.assertEqual(out.stdout.splitlines(), fake.SSH[1:]) diff --git a/ui/frame_agent.py b/ui/frame_agent.py index ae5a07c..b8a3dbe 100644 --- a/ui/frame_agent.py +++ b/ui/frame_agent.py @@ -1,7 +1,9 @@ """Agent actions and one-use human approvals. No model SDK or network calls here.""" import hashlib +import os from pathlib import Path import secrets +import shlex import shutil import subprocess import threading @@ -129,7 +131,10 @@ def run_script(server, name, args): script = server.HERE.parent / 'scripts' / name if not script.exists() or not shutil.which('zsh') or server.LOCAL: raise ValueError(name + ' requires a computer with zsh and the matching script installed') - result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60) + # The headset the server is routed to, not whatever `frame` means in ~/.ssh/config. + env = {**os.environ, 'FRAME_ALIAS': server.FRAME, + 'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])} + result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env) if result.returncode: raise ValueError(result.stderr.strip() or 'Script failed') return {'message': result.stdout.strip()} diff --git a/ui/frame_macview.py b/ui/frame_macview.py index c277241..084e485 100644 --- a/ui/frame_macview.py +++ b/ui/frame_macview.py @@ -262,12 +262,13 @@ class MacView: """Tries the ports on one route; True once the tunnel answers. With self.lock held.""" last = "" for port in ports: + target = (self.frame, self.host_opts) # retarget() may change these meanwhile # `via` first: ssh keeps the first value of an option, so USB-C's HostName wins # while the headset's pinned identity (in host_opts) still checks it. - proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *self.host_opts, + proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1], "-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N", - "-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame], + "-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) # A taken port makes ssh exit once it's connected; a working @@ -280,6 +281,9 @@ class MacView: if self._probe(port): ok = True break + if ok and target[0] != self.frame: + ok = False # the app switched headset while this one connected: not its tunnel + self._last_tunnel_error = "switched headset" if ok: self.tunnel, self.remote_port = proc, port self.track(proc)