Learn the cloud proxy split well enough to audit the next one
This lesson teaches the idea behind PR #1561: a cloud request is not just “a cloud request”. It either targets the OpenHands app backend, or it tunnels into a sandbox runtime. The PR makes that distinction visible in code.
1Background: two cloud worlds share one UI
Agent Canvas is a browser UI. In local mode it talks to a nearby agent-server. In cloud mode there are two important servers in play:
The durable control plane: organizations, app-conversation records, settings, profiles, automations, and gateway routes. It is reached at backend.host with cloud auth.
The live execution plane inside a workspace sandbox: terminal output, file bytes, confirmation responses, and endpoints that may exist only while the sandbox is running.
The old helper, callCloudProxy(), served both worlds. With no hostOverride, it called the app backend directly. With hostOverride, it posted an envelope to local /api/cloud-proxy, which then called a sandbox runtime. That branch lives in the old code at origin/main proxy.ts:74-118.
Narrow background: the problem was semantic, not just naming
Both paths were useful, but their shared name made audits hard. A reviewer seeing callCloudProxy(...) could not tell whether the browser was making a normal cloud API call or asking the local agent-server to tunnel into a sandbox. The difference matters for auth, CORS, runtime availability, and future migration work.
2Intuition: label the road, not the destination
Imagine a toy request: “give me events for conversation conv-1.” There are two ways that could work.
GET /api/v1/conversation/conv-1/events/search
The cloud app backend owns the route and returns persisted or gatewayed events.
GET /api/conversations/conv-1/events/...
The browser cannot call the sandbox host directly, so it asks the local proxy bridge to do it.
callCloudApi(). If it is still runtime-only, make the legacy bridge explicit with callLegacyRuntimeCloudProxy().3Code walkthrough: what changed, in learning order
Step A — split the helper contract
The new public surface is in src/api/cloud/proxy.ts:55-93.
callCloudApi(req)
-> axios.request(`${backend.host}${path}`)
-> cloud auth headers + active X-Org-Id
callLegacyRuntimeCloudProxy(req)
-> POST `${localAgentServer}/api/cloud-proxy`
-> envelope includes { host, method, path, headers, body }The app API helper does not accept a runtime host. The runtime helper requires one. That makes misuse harder to hide in reviews.
Step B — migrate app-owned behavior away from runtime hosts
| Behavior | Important line | Why it teaches the change |
|---|---|---|
| Follow-up messages | conversation-service.api.ts:229-238 | The browser calls /api/v1/app-conversations/{id}/send-message. |
| Event count/search | event-service.api.ts:63-122 | History and counts are app API reads. |
| Conversation metrics | agent-server-conversation-service.api.ts:563-584 | Runtime info comes from app-conversation data. |
| Git changes/diff | agent-server-git-service.api.ts:51-105 | Git panels now use app-conversation gateway routes. |
Step C — keep true runtime-only behavior honest
These remain behind callLegacyRuntimeCloudProxy(): confirmation responses event-service.api.ts:34-41, bash output bash-service.api.ts:98-105, command execution and file download agent-server-runtime-service.ts:35-84.
Step D — the main merge joined the same pattern
Cloud profile CRUD uses callCloudApi in profiles-service.api.ts:48-140; running cloud conversation profile switches use /switch_profile through callCloudApi.
4Quiz: check that the model stuck
Choose an answer. Feedback explains the reasoning.
1. What was the main problem with old callCloudProxy()?
2. Which request belongs on callCloudApi()?
3. Why keep callLegacyRuntimeCloudProxy()?
callCloudApi() builds cloud auth headers.4. After merging main, how should cloud profile CRUD route?
5. What should reviewers ask for any new cloud call?
Score: 0 / 5 answered