- Every request made with an F-Droid repository's login (entry.jar, index,
APKs), redirects included, now uses the scoped check: same origin and
inside the repository directory by whole plain path segments. A
same-origin redirect elsewhere on the server gets no login.
- _child() refuses file names a server could normalise outside the
repository: double-encoded dots, ';' path parameters, encoded slashes or
backslashes (anything _credentials.path_segments rejects).
- Store page: moveLogin() strips the login from the address with plain
text matching first, then decodes with a fallback to the text as typed,
so '100%done', '%FF' or an address the URL parser rejects can no longer
leave the password in the field.
Tests: outgoing headers for '%252e%252e/...' and '..;/...' payloads and a
same-origin redirect outside the repository carry no Authorization; the
page test covers undecodable and unparseable logins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Artwork: a saved login is only sent for URLs under the repository by whole
path segments. Paths with dot segments (plain or percent-encoded), encoded
slashes/backslashes, double encoding or ';' get nothing, since a server
could normalise them outside the repository. Checked again on every
redirect hop; Handler(scoped=True) does the same for urllib callers.
- Credentials file: every read-modify-write holds an inter-process lock
(source-credentials.lock, flock or msvcrt), so the CLI and the app can't
undo each other's saves or bring back a removed login.
- Store page: a login typed into the address moves into the login fields,
and out of the address field, before the add starts, and stays there if
it fails.
- Rate-limit backoff is keyed per login (anonymous or each token), so saving
a GitHub token after anonymous requests were limited is used at once.
Regression tests: outgoing artwork headers for traversal URLs and a
redirect to one, two processes churning the credentials file, anonymous
limit then token, and the page's login handling in node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F-Droid repositories can now be added with HTTP basic auth, either as
https://user:pass@host/fdroid/repo (as F-Droid's client accepts) or with
separate username/password fields (store form, server API, and the CLI's
--username/--password-stdin). The login is stripped from the saved URL and
kept in a new shared module, apk_sources/_credentials.py, which writes
source-credentials.json in the data folder with mode 0600.
A credential is only ever added per request to its exact origin (scheme,
host, port): redirects elsewhere get nothing and have Authorization
removed. HTTPS only. 401 (or 403 with a login) on add gives a "check the
username and password" error and saves nothing; signature, pin, TOFU,
hash and rollback checks are unchanged. Removing a repository deletes its
login.
GitHub gets a user-settable token (FRAME_GITHUB_TOKEN still wins), sent as
Bearer only to api.github.com. If GitHub answers 401, the request is
retried once without it and the rejection is reported in source settings.
The source list shows only whether a token is set, never its value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_agents had no harness.require() and FrameTestCase started ui/server.py
unconditionally, so plain discovery over tests/e2e started a server on this
computer's real app data, ~/.ssh and cache. FRAME_E2E=1 alone outside the
container did the same with the real cache.
require() now also needs FRAME_E2E_CONTAINER=1 (set in compose.yaml) and the
container runtime's marker file, and says why it refuses; FrameTestCase and
Server.start call it before starting anything; test_agents calls it at
module level. test_sandbox checks discovery over tests/e2e, with and
without FRAME_E2E=1, skips everything and starts no server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/e2e/test_android imported its APK helpers from the sandboxed
test_frame_apk, so the HOME move reached the whole e2e process: frame_connect
wrote keys and config into the temp home while OpenSSH, which finds ~/.ssh
from the password database, kept reading the container user's. The helpers
now live in the sandbox-free tests/apk_fixtures.py, and the e2e harness sets
the data dir, telemetry and compat DB guards itself (it used to get them
through that import).
sandbox.py also points LOCALAPPDATA, APPDATA and the XDG folders into the
throwaway home, so test servers on Windows and Linux no longer warm indexes
or sweep staging folders in the real app cache. test_sandbox checks both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Servers started by test_server's OneServer and LocalMode tests got no
FRAME_CONTROL_SSH_DIR, so each one imported the real ~/.ssh/config into a
fresh throwaway devices.json and seeded a pin into the real
~/.ssh/frame-control-hosts: about a dozen files per suite run.
tests/sandbox.py now gives the whole run a throwaway HOME/USERPROFILE and
FRAME_CONTROL_SSH_DIR (inherited by child processes), every test module
imports it first, and frame_connect.SSH_DIR honours FRAME_CONTROL_SSH_DIR
like frame_devices.ssh_dir(). test_sandbox checks the paths and reruns the
tests that leaked from the original environment, asserting nothing under
the real ~/.ssh changed.
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>
Round-2 review of #83: the display search ran before the usual 5 s property
and geometry reads, and its 30 s memory never helped because the server
starts a fresh `python3 - panels` per request. With X tools answering in
4.6 s the run passed the server's 20 s.
- One xprop call reads all of gamescope's root properties; :0 is read first
and, when it has them, used with no other probe. Other displays are only
asked (2 s each) if :0 has none.
- focus() and focusable() reuse that reading instead of re-reading.
- A `panels` run has one deadline (12 s); every xprop/xwininfo is cut to
what's left, and past it the run returns what it found with a "why"
saying the headset was too slow.
- The display memory is documented as being for the long-lived input helper
only.
- Tests: `frame_touch.py panels` run as a fresh process with fake xprop and
xwininfo on PATH (lists panels; 4.6 s tools return in about 12 s, where
the previous head ran past 60 s), plus unit tests for the single read,
probing, the deadline and partial results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compare the deadline passed on with the caller's own deadline instead of a
fresh time.monotonic(), which failed on windows-latest with
5.000000000000028 > 5 (CI run 38200136884).
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>
- The duplicate-name check now compiles each page's scripts together as one
module with node --check, where a top-level name declared twice is a
SyntaxError. Node's parser handles nested scopes, comments and strings,
which the old line scanner did not. Fixture tests show it catches same-line,
cross-block and const/function duplicates and ignores nested ones.
- The live view's loadPanels no longer logs expected offline or stale poll
failures (as refresh() does).
- After a render failure, openStoreApp skips the details request, and
loadOfferDetails no longer writes to a missing #detailInstallNote, which
threw an unhandled TypeError. store_detail.mjs now covers the full open flow.
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>
Review of #83: the display search shares one 6 s deadline (2 s per display)
and is remembered (found for good, not found for 30 s), so a stalled display
can't use up the server's 20 s. Control now shows a failed panel listing
instead of "Finding the headset's panels..." when the first one fails.
Page test (tests/page/desk_panels.mjs) covers the Desktop listing: the
headset's reason, initial failure and recovery.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking any app in the Android tab opened an empty dialog, which shows as
a single horizontal line (its 1px border). The store's renderDetail was
replaced by the Devices page's renderDetail, declared further down the same
script: a later function declaration silently wins. The store's call then
rendered "Pick a headset." into the Devices panel and left #detailBody empty.
Every platform and both 0.4.x releases were affected.
The store's renderer is now renderAppDetail, and a render failure shows a
clear message with a Close button rather than an empty dialog. The same
clash hid the live view's loadPanels behind the Tools panel switcher's; that
one is now loadPanelSwitcher. Both names match the open store PR (#56).
Tests: a guard that no top-level name is declared twice in the page's
shared script scope, and tests/page/store_detail.mjs, which runs the real
store functions for a good listing and a broken one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #73 (SteamOS 0.4.5): Capture on the Desktop view failed with "no app
panel is open in the headset" while the Plasma desktop was plainly open.
frame_touch.py's panels() came back empty and the page only knew to say
"none open".
- frame_touch.py: read gamescope's properties from :0 first, probing the
other displays if :0 has none; list displays from /proc/net/unix's
abstract X sockets as well as /tmp/.X11-unix; when nothing is found,
return a "why" (X tools missing, listed windows that aren't X11 windows,
desktop running but not listed, or nothing open).
- index.html: the Desktop view's Capture and Control show that reason, or
the listing error, instead of a bare "no app panel".
- server.py: /api/clipboard answers 409 when the headset's desktop isn't
open (plasmashell not running), so it no longer reaches telemetry as an
$exception; other paste failures stay 502.
Fixes#73
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There `bash` is the WSL launcher (no distribution on GitHub's runners), so
the step couldn't run; it only ever runs on ubuntu-latest.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
v0.4.2's first run created the draft with gh release create, then looked its
id up in the release list, which didn't show the fresh draft yet; the PATCH
went to releases/ (404) and the builds were skipped. Now the draft is POSTed
and the id read from the response, after checking the tag exists on GitHub
(what --verify-tag guarded). A rerun still reuses the draft found by tag_name,
and an empty id stops the job instead of PATCHing releases/.
tests/test_release_workflow.py runs the step against tests/fakegh, which now
answers POST releases and applies --jq through jq when installed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Windows, terminate() is TerminateProcess (a hard kill, exit 1, no cleanup);
the app stops the server there by closing stdin. The staging-folder test now
stops it by closing stdin on every platform, and also by SIGTERM off Windows.
Co-Authored-By: Claude Opus 5.5 (1M context) <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>
Leaving through os._exit skips the weakref finalizers that clean up a
TemporaryDirectory still in use by a background thread, so quitting during
a patched VR APK transfer left the APK behind (and likewise a file staged
for the assistant, or a half-downloaded app index in the cache folder).
Those folders now carry the server's PID (frame-vr-, frame-agent-, and
.download- in the apk-sources cache folder), like the web-install and title
staging folders already did. sweep_tmp covers all five; it still runs at
start for dead servers' folders, and with own=True at the end of the
shutdown cleanup for this server's. The exit comment now says which exit
work is skipped and why that is safe.
Tests: the sweep test covers every prefix and own=True; a new server test
stops a server with in-flight folders and checks they are gone, and that a
dead server's are removed at the next start.
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>
Review round 4 of #78: /api/android and /api/titles re-raise frame_android's
FrameError as Failure(str(e)), which dropped frame_link_failed, so their
connection failures were still reported after the status poll's first one.
Both now raise ... from e, and diagnostic() follows __cause__
(frame_telemetry.link_failed). server.ssh() judges a link failure on ssh's
stderr only, never the command's stdout.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Linux with Python 3.13 (GitHub's ubuntu-latest runner, CPython 3.13.16),
about 1 stop in 100 crashed the server with SIGSEGV. A native backtrace
taken at the crash shows background threads inside OpenSSL
(X509_STORE_set_default_paths_ex, called from _ssl while the app-index
download threads build their TLS context) while the main thread was already
in exit(), where libcrypto's own atexit cleanup frees the state those
threads are using.
After the shutdown cleanup has run, the server now flushes stdout/stderr and
calls os._exit(0). Daemon threads (index downloads, stdin watcher,
telemetry, contact, headset link, request handlers) cannot be stopped
promptly, and nothing in the server registers atexit work; the OS frees the
one-server lock with the process. The stop test now runs its scenario five
times with faulthandler on.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 3 of #78: matching ssh's words in the message could still be
spoofed by a download's file name ("Connection closed by frame port 22.zip"
in a checksum failure lost its $exception after a poll timeout). Now
server.ssh and frame_android.ssh mark the exception (frame_host.link_failure)
when ssh itself failed to reach the headset: a time out, or exit 255 with a
line ssh starts (frame_host.ssh_link_failed). diagnostic() holds back only
marked errors in frame_unreachable / frame_not_set_up, once per session;
everything else keeps the 10-minute window. Web-link download and checksum
failures are classified download_failed before any other category.
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>
Review round 2 of #78: FRAME_LINK_FAILED matched its words anywhere, so a
web-link checksum mismatch for a file named kex_exchange_identification.zip,
after a status-poll timeout, lost its $exception. Each pattern is now
anchored to the start of a line as ssh prints it (re.M), and our own
"Timed out talking to HOST" must be the whole line.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>