Three review rounds kept finding ways that matching names, ssh operands and
user@host in one pass shows something the scrubber on main hid (URLs, home
folders, keys). The two #81 edge cases are minor; the scrubber goes back to
main's, and #81 stays open with the findings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 2 of the review: scrubbing the text before matching names stopped a
name the scrubber partly rewrote ("frame.local Jane Doe headset") from
matching whole, and cut the text before names were hidden. Instead the
shared scrubber's user@host patterns are found in the original text as
spans too, so a name can't take their @. A randomised comparison with the
scrubber on main (160,000 cases) found nothing it hid that this shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #81 found two cases that scrubbed less than before: "to hostname X"
left X visible, and a display name ending in "@<host>" took the @ the user
name scrubber needs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to #85 from #87:
- A failure can carry a short reason (SSH identity changed, Saved SSH identity
missing, This computer's key refused). The address that failed shows it
instead of repeating the whole message, so the offline banner no longer
reads "...may be answering here.." or "...must be on).."; the banner also
drops a closing full stop before adding its own and keeps "SSH"/"Frame"
capitalised.
- The header pill leads with that reason (then the countdown), so a narrow
pill cuts the countdown, not the reason. The Devices tab's changed-key and
refused-key explanations wrap; the list row shows the short reason with the
whole message as its tooltip.
- The header's pill and buttons sit in one group pushed right with tighter
gaps; the pill and headset picker give way, the buttons don't, and the
shortcut hints hide at 1366 px and below. Refresh no longer overhangs at
1280 px.
- Use this headset selects the headset it switched to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to #79. Known names and the hosts ssh names are now located as
spans in the text as it came, overlapping spans merged, and replaced in one
pass. A display name containing a scrubber token ("<host> Jane Doe headset")
now goes whole, and a name like "to host" can no longer hide ssh's
"connect to host X" operand. Tokens on their own are still left as they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Every word of the hardened ProxyCommand is quoted for the shell ssh runs
it with (shlex.quote; list2cmdline on Windows) and its % doubled for
ssh's token expansion. The ssh path is always the absolute one, spaces
and all; an ssh at a path with $(), backticks, quotes or ; no longer runs
shell code.
- The forward is -W [%h]:%p (an IPv6 headset), and an IPv6 jump host is
passed bare with -p.
- The stand-in ssh now runs ProxyCommand as OpenSSH does (tokens, then
/bin/sh -c exec), with regressions for hostile paths and IPv6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Key check through a ProxyJump host: build the hop as our own ProxyCommand
(no agent/X11/port forwarding, no LocalCommand or RemoteCommand, no tty,
no further proxy), with -F none for the device; ProxyJump's own ssh would
inherit ForwardAgent and LocalCommand from ~/.ssh/config. Refuse
ProxyCommand and multi-hop routes, which can't be hardened.
- Identity history is judged on the current registry (named_route used a
frozen copy and fell back to accept-new).
- A key ssh saves on first contact is recorded, made private and the route
tightened as soon as it's saved, even if the login is then refused, and
for keys commands save (the loop settles them every tick).
- identity_established is a device-level flag that never clears (migrated
from last_ok), so editing or removing the address that worked can't make
a headset new again.
- The handshake waits for its reader's end marker before taking ssh's exit
as the end, so a late "Permission denied" isn't lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Key check: ssh writes the key during the key exchange and is stopped at once;
no authentication, agent, forwarding, commands (-N), terminal or local
command, all forced ahead of ~/.ssh/config, and -F none unless a jump host
needs the config. A device accepting "none" gets nothing.
- accept-new only for a headset that never had an identity: the saved key's
fingerprint is recorded in devices.json, and a recorded identity, an
existing pin file or a connection that ever worked makes a deleted, empty,
unreadable or unparseable pin fail closed ("missing or can't be read").
- forget-identity is refused (it let the next contact save any key unseen).
- A key read before the headset's port, user or that address changed can't be
trusted.
- Without a ControlMaster (Windows) the handshake's stderr goes to a file read
as it grows, not a pipe.
- Pin files and the check's throwaway known_hosts get make_private.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.4.2 sent $exception reports from the status poll (POST /api/comfort status,
frame_auth) on Linux and Windows for three states the person could not fix from
the app:
- A changed host key (REMOTE HOST IDENTIFICATION HAS CHANGED): the advice was
"Run Set Up Connection again", which checks ~/.ssh/known_hosts and never
replaces the headset's pinned key; Forget identity trusted whatever answered
next without showing it.
- No pinned key ("No ED25519 host key is known for frame-control-<id> and you
have requested strict checking"): every command said
StrictHostKeyChecking=yes, so any command that ran before (or without) the
connector's first handshake saving the key failed.
- Permission denied (publickey,password): not something a retry fixes.
Commands now use accept-new until a key is saved and yes after (accept-new
never replaces a saved key). A changed key fails at the identity stage with its
fingerprint; the banner and Devices tab offer "Check the new key", which reads
the key with a throwaway known_hosts file and no login, shows it beside the
saved one, and pins exactly that key only when the person confirms. Refused
keys point to Set Up Connection. ssh's own host key and login refusals are
marked as link failures and frame_auth joins the expected categories, so each
is reported at most once a session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-4 review (pr79-review-r4), opt-in log only:
- The headset's own names are replaced whole (longest first, any case) before
ssh's hostname/host/to operands, so "talking to Jane Doe's work headset"
can't be cut to "<host> Doe's work headset".
- Only the scrubbers' own tokens (<host>, <user>, <ip>, ...) are exempt from
name replacement, so a display name like "<Jane Doe>" goes too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round-3 review (pr79-review-r3), opt-in log only:
- Each attempt's redaction set now includes the headset's ssh alias and
display name (any case), and "has no addresses" no longer names it.
- ssh's hostname/host/to operands are redacted before the known names, known
names never replace inside an existing <placeholder>, and names that are
ssh's own words (host, hostname, to, port) aren't treated as names.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round-2 review (pr79-review-r2):
- The summary names the alias only as default ("frame") or custom, and the
~/.ssh/config line says "for the active alias" without naming it.
- ssh -V is parsed only as a banner at the start (OpenSSH_N.N[pN], optional
LibreSSL/OpenSSL N.N.N) and rebuilt from fixed names and numbers; anything
else is "unknown".
- scrub: ssh's whole user@host field (spaces, DOMAIN\ prefix, full domain
names, "user@host's password:") goes before the email rule; a home folder's
whole name runs to the next separator (apostrophes too), prefixes matched
whatever their case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Independent review (pr79-review-r1) found leaks in the first version: a
failure's raw text was hidden with the current headset's hosts (so switching
headsets let the old one's name through), unknown hosts and other cases
survived, Windows user names with spaces partly survived, the PATH scan ran on
every preview, and the log throttle only caught consecutive repeats.
- The always-on summary has no free text: the current error and last failure
are a stage plus the telemetry error category (decided when the failure
happens) and how long ago. ssh -V is reduced to its version words.
- The opt-in server log line is scrubbed when written, with that attempt's
hosts (case-insensitive), any name ssh gives after hostname/host/to, and
then the shared scrubber.
- frame_telemetry.scrub: a Windows home folder's whole name (spaces too) and
the whole user part of ssh's user@host (spaces, DOMAIN\user) become <user>.
- The ssh discovery runs once in the background from server start; a report
waits at most 0.3 s for it.
- The log throttle keeps per-failure timestamps and counts, bounded to 32.
- Regression tests for each case; privacy.md says exactly what's sent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three Windows reports said "ssh frame works in the terminal, but the app can't
find the headset", and their diagnostics held only versions: activity and the
server log are opt-in, and the connector never logged its failures anyway.
- frame_report.diagnostics always adds a connection summary (under "Include
diagnostics"): connector phase, failed stage, attempt and reason; headset
count and active alias; the current error and last failure plus ssh's last
line, with the headset's addresses/hosts and user@ hidden and then the usual
scrub; each address tried as its kind only (.local, ipv4, ipv6 link-local,
tailscale, hostname, alias, and what a name resolved to) with its probe
state; gateway/Tailscale; the ssh the app runs by kind (never its path),
`ssh -V`, other ssh kinds on PATH; and whether ~/.ssh/config has the managed
block and a hand-written Host for the alias.
- frame_link prints each failed attempt (stage, message, ssh's last line,
address kinds) to stderr, scrubbed, so it lands in server.log; a failure that
repeats every retry is written at most every 5 minutes.
- docs/privacy.md and the report dialog say exactly what the summary contains.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ssh's %C hashes only address, user and port, so two headsets reached at one
address shared a ControlMaster and one's commands could run on the other: the
ControlPath now names the headset. Another Frame Control server choosing a
different headset no longer moves this one's commands mid-install.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- While the connector is taking a queued reconnect off its list, the old
connection no longer counts as live, so no install starts on it.
- Importing a block without a Port takes the port ssh would really use
(ssh -F <config> -G), e.g. one a later Host * sets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Behind a jump host, a forward it couldn't open moves on to the next address;
only a refused key stops (judged by ssh's words, not the step).
- Add a headset suggests an alias no Host in ~/.ssh/config already uses.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A jump host's own "Authenticated to" line no longer counts as the headset's,
and a master that logs in but doesn't start lets the next address be tried.
- Devices tab changes refresh through the ordered list load, so a late answer
can't undo a newer selection.
- A probe's time out starts after the name lookup: macOS can take 5 s to look
up a .local name (found on the real Frame once its USB link went away).
- An attempt's ending is published from a method, not a return in finally.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A switch the server refuses no longer drops answers the page is waiting for
(an install's job id): only an actual change of headset does.
- Test now goes through a jump host when the alias uses one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A set-up headset whose alias goes through a jump host (ProxyJump or
ProxyCommand in ~/.ssh/config) is reached through it, address by address,
still pinned per headset.
- A refused switch puts the header's switcher back on the headset in use.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A command that fails after a switch doesn't make the connector drop the new
headset's connection.
- On first import, the app keeps using the `frame` headset even when Set Up
Connection put another block above it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Terminals, power and a reconnect's probes use the route commands have now
(a pinned bare-alias destination, a login change still deferred).
- Saving port 22 keeps an explicit Port line where there was one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A bare alias's route is pinned to where ~/.ssh/config sent it when it was
routed (HostName, Port, User), so editing that file can't move an install.
- Renaming during an install is allowed: only a real user or port change waits.
- The Devices tab follows a network change even while the headset is offline.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Retry now (while connected) and Forget identity wait for running installs.
- Terminal windows get the headset's address by name, so a link-local IPv6
zone never has to pass through Windows' console.
- Renaming the headset in use shows at once in the header.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Every command to a set-up headset checks its pinned key
(StrictHostKeyChecking=yes, whatever ~/.ssh/config says); only the
connector's first handshake may save one.
- A reconnect during an install keeps the whole route it started with, also
when a bare alias is set up meanwhile.
- Removing the headset FRAME_ALIAS named doesn't bring it back as a bare alias.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A reconnect while an install runs keeps the login it started with; a new
one from ~/.ssh/config applies after.
- Frame > Open SSH goes through the server, so it uses the same headset and
address as the app and refuses when there's none.
- A bare frame alias in use when a headset is set up stays selectable.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A headset set up while a bare alias is in use doesn't take over by itself;
a login change from ~/.ssh/config waits for running installs.
- Saving a headset writes only the login fields that changed, and only if the
block still holds the old ones.
- SSH, SFTP, power and remote desktop open with the same headset and address
as every other command, and refuse when there's no address.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Removing or moving the active headset's address waits for running installs,
like switching.
- A volume change still waiting to be sent goes to the headset whose slider
it was, and a switch cancels it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A title waiting its turn to be read stays with the headset it was dropped
on, and is dropped if the app switches meanwhile.
- Probes still finishing from an earlier attempt can't overwrite the rows of
a newer one.
- The FRAME_ALIAS the server started with stays on the list after switching
away, so it can be picked again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A batch of dropped files stays with the headset it was dropped on, and
stops if the app switches.
- Removing or moving the address in use reroutes at once; a headset with no
addresses reaches nothing rather than whatever ~/.ssh/config says.
- A late answer to an older device-list request is ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Removing every headset leaves none in use (commands fail at once) instead of
falling back to the `frame` alias.
- ssh goes to the IP that answered for IPv6 too, with a link-local address's
interface (verified: frame.local over fe80::…%en9 on the real Frame).
- Find results only show in the panel of the headset they were for.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The headset a change is meant for is checked and the work counted in one
step, so a switch can't slip in between (uploads too).
- A sideloaded title read on one headset can't be installed on another; open
confirmations close on a switch.
- The only headset can't be removed while its ssh alias stays behind.
- Answers about the previous headset are dropped without touching panels; the
catalogue's Installed tags are rebuilt for the new headset.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A switch publishes the new headset at once, so the page clears the old one's
panels and the lists behind them (games, store, Android apps, screenshots).
- The page names the headset its changes are for (X-Frame-Device); the server
refuses one meant for a headset it has switched away from (409).
- A rejected address edit changes nothing.
- Test now goes to the IPv4 address that answered, like the connection.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Attempts carry a generation: one overtaken by a switch, a removal or a login
change routes nothing back to the old headset and can't report connected.
- Removing the active headset or changing its user/port reroutes at once,
before anything that can fail.
- One known_hosts file per headset (~/.ssh/frame-control-hosts/<id>):
forgetting one headset's key can't drop another's, whoever else writes.
- learn() checks, under the config lock, that the block is still what the
attempt started from before writing to it.
- A switch stops live video and drops captures from the previous headset.
- A probe shares its time between the addresses a name resolves to.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Switching headsets is serialized with the start of any install; background
work counts as running from before its thread starts. use() reroutes every
command at once and makes ensure() wait for the new headset.
- A new user or port reroutes commands even if the attempt then fails.
- ~/.ssh/config edits take a lock file shared with Set Up Connection
(frame_connect.py and connect.sh, which now also writes atomically).
- A finished attempt no longer writes its older settings over a change Set
Up Connection made meanwhile.
- Pin edits are locked and swapped atomically.
- A bare alias behind ProxyJump/ProxyCommand is left to ssh to reach.
- The page drops answers about the previous headset after a switch; the
header switcher takes clicks in the macOS title bar.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- No switching headsets (or changing the active one's user/port, or removing
it) while installs run: they read the ssh settings step by step.
- Switching reroutes every command to the new headset at once, even if it
never answers.
- ~/.ssh/config edits are serialized, use unique temp files, and back off if
another program wrote the file meanwhile.
- stop() ends a handshake in progress and joins the connector.
- Pinned keys are written unhashed (HashKnownHosts=no); hashed ones are still
found and forgotten via ssh-keygen.
- Set Up Connection changing a headset's user or port updates the registry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Frame Control can now manage more than one Steam Frame, and reach each at any
of several addresses (LAN IPs per network, its .local name, Tailscale). A
connector in the server tries them all at once, picks the best one that
answers, follows ssh -v through each stage (network, finding, SSH, identity,
login) and streams that to the page. The header shows it live; a new Devices
tab (key 5) manages headsets, addresses and network names.
- ui/frame_devices.py: registry in devices.json, imported from the managed
~/.ssh/config blocks; per-headset host key pinning; config block updates.
- ui/frame_network.py: gateway IP+MAC fingerprint, Wi-Fi name, Tailscale.
- ui/frame_link.py: the connector, Test now, Tailscale/mDNS discovery, API.
- server.py: ensure_master delegates to the connector; /api/connection,
/api/connection/events (SSE), /api/devices.
- Electron: headset switcher and Devices item in the Frame menu.
- frame_connect.py --alias; FRAME_CONTROL_DATA_DIR / FRAME_CONTROL_SSH_DIR
keep tests off real data.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>