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/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'), ], { diff --git a/src/__tests__/device-flow-client.test.ts b/src/__tests__/device-flow-client.test.ts new file mode 100644 index 0000000..88d9a09 --- /dev/null +++ b/src/__tests__/device-flow-client.test.ts @@ -0,0 +1,74 @@ +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' }, + }); +} + +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; + 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: { + 'content-type': 'text/plain', + 'X-OpenHands-Client': 'agent_canvas', + }, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://cloud.example.com/oauth/device/authorize', + 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 () => { + 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: { + 'CONTENT-TYPE': 'text/plain', + 'X-OpenHands-Client-Version': '1.4.0', + }, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://cloud.example.com/oauth/device/token', + 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/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..16880ea 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' || @@ -73,22 +79,29 @@ 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: { 'Content-Type': contentType }, + headers: requestHeaders, 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 +159,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';