Skip to content

fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) - #3800

Open
btli wants to merge 72 commits into
omnigent-ai:mainfrom
btli:android-proxy-auth
Open

fix(android): sign in to servers behind front-door auth proxies (Databricks Apps)#3800
btli wants to merge 72 commits into
omnigent-ai:mainfrom
btli:android-proxy-auth

Conversation

@btli

@btli btli commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Related issue

Closes #3799

Summary

The Android shell cannot sign in to a server deployed behind a hosting platform's front-door auth proxy (Databricks Apps; architecturally also Cloudflare Access, EasyAuth, oauth2-proxy). Two user-visible symptoms, one cause: switching to such a server appears to do nothing — the previous server's page stays painted — and sign-in never completes, with no error shown.

The edge 302s every path to its IdP, including POST /auth/cli-login. The shell misread that bounce as omnigent's own OIDC redirect: it cancelled the navigation (leaving the stale page rendered) and started the native browser login, whose ticket request was itself 302'd into IdP HTML and failed silently.

This is not a one-line fix, because the front door binds the flow to the cookie jar that starts it — its first 302 sets an HttpOnly; SameSite=None CSRF cookie — and Android has no browser→WebView cookie import. The flow must therefore complete inline in the WebView, which means the shell has to tell a front-door bounce apart from its own OIDC bounce and stay in that flow without turning into a general-purpose browser.

What this PR does

  • Classifies front-door bounces (Origins.isProxyAuthUrl): an off-origin URL whose redirect_uri returns to the pinned origin on a path other than omnigent's own /auth/callback. omnigent's own bounce keeps the RFC 8252 system-browser path unchanged.
  • Replaces the in-flight boolean with an explicit state machine (IDLE / IN_FLIGHT / REFUSED) and a five-row dispatch table, so a proxy-shaped URL can never fall through to the branch that cancels the navigation — the structural bug behind symptom 1.
  • Bounds the flow: four terminal exits (main-frame error, HTTP error, SSL error, renderer gone), URL-guarded against late callbacks from a previous server, plus a lazy 6-minute deadline derived from the edge's ~5-minute CSRF TTL. An abandoned flow can no longer leave the shell loading arbitrary sites inline.
  • Degrades gracefully when an IdP refuses embedded WebViews: a dialog naming the host that offers to open a real browser (resolved with a bare-scheme probe that excludes this app and non-browser deep-link handlers), plus a permanent "Trouble signing in? Open in browser" item in the server switcher that works regardless of flow state.
  • Removes the user-agent masking added by the earlier commits on this branch. It was measurably broken — the mask/restart sequence looped indefinitely — and it spends a third party's compliance risk: Google's "use secure browsers" policy attaches to the OAuth client, which in enterprise SSO belongs to the hosting platform or the customer, not to us. Accepted cost: any proxy/IdP combination that would have accepted a masked UA now needs the browser fallback.
  • Fixes pre-existing silent failures in OidcLoginManager: a malformed poll payload was retried for the full five-minute deadline, and start() after shutdown() threw on the main thread while leaving the in-flight flag stuck true. Login now reports a LoginResult (Success / Rejected / TimedOut), and failures surface a "Couldn't sign in to <host>" dialog with Retry instead of only a logcat line.

Defects found by adversarial review during this work, each with a regression test: a login result was not bound to the server that started it, so switching servers mid-poll installed the previous server's session token as the new server's cookie; a completed flow could deliver its result to a different flow's callback; a server switch during a login stranded the new server with no surface and no retry; a renderer crash killed the app because onRenderProcessGone returned false and nothing rebuilt the WebView; and a late setCookie acknowledgement could reload the previous server after a switch.

Second wave: multi-engine bug scan of the whole Android shell (commits ac7b8c24..3c45c8cb). A five-engine adversarial scan (codex gpt-5.6-sol xhigh + parallel Claude reviewers) of the app produced 2 HIGH / 13 MEDIUM findings; the HIGHs and every cross-server MEDIUM are fixed here, then the fixes themselves went through seven review rounds until both engines attested with no findings:

  • Cross-server lifetimes. Notification activations now carry the origin that posted them and are dropped (and stale notifications cancelled) after a server switch — including across Activity recreation, "Don't keep activities", and process-death intent replay, where per-site guards provably fail. pendingNavigatePath and login results got the same treatment: browser logins live in a process-scoped OidcLoginManager with explicit attach/detach, so a login finished in the browser lands after recreate(), but a result from a previous server can never install its session on the new one.
  • WebView lifecycle. A crashed-renderer WebView is destroyed and never reused; any late navigation triggers a clean recreate(). The file chooser refuses to open unless the main frame is still on the pinned origin.
  • Downloads rebuilt. DownloadManager re-applies custom headers after a cross-host redirect (AOSP DownloadThread), leaking the session cookie to wherever a redirect chain ends. Downloads now run in an own pinned-origin downloader under WorkManager: manual redirects with a monotonic cookie-drop rule (once a hop leaves the pinned origin the cookie never comes back), front-door auth bounces and terminal HTML-as-PDF responses rejected as "sign in again", 408/429/Retry-After honored, long-running dataSync foreground execution with stop handling, a persisted three-stop bound plus an eight-lives entry gate that also bounds process-death restart loops (verified against the shipped WorkManager 2.11.2 bytecode: every run start increments runAttemptCount), and MediaStore idempotency across process death via a capped, eviction-safe operation journal. Outcomes stay visible when notifications or just the downloads channel are blocked (toast fallback with a persisted replay).
  • The session cookie is read only at execution time and never enters WorkManager's persisted Data.

Accepted residuals, documented in code: no Range resumption (retries restart from byte zero, now bounded); the file-chooser gate is main-frame-only (FileChooserParams exposes no requesting-frame origin); Play will require a dataSync FGS declaration and Android 15+ steers user-initiated transfers toward UIDT.

Test Plan

  • cd web/android && ./gradlew testDebugUnitTest221 tests, 0 failures (up from 21 on main, 121 after the first wave). Needs ANDROID_HOME and Android Studio's JDK 21.
  • Every new test is mutation-checked: 28 mutations, each reverting one load-bearing rule, each turning exactly its paired test(s) red. The second wave added ~25 more mutation-verified rules the same way; the two disclosed exceptions (one property pinned at the defense-in-depth ensemble level, one lock-atomicity fix with no observable interleaving seam) are called out to reviewers rather than papered over. This caught a genuinely vacuous set — the deadline tests used a fake clock starting at 0, the same value the flow-start field defaults to, so deleting the start-time stamp left the suite green while a real device (uptime is never 0) would have expired every flow the instant it entered.
  • pre-commit run --all-files — both Android ktlint hooks pass. (web prettier / web oxlint fail identically on unmodified main and cover only web/** TypeScript, which this branch does not touch.)
  • Live on an emulator against the real Databricks Apps deployment: fresh install → connect to the front-door server → the tenant login page renders inline in the WebView (proxy-auth nav … loading inline, then proxy-auth landing … loading inline). Against omnigent.joyful.house: cli-login -> ticket ok, opening login in browser, and Chrome comes to the foreground at the IdP — the system-browser path is unchanged.
  • The device install also caught a bug no unit test could: the <queries> block declared two <data> schemes inside one <intent>, which Android's package parser rejects outright (INSTALL_PARSE_FAILED_MANIFEST_MALFORMED) — the APK would not install.

Not verified: completing a sign-in end-to-end. The test deployment's IdP federates to Google and account policy blocks that leg here, so validation stops at the login page rendering in the right place. The disallowed_useragent refusal detector is likewise unverified against a live refusal; it is documented as such, and the menu escape hatch means a missed detection never strands the user.

  • Adversarial ship gate: seven review rounds on the second wave (codex gpt-5.6-sol xhigh + independent Claude reviewers, re-reviewing after every fix). Rounds 1–6 each found real defects — including an unbounded stop→restart loop, a session-cookie path to third parties, an executor-starvation mode, and a duplicate-download race — all fixed and mutation-pinned. Round 7: both engines attest with zero findings on the final SHA.

Demo

Emulator, this branch's APK, against the live deployments.

Launching against the front-door server — the Databricks tenant login page renders inline in the WebView, with the server pill still showing the pinned host. The shell classified the edge's 302 as a front-door bounce rather than its own OIDC redirect (proxy-auth nav … loading inline, then proxy-auth landing … loading inline) and stayed in the flow instead of cancelling the navigation:

Front-door server (Databricks Apps) Own-IdP server (joyful.house)
The tenant login page renders inline in the WebView — the server pill still shows the pinned host. Before this change the switch left the previous server's page painted and sign-in died silently. The non-proxied server is unchanged: cli-login succeeds and the system browser opens at the IdP (RFC 8252).

The permanent escape hatch in the server switcher, available regardless of flow state:

Connect-time origin gate (review-round hardening)

Recorded on the emulator against a local omnigent server. normalizeServerUrl
now requires the pinned-origin canonicalizer to accept the URL, so inputs the
gate can't canonicalize fail at connect time with a visible error instead of
persisting a server whose null pin makes the fail-closed page gate stop every
load (a mute blank screen). Shown: a port past the 16-bit range
(example.com:99999999999, which also overflows Uri.parsePort to -1), then a
malformed port (good.com:notaport), then a successful connect that loads the
web UI with the pinned host on the server pill.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • UI / frontend change
  • Documentation update

Test coverage

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual verification completed
  • Not applicable

Coverage notes

Manual verification covered both routing outcomes on an Android emulator — the live Databricks deployment and a non-proxied server — as described in the Test Plan. Sign-in could not be completed end-to-end because the IdP's Google leg is blocked by account policy here, so the post-login session path and the refusal detector remain manually unverified; both are covered by unit tests against synthetic inputs.

@btli

btli commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Marking this ready for review. Two things it needs that I can't close from a fork, and one honest gap in verification.

Still needs a live test on a managed device

I cannot side-load this build into a work profile, so the front-door flow is verified only up to the point where the tenant login page renders inline in the WebView. The test deployment's IdP federates to Google and account policy blocks that leg here, so these remain unverified end to end:

  • Completing a sign-in through the front door and landing back on an authenticated SPA.
  • The disallowed_useragent refusal detector against a real refusal. It is documented as unverified; the permanent "Trouble signing in? Open in browser" item in the server switcher means a missed detection never strands the user.
  • The rebuilt download path against a real front-door-served file (the unit tests cover the logic, but not a live edge).

Anyone with work-profile access can validate in a few minutes:

  1. Install the branch build into the work profile and connect to the Databricks-Apps-hosted server.
  2. Expect the tenant login page inline in the WebView, with the server pill still showing the pinned host — then complete the sign-in and confirm the SPA loads authenticated.
  3. Connect to a server on its own IdP (non-proxied) and confirm the system browser still opens for that flow — RFC 8252 behavior is deliberately unchanged.
  4. Download a file: expect a "Downloading…" foreground notification and the file in Downloads via MediaStore. Then let the session expire before tapping a download and confirm it fails with "sign in again" rather than silently saving the login page as the file.

CI never runs the Android unit tests

This branch takes the Android suite from 21 to 221 tests, but no workflow runs testDebugUnitTest. The only Android PR check is Build unsigned AAB; ktlint runs through pre-commit, and web/android/bin/ktlint.sh exits 0 when ktlint is absent. So these tests — and the invariants they pin — can regress silently on any future PR.

I have a working job for this, verified green on a GitHub runner in 2m52s, but it can't land through this PR: .github/workflows/* is FAIL-tier in sensitive-paths.sh, which is correct, so it needs a maintainer to author it. Happy to hand over the patch, or it's a small addition to the existing android-bundle.yml (its pull_request triggers already cover web/android/**):

  test:
    name: Unit tests
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: web/android
    steps:
      - name: Check out
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0  # v7.0.0
      # Robolectric's SDK provider rejects JDK 17 for these compileSdk levels:
      # every test class fails at DefaultSdkProvider. The app still targets
      # Java 17 bytecode; only the JVM running Gradle needs to be newer.
      - name: Set up JDK 21
        uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9  # v4
        with:
          distribution: temurin
          java-version: 21
      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947  # v4
        with:
          cache-read-only: false
      - name: Run unit tests
        run: ./gradlew testDebugUnitTest --no-daemon --console=plain

The JDK 21 line is the part worth keeping: copying the sibling build job's JDK 17 looks right (the app targets Java 17 bytecode) and fails — under 17 every Robolectric class dies with UnsupportedOperationException at DefaultSdkProvider.java:170.

One open design question for maintainers

The user-agent masking added by the earlier commits on this branch is removed here. It was measurably broken (the mask/restart sequence looped indefinitely), and it spends a third party's compliance risk: Google's "use secure browsers" policy attaches to the OAuth client, which under enterprise SSO belongs to the hosting platform or the customer rather than to us. The accepted cost is that any proxy/IdP combination which would have accepted a masked UA now needs the browser fallback instead. If you'd rather keep masking behind a flag, say so and I'll rework it.

@btli
btli marked this pull request as ready for review August 4, 2026 02:58
@github-actions
github-actions Bot requested a review from serena-ruan August 4, 2026 02:59
@btli

btli commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Note on the red checks here, so they aren't mistaken for this branch: Pre-commit (ruff, pyrefly) and all three E2E UI shards fail because of a regression on main, not because of anything in this PR. This branch changes only Kotlin under web/android/ — it contains no Python at all.

main's tip (2ce9c60, #3783) calls _spawn_archive_stop(...) in routes_core.py without importing it, so archiving a session raises NameError → 500. That single omission accounts for every red check here:

  • ruffF821 Undefined name '_spawn_archive_stop'
  • pyrefly → same undefined name
  • E2E UI shardstest_archived_project_filter_load_more_pages_through fails on the 500 from the archive PATCH

One-line fix filed as #4012; these should go green once it lands and this branch is rebased or re-run.

The Android-specific signals are green: Build unsigned AAB passes, ktlint passes, and the module's 221 unit tests pass locally (cd web/android && ./gradlew testDebugUnitTest, JDK 21 — see the note above about there being no CI job for them yet).

btli added a commit to btli/omnigent that referenced this pull request Aug 4, 2026
@btli
btli force-pushed the android-proxy-auth branch from 621fd80 to e2d9857 Compare August 4, 2026 09:26
@btli

btli commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

CI update after the archive fix landed on main (#3943): Pre-commit and two of three E2E shards are now green on the re-run.

The one remaining red, E2E UI Tests (shard 2/3), fails on tests/e2e_ui/chat/test_codex_goal_mode.py::test_codex_goal_mode_with_mocked_responses — a web-chat Playwright test this Kotlin-only branch cannot affect. The same test is failing with the identical assertion on other branches right now (e.g. fix/android-right-rail, same hour), so it is currently red across PRs on main's tip. Once that settles upstream, a re-run should bring this PR fully green apart from the Maintainer Approval gate.

@btli

btli commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 3 commits addressing the adversarial-review findings:

  • The session cookie is now HttpOnly (matching the server's own attribute).
  • Permission, file-chooser, download, and bridge gates deny when no origin is pinned, instead of comparing null == null.
  • originOf canonicalizes IDN hosts to punycode, and rejects malformed ports per WHATWG — Android's Uri keeps :notaport inside host, so https://good.com:notaport used to come back as its own "origin". (Caught by the suite's first real Robolectric run.)
  • DownloadManager.enqueue failures surface as failed downloads instead of crashing.
  • Origin switches ignore WebViewClient callbacks from the previous navigation until the new origin's first page starts.

Verification: Android unit suite 240/240 on JDK 21; ktlint clean.


Recommended merge order — this PR is one of seven fixed and verified together; the fully integrated reference (all cross-PR conflicts resolved, 5,003 web tests + 273/273 Android tests green) is btli:test-android-ui-integration @ 3fa45ff3.

Android track (suggest landing first — security fixes, and the later two resolve against it):

  1. fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) #3800 — no dependencies
  2. fix(web): keep the Workspace rail clear of the OS status and nav bars #3587 — rebase after fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) #3800: it picks up applySystemBarContrast(Configuration), which fixes this branch's one pre-existing Robolectric failure; and since main now has flush rails (feat(web): make the rails flush boxes and move the canvas gradient #4020), the rebase should also drop the stale +16 from --workspace-panel-offset (see integration commit 8b687d7c)
  3. fix(android): keep the server switcher centred over the chat column, clear of header controls #3589 — after fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) #3800: both add constructor callbacks to OmnigentBridgeListener; union them (resolution in integration merge 3fa45ff3)

Web track (file-disjoint from the Android track; order within it matters):
4. #3985 — base swipe-actions feature
5. #4060 — folder context menu
6. #4057 — mobile ungroup drop zone
7. #4065 — hold: superseded by the unified row-gesture recognizer, which builds on #3985 and interacts with #4057/#4060; after 4–6 land, update it to the recognizer commits (69a8ada5, bf597b87, e388aacf, reachable on the integration branch)

@btli

btli commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

E2E coverage note for the E2E UI Required gate: this PR's diff is entirely web/android/** (native WebView shell: origin pinning, OIDC login manager, download worker) — there is no web-app surface the Playwright tests/e2e_ui harness can drive, and the flows need a real Android WebView plus a front-door auth proxy.

Local-run proof: the PR ships an extensive Robolectric/JVM suite covering every changed path (origin canonicalization incl. UTS-46/IPv4/IPv6/port edges, cookie path scoping per endpoint and per redirect hop, fail-closed null-pin page starts, OIDC flow-slot race, download hardening). cd web/android && ./gradlew testDebugUnitTestBUILD SUCCESSFUL, 260+ tests, 0 failures (Java 21 / Robolectric SDK 36).

Requesting a maintainer apply the skip-e2e-ui-test label per the gate's waiver path.

@btli
btli force-pushed the android-proxy-auth branch from e37110f to 0b6d77a Compare August 6, 2026 01:41
btli added 7 commits August 6, 2026 10:44
On a tablet or unfolded foldable the right Workspace rail rendered under
the status bar, leaving its tab icons un-tappable, and ran under the
gesture-nav bar at the bottom.

The rail is `md:m-2` and only renders at md+, but every native-shell
inset rule lives inside `@media (width < 48rem)` — so none of them reach
it — and it is absent from the shared panel selector lists on both the
CSS and Android-injected sides. Add the safe-area margins outside the
width gate, and mirror the same declaration into the Android injected
sheet so shells pointed at an older web build get the fix too.

Uses --omnigent-safe-*, not --omnigent-inset-*: the latter folds in the
native bottom-bar footprint and would double-count.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
The panel docks as a full-height rail at md+ but was missing from both
inset selector lists, so it had no safe-area padding at any width. It is
reachable only in debug mode, which is why it went unnoticed.

Pre-existing gap, adjacent to the Workspace rail fix rather than part of
it.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
A media-gated override appearing later in index.css with the same
selector previously slipped past the ancestry check, which only
inspected the first matching rule. Assert over every rule that sets
the Workspace rail's margins so a later override at md+ fails the
suite instead of silently winning the cascade.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
The safe-area assertion read only the first matching rule, so a second
top-level rule could reintroduce the composite inset vars — double
counting the native bottom bar — while the suite stayed green.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Only the longhand properties were matched, so a shorthand `margin`
declaration set both edges without ever being checked for an enclosing
at-rule.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Matching the exact selector text let an equivalently-spelled one through,
and checking only the first rule let a later override zero the margins
without naming a banned variable. Match on the rail's aria-label and
require the margin rule to be unique.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
btli and others added 29 commits August 6, 2026 12:05
…eir origin

Adversarial review found three shipping defects. A renderer crash left
onRenderProcessGone returning false, which kills the app and leaves an
unusable WebView; the client now reports it and MainActivity recreates.
A login result carried no origin, so switching servers mid-poll
injected the previous server's session JWT as the new server's cookie
— results are now bound to the origin that started them and dropped
when it no longer matches. Rejections and timeouts showed a transient
toast with no way forward; they now show a generic retry dialog that
restores the attempt budget, distinct from the browser-required
refusal dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
setCookie acknowledges asynchronously, so a server switch landing
between the write and its callback reloaded the previous server while
pinnedOrigin already named the new one, leaving the WebView and the
host disagreeing about which server is displayed. The continuation now
lives behind onSessionCookieSet, which re-checks the origin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
A completed login delivered through a shared callback field, so a
second login starting between completion and delivery received the
first flow's result — the previous server's token installed as the new
server's session. Each flow now owns its callback and a cancellation
generation. A server switch also left the old login in flight, which
made the new server's sign-in a no-op with nothing on screen; switching
now cancels the abandoned flow so the new one can start immediately.
The refusal and generic-failure dialogs no longer stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Login could stop responding with nothing on screen: an exhausted retry
budget, a malformed session token and a rejected cookie all returned
silently. Each now shows the generic failure dialog, whose Retry
restores the budget. Renderer-crash recovery is capped at two
recreations (reset once a pinned page loads) so a renderer that dies on
every load reports instead of spinning. The poll deadline moves to a
monotonic clock, non-http links still hand off while the refusal dialog
is up, and the refusal copy now says that signing in via the browser
does not sign you in inside the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
The cancel test only covered a flow interrupted mid-poll, so the
suppression half stayed green even with the generation check removed.
This drives the real interleaving: the flow completes and posts its
result, cancel lands before the looper drains it, and the abandoned
callback must not run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
The browser-visibility <queries> block put both http and https <data>
schemes in a single <intent>, which the package parser rejects — the
APK failed to install with INSTALL_PARSE_FAILED_MANIFEST_MALFORMED.
Robolectric does not parse the manifest this way, so only a device
install surfaces it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
…ly once it lands

Past the recreation cap the shell showed "Couldn't sign in" for a
renderer crash and offered a Retry that ran a login against a defunct
WebView; a renderer failure now has its own dialog whose action
rebuilds the shell, and it is no longer suppressed by a showing
refusal dialog. The "Signed in" notification and the reorder-to-front
moved behind the cookie acknowledgement, so a rejected cookie no
longer reads as success followed by failure. A same-package test
subclass finally drives the real onReceivedError override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
… them

A notification's PendingIntent carried only the navigate path, and a server
switch cancelled nothing and left pendingNavigatePath set. Tapping server A's
stale notification after switching to B therefore routed A's conversation id
into B's SPA, and the single badge slot showed A's count while pinned to B.

Stamp the posting origin into every activation intent and drop an activation
whose origin isn't the current pin; cancel outstanding notifications and clear
the pending activation on switch. The launch intent's extras are now consumed
when read, so a recreate() can't replay an activation the SPA already handled,
and notification ids come from persisted state so a new Activity or process
can't reuse an unread notification's id.

Also fix a crash on the blob path: onDestroy shut the saver's executor down
before removing the bridge, so a download message already queued on the UI
handler hit a dead executor and threw RejectedExecutionException out of a
Handler callback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
The login manager was an Activity field and onDestroy shut it down, so any
recreation while the user was away in the system browser killed the poll. The
likeliest trigger is a renderer crash of the backgrounded WebView -- exactly
what Android reclaims when the user leaves -- which routes to recreate(). The
user finished signing in and nothing happened: no cookie, no notification, no
error, and returning popped the browser a second time.

Scope the manager to the process and have each Activity attach an origin-bound
callback, so a result completing while nothing is attached is held and replayed
to the next Activity. Detaching clears the callback, which is the only thing
that could retain the Activity.

Leaving for good still abandons the flow: onDestroy cancels when isFinishing,
so an exit mid-login can't hold the eventual timeout and replay it as a sign-in
failure on the next launch. The two paired tests pin that condition from both
sides -- dropping the guard leaves a finished exit polling, and making it
unconditional breaks the recreation case the fix exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
…sion

Attaching the WebView cookie to a DownloadManager.Request was not safe.
Android's DownloadProvider follows redirects and re-applies custom request
headers on every new connection, including after the host changes, so a
same-origin /export that redirects to object storage handed __Host-ap_session
to that third party. Redirecting downloads to a CDN is a common deployment, so
the initial-origin check bought nothing.

Download pinned-origin URLs ourselves instead: follow redirects manually with
instanceFollowRedirects off, re-evaluating each hop, and drop the cookie
permanently the moment one leaves the pinned origin -- including when a later
hop returns to it. The body streams to the same MediaStore destination the blob
path uses, so a large artifact is never held in memory. Cross-origin downloads
still go to DownloadManager with no cookie, exactly as before.

The file chooser gate remains main-frame-only: FileChooserParams exposes no
requesting-frame origin, so a cross-origin iframe on a pinned page still passes
where the microphone path would not. Documented at the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Five follow-ups, all cases where a fix stopped short of the boundary it was
meant to cover.

The webViewUnusable branches of onNewIntent and reloadWithNewServer recreated
without running the parts of the switch teardown that don't need a WebView, and
a recreation isn't finishing so onDestroy didn't cancel either. Switching
servers from a dead-renderer state therefore left the old server's login in
flight -- so the new server's start() returned false and sign-in silently
no-opped for up to five minutes -- and left its notifications in the shade.

destroyUnusableWebView left the login attachment in place, so a result arriving
while the renderer was dead counted as delivered and was then dropped by the
guards. Detach instead, so it is held for the Activity the user gets after
Retry.

Consuming the launch intent's extras stopped a handled activation replaying,
but pendingNavigatePath lived only in a field, so a recreation before the page
was ready lost the tap entirely. Persist it across the save/restore cycle.

A held result now survives only if it is a Success: a failure with nobody
attached has no one waiting for it, and holding it meant a five-minute timeout
from an abandoned attempt could surface as a sign-in failure dialog at the next
launch, hours later.

The notification id wrap branch was fully shadowed by the normalization on the
next read, which made its test green with the branch removed. Drop the branch
and pin the rule that actually exists.

Finally, a bridge message dispatched to the UI looper just before a server
switch was stamped with the new pin, because removeWebMessageListener stops
future messages but not one already queued. Reject a message whose sourceOrigin
isn't the current pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Cancelling the previous server's login, withdrawing its notifications and
dropping its pending activation all ran only if a MainActivity instance was
alive to run them. Three separate paths defeat that. With "Don't keep
activities", opening the connect screen destroys MainActivity non-finishing, so
picking a new server strands it behind the old server's flow -- start() returns
false and sign-in silently no-ops. setIntent() assigns Activity.mIntent but
ActivityThread relaunches from ActivityClientRecord.intent, so the dead-renderer
branch dropped the notification tap it had just been handed. And removeExtra
only mutates the client-side Intent, so after process death the system's copy
re-supplies an activation the SPA already handled.

Move the invariants to chokepoints that don't depend on an Activity: attaching
to an origin now abandons a flow belonging to a different one, the last pinned
origin is persisted so a mismatch withdraws stale notifications at startup, and
the pending activation is persisted with its origin and dropped when it doesn't
match the pin. Saved state is authoritative when present, so a handled tap is
not replayed from the relaunch intent.

Storage safety: the pre-Q path wrote straight to the final name, so a failed
stream truncated a previously downloaded file and then reported failure. It now
writes to a temporary file and moves it into place only on success, and saves of
the same name are serialized across instances. Download failures are logged
rather than collapsing into one silent toast, and the API 28 message no longer
claims the file is in Downloads when it is in app-private storage.

Test adequacy, all three verified holes: the cookie predicate ANDs scheme, host
and port but the fixture differed only in port, so deleting the host comparison
left the whole suite green; no test exercised a successful MediaStore save, the
path every real device takes; and safeFileName was pinned by nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Downloading pinned-origin URLs ourselves stopped the session cookie leaking
across redirects, but it moved the transfer onto an in-process executor, so a
large download died whenever Android reclaimed the cached process. DownloadManager
had survived that.

Run the transfer as a WorkManager job instead, so the OS reschedules it after
process death. The redirect and cookie-scoping logic is unchanged -- it is now
the body the worker executes.

The session cookie is deliberately NOT worker input: WorkManager serializes input
Data into its Room database, so passing it would write a credential to disk. The
worker reads the live cookie from CookieManager when it starts, and a test asserts
the persisted WorkSpec row contains no cookie.

Transient failures (IO, timeouts, 5xx) retry with bounded backoff; terminal ones
(4xx, hop cap, missing or non-http Location, a rejected origin) fail without
retrying. A retry re-downloads from the start -- there is no resumption.

Because a worker cannot rely on being foreground, completion is surfaced as a
notification on its own channel, tagged so it can never be confused with an
origin-bound session activation or disturb the persisted session id counter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Review of the WorkManager conversion found five defects, two of them serious.

Nothing in the module honored isStopped. Worker.stop() sets a flag and calls
onStopped(); it does not interrupt doWork(). So a transfer outliving the
execution window kept streaming as a zombie -- still writing, still announcing
completion for work the framework had abandoned -- and because a stop is not a
Result.retry(), runAttemptCount never advanced, so the attempt bound gave no
protection. WorkManager's executor is a small fixed pool, so a few zombies would
starve every job in the process. The transfer now checks isStopped between hops
and inside the copy loop, and runs as a long-running foreground worker so the
ten-minute cap no longer applies.

Reading the cookie when the job starts keeps it off disk, but it also means a
deferred run can find the session expired. The front door then redirects to its
IdP and the loop dutifully saved the login page as report.pdf and announced
success -- the exact bug this branch exists to fix, reappearing in the deferred
case. Origins.isProxyAuthUrl already classifies that bounce, so treat it as
terminal and tell the user to sign in again.

Also: workDataOf throws above 10KB and the exception escaped through the
DownloadListener, so a long signed URL crashed the app; 408 and 429 were
terminal, ignoring exactly the backoff a throttled server asks for; and process
death left a duplicate published row on Q+ and an orphaned temporary on API 28.

The worker's only surface was a notification, so with notifications denied the
outcome was invisible on success and failure alike -- the path it replaced always
toasted. There is a fallback again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
A WorkManager stop reschedules without touching runAttemptCount, so a
transfer that keeps outliving its execution window restarted from byte
zero forever with nothing shown to the user. Persist a per-work stop
counter (incremented in onStopped, cleared on terminal outcomes) and
fail honestly after three stops; a failed foreground promotion is only
tolerated on the work's first life, since a later window is known to be
too short for the file that already outlived one.

Also close the round of smaller gaps around the worker: fall back to
the toast path when just the downloads channel is blocked, reject a
terminal HTML body when the caller asked for a non-HTML file, include
pending rows in the MediaStore sweep and journal probe (they are
filtered from queries by default), prune the journal once a row is
published, and replace the bucketed save monitors with per-name
refcounted locks acquired with an abort-aware timed loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
…ash reruns

A stop that voids an already-published run's result made the rerun
download a duplicate, because the journal entry was pruned at publish
time. Entries now outlive publication — a rerun recognizes its row —
and growth is bounded by a 64-entry sequence-ordered cap instead of
eager pruning; evicted pending rows become sweepable orphans.

Every run start increments runAttemptCount (retries, stops, and crash
recoveries alike — verified against the shipped WorkManager bytecode),
so a MAX_LIVES entry gate now bounds the one restart path the stop
counter cannot see: repeated process deaths mid-transfer. Comments
that claimed stops leave runAttemptCount untouched are corrected.

The outcome fallback's check-then-commit and register-then-drain pairs
now share one lock, so a result delivered while the activity is
starting can no longer be stranded invisibly in preferences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Eviction picked victims purely by age, so one slow transfer plus 64
younger journaled saves evicted the in-flight entry — and the next
save's sweep then deleted the still-pending row under the writer,
failing the download. In-process operations now register themselves
while writing and eviction skips them; crash orphans stay evictable.

Also pin the seq-sibling cleanup in eviction, which no test observed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
…viction shield

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
…nges

The pre-commit wrapper exits 0 when ktlint is absent, so these files
were never actually formatted locally; CI, which installs ktlint,
rejected them. No behavior change — 221 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Session cookie is now HttpOnly (matching the server's own attribute),
permission/file-chooser/download/bridge gates deny when no origin is
pinned instead of comparing null == null, originOf canonicalizes IDN
hosts to punycode, DownloadManager enqueue failures surface as failed
downloads instead of crashing, and worker notifications use the
sanitized filename. Origin switches ignore callbacks from the previous
navigation until the new origin's first page starts.

Co-authored-by: Isaac

Signed-off-by: Bryan Li <bryan.li@gmail.com>
readInput() discarded the result of the suggested-name lookup purely for
its early return, then aliased the sanitized name through a local. State
the requirement as an explicit null check and pass the sanitized name
directly.

Co-authored-by: Isaac

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Android's Uri leaves a non-numeric port inside host instead of failing
the parse, so "https://good.com:notaport" came back as its own origin.
WHATWG treats an invalid port as a parse failure — return null, keeping
bracketed IPv6 literals. First actual run of this suite (Robolectric
was network-blocked when the tests were written) caught it; the other
239 tests pass.

Co-authored-by: Isaac

Signed-off-by: Bryan Li <bryan.li@gmail.com>
java.net.IDN is IDNA2003 and maps hosts like faß.de to fass.de — a
different registrable domain than the xn--fa-hia.de the WebView actually
loads and reports in navigation callbacks, so the pinned origin could
never match its own pages. Canonicalize with android.icu's UTS-46
(non-transitional, CHECK_BIDI/CHECK_CONTEXTJ) instead, and pass bracketed
IPv6 literals through unmapped, with regression coverage pinning that
they already canonicalized correctly.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
The worker cleared the active-flow slot on the io thread before posting
the result to the main thread, so a login trigger queued in that window
could start a second concurrent browser flow and both tokens would
deliver. The slot now stays occupied until the result crosses to the
main thread (where it is vacated just before delivery, so a retry from
the callback can start a fresh flow).

Also remove the tests-only start(...) overload with a flow-local
callback override that bypassed deliverOrHold; the tests now exercise
the production attach/deliver path.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
- Gate HTML responses even when no MIME type was requested (common from
  onDownloadStart): a proxy's 200 HTML login page must not be saved as
  the requested file. Intent falls back to the file-name extension so
  genuine .html downloads still save.
- Bring the CookieManager fetch inside the worker's error handling: a
  WebView-provider-unavailable exception now fails like any transient
  error (notification + stop-count cleanup) instead of crashing past
  both.
- Compare the worker-side origin gate through originOf so the enqueue
  side and worker side canonicalize hosts identically.
- Share one safeFileName and SHA-256-hex helper across DownloadStorage
  and the downloader, and derive the work name and failure id from a
  single downloadIdentity function.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Address round-2 review findings on the proxy-auth/download path:

- Query WebView cookies for the full /auth/* endpoint URL (login manager)
  and per redirect hop (download worker) so Path-scoped proxy session
  cookies match; the off-origin cookie ratchet is unchanged.
- Only let onReceivedHttpError end an in-flight proxy-auth flow for hops
  that never committed — an IdP's 401/403 interactive login page must not
  drop the flow while the user is typing.
- Match the embedded-sign-in rejection on error/error_subtype parameter
  values (including one encoding level deep) instead of a URL-wide
  substring, so state=disallowed_useragent no longer aborts login.
- Delete the pending MediaStore row when a download is cancelled right
  after row creation; cancelled work is never retried.
- Canonicalize IP-literal hosts in originOf (RFC 5952 IPv6 compression,
  WHATWG IPv4 shorthand) so pins like [0:0:0:0:0:0:0:1] or 127.1 match
  the WebView's canonical host, and reject servers at connect time whose
  host originOf cannot canonicalize instead of pinning a null origin.
- DRY: reuse clearCrossServerState in reloadWithNewServer, share the
  downloader TAG, fold canDeliver into canDeliverLocked, and drop a
  redundant null check in the download gate.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Only one trailing empty dot label is ignored (1.2.3.. stays a Chromium
domain instead of collapsing onto the 1.2.0.3 pin), and an all-digit
last label forces the IPv4 parse so hosts like foo.09 are rejected at
connect time the way Chromium rejects them.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
WHATWG IPv4 numbers carry no sign, so +1 stays a domain instead of
pinning as 0.0.0.1; ports beyond 65535 are rejected at canonicalization
so an unloadable server can't be persisted; and an SSL error only ends
a proxy-auth flow when it hit the flow's own tracked main-frame hop —
a subresource cert failure no longer misroutes the next redirect into
native login.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
A port too large for Int overflows Uri.parsePort to -1, silently
dropping it instead of rejecting the URL; and an over-long numeric
label is a valid WHATWG number that must fail the 32-bit address range,
not fall through to the domain path. Both persisted a server Chromium
would refuse to load.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
@btli
btli force-pushed the android-proxy-auth branch from 8069d91 to a3ba067 Compare August 6, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1-high Priority: major feature broken, no workaround size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android shell cannot sign in to servers behind a front-door auth proxy (Databricks Apps): server switch appears dead, login silently fails

2 participants