wooden-umbrella-84098
07/15/2026, 7:34 PM{
"error": "authorization_pending",
"error_description": "User has not yet completed authorization",
"interval": null
}
any ideas?straight-dog-12443
07/16/2026, 12:27 AMlimited-student-10747
07/16/2026, 6:16 AMwooden-umbrella-84098
07/16/2026, 1:30 PMwooden-umbrella-84098
07/16/2026, 1:33 PMwooden-umbrella-84098
07/16/2026, 1:33 PMwooden-umbrella-84098
07/16/2026, 1:34 PMnutritious-balloon-65098
07/16/2026, 1:34 PMnutritious-balloon-65098
07/16/2026, 1:59 PMDeviceFlowAuth → useDeviceFlow → startDeviceFlow / pollForToken (src/api/device-flow-client.ts). The polling 400 authorization_pending responses are normal RFC 8628 behavior — confirmed by your colleague's message. The token eventually comes back OK (you confirmed this too).
2. Token → cloud backend — On success, DeviceFlowAuth calls onSuccess(access_token) (device-flow-auth.tsx:114), which routes to CloudLoginColumn.handleLoginSuccess (backend-form-modal.tsx:957):onConnected({ name: "OpenHands Cloud", host: ..., apiKey, kind: "cloud" }, "cloud_login")The OAuth access_token is stored directly as the backend's apiKey — no exchange, no validation, no /api/keys/current call to mint a real API key first.
3. addBackend (active-backend-context.tsx:89) registers it, sets it active (with orgId: null), and calls retryBootstrapProbe() to re-run the config probe.
4. Root gate (root.tsx:284-328) then runs. Because the active backend is now cloud, loadAgentServerInfo() short-circuits (getEffectiveLocalBackend() is null, isNoBackend() is false → returns null, no error). So config succeeds. Then the cloud-specific gate fires:const activeCloudHealth = useBackendsHealth([active.backend])[active.backend.id];
const activeCloudLoggedOut =
active.backend.kind === "cloud" &&
activeCloudHealth?.isConnected === false &&
isCloudBackendLoggedOutHealthError(activeCloudHealth.lastError);
5. The cloud health probe (use-backends-health.ts) for a cloud backend calls getCurrentCloudApiKey(backend) → callCloudProxy({ backend, method: "GET", path: "/api/keys/current" }). Because there's no hostOverride, callCloudProxy (proxy.ts:96-104) makes a direct browser request to <https://app.all-hands.dev/api/keys/current> with Authorization: Bearer ${access_token}.
6. The failure → recovery screen — If that call returns 401, probeBackend throws CLOUD_BACKEND_LOGGED_OUT_ERROR, activeCloudLoggedOut becomes true, and root.tsx:322-328 renders <MissingAgentServerScreen /> → <ManageBackendsModal recoveryMode />. In non-locked mode that modal surfaces the manual (local) connection column alongside the cloud login — which is what you experience as "makes me add a new local backend."
The hypothesis The OAuth access_token returned by /oauth/device/token is not being accepted by `app.all-hands.dev`'s /api/keys/current — it returns 401 — so the frontend classifies the freshly-connected cloud backend as "logged out" and drops you to the Manage Backends recovery screen.
The root cause is most likely a credential-shape mismatch: the frontend assumes the device-flow access_token is a valid cloud API key and sends it as Authorization: Bearer … to /api/keys/current. But /api/keys/current appears to expect a dedicated API key credential. If the device flow is supposed to return an API key directly, then either:
• the cloud is returning a session/OAuth token instead of an API key (cloud-side bug), or
• there's a missing step where the frontend should exchange the access_token for an API key before storing it as backend.apiKey.
The fact that it's an immediate drop (not a delayed one) is the key tell: a CORS/network failure would need MAX_CONSECUTIVE_FAILURES (5) consecutive probes before disabled: true flips activeCloudUnreachable. A 401 fires activeCloudLoggedOut on the first failed probe — matching "drops me to this" right after the token arrives.
Strong secondary hypothesis: CORS callCloudProxy makes direct cross-origin calls to app.all-hands.dev for app-host endpoints (only runtime-sandbox calls go through the server-side /api/cloud-proxy). The OAuth device endpoints allow browser cross-origin polling by design, but /api/keys/current may not send Access-Control-Allow-Origin for your frontend's origin (localhost / a work-*.prod-runtime.all-hands.dev URL). That would throw a network error → CLOUD_BACKEND_API_KEY_OR_NETWORK_ERROR. This routes to the same recovery screen after the backend disables (5 failures). Less consistent with "immediate," but very plausible if you've been retrying repeatedly — and note disabled health state persists in localStorage (openhands-backend-health) across reloads, though each addBackend generates a fresh UUID so stale entries wouldn't attach to the new backend.
How to confirm which it is In the browser network tab, after the token comes back, look at the GET <https://app.all-hands.dev/api/keys/current> request:
• HTTP 401 response → primary hypothesis (token not accepted as API key). Fix: exchange/validate the token before storing it, or have the device-flow endpoint return a real API key.
• (failed) CORS preflight / no response / "Network Error" → secondary hypothesis (origin not allowed). Fix: route app-host cloud calls through the server-side /api/cloud-proxy like runtime-sandbox calls, instead of direct browser requests.
The quickest code-side tell: callCloudProxy (src/api/cloud/proxy.ts:96) only proxies when hostOverride is set. For app.all-hands.dev itself it goes direct — which is exactly the call that's failing post-OAuth.
I haven't made any code changes — this is investigation only. Want me to dig deeper into either branch (e.g., check what /api/keys/current actually returns for an OAuth token, or prototype routing the app-host call through the proxy)?straight-dog-12443
07/16/2026, 4:37 PM