From 5bdbacdabf0eafc82b16dfb3b2d9e6703628bcdf Mon Sep 17 00:00:00 2001 From: neubig Date: Fri, 17 Jul 2026 08:34:46 +0000 Subject: [PATCH 1/3] feat: allow device-flow request headers --- README.md | 6 ++- src/__tests__/device-flow-client.test.ts | 67 ++++++++++++++++++++++++ src/client/cloud-client.ts | 5 +- src/client/device-flow-client.ts | 24 ++++++--- src/clients.ts | 1 + 5 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/device-flow-client.test.ts diff --git a/README.md b/README.md index bc0bbb8..60f2066 100644 --- a/README.md +++ b/README.md @@ -202,10 +202,14 @@ To obtain a Cloud API key interactively, use the device-flow helpers: ```typescript import { startDeviceFlow, pollForToken } from '@openhands/typescript-client/clients'; -const auth = await startDeviceFlow('https://app.all-hands.dev'); +const requestMetadata = { + headers: { 'X-My-Client': 'my-client-name' }, +}; +const auth = await startDeviceFlow('https://app.all-hands.dev', requestMetadata); console.log(`Approve this device at ${auth.verification_uri_complete}`); const token = await pollForToken('https://app.all-hands.dev', auth.device_code, { interval: auth.interval, + ...requestMetadata, }); const cloud = new CloudClient({ host: 'https://app.all-hands.dev', diff --git a/src/__tests__/device-flow-client.test.ts b/src/__tests__/device-flow-client.test.ts new file mode 100644 index 0000000..84d5b5c --- /dev/null +++ b/src/__tests__/device-flow-client.test.ts @@ -0,0 +1,67 @@ +import { CloudClient, pollForToken } from '../clients'; + +const originalFetch = global.fetch; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('device flow request metadata', () => { + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('CloudClient forwards additional headers when starting authorization', async () => { + global.fetch = jest.fn().mockResolvedValue( + jsonResponse({ + device_code: 'device-code', + user_code: 'user-code', + verification_uri: 'https://cloud.example.com/device', + expires_in: 600, + interval: 5, + }) + ) as typeof fetch; + const client = new CloudClient({ host: 'https://cloud.example.com' }); + + await client.startDeviceFlow({ + headers: { 'X-OpenHands-Client': 'agent_canvas' }, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://cloud.example.com/oauth/device/authorize', + expect.objectContaining({ + headers: { + 'Content-Type': 'application/json', + 'X-OpenHands-Client': 'agent_canvas', + }, + }) + ); + }); + + it('forwards additional headers while polling for a token', async () => { + global.fetch = jest + .fn() + .mockResolvedValue( + jsonResponse({ access_token: 'token', token_type: 'Bearer' }) + ) as typeof fetch; + + await pollForToken('https://cloud.example.com', 'device-code', { + interval: 1, + headers: { 'X-OpenHands-Client-Version': '1.4.0' }, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://cloud.example.com/oauth/device/token', + expect.objectContaining({ + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-OpenHands-Client-Version': '1.4.0', + }, + }) + ); + }); +}); diff --git a/src/client/cloud-client.ts b/src/client/cloud-client.ts index eb80b44..9c3ced0 100644 --- a/src/client/cloud-client.ts +++ b/src/client/cloud-client.ts @@ -8,6 +8,7 @@ import { pollForToken, startDeviceFlow, type DeviceAuthorizationResponse, + type DeviceFlowRequestOptions, type DeviceTokenResponse, type PollDeviceTokenOptions, } from './device-flow-client'; @@ -265,8 +266,8 @@ export class CloudClient extends OpenHandsClient { : this.requestDirect(options); } - startDeviceFlow(): Promise { - return startDeviceFlow(this.host); + startDeviceFlow(options: DeviceFlowRequestOptions = {}): Promise { + return startDeviceFlow(this.host, options); } pollForToken(deviceCode: string, options: PollDeviceTokenOptions): Promise { diff --git a/src/client/device-flow-client.ts b/src/client/device-flow-client.ts index ee9189a..833d1e3 100644 --- a/src/client/device-flow-client.ts +++ b/src/client/device-flow-client.ts @@ -24,7 +24,12 @@ export interface DeviceTokenResponse { expires_in?: number; } -export interface PollDeviceTokenOptions { +export interface DeviceFlowRequestOptions { + /** Additional metadata headers sent with the device-flow request. */ + headers?: Readonly>; +} + +export interface PollDeviceTokenOptions extends DeviceFlowRequestOptions { interval: number; timeout?: number; signal?: AbortSignal; @@ -64,7 +69,8 @@ async function requestCloudDeviceEndpoint( path: string, body: unknown, contentType: string, - signal?: AbortSignal + signal?: AbortSignal, + headers?: Readonly> ): Promise { const requestBody = typeof body === 'string' || @@ -76,19 +82,24 @@ async function requestCloudDeviceEndpoint( return fetch(`${normalizeHost(host)}${path}`, { method: 'POST', - headers: { 'Content-Type': contentType }, + headers: { ...headers, 'Content-Type': contentType }, body: requestBody, signal, }); } -export async function startDeviceFlow(host: string): Promise { +export async function startDeviceFlow( + host: string, + options: DeviceFlowRequestOptions = {} +): Promise { try { const response = await requestCloudDeviceEndpoint( host, '/oauth/device/authorize', {}, - 'application/json' + 'application/json', + undefined, + options.headers ); if (!response.ok) { @@ -146,7 +157,8 @@ export async function pollForToken( '/oauth/device/token', body, 'application/x-www-form-urlencoded', - options.signal + options.signal, + options.headers ); if (response.ok) { diff --git a/src/clients.ts b/src/clients.ts index ba9f221..17e905d 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -118,6 +118,7 @@ export type { } from './client/cloud-client'; export type { DeviceAuthorizationResponse, + DeviceFlowRequestOptions, DeviceTokenResponse, PollDeviceTokenOptions, } from './client/device-flow-client'; From 6cb7dbafa51ff3d57c6c95a7d5862556f8249ae5 Mon Sep 17 00:00:00 2001 From: neubig Date: Fri, 17 Jul 2026 11:01:16 +0000 Subject: [PATCH 2/3] fix: expose device-flow client subpath --- package.json | 5 +++++ scripts/smoke-test-git-dependency.mjs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index dde3076..a66a8bb 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "import": "./dist/client/http-client.js", "default": "./dist/client/http-client.js" }, + "./client/device-flow-client": { + "types": "./dist/client/device-flow-client.d.ts", + "import": "./dist/client/device-flow-client.js", + "default": "./dist/client/device-flow-client.js" + }, "./events/remote-events-list": { "types": "./dist/events/remote-events-list.d.ts", "import": "./dist/events/remote-events-list.js", diff --git a/scripts/smoke-test-git-dependency.mjs b/scripts/smoke-test-git-dependency.mjs index df2a199..9631bfd 100644 --- a/scripts/smoke-test-git-dependency.mjs +++ b/scripts/smoke-test-git-dependency.mjs @@ -38,9 +38,10 @@ try { [ "import { ServerClient } from '@openhands/typescript-client/clients';", "import { HttpClient } from '@openhands/typescript-client/client/http-client';", + "import { isOpenHandsCloudHost } from '@openhands/typescript-client/client/device-flow-client';", "import { RemoteEventsList } from '@openhands/typescript-client/events/remote-events-list';", "import { RemoteWorkspace } from '@openhands/typescript-client/workspace/remote-workspace';", - 'console.log(typeof ServerClient, typeof HttpClient, typeof RemoteEventsList, typeof RemoteWorkspace);', + 'console.log(typeof ServerClient, typeof HttpClient, typeof isOpenHandsCloudHost, typeof RemoteEventsList, typeof RemoteWorkspace);', ].join('\n'), ], { From be749d078ab67d2cbc5d87a25d96ae0b32d9ce73 Mon Sep 17 00:00:00 2001 From: neubig Date: Fri, 17 Jul 2026 22:10:15 +0000 Subject: [PATCH 3/3] fix: keep device flow content type authoritative --- src/__tests__/device-flow-client.test.ts | 35 ++++++++++++++---------- src/client/device-flow-client.ts | 4 ++- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/__tests__/device-flow-client.test.ts b/src/__tests__/device-flow-client.test.ts index 84d5b5c..88d9a09 100644 --- a/src/__tests__/device-flow-client.test.ts +++ b/src/__tests__/device-flow-client.test.ts @@ -9,6 +9,11 @@ function jsonResponse(body: unknown): Response { }); } +function requestHeadersForCall(callIndex = 0): Headers { + const mockFetch = global.fetch as jest.MockedFunction; + return new Headers(mockFetch.mock.calls[callIndex]?.[1]?.headers); +} + describe('device flow request metadata', () => { afterEach(() => { global.fetch = originalFetch; @@ -28,18 +33,19 @@ describe('device flow request metadata', () => { const client = new CloudClient({ host: 'https://cloud.example.com' }); await client.startDeviceFlow({ - headers: { 'X-OpenHands-Client': 'agent_canvas' }, + headers: { + 'content-type': 'text/plain', + 'X-OpenHands-Client': 'agent_canvas', + }, }); expect(global.fetch).toHaveBeenCalledWith( 'https://cloud.example.com/oauth/device/authorize', - expect.objectContaining({ - headers: { - 'Content-Type': 'application/json', - 'X-OpenHands-Client': 'agent_canvas', - }, - }) + expect.objectContaining({ method: 'POST' }) ); + const headers = requestHeadersForCall(); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-OpenHands-Client')).toBe('agent_canvas'); }); it('forwards additional headers while polling for a token', async () => { @@ -51,17 +57,18 @@ describe('device flow request metadata', () => { await pollForToken('https://cloud.example.com', 'device-code', { interval: 1, - headers: { 'X-OpenHands-Client-Version': '1.4.0' }, + headers: { + 'CONTENT-TYPE': 'text/plain', + 'X-OpenHands-Client-Version': '1.4.0', + }, }); expect(global.fetch).toHaveBeenCalledWith( 'https://cloud.example.com/oauth/device/token', - expect.objectContaining({ - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-OpenHands-Client-Version': '1.4.0', - }, - }) + expect.objectContaining({ method: 'POST' }) ); + const headers = requestHeadersForCall(); + expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + expect(headers.get('X-OpenHands-Client-Version')).toBe('1.4.0'); }); }); diff --git a/src/client/device-flow-client.ts b/src/client/device-flow-client.ts index 833d1e3..16880ea 100644 --- a/src/client/device-flow-client.ts +++ b/src/client/device-flow-client.ts @@ -79,10 +79,12 @@ async function requestCloudDeviceEndpoint( body instanceof URLSearchParams ? body : JSON.stringify(body); + const requestHeaders = new Headers(headers); + requestHeaders.set('Content-Type', contentType); return fetch(`${normalizeHost(host)}${path}`, { method: 'POST', - headers: { ...headers, 'Content-Type': contentType }, + headers: requestHeaders, body: requestBody, signal, });