Skip to content

feat: allow metadata headers for device flow requests#283

Merged
neubig merged 3 commits into
mainfrom
agent/device-flow-request-headers
Jul 17, 2026
Merged

feat: allow metadata headers for device flow requests#283
neubig merged 3 commits into
mainfrom
agent/device-flow-request-headers

Conversation

@neubig

@neubig neubig commented Jul 17, 2026

Copy link
Copy Markdown
Member

What changed

  • Add optional request metadata headers to startDeviceFlow and PollDeviceTokenOptions.
  • Forward the same option through CloudClient.startDeviceFlow.
  • Keep the endpoint-managed Content-Type authoritative.
  • Document the API and cover authorization and token requests with focused tests.

Why

Consumers need to identify their client at Cloud ingress without copying the SDK's OAuth device-flow and RFC 8628 polling implementation. The current helpers hard-code request headers and expose no transport extension point.

This is backward-compatible: existing calls require no changes.

Validation

  • npm run lint (passes with pre-existing warnings)
  • npm run build
  • npm run test:coverage -- --runInBand (290 tests)
  • npm run format:check

@github-actions github-actions Bot added the type: feat A new feature label Jul 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Endpoint audit

❌ 40 off-contract call(s) — not on the agent-server · classifiers: cloud

Category Count
❌ Off-contract (not on agent-server) 40
  ⛔ no known backend 15
  ↗️ served by cloud 25
➕ Missing API (agent-server has, client lacks) 11
agent-server endpoints 121
client endpoints 148

❌ Not on agent-server (gated, 40)

⛔ (no known backend) — served by no backend we can see (15)

  • DELETE /api/automation/v1/{}
  • DELETE /api/meta-profiles/{}
  • GET /api/automation/health
  • GET /api/automation/v1
  • GET /api/automation/v1/{}
  • GET /api/automation/v1/{}/runs
  • GET /api/automation/v1/{}/tarball
  • GET /api/meta-profiles
  • GET /api/meta-profiles/{}
  • GET /api/v1/config/models/search{}
  • GET /api/v1/config/providers/search{}
  • PATCH /api/automation/v1/{}
  • POST /api/automation/v1/{}/dispatch
  • POST /api/meta-profiles/{}
  • POST /api/meta-profiles/{}/activate

↗️ served by cloud (25)

  • DELETE /api/v1/app-conversations/{}
  • DELETE /api/v1/secrets/{}
  • GET /api/keys/current
  • GET /api/organizations
  • GET /api/shared-events/search
  • GET /api/v1/app-conversations/search
  • GET /api/v1/app-conversations/{}/download
  • GET /api/v1/app-conversations/{}/file
  • GET /api/v1/git/branches/search
  • GET /api/v1/git/installations/search
  • GET /api/v1/git/repositories/search
  • GET /api/v1/secrets/search
  • GET /api/v1/settings
  • GET /api/v1/settings/agent-schema
  • GET /api/v1/settings/conversation-schema
  • GET /api/v1/skills/search
  • PATCH /api/v1/app-conversations/{}
  • POST /api/v1/app-conversations
  • POST /api/v1/app-conversations/{}/switch_acp_model
  • POST /api/v1/app-conversations/{}/switch_profile
  • POST /api/v1/sandboxes/{}/pause
  • POST /api/v1/sandboxes/{}/resume
  • POST /api/v1/secrets
  • POST /api/v1/settings
  • PUT /api/v1/secrets/{}

➕ Missing API — agent-server exposes it, client does not implement (11)

  • GET /
  • GET /api/conversations
  • GET /api/conversations/{}/events
  • GET /api/conversations/{}/workspace
  • GET /api/conversations/{}/workspace/{}
  • GET /api/file/archive
  • GET /api/git/commits
  • GET /api/git/commits/{}/changes
  • GET /api/init
  • POST /api/conversations/{}/load_plugin
  • POST /api/init

@neubig
neubig marked this pull request as ready for review July 17, 2026 11:51
@neubig
neubig requested a review from hieptl July 17, 2026 12:23

@hieptl hieptl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! 🙏

Comment thread src/client/device-flow-client.ts Outdated
return fetch(`${normalizeHost(host)}${path}`, {
method: 'POST',
headers: { 'Content-Type': contentType },
headers: { ...headers, 'Content-Type': contentType },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states that it "keeps the endpoint-managed Content-Type authoritative," but the current spread operation doesn't actually guarantee that behavior.

While HTTP header names are case-insensitive, JavaScript object keys are not. As a result, if a caller provides a lowercase content-type, the merged object ends up containing two distinct keys. Unfortunately, fetch doesn't resolve this using a last-write-wins approach. Instead, the Headers constructor appends the values together.

Verified against Node's undici:

const h = { ...{ 'content-type': 'text/plain', 'X-Foo': 'bar' }, 'Content-Type': 'application/json' };
[...new Headers(h)]
// => [['content-type', 'text/plain, application/json'], ['x-foo', 'bar']]

Instead of the SDK's value taking precedence, the request is sent with a malformed Content-Type. The server rejects the request, and the user receives an opaque Unexpected response from server: 415 error that provides no indication the issue originated from the header they supplied.

One possible approach is to construct a Headers instance first, since it normalizes header names, and then use set, which correctly overrides any existing value:

const requestHeaders = new Headers(headers);
requestHeaders.set('Content-Type', contentType);

return fetch(`${normalizeHost(host)}${path}`, {
  method: 'POST',
  headers: requestHeaders,
  body: requestBody,
  signal,
});

One small follow-up is that both new tests would need to be updated, as they currently assert against a plain object literal. With this change, they'd instead need to compare the resulting Headers entries.

I agree this is likely an uncommon code path—a caller passing content-type through a metadata headers API isn't something most users would do. That said, I think it's still worth addressing before merging because Content-Type is the only header the SDK itself manages, the PR explicitly states that it remains authoritative, and this behavior isn't currently covered by tests, which likely explains why it wasn't caught.

Thank you very much! 🙏

@neubig
neubig merged commit 21e18d1 into main Jul 17, 2026
11 checks passed
@openhands-release-bot openhands-release-bot Bot added the released: v1.34.0 Shipped in v1.34.0 label Jul 17, 2026
@openhands-release-bot

Copy link
Copy Markdown
Contributor

🚀 Released in v1.34.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released: v1.34.0 Shipped in v1.34.0 type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants