Skip to content

agents: canvas <session> serves the session's Agent Canvas UI through a local token-adding proxy - #2082

Merged
Spherrrical merged 4 commits into
digitalocean:mainfrom
sk09-dev:feat/agents-canvas
Oct 27, 2026
Merged

Spherrrical merged 4 commits into
digitalocean:mainfrom
sk09-dev:feat/agents-canvas

Conversation

@sk09-dev

@sk09-dev sk09-dev commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

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 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. Flags: --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.
    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.

sk09-dev and others added 4 commits October 27, 2026 10:56
…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
Spherrrical merged commit 95b204d into digitalocean:main Oct 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

⌂ 换网址