From bd398edd12f9f1bc04c17b122450453424970485 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 18 Oct 2026 03:31:54 -0700 Subject: [PATCH] fix(devices): Android fold controls work over T3 Connect The device hub proxy no longer forwards x-forwarded-proto, so the hub stops rejecting every mutation from an HTTPS client with 403. A fold change that fails with an expired hub ticket renews the ticket and sends once more. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceHubProxy.test.ts | 23 ++++++++++++++++++- apps/server/src/device/DeviceHubProxy.ts | 3 +++ .../device/DeviceAndroidFoldControls.tsx | 19 ++++++++++++--- .../components/device/DeviceStreamView.tsx | 3 ++- apps/web/src/components/device/deviceFold.ts | 12 +++++++++- apps/web/src/state/device.ts | 18 ++++++++++++++- 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index b46bbbafc327..bda888b1ea77 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -25,9 +25,11 @@ const fixture = ( ) => { let finalized = 0; const requests: string[] = []; + const headers: Array>> = []; const client = HttpClient.make((request, _url, signal) => Effect.gen(function* () { requests.push(request.url); + headers.push(request.headers); signal.addEventListener("abort", () => { finalized++; }); @@ -61,7 +63,7 @@ const fixture = ( { disableLogger: true }, ); disposers.push(dispose); - return { handler, requests, finalized: () => finalized }; + return { handler, requests, headers, finalized: () => finalized }; }; describe("device hub proxy", () => { @@ -131,6 +133,25 @@ describe("device hub proxy", () => { ]); }); + it("presents fold changes from an HTTPS client as same-origin hub requests", async () => { + const { handler, headers } = fixture([AuthOrchestrationOperateScope]); + const response = await handler( + new Request( + "https://relay.test/api/device-hub/vendor/serve-emu/api/fold?device=emulator-5554", + { + method: "POST", + headers: { origin: "https://relay.test", "x-forwarded-proto": "https" }, + body: '{"posture":"opened"}', + }, + ), + ); + expect(response.status).toBe(200); + await response.text(); + expect(headers).toHaveLength(1); + expect(headers[0]?.origin).toBe("http://hub.test"); + expect(headers[0]).not.toHaveProperty("x-forwarded-proto"); + }); + it("never forwards the vendor shell endpoint", async () => { const { handler, requests } = fixture([AuthOrchestrationOperateScope]); expect( diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index a00c49a424b8..3d4dc0eaafa6 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -57,6 +57,9 @@ const DROPPED_REQUEST_HEADERS = new Set([ "dpop", "content-length", "accept-encoding", + // Describes the client's HTTPS hop to T3. The hub builds its own origin from it and then + // rejects the loopback Origin set below, so every mutation fails over T3 Connect. + "x-forwarded-proto", ]); const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) => diff --git a/apps/web/src/components/device/DeviceAndroidFoldControls.tsx b/apps/web/src/components/device/DeviceAndroidFoldControls.tsx index 7b25063e7732..709c1985292e 100644 --- a/apps/web/src/components/device/DeviceAndroidFoldControls.tsx +++ b/apps/web/src/components/device/DeviceAndroidFoldControls.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { + AndroidFoldError, readAndroidFold, setAndroidFold, type AndroidFoldPosture, @@ -18,6 +19,7 @@ export function DeviceAndroidFoldControls(props: { readonly screenWidth: number | undefined; readonly screenHeight: number | undefined; readonly onFoldAngle: (angle: number | null) => void; + readonly renewAccess: () => Promise; }) { const [fold, setFold] = useState(null); const [pending, setPending] = useState(false); @@ -47,8 +49,10 @@ export function DeviceAndroidFoldControls(props: { ); } }) - .catch(() => { - if (!controller.signal.aborted) retry = window.setTimeout(read, 3_000); + .catch((cause: unknown) => { + // Retrying an expired ticket cannot succeed. New access re-runs this effect. + const expired = cause instanceof AndroidFoldError && cause.status === 401; + if (!controller.signal.aborted && !expired) retry = window.setTimeout(read, 3_000); }); }; read(); @@ -68,7 +72,16 @@ export function DeviceAndroidFoldControls(props: { props.onFoldAngle(posture === "closed" ? 0 : 180); const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 12_000); - void setAndroidFold(props.access, props.deviceId, posture, controller.signal) + const send = (access: DeviceHubAccess) => + setAndroidFold(access, props.deviceId, posture, controller.signal); + void send(props.access) + .catch(async (cause: unknown) => { + // Hub tickets expire after a few minutes, while the panel stays open much longer. + if (!(cause instanceof AndroidFoldError && cause.status === 401)) throw cause; + const renewed = await props.renewAccess(); + if (!renewed || controller.signal.aborted) throw cause; + return send(renewed); + }) .then((next) => { if (mounted.current) { setFold(next); diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index 25d0a313584f..5b27c1d69a70 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; -import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device"; +import { refreshDeviceHubAccess, renewDeviceHubAccess, useDeviceHubAccess } from "~/state/device"; import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame"; import { resolveDeviceShape } from "@t3tools/client-runtime/device/shape-profile"; import { deviceKeyboard, deviceModel } from "./deviceModels"; @@ -343,6 +343,7 @@ export function DeviceStreamView(props: { screenWidth={screen?.width} screenHeight={screen?.height} onFoldAngle={setFoldAngle} + renewAccess={() => renewDeviceHubAccess(props.environmentId, props.hostId)} /> ) : showPhone && isDuo && screen?.supportsHingeAngle ? ( => typeof value === "object" && value !== null && !Array.isArray(value); @@ -54,8 +63,9 @@ const foldRequest = async ( if (!response.ok) { const payload: unknown = await response.json().catch(() => null); const error = isRecord(payload) ? payload.error : null; - throw new Error( + throw new AndroidFoldError( typeof error === "string" ? error : `Fold command failed (${response.status}).`, + response.status, ); } return parseFold(await response.json()); diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index 31e4f7336b17..2afcff9066aa 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -7,7 +7,7 @@ import { import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -69,3 +69,19 @@ const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial( export function refreshDeviceHubAccess(environmentId: EnvironmentId): void { appAtomRegistry.refresh(deviceHubAccessAtom(environmentId)); } + +/** Mints a fresh hub ticket for a one-off request that failed with 401. Resolves null on failure. */ +export function renewDeviceHubAccess( + environmentId: EnvironmentId, + hostId = "local", +): Promise { + const atom = deviceHubAccessAtom(environmentId); + appAtomRegistry.refresh(atom); + return Effect.runPromise( + AtomRegistry.getResult(appAtomRegistry, atom, { suspendOnWaiting: true }).pipe( + Effect.map((access) => ({ ...access, query: { ...access.query, hostId } })), + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => null), + ), + ); +}