Whenever I try and connect OpenHands Cloud to my c...
# proj-agent-canvas
w
Whenever I try and connect OpenHands Cloud to my canvas instance, I get this 400 error and makes me add a new local backend
Copy code
{
  "error": "authorization_pending",
  "error_description": "User has not yet completed authorization",
  "interval": null
}
any ideas?
s
The 400 is things working normally. I tried just now with a fresh canvas. Clicked Login with OpenHands Cloud and the network tab immediately started stacking these: POST app.all-hands.dev/oauth/device/token -> 400 POST app.all-hands.dev/oauth/device/token -> 400 POST app.all-hands.dev/oauth/device/token -> 400 {"error":"authorization_pending","error_description":"User has not yet completed authorization","interval":null} interval:null and all. That's RFC 8628: the token endpoint returns 400 + authorization_pending on every poll until someone clicks Authorize. I wasn't signed into Cloud in that browser, so my popup landed here instead of the Authorize screen: app.all-hands.dev/login?returnTo=%2Foauth%2Fdevice%2F…... Signed in with GitHub, came back to the Authorize page with the code intact, clicked it, and canvas connected. So it works end to end from here, login bounce and all. Maybe your auth isn't landing? Anything in popups or console? Same behavior from Private mode or different browser? 🤔
l
Yeah @wooden-umbrella-84098, I’ve never encountered this, do you have repro directions? Happy to take a look!
w
I can repro very consistently, but no idea how I got into this state 😕
ok I actually do see the token come back OK!
but then it drops me to this
@nutritious-balloon-65098 can you investigate the openhands/agent-canvas repo and come up with a hypothesis as to why
n
I'm on it! Robert Brennan can track my progress here.
I traced the complete flow from clicking "Login with OpenHands Cloud" through to where the user ends up on the "add a local backend" screen. Here's my hypothesis. The flow (what the code does) 1. OAuth device flow —
DeviceFlowAuth
→
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)?
s
The question is auth at steps 2->3. Yours is doing as described, adding a backend but I think that's not what you want. Last night, I wasn't able to repro which is why I thought about using Private mode. If the info in local storage has corrupted somehow then it would cause problems that would be hard to impossible for others to repro. If it works fine in Private mode or a different browser then the wonky bit might be saved in the browser's storage. I'll poke at it more later if I can remember. I'm very ADHD. lol
thanks on a stick 1