Repository navigation
agents: canvas <session> serves the session's Agent Canvas UI through a local token-adding proxy - #2082
Merged
Conversation
…gh a local token-adding proxy
The openhands template serves Agent Canvas on the session's published port,
reachable at the session's public URL. That URL accepts one credential, the
team's token as `Authorization: Bearer`, and nothing a browser can send on
its own (no cookie, no query string: verified on preview, all 401). So
opening Canvas meant running a hand-written node proxy from the template
repo. This moves that proxy into doctl, next to port-forward:
doctl agents canvas my-session --open
- do: GetSessionIngress (GET /v2/agents/sessions/{id}/ingress), decoded
locally since godo v1.233 has no type for it; mock regenerated.
- commands: `canvas <session>` resolves the session, resumes it if paused
(the shared half of ensureSessionAwakeForPortForward, now
ensureSessionAwake with a verb), reads the ingress URL, and serves a
net/http ReverseProxy on 127.0.0.1:<port> (default: a free port, printed)
that rewrites Host, adds the token and device id on the outbound leg only,
and splices WebSocket upgrades. --address, --port, --open. Where sessions
have no public URL yet (harness-api 501/404, prod today) the error spells
out `port-forward <session> 8000:8000` and the local URL, which reaches
the same Canvas.
Tests: proxy adds the token and device id, keeps path and query, rewrites
Host, drops hop-by-hop headers; a WebSocket upgrade round-trips through it
with the token; ingress 501/404 maps to the port-forward hint; target
resolution on ready / not-ready / not-enabled with the mocked service; the
terminal-status error carries the verb. Live on preview: served a fresh
session's Canvas (HTML, settings, event WebSocket) and resumed a paused one
first. `TestRegistryLogout` fails on main as well on this machine.
…py, keep upstream errors in the terminal Review follow-ups: - The proxy adds the team token to everything it forwards, so a page on another site must not be able to drive it. Requests now pass agentproxy.AllowedProxyOrigin (exported; the rule start-proxy already applies: no Origin or a loopback Origin passes, anything else is 403) and must carry a loopback Host, so DNS rebinding cannot reach the proxy with a name that resolves to 127.0.0.1. Tested: foreign Origin 403, foreign Host 403, no Origin / loopback Origin / localhost Host served. - 501 and 404 told the same story. 501 is "this environment does not publish session URLs yet"; 404 is "this session has none" (not openhands, or not provisioned for ingress). Both still give the port-forward route, with the openhands port and path named as such. - GetSessionIngress path-escapes the session id like the other raw routes. - The ReverseProxy ErrorHandler logs the dial/TLS error to the terminal and answers the page with a plain 502. - http.Server.BaseContext is the signal context, so in-flight requests end on Ctrl-C; short help says "Serve", since --open is off by default; dead gomock import dropped.
… at canvas; assert Authorization is replaced - --address must be 127.0.0.1, localhost or ::1, or the command exits with an error instead of a warning. The proxy puts the team's token on every request it forwards, and a client that can reach a wider bind can forge a loopback Host, so the Host check alone would not hold; refusing the bind closes that class for good. - The start-proxy help, where the "use the agent's native UI" story lives, gets one line sending openhands users to `agents canvas`. - The proxy test sends its own Authorization header and asserts the outbound request carries exactly the token, not both.
Spherrrical
approved these changes
Oct 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
openhands template serves Agent Canvas on the session's published port, reachable at the session's public URL. That URL accepts one credential, the team's token as
Authorization: Bearer, and nothing a browser can send on its own (no cookie, no query string; verified on preview, all 401). Opening Canvas therefore meant running a hand-written node proxy from the template repo. This moves that proxy into doctl, next toport-forward:do:GetSessionIngress(GET/v2/agents/sessions/{id}/ingress), decoded locally since godo v1.233 has no type for it; mock regenerated.commands:canvas <session>resolves the session, resumes it if paused (the shared half ofensureSessionAwakeForPortForward, nowensureSessionAwakewith a verb), reads the ingress URL, and serves anet/httpReverseProxy on127.0.0.1:<port>(default a free port, printed) that rewrites Host, adds the token and device id on the outbound leg only, and splices WebSocket upgrades. Flags:--address,--port,--open. Where sessions have no public URL yet (harness-api 501/404, prod today) the error spells outport-forward <session> 8000:8000and the local URL, which reaches the same Canvas.Follow-ups after review: requests must carry a loopback Origin (or none) and a loopback Host, reusing start-proxy’s AllowedProxyOrigin (33b5fad); --address is loopback-only and refused otherwise (5329e76). 404 and 501 copy split, session id path-escaped, upstream errors logged to the terminal with a plain 502 to the page, BaseContext set to the signal context.
Tests: proxy adds the token and device id, keeps path and query, rewrites Host, drops hop-by-hop headers; a WebSocket upgrade round-trips through it with the token; ingress 501/404 maps to the port-forward hint; target resolution on ready / not-ready / not-enabled with the mocked service; the terminal-status error carries the verb.
Live on preview: served a fresh session's Canvas (HTML, settings, event WebSocket) and resumed a paused session first.